Repository: ankitects/anki Branch: main Commit: 2d44d4d6bc48 Files: 1595 Total size: 6.2 MB Directory structure: gitextract_gx96pb7w/ ├── .buildkite/ │ ├── linux/ │ │ ├── docker/ │ │ │ ├── Dockerfile │ │ │ ├── build.sh │ │ │ ├── buildkite.cfg │ │ │ ├── common.inc │ │ │ ├── environment │ │ │ └── run.sh │ │ ├── entrypoint │ │ └── release-entrypoint │ ├── mac/ │ │ └── entrypoint │ └── windows/ │ └── entrypoint.bat ├── .cargo/ │ └── config.toml ├── .config/ │ └── nextest.toml ├── .cursor/ │ └── rules/ │ ├── building.md │ └── i18n.md ├── .deny.toml ├── .dockerignore ├── .dprint.json ├── .eslintrc.cjs ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.md │ │ └── config.yml │ ├── actions/ │ │ └── setup-anki/ │ │ └── action.yml │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .gitmodules ├── .idea.dist/ │ └── repo.iml ├── .mypy.ini ├── .prettierrc ├── .python-version ├── .ruff.toml ├── .rustfmt-empty.toml ├── .rustfmt.toml ├── .version ├── .vscode.dist/ │ ├── extensions.json │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── .yarnrc.yml ├── CLAUDE.md ├── CONTRIBUTORS ├── Cargo.toml ├── LICENSE ├── README.md ├── SECURITY.md ├── build/ │ ├── configure/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── aqt.rs │ │ ├── launcher.rs │ │ ├── main.rs │ │ ├── platform.rs │ │ ├── pylib.rs │ │ ├── python.rs │ │ ├── rust.rs │ │ └── web.rs │ ├── ninja_gen/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── action.rs │ │ ├── archives.rs │ │ ├── bin/ │ │ │ ├── update_node.rs │ │ │ ├── update_protoc.rs │ │ │ └── update_uv.rs │ │ ├── build.rs │ │ ├── cargo.rs │ │ ├── command.rs │ │ ├── configure.rs │ │ ├── copy.rs │ │ ├── git.rs │ │ ├── hash.rs │ │ ├── input.rs │ │ ├── lib.rs │ │ ├── node.rs │ │ ├── protobuf.rs │ │ ├── python.rs │ │ ├── render.rs │ │ ├── rsync.rs │ │ └── sass.rs │ └── runner/ │ ├── Cargo.toml │ ├── build.rs │ └── src/ │ ├── archive.rs │ ├── build.rs │ ├── main.rs │ ├── paths.rs │ ├── pyenv.rs │ ├── rsync.rs │ ├── run.rs │ └── yarn.rs ├── cargo/ │ ├── README.md │ ├── format/ │ │ └── rust-toolchain.toml │ └── licenses.json ├── check ├── docs/ │ ├── architecture.md │ ├── build.md │ ├── contributing.md │ ├── development.md │ ├── docker/ │ │ ├── Dockerfile │ │ └── README.md │ ├── editing.md │ ├── language_bridge.md │ ├── linux.md │ ├── mac.md │ ├── ninja.md │ ├── protobuf.md │ ├── syncserver/ │ │ ├── Dockerfile │ │ ├── Dockerfile.distroless │ │ ├── README.md │ │ └── entrypoint.sh │ └── windows.md ├── ftl/ │ ├── .gitignore │ ├── Cargo.toml │ ├── README.md │ ├── copy-core-string.sh │ ├── core/ │ │ ├── actions.ftl │ │ ├── adding.ftl │ │ ├── browsing.ftl │ │ ├── card-stats.ftl │ │ ├── card-template-rendering.ftl │ │ ├── card-templates.ftl │ │ ├── change-notetype.ftl │ │ ├── custom-study.ftl │ │ ├── database-check.ftl │ │ ├── deck-config.ftl │ │ ├── decks.ftl │ │ ├── editing.ftl │ │ ├── empty-cards.ftl │ │ ├── errors.ftl │ │ ├── exporting.ftl │ │ ├── fields.ftl │ │ ├── findreplace.ftl │ │ ├── help.ftl │ │ ├── importing.ftl │ │ ├── keyboard.ftl │ │ ├── launcher.ftl │ │ ├── media-check.ftl │ │ ├── media.ftl │ │ ├── network.ftl │ │ ├── notetypes.ftl │ │ ├── preferences.ftl │ │ ├── profiles.ftl │ │ ├── scheduling.ftl │ │ ├── search.ftl │ │ ├── statistics.ftl │ │ ├── studying.ftl │ │ ├── sync.ftl │ │ └── undo.ftl │ ├── ftl │ ├── move-from-ankimobile │ ├── qt/ │ │ ├── about.ftl │ │ ├── addons.ftl │ │ ├── errors.ftl │ │ ├── preferences.ftl │ │ ├── profiles.ftl │ │ ├── qt-accel.ftl │ │ └── qt-misc.ftl │ ├── remove-unused.sh │ ├── src/ │ │ ├── garbage_collection.rs │ │ ├── main.rs │ │ ├── serialize.rs │ │ ├── string/ │ │ │ ├── copy.rs │ │ │ ├── mod.rs │ │ │ └── transform.rs │ │ └── sync.rs │ ├── update-ankidroid-usage.sh │ ├── update-ankimobile-usage.sh │ └── usage/ │ └── no-deprecate.json ├── justfile ├── ninja ├── package.json ├── pkgkey.asc ├── proto/ │ ├── .clang-format │ ├── .top_level │ ├── README.md │ └── anki/ │ ├── ankidroid.proto │ ├── ankihub.proto │ ├── ankiweb.proto │ ├── backend.proto │ ├── card_rendering.proto │ ├── cards.proto │ ├── collection.proto │ ├── config.proto │ ├── deck_config.proto │ ├── decks.proto │ ├── frontend.proto │ ├── generic.proto │ ├── i18n.proto │ ├── image_occlusion.proto │ ├── import_export.proto │ ├── links.proto │ ├── media.proto │ ├── notes.proto │ ├── notetypes.proto │ ├── scheduler.proto │ ├── search.proto │ ├── stats.proto │ ├── sync.proto │ └── tags.proto ├── pylib/ │ ├── .gitignore │ ├── README.md │ ├── anki/ │ │ ├── _backend.py │ │ ├── _legacy.py │ │ ├── _rsbridge.pyi │ │ ├── _vendor/ │ │ │ └── stringcase.py │ │ ├── browser.py │ │ ├── cards.py │ │ ├── collection.py │ │ ├── config.py │ │ ├── consts.py │ │ ├── db.py │ │ ├── dbproxy.py │ │ ├── decks.py │ │ ├── errors.py │ │ ├── exporting.py │ │ ├── find.py │ │ ├── foreign_data/ │ │ │ ├── __init__.py │ │ │ └── mnemosyne.py │ │ ├── hooks.py │ │ ├── httpclient.py │ │ ├── importing/ │ │ │ ├── __init__.py │ │ │ ├── anki2.py │ │ │ ├── apkg.py │ │ │ ├── base.py │ │ │ ├── csvfile.py │ │ │ ├── mnemo.py │ │ │ └── noteimp.py │ │ ├── lang.py │ │ ├── latex.py │ │ ├── media.py │ │ ├── models.py │ │ ├── notes.py │ │ ├── py.typed │ │ ├── rsbackend.py │ │ ├── scheduler/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── dummy.py │ │ │ ├── legacy.py │ │ │ └── v3.py │ │ ├── sound.py │ │ ├── stats.py │ │ ├── statsbg.py │ │ ├── stdmodels.py │ │ ├── storage.py │ │ ├── sync.py │ │ ├── syncserver.py │ │ ├── tags.py │ │ ├── template.py │ │ ├── types.py │ │ └── utils.py │ ├── hatch_build.py │ ├── pyproject.toml │ ├── rsbridge/ │ │ ├── .gitignore │ │ ├── Cargo.toml │ │ ├── build.rs │ │ └── lib.rs │ ├── tests/ │ │ ├── __init__.py │ │ ├── shared.py │ │ ├── support/ │ │ │ ├── anki12-broken.anki │ │ │ ├── anki12-due.anki │ │ │ ├── anki12.anki │ │ │ ├── anki2-alpha.anki2 │ │ │ ├── diffmodels1.anki │ │ │ ├── diffmodels2-1.apkg │ │ │ ├── diffmodels2-2.apkg │ │ │ ├── diffmodels2.anki │ │ │ ├── diffmodeltemplates-1.apkg │ │ │ ├── diffmodeltemplates-2.apkg │ │ │ ├── invalid-ords.anki │ │ │ ├── media.apkg │ │ │ ├── supermemo1.xml │ │ │ ├── suspended12.anki │ │ │ ├── text-2fields.txt │ │ │ ├── text-tags.txt │ │ │ ├── text-update.txt │ │ │ ├── update1.apkg │ │ │ └── update2.apkg │ │ ├── test_cards.py │ │ ├── test_collection.py │ │ ├── test_decks.py │ │ ├── test_exporting.py │ │ ├── test_find.py │ │ ├── test_flags.py │ │ ├── test_importing.py │ │ ├── test_latex.py │ │ ├── test_media.py │ │ ├── test_models.py │ │ ├── test_schedv3.py │ │ ├── test_stats.py │ │ ├── test_template.py │ │ └── test_utils.py │ └── tools/ │ ├── genbuildinfo.py │ ├── genhooks.py │ └── hookslib.py ├── pyproject.toml ├── python/ │ ├── mkempty.py │ ├── sphinx/ │ │ ├── build.py │ │ ├── conf.py │ │ └── index.rst │ └── version.py ├── qt/ │ ├── README.md │ ├── aqt/ │ │ ├── __init__.py │ │ ├── _macos_helper.py │ │ ├── about.py │ │ ├── addcards.py │ │ ├── addons.py │ │ ├── ankihub.py │ │ ├── browser/ │ │ │ ├── __init__.py │ │ │ ├── browser.py │ │ │ ├── card_info.py │ │ │ ├── find_and_replace.py │ │ │ ├── find_duplicates.py │ │ │ ├── layout.py │ │ │ ├── previewer.py │ │ │ ├── sidebar/ │ │ │ │ ├── __init__.py │ │ │ │ ├── item.py │ │ │ │ ├── model.py │ │ │ │ ├── searchbar.py │ │ │ │ ├── toolbar.py │ │ │ │ └── tree.py │ │ │ └── table/ │ │ │ ├── __init__.py │ │ │ ├── model.py │ │ │ ├── state.py │ │ │ └── table.py │ │ ├── changenotetype.py │ │ ├── clayout.py │ │ ├── colors.py │ │ ├── customstudy.py │ │ ├── data/ │ │ │ └── web/ │ │ │ ├── css/ │ │ │ │ ├── addonconf.scss │ │ │ │ ├── deckbrowser.scss │ │ │ │ ├── overview.scss │ │ │ │ ├── reviewer-bottom.scss │ │ │ │ ├── toolbar-bottom.scss │ │ │ │ ├── toolbar.scss │ │ │ │ └── webview.scss │ │ │ └── js/ │ │ │ ├── deckbrowser.ts │ │ │ ├── pycmd.d.ts │ │ │ ├── reviewer-bottom.ts │ │ │ ├── toolbar.ts │ │ │ ├── tsconfig.json │ │ │ ├── vendor/ │ │ │ │ └── plot.js │ │ │ └── webview.ts │ │ ├── dbcheck.py │ │ ├── debug_console.py │ │ ├── deckbrowser.py │ │ ├── deckchooser.py │ │ ├── deckconf.py │ │ ├── deckdescription.py │ │ ├── deckoptions.py │ │ ├── editcurrent.py │ │ ├── editor.py │ │ ├── emptycards.py │ │ ├── errors.py │ │ ├── exporting.py │ │ ├── fields.py │ │ ├── filtered_deck.py │ │ ├── flags.py │ │ ├── forms/ │ │ │ ├── __init__.py │ │ │ ├── about.py │ │ │ ├── about.ui │ │ │ ├── addcards.py │ │ │ ├── addcards.ui │ │ │ ├── addfield.py │ │ │ ├── addfield.ui │ │ │ ├── addmodel.py │ │ │ ├── addmodel.ui │ │ │ ├── addonconf.py │ │ │ ├── addonconf.ui │ │ │ ├── addons.py │ │ │ ├── addons.ui │ │ │ ├── browser.py │ │ │ ├── browser.ui │ │ │ ├── browserdisp.py │ │ │ ├── browserdisp.ui │ │ │ ├── browseropts.py │ │ │ ├── browseropts.ui │ │ │ ├── changemap.py │ │ │ ├── changemap.ui │ │ │ ├── changemodel.py │ │ │ ├── changemodel.ui │ │ │ ├── clayout_top.py │ │ │ ├── clayout_top.ui │ │ │ ├── customstudy.py │ │ │ ├── customstudy.ui │ │ │ ├── dconf.py │ │ │ ├── dconf.ui │ │ │ ├── debug.py │ │ │ ├── debug.ui │ │ │ ├── editcurrent.py │ │ │ ├── editcurrent.ui │ │ │ ├── edithtml.py │ │ │ ├── edithtml.ui │ │ │ ├── emptycards.py │ │ │ ├── emptycards.ui │ │ │ ├── exporting.py │ │ │ ├── exporting.ui │ │ │ ├── fields.py │ │ │ ├── fields.ui │ │ │ ├── filtered_deck.py │ │ │ ├── filtered_deck.ui │ │ │ ├── finddupes.py │ │ │ ├── finddupes.ui │ │ │ ├── findreplace.py │ │ │ ├── findreplace.ui │ │ │ ├── forget.py │ │ │ ├── forget.ui │ │ │ ├── getaddons.py │ │ │ ├── getaddons.ui │ │ │ ├── importing.py │ │ │ ├── importing.ui │ │ │ ├── main.py │ │ │ ├── main.ui │ │ │ ├── modelopts.py │ │ │ ├── modelopts.ui │ │ │ ├── models.py │ │ │ ├── models.ui │ │ │ ├── preferences.py │ │ │ ├── preferences.ui │ │ │ ├── preview.py │ │ │ ├── preview.ui │ │ │ ├── profiles.py │ │ │ ├── profiles.ui │ │ │ ├── progress.py │ │ │ ├── progress.ui │ │ │ ├── reposition.py │ │ │ ├── reposition.ui │ │ │ ├── setgroup.py │ │ │ ├── setgroup.ui │ │ │ ├── setlang.py │ │ │ ├── setlang.ui │ │ │ ├── stats.py │ │ │ ├── stats.ui │ │ │ ├── studydeck.py │ │ │ ├── studydeck.ui │ │ │ ├── synclog.py │ │ │ ├── synclog.ui │ │ │ ├── taglimit.py │ │ │ ├── taglimit.ui │ │ │ ├── template.py │ │ │ ├── template.ui │ │ │ ├── widgets.py │ │ │ └── widgets.ui │ │ ├── gui_hooks.py │ │ ├── import_export/ │ │ │ ├── __init__.py │ │ │ ├── exporting.py │ │ │ ├── import_dialog.py │ │ │ └── importing.py │ │ ├── importing.py │ │ ├── legacy.py │ │ ├── log.py │ │ ├── main.py │ │ ├── mediacheck.py │ │ ├── mediasrv.py │ │ ├── mediasync.py │ │ ├── modelchooser.py │ │ ├── models.py │ │ ├── mpv.py │ │ ├── notetypechooser.py │ │ ├── operations/ │ │ │ ├── __init__.py │ │ │ ├── card.py │ │ │ ├── collection.py │ │ │ ├── deck.py │ │ │ ├── note.py │ │ │ ├── notetype.py │ │ │ ├── scheduling.py │ │ │ └── tag.py │ │ ├── overview.py │ │ ├── package.py │ │ ├── preferences.py │ │ ├── profiles.py │ │ ├── progress.py │ │ ├── props.py │ │ ├── py.typed │ │ ├── qt/ │ │ │ ├── __init__.py │ │ │ └── qt6.py │ │ ├── reviewer.py │ │ ├── schema_change_tracker.py │ │ ├── sound.py │ │ ├── stats.py │ │ ├── studydeck.py │ │ ├── stylesheets.py │ │ ├── switch.py │ │ ├── sync.py │ │ ├── tagedit.py │ │ ├── taglimit.py │ │ ├── taskman.py │ │ ├── theme.py │ │ ├── toolbar.py │ │ ├── tts.py │ │ ├── undo.py │ │ ├── update.py │ │ ├── url_schemes.py │ │ ├── utils.py │ │ ├── webview.py │ │ ├── widgetgallery.py │ │ └── winpaths.py │ ├── hatch_build.py │ ├── icons/ │ │ ├── README.md │ │ └── sidebar.afdesign │ ├── launcher/ │ │ ├── Cargo.toml │ │ ├── addon/ │ │ │ ├── __init__.py │ │ │ └── manifest.json │ │ ├── build.rs │ │ ├── lin/ │ │ │ ├── README.md │ │ │ ├── anki │ │ │ ├── anki.1 │ │ │ ├── anki.desktop │ │ │ ├── anki.xml │ │ │ ├── anki.xpm │ │ │ ├── build.sh │ │ │ ├── install.sh │ │ │ └── uninstall.sh │ │ ├── mac/ │ │ │ ├── Info.plist │ │ │ ├── build.sh │ │ │ ├── dmg/ │ │ │ │ ├── build.sh │ │ │ │ ├── dmg_ds_store │ │ │ │ ├── set-dmg-settings.app/ │ │ │ │ │ └── Contents/ │ │ │ │ │ ├── Info.plist │ │ │ │ │ ├── MacOS/ │ │ │ │ │ │ └── applet │ │ │ │ │ ├── PkgInfo │ │ │ │ │ ├── Resources/ │ │ │ │ │ │ ├── Scripts/ │ │ │ │ │ │ │ └── main.scpt │ │ │ │ │ │ ├── applet.icns │ │ │ │ │ │ ├── applet.rsrc │ │ │ │ │ │ └── description.rtfd/ │ │ │ │ │ │ └── TXT.rtf │ │ │ │ │ └── _CodeSignature/ │ │ │ │ │ └── CodeResources │ │ │ │ └── set-dmg-settings.scpt │ │ │ ├── entitlements.python.xml │ │ │ ├── icon/ │ │ │ │ ├── Assets.car │ │ │ │ ├── Assets.xcassets/ │ │ │ │ │ ├── AppIcon.appiconset/ │ │ │ │ │ │ └── Contents.json │ │ │ │ │ └── Contents.json │ │ │ │ └── build.sh │ │ │ ├── notarize.sh │ │ │ └── stub.c │ │ ├── pyproject.toml │ │ ├── src/ │ │ │ ├── bin/ │ │ │ │ ├── anki_console.rs │ │ │ │ └── build_win.rs │ │ │ ├── main.rs │ │ │ └── platform/ │ │ │ ├── mac.rs │ │ │ ├── mod.rs │ │ │ ├── unix.rs │ │ │ └── windows.rs │ │ ├── versions.py │ │ └── win/ │ │ ├── anki-manifest.rc │ │ ├── anki.exe.manifest │ │ ├── anki.template.nsi │ │ ├── build.bat │ │ └── fileassoc.nsh │ ├── mac/ │ │ ├── README.md │ │ ├── anki_mac_helper/ │ │ │ ├── __init__.py │ │ │ └── py.typed │ │ ├── ankihelper.xcodeproj/ │ │ │ ├── project.pbxproj │ │ │ ├── project.xcworkspace/ │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ ├── xcshareddata/ │ │ │ │ │ └── IDEWorkspaceChecks.plist │ │ │ │ └── xcuserdata/ │ │ │ │ └── dae.xcuserdatad/ │ │ │ │ ├── UserInterfaceState.xcuserstate │ │ │ │ └── xcschemes/ │ │ │ │ └── xcschememanagement.plist │ │ │ └── xcuserdata/ │ │ │ └── dae.xcuserdatad/ │ │ │ └── xcschemes/ │ │ │ ├── ankihelper.xcscheme │ │ │ └── xcschememanagement.plist │ │ ├── appnap.swift │ │ ├── build.sh │ │ ├── helper_build.py │ │ ├── pyproject.toml │ │ ├── record.swift │ │ ├── theme.swift │ │ └── update-launcher-env │ ├── pyproject.toml │ ├── release/ │ │ ├── .gitignore │ │ └── build.sh │ ├── runanki.py │ ├── tests/ │ │ ├── __init__.py │ │ ├── test_addons.py │ │ └── test_i18n.py │ └── tools/ │ ├── build_qrc.py │ ├── build_ui.py │ ├── color_svg.py │ ├── extract_sass_vars.py │ ├── genhooks_gui.py │ └── runanki.system.in ├── rslib/ │ ├── .gitignore │ ├── Cargo.toml │ ├── README.md │ ├── bench.sh │ ├── benches/ │ │ └── benchmark.rs │ ├── build.rs │ ├── i18n/ │ │ ├── Cargo.toml │ │ ├── build.rs │ │ ├── check.rs │ │ ├── extract.rs │ │ ├── gather.rs │ │ ├── python.rs │ │ ├── src/ │ │ │ ├── generated.rs │ │ │ ├── generated_launcher.rs │ │ │ └── lib.rs │ │ ├── typescript.rs │ │ └── write_strings.rs │ ├── io/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── error.rs │ │ └── lib.rs │ ├── linkchecker/ │ │ ├── Cargo.toml │ │ ├── src/ │ │ │ └── lib.rs │ │ └── tests/ │ │ └── links.rs │ ├── process/ │ │ ├── Cargo.toml │ │ └── src/ │ │ └── lib.rs │ ├── proto/ │ │ ├── Cargo.toml │ │ ├── build.rs │ │ ├── python.rs │ │ ├── rust.rs │ │ ├── src/ │ │ │ ├── generic_helpers.rs │ │ │ └── lib.rs │ │ └── typescript.rs │ ├── proto_gen/ │ │ ├── Cargo.toml │ │ └── src/ │ │ └── lib.rs │ ├── rust_interface.rs │ ├── src/ │ │ ├── adding.rs │ │ ├── ankidroid/ │ │ │ ├── db.rs │ │ │ ├── error.rs │ │ │ ├── mod.rs │ │ │ └── service.rs │ │ ├── ankihub/ │ │ │ ├── http_client/ │ │ │ │ └── mod.rs │ │ │ ├── login.rs │ │ │ └── mod.rs │ │ ├── backend/ │ │ │ ├── adding.rs │ │ │ ├── ankidroid.rs │ │ │ ├── ankihub.rs │ │ │ ├── ankiweb.rs │ │ │ ├── card_rendering.rs │ │ │ ├── collection.rs │ │ │ ├── config.rs │ │ │ ├── dbproxy.rs │ │ │ ├── error.rs │ │ │ ├── i18n.rs │ │ │ ├── import_export.rs │ │ │ ├── mod.rs │ │ │ ├── ops.rs │ │ │ └── sync.rs │ │ ├── browser_table.rs │ │ ├── card/ │ │ │ ├── mod.rs │ │ │ ├── service.rs │ │ │ └── undo.rs │ │ ├── card_rendering/ │ │ │ ├── mod.rs │ │ │ ├── parser.rs │ │ │ ├── service.rs │ │ │ ├── tts/ │ │ │ │ ├── mod.rs │ │ │ │ ├── other.rs │ │ │ │ └── windows.rs │ │ │ └── writer.rs │ │ ├── cloze.rs │ │ ├── collection/ │ │ │ ├── backup.rs │ │ │ ├── mod.rs │ │ │ ├── service.rs │ │ │ ├── timestamps.rs │ │ │ ├── transact.rs │ │ │ └── undo.rs │ │ ├── config/ │ │ │ ├── bool.rs │ │ │ ├── deck.rs │ │ │ ├── mod.rs │ │ │ ├── notetype.rs │ │ │ ├── number.rs │ │ │ ├── schema11.rs │ │ │ ├── string.rs │ │ │ └── undo.rs │ │ ├── dbcheck.rs │ │ ├── deckconfig/ │ │ │ ├── mod.rs │ │ │ ├── schema11.rs │ │ │ ├── service.rs │ │ │ ├── undo.rs │ │ │ └── update.rs │ │ ├── decks/ │ │ │ ├── addupdate.rs │ │ │ ├── counts.rs │ │ │ ├── current.rs │ │ │ ├── filtered.rs │ │ │ ├── limits.rs │ │ │ ├── mod.rs │ │ │ ├── name.rs │ │ │ ├── remove.rs │ │ │ ├── reparent.rs │ │ │ ├── schema11.rs │ │ │ ├── service.rs │ │ │ ├── stats.rs │ │ │ ├── tree.rs │ │ │ └── undo.rs │ │ ├── error/ │ │ │ ├── db.rs │ │ │ ├── filtered.rs │ │ │ ├── invalid_input.rs │ │ │ ├── mod.rs │ │ │ ├── network.rs │ │ │ ├── not_found.rs │ │ │ ├── search.rs │ │ │ └── windows.rs │ │ ├── findreplace.rs │ │ ├── i18n/ │ │ │ ├── mod.rs │ │ │ └── service.rs │ │ ├── image_occlusion/ │ │ │ ├── imagedata.rs │ │ │ ├── imageocclusion.rs │ │ │ ├── mod.rs │ │ │ ├── notetype.css │ │ │ ├── notetype.rs │ │ │ └── service.rs │ │ ├── import_export/ │ │ │ ├── gather.rs │ │ │ ├── insert.rs │ │ │ ├── mod.rs │ │ │ ├── package/ │ │ │ │ ├── apkg/ │ │ │ │ │ ├── export.rs │ │ │ │ │ ├── import/ │ │ │ │ │ │ ├── cards.rs │ │ │ │ │ │ ├── decks.rs │ │ │ │ │ │ ├── media.rs │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ └── notes.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── tests.rs │ │ │ │ ├── colpkg/ │ │ │ │ │ ├── export.rs │ │ │ │ │ ├── import.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── tests.rs │ │ │ │ ├── media.rs │ │ │ │ ├── meta.rs │ │ │ │ └── mod.rs │ │ │ ├── service.rs │ │ │ └── text/ │ │ │ ├── csv/ │ │ │ │ ├── export.rs │ │ │ │ ├── import.rs │ │ │ │ ├── metadata.rs │ │ │ │ └── mod.rs │ │ │ ├── import.rs │ │ │ ├── json.rs │ │ │ └── mod.rs │ │ ├── latex.rs │ │ ├── lib.rs │ │ ├── links.rs │ │ ├── log.rs │ │ ├── markdown.rs │ │ ├── media/ │ │ │ ├── check.rs │ │ │ ├── files.rs │ │ │ ├── mod.rs │ │ │ └── service.rs │ │ ├── notes/ │ │ │ ├── mod.rs │ │ │ ├── service.rs │ │ │ └── undo.rs │ │ ├── notetype/ │ │ │ ├── cardgen.rs │ │ │ ├── checks.rs │ │ │ ├── cloze_styling.css │ │ │ ├── emptycards.rs │ │ │ ├── fields.rs │ │ │ ├── header.tex │ │ │ ├── merge.rs │ │ │ ├── mod.rs │ │ │ ├── notetypechange.rs │ │ │ ├── render.rs │ │ │ ├── restore.rs │ │ │ ├── schema11.rs │ │ │ ├── schemachange.rs │ │ │ ├── service.rs │ │ │ ├── stock.rs │ │ │ ├── styling.css │ │ │ ├── templates.rs │ │ │ └── undo.rs │ │ ├── ops.rs │ │ ├── preferences.rs │ │ ├── prelude.rs │ │ ├── progress.rs │ │ ├── revlog/ │ │ │ ├── mod.rs │ │ │ └── undo.rs │ │ ├── scheduler/ │ │ │ ├── answering/ │ │ │ │ ├── current.rs │ │ │ │ ├── learning.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── preview.rs │ │ │ │ ├── relearning.rs │ │ │ │ ├── review.rs │ │ │ │ └── revlog.rs │ │ │ ├── bury_and_suspend.rs │ │ │ ├── congrats.rs │ │ │ ├── filtered/ │ │ │ │ ├── card.rs │ │ │ │ ├── custom_study.rs │ │ │ │ └── mod.rs │ │ │ ├── fsrs/ │ │ │ │ ├── error.rs │ │ │ │ ├── memory_state.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── params.rs │ │ │ │ ├── rescheduler.rs │ │ │ │ ├── retention.rs │ │ │ │ ├── simulator.rs │ │ │ │ └── try_collect.rs │ │ │ ├── mod.rs │ │ │ ├── new.rs │ │ │ ├── queue/ │ │ │ │ ├── builder/ │ │ │ │ │ ├── burying.rs │ │ │ │ │ ├── gathering.rs │ │ │ │ │ ├── intersperser.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── sized_chain.rs │ │ │ │ │ └── sorting.rs │ │ │ │ ├── entry.rs │ │ │ │ ├── learning.rs │ │ │ │ ├── main.rs │ │ │ │ ├── mod.rs │ │ │ │ └── undo.rs │ │ │ ├── reviews.rs │ │ │ ├── service/ │ │ │ │ ├── answering.rs │ │ │ │ ├── mod.rs │ │ │ │ └── states/ │ │ │ │ ├── filtered.rs │ │ │ │ ├── learning.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── new.rs │ │ │ │ ├── normal.rs │ │ │ │ ├── preview.rs │ │ │ │ ├── relearning.rs │ │ │ │ ├── rescheduling.rs │ │ │ │ └── review.rs │ │ │ ├── states/ │ │ │ │ ├── filtered.rs │ │ │ │ ├── fuzz.rs │ │ │ │ ├── interval_kind.rs │ │ │ │ ├── learning.rs │ │ │ │ ├── load_balancer.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── new.rs │ │ │ │ ├── normal.rs │ │ │ │ ├── preview_filter.rs │ │ │ │ ├── relearning.rs │ │ │ │ ├── rescheduling_filter.rs │ │ │ │ ├── review.rs │ │ │ │ └── steps.rs │ │ │ ├── timespan.rs │ │ │ ├── timing.rs │ │ │ └── upgrade.rs │ │ ├── search/ │ │ │ ├── builder.rs │ │ │ ├── card_mod_order.sql │ │ │ ├── deck_order.sql │ │ │ ├── mod.rs │ │ │ ├── note_cards_order.sql │ │ │ ├── note_decks_order.sql │ │ │ ├── note_due_order.sql │ │ │ ├── note_ease_order.sql │ │ │ ├── note_interval_order.sql │ │ │ ├── note_lapses_order.sql │ │ │ ├── note_original_position_order.sql │ │ │ ├── note_reps_order.sql │ │ │ ├── notetype_order.sql │ │ │ ├── parser.rs │ │ │ ├── service/ │ │ │ │ ├── browser_table.rs │ │ │ │ ├── mod.rs │ │ │ │ └── search_node.rs │ │ │ ├── sqlwriter.rs │ │ │ ├── template_order.sql │ │ │ └── writer.rs │ │ ├── serde.rs │ │ ├── services.rs │ │ ├── stats/ │ │ │ ├── card.rs │ │ │ ├── graphs/ │ │ │ │ ├── added.rs │ │ │ │ ├── buttons.rs │ │ │ │ ├── card_counts.rs │ │ │ │ ├── eases.rs │ │ │ │ ├── future_due.rs │ │ │ │ ├── hours.rs │ │ │ │ ├── intervals.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── retention.rs │ │ │ │ ├── retrievability.rs │ │ │ │ ├── reviews.rs │ │ │ │ └── today.rs │ │ │ ├── mod.rs │ │ │ ├── service.rs │ │ │ └── today.rs │ │ ├── storage/ │ │ │ ├── card/ │ │ │ │ ├── active_new_cards.sql │ │ │ │ ├── add_card.sql │ │ │ │ ├── add_card_if_unique.sql │ │ │ │ ├── add_or_update.sql │ │ │ │ ├── at_or_above_position.sql │ │ │ │ ├── congrats.sql │ │ │ │ ├── data.rs │ │ │ │ ├── deck_due_counts.sql │ │ │ │ ├── due_cards.sql │ │ │ │ ├── filtered.rs │ │ │ │ ├── fix_due_new.sql │ │ │ │ ├── fix_due_other.sql │ │ │ │ ├── fix_ivl.sql │ │ │ │ ├── fix_low_ease.sql │ │ │ │ ├── fix_mod.sql │ │ │ │ ├── fix_odue.sql │ │ │ │ ├── fix_ordinal.sql │ │ │ │ ├── get_card.sql │ │ │ │ ├── get_card_entry.sql │ │ │ │ ├── get_ignored_before_count.sql │ │ │ │ ├── intraday_due.sql │ │ │ │ ├── mod.rs │ │ │ │ ├── new_cards.sql │ │ │ │ ├── search_cards_of_notes_into_table.sql │ │ │ │ ├── search_cids_setup.sql │ │ │ │ ├── search_cids_setup_ordered.sql │ │ │ │ ├── siblings_for_bury.sql │ │ │ │ └── update_card.sql │ │ │ ├── collection_timestamps.rs │ │ │ ├── config/ │ │ │ │ ├── add.sql │ │ │ │ ├── get.sql │ │ │ │ ├── get_entry.sql │ │ │ │ └── mod.rs │ │ │ ├── dbcheck/ │ │ │ │ ├── invalid_ids_count.sql │ │ │ │ ├── invalid_ids_create.sql │ │ │ │ ├── invalid_ids_drop.sql │ │ │ │ ├── invalid_ids_update.sql │ │ │ │ └── mod.rs │ │ │ ├── deck/ │ │ │ │ ├── active_deck_ids_sorted.sql │ │ │ │ ├── add_or_update_deck.sql │ │ │ │ ├── all_decks_and_original_of_search_cards.sql │ │ │ │ ├── all_decks_of_search_notes.sql │ │ │ │ ├── alloc_id.sql │ │ │ │ ├── cards_for_deck.sql │ │ │ │ ├── due_counts.sql │ │ │ │ ├── get_deck.sql │ │ │ │ ├── missing-decks.sql │ │ │ │ ├── mod.rs │ │ │ │ ├── update_active.sql │ │ │ │ └── update_deck.sql │ │ │ ├── deckconfig/ │ │ │ │ ├── add.sql │ │ │ │ ├── add_if_unique.sql │ │ │ │ ├── add_or_update.sql │ │ │ │ ├── get.sql │ │ │ │ ├── mod.rs │ │ │ │ └── update.sql │ │ │ ├── graves/ │ │ │ │ ├── add.sql │ │ │ │ ├── mod.rs │ │ │ │ └── remove.sql │ │ │ ├── mod.rs │ │ │ ├── note/ │ │ │ │ ├── add.sql │ │ │ │ ├── add_if_unique.sql │ │ │ │ ├── add_or_update.sql │ │ │ │ ├── get.sql │ │ │ │ ├── get_tags.sql │ │ │ │ ├── get_without_fields.sql │ │ │ │ ├── is_orphaned.sql │ │ │ │ ├── mod.rs │ │ │ │ ├── notes_types_checksums_decks.sql │ │ │ │ ├── search_nids_setup.sql │ │ │ │ ├── update.sql │ │ │ │ └── update_tags.sql │ │ │ ├── notetype/ │ │ │ │ ├── add_notetype.sql │ │ │ │ ├── add_or_update.sql │ │ │ │ ├── existing_cards.sql │ │ │ │ ├── field_names_for_notes.sql │ │ │ │ ├── get_fields.sql │ │ │ │ ├── get_notetype.sql │ │ │ │ ├── get_notetype_names.sql │ │ │ │ ├── get_templates.sql │ │ │ │ ├── get_use_counts.sql │ │ │ │ ├── highest_card_ord.sql │ │ │ │ ├── mod.rs │ │ │ │ ├── update_fields.sql │ │ │ │ ├── update_notetype_config.sql │ │ │ │ └── update_templates.sql │ │ │ ├── revlog/ │ │ │ │ ├── add.sql │ │ │ │ ├── fix_props.sql │ │ │ │ ├── get.sql │ │ │ │ ├── mod.rs │ │ │ │ ├── studied_today.sql │ │ │ │ ├── studied_today_by_deck.sql │ │ │ │ ├── time_of_last_review.sql │ │ │ │ └── v2_upgrade.sql │ │ │ ├── schema11.sql │ │ │ ├── sqlite.rs │ │ │ ├── sync.rs │ │ │ ├── sync_check.rs │ │ │ ├── tag/ │ │ │ │ ├── add.sql │ │ │ │ ├── alloc_id.sql │ │ │ │ ├── get.sql │ │ │ │ ├── mod.rs │ │ │ │ └── update.sql │ │ │ └── upgrades/ │ │ │ ├── mod.rs │ │ │ ├── schema11_downgrade.sql │ │ │ ├── schema14_upgrade.sql │ │ │ ├── schema15_upgrade.sql │ │ │ ├── schema17_upgrade.sql │ │ │ ├── schema18_downgrade.sql │ │ │ └── schema18_upgrade.sql │ │ ├── sync/ │ │ │ ├── collection/ │ │ │ │ ├── changes.rs │ │ │ │ ├── chunks.rs │ │ │ │ ├── download.rs │ │ │ │ ├── finish.rs │ │ │ │ ├── graves.rs │ │ │ │ ├── meta.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── normal.rs │ │ │ │ ├── progress.rs │ │ │ │ ├── protocol.rs │ │ │ │ ├── sanity.rs │ │ │ │ ├── start.rs │ │ │ │ ├── status.rs │ │ │ │ ├── tests.rs │ │ │ │ └── upload.rs │ │ │ ├── error.rs │ │ │ ├── http_client/ │ │ │ │ ├── full_sync.rs │ │ │ │ ├── io_monitor.rs │ │ │ │ ├── mod.rs │ │ │ │ └── protocol.rs │ │ │ ├── http_server/ │ │ │ │ ├── handlers.rs │ │ │ │ ├── logging.rs │ │ │ │ ├── media_manager/ │ │ │ │ │ ├── download.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── upload.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── routes.rs │ │ │ │ └── user.rs │ │ │ ├── login.rs │ │ │ ├── media/ │ │ │ │ ├── begin.rs │ │ │ │ ├── changes.rs │ │ │ │ ├── database/ │ │ │ │ │ ├── client/ │ │ │ │ │ │ ├── changetracker.rs │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ └── schema.sql │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── server/ │ │ │ │ │ ├── entry/ │ │ │ │ │ │ ├── changes.rs │ │ │ │ │ │ ├── changes.sql │ │ │ │ │ │ ├── download.rs │ │ │ │ │ │ ├── get_entry.sql │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ ├── set_entry.sql │ │ │ │ │ │ └── upload.rs │ │ │ │ │ ├── meta/ │ │ │ │ │ │ ├── get_meta.sql │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ └── set_meta.sql │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── schema_v3.sql │ │ │ │ │ └── schema_v4.sql │ │ │ │ ├── download.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── progress.rs │ │ │ │ ├── protocol.rs │ │ │ │ ├── sanity.rs │ │ │ │ ├── syncer.rs │ │ │ │ ├── tests.rs │ │ │ │ ├── upload.rs │ │ │ │ └── zip.rs │ │ │ ├── mod.rs │ │ │ ├── request/ │ │ │ │ ├── header_and_stream.rs │ │ │ │ ├── mod.rs │ │ │ │ └── multipart.rs │ │ │ ├── response.rs │ │ │ └── version.rs │ │ ├── tags/ │ │ │ ├── bulkadd.rs │ │ │ ├── complete.rs │ │ │ ├── findreplace.rs │ │ │ ├── matcher.rs │ │ │ ├── mod.rs │ │ │ ├── notes.rs │ │ │ ├── register.rs │ │ │ ├── remove.rs │ │ │ ├── rename.rs │ │ │ ├── reparent.rs │ │ │ ├── service.rs │ │ │ ├── tree.rs │ │ │ └── undo.rs │ │ ├── template.rs │ │ ├── template_filters.rs │ │ ├── tests.rs │ │ ├── text.rs │ │ ├── timestamp.rs │ │ ├── typeanswer.rs │ │ ├── types.rs │ │ ├── undo/ │ │ │ ├── changes.rs │ │ │ └── mod.rs │ │ └── version.rs │ ├── sync/ │ │ ├── Cargo.toml │ │ └── main.rs │ └── tests/ │ └── support/ │ └── mediacheck.anki2 ├── run ├── run.bat ├── rust-toolchain.toml ├── tools/ │ ├── build │ ├── build-arm-lin │ ├── build-x64-mac │ ├── build.bat │ ├── clean │ ├── dmypy │ ├── install-n2 │ ├── minilints/ │ │ ├── Cargo.toml │ │ └── src/ │ │ └── main.rs │ ├── ninja.bat │ ├── profile │ ├── publish │ ├── rebuild-web │ ├── reload_webviews.py │ ├── run-qt6.6 │ ├── run-qt6.7 │ ├── run-qt6.8 │ ├── run.py │ ├── runopt │ ├── unused-rust-deps │ ├── update-launcher-env │ ├── update-launcher-env.bat │ └── web-watch ├── ts/ │ ├── .gitignore │ ├── README.md │ ├── bundle_svelte.mjs │ ├── bundle_ts.mjs │ ├── editable/ │ │ ├── ContentEditable.svelte │ │ ├── Mathjax.svelte │ │ ├── change-timer.ts │ │ ├── content-editable.ts │ │ ├── cooldown-timer.ts │ │ ├── decorated.ts │ │ ├── editable-base.scss │ │ ├── frame-element.ts │ │ ├── frame-handle.ts │ │ ├── index.ts │ │ ├── mathjax-element.svelte.ts │ │ └── mathjax.ts │ ├── editor/ │ │ ├── BrowserEditor.svelte │ │ ├── ClozeButtons.svelte │ │ ├── CodeMirror.svelte │ │ ├── CollapseBadge.svelte │ │ ├── CollapseLabel.svelte │ │ ├── DuplicateLink.svelte │ │ ├── EditingArea.svelte │ │ ├── EditorField.svelte │ │ ├── FieldDescription.svelte │ │ ├── FieldState.svelte │ │ ├── Fields.svelte │ │ ├── HandleBackground.svelte │ │ ├── HandleControl.svelte │ │ ├── HandleLabel.svelte │ │ ├── LabelContainer.svelte │ │ ├── LabelName.svelte │ │ ├── NoteCreator.svelte │ │ ├── NoteEditor.svelte │ │ ├── Notification.svelte │ │ ├── PlainTextBadge.svelte │ │ ├── PreviewButton.svelte │ │ ├── ReviewerEditor.svelte │ │ ├── RichTextBadge.svelte │ │ ├── StickyBadge.svelte │ │ ├── base.ts │ │ ├── code-mirror.ts │ │ ├── decorated-elements.ts │ │ ├── destroyable.ts │ │ ├── editor-base.scss │ │ ├── editor-toolbar/ │ │ │ ├── AddonButtons.svelte │ │ │ ├── BlockButtons.svelte │ │ │ ├── BoldButton.svelte │ │ │ ├── ColorPicker.svelte │ │ │ ├── CommandIconButton.svelte │ │ │ ├── EditorToolbar.svelte │ │ │ ├── HighlightColorButton.svelte │ │ │ ├── ImageOcclusionButton.svelte │ │ │ ├── InlineButtons.svelte │ │ │ ├── ItalicButton.svelte │ │ │ ├── LatexButton.svelte │ │ │ ├── NotetypeButtons.svelte │ │ │ ├── OptionsButton.svelte │ │ │ ├── OptionsButtons.svelte │ │ │ ├── RemoveFormatButton.svelte │ │ │ ├── RichTextClozeButtons.svelte │ │ │ ├── SubscriptButton.svelte │ │ │ ├── SuperscriptButton.svelte │ │ │ ├── TemplateButtons.svelte │ │ │ ├── TextAttributeButton.svelte │ │ │ ├── TextColorButton.svelte │ │ │ ├── UnderlineButton.svelte │ │ │ ├── WithColorHelper.svelte │ │ │ └── index.ts │ │ ├── helpers.ts │ │ ├── image-overlay/ │ │ │ ├── FloatButtons.svelte │ │ │ ├── ImageOverlay.svelte │ │ │ ├── SizeSelect.svelte │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── legacy.scss │ │ ├── mathjax-overlay/ │ │ │ ├── MathjaxButtons.svelte │ │ │ ├── MathjaxEditor.svelte │ │ │ ├── MathjaxOverlay.svelte │ │ │ └── index.ts │ │ ├── old-editor-adapter.ts │ │ ├── plain-text-input/ │ │ │ ├── PlainTextInput.svelte │ │ │ ├── index.ts │ │ │ ├── remove-prohibited.ts │ │ │ └── transform.ts │ │ ├── rich-text-input/ │ │ │ ├── CustomStyles.svelte │ │ │ ├── RichTextInput.svelte │ │ │ ├── RichTextStyles.svelte │ │ │ ├── StyleLink.svelte │ │ │ ├── StyleTag.svelte │ │ │ ├── index.ts │ │ │ ├── normalizing-node-store.ts │ │ │ ├── rich-text-resolve.ts │ │ │ └── transform.ts │ │ ├── surround.ts │ │ └── types.ts │ ├── html-filter/ │ │ ├── element.ts │ │ ├── helpers.ts │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── node.ts │ │ └── styling.ts │ ├── lib/ │ │ ├── components/ │ │ │ ├── Absolute.svelte │ │ │ ├── BackendProgressIndicator.svelte │ │ │ ├── Badge.svelte │ │ │ ├── ButtonGroup.svelte │ │ │ ├── ButtonGroupItem.svelte │ │ │ ├── ButtonToolbar.svelte │ │ │ ├── CheckBox.svelte │ │ │ ├── Col.svelte │ │ │ ├── Collapsible.svelte │ │ │ ├── ConfigInput.svelte │ │ │ ├── Container.svelte │ │ │ ├── DropdownDivider.svelte │ │ │ ├── DropdownItem.svelte │ │ │ ├── DynamicallySlottable.svelte │ │ │ ├── EnumSelector.svelte │ │ │ ├── EnumSelectorRow.svelte │ │ │ ├── ErrorPage.svelte │ │ │ ├── FloatingArrow.svelte │ │ │ ├── HelpModal.svelte │ │ │ ├── HelpSection.svelte │ │ │ ├── Icon.svelte │ │ │ ├── IconButton.svelte │ │ │ ├── IconConstrain.svelte │ │ │ ├── Item.svelte │ │ │ ├── Label.svelte │ │ │ ├── LabelButton.svelte │ │ │ ├── Popover.svelte │ │ │ ├── Portal.svelte │ │ │ ├── RenderChildren.svelte │ │ │ ├── RevertButton.svelte │ │ │ ├── Row.svelte │ │ │ ├── ScrollArea.svelte │ │ │ ├── Select.svelte │ │ │ ├── SelectOption.svelte │ │ │ ├── SettingTitle.svelte │ │ │ ├── Shortcut.svelte │ │ │ ├── Spacer.svelte │ │ │ ├── SpinBox.svelte │ │ │ ├── StickyContainer.svelte │ │ │ ├── Switch.svelte │ │ │ ├── SwitchRow.svelte │ │ │ ├── TitledContainer.svelte │ │ │ ├── VirtualTable.svelte │ │ │ ├── WithContext.svelte │ │ │ ├── WithFloating.svelte │ │ │ ├── WithOverlay.svelte │ │ │ ├── WithState.svelte │ │ │ ├── WithTooltip.svelte │ │ │ ├── context-keys.ts │ │ │ ├── helpers.ts │ │ │ ├── icons.ts │ │ │ ├── resizable.ts │ │ │ └── types.ts │ │ ├── domlib/ │ │ │ ├── content-editable.ts │ │ │ ├── find-above.ts │ │ │ ├── index.ts │ │ │ ├── location/ │ │ │ │ ├── document.ts │ │ │ │ ├── index.ts │ │ │ │ ├── location.ts │ │ │ │ ├── node.ts │ │ │ │ ├── range.ts │ │ │ │ └── selection.ts │ │ │ ├── move-nodes.ts │ │ │ ├── place-caret.ts │ │ │ └── surround/ │ │ │ ├── apply/ │ │ │ │ ├── format.ts │ │ │ │ └── index.ts │ │ │ ├── build/ │ │ │ │ ├── add-merge.ts │ │ │ │ ├── build-tree.ts │ │ │ │ ├── extend-merge.ts │ │ │ │ ├── format.ts │ │ │ │ └── index.ts │ │ │ ├── flat-range.ts │ │ │ ├── index.ts │ │ │ ├── match-type.ts │ │ │ ├── split-text.ts │ │ │ ├── surround-format.ts │ │ │ ├── surround.test.ts │ │ │ ├── surround.ts │ │ │ ├── test-utils.ts │ │ │ ├── tree/ │ │ │ │ ├── block-node.ts │ │ │ │ ├── element-node.ts │ │ │ │ ├── formatting-node.ts │ │ │ │ ├── index.ts │ │ │ │ └── tree-node.ts │ │ │ └── unsurround.test.ts │ │ ├── generated/ │ │ │ ├── README.md │ │ │ ├── ftl-helpers.ts │ │ │ └── post.ts │ │ ├── sass/ │ │ │ ├── _button-mixins.scss │ │ │ ├── _color-palette.scss │ │ │ ├── _functions.scss │ │ │ ├── _root-vars.scss │ │ │ ├── _vars.scss │ │ │ ├── base.scss │ │ │ ├── bootstrap-dark.scss │ │ │ ├── bootstrap-forms.scss │ │ │ ├── bootstrap-tooltip.scss │ │ │ ├── breakpoints.scss │ │ │ ├── buttons.scss │ │ │ ├── card-counts.scss │ │ │ ├── core.scss │ │ │ ├── elevation.scss │ │ │ ├── night-mode.scss │ │ │ ├── panes.scss │ │ │ └── scrollbar.scss │ │ ├── sveltelib/ │ │ │ ├── action-list.ts │ │ │ ├── closing-click.ts │ │ │ ├── closing-keyup.ts │ │ │ ├── composition.ts │ │ │ ├── context-property.ts │ │ │ ├── dom-mirror.ts │ │ │ ├── dynamic-slotting.ts │ │ │ ├── dynamicComponent.ts │ │ │ ├── event-predicate.d.ts │ │ │ ├── event-store.ts │ │ │ ├── export-runtime.ts │ │ │ ├── handler-list.ts │ │ │ ├── input-handler.ts │ │ │ ├── lifecycle-hooks.ts │ │ │ ├── modal-closing.ts │ │ │ ├── node-store.ts │ │ │ ├── position/ │ │ │ │ ├── auto-update.ts │ │ │ │ ├── position-algorithm.d.ts │ │ │ │ ├── position-floating.ts │ │ │ │ └── position-overlay.ts │ │ │ ├── preferences.ts │ │ │ ├── resize-store.ts │ │ │ ├── shortcut.ts │ │ │ ├── store-subscribe.ts │ │ │ ├── subscribe-updates.ts │ │ │ ├── theme.ts │ │ │ └── toggleable.ts │ │ ├── tag-editor/ │ │ │ ├── AutocompleteItem.svelte │ │ │ ├── Tag.svelte │ │ │ ├── TagDeleteBadge.svelte │ │ │ ├── TagEditMode.svelte │ │ │ ├── TagEditor.svelte │ │ │ ├── TagInput.svelte │ │ │ ├── TagSpacer.svelte │ │ │ ├── TagWithTooltip.svelte │ │ │ ├── TagsRow.svelte │ │ │ ├── WithAutocomplete.svelte │ │ │ ├── index.ts │ │ │ ├── tag-options-button/ │ │ │ │ ├── TagAddButton.svelte │ │ │ │ ├── TagOptionsButton.svelte │ │ │ │ ├── TagsSelectedButton.svelte │ │ │ │ └── index.ts │ │ │ └── tags.ts │ │ └── tslib/ │ │ ├── bridgecommand.ts │ │ ├── cards.ts │ │ ├── children-access.ts │ │ ├── context-keys.ts │ │ ├── cross-browser.ts │ │ ├── dom.ts │ │ ├── events.ts │ │ ├── functional.ts │ │ ├── globals.ts │ │ ├── help-page.ts │ │ ├── helpers.ts │ │ ├── i18n/ │ │ │ ├── index.ts │ │ │ └── utils.ts │ │ ├── image-import.d.ts │ │ ├── keys.ts │ │ ├── nightmode.ts │ │ ├── node.ts │ │ ├── parsing.ts │ │ ├── platform.ts │ │ ├── progress.ts │ │ ├── promise.ts │ │ ├── runtime-require.ts │ │ ├── shadow-dom.d.ts │ │ ├── shortcuts.ts │ │ ├── styling.ts │ │ ├── time.test.ts │ │ ├── time.ts │ │ ├── typing.ts │ │ ├── ui.ts │ │ └── wrap.ts │ ├── licenses.json │ ├── mathjax/ │ │ ├── index.ts │ │ └── mathjax-types.d.ts │ ├── page.html │ ├── reviewer/ │ │ ├── answering.ts │ │ ├── browser_selector.ts │ │ ├── images.ts │ │ ├── index.ts │ │ ├── index_wrapper.ts │ │ ├── lib.test.ts │ │ ├── preload.ts │ │ ├── reviewer.scss │ │ ├── reviewer_extras.scss │ │ └── reviewer_extras.ts │ ├── routes/ │ │ ├── +error.svelte │ │ ├── +layout.svelte │ │ ├── +layout.ts │ │ ├── base.scss │ │ ├── card-info/ │ │ │ ├── CardInfo.svelte │ │ │ ├── CardInfoPlaceholder.svelte │ │ │ ├── CardStats.svelte │ │ │ ├── ForgettingCurve.svelte │ │ │ ├── Revlog.svelte │ │ │ ├── [cardId]/ │ │ │ │ ├── +page.svelte │ │ │ │ ├── +page.ts │ │ │ │ └── [previousId]/ │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ │ └── forgetting-curve.ts │ │ ├── change-notetype/ │ │ │ ├── Alert.svelte │ │ │ ├── ChangeNotetypePage.svelte │ │ │ ├── Mapper.svelte │ │ │ ├── MapperRow.svelte │ │ │ ├── NotetypeSelector.svelte │ │ │ ├── SaveButton.svelte │ │ │ ├── StickyHeader.svelte │ │ │ ├── [...notetypeIds]/ │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ │ ├── change-notetype-base.scss │ │ │ ├── index.ts │ │ │ ├── lib.test.ts │ │ │ └── lib.ts │ │ ├── congrats/ │ │ │ ├── +page.svelte │ │ │ ├── +page.ts │ │ │ ├── CongratsPage.svelte │ │ │ ├── congrats-base.scss │ │ │ ├── index.ts │ │ │ └── lib.ts │ │ ├── deck-options/ │ │ │ ├── Addons.svelte │ │ │ ├── AdvancedOptions.svelte │ │ │ ├── AudioOptions.svelte │ │ │ ├── AutoAdvance.svelte │ │ │ ├── BuryOptions.svelte │ │ │ ├── CardStateCustomizer.svelte │ │ │ ├── ConfigSelector.svelte │ │ │ ├── DailyLimits.svelte │ │ │ ├── DateInput.svelte │ │ │ ├── DeckOptionsPage.svelte │ │ │ ├── DisplayOrder.svelte │ │ │ ├── EasyDays.svelte │ │ │ ├── EasyDaysInput.svelte │ │ │ ├── FsrsOptions.svelte │ │ │ ├── FsrsOptionsOuter.svelte │ │ │ ├── GlobalLabel.svelte │ │ │ ├── HtmlAddon.svelte │ │ │ ├── LapseOptions.svelte │ │ │ ├── NewOptions.svelte │ │ │ ├── ParamsInput.svelte │ │ │ ├── ParamsInputRow.svelte │ │ │ ├── ParamsSearchRow.svelte │ │ │ ├── SaveButton.svelte │ │ │ ├── SimulatorModal.svelte │ │ │ ├── SpinBoxFloatRow.svelte │ │ │ ├── SpinBoxRow.svelte │ │ │ ├── StepsInput.svelte │ │ │ ├── StepsInputRow.svelte │ │ │ ├── TabbedValue.svelte │ │ │ ├── TextInputModal.svelte │ │ │ ├── TimerOptions.svelte │ │ │ ├── Warning.svelte │ │ │ ├── [deckId]/ │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ │ ├── choices.ts │ │ │ ├── deck-options-base.scss │ │ │ ├── index.ts │ │ │ ├── lib.test.ts │ │ │ ├── lib.ts │ │ │ ├── steps.test.ts │ │ │ └── steps.ts │ │ ├── graphs/ │ │ │ ├── +page.svelte │ │ │ ├── AddedGraph.svelte │ │ │ ├── AxisTicks.svelte │ │ │ ├── ButtonsGraph.svelte │ │ │ ├── CalendarGraph.svelte │ │ │ ├── CardCounts.svelte │ │ │ ├── CumulativeOverlay.svelte │ │ │ ├── DifficultyGraph.svelte │ │ │ ├── EaseGraph.svelte │ │ │ ├── FutureDue.svelte │ │ │ ├── Graph.svelte │ │ │ ├── GraphRangeRadios.svelte │ │ │ ├── GraphsPage.svelte │ │ │ ├── HistogramGraph.svelte │ │ │ ├── HourGraph.svelte │ │ │ ├── HoverColumns.svelte │ │ │ ├── InputBox.svelte │ │ │ ├── IntervalsGraph.svelte │ │ │ ├── NoDataOverlay.svelte │ │ │ ├── PercentageRange.svelte │ │ │ ├── RangeBox.svelte │ │ │ ├── RetrievabilityGraph.svelte │ │ │ ├── ReviewsGraph.svelte │ │ │ ├── StabilityGraph.svelte │ │ │ ├── TableData.svelte │ │ │ ├── TodayStats.svelte │ │ │ ├── Tooltip.svelte │ │ │ ├── TrueRetention.svelte │ │ │ ├── TrueRetentionCombined.svelte │ │ │ ├── TrueRetentionSingle.svelte │ │ │ ├── WithGraphData.svelte │ │ │ ├── _true-retention-base.scss │ │ │ ├── added.ts │ │ │ ├── buttons.ts │ │ │ ├── calendar.ts │ │ │ ├── card-counts.ts │ │ │ ├── difficulty.ts │ │ │ ├── ease.ts │ │ │ ├── future-due.ts │ │ │ ├── graph-helpers.ts │ │ │ ├── graph-styles.ts │ │ │ ├── graphs-base.scss │ │ │ ├── histogram-graph.ts │ │ │ ├── hours.ts │ │ │ ├── index.ts │ │ │ ├── intervals.ts │ │ │ ├── percentageRange.ts │ │ │ ├── retrievability.ts │ │ │ ├── reviews.ts │ │ │ ├── simulator.ts │ │ │ ├── today.ts │ │ │ ├── tooltip-utils.svelte.ts │ │ │ └── true-retention.ts │ │ ├── image-occlusion/ │ │ │ ├── ImageOcclusionPage.svelte │ │ │ ├── ImageOcclusionPicker.svelte │ │ │ ├── MaskEditor.svelte │ │ │ ├── Notes.svelte │ │ │ ├── StickyFooter.svelte │ │ │ ├── Tags.svelte │ │ │ ├── Toast.svelte │ │ │ ├── Toolbar.svelte │ │ │ ├── [...imagePathOrNoteId]/ │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ │ ├── add-or-update-note.svelte.ts │ │ │ ├── canvas-scale.ts │ │ │ ├── fabric.d.ts │ │ │ ├── image-occlusion-base.scss │ │ │ ├── index.ts │ │ │ ├── lib.ts │ │ │ ├── mask-editor.ts │ │ │ ├── notes-toolbar/ │ │ │ │ ├── MoreTools.svelte │ │ │ │ ├── NotesToolbar.svelte │ │ │ │ ├── TextFormatting.svelte │ │ │ │ ├── index.ts │ │ │ │ └── lib.ts │ │ │ ├── review.scss │ │ │ ├── review.ts │ │ │ ├── shapes/ │ │ │ │ ├── base.ts │ │ │ │ ├── ellipse.ts │ │ │ │ ├── from-cloze.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lib.ts │ │ │ │ ├── polygon.ts │ │ │ │ ├── position.ts │ │ │ │ ├── rectangle.ts │ │ │ │ ├── text.ts │ │ │ │ └── to-cloze.ts │ │ │ ├── store.ts │ │ │ ├── tools/ │ │ │ │ ├── add-from-cloze.ts │ │ │ │ ├── api.ts │ │ │ │ ├── from-shapes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lib.ts │ │ │ │ ├── more-tools.ts │ │ │ │ ├── shortcuts.ts │ │ │ │ ├── tool-aligns.ts │ │ │ │ ├── tool-buttons.ts │ │ │ │ ├── tool-cursor.ts │ │ │ │ ├── tool-ellipse.ts │ │ │ │ ├── tool-fill.ts │ │ │ │ ├── tool-polygon.ts │ │ │ │ ├── tool-rect.ts │ │ │ │ ├── tool-text.ts │ │ │ │ ├── tool-undo-redo.ts │ │ │ │ └── tool-zoom.ts │ │ │ └── types.ts │ │ ├── import-anki-package/ │ │ │ ├── Header.svelte │ │ │ ├── ImportAnkiPackagePage.svelte │ │ │ ├── [...path]/ │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ │ ├── choices.ts │ │ │ ├── import-anki-package-base.scss │ │ │ └── index.ts │ │ ├── import-csv/ │ │ │ ├── FieldMapper.svelte │ │ │ ├── FileOptions.svelte │ │ │ ├── ImportCsvPage.svelte │ │ │ ├── ImportOptions.svelte │ │ │ ├── MapperRow.svelte │ │ │ ├── Preview.svelte │ │ │ ├── [...path]/ │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ │ ├── choices.ts │ │ │ ├── import-csv-base.scss │ │ │ ├── index.ts │ │ │ └── lib.ts │ │ ├── import-page/ │ │ │ ├── DetailsTable.svelte │ │ │ ├── ImportLogPage.svelte │ │ │ ├── ImportPage.svelte │ │ │ ├── QueueSummary.svelte │ │ │ ├── StickyHeader.svelte │ │ │ ├── TableCell.svelte │ │ │ ├── TableCellWithTooltip.svelte │ │ │ ├── [...path]/ │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ │ ├── import-page-base.scss │ │ │ ├── index.ts │ │ │ ├── lib.ts │ │ │ └── types.ts │ │ └── tmp/ │ │ └── _page.ts │ ├── src/ │ │ ├── app.d.ts │ │ ├── app.html │ │ └── hooks.client.js │ ├── svelte.config.js │ ├── tools/ │ │ ├── markpure.ts │ │ └── sql_format.ts │ ├── transform_ts.mjs │ ├── tsconfig.json │ ├── tsconfig_legacy.json │ └── vite.config.ts ├── yarn └── yarn.bat ================================================ FILE CONTENTS ================================================ ================================================ FILE: .buildkite/linux/docker/Dockerfile ================================================ FROM ubuntu:22.04 ARG DEBIAN_FRONTEND="noninteractive" RUN useradd -d /state -m -u 998 user RUN apt-get update && apt install --yes gnupg ca-certificates && \ apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 32A37959C2FA5C3C99EFBC32A79206696452D198 \ && echo "deb https://apt.buildkite.com/buildkite-agent stable main" > /etc/apt/sources.list.d/buildkite-agent.list \ && apt-get update \ && apt-get install --yes --no-install-recommends \ autoconf \ bash \ buildkite-agent \ ca-certificates \ curl \ findutils \ g++ \ gcc \ git \ grep \ libdbus-1-3 \ libegl1 \ libfontconfig1 \ libgl1 \ libgstreamer-gl1.0-0 \ libgstreamer-plugins-base1.0 \ libgstreamer1.0-0 \ libnss3 \ libpulse-mainloop-glib0 \ libpulse-mainloop-glib0 \ libssl-dev \ libxcomposite1 \ libxcursor1 \ libxdamage1 \ libxi6 \ libxkbcommon-x11-0 \ libxkbcommon0 \ libxkbfile1 \ libxrandr2 \ libxrender1 \ libxtst6 \ make \ pkg-config \ portaudio19-dev \ python3-dev \ rsync \ unzip \ zstd \ && rm -rf /var/lib/apt/lists/* RUN mkdir -p /etc/buildkite-agent/hooks && chown -R user /etc/buildkite-agent COPY buildkite.cfg /etc/buildkite-agent/buildkite-agent.cfg COPY environment /etc/buildkite-agent/hooks/environment RUN mkdir /state/rust && chown user /state/rust USER user ENV CARGO_HOME=/state/rust/cargo ENV RUSTUP_HOME=/state/rust/rustup RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --no-modify-path --default-toolchain none WORKDIR /code/buildkite ENTRYPOINT ["/usr/bin/buildkite-agent", "start"] ================================================ FILE: .buildkite/linux/docker/build.sh ================================================ #!/bin/bash # builds an 'anki-[amd|arm]' image for the current platform # # for a cross-compile on recent Docker: # docker buildx create --use # docker run --privileged --rm tonistiigi/binfmt --install amd64 # docker buildx build --platform linux/amd64 --tag anki-amd64 . --load . common.inc DOCKER_BUILDKIT=1 docker build --tag anki-${platform} . ================================================ FILE: .buildkite/linux/docker/buildkite.cfg ================================================ name="lin-ci" tags="queue=lin-ci" build-path="/state/build" hooks-path="/etc/buildkite-agent/hooks" no-plugins=true no-local-hooks=true no-git-submodules=true ================================================ FILE: .buildkite/linux/docker/common.inc ================================================ #!/bin/bash set -e if [[ "$(uname -m)" == "x86_64" ]]; then platform="amd" else platform="arm" fi ================================================ FILE: .buildkite/linux/docker/environment ================================================ #!/bin/bash if [[ "${BUILDKITE_COMMAND}" != ".buildkite/linux/entrypoint" && "${BUILDKITE_COMMAND}" != ".buildkite/linux/release-entrypoint" ]]; then echo "Command not allowed: ${BUILDKITE_COMMAND}" exit 1 fi ================================================ FILE: .buildkite/linux/docker/run.sh ================================================ #!/bin/bash # - use './run.sh' to run in the foreground # - use './run.sh serve' to daemonize. set -e . common.inc if [ "$1" = "serve" ]; then extra_args="-d --restart always" else extra_args="-it" fi name=anki-${platform} # Stop and remove the existing container if it exists. # This doesn't delete the associated volume. if docker container inspect $name > /dev/null 2>&1; then docker stop $name || true docker container rm $name fi docker run $extra_args \ --name $name \ -v ${name}-state:/state \ -e BUILDKITE_AGENT_TOKEN \ -e BUILDKITE_AGENT_TAGS \ $name ================================================ FILE: .buildkite/linux/entrypoint ================================================ #!/bin/bash set -e export PATH="$PATH:/state/rust/cargo/bin" export BUILD_ROOT=/state/build export ONLINE_TESTS=1 echo "--- Install n2" ./tools/install-n2 echo "+++ Building and testing" ln -sf out/node_modules . if [ "$CLEAR_RUST" = "1" ]; then rm -rf $BUILD_ROOT/rust fi rm -f out/build.ninja ./ninja pylib qt check echo "--- Ensure libs importable" SKIP_RUN=1 ./run echo "--- Check Rust libs" cargo install cargo-deny@0.19.0 cargo deny check echo "--- Cleanup" rm -rf /tmp/* || true ================================================ FILE: .buildkite/linux/release-entrypoint ================================================ #!/bin/bash set -e export PATH="$PATH:/state/rust/cargo/bin" export BUILD_ROOT=/state/build export RELEASE=2 ln -sf out/node_modules . echo "--- Install n2" ./tools/install-n2 echo "+++ Building" if [ $(uname -m) = "aarch64" ]; then export PYTHONPATH=/usr/lib/python3/dist-packages ./ninja wheels:anki else ./ninja bundle fi ================================================ FILE: .buildkite/mac/entrypoint ================================================ #!/bin/bash set -e STATE=$(pwd)/../state/anki-ci mkdir -p $STATE echo "+++ Building and testing" ln -sf out/node_modules . SKIP_RUNNER_BUILD=0 BUILD_ROOT=$STATE/build ./ninja pylib qt wheels check ================================================ FILE: .buildkite/windows/entrypoint.bat ================================================ set PATH=c:\cargo\bin;%PATH% echo +++ Building and testing if exist \buildkite\state\out ( move \buildkite\state\out . ) if exist \buildkite\state\node_modules ( move \buildkite\state\node_modules . ) call tools\ninja build pylib qt check || exit /b 1 echo --- Cleanup move out \buildkite\state\ move node_modules \buildkite\state\ ================================================ FILE: .cargo/config.toml ================================================ [env] STRINGS_PY = { value = "out/pylib/anki/_fluent.py", relative = true } STRINGS_TS = { value = "out/ts/lib/generated/ftl.ts", relative = true } DESCRIPTORS_BIN = { value = "out/rslib/proto/descriptors.bin", relative = true } # build script will append .exe if necessary PROTOC = { value = "out/extracted/protoc/bin/protoc", relative = true } PYO3_NO_PYTHON = "1" MACOSX_DEPLOYMENT_TARGET = "11" PYTHONDONTWRITEBYTECODE = "1" # prevent junk files on Windows [term] color = "always" [target.'cfg(all(target_env = "msvc", target_os = "windows"))'] rustflags = ["-C", "target-feature=+crt-static"] ================================================ FILE: .config/nextest.toml ================================================ [store] dir = "out/tests/nextest" ================================================ FILE: .cursor/rules/building.md ================================================ - To build and check the project, use ./check in the root folder (or check.bat on Windows) - This will format files, then run lints and unit tests. ================================================ FILE: .cursor/rules/i18n.md ================================================ - We use the fluent system+code generation for translation. - New strings should be added to rslib/core/. Ask for the appropriate file if you're not sure. - Assuming a string addons-you-have-count has been added to addons.ftl, that string is accessible in our different languages as follows: - Python: from aqt.utils import tr; msg = tr.addons_you_have_count(count=3) - TypeScript: import * as tr from "@generated/ftl"; tr.addonsYouHaveCount({count: 3}) - Rust: collection.tr.addons_you_have_count(3) - In Qt .ui files, strings that are marked as translatable will automatically use the registered ftl strings. So a QLabel with a title 'addons_you_have_count' that is marked as translatable will automatically use the translation defined in our addons.ftl file. ================================================ FILE: .deny.toml ================================================ # all-features = true # features = [] [advisories] db-path = "~/.cargo/advisory-db" db-urls = ["https://github.com/rustsec/advisory-db"] ignore = [ # burn depends on an unmaintained package 'paste' "RUSTSEC-2024-0436", # bincode is unmaintained (via burn). Alternatives: postcard, bitcode, rkyv, wincode "RUSTSEC-2025-0141", # rustls-pemfile is unmaintained. Alternative: use rustls-pki-types directly (PemObject trait) "RUSTSEC-2025-0134", # unic-* crates are unmaintained (used for Unicode category detection). # Alternative: icu_properties "RUSTSEC-2025-0081", # unic-char-property "RUSTSEC-2025-0075", # unic-char-range (or use native Rust char ranges since 1.45.0) "RUSTSEC-2025-0080", # unic-common "RUSTSEC-2025-0094", # unic-ucd-category "RUSTSEC-2025-0098", # unic-ucd-version ] [licenses] allow = [ "MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "CDLA-Permissive-2.0", "ISC", "MPL-2.0", "BSD-2-Clause", "BSD-3-Clause", "CC0-1.0", "Unlicense", "Zlib", "Unicode-3.0", ] confidence-threshold = 0.8 # eg { allow = ["Zlib"], name = "adler32", version = "*" }, exceptions = [] [[licenses.clarify]] name = "ring" version = "*" expression = "MIT AND ISC AND OpenSSL" license-files = [ { path = "LICENSE", hash = 0xbd0eed23 }, ] [licenses.private] ignore = true [sources] unknown-registry = "warn" unknown-git = "warn" allow-registry = ["https://github.com/rust-lang/crates.io-index"] [sources.allow-org] github = ["ankitects"] [bans] multiple-versions = "allow" wildcards = "allow" highlight = "all" workspace-default-features = "allow" external-default-features = "allow" # eg { name = "ansi_term", version = "=0.11.0" }, allow = [] deny = [] # Certain crates/versions that will be skipped when doing duplicate detection. skip = [] # Similarly to `skip` allows you to skip certain crates during duplicate # detection. Unlike skip, it also includes the entire tree of transitive # dependencies starting at the specified crate, up to a certain depth, which is # by default infinite. # eg { name = "ansi_term", version = "=0.11.0", depth = 20 }, skip-tree = [] ================================================ FILE: .dockerignore ================================================ node_modules/ target/ out/ ================================================ FILE: .dprint.json ================================================ { "typescript": { "indentWidth": 4, "useBraces": "always" }, "json": { "indentWidth": 4 }, "markdown": {}, "toml": {}, "includes": ["**/*.{ts,tsx,js,jsx,cjs,mjs,json,md,toml,svelte,scss}"], "excludes": [ ".vscode", "**/node_modules", "out/**", "**/*-lock.json", "qt/aqt/data/web/js/vendor/*.js", "ftl/qt-repo", "ftl/core-repo", "ftl/usage", "licenses.json", ".dmypy.json", "target", ".mypy_cache", "extra", "ts/.svelte-kit", "ts/vite.config.ts.timestamp*" ], "plugins": [ "https://plugins.dprint.dev/typescript-0.91.6.wasm", "https://plugins.dprint.dev/json-0.19.3.wasm", "https://plugins.dprint.dev/markdown-0.17.6.wasm", "https://plugins.dprint.dev/toml-0.6.2.wasm", "https://plugins.dprint.dev/disrupted/css-0.2.3.wasm" ] } ================================================ FILE: .eslintrc.cjs ================================================ module.exports = { root: true, extends: ["eslint:recommended", "plugin:compat/recommended", "plugin:svelte/recommended"], parser: "@typescript-eslint/parser", parserOptions: { extraFileExtensions: [".svelte"], }, plugins: [ "import", "@typescript-eslint", "@typescript-eslint/eslint-plugin", ], rules: { "@typescript-eslint/ban-ts-comment": "warn", "@typescript-eslint/no-unused-vars": [ "warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, ], "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], "import/newline-after-import": "warn", "import/no-useless-path-segments": "warn", "prefer-const": "warn", "no-nested-ternary": "warn", "curly": "error", "@typescript-eslint/consistent-type-imports": "error", }, overrides: [ { files: "**/*.ts", extends: [ "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended", ], rules: { "@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/no-explicit-any": "off", }, }, { files: ["*.svelte"], parser: "svelte-eslint-parser", parserOptions: { parser: "@typescript-eslint/parser", }, rules: { "svelte/no-at-html-tags": "off", "svelte/valid-compile": ["error", { "ignoreWarnings": true }], "@typescript-eslint/no-explicit-any": "off", }, }, ], env: { browser: true, es2020: true }, ignorePatterns: ["backend_proto.d.ts", "*.svelte.d.ts", "vendor", "extra/*", "vite.config.ts", "hooks.client.js"], globals: { globalThis: false, NodeListOf: false, $$Generic: "readonly", }, }; ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf *.ftl -linguist-detectable cargo/remote/* linguist-vendored ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.md ================================================ --- name: Developer Tasks about: For bug reports, suggestions and support, please see the options below. title: "" labels: "" assignees: "" --- - Have a question or feature suggestion? - Problems building/running on your system? - Not 100% sure you've found a bug? If so, please post on https://forums.ankiweb.net/ instead. This issue tracker is intended primarily to track development tasks, and it is easier to provide support over on the forums. Please make sure you read the following pages before you post there: - https://faqs.ankiweb.net/when-problems-occur.html - https://faqs.ankiweb.net/getting-help.html If you post questions, suggestions, or vague bug reports here, please do not be offended if we close your ticket without replying. If in doubt, please post on https://forums.ankiweb.net/ instead. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Bug Reports url: https://forums.ankiweb.net about: This issue tracker is for developers. Please report any bugs you encounter over on the user forum, and they will be triaged there. - name: Questions/Support url: https://forums.ankiweb.net about: If you have a question or need support, please post on the user forum. - name: Feature Requests/Suggestions url: https://forums.ankiweb.net/c/suggestions/17 about: Please post suggestions and feature requests on our user forum. ================================================ FILE: .github/actions/setup-anki/action.yml ================================================ name: Setup Anki Build Environment description: Install system dependencies, Rust toolchain, uv, and n2 runs: using: composite steps: - name: Install Linux system dependencies if: runner.os == 'Linux' shell: bash run: | sudo apt-get update sudo apt-get install --yes --no-install-recommends \ autoconf \ bash \ curl \ findutils \ g++ \ gcc \ git \ grep \ libdbus-1-3 \ libegl1 \ libfontconfig1 \ libgl1 \ libgstreamer-gl1.0-0 \ libgstreamer-plugins-base1.0 \ libgstreamer1.0-0 \ libnss3 \ libpulse-mainloop-glib0 \ libssl-dev \ libxcomposite1 \ libxcursor1 \ libxdamage1 \ libxi6 \ libxkbcommon-x11-0 \ libxkbcommon0 \ libxkbfile1 \ libxrandr2 \ libxrender1 \ libxtst6 \ make \ pkg-config \ portaudio19-dev \ python3-dev \ rsync \ unzip \ zstd - name: Install rsync (Windows) if: runner.os == 'Windows' shell: bash run: | /c/msys64/usr/bin/pacman.exe -Sy --noconfirm rsync echo "C:\msys64\usr\bin" >> "$GITHUB_PATH" # Reads toolchain version from rust-toolchain.toml automatically. - name: Install Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@v1 with: components: clippy cache: false # ci.yml manages its own cargo cache rustflags: "" # don't inject -D warnings; the build system handles this - name: Install uv id: setup-uv uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-python: true prune-cache: ${{ runner.os != 'Windows' }} # Windows file-locking breaks cache pruning # Set UV_CACHE_DIR so both setup-uv's binary and the build system's # downloaded uv share the same dependency cache for faster builds. - name: Configure uv cache shell: bash run: | CACHE_DIR="${{ steps.setup-uv.outputs.cache-dir }}" if [ -n "$CACHE_DIR" ]; then echo "UV_CACHE_DIR=$CACHE_DIR" >> "$GITHUB_ENV" fi # UV_BINARY tells the build system to use our uv instead of downloading # its own (see build/ninja_gen/src/python.rs). Linux-only because on # macOS ARM the launcher needs the downloaded archive to build a universal # binary via lipo (see build/configure/src/launcher.rs). # On macOS/Windows, the downloaded uv will still use the shared cache. - name: Set UV_BINARY (Linux) if: runner.os == 'Linux' shell: bash run: echo "UV_BINARY=${{ steps.setup-uv.outputs.uv-path }}" >> "$GITHUB_ENV" # Ensure cargo-installed tools like n2 are discoverable. - name: Add CARGO_HOME/bin to PATH shell: bash run: echo "${CARGO_HOME:-$HOME/.cargo}/bin" >> "$GITHUB_PATH" - name: Install n2 shell: bash run: | if ! command -v n2 &>/dev/null; then tools/install-n2 fi ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [main] pull_request: branches: [main] types: [opened, synchronize, reopened, labeled] concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: minilints: runs-on: ubuntu-24.04 steps: # Check out PR head commit so minilints can verify the author is in CONTRIBUTORS. - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Install Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Run minilints run: cargo run -p minilints -- check /tmp/minilints.stamp env: CONTRIBUTORS_BYPASS_EMAILS: ${{ vars.CONTRIBUTORS_BYPASS_EMAILS }} # Lightweight formatting checks (no build outputs needed). # Uses individual tool installs instead of setup-anki to stay fast. format: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: Install Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Install uv uses: astral-sh/setup-uv@v7 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Install n2 run: tools/install-n2 env: RUSTFLAGS: "--cap-lints warn" - name: Install just uses: extractions/setup-just@v3 - name: Run format checks run: just fmt # Linux runs on every PR and push to main. check-linux: runs-on: ubuntu-24.04 name: check (linux) steps: - uses: actions/checkout@v4 - name: Restore cargo cache uses: actions/cache@v4 with: path: | ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db ~/.cargo/bin ~/.cargo/.crates.toml ~/.cargo/.crates2.json key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} restore-keys: cargo-${{ runner.os }}- - name: Restore build output cache uses: actions/cache/restore@v4 with: path: out key: build-Linux-${{ github.event.pull_request.number || 'main' }}-${{ github.run_id }} restore-keys: | build-Linux-${{ github.event.pull_request.number || 'main' }}- build-Linux-main- build-Linux- - name: Setup build environment uses: ./.github/actions/setup-anki - name: Install just uses: extractions/setup-just@v3 - name: Symlink node_modules run: ln -sf out/node_modules . - name: Build, lint, and test env: ONLINE_TESTS: "1" run: | just build just lint just test - name: Ensure libs importable env: SKIP_RUN: "1" run: ./run - name: Check Rust dependencies uses: EmbarkStudios/cargo-deny-action@v2 # out/pyenv contains a venv with absolute Python paths that break # across runs. out/build.ninja is regenerated by configure each time. # Remove both before saving so the cache stays portable. - name: Clean non-cacheable state if: always() shell: bash run: | rm -rf out/pyenv rm -f out/build.ninja - name: Save build output cache if: always() uses: actions/cache/save@v4 with: path: out key: build-Linux-${{ github.event.pull_request.number || 'main' }}-${{ github.run_id }} # Runs on pushes to main or on PRs with the check:macos label. check-macos: if: >- github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'check:macos') runs-on: macos-latest name: check (macos) steps: - uses: actions/checkout@v4 - name: Restore cargo cache uses: actions/cache@v4 with: path: | ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db ~/.cargo/bin ~/.cargo/.crates.toml ~/.cargo/.crates2.json key: cargo-macOS-${{ hashFiles('Cargo.lock') }} restore-keys: cargo-macOS- - name: Restore build output cache uses: actions/cache/restore@v4 with: path: out key: build-macOS-${{ github.event.pull_request.number || 'main' }}-${{ github.run_id }} restore-keys: | build-macOS-${{ github.event.pull_request.number || 'main' }}- build-macOS-main- build-macOS- - name: Setup build environment uses: ./.github/actions/setup-anki - name: Install just uses: extractions/setup-just@v3 - name: Symlink node_modules run: ln -sf out/node_modules . - name: Build, lint, and test run: | just build just wheels just lint just test - name: Clean non-cacheable state if: always() shell: bash run: | rm -rf out/pyenv rm -f out/build.ninja - name: Save build output cache if: always() uses: actions/cache/save@v4 with: path: out key: build-macOS-${{ github.event.pull_request.number || 'main' }}-${{ github.run_id }} # Runs on pushes to main or on PRs with the check:windows label. check-windows: if: >- github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'check:windows') runs-on: windows-latest name: check (windows) # Colocate CARGO_HOME and TEMP on D: to keep all I/O on the same fast # local disk. env: CARGO_HOME: D:\cargo-home TEMP: D:\tmp TMP: D:\tmp steps: - uses: actions/checkout@v4 - name: Prepare D:\ directories shell: bash run: mkdir -p /d/cargo-home /d/tmp - name: Restore cargo cache uses: actions/cache@v4 with: path: | D:\cargo-home\registry\index D:\cargo-home\registry\cache D:\cargo-home\git\db D:\cargo-home\bin D:\cargo-home\.crates.toml D:\cargo-home\.crates2.json key: cargo-Windows-${{ hashFiles('Cargo.lock') }} restore-keys: cargo-Windows- - name: Restore build output cache uses: actions/cache/restore@v4 with: path: out key: build-Windows-${{ github.event.pull_request.number || 'main' }}-${{ github.run_id }} restore-keys: | build-Windows-${{ github.event.pull_request.number || 'main' }}- build-Windows-main- build-Windows- - name: Setup build environment uses: ./.github/actions/setup-anki - name: Install just uses: extractions/setup-just@v3 - name: Build, lint, and test run: | just build just lint just test # Also remove node_modules on Windows — file-locking corrupts the cache. - name: Clean non-cacheable state if: always() shell: bash run: | rm -rf out/pyenv out/node_modules rm -f out/build.ninja - name: Save build output cache if: always() uses: actions/cache/save@v4 with: path: out key: build-Windows-${{ github.event.pull_request.number || 'main' }}-${{ github.run_id }} ================================================ FILE: .gitignore ================================================ __pycache__ .mypy_cache .DS_Store anki.prof target /user.bazelrc .dmypy.json /.idea/ /.vscode /.bazel /windows.bazelrc /out node_modules .n2_db .ninja_log .ninja_deps /extra yarn-error.log ts/.svelte-kit .yarn .claude/settings.local.json .claude/user.md ================================================ FILE: .gitmodules ================================================ [submodule "ftl/core-repo"] path = ftl/core-repo url = https://github.com/ankitects/anki-core-i18n.git shallow = true [submodule "ftl/qt-repo"] path = ftl/qt-repo url = https://github.com/ankitects/anki-desktop-ftl.git shallow = true ================================================ FILE: .idea.dist/repo.iml ================================================ ================================================ FILE: .mypy.ini ================================================ [mypy] python_version = 3.9 pretty = False strict_optional = False show_error_codes = True check_untyped_defs = True disallow_untyped_decorators = True warn_redundant_casts = True warn_unused_configs = True strict_equality = True namespace_packages = True explicit_package_bases = True mypy_path = pylib, out/pylib, qt, out/qt, ftl, pylib/tools, python exclude = (pylib/anki/_vendor) [mypy-anki.*] disallow_untyped_defs = True [mypy-anki.importing.*] disallow_untyped_defs = False [mypy-anki.exporting] disallow_untyped_defs = False [mypy-aqt] strict_optional = True [mypy-aqt.browser.*] strict_optional = True [mypy-aqt.data.*] strict_optional = True [mypy-aqt.forms.*] strict_optional = True [mypy-aqt.import_export.*] strict_optional = True [mypy-aqt.operations.*] strict_optional = True [mypy-aqt.editor] strict_optional = True [mypy-aqt.importing] strict_optional = True [mypy-aqt.preferences] strict_optional = True [mypy-aqt.overview] strict_optional = True [mypy-aqt.customstudy] strict_optional = True [mypy-aqt.taglimit] strict_optional = True [mypy-aqt.modelchooser] strict_optional = True [mypy-aqt.deckdescription] strict_optional = True [mypy-aqt.deckbrowser] strict_optional = True [mypy-aqt.studydeck] strict_optional = True [mypy-aqt.tts] strict_optional = True [mypy-aqt.mediasrv] strict_optional = True [mypy-aqt.changenotetype] strict_optional = True [mypy-aqt.clayout] strict_optional = True [mypy-aqt.fields] strict_optional = True [mypy-aqt.filtered_deck] strict_optional = True [mypy-aqt.editcurrent] strict_optional = True [mypy-aqt.deckoptions] strict_optional = True [mypy-aqt.notetypechooser] strict_optional = True [mypy-aqt.stats] strict_optional = True [mypy-aqt.switch] strict_optional = True [mypy-aqt.debug_console] strict_optional = True [mypy-aqt.emptycards] strict_optional = True [mypy-aqt.flags] strict_optional = True [mypy-aqt.mediacheck] strict_optional = True [mypy-aqt.theme] strict_optional = True [mypy-aqt.toolbar] strict_optional = True [mypy-aqt.deckchooser] strict_optional = True [mypy-aqt.about] strict_optional = True [mypy-aqt.webview] strict_optional = True [mypy-aqt.mediasync] strict_optional = True [mypy-aqt.package] strict_optional = True [mypy-aqt.progress] strict_optional = True [mypy-aqt.tagedit] strict_optional = True [mypy-aqt.utils] strict_optional = True [mypy-aqt.sync] strict_optional = True [mypy-anki.scheduler.base] strict_optional = True [mypy-anki._backend.rsbridge] ignore_missing_imports = True [mypy-anki._vendor.stringcase] disallow_untyped_defs = False [mypy-stringcase] ignore_missing_imports = True [mypy-aqt.mpv] disallow_untyped_defs = False ignore_errors = True [mypy-aqt.winpaths] disallow_untyped_defs = False [mypy-win32file] ignore_missing_imports = True [mypy-win32pipe] ignore_missing_imports = True [mypy-pywintypes] ignore_missing_imports = True [mypy-winerror] ignore_missing_imports = True [mypy-distro] ignore_missing_imports = True [mypy-win32api] ignore_missing_imports = True [mypy-xml.dom] ignore_missing_imports = True [mypy-psutil] ignore_missing_imports = True [mypy-bs4] ignore_missing_imports = True [mypy-fluent.*] ignore_missing_imports = True [mypy-compare_locales.*] ignore_missing_imports = True [mypy-PyQt5.*] ignore_errors = True ignore_missing_imports = True [mypy-send2trash] ignore_missing_imports = True [mypy-win32com.*] ignore_missing_imports = True [mypy-jsonschema.*] ignore_missing_imports = True [mypy-socks] ignore_missing_imports = True [mypy-pythoncom] ignore_missing_imports = True [mypy-snakeviz.*] ignore_missing_imports = True [mypy-wheel.*] ignore_missing_imports = True [mypy-pip_system_certs.*] ignore_missing_imports = True [mypy-anki_audio] ignore_missing_imports = True ================================================ FILE: .prettierrc ================================================ { "trailingComma": "all", "printWidth": 88, "tabWidth": 4, "semi": true, "htmlWhitespaceSensitivity": "ignore", "plugins": ["prettier-plugin-svelte"] } ================================================ FILE: .python-version ================================================ 3.13.5 ================================================ FILE: .ruff.toml ================================================ lint.select = [ "E", # pycodestyle errors "F", # Pyflakes errors "PL", # Pylint rules "I", # Isort rules "ARG", # "UP", # pyupgrade # "B", # flake8-bugbear # "SIM", # flake8-simplify ] extend-exclude = ["*_pb2.py", "*_pb2.pyi"] lint.ignore = [ # Docstring rules (missing-*-docstring in pylint) "D100", # Missing docstring in public module "D101", # Missing docstring in public class "D103", # Missing docstring in public function # Import rules (wrong-import-* in pylint) "E402", # Module level import not at top of file "E501", # Line too long # pycodestyle rules "E741", # ambiguous-variable-name # Comment rules (fixme in pylint) "FIX002", # Line contains TODO # Pyflakes rules "F402", # import-shadowed-by-loop-var "F403", # undefined-local-with-import-star "F405", # undefined-local-with-import-star-usage # Naming rules (invalid-name in pylint) "N801", # Class name should use CapWords convention "N802", # Function name should be lowercase "N803", # Argument name should be lowercase "N806", # Variable in function should be lowercase "N811", # Constant imported as non-constant "N812", # Lowercase imported as non-lowercase "N813", # Camelcase imported as lowercase "N814", # Camelcase imported as constant "N815", # Variable in class scope should not be mixedCase "N816", # Variable in global scope should not be mixedCase "N817", # CamelCase imported as acronym "N818", # Error suffix in exception names # Pylint rules "PLW0603", # global-statement "PLW2901", # redefined-loop-name "PLC0415", # import-outside-top-level "PLR2004", # magic-value-comparison # Exception handling (broad-except, bare-except in pylint) "BLE001", # Do not catch blind exception # Argument rules (unused-argument in pylint) "ARG001", # Unused function argument "ARG002", # Unused method argument "ARG005", # Unused lambda argument # Access rules (protected-access in pylint) "SLF001", # Private member accessed # String formatting (consider-using-f-string in pylint) "UP032", # Use f-string instead of format call # Exception rules (broad-exception-raised in pylint) "TRY301", # Abstract raise to an inner function # Builtin shadowing (redefined-builtin in pylint) "A001", # Variable shadows a Python builtin "A002", # Argument shadows a Python builtin "A003", # Class attribute shadows a Python builtin ] [lint.per-file-ignores] "**/anki/*_pb2.py" = ["ALL"] [lint.pep8-naming] ignore-names = ["id", "tr", "db", "ok", "ip"] [lint.pylint] max-args = 12 max-returns = 10 max-branches = 35 max-statements = 125 [lint.isort] known-first-party = ["anki", "aqt", "tests"] ================================================ FILE: .rustfmt-empty.toml ================================================ ================================================ FILE: .rustfmt.toml ================================================ # These settings are not supported on stable Rust, and are ignored by the ninja # build script - to use them you need to run 'cargo +nightly fmt' group_imports = "StdExternalCrate" imports_granularity = "Item" imports_layout = "Vertical" wrap_comments = true ================================================ FILE: .version ================================================ 25.09.2 ================================================ FILE: .vscode.dist/extensions.json ================================================ { "recommendations": [ "dprint.dprint", "ms-python.python", "charliermarsh.ruff", "rust-lang.rust-analyzer", "svelte.svelte-vscode", "zxh404.vscode-proto3", "usernamehw.errorlens", "eamodio.gitlens" ] } ================================================ FILE: .vscode.dist/launch.json ================================================ { // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "Run", "type": "debugpy", "request": "launch", "program": "tools/run.py", "args": [ // "-p", // "My test profile" ], "console": "integratedTerminal", "cwd": "${workspaceFolder}", "python": "${workspaceFolder}/out/pyenv/bin/python", "windows": { "python": "${workspaceFolder}/out/pyenv/scripts/python.exe" }, "env": { "PYTHONWARNINGS": "default", "PYTHONPYCACHEPREFIX": "out/pycache", "ANKIDEV": "1", "QTWEBENGINE_REMOTE_DEBUGGING": "8080", "QTWEBENGINE_CHROMIUM_FLAGS": "--remote-allow-origins=http://localhost:8080", "RUST_BACKTRACE": "1", // "TRACESQL": "1", // "HMR": "1", "ANKI_API_PORT": "40000", "ANKI_API_HOST": "127.0.0.1" }, "justMyCode": true, "preLaunchTask": "ninja" } ] } ================================================ FILE: .vscode.dist/settings.json ================================================ { "editor.formatOnSave": true, "[python]": { "editor.codeActionsOnSave": { "source.organizeImports": "explicit" } }, "files.watcherExclude": { "**/.git/objects/**": true, "**/.git/subtree-cache/**": true, "**/node_modules/*/**": true, ".bazel/**": true }, "python.analysis.extraPaths": [ "./pylib", "out/pylib", "./pylib/anki/_vendor", "out/qt", "qt" ], "python.formatting.provider": "charliermarsh.ruff", "python.linting.mypyEnabled": false, "python.analysis.diagnosticSeverityOverrides": { "reportMissingModuleSource": "none" }, "rust-analyzer.check.allTargets": false, "rust-analyzer.files.excludeDirs": [".bazel", "node_modules"], "rust-analyzer.procMacro.enable": true, // this formats 'use' blocks in a nicer way, but requires you to run // 'rustup install nightly'. "rust-analyzer.rustfmt.extraArgs": ["+nightly"], "search.exclude": { "**/node_modules": true, ".bazel/**": true }, "rust-analyzer.cargo.buildScripts.enable": true, "python.analysis.typeCheckingMode": "off", "python.analysis.exclude": [ "out/launcher/**" ], "terminal.integrated.env.windows": { "PATH": "c:\\msys64\\usr\\bin;${env:Path}" } } ================================================ FILE: .vscode.dist/tasks.json ================================================ { "version": "2.0.0", "tasks": [ { "label": "ninja", "command": "ninja", "args": [ "pylib", "qt" ], "windows": { "command": "tools/ninja.bat", "args": [ "pylib", "qt" ] } } ] } ================================================ FILE: .yarnrc.yml ================================================ nodeLinker: node-modules enableScripts: false ================================================ FILE: CLAUDE.md ================================================ # Claude Code Configuration ## Project Overview Anki is a spaced repetition flashcard program with a multi-layered architecture. Main components: - Web frontend: Svelte/TypeScript in ts/ - PyQt GUI, which embeds the web components in aqt/ - Python library which wraps our rust Layer (pylib/, with Rust module in pylib/rsbridge) - Core Rust layer in rslib/ - Protobuf definitions in proto/ that are used by the different layers to talk to each other. ## Building/checking ./check (check.bat) will format the code and run the main build & checks. Please do this as a final step before marking a task as completed. ## Quick iteration During development, you can build/check subsections of our code: - Rust: 'cargo check' - Python: './tools/dmypy', and if wheel-related, './ninja wheels' - TypeScript/Svelte: './ninja check:svelte' Be mindful that some changes (such as modifications to .proto files) may need a full build with './check' first. ## Build tooling './check' and './ninja' invoke our build system, which is implemented in build/. It takes care of downloading required deps and invoking our build steps. ## Translations ftl/ contains our Fluent translation files. We have scripts in rslib/i18n to auto-generate an API for Rust, TypeScript and Python so that our code can access the translations in a type-safe manner. Changes should be made to ftl/core or ftl/qt. Except for features specific to our Qt interface, prefer the core module. When adding new strings, confirm the appropriate ftl file first, and try to match the existing style. ## Protobuf and IPC Our build scripts use the .proto files to define our Rust library's non-Rust API. pylib/rsbridge exposes that API, and _backend.py exposes snake_case methods for each protobuf RPC that call into the API. Similar tooling creates a @generated/backend TypeScript module for communicating with the Rust backend (which happens over POST requests). ## Fixing errors When dealing with build errors or failing tests, invoke 'check' or one of the quick iteration commands regularly. This helps verify your changes are correct. To locate other instances of a problem, run the check again - don't attempt to grep the codebase. ## Ignores The files in out/ are auto-generated. Mostly you should ignore that folder, though you may sometimes find it useful to view out/{pylib/anki,qt/_aqt,ts/lib/generated} when dealing with cross-language communication or our other generated sourcecode. ## Launcher/installer The code for our launcher is in qt/launcher, with separate code for each platform. ## Rust dependencies Prefer adding to the root workspace, and using dep.workspace = true in the individual Rust project. ## Rust utilities rslib/{process,io} contain some helpers for file and process operations, which provide better error messages/context and some ergonomics. Use them when possible. ## Rust error handling in rslib, use error/mod.rs's AnkiError/Result and snafu. In our other Rust modules, prefer anyhow + additional context where appropriate. Unwrapping in build scripts/tests is fine. ## Individual preferences See @.claude/user.md ================================================ FILE: CONTRIBUTORS ================================================ If you have made changes to Anki's AGPL code, you are welcome to distribute the changed code under the AGPL license. If you would like to contribute your code back to the official release, we ask that you license your contributions under the BSD 3 clause license. Portions of the code are also used in AnkiWeb and AnkiMobile, and accepting contributions under an AGPL license would mean we could no longer use the code we have written in those projects. In your first pull request, please add your name below. By adding your name to this file, you assert that any code you contribute to the Anki project is licensed under the BSD 3 clause license. If any pull request you make contains code that you don't own the copyright to, you agree to make that clear when submitting the request. When submitting a pull request, GitHub Actions will check that the Git email you are submitting from matches the one you used to edit this file. A common issue is adding yourself to this file using the username on your computer, but then using GitHub to rebase or edit a pull request online. This will result in your Git email becoming something like user@noreply.github.com. To prevent the automatic check from failing, you can edit this file again using GitHub's online editor, making a trivial edit like adding a space after your name, and then pull requests will work regardless of whether you create them using your computer or GitHub's online interface. For users who previously confirmed the license of their contributions on the support site, it would be great if you could add your name below as well. ******************** AMBOSS MD Inc. Aristotelis P. Erez Volk zjosua Yngve Hoiseth Arthur Milchior Ijgnd Yoonchae Lee Evandro Coan Alan Du Yuchen Lei Henry Tang Simone Gaiarin Rai (Michal Pokorny) Zeno Gantner Henrik Giesel Michał Bartoszkiewicz Sander Santema Thomas Brownback Andrew Gaul kenden Emil Hamrin Nickolay Yudin neitrinoweb Andreas Reis Matt Krump Alexander Presnyakov Abdo aplaice phwoo Soren Bjornstad Aleksa Sarai Jakub Kaczmarzyk Akshara Balachandra lukkea David Allison David Allison <62114487+david-allison@users.noreply.github.com> Tsung-Han Yu Piotr Kubowicz RumovZ Cecini Krish Shah ianki rye761 Guillem Palau Salvà Meredith Derecho Daniel Wallgren Kerrick Staley Maksim Abramchuk Benjamin Kulnik Shaun Ren Ryan Greenblatt Matthias Metelka qubist-pixel-ux cherryblossom Hikaru Yoshiga Thore Tyborski Alexander Giorev Ren Tatsumoto lolilolicon Gesa Stupperich git9527 Vova Selin qxo <49526356@qq.com> Spooghetti420 Danish Prakash Araceli Yanez Sam Bradshaw gnnoh Sachin Govind Bruce Harris Patric Cunha Brayan Oliveira <69634269+BrayanDSO@users.noreply.github.com> Luka Warren wisherhxl dobefore <1432338032@qq.com> Bart Louwers Sam Penny Yutsuten Zoom TRIAEIOU Stefan Kangas Fabricio Duarte Mani Kaben Nanlohy Tobias Predel Daniel Tang Jack Pearson yellowjello Ingemar Berg Ben Kerman Euan Kemp Kieran Black XeR mgrottenthaler Austin Siew Joel Koen Christopher Woggon Kavel Rao Ben Yip mmjang <752515918@qq.com> shunlog 3ter Derek Dang Luc Mcgrady Kehinde Adeleke Marko Juhanne Gabriel Heinatz Monty Evans Nil Admirari Michael Winkworth Mateusz Wojewoda Jarrett Ye Sam Waechter Michael Eliachevitch Carlo Quick Dominique Martinet chandraiyengar user1823 <92206575+user1823@users.noreply.github.com> Gustaf Carefall virinci snowtimeglass brishtibheja <136738526+brishtibheja@users.noreply.github.com> Ben Olson Akash Reddy Lucio Sauer Gustavo Sales Shawn M Moore Marko Sisovic Viktor Ricci Harvey Randall Pedro Lameiras Kai Knoblich Lucas Scharenbroch Antonio Cavallo Han Yeong-woo Jean Khawand Pedro Schreiber Foxy_null Arbyste Vasll laalsaas ijqq AntoineQ1 jthulhu Escape0707 Loudwig Wu Yi-Wei RRomeroJr <117.rromero@gmail.com> Xidorn Quan Alexander Bocken James Elmore Ian Samir Yep Manzano David Culley <6276049+davidculley@users.noreply.github.com> Rastislav Kish jake Expertium Christian Donat Asuka Minato Dillon Baldwin Voczi Ben Nguyen <105088397+bpnguyen107@users.noreply.github.com> Themis Demetriades Luke Bartholomew Gregory Abrasaldo Taylor Obyen <162023405+taylorobyen@users.noreply.github.com> Kris Cherven twwn Cy Pokhrel Park Hyunwoo Tomas Fabrizio Orsi Dongjin Ouyang <1113117424@qq.com> Sawan Sunar hideo aoyama Ross Brown 🦙 Lukas Sommer Luca Auer Niclas Heinz Omar Kohl David Elizalde beyondcompute Yuki wackbyte GithubAnon0000 Mike Hardy Danika_Dakika Mumtaz Hajjo Alrifai Thomas Graves Jakub Fidler Valerie Enfys Julien Chol ikkz derivativeoflog7 rreemmii-dev babofitos Jonathan Schoreels JL710 Matt Brubeck Yaoliang Chen KolbyML Adnane Taghi Spiritual Father Emmanuel Ferdman Sunong2008 Marvin Kopf Kevin Nakamura Bradley Szoke jcznk Thomas Rixen Siyuan Mattuwu Yan Lee Doughty <32392044+leedoughty@users.noreply.github.com> memchr Max Romanowski Aldlss Hanna Nilsén Elias Johansson Lara Toby Penner Danilo Spillebeen Matbe766 Amanda Sternberg arold0 nav1s Ranjit Odedra Eltaurus jariji Francisco Esteva Junia Mannervik Emma Plante SelfishPig defkorean Michael Lappas Brett Schwartz Lovro Boban Yuuki Gabriele Patriarca SecretX Daniel Pechersky fernandolins <1887601+fernandolins@users.noreply.github.com> ******************** The text of the 3 clause BSD license follows: Contributions copyright the above contributors, 2010-Present. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: Cargo.toml ================================================ [workspace.package] version = "0.0.0" authors = ["Ankitects Pty Ltd and contributors "] edition = "2021" license = "AGPL-3.0-or-later" rust-version = "1.80" [workspace] members = [ "build/configure", "build/ninja_gen", "build/runner", "ftl", "pylib/rsbridge", "qt/launcher", "rslib", "rslib/i18n", "rslib/io", "rslib/linkchecker", "rslib/process", "rslib/proto", "rslib/sync", "tools/minilints", ] resolver = "2" [workspace.dependencies.percent-encoding-iri] git = "https://github.com/ankitects/rust-url.git" rev = "bb930b8d089f4d30d7d19c12e54e66191de47b88" [workspace.dependencies.linkcheck] git = "https://github.com/ankitects/linkcheck.git" rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca" [workspace.dependencies.fsrs] version = "5.2.0" # git = "https://github.com/open-spaced-repetition/fsrs-rs.git" # path = "../open-spaced-repetition/fsrs-rs" [workspace.dependencies] # local anki = { path = "rslib" } anki_i18n = { path = "rslib/i18n" } anki_io = { path = "rslib/io" } anki_process = { path = "rslib/process" } anki_proto = { path = "rslib/proto" } anki_proto_gen = { path = "rslib/proto_gen" } ninja_gen = { "path" = "build/ninja_gen" } # pinned unicase = "=2.6.0" # any changes could invalidate sqlite indexes # normal ammonia = "4.1.2" anyhow = "1.0.98" async-compression = { version = "0.4.24", features = ["zstd", "tokio"] } async-stream = "0.3.6" async-trait = "0.1.88" axum = { version = "0.8.4", features = ["multipart", "macros"] } axum-client-ip = "1.1.3" axum-extra = { version = "0.10.1", features = ["typed-header"] } bitflags = "2.9.1" blake3 = "1.8.2" bytes = "1.11.1" camino = "1.1.10" chrono = { version = "0.4.41", default-features = false, features = ["std", "clock"] } clap = { version = "4.5.40", features = ["derive"] } coarsetime = "0.1.36" convert_case = "0.8.0" criterion = { version = "0.6.0" } csv = "1.3.1" data-encoding = "2.9.0" difflib = "0.4.0" dirs = "6.0.0" dunce = "1.0.5" embed-resource = "3.0.4" envy = "0.4.2" flate2 = "1.1.2" fluent = "0.17.0" fluent-bundle = "0.16.0" fluent-syntax = "0.12.0" fnv = "1.0.7" futures = "0.3.31" globset = "0.4.16" hex = "0.4.3" htmlescape = "0.3.1" hyper = "1" id_tree = "1.8.0" inflections = "1.1.1" intl-memoizer = "0.5.3" itertools = "0.14.0" junction = "1.2.0" libc = "0.2" libc-stdhandle = "0.1" locale_config = "0.3.0" maplit = "1.0.2" nom = "8.0.0" num-format = "0.4.4" num_cpus = "1.17.0" num_enum = "0.7.3" once_cell = "1.21.3" pbkdf2 = { version = "0.12", features = ["simple"] } permutation = "0.4.1" phf = { version = "0.11.3", features = ["macros"] } pin-project = "1.1.10" prettyplease = "0.2.34" prost = "0.13" prost-build = "0.13" prost-reflect = "0.14.7" prost-types = "0.13" pulldown-cmark = "0.13.0" pyo3 = { version = "0.25.1", features = ["extension-module", "abi3", "abi3-py39"] } rand = "0.9.1" rayon = "1.10.0" regex = "1.11.1" reqwest = { version = "0.12.20", default-features = false, features = ["json", "socks", "stream", "multipart"] } rusqlite = { version = "0.36.0", features = ["trace", "functions", "collation", "bundled"] } rustls-pemfile = "2.2.0" scopeguard = "1.2.0" serde = { version = "1.0.219", features = ["derive"] } serde-aux = "4.7.0" serde_json = "1.0.140" serde_repr = "0.1.20" serde_tuple = "1.1.0" sha1 = "0.10.6" sha2 = { version = "0.10.9" } snafu = { version = "0.8.6", features = ["rust_1_61"] } strum = { version = "0.27.1", features = ["derive"] } syn = { version = "2.0.103", features = ["parsing", "printing"] } tar = "0.4.44" tempfile = "3.20.0" termcolor = "1.4.1" tokio = { version = "1.45", features = ["fs", "rt-multi-thread", "macros", "signal"] } tokio-util = { version = "0.7.15", features = ["io"] } tower-http = { version = "0.6.6", features = ["trace"] } tracing = { version = "0.1.41", features = ["max_level_trace", "release_max_level_debug"] } tracing-appender = "0.2.3" tracing-subscriber = { version = "0.3.20", features = ["fmt", "env-filter"] } unic-langid = { version = "0.9.6", features = ["macros"] } unic-ucd-category = "0.9.0" unicode-normalization = "0.1.24" walkdir = "2.5.0" which = "8.0.0" widestring = "1.1.0" winapi = { version = "0.3", features = ["wincon", "winreg"] } windows = { version = "0.61.3", features = ["Media_SpeechSynthesis", "Media_Core", "Foundation_Collections", "Storage_Streams", "Win32_System_Console", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_Foundation", "Win32_UI_Shell", "Wdk_System_SystemServices"] } wiremock = "0.6.3" xz2 = "0.1.7" zip = { version = "4.1.0", default-features = false, features = ["deflate", "time"] } zstd = { version = "0.13.3", features = ["zstdmt"] } # Apply mild optimizations to our dependencies in dev mode, which among other things # improves sha2 performance by about 21x. Opt 1 chosen due to # https://doc.rust-lang.org/cargo/reference/profiles.html#overrides-and-generics. This # applies to the dependencies of unit tests as well. [profile.dev.package."*"] opt-level = 1 debug = 0 [profile.dev.package.anki_i18n] opt-level = 1 debug = 0 [profile.dev.package.anki_proto] opt-level = 1 debug = 0 # Debug info off by default, which speeds up incremental builds and produces a considerably # smaller library. [profile.dev.package.anki] debug = 0 [profile.dev.package.rsbridge] debug = 0 [profile.release-lto] inherits = "release" lto = true ================================================ FILE: LICENSE ================================================ Anki is licensed under the GNU Affero General Public License, version 3 or later, with portions contributed by Anki users licensed under the BSD-3 license (see CONTRIBUTORS). The following included source code items use a license other than AGPL3: In the pylib folder: * statsbg.py: CC BY 4.0. In the qt folder: * Anki's translations are a mix of BSD and public domain. * mpv.py: MIT. * winpaths.py: MIT. * MathJax: Apache 2. * jQuery and jQuery-UI: MIT. * plot.js: MIT. * protobuf.js: BSD 3 clause The above list only covers the source code that is vendored in this repository. Binary distributions also include copies of Qt translation files (LGPL), and all of the Python, Rust and Javascript libraries that this code references. Anki's logo is copyright Alex Fraser, and is licensed under the AGPL3 like the rest of Anki's code. The logo is also available under a limited alternative license for inclusion in books, blogs, videos and so on. If the following conditions are met, you may use the logo in your work without the need to license your work under an AGPL3-compatible license: * The logo must be used to refer to Anki, AnkiWeb, AnkiMobile or AnkiDroid, and a link to https://apps.ankiweb.net must be provided. When your content is focused specifically on AnkiDroid, a link to https://play.google.com/store/apps/details?id=com.ichi2.anki&hl=en may be provided instead of the first link. * The work must make it clear that the text/video/etc you are publishing is your own content and not something originating from the Anki project. * The logo must be used unmodified - no cropping, changing of colours or adding or deleting content is allowed. You may resize the image provided the horizontal and vertical dimensions are resized equally. ================================================ FILE: README.md ================================================ # Anki® [![Build status](https://badge.buildkite.com/c9edf020a4aec976f9835e54751cc5409d843adbb66d043bd3.svg?branch=main)](https://buildkite.com/ankitects/anki-ci) This repo contains the source code for the computer version of [Anki](https://apps.ankiweb.net). # About Anki is a spaced repetition program. Please see the [website](https://apps.ankiweb.net) to learn more. # Getting Started ### Anki Betas If you'd like to try development builds of Anki but don't feel comfortable building the code, please see [Anki betas](https://betas.ankiweb.net/) ### Developing For more information on building and developing, please see [Development](./docs/development.md). ### Contributing Want to contribute to Anki? Check out the [Contribution Guidelines](./docs/contributing.md). ### Anki Contributors [CONTRIBUTORS](./CONTRIBUTORS) # License Anki's license: [LICENSE](./LICENSE) ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting a Vulnerability Anki does not currently have a bug bounty program, but if you have discovered a security issue, a private message on our support site would be greatly appreciated. No account is required to post a message: https://anki.tenderapp.com/discussion/new ## FAQ ### Javascript on Cards/Templates Anki allows users and shared deck authors to augment their card designs with Javascript. This is used frequently, so disabling Javascript by default would likely break a lot of the shared decks out there. That said, the default may be changed in the future. The computer version has a limited interface between Javascript and the parts of Anki outside of the webview, so arbitrary code execution outside of the webview should not be possible. AnkiWeb hosts its study and editing interface on a separate ankiuser.net domain, so that malicious Javascript on cards can not trigger endpoints hosted on the main site. If you've found that not to be the case, or found an instance of JS not being filtered on the main site, please let us know. ================================================ FILE: build/configure/Cargo.toml ================================================ [package] name = "configure" version.workspace = true authors.workspace = true edition.workspace = true license.workspace = true publish = false rust-version.workspace = true [dependencies] anyhow.workspace = true itertools.workspace = true ninja_gen.workspace = true ================================================ FILE: build/configure/src/aqt.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anyhow::Result; use ninja_gen::action::BuildAction; use ninja_gen::command::RunCommand; use ninja_gen::copy::CopyFile; use ninja_gen::copy::CopyFiles; use ninja_gen::glob; use ninja_gen::hashmap; use ninja_gen::inputs; use ninja_gen::node::CompileSass; use ninja_gen::node::EsbuildScript; use ninja_gen::node::TypescriptCheck; use ninja_gen::python::python_format; use ninja_gen::python::PythonTest; use ninja_gen::rsync::RsyncFiles; use ninja_gen::Build; use ninja_gen::Utf8Path; use ninja_gen::Utf8PathBuf; use crate::anki_version; use crate::python::BuildWheel; use crate::web::copy_mathjax; pub fn build_and_check_aqt(build: &mut Build) -> Result<()> { build_forms(build)?; build_generated_sources(build)?; build_data_folder(build)?; build_wheel(build)?; check_python(build)?; Ok(()) } fn build_forms(build: &mut Build) -> Result<()> { let ui_files = glob!["qt/aqt/forms/*.ui"]; let outdir = Utf8PathBuf::from("qt/_aqt/forms"); let mut py_files = vec![]; for path in ui_files.resolve() { let outpath = outdir.join(path.file_name().unwrap()).into_string(); py_files.push(outpath.replace(".ui", "_qt6.py")); } build.add_action( "qt:aqt:forms", RunCommand { command: ":pyenv:bin", args: "$script $first_form", inputs: hashmap! { "script" => inputs!["qt/tools/build_ui.py"], "" => inputs![ui_files], }, outputs: hashmap! { "first_form" => vec![py_files[0].as_str()], "" => py_files.iter().skip(1).map(|s| s.as_str()).collect(), }, }, ) } /// For legacy reasons, we can not easily separate sources and generated files /// up with a PEP420 namespace, as aqt/__init__.py exports a bunch of things. /// To allow code to run/typecheck without having to merge source and generated /// files into a separate folder, the generated files are exported as a separate /// _aqt module. fn build_generated_sources(build: &mut Build) -> Result<()> { build.add_action( "qt:aqt:hooks.py", RunCommand { command: ":pyenv:bin", args: "$script $out", inputs: hashmap! { "script" => inputs!["qt/tools/genhooks_gui.py"], "" => inputs!["pylib/anki/_vendor/stringcase.py", "pylib/tools/hookslib.py"] }, outputs: hashmap! { "out" => vec!["qt/_aqt/hooks.py"] }, }, )?; build.add_action( "qt:aqt:sass_vars", RunCommand { command: ":pyenv:bin", args: "$script $root_scss $out", inputs: hashmap! { "script" => inputs!["qt/tools/extract_sass_vars.py"], "root_scss" => inputs![":css:_root-vars"], }, outputs: hashmap! { "out" => vec![ "qt/_aqt/colors.py", "qt/_aqt/props.py" ] }, }, )?; // we need to add a py.typed file to the generated sources, or mypy // will ignore them when used with the generated wheel build.add_action( "qt:aqt:py.typed", CopyFile { input: "qt/aqt/py.typed".into(), output: "qt/_aqt/py.typed", }, )?; Ok(()) } fn build_data_folder(build: &mut Build) -> Result<()> { build_css(build)?; build_imgs(build)?; build_js(build)?; build_pages(build)?; build_icons(build)?; copy_sveltekit(build)?; Ok(()) } fn copy_sveltekit(build: &mut Build) -> Result<()> { build.add_action( "qt:aqt:data:web:sveltekit", RsyncFiles { inputs: inputs![":sveltekit:folder"], target_folder: "qt/_aqt/data/web/", strip_prefix: "$builddir/", extra_args: "-a --delete", }, ) } fn build_css(build: &mut Build) -> Result<()> { let scss_files = build.expand_inputs(inputs![glob!["qt/aqt/data/web/css/*.scss"]]); let out_dir = Utf8Path::new("qt/_aqt/data/web/css"); for scss in scss_files { let stem = Utf8Path::new(&scss).file_stem().unwrap(); let mut out_path = out_dir.join(stem); out_path.set_extension("css"); build.add_action( "qt:aqt:data:web:css", CompileSass { input: scss.into(), output: out_path.as_str(), deps: inputs![":sass"], load_paths: vec![".", "node_modules"], }, )?; } let other_ts_css = build.inputs_with_suffix( inputs![":ts:editor", ":ts:editable", ":ts:reviewer:reviewer.css"], ".css", ); build.add_action( "qt:aqt:data:web:css", CopyFiles { inputs: other_ts_css.into(), output_folder: "qt/_aqt/data/web/css", }, ) } fn build_imgs(build: &mut Build) -> Result<()> { build.add_action( "qt:aqt:data:web:imgs", CopyFiles { inputs: inputs![glob!["qt/aqt/data/web/imgs/*"]], output_folder: "qt/_aqt/data/web/imgs", }, ) } fn build_js(build: &mut Build) -> Result<()> { for ts_file in &["deckbrowser", "webview", "toolbar", "reviewer-bottom"] { build.add_action( "qt:aqt:data:web:js", EsbuildScript { script: "ts/transform_ts.mjs".into(), entrypoint: format!("qt/aqt/data/web/js/{ts_file}.ts").into(), deps: inputs![], output_stem: &format!("qt/_aqt/data/web/js/{ts_file}"), extra_exts: &[], }, )?; } let files = inputs![glob!["qt/aqt/data/web/js/*"]]; build.add_action( "check:typescript:aqt", TypescriptCheck { tsconfig: "qt/aqt/data/web/js/tsconfig.json".into(), inputs: files, }, )?; let files_from_ts = build.inputs_with_suffix( inputs![":ts:editor", ":ts:reviewer:reviewer.js", ":ts:mathjax"], ".js", ); build.add_action( "qt:aqt:data:web:js", CopyFiles { inputs: files_from_ts.into(), output_folder: "qt/_aqt/data/web/js", }, )?; build_vendor_js(build) } fn build_vendor_js(build: &mut Build) -> Result<()> { build.add_action("qt:aqt:data:web:js:vendor:mathjax", copy_mathjax())?; build.add_action( "qt:aqt:data:web:js:vendor", CopyFiles { inputs: inputs![ ":node_modules:jquery", ":node_modules:jquery-ui", ":node_modules:bootstrap-dist", "qt/aqt/data/web/js/vendor/plot.js" ], output_folder: "qt/_aqt/data/web/js/vendor", }, ) } fn build_pages(build: &mut Build) -> Result<()> { build.add_action( "qt:aqt:data:web:pages", CopyFiles { inputs: inputs![":ts:pages"], output_folder: "qt/_aqt/data/web/pages", }, )?; Ok(()) } fn build_icons(build: &mut Build) -> Result<()> { build_themed_icons(build)?; build.add_action( "qt:aqt:data:qt:icons:mdi_unthemed", CopyFiles { inputs: inputs![":node_modules:mdi_unthemed"], output_folder: "qt/_aqt/data/qt/icons", }, )?; build.add_action( "qt:aqt:data:qt:icons:from_src", CopyFiles { inputs: inputs![glob!["qt/aqt/data/qt/icons/*.{png,svg}"]], output_folder: "qt/_aqt/data/qt/icons", }, )?; build.add_action( "qt:aqt:data:qt:icons", RunCommand { command: ":pyenv:bin", args: "$script $out $in", inputs: hashmap! { "script" => inputs!["qt/tools/build_qrc.py"], "in" => inputs![ ":qt:aqt:data:qt:icons:mdi_unthemed", ":qt:aqt:data:qt:icons:mdi_themed", ":qt:aqt:data:qt:icons:from_src", ] }, outputs: hashmap! { "out" => vec!["qt/_aqt/data/qt/icons.qrc"] }, }, )?; Ok(()) } fn build_themed_icons(build: &mut Build) -> Result<()> { let themed_icons_with_extra = hashmap! { "chevron-up" => &["FG_DISABLED"], "chevron-down" => &["FG_DISABLED"], "drag-vertical" => &["FG_SUBTLE"], "drag-horizontal" => &["FG_SUBTLE"], "check" => &["FG_DISABLED"], "circle-medium" => &["FG_DISABLED"], "minus-thick" => &["FG_DISABLED"], }; for icon_path in build.expand_inputs(inputs![":node_modules:mdi_themed"]) { let path = Utf8Path::new(&icon_path); let stem = path.file_stem().unwrap(); let mut colors = vec!["FG"]; if let Some(&extra) = themed_icons_with_extra.get(stem) { colors.extend(extra); } build.add_action( "qt:aqt:data:qt:icons:mdi_themed", BuildThemedIcon { src_icon: path, colors, }, )?; } Ok(()) } struct BuildThemedIcon<'a> { src_icon: &'a Utf8Path, colors: Vec<&'a str>, } impl BuildAction for BuildThemedIcon<'_> { fn command(&self) -> &str { "$pyenv_bin $script $in $colors $out" } fn files(&mut self, build: &mut impl ninja_gen::build::FilesHandle) { let stem = self.src_icon.file_stem().unwrap(); // eg foo-light.svg, foo-dark.svg, foo-FG_SUBTLE-light.svg, // foo-FG_SUBTLE-dark.svg let outputs: Vec<_> = self .colors .iter() .flat_map(|&color| { let variant = if color == "FG" { "".into() } else { format!("-{color}") }; [ format!("qt/_aqt/data/qt/icons/{stem}{variant}-light.svg"), format!("qt/_aqt/data/qt/icons/{stem}{variant}-dark.svg"), ] }) .collect(); build.add_inputs("pyenv_bin", inputs![":pyenv:bin"]); build.add_inputs("script", inputs!["qt/tools/color_svg.py"]); build.add_inputs("in", inputs![self.src_icon.as_str()]); build.add_inputs("", inputs![":qt:aqt:sass_vars"]); build.add_variable("colors", self.colors.join(":")); build.add_outputs("out", outputs); } } fn build_wheel(build: &mut Build) -> Result<()> { build.add_action( "wheels:aqt", BuildWheel { name: "aqt", version: anki_version(), platform: None, deps: inputs![ ":qt:aqt", glob!("qt/aqt/**"), "qt/pyproject.toml", "qt/hatch_build.py" ], }, ) } fn check_python(build: &mut Build) -> Result<()> { python_format(build, "qt", inputs![glob!("qt/**/*.py")])?; build.add_action( "check:pytest:aqt", PythonTest { folder: "qt/tests", python_path: &["pylib", "$builddir/pylib", "$builddir/qt"], deps: inputs![":pylib:anki", ":qt:aqt", glob!["qt/tests/**"]], }, ) } ================================================ FILE: build/configure/src/launcher.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anyhow::Result; use ninja_gen::archives::download_and_extract; use ninja_gen::archives::empty_manifest; use ninja_gen::archives::OnlineArchive; use ninja_gen::command::RunCommand; use ninja_gen::hashmap; use ninja_gen::inputs; use ninja_gen::Build; pub fn setup_uv_universal(build: &mut Build) -> Result<()> { if !cfg!(target_arch = "aarch64") { return Ok(()); } build.add_action( "launcher:uv_universal", RunCommand { command: "/usr/bin/lipo", args: "-create -output $out $arm_bin $x86_bin", inputs: hashmap! { "arm_bin" => inputs![":extract:uv:bin"], "x86_bin" => inputs![":extract:uv_mac_x86:bin"], }, outputs: hashmap! { "out" => vec!["launcher/uv"], }, }, ) } pub fn build_launcher(build: &mut Build) -> Result<()> { setup_uv_universal(build)?; download_and_extract(build, "nsis_plugins", NSIS_PLUGINS, empty_manifest())?; Ok(()) } const NSIS_PLUGINS: OnlineArchive = OnlineArchive { url: "https://github.com/ankitects/anki-bundle-extras/releases/download/anki-2023-05-19/nsis.tar.zst", sha256: "6133f730ece699de19714d0479c73bc848647d277e9cc80dda9b9ebe532b40a8", }; ================================================ FILE: build/configure/src/main.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod aqt; mod launcher; mod platform; mod pylib; mod python; mod rust; mod web; use std::env; use anyhow::Result; use aqt::build_and_check_aqt; use launcher::build_launcher; use ninja_gen::glob; use ninja_gen::inputs; use ninja_gen::protobuf::check_proto; use ninja_gen::protobuf::setup_protoc; use ninja_gen::python::setup_uv; use ninja_gen::Build; use platform::overriden_python_venv_platform; use pylib::build_pylib; use pylib::check_pylib; use python::check_python; use python::setup_venv; use rust::build_rust; use rust::check_minilints; use rust::check_rust; use web::build_and_check_web; use web::check_sql; use crate::python::setup_sphinx; fn anki_version() -> String { std::fs::read_to_string(".version") .unwrap() .trim() .to_string() } fn main() -> Result<()> { let mut build = Build::new()?; let build = &mut build; setup_protoc(build)?; check_proto(build, inputs![glob!["proto/**/*.proto"]])?; if env::var("OFFLINE_BUILD").is_err() { setup_uv( build, overriden_python_venv_platform().unwrap_or(build.host_platform), )?; } setup_venv(build)?; build_rust(build)?; build_pylib(build)?; build_and_check_web(build)?; build_and_check_aqt(build)?; if env::var("OFFLINE_BUILD").is_err() { build_launcher(build)?; } setup_sphinx(build)?; check_rust(build)?; check_pylib(build)?; check_python(build)?; check_sql(build)?; check_minilints(build)?; build.trailing_text = "default pylib qt\n".into(); build.write_build_file()?; Ok(()) } ================================================ FILE: build/configure/src/platform.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::env; use ninja_gen::archives::Platform; /// Please see [`overriden_python_target_platform()`] for details. pub fn overriden_rust_target_triple() -> Option<&'static str> { overriden_python_wheel_platform().map(|p| p.as_rust_triple()) } /// Usually None to use the host architecture, except on Windows which /// always uses x86_64, since WebEngine is unavailable for ARM64. pub fn overriden_python_venv_platform() -> Option { if cfg!(target_os = "windows") { Some(Platform::WindowsX64) } else { None } } /// Like [`overriden_python_venv_platform`], but: /// If MAC_X86 is set, an X86 wheel will be built on macOS ARM. /// If LIN_ARM64 is set, an ARM64 wheel will be built on Linux AMD64. pub fn overriden_python_wheel_platform() -> Option { if env::var("MAC_X86").is_ok() { Some(Platform::MacX64) } else if env::var("LIN_ARM64").is_ok() { Some(Platform::LinuxArm) } else { overriden_python_venv_platform() } } ================================================ FILE: build/configure/src/pylib.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anyhow::Result; use ninja_gen::action::BuildAction; use ninja_gen::archives::Platform; use ninja_gen::command::RunCommand; use ninja_gen::copy::LinkFile; use ninja_gen::glob; use ninja_gen::hashmap; use ninja_gen::inputs; use ninja_gen::python::python_format; use ninja_gen::python::PythonTest; use ninja_gen::Build; use crate::anki_version; use crate::platform::overriden_python_wheel_platform; use crate::python::BuildWheel; use crate::python::GenPythonProto; pub fn build_pylib(build: &mut Build) -> Result<()> { // generated files build.add_action( "pylib:anki:proto", GenPythonProto { proto_files: inputs![glob!["proto/anki/*.proto"]], }, )?; build.add_dependency("pylib:anki:proto", ":rslib:proto:py".into()); build.add_dependency("pylib:anki:i18n", ":rslib:i18n:py".into()); build.add_action( "pylib:anki:hooks_gen.py", RunCommand { command: ":pyenv:bin", args: "$script $out", inputs: hashmap! { "script" => inputs!["pylib/tools/genhooks.py"], "" => inputs!["pylib/anki/_vendor/stringcase.py", "pylib/tools/hookslib.py"] }, outputs: hashmap! { "out" => vec!["pylib/anki/hooks_gen.py"] }, }, )?; build.add_action( "pylib:anki:rsbridge", LinkFile { input: inputs![":pylib:rsbridge"], output: &format!( "pylib/anki/_rsbridge.{}", match build.host_platform { Platform::WindowsX64 | Platform::WindowsArm => "pyd", _ => "so", } ), }, )?; build.add_action("pylib:anki:buildinfo.py", GenBuildInfo {})?; // wheel build.add_action( "wheels:anki", BuildWheel { name: "anki", version: anki_version(), platform: overriden_python_wheel_platform().or(Some(build.host_platform)), deps: inputs![ ":pylib:anki", glob!("pylib/anki/**"), "pylib/pyproject.toml", "pylib/hatch_build.py" ], }, )?; Ok(()) } pub fn check_pylib(build: &mut Build) -> Result<()> { python_format(build, "pylib", inputs![glob!("pylib/**/*.py")])?; build.add_action( "check:pytest:pylib", PythonTest { folder: "pylib/tests", python_path: &["$builddir/pylib"], deps: inputs![":pylib:anki", glob!["pylib/{anki,tests}/**"]], }, ) } pub struct GenBuildInfo {} impl BuildAction for GenBuildInfo { fn command(&self) -> &str { "$pyenv_bin $script $version_file $buildhash_file $out" } fn files(&mut self, build: &mut impl ninja_gen::build::FilesHandle) { build.add_inputs("pyenv_bin", inputs![":pyenv:bin"]); build.add_inputs("script", inputs!["pylib/tools/genbuildinfo.py"]); build.add_inputs("version_file", inputs![".version"]); build.add_inputs("buildhash_file", inputs!["$builddir/buildhash"]); build.add_outputs("out", vec!["pylib/anki/buildinfo.py"]); } } ================================================ FILE: build/configure/src/python.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anyhow::Result; use ninja_gen::action::BuildAction; use ninja_gen::archives::Platform; use ninja_gen::build::FilesHandle; use ninja_gen::copy::CopyFiles; use ninja_gen::glob; use ninja_gen::input::BuildInput; use ninja_gen::inputs; use ninja_gen::python::python_format; use ninja_gen::python::PythonEnvironment; use ninja_gen::python::PythonTypecheck; use ninja_gen::python::RuffCheck; use ninja_gen::Build; /// Normalize version string by removing leading zeros from numeric parts /// while preserving pre-release markers (b1, rc2, a3, etc.) fn normalize_version(version: &str) -> String { version .split('.') .map(|part| { // Check if the part contains only digits if part.chars().all(|c| c.is_ascii_digit()) { // Numeric part: remove leading zeros part.parse::().unwrap_or(0).to_string() } else { // Mixed part (contains both numbers and pre-release markers) // Split on first non-digit character and normalize the numeric prefix let chars = part.chars(); let mut numeric_prefix = String::new(); let mut rest = String::new(); let mut found_non_digit = false; for ch in chars { if ch.is_ascii_digit() && !found_non_digit { numeric_prefix.push(ch); } else { found_non_digit = true; rest.push(ch); } } if numeric_prefix.is_empty() { part.to_string() } else { let normalized_prefix = numeric_prefix.parse::().unwrap_or(0).to_string(); format!("{normalized_prefix}{rest}") } } }) .collect::>() .join(".") } pub fn setup_venv(build: &mut Build) -> Result<()> { let extra_binary_exports = &["mypy", "ruff", "pytest", "protoc-gen-mypy"]; build.add_action( "pyenv", PythonEnvironment { venv_folder: "pyenv", deps: inputs![ "pyproject.toml", "pylib/pyproject.toml", "qt/pyproject.toml", "uv.lock" ], extra_args: "--all-packages --extra qt --extra audio", extra_binary_exports, }, )?; Ok(()) } pub struct GenPythonProto { pub proto_files: BuildInput, } impl BuildAction for GenPythonProto { fn command(&self) -> &str { "$protoc $ --plugin=protoc-gen-mypy=$protoc-gen-mypy $ --python_out=$builddir/pylib $ --mypy_out=$builddir/pylib $ -Iproto $in" } fn files(&mut self, build: &mut impl FilesHandle) { let proto_inputs = build.expand_inputs(&self.proto_files); let python_outputs: Vec<_> = proto_inputs .iter() .flat_map(|path| { let path = path .replace('\\', "/") .replace("proto/", "pylib/") .replace(".proto", "_pb2"); [format!("{path}.py"), format!("{path}.pyi")] }) .collect(); build.add_inputs("in", &self.proto_files); build.add_inputs("protoc", inputs![":protoc_binary"]); build.add_inputs("protoc-gen-mypy", inputs![":pyenv:protoc-gen-mypy"]); build.add_outputs("", python_outputs); } fn hide_progress(&self) -> bool { true } } pub struct BuildWheel { pub name: &'static str, pub version: String, pub platform: Option, pub deps: BuildInput, } impl BuildAction for BuildWheel { fn command(&self) -> &str { "$uv build --wheel --out-dir=$out_dir --project=$project_dir" } fn files(&mut self, build: &mut impl FilesHandle) { if std::env::var("OFFLINE_BUILD").ok().as_deref() == Some("1") { let uv_path = std::env::var("UV_BINARY").expect("UV_BINARY must be set in OFFLINE_BUILD mode"); build.add_inputs("uv", inputs![uv_path]); } else { build.add_inputs("uv", inputs![":uv_binary"]); } build.add_inputs("", &self.deps); // Set the project directory based on which package we're building let project_dir = if self.name == "anki" { "pylib" } else { "qt" }; build.add_variable("project_dir", project_dir); // Set environment variable for uv to use our pyenv build.add_variable("pyenv_path", "$builddir/pyenv"); build.add_env_var("UV_PROJECT_ENVIRONMENT", "$pyenv_path"); // Set output directory build.add_variable("out_dir", "$builddir/wheels/"); // Calculate the wheel filename that uv will generate let tag = if let Some(platform) = self.platform { let platform_tag = match platform { Platform::LinuxX64 => "manylinux_2_36_x86_64", Platform::LinuxArm => "manylinux_2_36_aarch64", Platform::MacX64 => "macosx_12_0_x86_64", Platform::MacArm => "macosx_12_0_arm64", Platform::WindowsX64 => "win_amd64", Platform::WindowsArm => "win_arm64", }; format!("cp39-abi3-{platform_tag}") } else { "py3-none-any".into() }; // Set environment variable for hatch_build.py to use the correct platform tag build.add_variable("wheel_tag", &tag); build.add_env_var("ANKI_WHEEL_TAG", "$wheel_tag"); let name = self.name; let normalized_version = normalize_version(&self.version); let wheel_path = format!("wheels/{name}-{normalized_version}-{tag}.whl"); build.add_outputs("out", vec![wheel_path]); } } pub fn check_python(build: &mut Build) -> Result<()> { python_format(build, "tools", inputs![glob!("tools/**/*.py")])?; build.add_action( "check:mypy", PythonTypecheck { folders: &[ "pylib", "qt/aqt", "qt/tools", "out/pylib/anki", "out/qt/_aqt", "python", "tools", ], deps: inputs![ glob!["{pylib,ftl,qt}/**/*.{py,pyi}"], ":pylib:anki", ":qt:aqt" ], }, )?; let ruff_folders = &["qt/aqt", "ftl", "pylib/tools", "tools", "python"]; let ruff_deps = inputs![ glob!["{pylib,ftl,qt,python,tools}/**/*.py"], ":pylib:anki", ":qt:aqt" ]; build.add_action( "check:ruff", RuffCheck { folders: ruff_folders, deps: ruff_deps.clone(), check_only: true, }, )?; build.add_action( "fix:ruff", RuffCheck { folders: ruff_folders, deps: ruff_deps, check_only: false, }, )?; Ok(()) } struct Sphinx { deps: BuildInput, } impl BuildAction for Sphinx { fn command(&self) -> &str { if std::env::var("OFFLINE_BUILD").ok().as_deref() == Some("1") { "$python python/sphinx/build.py" } else { "$uv sync --extra sphinx && $python python/sphinx/build.py" } } fn files(&mut self, build: &mut impl FilesHandle) { if std::env::var("OFFLINE_BUILD").ok().as_deref() == Some("1") { let uv_path = std::env::var("UV_BINARY").expect("UV_BINARY must be set in OFFLINE_BUILD mode"); build.add_inputs("uv", inputs![uv_path]); } else { build.add_inputs("uv", inputs![":uv_binary"]); // Set environment variable to use the existing pyenv build.add_variable("pyenv_path", "$builddir/pyenv"); build.add_env_var("UV_PROJECT_ENVIRONMENT", "$pyenv_path"); } build.add_inputs("python", inputs![":pyenv:bin"]); build.add_inputs("", &self.deps); build.add_output_stamp("python/sphinx/stamp"); } fn hide_success(&self) -> bool { false } } pub(crate) fn setup_sphinx(build: &mut Build) -> Result<()> { build.add_action( "python:sphinx:copy_conf", CopyFiles { inputs: inputs![glob!("python/sphinx/{conf.py,index.rst}")], output_folder: "python/sphinx", }, )?; build.add_action( "python:sphinx", Sphinx { deps: inputs![ ":pylib", ":qt", ":python:sphinx:copy_conf", "pyproject.toml" ], }, )?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_normalize_version_basic() { assert_eq!(normalize_version("1.2.3"), "1.2.3"); assert_eq!(normalize_version("01.02.03"), "1.2.3"); assert_eq!(normalize_version("1.0.0"), "1.0.0"); } #[test] fn test_normalize_version_with_prerelease() { assert_eq!(normalize_version("1.2.3b1"), "1.2.3b1"); assert_eq!(normalize_version("01.02.03b1"), "1.2.3b1"); assert_eq!(normalize_version("1.0.0rc2"), "1.0.0rc2"); assert_eq!(normalize_version("2.1.0a3"), "2.1.0a3"); assert_eq!(normalize_version("1.2.3beta1"), "1.2.3beta1"); assert_eq!(normalize_version("1.2.3alpha1"), "1.2.3alpha1"); } } ================================================ FILE: build/configure/src/rust.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::env; use anyhow::Result; use ninja_gen::action::BuildAction; use ninja_gen::build::BuildProfile; use ninja_gen::build::FilesHandle; use ninja_gen::cargo::CargoBuild; use ninja_gen::cargo::CargoClippy; use ninja_gen::cargo::CargoFormat; use ninja_gen::cargo::CargoTest; use ninja_gen::cargo::RustOutput; use ninja_gen::git::SyncSubmodule; use ninja_gen::glob; use ninja_gen::hash::simple_hash; use ninja_gen::input::BuildInput; use ninja_gen::inputs; use ninja_gen::Build; use crate::platform::overriden_rust_target_triple; pub fn build_rust(build: &mut Build) -> Result<()> { prepare_translations(build)?; build_proto_descriptors_and_interfaces(build)?; build_rsbridge(build) } fn prepare_translations(build: &mut Build) -> Result<()> { let offline_build = env::var("OFFLINE_BUILD").is_ok(); // ensure repos are checked out build.add_action( "ftl:repo:core", SyncSubmodule { path: "ftl/core-repo", offline_build, }, )?; build.add_action( "ftl:repo:qt", SyncSubmodule { path: "ftl/qt-repo", offline_build, }, )?; // build anki_i18n and spit out strings.json build.add_action( "rslib:i18n", CargoBuild { inputs: inputs![ glob!["rslib/i18n/**"], glob!["ftl/{core,core-repo,qt,qt-repo}/**"], ":ftl:repo", ], outputs: &[ RustOutput::Data("py", "pylib/anki/_fluent.py"), RustOutput::Data("ts", "ts/lib/generated/ftl.ts"), ], target: None, extra_args: "-p anki_i18n", release_override: None, }, )?; build.add_action( "ftl:bin", CargoBuild { inputs: inputs![glob!["ftl/**"],], outputs: &[RustOutput::Binary("ftl")], target: None, extra_args: "-p ftl", release_override: None, }, )?; // These don't use :group notation, as it doesn't make sense to invoke multiple // commands as a group. build.add_action( "ftl-sync", FtlCommand { args: "sync", deps: inputs![":ftl:repo", glob!["ftl/**"]], }, )?; build.add_action( "ftl-deprecate", FtlCommand { args: "deprecate --ftl-roots ftl/core ftl/qt --source-roots pylib qt rslib ts --json-roots ftl/usage", deps: inputs!["ftl/core", "ftl/qt", "pylib", "qt", "rslib", "ts"], }, )?; Ok(()) } struct FtlCommand { args: &'static str, deps: BuildInput, } impl BuildAction for FtlCommand { fn command(&self) -> &str { "$ftl_bin $args" } fn files(&mut self, build: &mut impl FilesHandle) { build.add_inputs("", &self.deps); build.add_inputs("ftl_bin", inputs![":ftl:bin"]); build.add_variable("args", self.args); build.add_output_stamp(format!("ftl/stamp.{}", simple_hash(self.args))); } } fn build_proto_descriptors_and_interfaces(build: &mut Build) -> Result<()> { let outputs = vec![ RustOutput::Data("descriptors.bin", "rslib/proto/descriptors.bin"), RustOutput::Data("py", "pylib/anki/_backend_generated.py"), RustOutput::Data("ts", "ts/lib/generated/backend.ts"), ]; build.add_action( "rslib:proto", CargoBuild { inputs: inputs![glob!["{proto,rslib/proto}/**"], ":protoc_binary",], outputs: &outputs, target: None, extra_args: "-p anki_proto", release_override: None, }, )?; Ok(()) } fn build_rsbridge(build: &mut Build) -> Result<()> { let features = if cfg!(target_os = "linux") { "rustls" } else { "native-tls" }; build.add_action( "pylib:rsbridge", CargoBuild { inputs: inputs![ glob!["{pylib/rsbridge/**,rslib/**}"], // declare a dependency on i18n/proto so they get built first, allowing // things depending on them to build faster, and ensuring // changes to the ftl files trigger a rebuild ":rslib:i18n", ":rslib:proto", // when env vars change the build hash gets updated "$builddir/env", "$builddir/buildhash", // building on Windows requires python3.lib if cfg!(windows) { inputs![":pyenv:bin"] } else { inputs![] } ], outputs: &[RustOutput::DynamicLib("rsbridge")], target: overriden_rust_target_triple(), extra_args: &format!("-p rsbridge --features {features}"), release_override: None, }, ) } pub fn check_rust(build: &mut Build) -> Result<()> { let inputs = inputs![ glob!("{rslib/**,pylib/rsbridge/**,ftl/**,build/**,qt/launcher/**,tools/minilints/**}"), "Cargo.lock", "Cargo.toml", "rust-toolchain.toml", ]; build.add_action( "check:format:rust", CargoFormat { inputs: inputs.clone(), check_only: true, working_dir: Some("cargo/format"), }, )?; build.add_action( "format:rust", CargoFormat { inputs: inputs.clone(), check_only: false, working_dir: Some("cargo/format"), }, )?; let inputs = inputs![ inputs, // defer tests until build has completed; ensure re-run on changes ":pylib:rsbridge" ]; build.add_action( "check:clippy", CargoClippy { inputs: inputs.clone(), }, )?; build.add_action("check:rust_test", CargoTest { inputs })?; Ok(()) } pub fn check_minilints(build: &mut Build) -> Result<()> { struct RunMinilints { pub deps: BuildInput, pub fix: bool, } impl BuildAction for RunMinilints { fn command(&self) -> &str { "$minilints_bin $fix $stamp" } fn bypass_runner(&self) -> bool { true } fn files(&mut self, build: &mut impl FilesHandle) { build.add_inputs("minilints_bin", inputs![":build:minilints"]); build.add_inputs("", &self.deps); build.add_variable("fix", if self.fix { "fix" } else { "check" }); build.add_output_stamp(format!("tests/minilints.{}", self.fix)); } fn on_first_instance(&self, build: &mut Build) -> Result<()> { build.add_action( "build:minilints", CargoBuild { inputs: inputs![glob!("tools/minilints/**/*")], outputs: &[RustOutput::Binary("minilints")], target: None, extra_args: "-p minilints", release_override: Some(BuildProfile::Debug), }, ) } } let files = inputs![ glob![ "**/*.{py,rs,ts,svelte,mjs,md}", "{node_modules,ts/.svelte-kit}/**" ], "Cargo.lock" ]; build.add_action( "check:minilints", RunMinilints { deps: files.clone(), fix: false, }, )?; build.add_action( "fix:minilints", RunMinilints { deps: files, fix: true, }, )?; Ok(()) } ================================================ FILE: build/configure/src/web.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anyhow::Result; use ninja_gen::action::BuildAction; use ninja_gen::copy::CopyFiles; use ninja_gen::glob; use ninja_gen::hashmap; use ninja_gen::input::BuildInput; use ninja_gen::inputs; use ninja_gen::node::node_archive; use ninja_gen::node::CompileSass; use ninja_gen::node::DPrint; use ninja_gen::node::EsbuildScript; use ninja_gen::node::Eslint; use ninja_gen::node::GenTypescriptProto; use ninja_gen::node::Prettier; use ninja_gen::node::SqlFormat; use ninja_gen::node::SvelteCheck; use ninja_gen::node::SveltekitBuild; use ninja_gen::node::ViteTest; use ninja_gen::rsync::RsyncFiles; use ninja_gen::Build; pub fn build_and_check_web(build: &mut Build) -> Result<()> { setup_node(build)?; build_sass(build)?; build_and_check_tslib(build)?; build_sveltekit(build)?; declare_and_check_other_libraries(build)?; build_and_check_pages(build)?; build_and_check_editor(build)?; build_and_check_reviewer(build)?; build_and_check_mathjax(build)?; check_web(build)?; Ok(()) } fn build_sveltekit(build: &mut Build) -> Result<()> { build.add_action( "sveltekit", SveltekitBuild { output_folder: inputs!["sveltekit"], deps: inputs![ "ts/tsconfig.json", glob!["ts/**", "ts/.svelte-kit/**"], ":ts:lib" ], }, ) } fn setup_node(build: &mut Build) -> Result<()> { ninja_gen::node::setup_node( build, node_archive(build.host_platform), &[ "dprint", "svelte-check", "eslint", "sass", "tsc", "tsx", "vite", "vitest", "protoc-gen-es", "prettier", ], hashmap! { "jquery" => vec![ "jquery/dist/jquery.min.js".into() ], "jquery-ui" => vec![ "jquery-ui-dist/jquery-ui.min.js".into() ], "bootstrap-dist" => vec![ "bootstrap/dist/js/bootstrap.bundle.min.js".into(), ], "mathjax" => MATHJAX_FILES.iter().map(|&v| v.into()).collect(), "mdi_unthemed" => [ // saved searches "heart-outline.svg", // today "clock-outline.svg", // state "circle.svg", "circle-outline.svg", // flags "flag-variant.svg", "flag-variant-outline.svg", "flag-variant-off-outline.svg", // decks "book-outline.svg", "book-clock-outline.svg", "book-cog-outline.svg", // notetypes "newspaper.svg", // cardtype "application-braces-outline.svg", // fields "form-textbox.svg", // tags "tag-outline.svg", "tag-off-outline.svg", ].iter().map(|file| format!("@mdi/svg/svg/{file}").into()).collect(), "mdi_themed" => [ // sidebar tools "magnify.svg", "selection-drag.svg", // QComboBox arrows "chevron-up.svg", "chevron-down.svg", // QHeaderView arrows "menu-up.svg", "menu-down.svg", // drag handle "drag-vertical.svg", "drag-horizontal.svg", // checkbox "check.svg", "minus-thick.svg", // QRadioButton "circle-medium.svg", ].iter().map(|file| format!("@mdi/svg/svg/{file}").into()).collect(), }, )?; Ok(()) } fn build_and_check_tslib(build: &mut Build) -> Result<()> { build.add_dependency("ts:generated:i18n", ":rslib:i18n:ts".into()); build.add_action( "ts:generated:proto", GenTypescriptProto { protos: inputs![glob!["proto/**/*.proto"]], include_dirs: &["proto"], out_dir: "out/ts/lib/generated", out_path_transform: |path| { path.replace("proto/", "ts/lib/generated/") .replace("proto\\", "ts/lib/generated\\") }, ts_transform_script: "ts/tools/markpure.ts", }, )?; // ensure _service files are generated by rslib build.add_dependency("ts:generated:proto", inputs![":rslib:proto:ts"]); // copy source files from ts/lib/generated build.add_action( "ts:generated:src", CopyFiles { inputs: inputs![glob!["ts/lib/generated/*.ts"]], output_folder: "ts/lib/generated", }, )?; let src_files = inputs![glob!["ts/lib/**"]]; build.add_dependency("ts:lib", inputs![":ts:generated"]); build.add_dependency("ts:lib", src_files); Ok(()) } fn declare_and_check_other_libraries(build: &mut Build) -> Result<()> { for (library, inputs) in [ ("sveltelib", inputs![":ts:lib", glob!("ts/sveltelib/**")]), ("domlib", inputs![":ts:lib", glob!("ts/domlib/**")]), ( "components", inputs![":ts:lib", ":ts:sveltelib", glob!("ts/components/**")], ), ("html-filter", inputs![glob!("ts/html-filter/**")]), ] { let library_with_ts = format!("ts:{library}"); build.add_dependency(&library_with_ts, inputs.clone()); } Ok(()) } fn build_and_check_pages(build: &mut Build) -> Result<()> { let mut build_page = |name: &str, html: bool, deps: BuildInput| -> Result<()> { let group = format!("ts:{name}"); let deps = inputs![deps, glob!(format!("ts/{name}/**"))]; let extra_exts = if html { &["css", "html"][..] } else { &["css"] }; let entrypoint = if html { format!("ts/routes/{name}/index.ts") } else { format!("ts/{name}/index.ts") }; build.add_action( &group, EsbuildScript { script: inputs!["ts/bundle_svelte.mjs"], entrypoint: inputs![entrypoint], output_stem: &format!("ts/{name}/{name}"), deps: deps.clone(), extra_exts, }, )?; build.add_dependency("ts:pages", inputs![format!(":{group}")]); Ok(()) }; // we use the generated .css file separately build_page( "editable", false, inputs![ // ":ts:lib", ":ts:components", ":ts:domlib", ":ts:sveltelib", ":sass", ":sveltekit", ], )?; build_page( "congrats", true, inputs![ // ":ts:lib", ":ts:components", ":sass", ":sveltekit" ], )?; Ok(()) } fn build_and_check_editor(build: &mut Build) -> Result<()> { let editor_deps = inputs![ // ":ts:lib", ":ts:components", ":ts:domlib", ":ts:sveltelib", ":ts:html-filter", ":sass", ":sveltekit", glob!("ts/{editable,editor,routes/image-occlusion}/**") ]; build.add_action( "ts:editor", EsbuildScript { script: "ts/bundle_svelte.mjs".into(), entrypoint: "ts/editor/index.ts".into(), output_stem: "ts/editor/editor", deps: editor_deps.clone(), extra_exts: &["css"], }, )?; Ok(()) } fn build_and_check_reviewer(build: &mut Build) -> Result<()> { let reviewer_deps = inputs![ ":ts:lib", glob!("ts/{reviewer,image-occlusion}/**"), ":sveltekit" ]; build.add_action( "ts:reviewer:reviewer.js", EsbuildScript { script: inputs!["ts/bundle_ts.mjs"], entrypoint: "ts/reviewer/index_wrapper.ts".into(), output_stem: "ts/reviewer/reviewer", deps: reviewer_deps.clone(), extra_exts: &[], }, )?; build.add_action( "ts:reviewer:reviewer.css", CompileSass { input: inputs!["ts/reviewer/reviewer.scss"], output: "ts/reviewer/reviewer.css", deps: inputs![":sass", "ts/routes/image-occlusion/review.scss"], load_paths: vec!["."], }, )?; build.add_action( "ts:reviewer:reviewer_extras_bundle.js", EsbuildScript { script: inputs!["ts/bundle_ts.mjs"], entrypoint: "ts/reviewer/reviewer_extras.ts".into(), output_stem: "ts/reviewer/reviewer_extras_bundle", deps: reviewer_deps.clone(), extra_exts: &[], }, )?; build.add_action( "ts:reviewer:reviewer_extras.css", CompileSass { input: inputs!["ts/reviewer/reviewer_extras.scss"], output: "ts/reviewer/reviewer_extras.css", deps: inputs!["ts/routes/image-occlusion/review.scss"], load_paths: vec!["."], }, )?; Ok(()) } fn check_web(build: &mut Build) -> Result<()> { let fmt_excluded = "{target,ts/.svelte-kit,node_modules}/**"; let dprint_files = inputs![glob!["**/*.{ts,mjs,js,md,json,toml,scss}", fmt_excluded]]; let prettier_files = inputs![glob!["**/*.svelte", fmt_excluded]]; build.add_action( "check:format:dprint", DPrint { inputs: dprint_files.clone(), check_only: true, }, )?; build.add_action( "format:dprint", DPrint { inputs: dprint_files, check_only: false, }, )?; build.add_action( "check:format:prettier", Prettier { inputs: prettier_files.clone(), check_only: true, }, )?; build.add_action( "format:prettier", Prettier { inputs: prettier_files, check_only: false, }, )?; build.add_action( "check:vitest", ViteTest { deps: inputs![ ":node_modules", ":ts:generated", glob!["ts/{svelte.config.js,vite.config.ts,tsconfig.json}"], glob!["ts/{lib,deck-options,html-filter,domlib,reviewer,change-notetype}/**/*"], ], }, )?; build.add_action( "check:svelte", SvelteCheck { tsconfig: inputs!["ts/tsconfig.json"], inputs: inputs![ ":node_modules", ":ts:generated", glob!["ts/**/*", "ts/.svelte-kit/**"], ], }, )?; let eslint_rc = inputs![".eslintrc.cjs"]; for folder in ["ts", "qt/aqt/data/web/js"] { let inputs = inputs![glob![format!("{folder}/**"), "ts/.svelte-kit/**"]]; build.add_action( "check:eslint", Eslint { folder, inputs: inputs.clone(), eslint_rc: eslint_rc.clone(), fix: false, }, )?; build.add_action( "fix:eslint", Eslint { folder, inputs, eslint_rc: eslint_rc.clone(), fix: true, }, )?; } Ok(()) } pub fn check_sql(build: &mut Build) -> Result<()> { build.add_action( "check:format:sql", SqlFormat { inputs: inputs![glob!["**/*.sql"]], check_only: true, }, )?; build.add_action( "format:sql", SqlFormat { inputs: inputs![glob!["**/*.sql"]], check_only: false, }, )?; Ok(()) } fn build_and_check_mathjax(build: &mut Build) -> Result<()> { let files = inputs![glob!["ts/mathjax/*"], ":sveltekit"]; build.add_action( "ts:mathjax", EsbuildScript { script: "ts/transform_ts.mjs".into(), entrypoint: "ts/mathjax/index.ts".into(), deps: files.clone(), output_stem: "ts/mathjax/mathjax", extra_exts: &[], }, ) } pub const MATHJAX_FILES: &[&str] = &[ "mathjax/es5/a11y/assistive-mml.js", "mathjax/es5/a11y/complexity.js", "mathjax/es5/a11y/explorer.js", "mathjax/es5/a11y/semantic-enrich.js", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_AMS-Regular.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Bold.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Regular.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Fraktur-Bold.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Fraktur-Regular.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Main-Bold.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Main-Italic.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Main-Regular.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Math-BoldItalic.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Math-Italic.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Math-Regular.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_SansSerif-Bold.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_SansSerif-Italic.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_SansSerif-Regular.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Script-Regular.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Size1-Regular.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Size2-Regular.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Size3-Regular.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Size4-Regular.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Typewriter-Regular.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Vector-Bold.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Vector-Regular.woff", "mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Zero.woff", "mathjax/es5/tex-chtml-full.js", "mathjax/es5/sre/mathmaps/de.json", "mathjax/es5/sre/mathmaps/en.json", "mathjax/es5/sre/mathmaps/es.json", "mathjax/es5/sre/mathmaps/fr.json", "mathjax/es5/sre/mathmaps/hi.json", "mathjax/es5/sre/mathmaps/it.json", "mathjax/es5/sre/mathmaps/nemeth.json", ]; pub fn copy_mathjax() -> impl BuildAction { RsyncFiles { inputs: inputs![":node_modules:mathjax"], target_folder: "qt/_aqt/data/web/js/vendor/mathjax", strip_prefix: "$builddir/node_modules/mathjax/es5", extra_args: "", } } fn build_sass(build: &mut Build) -> Result<()> { build.add_dependency("sass", inputs![glob!("ts/lib/sass/**")]); build.add_action( "css:_root-vars", CompileSass { input: inputs!["ts/lib/sass/_root-vars.scss"], output: "ts/lib/sass/_root-vars.css", deps: inputs![glob!["ts/lib/sass/*"]], load_paths: vec![], }, )?; Ok(()) } ================================================ FILE: build/ninja_gen/Cargo.toml ================================================ [package] name = "ninja_gen" version.workspace = true authors.workspace = true edition.workspace = true license.workspace = true publish = false rust-version.workspace = true [dependencies] anki_io.workspace = true anyhow.workspace = true camino.workspace = true dunce.workspace = true globset.workspace = true itertools.workspace = true maplit.workspace = true num_cpus.workspace = true regex.workspace = true serde_json.workspace = true sha2.workspace = true walkdir.workspace = true which.workspace = true [target.'cfg(windows)'.dependencies] reqwest = { workspace = true, features = ["blocking", "json", "native-tls"] } [target.'cfg(not(windows))'.dependencies] reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] } [[bin]] name = "update_uv" path = "src/bin/update_uv.rs" [[bin]] name = "update_protoc" path = "src/bin/update_protoc.rs" [[bin]] name = "update_node" path = "src/bin/update_node.rs" ================================================ FILE: build/ninja_gen/src/action.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anyhow::Result; use crate::build::FilesHandle; use crate::Build; pub trait BuildAction { /// Command line to invoke for each build statement. fn command(&self) -> &str; /// Declare the input files and variables, and output files. fn files(&mut self, build: &mut impl FilesHandle); /// If true, this action will not trigger a rebuild of dependent targets if /// the output files are unchanged. This corresponds to Ninja's "restat" /// argument. fn check_output_timestamps(&self) -> bool { false } /// True if this rule generates build.ninja fn generator(&self) -> bool { false } /// Called on first action invocation; can be used to inject other build /// actions to perform initial setup. #[allow(unused_variables)] fn on_first_instance(&self, build: &mut Build) -> Result<()> { Ok(()) } fn concurrency_pool(&self) -> Option<&'static str> { None } fn bypass_runner(&self) -> bool { false } fn hide_success(&self) -> bool { true } fn hide_progress(&self) -> bool { false } fn name(&self) -> &'static str { std::any::type_name::() .split("::") .last() .unwrap() .split('<') .next() .unwrap() } } #[cfg(test)] trait TestBuildAction {} #[cfg(test)] impl BuildAction for T { fn command(&self) -> &str { "test" } fn files(&mut self, _build: &mut impl FilesHandle) {} } #[allow(dead_code, unused_variables)] #[test] fn should_strip_regions_in_type_name() { struct Bare; impl TestBuildAction for Bare {} assert_eq!(Bare {}.name(), "Bare"); struct WithLifeTime<'a>(&'a str); impl TestBuildAction for WithLifeTime<'_> {} assert_eq!(WithLifeTime("test").name(), "WithLifeTime"); struct WithMultiLifeTime<'a, 'b>(&'a str, &'b str); impl TestBuildAction for WithMultiLifeTime<'_, '_> {} assert_eq!( WithMultiLifeTime("test", "test").name(), "WithMultiLifeTime" ); struct WithGeneric(T); impl TestBuildAction for WithGeneric {} assert_eq!(WithGeneric(3).name(), "WithGeneric"); } ================================================ FILE: build/ninja_gen/src/archives.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::collections::HashMap; use anyhow::Result; use camino::Utf8Path; use camino::Utf8PathBuf; use crate::action::BuildAction; use crate::input::BuildInput; use crate::inputs; use crate::Build; #[derive(Clone, Copy, Debug)] pub struct OnlineArchive { pub url: &'static str, pub sha256: &'static str, } #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum Platform { LinuxX64, LinuxArm, MacX64, MacArm, WindowsX64, WindowsArm, } impl Platform { pub fn current() -> Self { let os = std::env::consts::OS; let arch = std::env::consts::ARCH; match (os, arch) { ("linux", "x86_64") => Self::LinuxX64, ("linux", "aarch64") => Self::LinuxArm, ("macos", "x86_64") => Self::MacX64, ("macos", "aarch64") => Self::MacArm, ("windows", "x86_64") => Self::WindowsX64, ("windows", "aarch64") => Self::WindowsArm, _ => panic!("unsupported os/arch {os} {arch} - PR welcome!"), } } pub fn tls_feature() -> &'static str { match Self::current() { // On Linux, wheels are not allowed to link to OpenSSL, and linking setup // caused pain for AnkiDroid in the past. On other platforms, we stick to // native libraries, for smaller binaries. Platform::LinuxX64 | Platform::LinuxArm => "rustls", _ => "native-tls", } } pub fn as_rust_triple(&self) -> &'static str { match self { Platform::LinuxX64 => "x86_64-unknown-linux-gnu", Platform::LinuxArm => "aarch64-unknown-linux-gnu", Platform::MacX64 => "x86_64-apple-darwin", Platform::MacArm => "aarch64-apple-darwin", Platform::WindowsX64 => "x86_64-pc-windows-msvc", Platform::WindowsArm => "aarch64-pc-windows-msvc", } } } /// Append .exe to path if on Windows. pub fn with_exe(path: &str) -> Cow<'_, str> { if cfg!(windows) { format!("{path}.exe").into() } else { path.into() } } struct DownloadArchive { pub archive: OnlineArchive, } impl BuildAction for DownloadArchive { fn command(&self) -> &str { "$runner archive download $url $checksum $out" } fn files(&mut self, build: &mut impl crate::build::FilesHandle) { let (_, filename) = self.archive.url.rsplit_once('/').unwrap(); let output_path = Utf8Path::new("download").join(filename); build.add_variable("url", self.archive.url); build.add_variable("checksum", self.archive.sha256); build.add_outputs("out", &[output_path.into_string()]) } fn check_output_timestamps(&self) -> bool { true } } struct ExtractArchive<'a, I> { pub archive_path: BuildInput, /// The folder that the archive should be extracted into, relative to /// $builddir/extracted. If the archive contains a single top-level /// folder, its contents will be extracted into the provided folder, so /// that output like tool-1.2/ can be extracted into tool/. pub extraction_folder_name: &'a str, /// Files contained inside the archive, relative to the archive root, and /// excluding the top-level folder if it is the sole top-level entry. /// Any files you wish to use as part of subsequent rules /// must be declared here. pub file_manifest: HashMap<&'static str, I>, } impl ExtractArchive<'_, I> { fn extraction_folder(&self) -> Utf8PathBuf { Utf8Path::new("$builddir") .join("extracted") .join(self.extraction_folder_name) } } impl BuildAction for ExtractArchive<'_, I> where I: IntoIterator, I::Item: AsRef, { fn command(&self) -> &str { "$runner archive extract $in $extraction_folder" } fn files(&mut self, build: &mut impl crate::build::FilesHandle) { build.add_inputs("in", inputs![self.archive_path.clone()]); let folder = self.extraction_folder(); build.add_variable("extraction_folder", folder.to_string()); for (subgroup, files) in self.file_manifest.drain() { build.add_outputs_ext( subgroup, files .into_iter() .map(|f| folder.join(f.as_ref()).to_string()), !subgroup.is_empty(), ); } build.add_output_stamp(folder.with_extension("marker")); } fn name(&self) -> &'static str { "extract" } fn check_output_timestamps(&self) -> bool { true } } /// See [DownloadArchive] and [ExtractArchive]. pub fn download_and_extract( build: &mut Build, group_name: &str, archive: OnlineArchive, file_manifest: HashMap<&'static str, I>, ) -> Result<()> where I: IntoIterator, I::Item: AsRef, { let download_group = format!("download:{group_name}"); build.add_action(&download_group, DownloadArchive { archive })?; let extract_group = format!("extract:{group_name}"); build.add_action( extract_group, ExtractArchive { archive_path: inputs![format!(":{download_group}")], extraction_folder_name: group_name, file_manifest, }, )?; Ok(()) } pub fn empty_manifest() -> HashMap<&'static str, &'static [&'static str]> { Default::default() } ================================================ FILE: build/ninja_gen/src/bin/update_node.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::error::Error; use std::fs; use std::path::Path; use regex::Regex; use reqwest::blocking::Client; use serde_json::Value; #[derive(Debug)] struct NodeRelease { version: String, files: Vec, } #[derive(Debug)] struct NodeFile { filename: String, url: String, } fn main() -> Result<(), Box> { let release_info = fetch_node_release_info()?; let new_text = generate_node_archive_function(&release_info)?; update_node_text(&new_text)?; println!("Node.js archive function updated successfully!"); Ok(()) } fn fetch_node_release_info() -> Result> { let client = Client::new(); // Get the Node.js release info let response = client .get("https://nodejs.org/dist/index.json") .header("User-Agent", "anki-build-updater") .send()?; let releases: Vec = response.json()?; // Find the latest LTS release let latest = releases .iter() .find(|release| { // LTS releases have a non-false "lts" field release["lts"].as_str().is_some() && release["lts"] != false }) .ok_or("No LTS releases found")?; let version = latest["version"] .as_str() .ok_or("Version not found")? .to_string(); let files = latest["files"] .as_array() .ok_or("Files array not found")? .iter() .map(|f| f.as_str().unwrap_or("")) .collect::>(); let lts_name = latest["lts"].as_str().unwrap_or("unknown"); println!("Found Node.js LTS version: {version} ({lts_name})"); // Map platforms to their expected file keys and full filenames let platform_mapping = vec![ ( "linux-x64", "linux-x64", format!("node-{version}-linux-x64.tar.xz"), ), ( "linux-arm64", "linux-arm64", format!("node-{version}-linux-arm64.tar.xz"), ), ( "darwin-x64", "osx-x64-tar", format!("node-{version}-darwin-x64.tar.xz"), ), ( "darwin-arm64", "osx-arm64-tar", format!("node-{version}-darwin-arm64.tar.xz"), ), ( "win-x64", "win-x64-zip", format!("node-{version}-win-x64.zip"), ), ( "win-arm64", "win-arm64-zip", format!("node-{version}-win-arm64.zip"), ), ]; let mut node_files = Vec::new(); for (platform, file_key, filename) in platform_mapping { // Check if this file exists in the release if files.contains(&file_key) { let url = format!("https://nodejs.org/dist/{version}/{filename}"); node_files.push(NodeFile { filename: filename.clone(), url, }); println!("Found file for {platform}: {filename} (key: {file_key})"); } else { return Err( format!("File not found for {platform} (key: {file_key}): {filename}").into(), ); } } Ok(NodeRelease { version, files: node_files, }) } fn generate_node_archive_function(release: &NodeRelease) -> Result> { let client = Client::new(); // Fetch the SHASUMS256.txt file once println!("Fetching SHA256 checksums..."); let shasums_url = format!("https://nodejs.org/dist/{}/SHASUMS256.txt", release.version); let shasums_response = client .get(&shasums_url) .header("User-Agent", "anki-build-updater") .send()?; let shasums_text = shasums_response.text()?; // Create a mapping from filename patterns to platform names - using the exact // patterns we stored in files let platform_mapping = vec![ ("linux-x64.tar.xz", "LinuxX64"), ("linux-arm64.tar.xz", "LinuxArm"), ("darwin-x64.tar.xz", "MacX64"), ("darwin-arm64.tar.xz", "MacArm"), ("win-x64.zip", "WindowsX64"), ("win-arm64.zip", "WindowsArm"), ]; let mut platform_blocks = Vec::new(); for (file_pattern, platform_name) in platform_mapping { // Find the file that ends with this pattern if let Some(file) = release .files .iter() .find(|f| f.filename.ends_with(file_pattern)) { // Find the SHA256 for this file let sha256 = shasums_text .lines() .find(|line| line.contains(&file.filename)) .and_then(|line| line.split_whitespace().next()) .ok_or_else(|| format!("SHA256 not found for {}", file.filename))?; println!( "Found SHA256 for {}: {} => {}", platform_name, file.filename, sha256 ); let block = format!( " Platform::{} => OnlineArchive {{\n url: \"{}\",\n sha256: \"{}\",\n }},", platform_name, file.url, sha256 ); platform_blocks.push(block); } else { return Err(format!( "File not found for platform {platform_name}: no file ending with {file_pattern}" ) .into()); } } let function = format!( "pub fn node_archive(platform: Platform) -> OnlineArchive {{\n match platform {{\n{}\n }}\n}}", platform_blocks.join("\n") ); Ok(function) } fn update_node_text(new_function: &str) -> Result<(), Box> { let node_rs_content = read_node_rs()?; // Regex to match the entire node_archive function with proper multiline // matching let re = Regex::new( r"(?s)pub fn node_archive\(platform: Platform\) -> OnlineArchive \{.*?\n\s*\}\s*\n\s*\}", )?; let updated_content = re.replace(&node_rs_content, new_function); write_node_rs(&updated_content)?; Ok(()) } fn read_node_rs() -> Result> { // Use CARGO_MANIFEST_DIR to get the crate root, then find src/node.rs let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| "CARGO_MANIFEST_DIR not set")?; let path = Path::new(&manifest_dir).join("src").join("node.rs"); Ok(fs::read_to_string(path)?) } fn write_node_rs(content: &str) -> Result<(), Box> { // Use CARGO_MANIFEST_DIR to get the crate root, then find src/node.rs let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| "CARGO_MANIFEST_DIR not set")?; let path = Path::new(&manifest_dir).join("src").join("node.rs"); fs::write(path, content)?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_regex_replacement() { let sample_content = r#"Some other code pub fn node_archive(platform: Platform) -> OnlineArchive { match platform { Platform::LinuxX64 => OnlineArchive { url: "https://nodejs.org/dist/v20.11.0/node-v20.11.0-linux-x64.tar.xz", sha256: "old_hash", }, Platform::MacX64 => OnlineArchive { url: "https://nodejs.org/dist/v20.11.0/node-v20.11.0-darwin-x64.tar.xz", sha256: "old_hash", }, } } More code here"#; let new_function = r#"pub fn node_archive(platform: Platform) -> OnlineArchive { match platform { Platform::LinuxX64 => OnlineArchive { url: "https://nodejs.org/dist/v21.0.0/node-v21.0.0-linux-x64.tar.xz", sha256: "new_hash", }, Platform::MacX64 => OnlineArchive { url: "https://nodejs.org/dist/v21.0.0/node-v21.0.0-darwin-x64.tar.xz", sha256: "new_hash", }, } }"#; let re = Regex::new( r"(?s)pub fn node_archive\(platform: Platform\) -> OnlineArchive \{.*?\n\s*\}\s*\n\s*\}" ).unwrap(); let result = re.replace(sample_content, new_function); assert!(result.contains("v21.0.0")); assert!(result.contains("new_hash")); assert!(!result.contains("old_hash")); assert!(result.contains("Some other code")); assert!(result.contains("More code here")); } } ================================================ FILE: build/ninja_gen/src/bin/update_protoc.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::error::Error; use std::fs; use std::path::Path; use regex::Regex; use reqwest::blocking::Client; use serde_json::Value; use sha2::Digest; use sha2::Sha256; fn fetch_protoc_release_info() -> Result> { let client = Client::new(); println!("Fetching latest protoc release info from GitHub..."); // Fetch latest release info let response = client .get("https://api.github.com/repos/protocolbuffers/protobuf/releases/latest") .header("User-Agent", "Anki-Build-Script") .send()?; let release_info: Value = response.json()?; let assets = release_info["assets"] .as_array() .expect("assets should be an array"); // Map platform names to their corresponding asset patterns let platform_patterns = [ ("LinuxX64", "linux-x86_64"), ("LinuxArm", "linux-aarch_64"), ("MacX64", "osx-universal_binary"), // Mac uses universal binary for both ("MacArm", "osx-universal_binary"), ("WindowsX64", "win64"), // Windows uses x86 binary for both archs ("WindowsArm", "win64"), ]; let mut match_blocks = Vec::new(); for (platform, pattern) in platform_patterns { // Find the asset matching the platform pattern let asset = assets.iter().find(|asset| { let name = asset["name"].as_str().unwrap_or(""); name.starts_with("protoc-") && name.contains(pattern) && name.ends_with(".zip") }); if asset.is_none() { eprintln!("No asset found for platform {platform} pattern {pattern}"); continue; } let asset = asset.unwrap(); let download_url = asset["browser_download_url"].as_str().unwrap(); let asset_name = asset["name"].as_str().unwrap(); // Download the file and calculate SHA256 locally println!("Downloading and checksumming {asset_name} for {platform}..."); let response = client .get(download_url) .header("User-Agent", "Anki-Build-Script") .send()?; let bytes = response.bytes()?; let mut hasher = Sha256::new(); hasher.update(&bytes); let sha256 = format!("{:x}", hasher.finalize()); // Handle platform-specific match patterns let match_pattern = match platform { "MacX64" => "Platform::MacX64 | Platform::MacArm", "MacArm" => continue, // Skip MacArm since it's handled with MacX64 "WindowsX64" => "Platform::WindowsX64 | Platform::WindowsArm", "WindowsArm" => continue, // Skip WindowsArm since it's handled with WindowsX64 _ => &format!("Platform::{platform}"), }; match_blocks.push(format!( " {match_pattern} => {{\n OnlineArchive {{\n url: \"{download_url}\",\n sha256: \"{sha256}\",\n }}\n }}" )); } Ok(format!( "pub fn protoc_archive(platform: Platform) -> OnlineArchive {{\n match platform {{\n{}\n }}\n}}", match_blocks.join(",\n") )) } fn read_protobuf_rs() -> Result> { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()); let path = Path::new(&manifest_dir).join("src/protobuf.rs"); println!("Reading {}", path.display()); let content = fs::read_to_string(path)?; Ok(content) } fn update_protoc_text(old_text: &str, new_protoc_text: &str) -> Result> { let re = Regex::new(r"(?ms)^pub fn protoc_archive\(platform: Platform\) -> OnlineArchive \{.*?\n\}") .unwrap(); if !re.is_match(old_text) { return Err("Could not find protoc_archive function block to replace".into()); } let new_content = re.replace(old_text, new_protoc_text).to_string(); println!("Original lines: {}", old_text.lines().count()); println!("Updated lines: {}", new_content.lines().count()); Ok(new_content) } fn write_protobuf_rs(content: &str) -> Result<(), Box> { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()); let path = Path::new(&manifest_dir).join("src/protobuf.rs"); println!("Writing to {}", path.display()); fs::write(path, content)?; Ok(()) } fn main() -> Result<(), Box> { let new_protoc_archive = fetch_protoc_release_info()?; let content = read_protobuf_rs()?; let updated_content = update_protoc_text(&content, &new_protoc_archive)?; write_protobuf_rs(&updated_content)?; println!("Successfully updated protoc_archive function in protobuf.rs"); Ok(()) } ================================================ FILE: build/ninja_gen/src/bin/update_uv.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::error::Error; use std::fs; use std::path::Path; use regex::Regex; use reqwest::blocking::Client; use serde_json::Value; fn fetch_uv_release_info() -> Result> { let client = Client::new(); println!("Fetching latest uv release info from GitHub..."); // Fetch latest release info let response = client .get("https://api.github.com/repos/astral-sh/uv/releases/latest") .header("User-Agent", "Anki-Build-Script") .send()?; let release_info: Value = response.json()?; let assets = release_info["assets"] .as_array() .expect("assets should be an array"); // Map platform names to their corresponding asset patterns let platform_patterns = [ ("LinuxX64", "x86_64-unknown-linux-gnu"), ("LinuxArm", "aarch64-unknown-linux-gnu"), ("MacX64", "x86_64-apple-darwin"), ("MacArm", "aarch64-apple-darwin"), ("WindowsX64", "x86_64-pc-windows-msvc"), ("WindowsArm", "aarch64-pc-windows-msvc"), ]; let mut match_blocks = Vec::new(); for (platform, pattern) in platform_patterns { // Find the asset matching the platform pattern (the binary) let asset = assets.iter().find(|asset| { let name = asset["name"].as_str().unwrap_or(""); name.contains(pattern) && (name.ends_with(".tar.gz") || name.ends_with(".zip")) }); if asset.is_none() { eprintln!("No asset found for platform {platform} pattern {pattern}"); continue; } let asset = asset.unwrap(); let download_url = asset["browser_download_url"].as_str().unwrap(); let asset_name = asset["name"].as_str().unwrap(); // Find the corresponding .sha256 or .sha256sum asset let sha_asset = assets.iter().find(|a| { let name = a["name"].as_str().unwrap_or(""); name == format!("{asset_name}.sha256") || name == format!("{asset_name}.sha256sum") }); if sha_asset.is_none() { eprintln!("No sha256 asset found for {asset_name}"); continue; } let sha_asset = sha_asset.unwrap(); let sha_url = sha_asset["browser_download_url"].as_str().unwrap(); println!("Fetching SHA256 for {platform}..."); let sha_text = client .get(sha_url) .header("User-Agent", "Anki-Build-Script") .send()? .text()?; // The sha file is usually of the form: " " let sha256 = sha_text.split_whitespace().next().unwrap_or(""); match_blocks.push(format!( " Platform::{platform} => {{\n OnlineArchive {{\n url: \"{download_url}\",\n sha256: \"{sha256}\",\n }}\n }}" )); } Ok(format!( "pub fn uv_archive(platform: Platform) -> OnlineArchive {{\n match platform {{\n{}\n }}", match_blocks.join(",\n") )) } fn read_python_rs() -> Result> { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()); let path = Path::new(&manifest_dir).join("src/python.rs"); println!("Reading {}", path.display()); let content = fs::read_to_string(path)?; Ok(content) } fn update_uv_text(old_text: &str, new_uv_text: &str) -> Result> { let re = Regex::new(r"(?ms)^pub fn uv_archive\(platform: Platform\) -> OnlineArchive \{.*?\n\s*\}\s*\n\s*\}\s*\n\s*\}").unwrap(); if !re.is_match(old_text) { return Err("Could not find uv_archive function block to replace".into()); } let new_content = re.replace(old_text, new_uv_text).to_string(); println!("Original lines: {}", old_text.lines().count()); println!("Updated lines: {}", new_content.lines().count()); Ok(new_content) } fn write_python_rs(content: &str) -> Result<(), Box> { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()); let path = Path::new(&manifest_dir).join("src/python.rs"); println!("Writing to {}", path.display()); fs::write(path, content)?; Ok(()) } fn main() -> Result<(), Box> { let new_uv_archive = fetch_uv_release_info()?; let content = read_python_rs()?; let updated_content = update_uv_text(&content, &new_uv_archive)?; write_python_rs(&updated_content)?; println!("Successfully updated uv_archive function in python.rs"); Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_update_uv_text_with_actual_file() { let content = fs::read_to_string("src/python.rs").unwrap(); let original_lines = content.lines().count(); const EXPECTED_LINES_REMOVED: usize = 38; let updated = update_uv_text(&content, "").unwrap(); let updated_lines = updated.lines().count(); assert_eq!( updated_lines, original_lines - EXPECTED_LINES_REMOVED, "Expected line count to decrease by exactly {EXPECTED_LINES_REMOVED} lines (original: {original_lines}, updated: {updated_lines})" ); } } ================================================ FILE: build/ninja_gen/src/build.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::collections::HashSet; use std::fmt::Write; use anyhow::Result; use camino::Utf8PathBuf; use itertools::Itertools; use crate::action::BuildAction; use crate::archives::Platform; use crate::configure::ConfigureBuild; use crate::input::space_separated; use crate::input::BuildInput; #[derive(Debug)] pub struct Build { pub variables: HashMap<&'static str, String>, pub buildroot: Utf8PathBuf, pub build_profile: BuildProfile, pub pools: Vec<(&'static str, usize)>, pub trailing_text: String, pub host_platform: Platform, pub have_n2: bool, pub(crate) output_text: String, action_names: HashSet<&'static str>, pub(crate) groups: HashMap>, } impl Build { pub fn new() -> Result { let buildroot = if cfg!(windows) { Utf8PathBuf::from("out") } else { // on Unix systems we allow out to be a symlink to an external location Utf8PathBuf::from("out").canonicalize_utf8()? }; let mut build = Build { buildroot, build_profile: BuildProfile::from_env(), host_platform: Platform::current(), variables: Default::default(), pools: Default::default(), trailing_text: Default::default(), output_text: Default::default(), action_names: Default::default(), groups: Default::default(), have_n2: which::which("n2").is_ok(), }; build.add_action("build:configure", ConfigureBuild {})?; Ok(build) } pub fn variable(&mut self, name: &'static str, value: impl Into) { self.variables.insert(name, value.into()); } pub fn pool(&mut self, name: &'static str, size: usize) { self.pools.push((name, size)); } /// Evaluate the provided closure only once, using `key` to determine /// uniqueness. This key should not match any build action name. pub fn once_only( &mut self, key: &'static str, block: impl FnOnce(&mut Build) -> Result<()>, ) -> Result<()> { if self.action_names.insert(key) { block(self) } else { Ok(()) } } pub fn add_action(&mut self, group: impl AsRef, action: impl BuildAction) -> Result<()> { let group = group.as_ref(); let groups = split_groups(group); let group = groups[0]; let command = action.command(); let action_name = action.name(); // first invocation? let mut first_invocation = false; self.once_only(action_name, |build| { action.on_first_instance(build)?; first_invocation = true; Ok(()) })?; let action_name = action_name.to_string(); // ensure separator is delivered to runner, not shell let command = if cfg!(windows) || action.bypass_runner() { command.into() } else { command.replace("&&", "\"&&\"") }; let mut statement = BuildStatement::from_build_action( group, action, &self.groups, self.build_profile, self.have_n2, ); if first_invocation { let command = statement.prepare_command(command)?; writeln!( &mut self.output_text, "\ rule {action_name} command = {command}", ) .unwrap(); for (k, v) in &statement.rule_variables { writeln!(&mut self.output_text, " {k} = {v}").unwrap(); } self.output_text.push('\n'); } let (all_outputs, subgroups) = statement.render_into(&mut self.output_text); for group in groups { self.add_resolved_files_to_group(group, &all_outputs); } for (subgroup, outputs) in subgroups { let group_with_subgroup = format!("{group}:{subgroup}"); self.add_resolved_files_to_group(&group_with_subgroup, &outputs); } Ok(()) } /// Add one or more resolved files to a group. Does not add to the parent /// groups; that must be done by the caller. fn add_resolved_files_to_group<'a>( &mut self, group: &str, files: impl IntoIterator, ) { let buf = self.groups.entry(group.to_owned()).or_default(); buf.extend(files.into_iter().map(ToString::to_string)); } /// Allows you to add dependencies on files or build steps that aren't /// required to build the group itself, but are required by consumers of /// that group. Can also be used to allow substitution of local binaries /// for downloaded ones (eg :node_binary). pub fn add_dependency(&mut self, group: &str, deps: BuildInput) { let files = self.expand_inputs(deps); let groups = split_groups(group); for group in groups { self.add_resolved_files_to_group(group, &files); } } /// Outputs from a given build statement group. An error if no files have /// been registered yet. pub fn group_outputs(&self, group_name: &'static str) -> &[String] { self.groups .get(group_name) .unwrap_or_else(|| panic!("expected files in {group_name}")) } /// Single output from a given build statement group. An error if no files /// have been registered yet, or more than one file has been registered. pub fn group_output(&self, group_name: &'static str) -> String { let outputs = self.group_outputs(group_name); assert_eq!(outputs.len(), 1); outputs.first().unwrap().into() } pub fn expand_inputs(&self, inputs: impl AsRef) -> Vec { expand_inputs(inputs, &self.groups) } /// Expand inputs, the return a filtered subset. pub fn filter_inputs(&self, inputs: impl AsRef, func: F) -> Vec where F: FnMut(&String) -> bool, { self.expand_inputs(inputs) .into_iter() .filter(func) .collect() } pub fn inputs_with_suffix(&self, inputs: impl AsRef, ext: &str) -> Vec { self.filter_inputs(inputs, |f| f.ends_with(ext)) } } fn split_groups(group: &str) -> Vec<&str> { let mut rest = group; let mut groups = vec![group]; while let Some((head, _tail)) = rest.rsplit_once(':') { groups.push(head); rest = head; } groups } struct BuildStatement<'a> { /// Cache of outputs by already-evaluated build rules, allowing later rules /// to more easily consume the outputs of previous rules. existing_outputs: &'a HashMap>, rule_name: &'static str, // implicit refers to files that are not automatically assigned to $in and $out by Ninja, implicit_inputs: Vec, implicit_outputs: Vec, explicit_inputs: Vec, explicit_outputs: Vec, order_only_inputs: Vec, output_subsets: Vec<(String, Vec)>, variables: Vec<(String, String)>, rule_variables: Vec<(String, String)>, output_stamp: bool, env_vars: Vec, working_dir: Option, create_dirs: Vec, build_profile: BuildProfile, bypass_runner: bool, } impl BuildStatement<'_> { fn from_build_action<'a>( group: &str, mut action: impl BuildAction, existing_outputs: &'a HashMap>, build_profile: BuildProfile, have_n2: bool, ) -> BuildStatement<'a> { let mut stmt = BuildStatement { existing_outputs, rule_name: action.name(), implicit_inputs: Default::default(), implicit_outputs: Default::default(), explicit_inputs: Default::default(), explicit_outputs: Default::default(), order_only_inputs: Default::default(), variables: Default::default(), rule_variables: Default::default(), output_subsets: Default::default(), output_stamp: false, env_vars: Default::default(), working_dir: None, create_dirs: Default::default(), build_profile, bypass_runner: action.bypass_runner(), }; action.files(&mut stmt); if stmt.explicit_outputs.is_empty() && stmt.implicit_outputs.is_empty() { panic!("{} must generate at least one output", action.name()); } stmt.variables.push(("description".into(), group.into())); if action.check_output_timestamps() { stmt.rule_variables.push(("restat".into(), "1".into())); } if action.generator() { stmt.rule_variables.push(("generator".into(), "1".into())); } if let Some(pool) = action.concurrency_pool() { stmt.rule_variables.push(("pool".into(), pool.into())); } if have_n2 { if action.hide_success() { stmt.rule_variables .push(("hide_success".into(), "1".into())); } if action.hide_progress() { stmt.rule_variables .push(("hide_progress".into(), "1".into())); } } stmt } /// Returns a list of all output files, which `Build` will add to /// `existing_outputs`, and any subgroups. fn render_into(mut self, buf: &mut String) -> (Vec, Vec<(String, Vec)>) { let action_name = self.rule_name; self.implicit_inputs.sort(); self.implicit_outputs.sort(); let inputs_str = to_ninja_target_string( &self.explicit_inputs, &self.implicit_inputs, &self.order_only_inputs, ); let outputs_str = to_ninja_target_string(&self.explicit_outputs, &self.implicit_outputs, &[]); writeln!(buf, "build {outputs_str}: {action_name} {inputs_str}").unwrap(); for (key, value) in self.variables.iter().sorted() { writeln!(buf, " {key} = {value}").unwrap(); } writeln!(buf).unwrap(); let outputs_vec = { self.implicit_outputs.extend(self.explicit_outputs); self.implicit_outputs }; (outputs_vec, self.output_subsets) } fn prepare_command(&mut self, command: String) -> Result { if self.bypass_runner { return Ok(command); } if command.starts_with("$runner") { self.implicit_inputs.push("$runner".into()); return Ok(command); } let mut buf = String::from("$runner run "); if self.output_stamp { write!(&mut buf, "--stamp=$stamp ")?; } for var in &self.env_vars { write!(&mut buf, "--env=\"{var}\" ")?; } for dir in &self.create_dirs { write!(&mut buf, "--mkdir={dir} ")?; } if let Some(working_dir) = &self.working_dir { write!(&mut buf, "--cwd={working_dir} ")?; } buf.push_str(&command); Ok(buf) } } fn expand_inputs( input: impl AsRef, existing_outputs: &HashMap>, ) -> Vec { let mut vec = vec![]; input.as_ref().add_to_vec(&mut vec, existing_outputs); vec } #[derive(Debug, Eq, PartialEq, Clone, Copy)] pub enum BuildProfile { Debug, Release, ReleaseWithLto, } impl BuildProfile { fn from_env() -> Self { match std::env::var("RELEASE").unwrap_or_default().as_str() { "1" => Self::Release, "2" => Self::ReleaseWithLto, _ => Self::Debug, } } } pub trait FilesHandle { /// Add inputs to the build statement. Can be called multiple times with /// different variables. This is a shortcut for calling .expand_inputs() /// and then .add_inputs_vec() /// - If the variable name is non-empty, a variable of the same name will be /// created so the file list can be accessed in the command. By /// convention, this is often `in`. fn add_inputs(&mut self, variable: &'static str, inputs: impl AsRef); fn add_inputs_vec(&mut self, variable: &'static str, inputs: Vec); fn add_order_only_inputs(&mut self, variable: &'static str, inputs: impl AsRef); /// Add a variable that can be referenced in the command. fn add_variable(&mut self, name: impl Into, value: impl Into); fn expand_input(&self, input: &BuildInput) -> String; fn expand_inputs(&self, inputs: impl AsRef) -> Vec; /// Like [FilesHandle::add_outputs_ext], without adding a subgroup. fn add_outputs( &mut self, variable: &'static str, outputs: impl IntoIterator>, ) { self.add_outputs_ext(variable, outputs, false); } /// Add outputs to the build statement. Can be called multiple times with /// different variables. /// - Each output automatically has $builddir/ prefixed to it if it does not /// already start with it. /// - If the variable name is non-empty, a variable of the same name will be /// created so the file list can be accessed in the command. By /// convention, this is often `out`. /// - If subgroup is true, the files are also placed in a subgroup. Eg if a /// rule `foo` exists and subgroup `bar` is provided, the files are /// accessible via `:foo:bar`. The variable name must not be empty, or /// called `out`. fn add_outputs_ext( &mut self, variable: impl Into, outputs: impl IntoIterator>, subgroup: bool, ); /// Save an output stamp if the command completes successfully. Note that /// if you have bypassed the runner, you will need to create the file /// yourself. fn add_output_stamp(&mut self, path: impl Into); /// Set an env var for the duration of the provided command(s). /// Note this is defined once for the rule, so if the value should change /// for each command, `constant_value` should reference a `$variable` you /// have defined. fn add_env_var(&mut self, key: &str, constant_value: &str); /// Set the current working dir for the provided command(s). /// Note this is defined once for the rule, so if the value should change /// for each command, `constant_value` should reference a `$variable` you /// have defined. fn set_working_dir(&mut self, constant_value: &str); /// Ensure provided folder and parent folders are created before running /// the command. Can be called multiple times. Defines a variable pointing /// at the folder. fn create_dir_all(&mut self, key: &str, path: impl Into); fn build_profile(&self) -> BuildProfile; } impl FilesHandle for BuildStatement<'_> { fn add_inputs(&mut self, variable: &'static str, inputs: impl AsRef) { self.add_inputs_vec(variable, FilesHandle::expand_inputs(self, inputs)); } fn add_inputs_vec(&mut self, variable: &'static str, inputs: Vec) { match variable { "in" => self.explicit_inputs.extend(inputs), other_key => { if !other_key.is_empty() { self.add_variable(other_key, space_separated(&inputs)); } self.implicit_inputs.extend(inputs); } } } fn add_order_only_inputs(&mut self, variable: &'static str, inputs: impl AsRef) { let inputs = FilesHandle::expand_inputs(self, inputs); if !variable.is_empty() { self.add_variable(variable, space_separated(&inputs)) } self.order_only_inputs.extend(inputs); } fn add_variable(&mut self, key: impl Into, value: impl Into) { self.variables.push((key.into(), value.into())); } fn expand_input(&self, input: &BuildInput) -> String { let mut vec = Vec::with_capacity(1); input.add_to_vec(&mut vec, self.existing_outputs); if vec.len() != 1 { panic!("expected {input:?} to resolve to a single file; got ${vec:?}"); } vec.pop().unwrap() } fn add_outputs_ext( &mut self, variable: impl Into, outputs: impl IntoIterator>, subgroup: bool, ) { let outputs = outputs.into_iter().map(|v| { let v = v.as_ref(); let v = if !v.starts_with("$builddir/") && !v.starts_with("$builddir\\") { format!("$builddir/{v}") } else { v.to_owned() }; if cfg!(windows) { v.replace('/', "\\") } else { v } }); let variable = variable.into(); match variable.as_str() { "out" => self.explicit_outputs.extend(outputs), other_key => { let outputs: Vec<_> = outputs.collect(); if !other_key.is_empty() { self.add_variable(other_key, space_separated(&outputs)); } if subgroup { assert!(!other_key.is_empty()); self.output_subsets .push((other_key.to_owned(), outputs.to_owned())); } self.implicit_outputs.extend(outputs); } } } fn expand_inputs(&self, inputs: impl AsRef) -> Vec { expand_inputs(inputs, self.existing_outputs) } fn build_profile(&self) -> BuildProfile { self.build_profile } fn add_output_stamp(&mut self, path: impl Into) { self.output_stamp = true; self.add_outputs("stamp", vec![path.into()]); } fn add_env_var(&mut self, key: &str, constant_value: &str) { self.env_vars.push(format!("{key}={constant_value}")); } fn set_working_dir(&mut self, constant_value: &str) { self.working_dir = Some(constant_value.to_owned()); } fn create_dir_all(&mut self, key: &str, path: impl Into) { let path = path.into(); self.add_variable(key, &path); self.create_dirs.push(path); } } fn to_ninja_target_string( explicit: &[String], implicit: &[String], order_only: &[String], ) -> String { let mut joined = space_separated(explicit); if !implicit.is_empty() { joined.push_str(" | "); joined.push_str(&space_separated(implicit)); } if !order_only.is_empty() { joined.push_str(" || "); joined.push_str(&space_separated(order_only)); } joined } #[cfg(test)] mod test { use super::*; #[test] fn test_split_groups() { assert_eq!(&split_groups("foo"), &["foo"]); assert_eq!(&split_groups("foo:bar"), &["foo:bar", "foo"]); assert_eq!( &split_groups("foo:bar:baz"), &["foo:bar:baz", "foo:bar", "foo"] ); } } ================================================ FILE: build/ninja_gen/src/cargo.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anyhow::Result; use camino::Utf8Path; use camino::Utf8PathBuf; use crate::action::BuildAction; use crate::archives::with_exe; use crate::build::BuildProfile; use crate::build::FilesHandle; use crate::input::BuildInput; use crate::inputs; use crate::Build; #[derive(Debug, PartialEq, Eq)] pub enum RustOutput<'a> { Binary(&'a str), StaticLib(&'a str), DynamicLib(&'a str), /// (group_name, fully qualified path) Data(&'a str, &'a str), } impl RustOutput<'_> { pub fn name(&self) -> &str { match self { RustOutput::Binary(pkg) => pkg, RustOutput::StaticLib(pkg) => pkg, RustOutput::DynamicLib(pkg) => pkg, RustOutput::Data(name, _) => name, } } pub fn path( &self, rust_base: &Utf8Path, target: Option<&str>, build_profile: BuildProfile, ) -> String { let filename = match *self { RustOutput::Binary(package) => { if cfg!(windows) { format!("{package}.exe") } else { package.into() } } RustOutput::StaticLib(package) => format!("lib{package}.a"), RustOutput::DynamicLib(package) => { if cfg!(windows) { format!("{package}.dll") } else if cfg!(target_os = "macos") { format!("lib{package}.dylib") } else { format!("lib{package}.so") } } RustOutput::Data(_, path) => return path.to_string(), }; let mut path: Utf8PathBuf = rust_base.into(); if let Some(target) = target { path = path.join(target); } path = path.join(profile_output_dir(build_profile)).join(filename); path.to_string() } } fn profile_output_dir(profile: BuildProfile) -> &'static str { match profile { BuildProfile::Debug => "debug", BuildProfile::Release => "release", BuildProfile::ReleaseWithLto => "release-lto", } } #[derive(Debug, Default)] pub struct CargoBuild<'a> { pub inputs: BuildInput, pub outputs: &'a [RustOutput<'a>], pub target: Option<&'static str>, pub extra_args: &'a str, pub release_override: Option, } impl BuildAction for CargoBuild<'_> { fn command(&self) -> &str { "cargo build $release_arg $target_arg $cargo_flags $extra_args" } fn files(&mut self, build: &mut impl FilesHandle) { let release_build = self .release_override .unwrap_or_else(|| build.build_profile()); let release_arg = profile_arg_for_cargo(release_build).unwrap_or_default(); let target_arg = if let Some(target) = self.target { format!("--target {target}") } else { "".into() }; build.add_inputs("", &self.inputs); build.add_inputs( "", inputs![".cargo/config.toml", "rust-toolchain.toml", "Cargo.lock"], ); build.add_variable("release_arg", release_arg); build.add_variable("target_arg", target_arg); build.add_variable("extra_args", self.extra_args); let output_root = Utf8Path::new("$builddir/rust"); for output in self.outputs { let name = output.name(); let path = output.path(output_root, self.target, release_build); build.add_outputs_ext(name, vec![path], true); } } fn check_output_timestamps(&self) -> bool { true } fn on_first_instance(&self, build: &mut Build) -> Result<()> { setup_flags(build) } } fn profile_arg_for_cargo(profile: BuildProfile) -> Option<&'static str> { match profile { BuildProfile::Debug => None, BuildProfile::Release => Some("--release"), BuildProfile::ReleaseWithLto => Some("--profile release-lto"), } } fn setup_flags(build: &mut Build) -> Result<()> { build.once_only("cargo_flags_and_pool", |build| { build.variable("cargo_flags", "--locked"); Ok(()) }) } pub struct CargoTest { pub inputs: BuildInput, } impl BuildAction for CargoTest { fn command(&self) -> &str { "cargo nextest run --color=always --failure-output=final --status-level=none $cargo_flags" } fn files(&mut self, build: &mut impl FilesHandle) { build.add_inputs("", &self.inputs); build.add_inputs("", inputs![":cargo-nextest"]); build.add_env_var("ANKI_TEST_MODE", "1"); build.add_output_stamp("tests/cargo_test"); } fn on_first_instance(&self, build: &mut Build) -> Result<()> { build.add_action( "cargo-nextest", CargoInstall { binary_name: "cargo-nextest", args: "cargo-nextest --version 0.9.99 --locked --no-default-features --features default-no-update", }, )?; setup_flags(build) } } pub struct CargoClippy { pub inputs: BuildInput, } impl BuildAction for CargoClippy { fn command(&self) -> &str { "cargo clippy $cargo_flags --tests -- -Dclippy::dbg_macro -Dwarnings" } fn files(&mut self, build: &mut impl FilesHandle) { build.add_inputs( "", inputs![&self.inputs, "Cargo.lock", "rust-toolchain.toml"], ); build.add_output_stamp("tests/cargo_clippy"); } fn on_first_instance(&self, build: &mut Build) -> Result<()> { setup_flags(build) } } pub struct CargoFormat { pub inputs: BuildInput, pub check_only: bool, pub working_dir: Option<&'static str>, } impl BuildAction for CargoFormat { fn command(&self) -> &str { "cargo fmt $mode --all" } fn files(&mut self, build: &mut impl FilesHandle) { build.add_inputs("", &self.inputs); build.add_variable("mode", if self.check_only { "--check" } else { "" }); if let Some(working_dir) = self.working_dir { build.set_working_dir("$working_dir"); build.add_variable("working_dir", working_dir); } build.add_output_stamp(format!( "tests/cargo_format.{}", if self.check_only { "check" } else { "fmt" } )); } fn on_first_instance(&self, build: &mut Build) -> Result<()> { setup_flags(build) } } /// Use Cargo to download and build a Rust binary. If `binary_name` is `foo`, a /// `$foo` variable will be defined with the path to the binary. pub struct CargoInstall { pub binary_name: &'static str, /// eg 'foo --version 1.3' or '--git git://...' pub args: &'static str, } impl BuildAction for CargoInstall { fn command(&self) -> &str { "cargo install --color always $args --root $builddir" } fn files(&mut self, build: &mut impl FilesHandle) { build.add_variable("args", self.args); build.add_outputs("", vec![with_exe(&format!("bin/{}", self.binary_name))]) } fn check_output_timestamps(&self) -> bool { true } } pub struct CargoRun { pub binary_name: &'static str, pub cargo_args: &'static str, pub bin_args: &'static str, pub deps: BuildInput, } impl BuildAction for CargoRun { fn command(&self) -> &str { "cargo run --bin $binary $cargo_args -- $bin_args" } fn files(&mut self, build: &mut impl FilesHandle) { build.add_inputs("", &self.deps); build.add_variable("binary", self.binary_name); build.add_variable("cargo_args", self.cargo_args); build.add_variable("bin_args", self.bin_args); build.add_outputs("", vec![format!("phony-{}", self.binary_name)]); } } ================================================ FILE: build/ninja_gen/src/command.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use crate::action::BuildAction; use crate::input::space_separated; use crate::input::BuildInput; use crate::inputs; pub struct RunCommand<'a> { // Will be automatically included as a dependency pub command: &'static str, // Arguments to the script, eg `$in $out` or `$in > $out`. pub args: &'a str, pub inputs: HashMap<&'static str, BuildInput>, pub outputs: HashMap<&'static str, Vec<&'a str>>, } impl BuildAction for RunCommand<'_> { fn command(&self) -> &str { "$cmd $args" } fn files(&mut self, build: &mut impl crate::build::FilesHandle) { // Because we've defined a generic rule instead of making one for a specific use // case, we need to manually intepolate variables in the user-provided // args. let mut args = self.args.to_string(); for (key, inputs) in &self.inputs { let files = build.expand_inputs(inputs); build.add_inputs("", inputs); if !key.is_empty() { args = args.replace(&format!("${key}"), &space_separated(files)); } } for (key, outputs) in &self.outputs { if !key.is_empty() { let outputs = outputs.iter().map(|o| { if !o.starts_with("$builddir/") { format!("$builddir/{o}") } else { (*o).into() } }); args = args.replace(&format!("${key}"), &space_separated(outputs)); } } build.add_inputs("cmd", inputs![self.command]); build.add_variable("args", args); for outputs in self.outputs.values() { build.add_outputs("", outputs); } } } ================================================ FILE: build/ninja_gen/src/configure.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anyhow::Result; use crate::action::BuildAction; use crate::build::BuildProfile; use crate::build::FilesHandle; use crate::cargo::CargoBuild; use crate::cargo::RustOutput; use crate::glob; use crate::inputs; use crate::Build; pub struct ConfigureBuild {} impl BuildAction for ConfigureBuild { fn command(&self) -> &str { "$cmd" } fn files(&mut self, build: &mut impl FilesHandle) { build.add_inputs("cmd", inputs![":build:configure_bin"]); // reconfigure when external inputs change build.add_inputs("", inputs!["$builddir/env", ".version", ".git"]); build.add_outputs("", ["build.ninja"]) } fn on_first_instance(&self, build: &mut Build) -> Result<()> { build.add_action( "build:configure_bin", CargoBuild { inputs: inputs![glob!["build/**/*"]], outputs: &[RustOutput::Binary("configure")], target: None, extra_args: "-p configure", release_override: Some(BuildProfile::Debug), }, )?; Ok(()) } fn generator(&self) -> bool { true } fn check_output_timestamps(&self) -> bool { true } } ================================================ FILE: build/ninja_gen/src/copy.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use camino::Utf8Path; use crate::action::BuildAction; use crate::input::BuildInput; /// Copy the provided files into the specified destination folder. /// Directory structure is not preserved - eg foo/bar.js is copied /// into out/$output_folder/bar.js. pub struct CopyFiles<'a> { pub inputs: BuildInput, /// The folder (relative to the build folder) that files should be copied /// into. pub output_folder: &'a str, } impl BuildAction for CopyFiles<'_> { fn command(&self) -> &str { // The -f is because we may need to overwrite read-only files copied from Bazel. "cp -fr $in $builddir/$folder" } fn files(&mut self, build: &mut impl crate::build::FilesHandle) { let inputs = build.expand_inputs(&self.inputs); let output_folder = Utf8Path::new(self.output_folder); let outputs: Vec<_> = inputs .iter() .map(|f| output_folder.join(Utf8Path::new(f).file_name().unwrap())) .collect(); build.add_inputs("in", &self.inputs); build.add_outputs("", outputs); build.add_variable("folder", self.output_folder); } } /// Copy a single file to the provided output path, which should be relative to /// the output folder. This can be used to create a copy with a different name. pub struct CopyFile<'a> { pub input: BuildInput, pub output: &'a str, } impl BuildAction for CopyFile<'_> { fn command(&self) -> &str { "cp $in $out" } fn files(&mut self, build: &mut impl crate::build::FilesHandle) { build.add_inputs("in", &self.input); build.add_outputs("out", vec![self.output]); } } /// Create a symbolic link to the provided output path, which should be relative /// to the output folder. This can be used to create a copy with a different /// name. pub struct LinkFile<'a> { pub input: BuildInput, pub output: &'a str, } impl BuildAction for LinkFile<'_> { fn command(&self) -> &str { if cfg!(windows) { "cmd /c copy $in $out" } else { "ln -sf $in $out" } } fn files(&mut self, build: &mut impl crate::build::FilesHandle) { build.add_inputs("in", &self.input); build.add_outputs("out", vec![self.output]); } fn check_output_timestamps(&self) -> bool { true } } ================================================ FILE: build/ninja_gen/src/git.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anyhow::Result; use itertools::Itertools; use super::*; use crate::action::BuildAction; use crate::input::BuildInput; pub struct SyncSubmodule { pub path: &'static str, pub offline_build: bool, } impl BuildAction for SyncSubmodule { fn command(&self) -> &str { if self.offline_build { "echo OFFLINE_BUILD is set, skipping git repository update for $path" } else { "git -c protocol.file.allow=always submodule update --checkout --init $path" } } fn files(&mut self, build: &mut impl build::FilesHandle) { if !self.offline_build { if let Some(head) = locate_git_head() { build.add_inputs("", head); } else { println!("Warning, .git/HEAD not found; submodules may be stale"); } } build.add_variable("path", self.path); build.add_output_stamp(format!("git/{}", self.path)); } fn on_first_instance(&self, build: &mut Build) -> Result<()> { build.pool("git", 1); Ok(()) } fn concurrency_pool(&self) -> Option<&'static str> { Some("git") } } /// We check the mtime of .git/HEAD to detect when we should sync submodules. /// If this repo is a submodule of another project, .git/HEAD will not exist, /// and we fall back on .git/modules/*/HEAD in a parent folder instead. fn locate_git_head() -> Option { let standard_path = Utf8Path::new(".git/HEAD"); if standard_path.exists() { return Some(inputs![standard_path.to_string()]); } let mut folder = Utf8PathBuf::from_path_buf( dunce::canonicalize(Utf8Path::new(".").canonicalize().unwrap()).unwrap(), ) .unwrap(); loop { let path = folder.join(".git").join("modules"); if path.exists() { let heads = path .read_dir_utf8() .unwrap() .filter_map(|p| { let head = p.unwrap().path().join("HEAD"); if head.exists() { Some(head.as_str().replace(':', "$:")) } else { None } }) .collect_vec(); return Some(inputs![heads]); } if let Some(parent) = folder.parent() { folder = parent.to_owned(); } else { return None; } } } ================================================ FILE: build/ninja_gen/src/hash.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::hash_map::DefaultHasher; use std::hash::Hash; use std::hash::Hasher; pub fn simple_hash(hashable: impl Hash) -> u64 { let mut hasher = DefaultHasher::new(); hashable.hash(&mut hasher); hasher.finish() } ================================================ FILE: build/ninja_gen/src/input.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::fmt::Display; use std::sync::LazyLock; use camino::Utf8PathBuf; #[derive(Debug, Clone, Hash, Default)] pub enum BuildInput { Single(String), Multiple(Vec), Glob(Glob), Inputs(Vec), #[default] Empty, } impl AsRef for BuildInput { fn as_ref(&self) -> &BuildInput { self } } impl From for BuildInput { fn from(v: String) -> Self { BuildInput::Single(v) } } impl From<&str> for BuildInput { fn from(v: &str) -> Self { BuildInput::Single(v.to_owned()) } } impl From> for BuildInput { fn from(v: Vec) -> Self { BuildInput::Multiple(v) } } impl From for BuildInput { fn from(v: Glob) -> Self { BuildInput::Glob(v) } } impl From<&BuildInput> for BuildInput { fn from(v: &BuildInput) -> Self { BuildInput::Inputs(vec![v.clone()]) } } impl From<&[BuildInput]> for BuildInput { fn from(v: &[BuildInput]) -> Self { BuildInput::Inputs(v.to_vec()) } } impl From> for BuildInput { fn from(v: Vec) -> Self { BuildInput::Inputs(v) } } impl From for BuildInput { fn from(v: Utf8PathBuf) -> Self { BuildInput::Single(v.into_string()) } } impl BuildInput { pub fn add_to_vec( &self, vec: &mut Vec, exisiting_outputs: &HashMap>, ) { let mut resolve_and_add = |value: &str| { if let Some(stripped) = value.strip_prefix(':') { let files = exisiting_outputs.get(stripped).unwrap_or_else(|| { println!("{:?}", &exisiting_outputs); panic!("input referenced {value}, but rule missing/not processed"); }); for file in files { vec.push(file.into()) } } else { vec.push(value.into()); } }; match self { BuildInput::Single(s) => resolve_and_add(s), BuildInput::Multiple(v) => { for item in v { resolve_and_add(item); } } BuildInput::Glob(glob) => { for path in glob.resolve() { vec.push(path.into_string()); } } BuildInput::Inputs(inputs) => { for input in inputs { input.add_to_vec(vec, exisiting_outputs) } } BuildInput::Empty => {} } } } #[derive(Debug, Clone, Hash)] pub struct Glob { pub include: String, pub exclude: Option, } static CACHED_FILES: LazyLock> = LazyLock::new(cache_files); /// Walking the source tree once instead of for each glob yields ~4x speed /// improvements. fn cache_files() -> Vec { walkdir::WalkDir::new(".") // ensure the output order is predictable .sort_by_file_name() .into_iter() .filter_entry(move |e| { // don't walk into symlinks, or the top-level out/, or .git !(e.path_is_symlink() || (e.depth() == 1 && (e.file_name() == "out" || e.file_name() == ".git"))) }) .filter_map(move |e| { let path = e.as_ref().unwrap().path().strip_prefix("./").unwrap(); if !path.is_dir() { Some(Utf8PathBuf::from_path_buf(path.to_owned()).unwrap()) } else { None } }) .collect() } impl Glob { pub fn resolve(&self) -> impl Iterator { let include = globset::GlobBuilder::new(&self.include) .literal_separator(true) .build() .unwrap() .compile_matcher(); let exclude = self.exclude.as_ref().map(|glob| { globset::GlobBuilder::new(glob) .literal_separator(true) .build() .unwrap() .compile_matcher() }); CACHED_FILES.iter().filter_map(move |path| { if include.is_match(path) { let excluded = exclude .as_ref() .map(|exclude| exclude.is_match(path)) .unwrap_or_default(); if !excluded { return Some(path.to_owned()); } } None }) } } pub fn space_separated(iter: I) -> String where I: IntoIterator, I::Item: Display, { itertools::join(iter, " ") } ================================================ FILE: build/ninja_gen/src/lib.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub mod action; pub mod archives; pub mod build; pub mod cargo; pub mod command; pub mod configure; pub mod copy; pub mod git; pub mod hash; pub mod input; pub mod node; pub mod protobuf; pub mod python; pub mod render; pub mod rsync; pub mod sass; pub use build::Build; pub use camino::Utf8Path; pub use camino::Utf8PathBuf; pub use maplit::hashmap; pub use which::which; #[macro_export] macro_rules! inputs { ($($param:expr),+ $(,)?) => { $crate::input::BuildInput::from(vec![$($crate::input::BuildInput::from($param)),+]) }; () => { $crate::input::BuildInput::Empty }; } #[macro_export] macro_rules! glob { ($include:expr) => { $crate::input::Glob { include: $include.into(), exclude: None, } }; ($include:expr, $exclude:expr) => { $crate::input::Glob { include: $include.into(), exclude: Some($exclude.into()), } }; } ================================================ FILE: build/ninja_gen/src/node.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::collections::HashMap; use anyhow::Result; use itertools::Itertools; use super::*; use crate::action::BuildAction; use crate::archives::download_and_extract; use crate::archives::OnlineArchive; use crate::archives::Platform; use crate::hash::simple_hash; use crate::input::space_separated; use crate::input::BuildInput; pub fn node_archive(platform: Platform) -> OnlineArchive { match platform { Platform::LinuxX64 => OnlineArchive { url: "https://nodejs.org/dist/v22.17.0/node-v22.17.0-linux-x64.tar.xz", sha256: "325c0f1261e0c61bcae369a1274028e9cfb7ab7949c05512c5b1e630f7e80e12", }, Platform::LinuxArm => OnlineArchive { url: "https://nodejs.org/dist/v22.17.0/node-v22.17.0-linux-arm64.tar.xz", sha256: "140aee84be6774f5fb3f404be72adbe8420b523f824de82daeb5ab218dab7b18", }, Platform::MacX64 => OnlineArchive { url: "https://nodejs.org/dist/v22.17.0/node-v22.17.0-darwin-x64.tar.xz", sha256: "f79de1f64df4ac68493a344bb5ab7d289d0275271e87b543d1278392c9de778a", }, Platform::MacArm => OnlineArchive { url: "https://nodejs.org/dist/v22.17.0/node-v22.17.0-darwin-arm64.tar.xz", sha256: "cc9cc294eaf782dd93c8c51f460da610cc35753c6a9947411731524d16e97914", }, Platform::WindowsX64 => OnlineArchive { url: "https://nodejs.org/dist/v22.17.0/node-v22.17.0-win-x64.zip", sha256: "721ab118a3aac8584348b132767eadf51379e0616f0db802cc1e66d7f0d98f85", }, Platform::WindowsArm => OnlineArchive { url: "https://nodejs.org/dist/v22.17.0/node-v22.17.0-win-arm64.zip", sha256: "78355dc9ca117bb71d3f081e4b1b281855e2b134f3939bb0ca314f7567b0e621", }, } } pub struct YarnSetup {} impl BuildAction for YarnSetup { fn command(&self) -> &str { if cfg!(windows) { "corepack.cmd enable yarn" } else { "corepack enable yarn" } } fn files(&mut self, build: &mut impl build::FilesHandle) { build.add_inputs("", inputs![":node_binary"]); build.add_outputs_ext( "bin", vec![if cfg!(windows) { "extracted/node/yarn.cmd" } else { "extracted/node/bin/yarn" }], true, ); } fn check_output_timestamps(&self) -> bool { true } } pub struct YarnInstall<'a> { pub package_json_and_lock: BuildInput, pub exports: HashMap<&'a str, Vec>>, } impl BuildAction for YarnInstall<'_> { fn command(&self) -> &str { "$runner yarn $yarn $out" } fn files(&mut self, build: &mut impl build::FilesHandle) { build.add_inputs("", &self.package_json_and_lock); build.add_inputs("yarn", inputs![":yarn:bin"]); build.add_outputs("out", vec!["node_modules/.marker"]); for (key, value) in &self.exports { let outputs: Vec<_> = value.iter().map(|o| format!("node_modules/{o}")).collect(); build.add_outputs_ext(*key, outputs, true); } } fn check_output_timestamps(&self) -> bool { true } } fn with_cmd_ext(bin: &str) -> Cow<'_, str> { if cfg!(windows) { format!("{bin}.cmd").into() } else { bin.into() } } pub fn setup_node( build: &mut Build, archive: OnlineArchive, binary_exports: &[&'static str], mut data_exports: HashMap<&str, Vec>>, ) -> Result<()> { let node_binary = match std::env::var("NODE_BINARY") { Ok(path) => { assert!( Utf8Path::new(&path).is_absolute(), "NODE_BINARY must be absolute" ); path.into() } Err(_) => { download_and_extract( build, "node", archive, hashmap! { "bin" => vec![if cfg!(windows) { "node.exe" } else { "bin/node" }], "npm" => vec![if cfg!(windows) { "npm.cmd " } else { "bin/npm" }] }, )?; inputs![":extract:node:bin"] } }; build.add_dependency("node_binary", node_binary); match std::env::var("YARN_BINARY") { Ok(path) => { assert!( Utf8Path::new(&path).is_absolute(), "YARN_BINARY must be absolute" ); build.add_dependency("yarn:bin", inputs![path]); } Err(_) => { build.add_action("yarn", YarnSetup {})?; } }; for binary in binary_exports { data_exports.insert( *binary, vec![format!(".bin/{}", with_cmd_ext(binary)).into()], ); } build.add_action( "node_modules", YarnInstall { package_json_and_lock: inputs!["yarn.lock", "package.json"], exports: data_exports, }, )?; Ok(()) } pub struct EsbuildScript<'a> { pub script: BuildInput, pub entrypoint: BuildInput, pub deps: BuildInput, /// .js will be appended, and any extra extensions pub output_stem: &'a str, /// eg ['.css', '.html'] pub extra_exts: &'a [&'a str], } impl BuildAction for EsbuildScript<'_> { fn command(&self) -> &str { "$node_bin $script $entrypoint $out" } fn files(&mut self, build: &mut impl build::FilesHandle) { build.add_inputs("node_bin", inputs![":node_binary"]); build.add_inputs("script", &self.script); build.add_inputs("entrypoint", &self.entrypoint); build.add_inputs("", inputs!["yarn.lock", ":node_modules", &self.deps]); build.add_inputs("", inputs!["out/env"]); let stem = self.output_stem; let mut outs = vec![format!("{stem}.js")]; outs.extend(self.extra_exts.iter().map(|ext| format!("{stem}.{ext}"))); build.add_outputs("out", outs); } } pub struct DPrint { pub inputs: BuildInput, pub check_only: bool, } impl BuildAction for DPrint { fn command(&self) -> &str { "$dprint $mode" } fn files(&mut self, build: &mut impl build::FilesHandle) { build.add_inputs("dprint", inputs![":node_modules:dprint"]); build.add_inputs("", &self.inputs); let mode = if self.check_only { "check" } else { "fmt" }; build.add_variable("mode", mode); build.add_output_stamp(format!("tests/dprint.{mode}")); } } pub struct Prettier { pub inputs: BuildInput, pub check_only: bool, } impl BuildAction for Prettier { fn command(&self) -> &str { "$yarn prettier --cache $mode $pattern" } fn files(&mut self, build: &mut impl build::FilesHandle) { build.add_inputs("yarn", inputs![":yarn:bin"]); build.add_inputs("prettier", inputs![":node_modules:prettier"]); build.add_inputs("", &self.inputs); build.add_variable("pattern", r#""**/*.svelte""#); let (file_ext, mode) = if self.check_only { ("fmt", "--check") } else { ("check", "--write") }; build.add_variable("mode", mode); build.add_output_stamp(format!("tests/prettier.{file_ext}")); } } pub struct SvelteCheck { pub tsconfig: BuildInput, pub inputs: BuildInput, } impl BuildAction for SvelteCheck { fn command(&self) -> &str { "$yarn svelte-check:once" } fn files(&mut self, build: &mut impl build::FilesHandle) { build.add_inputs("svelte-check", inputs![":node_modules:svelte-check"]); build.add_inputs("tsconfig", &self.tsconfig); build.add_inputs("yarn", inputs![":yarn:bin"]); build.add_inputs("", &self.inputs); build.add_inputs("", inputs!["yarn.lock"]); let hash = simple_hash(&self.tsconfig); build.add_output_stamp(format!("tests/svelte-check.{hash}")); } fn hide_progress(&self) -> bool { true } } pub struct TypescriptCheck { pub tsconfig: BuildInput, pub inputs: BuildInput, } impl BuildAction for TypescriptCheck { fn command(&self) -> &str { "$tsc --noEmit -p $tsconfig" } fn files(&mut self, build: &mut impl build::FilesHandle) { build.add_inputs("tsc", inputs![":node_modules:tsc"]); build.add_inputs("tsconfig", &self.tsconfig); build.add_inputs("", &self.inputs); build.add_inputs("", inputs!["yarn.lock"]); let hash = simple_hash(&self.tsconfig); build.add_output_stamp(format!("tests/typescript.{hash}")); } } pub struct Eslint<'a> { pub folder: &'a str, pub inputs: BuildInput, pub eslint_rc: BuildInput, pub fix: bool, } impl BuildAction for Eslint<'_> { fn command(&self) -> &str { "$eslint --max-warnings=0 -c $eslint_rc $fix $folder" } fn files(&mut self, build: &mut impl build::FilesHandle) { build.add_inputs("eslint", inputs![":node_modules:eslint"]); build.add_inputs("eslint_rc", &self.eslint_rc); build.add_inputs("in", &self.inputs); build.add_inputs("", inputs!["yarn.lock", "ts/tsconfig.json"]); build.add_variable("fix", if self.fix { "--fix" } else { "" }); build.add_variable("folder", self.folder); let hash = simple_hash(self.folder); let kind = if self.fix { "fix" } else { "check" }; build.add_output_stamp(format!("tests/eslint.{kind}.{hash}")); } } pub struct ViteTest { pub deps: BuildInput, } impl BuildAction for ViteTest { fn command(&self) -> &str { "$yarn vitest:once" } fn files(&mut self, build: &mut impl build::FilesHandle) { build.add_inputs("vitest", inputs![":node_modules:vitest"]); build.add_inputs("yarn", inputs![":yarn:bin"]); build.add_inputs("", &self.deps); build.add_output_stamp("tests/vitest"); } } pub struct SqlFormat { pub inputs: BuildInput, pub check_only: bool, } impl BuildAction for SqlFormat { fn command(&self) -> &str { "$tsx $sql_format $mode $in" } fn files(&mut self, build: &mut impl build::FilesHandle) { build.add_inputs("tsx", inputs![":node_modules:tsx"]); build.add_inputs("sql_format", inputs!["ts/tools/sql_format.ts"]); build.add_inputs("in", &self.inputs); let mode = if self.check_only { "check" } else { "fix" }; build.add_variable("mode", mode); build.add_output_stamp(format!("tests/sql_format.{mode}")); } } pub struct GenTypescriptProto<'a> { pub protos: BuildInput, pub include_dirs: &'a [&'a str], /// Automatically created. pub out_dir: &'a str, /// Can be used to adjust the output js/dts files to point to out_dir. pub out_path_transform: fn(&str) -> String, /// Script to apply modifications to the generated files. pub ts_transform_script: &'static str, } impl BuildAction for GenTypescriptProto<'_> { fn command(&self) -> &str { "$protoc $includes $in \ --plugin $gen-es --es_out $out_dir && \ $tsx $transform_script $out_dir" } fn files(&mut self, build: &mut impl build::FilesHandle) { let proto_files = build.expand_inputs(&self.protos); let output_files: Vec<_> = proto_files .iter() .flat_map(|f| { let js_path = f.replace(".proto", "_pb.js"); let dts_path = f.replace(".proto", "_pb.d.ts"); [ (self.out_path_transform)(&js_path), (self.out_path_transform)(&dts_path), ] }) .collect(); build.create_dir_all("out_dir", self.out_dir); build.add_variable( "includes", self.include_dirs .iter() .map(|d| format!("-I {d}")) .join(" "), ); build.add_inputs("protoc", inputs![":protoc_binary"]); build.add_inputs("gen-es", inputs![":node_modules:protoc-gen-es"]); build.add_inputs_vec("in", proto_files); build.add_inputs("", inputs!["yarn.lock"]); build.add_inputs("tsx", inputs![":node_modules:tsx"]); build.add_inputs("transform_script", inputs![self.ts_transform_script]); build.add_outputs("", output_files); } } pub struct CompileSass<'a> { pub input: BuildInput, pub output: &'a str, pub deps: BuildInput, pub load_paths: Vec<&'a str>, } impl BuildAction for CompileSass<'_> { fn command(&self) -> &str { "$sass -s compressed $args $in -- $out" } fn files(&mut self, build: &mut impl build::FilesHandle) { build.add_inputs("sass", inputs![":node_modules:sass"]); build.add_inputs("in", &self.input); build.add_inputs("", &self.deps); let args = space_separated(self.load_paths.iter().map(|path| format!("-I {path}"))); build.add_variable("args", args); build.add_outputs("out", vec![self.output]); } } /// Usually we rely on esbuild to transpile our .ts files on the fly, but when /// we want generated code to be able to import a .ts file, we need to use /// typescript to generate .js/.d.ts files, or types can't be looked up, and /// esbuild can't find the file to bundle. pub struct CompileTypescript<'a> { pub ts_files: BuildInput, /// Automatically created. pub out_dir: &'a str, /// Can be used to adjust the output js/dts files to point to out_dir. pub out_path_transform: fn(&str) -> String, } impl BuildAction for CompileTypescript<'_> { fn command(&self) -> &str { "$tsc $in --outDir $out_dir -d --skipLibCheck --types node" } fn files(&mut self, build: &mut impl build::FilesHandle) { build.add_inputs("tsc", inputs![":node_modules:tsc"]); build.add_inputs("in", &self.ts_files); build.add_inputs("", inputs!["yarn.lock"]); let ts_files = build.expand_inputs(&self.ts_files); let output_files: Vec<_> = ts_files .iter() .flat_map(|f| { let js_path = f.replace(".ts", ".js"); let dts_path = f.replace(".ts", ".d.ts"); [ (self.out_path_transform)(&js_path), (self.out_path_transform)(&dts_path), ] }) .collect(); build.create_dir_all("out_dir", self.out_dir); build.add_outputs("", output_files); } } /// The output_folder will be declared as a build output, but each file inside /// it is not declared, as the files will vary. pub struct SveltekitBuild { pub output_folder: BuildInput, pub deps: BuildInput, } impl BuildAction for SveltekitBuild { fn command(&self) -> &str { if std::env::var("HMR").is_err() { "$yarn build" } else { "echo" } } fn files(&mut self, build: &mut impl build::FilesHandle) { build.add_inputs("node_modules", inputs![":node_modules"]); build.add_inputs("yarn", inputs![":yarn:bin"]); build.add_inputs("", &self.deps); build.add_inputs("", inputs!["yarn.lock"]); build.add_output_stamp("sveltekit.marker"); build.add_outputs_ext("folder", vec!["sveltekit"], true); } } ================================================ FILE: build/ninja_gen/src/protobuf.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::env; use anyhow::Result; use camino::Utf8Path; use maplit::hashmap; use crate::action::BuildAction; use crate::archives::download_and_extract; use crate::archives::with_exe; use crate::archives::OnlineArchive; use crate::archives::Platform; use crate::hash::simple_hash; use crate::input::BuildInput; use crate::inputs; use crate::Build; pub fn protoc_archive(platform: Platform) -> OnlineArchive { match platform { Platform::LinuxX64 => { OnlineArchive { url: "https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-linux-x86_64.zip", sha256: "96553041f1a91ea0efee963cb16f462f5985b4d65365f3907414c360044d8065", } }, Platform::LinuxArm => { OnlineArchive { url: "https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-linux-aarch_64.zip", sha256: "6c554de11cea04c56ebf8e45b54434019b1cd85223d4bbd25c282425e306ecc2", } }, Platform::MacX64 | Platform::MacArm => { OnlineArchive { url: "https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-osx-universal_binary.zip", sha256: "99ea004549c139f46da5638187a85bbe422d78939be0fa01af1aa8ab672e395f", } }, Platform::WindowsX64 | Platform::WindowsArm => { OnlineArchive { url: "https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-win64.zip", sha256: "70381b116ab0d71cb6a5177d9b17c7c13415866603a0fd40d513dafe32d56c35", } } } } fn clang_format_archive(platform: Platform) -> OnlineArchive { match platform { Platform::LinuxX64 => { OnlineArchive { url: "https://github.com/ankitects/clang-format-binaries/releases/download/anki-2021-01-09/clang-format_linux_x86_64.zip", sha256: "64060bc4dbca30d0d96aab9344e2783008b16e1cae019a2532f1126ca5ec5449", } } Platform::LinuxArm => { // todo: replace with arm64 binary OnlineArchive { url: "https://github.com/ankitects/clang-format-binaries/releases/download/anki-2021-01-09/clang-format_linux_x86_64.zip", sha256: "64060bc4dbca30d0d96aab9344e2783008b16e1cae019a2532f1126ca5ec5449", } } Platform::MacX64 | Platform::MacArm => { OnlineArchive { url: "https://github.com/ankitects/clang-format-binaries/releases/download/anki-2021-01-09/clang-format_macos_x86_64.zip", sha256: "238be68d9478163a945754f06a213483473044f5a004c4125d3d9d8d3556466e", } } Platform::WindowsX64 | Platform::WindowsArm=> { OnlineArchive { url: "https://github.com/ankitects/clang-format-binaries/releases/download/anki-2021-01-09/clang-format_windows_x86_64.zip", sha256: "7d9f6915e3f0fb72407830f0fc37141308d2e6915daba72987a52f309fbeaccc", } } } } pub struct ClangFormat { pub inputs: BuildInput, pub check_only: bool, } impl BuildAction for ClangFormat { fn command(&self) -> &str { "$clang-format --style=google $args $in" } fn files(&mut self, build: &mut impl crate::build::FilesHandle) { build.add_inputs("clang-format", inputs![":extract:clang-format:bin"]); build.add_inputs("in", &self.inputs); let (args, mode) = if self.check_only { ("--dry-run -ferror-limit=1 -Werror", "check") } else { ("-i", "fix") }; build.add_variable("args", args); let hash = simple_hash(&self.inputs); build.add_output_stamp(format!("tests/clang-format.{mode}.{hash}")); } fn on_first_instance(&self, build: &mut crate::Build) -> anyhow::Result<()> { let binary = with_exe("clang-format"); download_and_extract( build, "clang-format", clang_format_archive(build.host_platform), hashmap! { "bin" => [binary] }, ) } } pub fn setup_protoc(build: &mut Build) -> Result<()> { let protoc_binary = match env::var("PROTOC_BINARY") { Ok(path) => { assert!( Utf8Path::new(&path).is_absolute(), "PROTOC_BINARY must be absolute" ); path.into() } Err(_) => { download_and_extract( build, "protoc", protoc_archive(build.host_platform), hashmap! { "bin" => [with_exe("bin/protoc")] }, )?; inputs![":extract:protoc:bin"] } }; build.add_dependency("protoc_binary", protoc_binary); Ok(()) } pub fn check_proto(build: &mut Build, inputs: BuildInput) -> Result<()> { build.add_action( "check:format:proto", ClangFormat { inputs: inputs.clone(), check_only: true, }, )?; build.add_action( "format:proto", ClangFormat { inputs, check_only: false, }, )?; Ok(()) } ================================================ FILE: build/ninja_gen/src/python.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::env; use anki_io::read_file; use anyhow::Result; use camino::Utf8Path; use maplit::hashmap; use crate::action::BuildAction; use crate::archives::download_and_extract; use crate::archives::with_exe; use crate::archives::OnlineArchive; use crate::archives::Platform; use crate::hash::simple_hash; use crate::input::BuildInput; use crate::inputs; use crate::Build; // To update, run 'cargo run --bin update_uv'. // You'll need to do this when bumping Python versions, as uv bakes in // the latest known version. // When updating Python version, make sure to update version tag in BuildWheel // too. pub fn uv_archive(platform: Platform) -> OnlineArchive { match platform { Platform::LinuxX64 => { OnlineArchive { url: "https://github.com/astral-sh/uv/releases/download/0.7.13/uv-x86_64-unknown-linux-gnu.tar.gz", sha256: "909278eb197c5ed0e9b5f16317d1255270d1f9ea4196e7179ce934d48c4c2545", } }, Platform::LinuxArm => { OnlineArchive { url: "https://github.com/astral-sh/uv/releases/download/0.7.13/uv-aarch64-unknown-linux-gnu.tar.gz", sha256: "0b2ad9fe4295881615295add8cc5daa02549d29cc9a61f0578e397efcf12f08f", } }, Platform::MacX64 => { OnlineArchive { url: "https://github.com/astral-sh/uv/releases/download/0.7.13/uv-x86_64-apple-darwin.tar.gz", sha256: "d785753ac092e25316180626aa691c5dfe1fb075290457ba4fdb72c7c5661321", } }, Platform::MacArm => { OnlineArchive { url: "https://github.com/astral-sh/uv/releases/download/0.7.13/uv-aarch64-apple-darwin.tar.gz", sha256: "721f532b73171586574298d4311a91d5ea2c802ef4db3ebafc434239330090c6", } }, Platform::WindowsX64 => { OnlineArchive { url: "https://github.com/astral-sh/uv/releases/download/0.7.13/uv-x86_64-pc-windows-msvc.zip", sha256: "e199b10bef1a7cc540014483e7f60f825a174988f41020e9d2a6b01bd60f0669", } }, Platform::WindowsArm => { OnlineArchive { url: "https://github.com/astral-sh/uv/releases/download/0.7.13/uv-aarch64-pc-windows-msvc.zip", sha256: "bb40708ad549ad6a12209cb139dd751bf0ede41deb679ce7513ce197bd9ef234", } } } } pub fn setup_uv(build: &mut Build, platform: Platform) -> Result<()> { let uv_binary = match env::var("UV_BINARY") { Ok(path) => { assert!( Utf8Path::new(&path).is_absolute(), "UV_BINARY must be absolute" ); path.into() } Err(_) => { download_and_extract( build, "uv", uv_archive(platform), hashmap! { "bin" => [ with_exe("uv") ] }, )?; inputs![":extract:uv:bin"] } }; build.add_dependency("uv_binary", uv_binary); // Our macOS packaging needs access to the x86 binary on ARM. if cfg!(target_arch = "aarch64") { download_and_extract( build, "uv_mac_x86", uv_archive(Platform::MacX64), hashmap! { "bin" => [ with_exe("uv") ] }, )?; } // Our Linux packaging needs access to the ARM binary on x86 if cfg!(target_arch = "x86_64") { download_and_extract( build, "uv_lin_arm", uv_archive(Platform::LinuxArm), hashmap! { "bin" => [ with_exe("uv") ] }, )?; } Ok(()) } pub struct PythonEnvironment { pub deps: BuildInput, // todo: rename pub venv_folder: &'static str, pub extra_args: &'static str, pub extra_binary_exports: &'static [&'static str], } impl BuildAction for PythonEnvironment { fn command(&self) -> &str { if env::var("OFFLINE_BUILD").is_err() { "$runner pyenv $uv_binary $builddir/$pyenv_folder $python -- $extra_args" } else { "echo 'OFFLINE_BUILD is set. Using the existing PythonEnvironment.'" } } fn files(&mut self, build: &mut impl crate::build::FilesHandle) { let bin_path = |binary: &str| -> Vec { let folder = self.venv_folder; let path = if cfg!(windows) { format!("{folder}/scripts/{binary}.exe") } else { format!("{folder}/bin/{binary}") }; vec![path] }; build.add_inputs("", &self.deps); build.add_variable("pyenv_folder", self.venv_folder); if env::var("OFFLINE_BUILD").is_err() { build.add_inputs("uv_binary", inputs![":uv_binary"]); // Set --python flag to .python-version (--no-config ignores it) // override if PYTHON_BINARY is set let python = env::var("PYTHON_BINARY").unwrap_or_else(|_| { let python_version = read_file(".python-version").expect("No .python-version in cwd"); let python_version_str = String::from_utf8(python_version).expect("Invalid UTF-8 in .python-version"); python_version_str.trim().to_string() }); build.add_variable("python", python); build.add_variable("extra_args", self.extra_args); } build.add_outputs_ext("bin", bin_path("python"), true); for binary in self.extra_binary_exports { build.add_outputs_ext(*binary, bin_path(binary), true); } build.add_output_stamp(format!("{}/.stamp", self.venv_folder)); } fn check_output_timestamps(&self) -> bool { true } } pub struct PythonTypecheck { pub folders: &'static [&'static str], pub deps: BuildInput, } impl BuildAction for PythonTypecheck { fn command(&self) -> &str { "$mypy $folders" } fn files(&mut self, build: &mut impl crate::build::FilesHandle) { build.add_inputs("", &self.deps); build.add_inputs("mypy", inputs![":pyenv:mypy"]); build.add_inputs("", inputs![".mypy.ini"]); build.add_variable("folders", self.folders.join(" ")); let hash = simple_hash(self.folders); build.add_output_stamp(format!("tests/python_typecheck.{hash}")); } fn hide_progress(&self) -> bool { true } } struct PythonFormat<'a> { pub inputs: &'a BuildInput, pub check_only: bool, } impl BuildAction for PythonFormat<'_> { fn command(&self) -> &str { "$ruff format $mode $in && $ruff check --select I --fix $in" } fn files(&mut self, build: &mut impl crate::build::FilesHandle) { build.add_inputs("in", self.inputs); build.add_inputs("ruff", inputs![":pyenv:ruff"]); let hash = simple_hash(self.inputs); build.add_variable("mode", if self.check_only { "--check" } else { "" }); build.add_output_stamp(format!( "tests/python_format.{}.{hash}", if self.check_only { "check" } else { "fix" } )); } } pub fn python_format(build: &mut Build, group: &str, inputs: BuildInput) -> Result<()> { build.add_action( format!("check:format:python:{group}"), PythonFormat { inputs: &inputs, check_only: true, }, )?; build.add_action( format!("format:python:{group}"), PythonFormat { inputs: &inputs, check_only: false, }, )?; Ok(()) } pub struct RuffCheck { pub folders: &'static [&'static str], pub deps: BuildInput, pub check_only: bool, } impl BuildAction for RuffCheck { fn command(&self) -> &str { "$ruff check $folders $mode" } fn files(&mut self, build: &mut impl crate::build::FilesHandle) { build.add_inputs("", &self.deps); build.add_inputs("", inputs![".ruff.toml"]); build.add_inputs("ruff", inputs![":pyenv:ruff"]); build.add_variable("folders", self.folders.join(" ")); build.add_variable( "mode", if self.check_only { "" } else { "--fix --unsafe-fixes" }, ); let hash = simple_hash(&self.deps); let kind = if self.check_only { "check" } else { "fix" }; build.add_output_stamp(format!("tests/python_ruff.{kind}.{hash}")); } } pub struct PythonTest { pub folder: &'static str, pub python_path: &'static [&'static str], pub deps: BuildInput, } impl BuildAction for PythonTest { fn command(&self) -> &str { "$pytest -p no:cacheprovider $folder" } fn files(&mut self, build: &mut impl crate::build::FilesHandle) { build.add_inputs("", &self.deps); build.add_inputs("pytest", inputs![":pyenv:pytest"]); build.add_variable("folder", self.folder); build.add_variable( "pythonpath", self.python_path.join(if cfg!(windows) { ";" } else { ":" }), ); build.add_env_var("PYTHONPATH", "$pythonpath"); build.add_env_var("ANKI_TEST_MODE", "1"); let hash = simple_hash(self.folder); build.add_output_stamp(format!("tests/python_pytest.{hash}")); } fn hide_progress(&self) -> bool { true } } ================================================ FILE: build/ninja_gen/src/render.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::fmt::Write; use anki_io::create_dir_all; use anki_io::write_file_if_changed; use anyhow::Result; use itertools::Itertools; use crate::archives::with_exe; use crate::input::space_separated; use crate::Build; impl Build { pub fn render(&self) -> String { let mut buf = String::new(); writeln!( &mut buf, "# This file is automatically generated by configure.rs. Any edits will be lost.\n" ) .unwrap(); writeln!(&mut buf, "builddir = {}", self.buildroot.as_str()).unwrap(); writeln!( &mut buf, "runner = $builddir/rust/release/{}", with_exe("runner") ) .unwrap(); for (key, value) in &self.variables { writeln!(&mut buf, "{key} = {value}").unwrap(); } buf.push('\n'); for (key, value) in &self.pools { writeln!(&mut buf, "pool {key}\n depth = {value}").unwrap(); } buf.push('\n'); buf.push_str(&self.output_text); for (group, targets) in self.groups.iter().sorted() { let group = group.replace(':', "_"); writeln!( &mut buf, "build {group}: phony {}", space_separated(targets) ) .unwrap(); buf.push('\n'); } buf.push_str(&self.trailing_text); buf } pub fn write_build_file(&self) -> Result<()> { create_dir_all(&self.buildroot)?; let path = self.buildroot.join("build.ninja"); let contents = self.render().into_bytes(); write_file_if_changed(path, contents)?; Ok(()) } } ================================================ FILE: build/ninja_gen/src/rsync.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use camino::Utf8Path; use crate::action::BuildAction; use crate::build::FilesHandle; use crate::input::space_separated; use crate::input::BuildInput; /// Rsync the provided inputs into `output_folder`, preserving directory /// structure, eg foo/bar.js -> out/$target_folder/foo/bar.js. `strip_prefix` /// can be used to remove a portion of the the path when copying. If the input /// files are from previous build outputs, the prefix should begin with /// `$builddir/`. pub struct RsyncFiles<'a> { pub inputs: BuildInput, pub target_folder: &'a str, pub strip_prefix: &'static str, pub extra_args: &'a str, } impl BuildAction for RsyncFiles<'_> { fn command(&self) -> &str { "$runner rsync $extra_args --prefix $stripped_prefix --inputs $inputs_without_prefix --output-dir $builddir/$output_folder" } fn files(&mut self, build: &mut impl FilesHandle) { let inputs = build.expand_inputs(&self.inputs); build.add_inputs_vec("", inputs.clone()); let output_folder = Utf8Path::new(self.target_folder); let (prefix, inputs_without_prefix) = if self.strip_prefix.is_empty() { (".", inputs) } else { let stripped_inputs = inputs .iter() .map(|p| { Utf8Path::new(p) .strip_prefix(self.strip_prefix) .unwrap_or_else(|_| { panic!("expected {} to start with {}", p, self.strip_prefix) }) .to_string() }) .collect(); (self.strip_prefix, stripped_inputs) }; build.add_variable( "inputs_without_prefix", space_separated(&inputs_without_prefix), ); build.add_variable("stripped_prefix", prefix); build.add_variable("output_folder", self.target_folder); if !self.extra_args.is_empty() { build.add_variable( "extra_args", format!("--extra-args {}", self.extra_args.replace(' ', ",")), ); } let outputs = inputs_without_prefix .iter() .map(|p| output_folder.join(p).to_string()); build.add_outputs("", outputs); } fn check_output_timestamps(&self) -> bool { true } } ================================================ FILE: build/ninja_gen/src/sass.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anyhow::Result; use crate::action::BuildAction; use crate::cargo::CargoInstall; use crate::input::space_separated; use crate::input::BuildInput; use crate::inputs; use crate::Build; pub struct CompileSassWithGrass { pub input: BuildInput, pub output: &'static str, pub deps: BuildInput, pub load_paths: Vec<&'static str>, } impl BuildAction for CompileSassWithGrass { fn command(&self) -> &str { "$grass $args -s compressed $in -- $out" } fn files(&mut self, build: &mut impl crate::build::FilesHandle) { let args = space_separated(self.load_paths.iter().map(|path| format!("-I {path}"))); build.add_inputs("grass", inputs![":grass"]); build.add_inputs("in", &self.input); build.add_inputs("", &self.deps); build.add_variable("args", args); build.add_outputs("out", vec![self.output]); } fn on_first_instance(&self, build: &mut Build) -> Result<()> { build.add_action( "grass", CargoInstall { binary_name: "grass", args: "grass --version 0.11.2", }, )?; Ok(()) } } ================================================ FILE: build/runner/Cargo.toml ================================================ [package] name = "runner" version.workspace = true authors.workspace = true edition.workspace = true license.workspace = true publish = false rust-version.workspace = true [dependencies] anki_io.workspace = true anki_process.workspace = true anyhow.workspace = true camino.workspace = true clap.workspace = true flate2.workspace = true junction.workspace = true sha2.workspace = true tar.workspace = true termcolor.workspace = true tokio.workspace = true which.workspace = true xz2.workspace = true zip.workspace = true zstd.workspace = true [target.'cfg(windows)'.dependencies] reqwest = { workspace = true, features = ["native-tls"] } [target.'cfg(not(windows))'.dependencies] reqwest = { workspace = true, features = ["rustls-tls", "rustls-tls-native-roots"] } ================================================ FILE: build/runner/build.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html fn main() { println!( "cargo:rustc-env=TARGET={}", if std::env::var("MAC_X86").is_ok() { "x86_64-apple-darwin".into() } else { std::env::var("TARGET").unwrap() } ); } ================================================ FILE: build/runner/src/archive.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::fs; use std::io::Read; use std::path::Path; use std::path::PathBuf; use anki_io::read_file; use anki_io::write_file; use anyhow::Result; use camino::Utf8Path; use clap::Args; use clap::Subcommand; use sha2::Digest; #[derive(Subcommand)] pub enum ArchiveArgs { Download(DownloadArgs), Extract(ExtractArgs), } #[derive(Args)] pub struct DownloadArgs { archive_url: String, checksum: String, output_path: PathBuf, } #[derive(Args)] pub struct ExtractArgs { archive_path: String, output_folder: String, } #[tokio::main] pub async fn archive_command(args: ArchiveArgs) -> Result<()> { match args { ArchiveArgs::Download(args) => { download_and_check(&args.archive_url, &args.checksum, &args.output_path).await } ArchiveArgs::Extract(args) => extract_archive(&args.archive_path, &args.output_folder), } } async fn download_and_check(archive_url: &str, checksum: &str, output_path: &Path) -> Result<()> { // skip download if we already have a valid file if output_path.exists() && sha2_data(&read_file(output_path)?) == checksum { return Ok(()); } let response = reqwest::get(archive_url).await?.error_for_status()?; let data = response.bytes().await?.to_vec(); let actual_checksum = sha2_data(&data); if actual_checksum != checksum { println!("expected {checksum}, got {actual_checksum}"); std::process::exit(1); } fs::write(output_path, data)?; Ok(()) } fn sha2_data(data: &[u8]) -> String { let mut digest = sha2::Sha256::new(); digest.update(data); let result = digest.finalize(); format!("{result:x}") } enum CompressionKind { Zstd, Gzip, Lzma, /// handled by archive Internal, } enum ArchiveKind { Tar, Zip, } fn extract_archive(archive_path: &str, output_folder: &str) -> Result<()> { let archive_path = Utf8Path::new(archive_path); let archive_filename = archive_path.file_name().unwrap(); let mut components = archive_filename.rsplit('.'); let last_component = components.next().unwrap(); let (compression, archive_suffix) = match last_component { "zst" | "zstd" => (CompressionKind::Zstd, components.next().unwrap()), "gz" => (CompressionKind::Gzip, components.next().unwrap()), "xz" => (CompressionKind::Lzma, components.next().unwrap()), "tgz" => (CompressionKind::Gzip, last_component), "zip" => (CompressionKind::Internal, last_component), other => panic!("unexpected compression: {other}"), }; let archive = match archive_suffix { "tar" | "tgz" => ArchiveKind::Tar, "zip" => ArchiveKind::Zip, other => panic!("unexpected archive kind: {other}"), }; let reader = fs::File::open(archive_path)?; let uncompressed_data = match compression { CompressionKind::Zstd => zstd::decode_all(&reader)?, CompressionKind::Gzip => { let mut buf = Vec::new(); let mut decoder = flate2::read::GzDecoder::new(&reader); decoder.read_to_end(&mut buf)?; buf } CompressionKind::Lzma => { let mut buf = Vec::new(); let mut decoder = xz2::read::XzDecoder::new(&reader); decoder.read_to_end(&mut buf)?; buf } CompressionKind::Internal => { vec![] } }; let output_folder = Utf8Path::new(output_folder); if output_folder.exists() { fs::remove_dir_all(output_folder)?; } // extract into a temporary folder let output_tmp = output_folder.with_file_name(format!("{}.tmp", output_folder.file_name().unwrap())); match archive { ArchiveKind::Tar => { let mut archive = tar::Archive::new(&uncompressed_data[..]); archive.set_preserve_mtime(false); archive.unpack(&output_tmp)?; } ArchiveKind::Zip => { let mut archive = zip::ZipArchive::new(reader)?; archive.extract(&output_tmp)?; } } // if the output folder contains a single folder (eg foo-1.2), move it up a // level let mut entries: Vec<_> = output_tmp.read_dir_utf8()?.take(2).collect(); let first_entry = entries.pop().unwrap()?; if entries.is_empty() && first_entry.metadata()?.is_dir() { fs::rename(first_entry.path(), output_folder)?; fs::remove_dir_all(output_tmp)?; } else { fs::rename(output_tmp, output_folder)?; } write_file(output_folder.with_extension("marker"), "")?; Ok(()) } ================================================ FILE: build/runner/src/build.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::env; use std::fs; use std::io::Write; use std::process::Command; use std::time::Instant; use anki_process::CommandExt; use anyhow::Context; use camino::Utf8Path; use camino::Utf8PathBuf; use clap::Args; use termcolor::Color; use termcolor::ColorChoice; use termcolor::ColorSpec; use termcolor::StandardStream; use termcolor::WriteColor; #[derive(Args)] pub struct BuildArgs { #[arg(trailing_var_arg = true)] args: Vec, } pub fn run_build(args: BuildArgs) { let build_root = &setup_build_root(); let path = if cfg!(windows) { format!( "out\\bin;out\\extracted\\node;node_modules\\.bin;{};\\msys64\\usr\\bin", env::var("PATH").unwrap() ) } else { format!( "{br}/bin:{br}/extracted/node/bin:{path}", br = build_root .canonicalize_utf8() .expect("resolving build root") .as_str(), path = env::var("PATH").unwrap() ) }; maybe_update_env_file(build_root); maybe_update_buildhash(build_root); // Ensure build file is up to date let build_file = build_root.join("build.ninja"); if !build_file.exists() { bootstrap_build(); } // automatically convert foo:bar references to foo_bar, as Ninja can not // represent the former let ninja_args = args.args.into_iter().map(|a| a.replace(':', "_")); let start_time = Instant::now(); let mut command = Command::new(get_ninja_command()); command .arg("-f") .arg(&build_file) .args(ninja_args) .env("PATH", &path) .env( "MYPY_CACHE_DIR", build_root.join("tests").join("mypy").into_string(), ) .env( "PYTHONPYCACHEPREFIX", std::path::absolute(build_root.join("pycache")).unwrap(), ) // commands will not show colors by default, as we do not provide a tty .env("FORCE_COLOR", "1") .env("MYPY_FORCE_COLOR", "1") .env("TERM", std::env::var("TERM").unwrap_or_default()); if env::var("NINJA_STATUS").is_err() { command.env("NINJA_STATUS", "[%f/%t; %r active; %es] "); } // run build let Ok(mut status) = command.status() else { panic!("\nn2 and ninja missing/failed. did you forget 'bash tools/install-n2'?"); }; if !status.success() && Instant::now().duration_since(start_time).as_secs() < 3 { // if the build fails quickly, there's a reasonable chance that build.ninja // references a file that has been renamed/deleted. We currently don't // capture stderr, so we can't confirm, but in case that's the case, we // regenerate the build.ninja file then try again. bootstrap_build(); status = command.status().expect("ninja missing"); } let mut stdout = StandardStream::stdout(ColorChoice::Always); if status.success() { stdout .set_color(ColorSpec::new().set_fg(Some(Color::Green)).set_bold(true)) .unwrap(); writeln!( &mut stdout, "\nBuild succeeded in {:.2}s.", start_time.elapsed().as_secs_f32() ) .unwrap(); stdout.reset().unwrap(); } else { stdout .set_color(ColorSpec::new().set_fg(Some(Color::Red)).set_bold(true)) .unwrap(); writeln!(&mut stdout, "\nBuild failed.").unwrap(); stdout.reset().unwrap(); std::process::exit(1); } } fn get_ninja_command() -> &'static str { if which::which("n2").is_ok() { "n2" } else { "ninja" } } fn setup_build_root() -> Utf8PathBuf { let build_root = Utf8Path::new("out"); #[cfg(unix)] if let Ok(new_target) = env::var("BUILD_ROOT").map(camino::Utf8PathBuf::from) { let create = if let Ok(existing_target) = build_root.read_link_utf8() { if existing_target != new_target { fs::remove_file(build_root).unwrap(); true } else { false } } else { true }; if create { println!("Switching build root to {new_target}"); std::os::unix::fs::symlink(new_target, build_root).unwrap(); } } fs::create_dir_all(build_root).unwrap(); if cfg!(windows) { build_root.to_owned() } else { build_root.canonicalize_utf8().unwrap() } } fn bootstrap_build() { let status = Command::new("cargo") .args(["run", "-p", "configure"]) .status(); assert!(status.expect("ninja").success()); } fn maybe_update_buildhash(build_root: &Utf8Path) { // only updated on release builds let path = build_root.join("buildhash"); if (env::var("RELEASE").is_ok() && env::var("OFFLINE_BUILD").is_err()) || !path.exists() { write_if_changed(&path, &get_buildhash()) } } fn get_buildhash() -> String { let output = Command::new("git") .args(["rev-parse", "--short=8", "HEAD"]) .utf8_output() .context( "Make sure you're building from a clone of the git repo, and that 'git' is installed.", ) .unwrap(); output.stdout.trim().into() } fn write_if_changed(path: &Utf8Path, contents: &str) { if let Ok(old_contents) = fs::read_to_string(path) { if old_contents == contents { return; } } fs::write(path, contents).unwrap(); } /// Trigger reconfigure when our env vars change fn maybe_update_env_file(build_root: &Utf8Path) { let env_file = build_root.join("env"); let build_root_env = env::var("BUILD_ROOT").unwrap_or_default(); let release = env::var("RELEASE").unwrap_or_default(); let other_watched_env = env::var("RECONFIGURE_KEY").unwrap_or_default(); let current_env = format!("{build_root_env};{release};{other_watched_env}"); write_if_changed(&env_file, ¤t_env); } ================================================ FILE: build/runner/src/main.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html //! A helper for invoking one or more commands in a cross-platform way, //! silencing their output when they succeed. Most build actions implicitly use //! the 'run' command; we define separate commands for more complicated actions. mod archive; mod build; mod paths; mod pyenv; mod rsync; mod run; mod yarn; use anyhow::Result; use archive::archive_command; use archive::ArchiveArgs; use build::run_build; use build::BuildArgs; use clap::Parser; use clap::Subcommand; use pyenv::setup_pyenv; use pyenv::PyenvArgs; use rsync::rsync_files; use rsync::RsyncArgs; use run::run_commands; use run::RunArgs; use yarn::setup_yarn; use yarn::YarnArgs; #[derive(Parser)] struct Cli { #[command(subcommand)] command: Command, } #[derive(Subcommand)] enum Command { Pyenv(PyenvArgs), Yarn(YarnArgs), Rsync(RsyncArgs), Run(RunArgs), Build(BuildArgs), #[clap(subcommand)] Archive(ArchiveArgs), } fn main() -> Result<()> { match Cli::parse().command { Command::Pyenv(args) => setup_pyenv(args), Command::Run(args) => run_commands(args)?, Command::Rsync(args) => rsync_files(args), Command::Yarn(args) => setup_yarn(args), Command::Build(args) => run_build(args), Command::Archive(args) => archive_command(args)?, }; Ok(()) } ================================================ FILE: build/runner/src/paths.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use camino::Utf8Path; /// On Unix, just a normal path. On Windows, c:\foo\bar.txt becomes /// /c/foo/bar.txt, which msys rsync expects. pub fn absolute_msys_path(path: &Utf8Path) -> String { let path = path.canonicalize_utf8().unwrap().into_string(); if !cfg!(windows) { return path; } // strip off \\? verbatim prefix, which things like rsync/ninja choke on let drive = &path.chars().nth(4).unwrap(); // and \ -> / format!("/{drive}/{}", path[7..].replace('\\', "/")) } ================================================ FILE: build/runner/src/pyenv.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::fs; use std::process::Command; use camino::Utf8Path; use clap::Args; use crate::run::run_command; #[derive(Args)] pub struct PyenvArgs { uv_bin: String, pyenv_folder: String, python: String, #[arg(trailing_var_arg = true)] extra_args: Vec, } /// Set up a venv if one doesn't already exist, and then sync packages with /// provided requirements file. pub fn setup_pyenv(args: PyenvArgs) { let pyenv_folder = Utf8Path::new(&args.pyenv_folder); // On first run, ninja creates an empty bin/ folder which breaks the initial // install. But we don't want to indiscriminately remove the folder, or // macOS Gatekeeper needs to rescan the files each time. if pyenv_folder.exists() { let cache_tag = pyenv_folder.join("CACHEDIR.TAG"); if !cache_tag.exists() { fs::remove_dir_all(pyenv_folder).expect("Failed to remove existing pyenv folder"); } } let mut command = Command::new(args.uv_bin); // remove UV_* environment variables to avoid interference for (key, _) in std::env::vars() { if key.starts_with("UV_") || key == "VIRTUAL_ENV" { command.env_remove(key); } } run_command( command .env("UV_PROJECT_ENVIRONMENT", args.pyenv_folder.clone()) .args(["sync", "--locked", "--no-config"]) .args(["--python", &args.python]) .args(args.extra_args), ); // Write empty stamp file fs::write(pyenv_folder.join(".stamp"), "").expect("Failed to write stamp file"); } ================================================ FILE: build/runner/src/rsync.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::process::Command; use camino::Utf8Path; use clap::Args; use crate::paths::absolute_msys_path; use crate::run::run_command; #[derive(Args)] pub struct RsyncArgs { #[arg(long, value_delimiter(','), allow_hyphen_values(true))] extra_args: Vec, #[arg(long)] prefix: String, #[arg(long, required(true), num_args(..))] inputs: Vec, #[arg(long)] output_dir: String, } pub fn rsync_files(args: RsyncArgs) { let output_dir = absolute_msys_path(Utf8Path::new(&args.output_dir)); run_command( Command::new("rsync") .current_dir(&args.prefix) .arg("--relative") .args(args.extra_args) .args(args.inputs.iter().map(|i| { if cfg!(windows) { i.replace('\\', "/") } else { i.clone() } })) .arg(output_dir), ); } ================================================ FILE: build/runner/src/run.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::process::Command; use anki_io::create_dir_all; use anki_io::write_file; use anki_process::CommandExt; use anyhow::Result; use clap::Args; #[derive(Args)] pub struct RunArgs { #[arg(long)] stamp: Option, #[arg(long, value_parser = split_env)] env: Vec<(String, String)>, #[arg(long)] cwd: Option, #[arg(long)] mkdir: Vec, #[arg(trailing_var_arg = true)] args: Vec, } /// Run one or more commands separated by `&&`, optionally stamping or setting /// extra env vars. pub fn run_commands(args: RunArgs) -> Result<()> { let commands = split_args(args.args); for dir in args.mkdir { create_dir_all(&dir)?; } for command in commands { run_command(&mut build_command(command, &args.env, &args.cwd)); } if let Some(stamp_file) = args.stamp { write_file(stamp_file, b"")?; } Ok(()) } fn split_env(s: &str) -> Result<(String, String), std::io::Error> { if let Some((k, v)) = s.split_once('=') { Ok((k.into(), v.into())) } else { Err(std::io::Error::other("invalid env var")) } } fn build_command( command_and_args: Vec, env: &[(String, String)], cwd: &Option, ) -> Command { let mut command = Command::new(&command_and_args[0]); command.args(&command_and_args[1..]); for (k, v) in env { command.env(k, v); } if let Some(cwd) = cwd { command.current_dir(cwd); } command } /// If multiple commands have been provided separated by &&, split them up. fn split_args(args: Vec) -> Vec> { let mut commands = vec![]; let mut current_command = vec![]; for arg in args.into_iter() { if arg == "&&" { commands.push(current_command); current_command = vec![]; } else { current_command.push(arg) } } if !current_command.is_empty() { commands.push(current_command) } commands } pub fn run_command(command: &mut Command) { if let Err(err) = command.ensure_success() { println!("{err}"); std::process::exit(1); } } ================================================ FILE: build/runner/src/yarn.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::env; use std::path::Path; use std::process::Command; use clap::Args; use crate::run::run_command; #[derive(Args)] pub struct YarnArgs { yarn_bin: String, stamp: String, } pub fn setup_yarn(args: YarnArgs) { link_node_modules(); if env::var("OFFLINE_BUILD").is_ok() { println!("OFFLINE_BUILD is set"); println!("Running yarn with '--offline' and '--ignore-scripts'."); run_command( Command::new(&args.yarn_bin) .arg("install") .arg("--offline") .arg("--ignore-scripts"), ); } else { run_command( Command::new(&args.yarn_bin) .arg("install") .arg("--immutable"), ); } std::fs::write(args.stamp, b"").unwrap(); } /// Unfortunately a lot of the node ecosystem expects the output folder to /// reside in the repo root, so we need to link in our output folder. #[cfg(not(windows))] fn link_node_modules() { let target = Path::new("node_modules"); if target.exists() { if !target.is_symlink() { panic!("please remove the node_modules folder from the repo root"); } } else { std::os::unix::fs::symlink("out/node_modules", target).unwrap(); } } /// Things are more complicated on Windows - having $root/node_modules point to /// $root/out/node_modules breaks our globs for some reason, so we create the /// junction in the opposite direction instead. Ninja will have already created /// some empty folders based on our declared outputs, so we move the /// created folder into the root. #[cfg(windows)] fn link_node_modules() { let target = Path::new("out/node_modules"); let source = Path::new("node_modules"); if !source.exists() { std::fs::rename(target, source).unwrap(); junction::create(source, target).unwrap() } } ================================================ FILE: cargo/README.md ================================================ This folder contains: - a list of Rust crate licenses, which is checked/updated with ./ninja [check|fix]:minilints - a nightly toolchain definition for formatting ================================================ FILE: cargo/format/rust-toolchain.toml ================================================ [toolchain] channel = "nightly-2025-03-20" profile = "minimal" components = ["rustfmt"] ================================================ FILE: cargo/licenses.json ================================================ [ { "authors": null, "description": "A cross-platform symbolication library written in Rust, using `gimli`", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "addr2line", "repository": "https://github.com/gimli-rs/addr2line" }, { "authors": "Jonas Schievink |oyvindln ", "description": "A simple clean-room implementation of the Adler-32 checksum", "license": "0BSD OR Apache-2.0 OR MIT", "license_file": null, "name": "adler2", "repository": "https://github.com/oyvindln/adler2" }, { "authors": "Tom Kaitchuck ", "description": "A non-cryptographic hash function using AES-NI for high performance", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "ahash", "repository": "https://github.com/tkaitchuck/ahash" }, { "authors": "Andrew Gallant ", "description": "Fast multiple substring searching.", "license": "MIT OR Unlicense", "license_file": null, "name": "aho-corasick", "repository": "https://github.com/BurntSushi/aho-corasick" }, { "authors": "Zakarum ", "description": "Mirror of Rust's allocator API", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "allocator-api2", "repository": "https://github.com/zakarumych/allocator-api2" }, { "authors": "Michael Howell ", "description": "HTML Sanitization", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "ammonia", "repository": "https://github.com/rust-ammonia/ammonia" }, { "authors": "RumovZ", "description": "Parser for the Android-specific tzdata file", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "android-tzdata", "repository": "https://github.com/RumovZ/android-tzdata" }, { "authors": "Nicolas Silva ", "description": "Minimal Android system properties wrapper", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "android_system_properties", "repository": "https://github.com/nical/android_system_properties" }, { "authors": "Ankitects Pty Ltd and contributors ", "description": "Anki's Rust library code", "license": "AGPL-3.0-or-later", "license_file": null, "name": "anki", "repository": null }, { "authors": "Ankitects Pty Ltd and contributors ", "description": "Anki's Rust library i18n code", "license": "AGPL-3.0-or-later", "license_file": null, "name": "anki_i18n", "repository": null }, { "authors": "Ankitects Pty Ltd and contributors ", "description": "Utils for better I/O error reporting", "license": "AGPL-3.0-or-later", "license_file": null, "name": "anki_io", "repository": null }, { "authors": "Ankitects Pty Ltd and contributors ", "description": "Anki's Rust library protobuf code", "license": "AGPL-3.0-or-later", "license_file": null, "name": "anki_proto", "repository": null }, { "authors": "Ankitects Pty Ltd and contributors ", "description": "Helpers for interface code generation", "license": "AGPL-3.0-or-later", "license_file": null, "name": "anki_proto_gen", "repository": null }, { "authors": "David Tolnay ", "description": "Flexible concrete Error type built on std::error::Error", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "anyhow", "repository": "https://github.com/dtolnay/anyhow" }, { "authors": "The Rust-Fuzz Project Developers|Nick Fitzgerald |Manish Goregaokar |Simonas Kazlauskas |Brian L. Troutwine |Corey Farwell ", "description": "The trait for generating structured data from unstructured data", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "arbitrary", "repository": "https://github.com/rust-fuzz/arbitrary/" }, { "authors": "David Roundy ", "description": "Macros to take array references of slices", "license": "BSD-2-Clause", "license_file": null, "name": "arrayref", "repository": "https://github.com/droundy/arrayref" }, { "authors": "bluss", "description": "A vector with fixed capacity, backed by an array (it can be stored on the stack too). Implements fixed capacity ArrayVec and ArrayString.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "arrayvec", "repository": "https://github.com/bluss/arrayvec" }, { "authors": "Maik Klein |Benjamin Saunders |Marijn Suijten ", "description": "Vulkan bindings for Rust", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "ash", "repository": "https://github.com/ash-rs/ash" }, { "authors": "David Pedersen ", "description": "Easily compare two JSON values and get great output", "license": "MIT", "license_file": null, "name": "assert-json-diff", "repository": "https://github.com/davidpdrsn/assert-json-diff.git" }, { "authors": "Stjepan Glavina ", "description": "Async multi-producer multi-consumer channel", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "async-channel", "repository": "https://github.com/smol-rs/async-channel" }, { "authors": "Wim Looman |Allen Bui ", "description": "Adaptors between compression crates and Rust's modern asynchronous IO types.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "async-compression", "repository": "https://github.com/Nullus157/async-compression" }, { "authors": "Carl Lerche ", "description": "Asynchronous streams using async & await notation", "license": "MIT", "license_file": null, "name": "async-stream", "repository": "https://github.com/tokio-rs/async-stream" }, { "authors": "Carl Lerche ", "description": "proc macros for async-stream crate", "license": "MIT", "license_file": null, "name": "async-stream-impl", "repository": "https://github.com/tokio-rs/async-stream" }, { "authors": "David Tolnay ", "description": "Type erasure for async trait methods", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "async-trait", "repository": "https://github.com/dtolnay/async-trait" }, { "authors": "Stjepan Glavina |Contributors to futures-rs", "description": "A synchronization primitive for task wakeup", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "atomic-waker", "repository": "https://github.com/smol-rs/atomic-waker" }, { "authors": "Thom Chiovoloni ", "description": "Floating point types which can be safely shared between threads", "license": "Apache-2.0 OR MIT OR Unlicense", "license_file": null, "name": "atomic_float", "repository": "https://github.com/thomcc/atomic_float" }, { "authors": "Josh Stone ", "description": "Automatic cfg for Rust compiler features", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "autocfg", "repository": "https://github.com/cuviper/autocfg" }, { "authors": null, "description": "Web framework that focuses on ergonomics and modularity", "license": "MIT", "license_file": null, "name": "axum", "repository": "https://github.com/tokio-rs/axum" }, { "authors": null, "description": "Client IP address extractors for Axum", "license": "MIT", "license_file": null, "name": "axum-client-ip", "repository": "https://github.com/imbolc/axum-client-ip" }, { "authors": null, "description": "Core types and traits for axum", "license": "MIT", "license_file": null, "name": "axum-core", "repository": "https://github.com/tokio-rs/axum" }, { "authors": null, "description": "Extra utilities for axum", "license": "MIT", "license_file": null, "name": "axum-extra", "repository": "https://github.com/tokio-rs/axum" }, { "authors": null, "description": "Macros for axum", "license": "MIT", "license_file": null, "name": "axum-macros", "repository": "https://github.com/tokio-rs/axum" }, { "authors": "The Rust Project Developers", "description": "A library to acquire a stack trace (backtrace) at runtime in a Rust program.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "backtrace", "repository": "https://github.com/rust-lang/backtrace-rs" }, { "authors": "Marshall Pierce ", "description": "encodes and decodes base64 as bytes or utf8", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "base64", "repository": "https://github.com/marshallpierce/rust-base64" }, { "authors": "RustCrypto Developers", "description": "Pure Rust implementation of Base64 (RFC 4648) which avoids any usages of data-dependent branches/LUTs and thereby provides portable \"best effort\" constant-time operation and embedded-friendly no_std support", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "base64ct", "repository": "https://github.com/RustCrypto/formats" }, { "authors": "Ty Overby |Zoey Riordan |Victor Koenders ", "description": "A binary serialization / deserialization strategy for transforming structs into bytes and vice versa!", "license": "MIT", "license_file": null, "name": "bincode", "repository": "https://github.com/bincode-org/bincode" }, { "authors": "Alexis Beingessner ", "description": "A set of bits", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "bit-set", "repository": "https://github.com/contain-rs/bit-set" }, { "authors": "Alexis Beingessner ", "description": "A vector of bits", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "bit-vec", "repository": "https://github.com/contain-rs/bit-vec" }, { "authors": "The Rust Project Developers", "description": "A macro to generate structures which behave like bitflags.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "bitflags", "repository": "https://github.com/bitflags/bitflags" }, { "authors": "The Rust Project Developers", "description": "A macro to generate structures which behave like bitflags.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "bitflags", "repository": "https://github.com/bitflags/bitflags" }, { "authors": "Jack O'Connor |Samuel Neves", "description": "the BLAKE3 hash function", "license": "Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR CC0-1.0", "license_file": null, "name": "blake3", "repository": "https://github.com/BLAKE3-team/BLAKE3" }, { "authors": "Steven Sheldon", "description": "Rust interface for Apple's C language extension of blocks.", "license": "MIT", "license_file": null, "name": "block", "repository": "http://github.com/SSheldon/rust-block" }, { "authors": "RustCrypto Developers", "description": "Buffer type for block processing of data", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "block-buffer", "repository": "https://github.com/RustCrypto/utils" }, { "authors": "Nick Fitzgerald ", "description": "A fast bump allocation arena for Rust.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "bumpalo", "repository": "https://github.com/fitzgen/bumpalo" }, { "authors": "nathanielsimard ", "description": "Flexible and Comprehensive Deep Learning Framework in Rust", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "burn", "repository": "https://github.com/tracel-ai/burn" }, { "authors": "nathanielsimard ", "description": "Automatic differentiation backend for the Burn framework", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "burn-autodiff", "repository": "https://github.com/tracel-ai/burn/tree/main/crates/burn-autodiff" }, { "authors": "louisfd ", "description": "Candle backend for the Burn framework", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "burn-candle", "repository": "https://github.com/tracel-ai/burn/tree/main/crates/burn-candle" }, { "authors": "Dilshod Tadjibaev (@antimora)", "description": "Common crate for the Burn framework", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "burn-common", "repository": "https://github.com/tracel-ai/burn/tree/main/crates/burn-common" }, { "authors": "nathanielsimard ", "description": "Flexible and Comprehensive Deep Learning Framework in Rust", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "burn-core", "repository": "https://github.com/tracel-ai/burn/tree/main/crates/burn-core" }, { "authors": "nathanielsimard ", "description": "Generic backend that can be compiled just-in-time to any shader language target", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "burn-cubecl", "repository": "https://github.com/tracel-ai/burn/tree/main/crates/burn-cubecl" }, { "authors": "nathanielsimard ", "description": "CUDA backend for the Burn framework", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "burn-cuda", "repository": "https://github.com/tracel-ai/burn/tree/main/crates/burn-cuda" }, { "authors": "nathanielsimard ", "description": "Library with simple dataset APIs for creating ML data pipelines", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "burn-dataset", "repository": "https://github.com/tracel-ai/burn/tree/main/crates/burn-dataset" }, { "authors": "nathanielsimard ", "description": "Derive crate for the Burn framework", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "burn-derive", "repository": "https://github.com/tracel-ai/burn/tree/main/crates/burn-derive" }, { "authors": "laggui |nathanielsimard ", "description": "Intermediate representation for the Burn framework", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "burn-ir", "repository": "https://github.com/tracel-ai/burn/tree/main/crates/burn-ir" }, { "authors": "nathanielsimard ", "description": "Ndarray backend for the Burn framework", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "burn-ndarray", "repository": "https://github.com/tracel-ai/burn/tree/main/crates/burn-ndarray" }, { "authors": "nathanielsimard ", "description": "ROCm HIP backend for the Burn framework", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "burn-rocm", "repository": "https://github.com/tracel-ai/burn/tree/main/crates/burn-rocm" }, { "authors": "laggui |nathanielsimard ", "description": "Multi-backend router decorator for the Burn framework", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "burn-router", "repository": "https://github.com/tracel-ai/burn/tree/main/crates/burn-router" }, { "authors": "nathanielsimard ", "description": "Tensor library with user-friendly APIs and automatic differentiation support", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "burn-tensor", "repository": "https://github.com/tracel-ai/burn/tree/main/crates/burn-tensor" }, { "authors": "nathanielsimard ", "description": "Training crate for the Burn framework", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "burn-train", "repository": "https://github.com/tracel-ai/burn/tree/main/crates/burn-train" }, { "authors": "nathanielsimard ", "description": "WGPU backend for the Burn framework", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "burn-wgpu", "repository": "https://github.com/tracel-ai/burn/tree/main/crates/burn-wgpu" }, { "authors": "Lokathor ", "description": "A crate for mucking around with piles of bytes.", "license": "Apache-2.0 OR MIT OR Zlib", "license_file": null, "name": "bytemuck", "repository": "https://github.com/Lokathor/bytemuck" }, { "authors": "Lokathor ", "description": "derive proc-macros for `bytemuck`", "license": "Apache-2.0 OR MIT OR Zlib", "license_file": null, "name": "bytemuck_derive", "repository": "https://github.com/Lokathor/bytemuck" }, { "authors": "Andrew Gallant ", "description": "Library for reading/writing numbers in big-endian and little-endian.", "license": "MIT OR Unlicense", "license_file": null, "name": "byteorder", "repository": "https://github.com/BurntSushi/byteorder" }, { "authors": "Carl Lerche |Sean McArthur ", "description": "Types and traits for working with bytes", "license": "MIT", "license_file": null, "name": "bytes", "repository": "https://github.com/tokio-rs/bytes" }, { "authors": "Hyunsik Choi ", "description": "an utility for human-readable bytes representations", "license": "Apache-2.0", "license_file": null, "name": "bytesize", "repository": "https://github.com/bytesize-rs/bytesize/" }, { "authors": "Without Boats |Ashley Williams |Steve Klabnik |Rain ", "description": "UTF-8 paths", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "camino", "repository": "https://github.com/camino-rs/camino" }, { "authors": null, "description": "Minimalist ML framework.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "candle-core", "repository": "https://github.com/huggingface/candle" }, { "authors": "Alex Crichton ", "description": "A build-time dependency for Cargo build scripts to assist in invoking the native C compiler to compile native C code into a static archive to be linked into Rust code.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "cc", "repository": "https://github.com/rust-lang/cc-rs" }, { "authors": "Alex Crichton ", "description": "A macro to ergonomically define an item depending on a large number of #[cfg] parameters. Structured like an if-else chain, the first matching branch is the item that gets emitted.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "cfg-if", "repository": "https://github.com/rust-lang/cfg-if" }, { "authors": "Zicklag ", "description": "A tiny utility to help save you a lot of effort with long winded `#[cfg()]` checks.", "license": "MIT", "license_file": null, "name": "cfg_aliases", "repository": "https://github.com/katharostech/cfg_aliases" }, { "authors": null, "description": "Date and time library for Rust", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "chrono", "repository": "https://github.com/chronotope/chrono" }, { "authors": null, "description": "HTTP client IP address extractors", "license": "MIT", "license_file": null, "name": "client-ip", "repository": "https://github.com/imbolc/client-ip" }, { "authors": "Frank Denis ", "description": "Time and duration crate optimized for speed", "license": "ISC", "license_file": null, "name": "coarsetime", "repository": "https://github.com/jedisct1/rust-coarsetime" }, { "authors": "Brendan Zabarauskas ", "description": "Beautiful diagnostic reporting for text-based programming languages", "license": "Apache-2.0", "license_file": null, "name": "codespan-reporting", "repository": "https://github.com/brendanzab/codespan" }, { "authors": "Thomas Wickham ", "description": "The most simple way to add colors in your terminal", "license": "MPL-2.0", "license_file": null, "name": "colored", "repository": "https://github.com/mackwic/colored" }, { "authors": "Wim Looman |Allen Bui ", "description": "Adaptors for various compression algorithms.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "compression-codecs", "repository": "https://github.com/Nullus157/async-compression" }, { "authors": "Wim Looman |Allen Bui ", "description": "Abstractions for compression algorithms.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "compression-core", "repository": "https://github.com/Nullus157/async-compression" }, { "authors": "Stjepan Glavina |Taiki Endo |John Nunley ", "description": "Concurrent multi-producer multi-consumer queue", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "concurrent-queue", "repository": "https://github.com/smol-rs/concurrent-queue" }, { "authors": "Cesar Eduardo Barros ", "description": "Compares two equal-sized byte strings in constant time.", "license": "Apache-2.0 OR CC0-1.0 OR MIT-0", "license_file": null, "name": "constant_time_eq", "repository": "https://github.com/cesarb/constant_time_eq" }, { "authors": "rutrum ", "description": "Convert strings into any case", "license": "MIT", "license_file": null, "name": "convert_case", "repository": "https://github.com/rutrum/convert-case" }, { "authors": "The Servo Project Developers", "description": "Bindings to Core Foundation for macOS", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "core-foundation", "repository": "https://github.com/servo/core-foundation-rs" }, { "authors": "The Servo Project Developers", "description": "Bindings to Core Foundation for macOS", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "core-foundation", "repository": "https://github.com/servo/core-foundation-rs" }, { "authors": "The Servo Project Developers", "description": "Bindings to Core Foundation for macOS", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "core-foundation-sys", "repository": "https://github.com/servo/core-foundation-rs" }, { "authors": "The Servo Project Developers", "description": "Bindings for some fundamental Core Graphics types", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "core-graphics-types", "repository": "https://github.com/servo/core-foundation-rs" }, { "authors": "RustCrypto Developers", "description": "Lightweight runtime CPU feature detection for aarch64, loongarch64, and x86/x86_64 targets, with no_std support and support for mobile targets including Android and iOS", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "cpufeatures", "repository": "https://github.com/RustCrypto/utils" }, { "authors": "Sam Rijs |Alex Crichton ", "description": "Fast, SIMD-accelerated CRC32 (IEEE) checksum computation", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "crc32fast", "repository": "https://github.com/srijs/rust-crc32fast" }, { "authors": null, "description": "Multi-producer multi-consumer channels for message passing", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "crossbeam-channel", "repository": "https://github.com/crossbeam-rs/crossbeam" }, { "authors": null, "description": "Concurrent work-stealing deque", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "crossbeam-deque", "repository": "https://github.com/crossbeam-rs/crossbeam" }, { "authors": null, "description": "Epoch-based garbage collection", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "crossbeam-epoch", "repository": "https://github.com/crossbeam-rs/crossbeam" }, { "authors": null, "description": "Utilities for concurrent programming", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "crossbeam-utils", "repository": "https://github.com/crossbeam-rs/crossbeam" }, { "authors": "Eira Fransham ", "description": "Crunchy unroller: deterministically unroll constant loops", "license": "MIT", "license_file": null, "name": "crunchy", "repository": "https://github.com/eira-fransham/crunchy" }, { "authors": "RustCrypto Developers", "description": "Common cryptographic traits", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "crypto-common", "repository": "https://github.com/RustCrypto/traits" }, { "authors": "Simon Sapin ", "description": "Rust implementation of CSS Syntax Level 3", "license": "MPL-2.0", "license_file": null, "name": "cssparser", "repository": "https://github.com/servo/rust-cssparser" }, { "authors": "Simon Sapin ", "description": "Procedural macros for cssparser", "license": "MPL-2.0", "license_file": null, "name": "cssparser-macros", "repository": "https://github.com/servo/rust-cssparser" }, { "authors": "Andrew Gallant ", "description": "Fast CSV parsing with support for serde.", "license": "MIT OR Unlicense", "license_file": null, "name": "csv", "repository": "https://github.com/BurntSushi/rust-csv" }, { "authors": "Andrew Gallant ", "description": "Bare bones CSV parsing with no_std support.", "license": "MIT OR Unlicense", "license_file": null, "name": "csv-core", "repository": "https://github.com/BurntSushi/rust-csv" }, { "authors": "nathanielsimard ", "description": "Multi-platform high-performance compute language extension for Rust.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "cubecl", "repository": "https://github.com/tracel-ai/cubecl" }, { "authors": "Dilshod Tadjibaev (@antimora)|Nathaniel Simard (@nathanielsimard)", "description": "Common crate for CubeCL", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "cubecl-common", "repository": "https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-common" }, { "authors": "nathanielsimard |louisfd ", "description": "CubeCL core create", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "cubecl-core", "repository": "https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-core" }, { "authors": "nathanielsimard ", "description": "CPP transpiler for CubeCL", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "cubecl-cpp", "repository": "https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-cpp" }, { "authors": "nathanielsimard ", "description": "CUDA runtime for CubeCL", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "cubecl-cuda", "repository": "https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-cuda" }, { "authors": "nathanielsimard ", "description": "AMD ROCm HIP runtime for CubeCL", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "cubecl-hip", "repository": "https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-hip" }, { "authors": "Tracel Technologies Inc.", "description": "Rust bindings for AMD ROCm HIP runtime libraries used by CubeCL.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "cubecl-hip-sys", "repository": "https://github.com/tracel-ai/cubecl-hip/tree/main/crates/cubecl-hip-sys" }, { "authors": "nathanielsimard |louisfd |louisfd ", "description": "CubeCL Linear Algebra Library.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "cubecl-linalg", "repository": "https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-linalg" }, { "authors": "nathanielsimard |louisfd |louisfd |louisfd |maxtremblay ", "description": "CubeCL Reduce Algorithms.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "cubecl-reduce", "repository": "https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-reduce" }, { "authors": "louisfd |Nathaniel Simard", "description": "Crate that helps creating high performance async runtimes for CubeCL.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "cubecl-runtime", "repository": "https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-runtime" }, { "authors": "nathanielsimard |louisfd |maxtremblay ", "description": "CubeCL Standard Library.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "cubecl-std", "repository": "https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-std" }, { "authors": "nathanielsimard ", "description": "WGPU runtime for the CubeCL", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "cubecl-wgpu", "repository": "https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-wgpu" }, { "authors": null, "description": "Safe wrappers around CUDA apis", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "cudarc", "repository": "https://github.com/coreylowman/cudarc" }, { "authors": "Ted Driggs ", "description": "A proc-macro library for reading attributes into structs when implementing custom derives.", "license": "MIT", "license_file": null, "name": "darling", "repository": "https://github.com/TedDriggs/darling" }, { "authors": "Ted Driggs ", "description": "Helper crate for proc-macro library for reading attributes into structs when implementing custom derives. Use https://crates.io/crates/darling in your code.", "license": "MIT", "license_file": null, "name": "darling_core", "repository": "https://github.com/TedDriggs/darling" }, { "authors": "Ted Driggs ", "description": "Internal support for a proc-macro library for reading attributes into structs when implementing custom derives. Use https://crates.io/crates/darling in your code.", "license": "MIT", "license_file": null, "name": "darling_macro", "repository": "https://github.com/TedDriggs/darling" }, { "authors": "Julien Cretin ", "description": "Efficient and customizable data-encoding functions like base64, base32, and hex", "license": "MIT", "license_file": null, "name": "data-encoding", "repository": "https://github.com/ia0/data-encoding" }, { "authors": "Michael P. Jung ", "description": "Dead simple async pool", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "deadpool", "repository": "https://github.com/bikeshedder/deadpool" }, { "authors": "Michael P. Jung ", "description": "Dead simple async pool utitities for sync managers", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "deadpool-runtime", "repository": "https://github.com/bikeshedder/deadpool" }, { "authors": "Jacob Pratt ", "description": "Ranged integers", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "deranged", "repository": "https://github.com/jhpratt/deranged" }, { "authors": "Nick Cameron ", "description": "`#[derive(new)]` implements simple constructor functions for structs and enums.", "license": "MIT", "license_file": null, "name": "derive-new", "repository": "https://github.com/nrc/derive-new" }, { "authors": "Nick Cameron ", "description": "`#[derive(new)]` implements simple constructor functions for structs and enums.", "license": "MIT", "license_file": null, "name": "derive-new", "repository": "https://github.com/nrc/derive-new" }, { "authors": "The Rust-Fuzz Project Developers|Nick Fitzgerald |Manish Goregaokar |Andre Bogus |Corey Farwell ", "description": "Derives arbitrary traits", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "derive_arbitrary", "repository": "https://github.com/rust-fuzz/arbitrary" }, { "authors": "Jelte Fennema ", "description": "Adds #[derive(x)] macros for more traits", "license": "MIT", "license_file": null, "name": "derive_more", "repository": "https://github.com/JelteF/derive_more" }, { "authors": "Jelte Fennema ", "description": "Internal implementation of `derive_more` crate", "license": "MIT", "license_file": null, "name": "derive_more-impl", "repository": "https://github.com/JelteF/derive_more" }, { "authors": "Dima Kudosh ", "description": "Port of Python's difflib library to Rust.", "license": "MIT", "license_file": null, "name": "difflib", "repository": "https://github.com/DimaKudosh/difflib" }, { "authors": "RustCrypto Developers", "description": "Traits for cryptographic hash functions and message authentication codes", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "digest", "repository": "https://github.com/RustCrypto/traits" }, { "authors": "Simon Ochsenreither ", "description": "A tiny low-level library that provides platform-specific standard locations of directories for config, cache and other data on Linux, Windows, macOS and Redox by leveraging the mechanisms defined by the XDG base/user directory specifications on Linux, the Known Folder API on Windows, and the Standard Directory guidelines on macOS.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "dirs", "repository": "https://github.com/soc/dirs-rs" }, { "authors": "Simon Ochsenreither ", "description": "A tiny low-level library that provides platform-specific standard locations of directories for config, cache and other data on Linux, Windows, macOS and Redox by leveraging the mechanisms defined by the XDG base/user directory specifications on Linux, the Known Folder API on Windows, and the Standard Directory guidelines on macOS.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "dirs", "repository": "https://github.com/soc/dirs-rs" }, { "authors": "Simon Ochsenreither ", "description": "System-level helper functions for the dirs and directories crates.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "dirs-sys", "repository": "https://github.com/dirs-dev/dirs-sys-rs" }, { "authors": "Simon Ochsenreither ", "description": "System-level helper functions for the dirs and directories crates.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "dirs-sys", "repository": "https://github.com/dirs-dev/dirs-sys-rs" }, { "authors": "Jane Lusby ", "description": "A derive macro for implementing the display Trait via a doc comment and string interpolation", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "displaydoc", "repository": "https://github.com/yaahc/displaydoc" }, { "authors": "Slint Developers ", "description": "Extract documentation for the feature flags from comments in Cargo.toml", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "document-features", "repository": "https://github.com/slint-ui/document-features" }, { "authors": "David Tolnay ", "description": "Fast floating point primitive to string conversion", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "dtoa", "repository": "https://github.com/dtolnay/dtoa" }, { "authors": "Xidorn Quan ", "description": "Serialize float number and truncate to certain precision", "license": "MPL-2.0", "license_file": null, "name": "dtoa-short", "repository": "https://github.com/upsuper/dtoa-short" }, { "authors": "sarah <>", "description": "Dynamic stack wrapper for unsized allocations", "license": "MIT", "license_file": null, "name": "dyn-stack", "repository": "https://github.com/kitegi/dynstack/" }, { "authors": "sarah <>", "description": "Dynamic stack wrapper for unsized allocations", "license": "MIT", "license_file": null, "name": "dyn-stack", "repository": "https://github.com/kitegi/dynstack/" }, { "authors": "bluss", "description": "The enum `Either` with variants `Left` and `Right` is a general purpose sum type with two cases.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "either", "repository": "https://github.com/rayon-rs/either" }, { "authors": null, "description": "no-std, no-alloc utilities for working with futures", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "embassy-futures", "repository": "https://github.com/embassy-rs/embassy" }, { "authors": "Henri Sivonen ", "description": "A Gecko-oriented implementation of the Encoding Standard", "license": "(Apache-2.0 OR MIT) AND BSD-3-Clause", "license_file": null, "name": "encoding_rs", "repository": "https://github.com/hsivonen/encoding_rs" }, { "authors": "Benjamin Fry ", "description": "A proc-macro for deriving inner field accessor functions on enums.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "enum-as-inner", "repository": "https://github.com/bluejekyll/enum-as-inner" }, { "authors": "softprops ", "description": "deserialize env vars into typesafe structs", "license": "MIT", "license_file": null, "name": "envy", "repository": "https://github.com/softprops/envy" }, { "authors": null, "description": "Traits for key comparison in maps.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "equivalent", "repository": "https://github.com/indexmap-rs/equivalent" }, { "authors": "Chris Wong |Dan Gohman ", "description": "Cross-platform interface to the `errno` variable.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "errno", "repository": "https://github.com/lambda-fairy/rust-errno" }, { "authors": "Stjepan Glavina |John Nunley ", "description": "Notify async tasks or threads", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "event-listener", "repository": "https://github.com/smol-rs/event-listener" }, { "authors": "John Nunley ", "description": "Block or poll on event_listener easily", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "event-listener-strategy", "repository": "https://github.com/smol-rs/event-listener-strategy" }, { "authors": "Steven Fackler ", "description": "Fallible iterator traits", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "fallible-iterator", "repository": "https://github.com/sfackler/rust-fallible-iterator" }, { "authors": "Steven Fackler ", "description": "Fallible streaming iteration", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "fallible-streaming-iterator", "repository": "https://github.com/sfackler/fallible-streaming-iterator" }, { "authors": "Stjepan Glavina ", "description": "A simple and fast random number generator", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "fastrand", "repository": "https://github.com/smol-rs/fastrand" }, { "authors": "bluss", "description": "FixedBitSet is a simple bitset collection", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "fixedbitset", "repository": "https://github.com/petgraph/fixedbitset" }, { "authors": "Alex Crichton |Josh Triplett ", "description": "DEFLATE compression and decompression exposed as Read/BufRead/Write streams. Supports miniz_oxide and multiple zlib implementations. Supports zlib, gzip, and raw deflate streams.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "flate2", "repository": "https://github.com/rust-lang/flate2-rs" }, { "authors": "Michael Howell ", "description": "A total ordering for floating-point numbers", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "float-ord", "repository": "https://github.com/notriddle/rust-float-ord" }, { "authors": "Caleb Maclennan |Bruce Mitchener |Staś Małolepszy ", "description": "An umbrella crate exposing the combined features of fluent-rs crates with additional convenience macros for Project Fluent, a localization system designed to unleash the entire expressive power of natural language translations.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "fluent", "repository": "https://github.com/projectfluent/fluent-rs" }, { "authors": "Caleb Maclennan |Bruce Mitchener |Staś Małolepszy ", "description": "A low-level implementation of a collection of localization messages for a single locale for Project Fluent, a localization system designed to unleash the entire expressive power of natural language translations.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "fluent-bundle", "repository": "https://github.com/projectfluent/fluent-rs" }, { "authors": "Zibi Braniecki ", "description": "A library for language and locale negotiation.", "license": "Apache-2.0", "license_file": null, "name": "fluent-langneg", "repository": "https://github.com/projectfluent/fluent-langneg-rs" }, { "authors": "Caleb Maclennan |Bruce Mitchener |Staś Małolepszy ", "description": "A low-level parser, AST, and serializer API for the syntax used by Project Fluent, a localization system designed to unleash the entire expressive power of natural language translations.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "fluent-syntax", "repository": "https://github.com/projectfluent/fluent-rs" }, { "authors": "Alex Crichton ", "description": "Fowler–Noll–Vo hash function", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "fnv", "repository": "https://github.com/servo/rust-fnv" }, { "authors": "Orson Peters ", "description": "A fast, non-cryptographic, minimally DoS-resistant hashing algorithm.", "license": "Zlib", "license_file": null, "name": "foldhash", "repository": "https://github.com/orlp/foldhash" }, { "authors": "Steven Fackler ", "description": "A framework for Rust wrappers over C APIs", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "foreign-types", "repository": "https://github.com/sfackler/foreign-types" }, { "authors": "Steven Fackler ", "description": "A framework for Rust wrappers over C APIs", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "foreign-types", "repository": "https://github.com/sfackler/foreign-types" }, { "authors": "Steven Fackler ", "description": "An internal crate used by foreign-types", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "foreign-types-macros", "repository": "https://github.com/sfackler/foreign-types" }, { "authors": "Steven Fackler ", "description": "An internal crate used by foreign-types", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "foreign-types-shared", "repository": "https://github.com/sfackler/foreign-types" }, { "authors": "Steven Fackler ", "description": "An internal crate used by foreign-types", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "foreign-types-shared", "repository": "https://github.com/sfackler/foreign-types" }, { "authors": "The rust-url developers", "description": "Parser and serializer for the application/x-www-form-urlencoded syntax, as used by HTML forms.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "form_urlencoded", "repository": "https://github.com/servo/rust-url" }, { "authors": "Open Spaced Repetition", "description": "FSRS for Rust, including Optimizer and Scheduler", "license": "BSD-3-Clause", "license_file": null, "name": "fsrs", "repository": "https://github.com/open-spaced-repetition/fsrs-rs" }, { "authors": "Keegan McAllister ", "description": "Handling fragments of UTF-8", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "futf", "repository": "https://github.com/servo/futf" }, { "authors": null, "description": "An implementation of futures and streams featuring zero allocations, composability, and iterator-like interfaces.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "futures", "repository": "https://github.com/rust-lang/futures-rs" }, { "authors": null, "description": "Channels for asynchronous communication using futures-rs.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "futures-channel", "repository": "https://github.com/rust-lang/futures-rs" }, { "authors": null, "description": "The core traits and types in for the `futures` library.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "futures-core", "repository": "https://github.com/rust-lang/futures-rs" }, { "authors": null, "description": "Executors for asynchronous tasks based on the futures-rs library.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "futures-executor", "repository": "https://github.com/rust-lang/futures-rs" }, { "authors": null, "description": "The `AsyncRead`, `AsyncWrite`, `AsyncSeek`, and `AsyncBufRead` traits for the futures-rs library.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "futures-io", "repository": "https://github.com/rust-lang/futures-rs" }, { "authors": "Stjepan Glavina |Contributors to futures-rs", "description": "Futures, streams, and async I/O combinators", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "futures-lite", "repository": "https://github.com/smol-rs/futures-lite" }, { "authors": null, "description": "The futures-rs procedural macro implementations.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "futures-macro", "repository": "https://github.com/rust-lang/futures-rs" }, { "authors": null, "description": "The asynchronous `Sink` trait for the futures-rs library.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "futures-sink", "repository": "https://github.com/rust-lang/futures-rs" }, { "authors": null, "description": "Tools for working with tasks.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "futures-task", "repository": "https://github.com/rust-lang/futures-rs" }, { "authors": "Alex Crichton ", "description": "Timeouts for futures.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "futures-timer", "repository": "https://github.com/async-rs/futures-timer" }, { "authors": null, "description": "Common utilities and extension traits for the futures-rs library.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "futures-util", "repository": "https://github.com/rust-lang/futures-rs" }, { "authors": "sarah <>", "description": "Playground for matrix multiplication algorithms", "license": "MIT", "license_file": null, "name": "gemm", "repository": "https://github.com/sarah-ek/gemm/" }, { "authors": "sarah <>", "description": "Playground for matrix multiplication algorithms", "license": "MIT", "license_file": null, "name": "gemm", "repository": "https://github.com/sarah-ek/gemm/" }, { "authors": "sarah <>", "description": "Playground for matrix multiplication algorithms", "license": "MIT", "license_file": null, "name": "gemm-c32", "repository": "https://github.com/sarah-ek/gemm/" }, { "authors": "sarah <>", "description": "Playground for matrix multiplication algorithms", "license": "MIT", "license_file": null, "name": "gemm-c32", "repository": "https://github.com/sarah-ek/gemm/" }, { "authors": "sarah <>", "description": "Playground for matrix multiplication algorithms", "license": "MIT", "license_file": null, "name": "gemm-c64", "repository": "https://github.com/sarah-ek/gemm/" }, { "authors": "sarah <>", "description": "Playground for matrix multiplication algorithms", "license": "MIT", "license_file": null, "name": "gemm-c64", "repository": "https://github.com/sarah-ek/gemm/" }, { "authors": "sarah <>", "description": "Playground for matrix multiplication algorithms", "license": "MIT", "license_file": null, "name": "gemm-common", "repository": "https://github.com/sarah-ek/gemm/" }, { "authors": "sarah <>", "description": "Playground for matrix multiplication algorithms", "license": "MIT", "license_file": null, "name": "gemm-common", "repository": "https://github.com/sarah-ek/gemm/" }, { "authors": "sarah <>", "description": "Playground for matrix multiplication algorithms", "license": "MIT", "license_file": null, "name": "gemm-f16", "repository": "https://github.com/sarah-ek/gemm/" }, { "authors": "sarah <>", "description": "Playground for matrix multiplication algorithms", "license": "MIT", "license_file": null, "name": "gemm-f16", "repository": "https://github.com/sarah-ek/gemm/" }, { "authors": "sarah <>", "description": "Playground for matrix multiplication algorithms", "license": "MIT", "license_file": null, "name": "gemm-f32", "repository": "https://github.com/sarah-ek/gemm/" }, { "authors": "sarah <>", "description": "Playground for matrix multiplication algorithms", "license": "MIT", "license_file": null, "name": "gemm-f32", "repository": "https://github.com/sarah-ek/gemm/" }, { "authors": "sarah <>", "description": "Playground for matrix multiplication algorithms", "license": "MIT", "license_file": null, "name": "gemm-f64", "repository": "https://github.com/sarah-ek/gemm/" }, { "authors": "sarah <>", "description": "Playground for matrix multiplication algorithms", "license": "MIT", "license_file": null, "name": "gemm-f64", "repository": "https://github.com/sarah-ek/gemm/" }, { "authors": "Bartłomiej Kamiński |Aaron Trent ", "description": "Generic types implementing functionality of arrays", "license": "MIT", "license_file": null, "name": "generic-array", "repository": "https://github.com/fizyk20/generic-array.git" }, { "authors": "The Rust Project Developers", "description": "getopts-like option parsing", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "getopts", "repository": "https://github.com/rust-lang/getopts" }, { "authors": "The Rand Project Developers", "description": "A small cross-platform library for retrieving random data from system source", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "getrandom", "repository": "https://github.com/rust-random/getrandom" }, { "authors": "The Rand Project Developers", "description": "A small cross-platform library for retrieving random data from system source", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "getrandom", "repository": "https://github.com/rust-random/getrandom" }, { "authors": null, "description": "A library for reading and writing the DWARF debugging format.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "gimli", "repository": "https://github.com/gimli-rs/gimli" }, { "authors": "Brendan Zabarauskas |Corey Richardson|Arseny Kapoulkine", "description": "Code generators for creating bindings to the Khronos OpenGL APIs.", "license": "Apache-2.0", "license_file": null, "name": "gl_generator", "repository": "https://github.com/brendanzab/gl-rs/" }, { "authors": "The Rust Project Developers", "description": "Support for matching file paths against Unix shell style patterns.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "glob", "repository": "https://github.com/rust-lang/glob" }, { "authors": "Joshua Groves |Dzmitry Malyshau ", "description": "GL on Whatever: a set of bindings to run GL (Open GL, OpenGL ES, and WebGL) anywhere, and avoid target-specific code.", "license": "Apache-2.0 OR MIT OR Zlib", "license_file": null, "name": "glow", "repository": "https://github.com/grovesNL/glow" }, { "authors": "Kirill Chibisov ", "description": "The wgl bindings for glutin", "license": "Apache-2.0", "license_file": null, "name": "glutin_wgl_sys", "repository": "https://github.com/rust-windowing/glutin" }, { "authors": "Zakarum ", "description": "Implementation agnostic memory allocator for Vulkan like APIs", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "gpu-alloc", "repository": "https://github.com/zakarumych/gpu-alloc" }, { "authors": "Zakarum ", "description": "Core types of gpu-alloc crate", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "gpu-alloc-types", "repository": "https://github.com/zakarumych/gpu-alloc" }, { "authors": "Traverse Research ", "description": "Memory allocator for GPU memory in Vulkan and DirectX 12", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "gpu-allocator", "repository": "https://github.com/Traverse-Research/gpu-allocator" }, { "authors": "Zakarum ", "description": "Implementation agnostic descriptor allocator for Vulkan like APIs", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "gpu-descriptor", "repository": "https://github.com/zakarumych/gpu-descriptor" }, { "authors": "Zakarum ", "description": "Core types of gpu-descriptor crate", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "gpu-descriptor-types", "repository": "https://github.com/zakarumych/gpu-descriptor" }, { "authors": "Carl Lerche |Sean McArthur ", "description": "An HTTP/2 client and server", "license": "MIT", "license_file": null, "name": "h2", "repository": "https://github.com/hyperium/h2" }, { "authors": "Kathryn Long ", "description": "Half-precision floating point f16 and bf16 types for Rust implementing the IEEE 754-2008 standard binary16 and bfloat16 types.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "half", "repository": "https://github.com/VoidStarKat/half-rs" }, { "authors": "Amanieu d'Antras ", "description": "A Rust port of Google's SwissTable hash map", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "hashbrown", "repository": "https://github.com/rust-lang/hashbrown" }, { "authors": "Amanieu d'Antras ", "description": "A Rust port of Google's SwissTable hash map", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "hashbrown", "repository": "https://github.com/rust-lang/hashbrown" }, { "authors": "Amanieu d'Antras ", "description": "A Rust port of Google's SwissTable hash map", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "hashbrown", "repository": "https://github.com/rust-lang/hashbrown" }, { "authors": "kyren ", "description": "HashMap-like containers that hold their key-value pairs in a user controllable order", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "hashlink", "repository": "https://github.com/kyren/hashlink" }, { "authors": "Sean McArthur ", "description": "typed HTTP headers", "license": "MIT", "license_file": null, "name": "headers", "repository": "https://github.com/hyperium/headers" }, { "authors": "Sean McArthur ", "description": "typed HTTP headers core trait", "license": "MIT", "license_file": null, "name": "headers-core", "repository": "https://github.com/hyperium/headers" }, { "authors": null, "description": "heck is a case conversion library.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "heck", "repository": "https://github.com/withoutboats/heck" }, { "authors": "Stefan Lankes", "description": "Hermit system calls definitions.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "hermit-abi", "repository": "https://github.com/hermit-os/hermit-rs" }, { "authors": "KokaKiwi ", "description": "Encoding and decoding data into/from hexadecimal representation.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "hex", "repository": "https://github.com/KokaKiwi/rust-hex" }, { "authors": "Kang Seonghoon ", "description": "Parses hexadecimal floats (see also hexf)", "license": "CC0-1.0", "license_file": null, "name": "hexf-parse", "repository": "https://github.com/lifthrasiir/hexf" }, { "authors": "RustCrypto Developers", "description": "Generic implementation of Hash-based Message Authentication Code (HMAC)", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "hmac", "repository": "https://github.com/RustCrypto/MACs" }, { "authors": "The html5ever Project Developers", "description": "High-performance browser-grade HTML5 parser", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "html5ever", "repository": "https://github.com/servo/html5ever" }, { "authors": "Viktor Dahl ", "description": "A library for HTML entity encoding and decoding", "license": "Apache-2.0 OR MIT OR MPL-2.0", "license_file": null, "name": "htmlescape", "repository": "https://github.com/veddan/rust-htmlescape" }, { "authors": "Alex Crichton |Carl Lerche |Sean McArthur ", "description": "A set of types for representing HTTP requests and responses.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "http", "repository": "https://github.com/hyperium/http" }, { "authors": "Carl Lerche |Lucio Franco |Sean McArthur ", "description": "Trait representing an asynchronous, streaming, HTTP request or response body.", "license": "MIT", "license_file": null, "name": "http-body", "repository": "https://github.com/hyperium/http-body" }, { "authors": "Carl Lerche |Lucio Franco |Sean McArthur ", "description": "Combinators and adapters for HTTP request or response bodies.", "license": "MIT", "license_file": null, "name": "http-body-util", "repository": "https://github.com/hyperium/http-body" }, { "authors": null, "description": "No-dep range header parser", "license": "MIT", "license_file": null, "name": "http-range-header", "repository": "https://github.com/MarcusGrass/parse-range-headers" }, { "authors": "Sean McArthur ", "description": "A tiny, safe, speedy, zero-copy HTTP/1.x parser.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "httparse", "repository": "https://github.com/seanmonstar/httparse" }, { "authors": "Pyfisch ", "description": "HTTP date parsing and formatting", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "httpdate", "repository": "https://github.com/pyfisch/httpdate" }, { "authors": "Sean McArthur ", "description": "A protective and efficient HTTP library for all.", "license": "MIT", "license_file": null, "name": "hyper", "repository": "https://github.com/hyperium/hyper" }, { "authors": null, "description": "Rustls+hyper integration for pure rust HTTPS", "license": "Apache-2.0 OR ISC OR MIT", "license_file": null, "name": "hyper-rustls", "repository": "https://github.com/rustls/hyper-rustls" }, { "authors": "Sean McArthur ", "description": "Default TLS implementation for use with hyper", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "hyper-tls", "repository": "https://github.com/hyperium/hyper-tls" }, { "authors": "Sean McArthur ", "description": "hyper utilities", "license": "MIT", "license_file": null, "name": "hyper-util", "repository": "https://github.com/hyperium/hyper-util" }, { "authors": "Andrew Straw |René Kijewski |Ryan Lopopolo ", "description": "get the IANA time zone for the current system", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "iana-time-zone", "repository": "https://github.com/strawlab/iana-time-zone" }, { "authors": "René Kijewski ", "description": "iana-time-zone support crate for Haiku OS", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "iana-time-zone-haiku", "repository": "https://github.com/strawlab/iana-time-zone" }, { "authors": "The ICU4X Project Developers", "description": "Collection of API for use in ICU libraries.", "license": "Unicode-3.0", "license_file": null, "name": "icu_collections", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "The ICU4X Project Developers", "description": "API for managing Unicode Language and Locale Identifiers", "license": "Unicode-3.0", "license_file": null, "name": "icu_locale_core", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "The ICU4X Project Developers", "description": "API for normalizing text into Unicode Normalization Forms", "license": "Unicode-3.0", "license_file": null, "name": "icu_normalizer", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "The ICU4X Project Developers", "description": "Data for the icu_normalizer crate", "license": "Unicode-3.0", "license_file": null, "name": "icu_normalizer_data", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "The ICU4X Project Developers", "description": "Definitions for Unicode properties", "license": "Unicode-3.0", "license_file": null, "name": "icu_properties", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "The ICU4X Project Developers", "description": "Data for the icu_properties crate", "license": "Unicode-3.0", "license_file": null, "name": "icu_properties_data", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "The ICU4X Project Developers", "description": "Trait and struct definitions for the ICU data provider", "license": "Unicode-3.0", "license_file": null, "name": "icu_provider", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "Ian Burns ", "description": "A library for creating and modifying Tree structures.", "license": "MIT", "license_file": null, "name": "id_tree", "repository": "https://github.com/iwburns/id-tree" }, { "authors": "Ted Driggs ", "description": "Utility for applying case rules to Rust identifiers.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "ident_case", "repository": "https://github.com/TedDriggs/ident_case" }, { "authors": "The rust-url developers", "description": "IDNA (Internationalizing Domain Names in Applications) and Punycode.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "idna", "repository": "https://github.com/servo/rust-url/" }, { "authors": "The rust-url developers", "description": "Back end adapter for idna", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "idna_adapter", "repository": "https://github.com/hsivonen/idna_adapter" }, { "authors": null, "description": "A hash table with consistent order and fast iteration.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "indexmap", "repository": "https://github.com/indexmap-rs/indexmap" }, { "authors": "Caleb Meredith ", "description": "High performance inflection transformation library for changing properties of words like the case.", "license": "MIT", "license_file": null, "name": "inflections", "repository": "https://docs.rs/inflections" }, { "authors": "Caleb Maclennan |Bruce Mitchener |Staś Małolepszy ", "description": "A memoizer specifically tailored for storing lazy-initialized intl formatters for Project Fluent, a localization system designed to unleash the entire expressive power of natural language translations.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "intl-memoizer", "repository": "https://github.com/projectfluent/fluent-rs" }, { "authors": "Kekoa Riggin |Zibi Braniecki ", "description": "Unicode Plural Rules categorizer for numeric input.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "intl_pluralrules", "repository": "https://github.com/zbraniecki/pluralrules" }, { "authors": "quininer ", "description": "The low-level `io_uring` userspace interface for Rust", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "io-uring", "repository": "https://github.com/tokio-rs/io-uring" }, { "authors": "Kris Price ", "description": "Provides types and useful methods for working with IPv4 and IPv6 network addresses, commonly called IP prefixes. The new `IpNet`, `Ipv4Net`, and `Ipv6Net` types build on the existing `IpAddr`, `Ipv4Addr`, and `Ipv6Addr` types already provided in Rust's standard library and align to their design to stay consistent. The module also provides useful traits that extend `Ipv4Addr` and `Ipv6Addr` with methods for `Add`, `Sub`, `BitAnd`, and `BitOr` operations. The module only uses stable feature so it is guaranteed to compile using the stable toolchain.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "ipnet", "repository": "https://github.com/krisprice/ipnet" }, { "authors": "YOSHIOKA Takuma ", "description": "IRI as string types", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "iri-string", "repository": "https://github.com/lo48576/iri-string" }, { "authors": "bluss", "description": "Extra iterator adaptors, iterator methods, free functions, and macros.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "itertools", "repository": "https://github.com/rust-itertools/itertools" }, { "authors": "David Tolnay ", "description": "Fast integer primitive to string conversion", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "itoa", "repository": "https://github.com/dtolnay/itoa" }, { "authors": "Steven Fackler ", "description": "Rust definitions corresponding to jni.h", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "jni-sys", "repository": "https://github.com/sfackler/rust-jni-sys" }, { "authors": "Alex Crichton ", "description": "An implementation of the GNU Make jobserver for Rust.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "jobserver", "repository": "https://github.com/rust-lang/jobserver-rs" }, { "authors": "The wasm-bindgen Developers", "description": "Bindings for all JS global objects and functions in all JS environments like Node.js and browsers, built on `#[wasm_bindgen]` using the `wasm-bindgen` crate.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "js-sys", "repository": "https://github.com/rustwasm/wasm-bindgen/tree/master/crates/js-sys" }, { "authors": "Timothée Haudebourg |Sean Kerr ", "description": "Rust bindings for EGL", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "khronos-egl", "repository": "https://github.com/timothee-haudebourg/khronos-egl" }, { "authors": "Brendan Zabarauskas |Corey Richardson|Arseny Kapoulkine|Pierre Krieger ", "description": "The Khronos XML API Registry, exposed as byte string constants.", "license": "Apache-2.0", "license_file": null, "name": "khronos_api", "repository": "https://github.com/brendanzab/gl-rs/" }, { "authors": "Marvin Löbel ", "description": "A macro for declaring lazily evaluated statics in Rust.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "lazy_static", "repository": "https://github.com/rust-lang-nursery/lazy-static.rs" }, { "authors": "The Rust Project Developers", "description": "Raw FFI bindings to platform libraries like libc.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "libc", "repository": "https://github.com/rust-lang/libc" }, { "authors": "Simonas Kazlauskas ", "description": "Bindings around the platform's dynamic library loading primitives with greatly improved memory safety.", "license": "ISC", "license_file": null, "name": "libloading", "repository": "https://github.com/nagisa/rust_libloading/" }, { "authors": "Jorge Aparicio ", "description": "libm in pure Rust", "license": "MIT", "license_file": null, "name": "libm", "repository": "https://github.com/rust-lang/compiler-builtins" }, { "authors": "4lDO2 <4lDO2@protonmail.com>", "description": "Redox stable ABI", "license": "MIT", "license_file": null, "name": "libredox", "repository": "https://gitlab.redox-os.org/redox-os/libredox.git" }, { "authors": "The rusqlite developers", "description": "Native bindings to the libsqlite3 library", "license": "MIT", "license_file": null, "name": "libsqlite3-sys", "repository": "https://github.com/rusqlite/rusqlite" }, { "authors": null, "description": "A memory-safe zlib implementation written in rust", "license": "Zlib", "license_file": null, "name": "libz-rs-sys", "repository": "https://github.com/trifectatechfoundation/zlib-rs" }, { "authors": "Dan Gohman ", "description": "Generated bindings for Linux's userspace API", "license": "Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT", "license_file": null, "name": "linux-raw-sys", "repository": "https://github.com/sunfishcode/linux-raw-sys" }, { "authors": "The ICU4X Project Developers", "description": "A key-value Map implementation based on a flat, sorted Vec.", "license": "Unicode-3.0", "license_file": null, "name": "litemap", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "Lukas Kalbertodt ", "description": "Parse and inspect Rust literals (i.e. tokens in the Rust programming language representing fixed values). Particularly useful for proc macros, but can also be used outside of a proc-macro context.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "litrs", "repository": "https://github.com/LukasKalbertodt/litrs/" }, { "authors": "Amanieu d'Antras ", "description": "Wrappers to create fully-featured Mutex and RwLock types. Compatible with no_std.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "lock_api", "repository": "https://github.com/Amanieu/parking_lot" }, { "authors": "The Rust Project Developers", "description": "A lightweight logging facade for Rust", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "log", "repository": "https://github.com/rust-lang/log" }, { "authors": "Benjamin Saunders ", "description": "Pre-allocated storage with constant-time LRU tracking", "license": "Apache-2.0 OR MIT OR Zlib", "license_file": null, "name": "lru-slab", "repository": "https://github.com/Ralith/lru-slab" }, { "authors": "Jonathan Reem ", "description": "A collection of great and ubiqutitous macros.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "mac", "repository": "https://github.com/reem/rust-mac.git" }, { "authors": "Genna Wingert", "description": "Type and target-generic SIMD", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "macerator", "repository": "https://github.com/wingertge/macerator" }, { "authors": "Genna Wingert", "description": "proc-macros for macerator", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "macerator-macros", "repository": "https://github.com/wingertge/macerator" }, { "authors": "Steven Sheldon", "description": "Structs for handling malloc'd memory passed to Rust.", "license": "MIT", "license_file": null, "name": "malloc_buf", "repository": "https://github.com/SSheldon/malloc_buf" }, { "authors": "bluss", "description": "Collection “literal” macros for HashMap, HashSet, BTreeMap, and BTreeSet.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "maplit", "repository": "https://github.com/bluss/maplit" }, { "authors": "The html5ever Project Developers", "description": "Common code for xml5ever and html5ever", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "markup5ever", "repository": "https://github.com/servo/html5ever" }, { "authors": "The html5ever Project Developers", "description": "Procedural macro for html5ever.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "match_token", "repository": "https://github.com/servo/html5ever" }, { "authors": "Eliza Weisman ", "description": "Regex matching on character and byte streams.", "license": "MIT", "license_file": null, "name": "matchers", "repository": "https://github.com/hawkw/matchers" }, { "authors": null, "description": "A macro to evaluate, as a boolean, whether an expression matches a pattern.", "license": "MIT", "license_file": null, "name": "matches", "repository": "https://github.com/SimonSapin/rust-std-candidates" }, { "authors": "Ibraheem Ahmed ", "description": "A high performance, zero-copy URL router.", "license": "BSD-3-Clause AND MIT", "license_file": null, "name": "matchit", "repository": "https://github.com/ibraheemdev/matchit" }, { "authors": "bluss|R. Janis Goldschmidt", "description": "General matrix multiplication for f32 and f64 matrices. Operates on matrices with general layout (they can use arbitrary row and column stride). Detects and uses AVX or SSE2 on x86 platforms transparently for higher performance. Uses a microkernel strategy, so that the implementation is easy to parallelize and optimize. Supports multithreading.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "matrixmultiply", "repository": "https://github.com/bluss/matrixmultiply/" }, { "authors": "Ivan Ukhov |Kamal Ahmad |Konstantin Stepanov |Lukas Kalbertodt |Nathan Musoke |Scott Mabin |Tony Arcieri |Wim de With |Yosef Dinerstein ", "description": "The package provides the MD5 hash function.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "md5", "repository": "https://github.com/stainless-steel/md5" }, { "authors": "Andrew Gallant |bluss", "description": "Provides extremely fast (uses SIMD on x86_64, aarch64 and wasm32) routines for 1, 2 or 3 byte search and single substring search.", "license": "MIT OR Unlicense", "license_file": null, "name": "memchr", "repository": "https://github.com/BurntSushi/memchr" }, { "authors": "Dan Burkert |Yevhenii Reizner ", "description": "Cross-platform Rust API for memory-mapped file IO", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "memmap2", "repository": "https://github.com/RazrFalcon/memmap2-rs" }, { "authors": "gfx-rs developers", "description": "Rust bindings for Metal", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "metal", "repository": "https://github.com/gfx-rs/metal-rs" }, { "authors": "Sean McArthur ", "description": "Strongly Typed Mimes", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "mime", "repository": "https://github.com/hyperium/mime" }, { "authors": "Austin Bonander ", "description": "A simple crate for detection of a file's MIME type by its extension.", "license": "MIT", "license_file": null, "name": "mime_guess", "repository": "https://github.com/abonander/mime_guess" }, { "authors": "Alex Huszagh ", "description": "Fast float parsing conversion routines.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "minimal-lexical", "repository": "https://github.com/Alexhuszagh/minimal-lexical" }, { "authors": "Frommi |oyvindln |Rich Geldreich richgel99@gmail.com", "description": "DEFLATE compression and decompression library rewritten in Rust based on miniz", "license": "Apache-2.0 OR MIT OR Zlib", "license_file": null, "name": "miniz_oxide", "repository": "https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide" }, { "authors": "Carl Lerche |Thomas de Zeeuw |Tokio Contributors ", "description": "Lightweight non-blocking I/O.", "license": "MIT", "license_file": null, "name": "mio", "repository": "https://github.com/tokio-rs/mio" }, { "authors": null, "description": "Macro for convenient module declaration. Each module can be put in a group, and visibility can be applied to the whole group with ease.", "license": "MIT", "license_file": null, "name": "moddef", "repository": "https://github.com/sigurd4/moddef" }, { "authors": "Rousan Ali ", "description": "An async parser for `multipart/form-data` content-type in Rust.", "license": "MIT", "license_file": null, "name": "multer", "repository": "https://github.com/rwf2/multer" }, { "authors": "Håvar Nøvik ", "description": "A multimap implementation.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "multimap", "repository": "https://github.com/havarnov/multimap" }, { "authors": "gfx-rs developers", "description": "Shader translator and validator. Part of the wgpu project", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "naga", "repository": "https://github.com/gfx-rs/wgpu/tree/trunk/naga" }, { "authors": "Steven Fackler ", "description": "A wrapper over a platform's native TLS implementation", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "native-tls", "repository": "https://github.com/sfackler/rust-native-tls" }, { "authors": "Ulrik Sverdrup \"bluss\"|Jim Turner", "description": "An n-dimensional array for general elements and for numerics. Lightweight array views and slicing; views support chunking and splitting.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "ndarray", "repository": "https://github.com/rust-ndarray/ndarray" }, { "authors": "The Rust Windowing contributors", "description": "FFI bindings for the Android NDK", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "ndk-sys", "repository": "https://github.com/rust-mobile/ndk" }, { "authors": "Matt Brubeck |Jonathan Reem ", "description": "panic in debug, intrinsics::unreachable() in release (fork of debug_unreachable)", "license": "MIT", "license_file": null, "name": "new_debug_unreachable", "repository": "https://github.com/mbrubeck/rust-debug-unreachable" }, { "authors": "contact@geoffroycouprie.com", "description": "A byte-oriented, zero-copy, parser combinators library", "license": "MIT", "license_file": null, "name": "nom", "repository": "https://github.com/Geal/nom" }, { "authors": "contact@geoffroycouprie.com", "description": "A byte-oriented, zero-copy, parser combinators library", "license": "MIT", "license_file": null, "name": "nom", "repository": "https://github.com/rust-bakery/nom" }, { "authors": "MSxDOS ", "description": "FFI bindings for Native API", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "ntapi", "repository": "https://github.com/MSxDOS/ntapi" }, { "authors": "ogham@bsago.me|Ryan Scheel (Havvy) |Josh Triplett |The Nushell Project Developers", "description": "Library for ANSI terminal colors and styles (bold, underline)", "license": "MIT", "license_file": null, "name": "nu-ansi-term", "repository": "https://github.com/nushell/nu-ansi-term" }, { "authors": "The Rust Project Developers", "description": "A collection of numeric types and traits for Rust, including bigint, complex, rational, range iterators, generic integers, and more!", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "num", "repository": "https://github.com/rust-num/num" }, { "authors": "The Rust Project Developers", "description": "Big integer implementation for Rust", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "num-bigint", "repository": "https://github.com/rust-num/num-bigint" }, { "authors": "The Rust Project Developers", "description": "Complex numbers implementation for Rust", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "num-complex", "repository": "https://github.com/rust-num/num-complex" }, { "authors": "Jacob Pratt ", "description": "`num_conv` is a crate to convert between integer types without using `as` casts. This provides better certainty when refactoring, makes the exact behavior of code more explicit, and allows using turbofish syntax.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "num-conv", "repository": "https://github.com/jhpratt/num-conv" }, { "authors": "Brian Myers ", "description": "A Rust crate for producing string-representations of numbers, formatted according to international standards", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "num-format", "repository": "https://github.com/bcmyers/num-format" }, { "authors": "The Rust Project Developers", "description": "Integer traits and functions", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "num-integer", "repository": "https://github.com/rust-num/num-integer" }, { "authors": "The Rust Project Developers", "description": "External iterators for generic mathematics", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "num-iter", "repository": "https://github.com/rust-num/num-iter" }, { "authors": "The Rust Project Developers", "description": "Rational numbers implementation for Rust", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "num-rational", "repository": "https://github.com/rust-num/num-rational" }, { "authors": "The Rust Project Developers", "description": "Numeric traits for generic mathematics", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "num-traits", "repository": "https://github.com/rust-num/num-traits" }, { "authors": "Sean McArthur ", "description": "Get the number of CPUs on a machine.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "num_cpus", "repository": "https://github.com/seanmonstar/num_cpus" }, { "authors": "Daniel Wagner-Hall |Daniel Henry-Mantilla |Vincent Esche ", "description": "Procedural macros to make inter-operation between primitives and enums easier.", "license": "Apache-2.0 OR BSD-3-Clause OR MIT", "license_file": null, "name": "num_enum", "repository": "https://github.com/illicitonion/num_enum" }, { "authors": "Daniel Wagner-Hall |Daniel Henry-Mantilla |Vincent Esche ", "description": "Internal implementation details for ::num_enum (Procedural macros to make inter-operation between primitives and enums easier)", "license": "Apache-2.0 OR BSD-3-Clause OR MIT", "license_file": null, "name": "num_enum_derive", "repository": "https://github.com/illicitonion/num_enum" }, { "authors": "Cldfire", "description": "A safe and ergonomic Rust wrapper for the NVIDIA Management Library", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "nvml-wrapper", "repository": "https://github.com/Cldfire/nvml-wrapper" }, { "authors": "Cldfire", "description": "Generated bindings to the NVIDIA Management Library.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "nvml-wrapper-sys", "repository": "https://github.com/Cldfire/nvml-wrapper" }, { "authors": "Steven Sheldon", "description": "Objective-C Runtime bindings and wrapper for Rust.", "license": "MIT", "license_file": null, "name": "objc", "repository": "http://github.com/SSheldon/rust-objc" }, { "authors": null, "description": "A unified interface for reading and writing object file formats.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "object", "repository": "https://github.com/gimli-rs/object" }, { "authors": "Aleksey Kladov ", "description": "Single assignment cells and lazy values.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "once_cell", "repository": "https://github.com/matklad/once_cell" }, { "authors": "Steven Fackler ", "description": "OpenSSL bindings", "license": "Apache-2.0", "license_file": null, "name": "openssl", "repository": "https://github.com/sfackler/rust-openssl" }, { "authors": null, "description": "Internal macros used by the openssl crate.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "openssl-macros", "repository": null }, { "authors": "Alex Crichton ", "description": "Tool for helping to find SSL certificate locations on the system for OpenSSL", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "openssl-probe", "repository": "https://github.com/alexcrichton/openssl-probe" }, { "authors": "Alex Crichton |Steven Fackler ", "description": "FFI bindings to OpenSSL", "license": "MIT", "license_file": null, "name": "openssl-sys", "repository": "https://github.com/sfackler/rust-openssl" }, { "authors": "Simon Ochsenreither ", "description": "Extends `Option` with additional operations", "license": "MPL-2.0", "license_file": null, "name": "option-ext", "repository": "https://github.com/soc/option-ext.git" }, { "authors": "Jonathan Reem |Matt Brubeck ", "description": "Wrappers for total ordering on floats", "license": "MIT", "license_file": null, "name": "ordered-float", "repository": "https://github.com/reem/rust-ordered-float" }, { "authors": "Jonathan Reem |Matt Brubeck ", "description": "Wrappers for total ordering on floats", "license": "MIT", "license_file": null, "name": "ordered-float", "repository": "https://github.com/reem/rust-ordered-float" }, { "authors": "Stjepan Glavina |The Rust Project Developers", "description": "Thread parking and unparking", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "parking", "repository": "https://github.com/smol-rs/parking" }, { "authors": "Amanieu d'Antras ", "description": "More compact and efficient implementations of the standard synchronization primitives.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "parking_lot", "repository": "https://github.com/Amanieu/parking_lot" }, { "authors": "Amanieu d'Antras ", "description": "An advanced API for creating custom synchronization primitives.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "parking_lot_core", "repository": "https://github.com/Amanieu/parking_lot" }, { "authors": "RustCrypto Developers", "description": "Traits which describe the functionality of password hashing algorithms, as well as a `no_std`-friendly implementation of the PHC string format (a well-defined subset of the Modular Crypt Format a.k.a. MCF)", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "password-hash", "repository": "https://github.com/RustCrypto/traits/tree/master/password-hash" }, { "authors": "David Tolnay ", "description": "Macros for all your token pasting needs", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "paste", "repository": "https://github.com/dtolnay/paste" }, { "authors": "RustCrypto Developers", "description": "Generic implementation of PBKDF2", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "pbkdf2", "repository": "https://github.com/RustCrypto/password-hashes/tree/master/pbkdf2" }, { "authors": "The rust-url developers", "description": "Percent encoding and decoding", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "percent-encoding", "repository": "https://github.com/servo/rust-url/" }, { "authors": "The rust-url developers", "description": "Percent encoding and decoding, preserving non-Latin characters.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "percent-encoding-iri", "repository": "https://github.com/servo/rust-url/" }, { "authors": "Jeremy Salwen ", "description": "Small utility for creating, manipulating, and applying permutations.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "permutation", "repository": "https://github.com/jeremysalwen/rust-permutations" }, { "authors": "bluss|mitchmindtree", "description": "Graph data structure library. Provides graph types and graph algorithms.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "petgraph", "repository": "https://github.com/petgraph/petgraph" }, { "authors": "Steven Fackler ", "description": "Runtime support for perfect hash function data structures", "license": "MIT", "license_file": null, "name": "phf", "repository": "https://github.com/rust-phf/rust-phf" }, { "authors": "Steven Fackler ", "description": "Codegen library for PHF types", "license": "MIT", "license_file": null, "name": "phf_codegen", "repository": "https://github.com/rust-phf/rust-phf" }, { "authors": "Steven Fackler ", "description": "PHF generation logic", "license": "MIT", "license_file": null, "name": "phf_generator", "repository": "https://github.com/rust-phf/rust-phf" }, { "authors": "Steven Fackler ", "description": "Macros to generate types in the phf crate", "license": "MIT", "license_file": null, "name": "phf_macros", "repository": "https://github.com/rust-phf/rust-phf" }, { "authors": "Steven Fackler ", "description": "Support code shared by PHF libraries", "license": "MIT", "license_file": null, "name": "phf_shared", "repository": "https://github.com/rust-phf/rust-phf" }, { "authors": null, "description": "A crate for safe and ergonomic pin-projection.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "pin-project", "repository": "https://github.com/taiki-e/pin-project" }, { "authors": null, "description": "Implementation detail of the `pin-project` crate.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "pin-project-internal", "repository": "https://github.com/taiki-e/pin-project" }, { "authors": null, "description": "A lightweight version of pin-project written with declarative macros.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "pin-project-lite", "repository": "https://github.com/taiki-e/pin-project-lite" }, { "authors": "Josef Brandl ", "description": "Utilities for pinning", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "pin-utils", "repository": "https://github.com/rust-lang-nursery/pin-utils" }, { "authors": "Alex Crichton ", "description": "A library to run the pkg-config system tool at build time in order to be used in Cargo build scripts.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "pkg-config", "repository": "https://github.com/rust-lang/pkg-config-rs" }, { "authors": null, "description": "Portable atomic types including support for 128-bit atomics, atomic float, etc.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "portable-atomic", "repository": "https://github.com/taiki-e/portable-atomic" }, { "authors": null, "description": "Synchronization primitives built with portable-atomic.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "portable-atomic-util", "repository": "https://github.com/taiki-e/portable-atomic" }, { "authors": "The ICU4X Project Developers", "description": "Unvalidated string and character types", "license": "Unicode-3.0", "license_file": null, "name": "potential_utf", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "Jacob Pratt ", "description": "`powerfmt` is a library that provides utilities for formatting values. This crate makes it significantly easier to support filling to a minimum width with alignment, avoid heap allocation, and avoid repetitive calculations.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "powerfmt", "repository": "https://github.com/jhpratt/powerfmt" }, { "authors": "The CryptoCorrosion Contributors", "description": "Cross-platform cryptography-oriented low-level SIMD library.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "ppv-lite86", "repository": "https://github.com/cryptocorrosion/cryptocorrosion" }, { "authors": "Emilio Cobos Álvarez ", "description": "A library intending to be a base dependency to expose a precomputed hash", "license": "MIT", "license_file": null, "name": "precomputed-hash", "repository": "https://github.com/emilio/precomputed-hash" }, { "authors": "Embark |Gray Olson ", "description": "A minimal `syn` syntax tree pretty-printer", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "prettyplease", "repository": "https://github.com/dtolnay/prettyplease" }, { "authors": "Gianmarco Garrisi ", "description": "A Priority Queue implemented as a heap with a function to efficiently change the priority of an item.", "license": "LGPL-3.0-or-later OR MPL-2.0", "license_file": null, "name": "priority-queue", "repository": "https://github.com/garro95/priority-queue" }, { "authors": "Bastian Köcher ", "description": "Replacement for crate (macro_rules keyword) in proc-macros", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "proc-macro-crate", "repository": "https://github.com/bkchr/proc-macro-crate" }, { "authors": "David Tolnay ", "description": "Procedural macros in expression position", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "proc-macro-hack", "repository": "https://github.com/dtolnay/proc-macro-hack" }, { "authors": "David Tolnay |Alex Crichton ", "description": "A substitute implementation of the compiler's `proc_macro` API to decouple token-based libraries from the procedural macro use case.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "proc-macro2", "repository": "https://github.com/dtolnay/proc-macro2" }, { "authors": "Philip Degarmo ", "description": "This crate provides a very thin abstraction over other profiler crates.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "profiling", "repository": "https://github.com/aclysma/profiling" }, { "authors": "Dan Burkert |Lucio Franco |Casper Meijn |Tokio Contributors ", "description": "A Protocol Buffers implementation for the Rust Language.", "license": "Apache-2.0", "license_file": null, "name": "prost", "repository": "https://github.com/tokio-rs/prost" }, { "authors": "Dan Burkert |Lucio Franco |Casper Meijn |Tokio Contributors ", "description": "Generate Prost annotated Rust types from Protocol Buffers files.", "license": "Apache-2.0", "license_file": null, "name": "prost-build", "repository": "https://github.com/tokio-rs/prost" }, { "authors": "Dan Burkert |Lucio Franco |Casper Meijn |Tokio Contributors ", "description": "Generate encoding and decoding implementations for Prost annotated types.", "license": "Apache-2.0", "license_file": null, "name": "prost-derive", "repository": "https://github.com/tokio-rs/prost" }, { "authors": "Andrew Hickman ", "description": "A protobuf library extending prost with reflection support and dynamic messages.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "prost-reflect", "repository": "https://github.com/andrewhickman/prost-reflect" }, { "authors": "Dan Burkert |Lucio Franco |Casper Meijn |Tokio Contributors ", "description": "Prost definitions of Protocol Buffers well known types.", "license": "Apache-2.0", "license_file": null, "name": "prost-types", "repository": "https://github.com/tokio-rs/prost" }, { "authors": "Raph Levien |Marcus Klaas de Vries ", "description": "A pull parser for CommonMark", "license": "MIT", "license_file": null, "name": "pulldown-cmark", "repository": "https://github.com/raphlinus/pulldown-cmark" }, { "authors": "Raph Levien |Marcus Klaas de Vries ", "description": "An escape library for HTML created in the pulldown-cmark project", "license": "MIT", "license_file": null, "name": "pulldown-cmark-escape", "repository": "https://github.com/raphlinus/pulldown-cmark" }, { "authors": "sarah <>", "description": "Safe generic simd", "license": "MIT", "license_file": null, "name": "pulp", "repository": "https://github.com/sarah-ek/pulp/" }, { "authors": "sarah <>", "description": "Safe generic simd", "license": "MIT", "license_file": null, "name": "pulp", "repository": "https://github.com/sarah-ek/pulp/" }, { "authors": null, "description": "Versatile QUIC transport protocol implementation", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "quinn", "repository": "https://github.com/quinn-rs/quinn" }, { "authors": null, "description": "State machine for the QUIC transport protocol", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "quinn-proto", "repository": "https://github.com/quinn-rs/quinn" }, { "authors": null, "description": "UDP sockets with ECN information for the QUIC transport protocol", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "quinn-udp", "repository": "https://github.com/quinn-rs/quinn" }, { "authors": "David Tolnay ", "description": "Quasi-quoting macro quote!(...)", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "quote", "repository": "https://github.com/dtolnay/quote" }, { "authors": null, "description": "UEFI Reference Specification Protocol Constants and Definitions", "license": "Apache-2.0 OR LGPL-2.1-or-later OR MIT", "license_file": null, "name": "r-efi", "repository": "https://github.com/r-efi/r-efi" }, { "authors": "The Rand Project Developers|The Rust Project Developers", "description": "Random number generators and other randomness functionality.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rand", "repository": "https://github.com/rust-random/rand" }, { "authors": "The Rand Project Developers|The Rust Project Developers", "description": "Random number generators and other randomness functionality.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rand", "repository": "https://github.com/rust-random/rand" }, { "authors": "The Rand Project Developers|The Rust Project Developers|The CryptoCorrosion Contributors", "description": "ChaCha random number generator", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rand_chacha", "repository": "https://github.com/rust-random/rand" }, { "authors": "The Rand Project Developers|The Rust Project Developers|The CryptoCorrosion Contributors", "description": "ChaCha random number generator", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rand_chacha", "repository": "https://github.com/rust-random/rand" }, { "authors": "The Rand Project Developers|The Rust Project Developers", "description": "Core random number generator traits and tools for implementation.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rand_core", "repository": "https://github.com/rust-random/rand" }, { "authors": "The Rand Project Developers|The Rust Project Developers", "description": "Core random number generator traits and tools for implementation.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rand_core", "repository": "https://github.com/rust-random/rand" }, { "authors": "The Rand Project Developers", "description": "Sampling from random number distributions", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rand_distr", "repository": "https://github.com/rust-random/rand_distr" }, { "authors": "the gfx-rs Developers", "description": "Generic range allocator", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "range-alloc", "repository": "https://github.com/gfx-rs/range-alloc" }, { "authors": "Gerd Zellweger ", "description": "A library to parse the x86 CPUID instruction, written in rust with no external dependencies. The implementation closely resembles the Intel CPUID manual description. The library does only depend on libcore.", "license": "MIT", "license_file": null, "name": "raw-cpuid", "repository": "https://github.com/gz/rust-cpuid" }, { "authors": "Gerd Zellweger ", "description": "A library to parse the x86 CPUID instruction, written in rust with no external dependencies. The implementation closely resembles the Intel CPUID manual description. The library does only depend on libcore.", "license": "MIT", "license_file": null, "name": "raw-cpuid", "repository": "https://github.com/gz/rust-cpuid" }, { "authors": "Osspial ", "description": "Interoperability library for Rust Windowing applications.", "license": "Apache-2.0 OR MIT OR Zlib", "license_file": null, "name": "raw-window-handle", "repository": "https://github.com/rust-windowing/raw-window-handle" }, { "authors": "bluss", "description": "Extra methods for raw pointers and `NonNull`. For example `.post_inc()` and `.pre_dec()` (c.f. `ptr++` and `--ptr`), `offset` and `add` for `NonNull`, and the function `ptrdistance`.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rawpointer", "repository": "https://github.com/bluss/rawpointer/" }, { "authors": null, "description": "Simple work-stealing parallelism for Rust", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rayon", "repository": "https://github.com/rayon-rs/rayon" }, { "authors": null, "description": "Core APIs for Rayon", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rayon-core", "repository": "https://github.com/rayon-rs/rayon" }, { "authors": "sarah <>", "description": "Emulate reborrowing for user types.", "license": "MIT", "license_file": null, "name": "reborrow", "repository": "https://github.com/sarah-ek/reborrow/" }, { "authors": "Jeremy Soller ", "description": "A Rust library to access raw Redox system calls", "license": "MIT", "license_file": null, "name": "redox_syscall", "repository": "https://gitlab.redox-os.org/redox-os/syscall" }, { "authors": "Jose Narvaez |Wesley Hershberger ", "description": "A Rust library to access Redox users and groups functionality", "license": "MIT", "license_file": null, "name": "redox_users", "repository": "https://gitlab.redox-os.org/redox-os/users" }, { "authors": "Jose Narvaez |Wesley Hershberger ", "description": "A Rust library to access Redox users and groups functionality", "license": "MIT", "license_file": null, "name": "redox_users", "repository": "https://gitlab.redox-os.org/redox-os/users" }, { "authors": "The Rust Project Developers|Andrew Gallant ", "description": "An implementation of regular expressions for Rust. This implementation uses finite automata and guarantees linear time matching on all inputs.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "regex", "repository": "https://github.com/rust-lang/regex" }, { "authors": "The Rust Project Developers|Andrew Gallant ", "description": "Automata construction and matching using regular expressions.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "regex-automata", "repository": "https://github.com/rust-lang/regex" }, { "authors": "The Rust Project Developers|Andrew Gallant ", "description": "A regular expression parser.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "regex-syntax", "repository": "https://github.com/rust-lang/regex" }, { "authors": "John-John Tedro ", "description": "Portable, relative paths for Rust.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "relative-path", "repository": "https://github.com/udoprog/relative-path" }, { "authors": "Eyal Kalderon ", "description": "Low-level bindings to the RenderDoc API", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "renderdoc-sys", "repository": "https://github.com/ebkalderon/renderdoc-rs" }, { "authors": "Sean McArthur ", "description": "higher level HTTP client library", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "reqwest", "repository": "https://github.com/seanmonstar/reqwest" }, { "authors": null, "description": "An experiment.", "license": "Apache-2.0 AND ISC", "license_file": null, "name": "ring", "repository": "https://github.com/briansmith/ring" }, { "authors": "Evgeny Safronov ", "description": "Pure Rust MessagePack serialization implementation", "license": "MIT", "license_file": null, "name": "rmp", "repository": "https://github.com/3Hren/msgpack-rust" }, { "authors": "Evgeny Safronov ", "description": "Serde bindings for RMP", "license": "MIT", "license_file": null, "name": "rmp-serde", "repository": "https://github.com/3Hren/msgpack-rust" }, { "authors": "Michele d'Amico ", "description": "Rust fixture based test framework. It use procedural macro to implement fixtures and table based tests.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rstest", "repository": "https://github.com/la10736/rstest" }, { "authors": "Michele d'Amico ", "description": "Rust fixture based test framework. It use procedural macro to implement fixtures and table based tests.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rstest_macros", "repository": "https://github.com/la10736/rstest" }, { "authors": "The rusqlite developers", "description": "Ergonomic wrapper for SQLite", "license": "MIT", "license_file": null, "name": "rusqlite", "repository": "https://github.com/rusqlite/rusqlite" }, { "authors": "Alex Crichton ", "description": "Rust compiler symbol demangling.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rustc-demangle", "repository": "https://github.com/rust-lang/rustc-demangle" }, { "authors": "The Rust Project Developers", "description": "speed, non-cryptographic hash used in rustc", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rustc-hash", "repository": "https://github.com/rust-lang-nursery/rustc-hash" }, { "authors": "The Rust Project Developers", "description": "A speedy, non-cryptographic hashing algorithm used by rustc", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rustc-hash", "repository": "https://github.com/rust-lang/rustc-hash" }, { "authors": null, "description": "A library for querying the version of a installed rustc compiler", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rustc_version", "repository": "https://github.com/djc/rustc-version-rs" }, { "authors": "Dan Gohman |Jakub Konka ", "description": "Safe Rust bindings to POSIX/Unix/Linux/Winsock-like syscalls", "license": "Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT", "license_file": null, "name": "rustix", "repository": "https://github.com/bytecodealliance/rustix" }, { "authors": null, "description": "Rustls is a modern TLS library written in Rust.", "license": "Apache-2.0 OR ISC OR MIT", "license_file": null, "name": "rustls", "repository": "https://github.com/rustls/rustls" }, { "authors": null, "description": "rustls-native-certs allows rustls to use the platform native certificate store", "license": "Apache-2.0 OR ISC OR MIT", "license_file": null, "name": "rustls-native-certs", "repository": "https://github.com/rustls/rustls-native-certs" }, { "authors": null, "description": "Basic .pem file parser for keys and certificates", "license": "Apache-2.0 OR ISC OR MIT", "license_file": null, "name": "rustls-pemfile", "repository": "https://github.com/rustls/pemfile" }, { "authors": null, "description": "Shared types for the rustls PKI ecosystem", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rustls-pki-types", "repository": "https://github.com/rustls/pki-types" }, { "authors": null, "description": "Web PKI X.509 Certificate Verification.", "license": "ISC", "license_file": null, "name": "rustls-webpki", "repository": "https://github.com/rustls/webpki" }, { "authors": "David Tolnay ", "description": "Conditional compilation according to rustc compiler version", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "rustversion", "repository": "https://github.com/dtolnay/rustversion" }, { "authors": "David Tolnay ", "description": "Fast floating point to string conversion", "license": "Apache-2.0 OR BSL-1.0", "license_file": null, "name": "ryu", "repository": "https://github.com/dtolnay/ryu" }, { "authors": null, "description": "Provides functions to read and write safetensors which aim to be safer than their PyTorch counterpart. The format is 8 bytes which is an unsized int, being the size of a JSON header, the JSON header refers the `dtype` the `shape` and `data_offsets` which are the offsets for the values in the rest of the file.", "license": "Apache-2.0", "license_file": null, "name": "safetensors", "repository": "https://github.com/huggingface/safetensors" }, { "authors": "Andrew Gallant ", "description": "A simple crate for determining whether two file paths point to the same file.", "license": "MIT OR Unlicense", "license_file": null, "name": "same-file", "repository": "https://github.com/BurntSushi/same-file" }, { "authors": "Jacob Brown ", "description": "A simple filename sanitizer, based on Node's sanitize-filename", "license": "MIT", "license_file": null, "name": "sanitize-filename", "repository": "https://github.com/kardeiz/sanitize-filename" }, { "authors": "Jacob Brown ", "description": "A simple filename sanitizer, based on Node's sanitize-filename", "license": "MIT", "license_file": null, "name": "sanitize-filename", "repository": "https://github.com/kardeiz/sanitize-filename" }, { "authors": "Steven Fackler |Steffen Butzer ", "description": "Schannel bindings for rust, allowing SSL/TLS (e.g. https) without openssl", "license": "MIT", "license_file": null, "name": "schannel", "repository": "https://github.com/steffengy/schannel-rs" }, { "authors": "bluss", "description": "A RAII scope guard that will run a given closure when it goes out of scope, even if the code between panics (assuming unwinding panic). Defines the macros `defer!`, `defer_on_unwind!`, `defer_on_success!` as shorthands for guards with one of the implemented strategies.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "scopeguard", "repository": "https://github.com/bluss/scopeguard" }, { "authors": "Steven Fackler |Kornel ", "description": "Security.framework bindings for macOS and iOS", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "security-framework", "repository": "https://github.com/kornelski/rust-security-framework" }, { "authors": "Steven Fackler |Kornel ", "description": "Security.framework bindings for macOS and iOS", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "security-framework", "repository": "https://github.com/kornelski/rust-security-framework" }, { "authors": "Steven Fackler |Kornel ", "description": "Apple `Security.framework` low-level FFI bindings", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "security-framework-sys", "repository": "https://github.com/kornelski/rust-security-framework" }, { "authors": "Lukas Bergdoll ", "description": "Safe-to-use proc-macro-free self-referential structs in stable Rust.", "license": "Apache-2.0", "license_file": null, "name": "self_cell", "repository": "https://github.com/Voultapher/self_cell" }, { "authors": "David Tolnay ", "description": "Parser and evaluator for Cargo's flavor of Semantic Versioning", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "semver", "repository": "https://github.com/dtolnay/semver" }, { "authors": "David Tolnay ", "description": "Macro to repeat sequentially indexed copies of a fragment of code.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "seq-macro", "repository": "https://github.com/dtolnay/seq-macro" }, { "authors": "Erick Tryzelaar |David Tolnay ", "description": "A generic serialization/deserialization framework", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "serde", "repository": "https://github.com/serde-rs/serde" }, { "authors": "Victor Polevoy ", "description": "A serde crate's auxiliary library", "license": "MIT", "license_file": null, "name": "serde-aux", "repository": "https://github.com/iddm/serde-aux" }, { "authors": "arcnmx", "description": "Serialization value trees", "license": "MIT", "license_file": null, "name": "serde-value", "repository": "https://github.com/arcnmx/serde-value" }, { "authors": "David Tolnay ", "description": "Optimized handling of `&[u8]` and `Vec` for Serde", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "serde_bytes", "repository": "https://github.com/serde-rs/bytes" }, { "authors": "Erick Tryzelaar |David Tolnay ", "description": "Serde traits only, with no support for derive -- use the `serde` crate instead", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "serde_core", "repository": "https://github.com/serde-rs/serde" }, { "authors": "Erick Tryzelaar |David Tolnay ", "description": "Macros 1.1 implementation of #[derive(Serialize, Deserialize)]", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "serde_derive", "repository": "https://github.com/serde-rs/serde" }, { "authors": "Erick Tryzelaar |David Tolnay ", "description": "A JSON serialization file format", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "serde_json", "repository": "https://github.com/serde-rs/json" }, { "authors": "David Tolnay ", "description": "Path to the element that failed to deserialize", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "serde_path_to_error", "repository": "https://github.com/dtolnay/path-to-error" }, { "authors": "David Tolnay ", "description": "Derive Serialize and Deserialize that delegates to the underlying repr of a C-like enum.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "serde_repr", "repository": "https://github.com/dtolnay/serde-repr" }, { "authors": "Jacob Brown ", "description": "De/serialize structs with named fields as array of values", "license": "MIT", "license_file": null, "name": "serde_tuple", "repository": "https://github.com/kardeiz/serde_tuple" }, { "authors": "Jacob Brown ", "description": "Internal proc-macro crate for serde_tuple", "license": "MIT", "license_file": null, "name": "serde_tuple_macros", "repository": "https://github.com/kardeiz/serde_tuple" }, { "authors": "Anthony Ramine ", "description": "`x-www-form-urlencoded` meets Serde", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "serde_urlencoded", "repository": "https://github.com/nox/serde_urlencoded" }, { "authors": "RustCrypto Developers", "description": "SHA-1 hash function", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "sha1", "repository": "https://github.com/RustCrypto/hashes" }, { "authors": "RustCrypto Developers", "description": "Pure Rust implementation of the SHA-2 hash function family including SHA-224, SHA-256, SHA-384, and SHA-512.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "sha2", "repository": "https://github.com/RustCrypto/hashes" }, { "authors": "Eliza Weisman ", "description": "A lock-free concurrent slab.", "license": "MIT", "license_file": null, "name": "sharded-slab", "repository": "https://github.com/hawkw/sharded-slab" }, { "authors": "comex |Fenhl |Adrian Taylor |Alex Touchet |Daniel Parks |Garrett Berg ", "description": "Split a string into shell words, like Python's shlex.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "shlex", "repository": "https://github.com/comex/rust-shlex" }, { "authors": "Michal 'vorner' Vaner |Masaki Hara ", "description": "Backend crate for signal-hook", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "signal-hook-registry", "repository": "https://github.com/vorner/signal-hook" }, { "authors": "Marvin Countryman ", "description": "A SIMD-accelerated Adler-32 hash algorithm implementation.", "license": "MIT", "license_file": null, "name": "simd-adler32", "repository": "https://github.com/mcountryman/simd-adler32" }, { "authors": "Frank Denis ", "description": "SipHash-2-4, SipHash-1-3 and 128-bit variants in pure Rust", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "siphasher", "repository": "https://github.com/jedisct1/rust-siphash" }, { "authors": "Carl Lerche ", "description": "Pre-allocated storage for a uniform data type", "license": "MIT", "license_file": null, "name": "slab", "repository": "https://github.com/tokio-rs/slab" }, { "authors": "Orson Peters ", "description": "Slotmap data structure", "license": "Zlib", "license_file": null, "name": "slotmap", "repository": "https://github.com/orlp/slotmap" }, { "authors": "The Servo Project Developers", "description": "'Small vector' optimization: store up to a small number of items on the stack", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "smallvec", "repository": "https://github.com/servo/rust-smallvec" }, { "authors": "Jake Goulding ", "description": "An ergonomic error handling library", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "snafu", "repository": "https://github.com/shepmaster/snafu" }, { "authors": "Jake Goulding ", "description": "An ergonomic error handling library", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "snafu-derive", "repository": "https://github.com/shepmaster/snafu" }, { "authors": "Steven Allen ", "description": "A module for generating guaranteed process unique IDs.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "snowflake", "repository": "https://github.com/Stebalien/snowflake" }, { "authors": "Alex Crichton |Thomas de Zeeuw ", "description": "Utilities for handling networking sockets with a maximal amount of configuration possible intended.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "socket2", "repository": "https://github.com/rust-lang/socket2" }, { "authors": "Mathijs van de Nes |John Ericson |Joshua Barretto ", "description": "Spin-based synchronization primitives", "license": "MIT", "license_file": null, "name": "spin", "repository": "https://github.com/mvdnes/spin-rs.git" }, { "authors": "Mathijs van de Nes |John Ericson |Joshua Barretto ", "description": "Spin-based synchronization primitives", "license": "MIT", "license_file": null, "name": "spin", "repository": "https://github.com/mvdnes/spin-rs.git" }, { "authors": "Lei Zhang ", "description": "Rust definition of SPIR-V structs and enums", "license": "Apache-2.0", "license_file": null, "name": "spirv", "repository": "https://github.com/gfx-rs/rspirv" }, { "authors": "Robert Grosse ", "description": "An unsafe marker trait for types like Box and Rc that dereference to a stable address even when moved, and hence can be used with libraries such as owning_ref and rental.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "stable_deref_trait", "repository": "https://github.com/storyyeller/stable_deref_trait" }, { "authors": "Nikolai Vazquez", "description": "Compile-time assertions to ensure that invariants are met.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "static_assertions", "repository": "https://github.com/nvzqz/static-assertions-rs" }, { "authors": "The Servo Project Developers", "description": "A string interning library for Rust, developed as part of the Servo project.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "string_cache", "repository": "https://github.com/servo/string-cache" }, { "authors": "The Servo Project Developers", "description": "A codegen library for string-cache, developed as part of the Servo project.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "string_cache_codegen", "repository": "https://github.com/servo/string-cache" }, { "authors": "Danny Guo |maxbachmann ", "description": "Implementations of string similarity metrics. Includes Hamming, Levenshtein, OSA, Damerau-Levenshtein, Jaro, Jaro-Winkler, and Sørensen-Dice.", "license": "MIT", "license_file": null, "name": "strsim", "repository": "https://github.com/rapidfuzz/strsim-rs" }, { "authors": "Peter Glotfelty ", "description": "Helpful macros for working with enums and strings", "license": "MIT", "license_file": null, "name": "strum", "repository": "https://github.com/Peternator7/strum" }, { "authors": "Peter Glotfelty ", "description": "Helpful macros for working with enums and strings", "license": "MIT", "license_file": null, "name": "strum", "repository": "https://github.com/Peternator7/strum" }, { "authors": "Peter Glotfelty ", "description": "Helpful macros for working with enums and strings", "license": "MIT", "license_file": null, "name": "strum_macros", "repository": "https://github.com/Peternator7/strum" }, { "authors": "Peter Glotfelty ", "description": "Helpful macros for working with enums and strings", "license": "MIT", "license_file": null, "name": "strum_macros", "repository": "https://github.com/Peternator7/strum" }, { "authors": "Isis Lovecruft |Henry de Valence ", "description": "Pure-Rust traits and utilities for constant-time cryptographic implementations.", "license": "BSD-3-Clause", "license_file": null, "name": "subtle", "repository": "https://github.com/dalek-cryptography/subtle" }, { "authors": "David Tolnay ", "description": "Parser for Rust source code", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "syn", "repository": "https://github.com/dtolnay/syn" }, { "authors": "Actyx AG ", "description": "A tool for enlisting the compiler's help in proving the absence of concurrency", "license": "Apache-2.0", "license_file": null, "name": "sync_wrapper", "repository": "https://github.com/Actyx/sync_wrapper" }, { "authors": "Nika Layzell ", "description": "Helper methods and macros for custom derives", "license": "MIT", "license_file": null, "name": "synstructure", "repository": "https://github.com/mystor/synstructure" }, { "authors": "Johannes Lundberg |Ivan Temchenko |Fabian Freyer ", "description": "Simplified interface to libc::sysctl", "license": "MIT", "license_file": null, "name": "sysctl", "repository": "https://github.com/johalun/sysctl-rs" }, { "authors": "Johannes Lundberg |Ivan Temchenko |Fabian Freyer ", "description": "Simplified interface to libc::sysctl", "license": "MIT", "license_file": null, "name": "sysctl", "repository": "https://github.com/johalun/sysctl-rs" }, { "authors": "Guillaume Gomez ", "description": "Library to get system information such as processes, CPUs, disks, components and networks", "license": "MIT", "license_file": null, "name": "sysinfo", "repository": "https://github.com/GuillaumeGomez/sysinfo" }, { "authors": "Val Packett ", "description": "Get system information/statistics in a cross-platform way", "license": "Unlicense", "license_file": null, "name": "systemstat", "repository": "https://github.com/valpackett/systemstat" }, { "authors": "Steven Allen |The Rust Project Developers|Ashley Mannix |Jason White ", "description": "A library for managing temporary files and directories.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "tempfile", "repository": "https://github.com/Stebalien/tempfile" }, { "authors": "Keegan McAllister |Simon Sapin |Chris Morgan ", "description": "Compact buffer/string type for zero-copy parsing", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "tendril", "repository": "https://github.com/servo/tendril" }, { "authors": "Andrew Gallant ", "description": "A simple cross platform library for writing colored text to a terminal.", "license": "MIT OR Unlicense", "license_file": null, "name": "termcolor", "repository": "https://github.com/BurntSushi/termcolor" }, { "authors": "Bernardo Araujo ", "description": "A flexible text template engine", "license": "MIT", "license_file": null, "name": "text_placeholder", "repository": "https://github.com/bernardoamc/text-placeholder" }, { "authors": "David Tolnay ", "description": "derive(Error)", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "thiserror", "repository": "https://github.com/dtolnay/thiserror" }, { "authors": "David Tolnay ", "description": "derive(Error)", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "thiserror", "repository": "https://github.com/dtolnay/thiserror" }, { "authors": "David Tolnay ", "description": "Implementation detail of the `thiserror` crate", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "thiserror-impl", "repository": "https://github.com/dtolnay/thiserror" }, { "authors": "David Tolnay ", "description": "Implementation detail of the `thiserror` crate", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "thiserror-impl", "repository": "https://github.com/dtolnay/thiserror" }, { "authors": "bluss <>", "description": "A tree-structured thread pool for splitting jobs hierarchically on worker threads. The tree structure means that there is no contention between workers when delivering jobs.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "thread-tree", "repository": "https://github.com/bluss/thread-tree" }, { "authors": "Amanieu d'Antras ", "description": "Per-object thread-local storage", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "thread_local", "repository": "https://github.com/Amanieu/thread_local-rs" }, { "authors": "Jacob Pratt |Time contributors", "description": "Date and time library. Fully interoperable with the standard library. Mostly compatible with #![no_std].", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "time", "repository": "https://github.com/time-rs/time" }, { "authors": "Jacob Pratt |Time contributors", "description": "This crate is an implementation detail and should not be relied upon directly.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "time-core", "repository": "https://github.com/time-rs/time" }, { "authors": "Jacob Pratt |Time contributors", "description": "Procedural macros for the time crate. This crate is an implementation detail and should not be relied upon directly.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "time-macros", "repository": "https://github.com/time-rs/time" }, { "authors": "The ICU4X Project Developers", "description": "A small ASCII-only bounded length string representation.", "license": "Unicode-3.0", "license_file": null, "name": "tinystr", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "Lokathor ", "description": "`tinyvec` provides 100% safe vec-like data structures.", "license": "Apache-2.0 OR MIT OR Zlib", "license_file": null, "name": "tinyvec", "repository": "https://github.com/Lokathor/tinyvec" }, { "authors": "Soveu ", "description": "Some macros for tiny containers", "license": "Apache-2.0 OR MIT OR Zlib", "license_file": null, "name": "tinyvec_macros", "repository": "https://github.com/Soveu/tinyvec_macros" }, { "authors": "Tokio Contributors ", "description": "An event-driven, non-blocking I/O platform for writing asynchronous I/O backed applications.", "license": "MIT", "license_file": null, "name": "tokio", "repository": "https://github.com/tokio-rs/tokio" }, { "authors": "Tokio Contributors ", "description": "Tokio's proc macros.", "license": "MIT", "license_file": null, "name": "tokio-macros", "repository": "https://github.com/tokio-rs/tokio" }, { "authors": "Tokio Contributors ", "description": "An implementation of TLS/SSL streams for Tokio using native-tls giving an implementation of TLS for nonblocking I/O streams.", "license": "MIT", "license_file": null, "name": "tokio-native-tls", "repository": "https://github.com/tokio-rs/tls" }, { "authors": null, "description": "Asynchronous TLS/SSL streams for Tokio using Rustls.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "tokio-rustls", "repository": "https://github.com/rustls/tokio-rustls" }, { "authors": "Daniel Abramov |Alexey Galakhov ", "description": "Tokio binding for Tungstenite, the Lightweight stream-based WebSocket implementation", "license": "MIT", "license_file": null, "name": "tokio-tungstenite", "repository": "https://github.com/snapview/tokio-tungstenite" }, { "authors": "Tokio Contributors ", "description": "Additional utilities for working with Tokio.", "license": "MIT", "license_file": null, "name": "tokio-util", "repository": "https://github.com/tokio-rs/tokio" }, { "authors": null, "description": "A TOML-compatible datetime type", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "toml_datetime", "repository": "https://github.com/toml-rs/toml" }, { "authors": null, "description": "Yet another format-preserving TOML parser.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "toml_edit", "repository": "https://github.com/toml-rs/toml" }, { "authors": "Tower Maintainers ", "description": "Tower is a library of modular and reusable components for building robust clients and servers.", "license": "MIT", "license_file": null, "name": "tower", "repository": "https://github.com/tower-rs/tower" }, { "authors": "Tower Maintainers ", "description": "Tower middleware and utilities for HTTP clients and servers", "license": "MIT", "license_file": null, "name": "tower-http", "repository": "https://github.com/tower-rs/tower-http" }, { "authors": "Tower Maintainers ", "description": "Decorates a `Service` to allow easy composition between `Service`s.", "license": "MIT", "license_file": null, "name": "tower-layer", "repository": "https://github.com/tower-rs/tower" }, { "authors": "Tower Maintainers ", "description": "Trait representing an asynchronous, request / response based, client or server.", "license": "MIT", "license_file": null, "name": "tower-service", "repository": "https://github.com/tower-rs/tower" }, { "authors": "Eliza Weisman |Tokio Contributors ", "description": "Application-level tracing for Rust.", "license": "MIT", "license_file": null, "name": "tracing", "repository": "https://github.com/tokio-rs/tracing" }, { "authors": "Zeki Sherif |Tokio Contributors ", "description": "Provides utilities for file appenders and making non-blocking writers.", "license": "MIT", "license_file": null, "name": "tracing-appender", "repository": "https://github.com/tokio-rs/tracing" }, { "authors": "Tokio Contributors |Eliza Weisman |David Barsky ", "description": "Procedural macro attributes for automatically instrumenting functions.", "license": "MIT", "license_file": null, "name": "tracing-attributes", "repository": "https://github.com/tokio-rs/tracing" }, { "authors": "Tokio Contributors ", "description": "Core primitives for application-level tracing.", "license": "MIT", "license_file": null, "name": "tracing-core", "repository": "https://github.com/tokio-rs/tracing" }, { "authors": "Tokio Contributors ", "description": "Provides compatibility between `tracing` and the `log` crate.", "license": "MIT", "license_file": null, "name": "tracing-log", "repository": "https://github.com/tokio-rs/tracing" }, { "authors": "Eliza Weisman |David Barsky |Tokio Contributors ", "description": "Utilities for implementing and composing `tracing` subscribers.", "license": "MIT", "license_file": null, "name": "tracing-subscriber", "repository": "https://github.com/tokio-rs/tracing" }, { "authors": "Sean McArthur ", "description": "A lightweight atomic lock.", "license": "MIT", "license_file": null, "name": "try-lock", "repository": "https://github.com/seanmonstar/try-lock" }, { "authors": "Alexey Galakhov|Daniel Abramov", "description": "Lightweight stream-based WebSocket implementation", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "tungstenite", "repository": "https://github.com/snapview/tungstenite-rs" }, { "authors": "Jacob Brown ", "description": "Provides a typemap container with FxHashMap", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "type-map", "repository": "https://github.com/kardeiz/type-map" }, { "authors": "Paho Lurie-Gregg |Andre Bogus ", "description": "Typenum is a Rust library for type-level numbers evaluated at compile time. It currently supports bits, unsigned integers, and signed integers. It also provides a type-level array of type-level numbers, but its implementation is incomplete.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "typenum", "repository": "https://github.com/paholg/typenum" }, { "authors": null, "description": "Micro compiler for tensor operations.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "ug", "repository": "https://github.com/LaurentMazare/ug" }, { "authors": "The UNIC Project Developers", "description": "UNIC — Unicode Character Tools — Character Property taxonomy, contracts and build macros", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "unic-char-property", "repository": "https://github.com/open-i18n/rust-unic/" }, { "authors": "The UNIC Project Developers", "description": "UNIC — Unicode Character Tools — Character Range and Iteration", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "unic-char-range", "repository": "https://github.com/open-i18n/rust-unic/" }, { "authors": "The UNIC Project Developers", "description": "UNIC — Common Utilities", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "unic-common", "repository": "https://github.com/open-i18n/rust-unic/" }, { "authors": "Zibi Braniecki ", "description": "API for managing Unicode Language Identifiers", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "unic-langid", "repository": "https://github.com/zbraniecki/unic-locale" }, { "authors": "Zibi Braniecki ", "description": "API for managing Unicode Language Identifiers", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "unic-langid-impl", "repository": "https://github.com/zbraniecki/unic-locale" }, { "authors": "Zibi Braniecki ", "description": "API for managing Unicode Language Identifiers", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "unic-langid-macros", "repository": "https://github.com/zbraniecki/unic-locale" }, { "authors": "Zibi Braniecki ", "description": "API for managing Unicode Language Identifiers", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "unic-langid-macros-impl", "repository": "https://github.com/zbraniecki/unic-locale" }, { "authors": "The UNIC Project Developers", "description": "UNIC — Unicode Character Database — General Category", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "unic-ucd-category", "repository": "https://github.com/open-i18n/rust-unic/" }, { "authors": "The UNIC Project Developers", "description": "UNIC — Unicode Character Database — Version", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "unic-ucd-version", "repository": "https://github.com/open-i18n/rust-unic/" }, { "authors": "Sean McArthur ", "description": "A case-insensitive wrapper around strings.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "unicase", "repository": "https://github.com/seanmonstar/unicase" }, { "authors": "David Tolnay ", "description": "Determine whether characters have the XID_Start or XID_Continue properties according to Unicode Standard Annex #31", "license": "(Apache-2.0 OR MIT) AND Unicode-3.0", "license_file": null, "name": "unicode-ident", "repository": "https://github.com/dtolnay/unicode-ident" }, { "authors": "kwantam |Manish Goregaokar ", "description": "This crate provides functions for normalization of Unicode strings, including Canonical and Compatible Decomposition and Recomposition, as described in Unicode Standard Annex #15.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "unicode-normalization", "repository": "https://github.com/unicode-rs/unicode-normalization" }, { "authors": "kwantam |Manish Goregaokar ", "description": "This crate provides Grapheme Cluster, Word and Sentence boundaries according to Unicode Standard Annex #29 rules.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "unicode-segmentation", "repository": "https://github.com/unicode-rs/unicode-segmentation" }, { "authors": "kwantam |Manish Goregaokar ", "description": "Determine displayed width of `char` and `str` types according to Unicode Standard Annex #11 rules.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "unicode-width", "repository": "https://github.com/unicode-rs/unicode-width" }, { "authors": "erick.tryzelaar |kwantam |Manish Goregaokar ", "description": "Determine whether characters have the XID_Start or XID_Continue properties according to Unicode Standard Annex #31.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "unicode-xid", "repository": "https://github.com/unicode-rs/unicode-xid" }, { "authors": "Brian Smith ", "description": "Safe, fast, zero-panic, zero-crashing, zero-allocation parsing of untrusted inputs in Rust.", "license": "ISC", "license_file": null, "name": "untrusted", "repository": "https://github.com/briansmith/untrusted" }, { "authors": "Victor Koenders ", "description": "Explicitly types your generics", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "unty", "repository": "https://github.com/bincode-org/unty" }, { "authors": "The rust-url developers", "description": "URL library for Rust, based on the WHATWG URL Standard", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "url", "repository": "https://github.com/servo/rust-url" }, { "authors": "Simon Sapin ", "description": "Incremental, zero-copy UTF-8 decoding with error handling", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "utf-8", "repository": "https://github.com/SimonSapin/rust-utf8" }, { "authors": "Henri Sivonen ", "description": "Iterator by char over potentially-invalid UTF-8 in &[u8]", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "utf8_iter", "repository": "https://github.com/hsivonen/utf8_iter" }, { "authors": "Ashley Mannix|Dylan DPC|Hunar Roop Kahlon", "description": "A library to generate and parse UUIDs.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "uuid", "repository": "https://github.com/uuid-rs/uuid" }, { "authors": null, "description": "Object-safe value inspection, used to pass un-typed structured data across trait-object boundaries.", "license": "MIT", "license_file": null, "name": "valuable", "repository": "https://github.com/tokio-rs/valuable" }, { "authors": null, "description": "Implement things as if rust had variadics", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "variadics_please", "repository": "https://github.com/bevyengine/variadics_please" }, { "authors": "Jim McGrath ", "description": "A library to find native dependencies in a vcpkg tree at build time in order to be used in Cargo build scripts.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "vcpkg", "repository": "https://github.com/mcgoo/vcpkg-rs" }, { "authors": "Sergio Benitez ", "description": "Tiny crate to check the version of the installed/running rustc.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "version_check", "repository": "https://github.com/SergioBenitez/version_check" }, { "authors": "Andrew Gallant ", "description": "Recursively walk a directory.", "license": "MIT OR Unlicense", "license_file": null, "name": "walkdir", "repository": "https://github.com/BurntSushi/walkdir" }, { "authors": "Sean McArthur ", "description": "Detect when another Future wants a result.", "license": "MIT", "license_file": null, "name": "want", "repository": "https://github.com/seanmonstar/want" }, { "authors": "The Cranelift Project Developers", "description": "Experimental WASI API bindings for Rust", "license": "Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT", "license_file": null, "name": "wasi", "repository": "https://github.com/bytecodealliance/wasi" }, { "authors": "The Cranelift Project Developers", "description": "WASI API bindings for Rust", "license": "Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT", "license_file": null, "name": "wasi", "repository": "https://github.com/bytecodealliance/wasi-rs" }, { "authors": "The Cranelift Project Developers|john-sharratt", "description": "WASIX API bindings for Rust", "license": "Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT", "license_file": null, "name": "wasix", "repository": "https://github.com/wasix-org/wasix-abi-rust" }, { "authors": "The wasm-bindgen Developers", "description": "Easy support for interacting between JS and Rust.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "wasm-bindgen", "repository": "https://github.com/rustwasm/wasm-bindgen" }, { "authors": "The wasm-bindgen Developers", "description": "Backend code generation of the wasm-bindgen tool", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "wasm-bindgen-backend", "repository": "https://github.com/rustwasm/wasm-bindgen/tree/master/crates/backend" }, { "authors": "The wasm-bindgen Developers", "description": "Bridging the gap between Rust Futures and JavaScript Promises", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "wasm-bindgen-futures", "repository": "https://github.com/rustwasm/wasm-bindgen/tree/master/crates/futures" }, { "authors": "The wasm-bindgen Developers", "description": "Definition of the `#[wasm_bindgen]` attribute, an internal dependency", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "wasm-bindgen-macro", "repository": "https://github.com/rustwasm/wasm-bindgen/tree/master/crates/macro" }, { "authors": "The wasm-bindgen Developers", "description": "The part of the implementation of the `#[wasm_bindgen]` attribute that is not in the shared backend crate", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "wasm-bindgen-macro-support", "repository": "https://github.com/rustwasm/wasm-bindgen/tree/master/crates/macro-support" }, { "authors": "The wasm-bindgen Developers", "description": "Shared support between wasm-bindgen and wasm-bindgen cli, an internal dependency.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "wasm-bindgen-shared", "repository": "https://github.com/rustwasm/wasm-bindgen/tree/master/crates/shared" }, { "authors": "Mattias Buelens ", "description": "Bridging between web streams and Rust streams using WebAssembly", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "wasm-streams", "repository": "https://github.com/MattiasBuelens/wasm-streams/" }, { "authors": "The wasm-bindgen Developers", "description": "Bindings for all Web APIs, a procedurally generated crate from WebIDL", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "web-sys", "repository": "https://github.com/rustwasm/wasm-bindgen/tree/master/crates/web-sys" }, { "authors": null, "description": "Drop-in replacement for std::time for Wasm in browsers", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "web-time", "repository": "https://github.com/daxpedda/web-time" }, { "authors": "The html5ever Project Developers", "description": "Atoms for xml5ever and html5ever", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "web_atoms", "repository": "https://github.com/servo/html5ever" }, { "authors": null, "description": "Mozilla's CA root certificates for use with webpki", "license": "CDLA-Permissive-2.0", "license_file": null, "name": "webpki-roots", "repository": "https://github.com/rustls/webpki-roots" }, { "authors": "gfx-rs developers", "description": "Cross-platform, safe, pure-rust graphics API", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "wgpu", "repository": "https://github.com/gfx-rs/wgpu" }, { "authors": "gfx-rs developers", "description": "Core implementation logic of wgpu, the cross-platform, safe, pure-rust graphics API", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "wgpu-core", "repository": "https://github.com/gfx-rs/wgpu" }, { "authors": "gfx-rs developers", "description": "Feature unification helper crate for Apple platforms", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "wgpu-core-deps-apple", "repository": "https://github.com/gfx-rs/wgpu" }, { "authors": "gfx-rs developers", "description": "Feature unification helper crate for the Emscripten platform", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "wgpu-core-deps-emscripten", "repository": "https://github.com/gfx-rs/wgpu" }, { "authors": "gfx-rs developers", "description": "Feature unification helper crate for the Windows/Linux/Android platforms", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "wgpu-core-deps-windows-linux-android", "repository": "https://github.com/gfx-rs/wgpu" }, { "authors": "gfx-rs developers", "description": "Hardware abstraction layer for wgpu, the cross-platform, safe, pure-rust graphics API", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "wgpu-hal", "repository": "https://github.com/gfx-rs/wgpu" }, { "authors": "gfx-rs developers", "description": "Common types and utilities for wgpu, the cross-platform, safe, pure-rust graphics API", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "wgpu-types", "repository": "https://github.com/gfx-rs/wgpu" }, { "authors": "Peter Atashian ", "description": "Raw FFI bindings for all of Windows API.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "winapi", "repository": "https://github.com/retep998/winapi-rs" }, { "authors": "Peter Atashian ", "description": "Import libraries for the i686-pc-windows-gnu target. Please don't use this crate directly, depend on winapi instead.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "winapi-i686-pc-windows-gnu", "repository": "https://github.com/retep998/winapi-rs" }, { "authors": "Andrew Gallant ", "description": "A dumping ground for high level safe wrappers over windows-sys.", "license": "MIT OR Unlicense", "license_file": null, "name": "winapi-util", "repository": "https://github.com/BurntSushi/winapi-util" }, { "authors": "Peter Atashian ", "description": "Import libraries for the x86_64-pc-windows-gnu target. Please don't use this crate directly, depend on winapi instead.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "winapi-x86_64-pc-windows-gnu", "repository": "https://github.com/retep998/winapi-rs" }, { "authors": "Microsoft", "description": "Rust for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Rust for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Rust for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": null, "description": "Windows collection types", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-collections", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Rust for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-core", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Rust for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-core", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Core type support for COM and Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-core", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": null, "description": "Windows async types", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-future", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "The implement macro for the windows crate", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-implement", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "The implement macro for the windows crate", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-implement", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "The implement macro for the windows crate", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-implement", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "The interface macro for the windows crate", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-interface", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "The interface macro for the windows crate", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-interface", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "The interface macro for the windows crate", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-interface", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Linking for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-link", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": null, "description": "Windows numeric types", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-numerics", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Windows error handling", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-result", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Windows error handling", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-result", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Windows error handling", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-result", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Rust for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-strings", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Windows string types", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-strings", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Rust for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-sys", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Rust for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-sys", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Rust for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-sys", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Rust for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-sys", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import libs for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-targets", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import libs for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-targets", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import libs for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-targets", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Windows threading", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows-threading", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_aarch64_gnullvm", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_aarch64_gnullvm", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_aarch64_gnullvm", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_aarch64_msvc", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_aarch64_msvc", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_aarch64_msvc", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_i686_gnu", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_i686_gnu", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_i686_gnu", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_i686_gnullvm", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_i686_gnullvm", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_i686_msvc", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_i686_msvc", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_i686_msvc", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_x86_64_gnu", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_x86_64_gnu", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_x86_64_gnu", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_x86_64_gnullvm", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_x86_64_gnullvm", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_x86_64_gnullvm", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_x86_64_msvc", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_x86_64_msvc", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": "Microsoft", "description": "Import lib for Windows", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "windows_x86_64_msvc", "repository": "https://github.com/microsoft/windows-rs" }, { "authors": null, "description": "A byte-oriented, zero-copy, parser combinators library", "license": "MIT", "license_file": null, "name": "winnow", "repository": "https://github.com/winnow-rs/winnow" }, { "authors": "Luca Palmieri ", "description": "HTTP mocking to test Rust applications.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "wiremock", "repository": "https://github.com/LukeMathWalker/wiremock-rs" }, { "authors": "Alex Crichton ", "description": "Rust bindings generator and runtime support for WIT and the component model. Used when compiling Rust programs to the component model.", "license": "Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT", "license_file": null, "name": "wit-bindgen", "repository": "https://github.com/bytecodealliance/wit-bindgen" }, { "authors": "Cldfire", "description": "Derive macro for nvml-wrapper, not for general use", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "wrapcenum-derive", "repository": "https://github.com/Cldfire/wrapcenum-derive" }, { "authors": "The ICU4X Project Developers", "description": "A more efficient alternative to fmt::Display", "license": "Unicode-3.0", "license_file": null, "name": "writeable", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "Vladimir Matveev ", "description": "An XML library in pure Rust", "license": "MIT", "license_file": null, "name": "xml-rs", "repository": "https://github.com/kornelski/xml-rs" }, { "authors": "Manish Goregaokar ", "description": "Abstraction allowing borrowed data to be carried along with the backing data it borrows from", "license": "Unicode-3.0", "license_file": null, "name": "yoke", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "Manish Goregaokar ", "description": "Abstraction allowing borrowed data to be carried along with the backing data it borrows from", "license": "Unicode-3.0", "license_file": null, "name": "yoke", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "Manish Goregaokar ", "description": "Custom derive for the yoke crate", "license": "Unicode-3.0", "license_file": null, "name": "yoke-derive", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "Manish Goregaokar ", "description": "Custom derive for the yoke crate", "license": "Unicode-3.0", "license_file": null, "name": "yoke-derive", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "Joshua Liebow-Feeser |Jack Wrenn ", "description": "Zerocopy makes zero-cost memory manipulation effortless. We write \"unsafe\" so you don't have to.", "license": "Apache-2.0 OR BSD-2-Clause OR MIT", "license_file": null, "name": "zerocopy", "repository": "https://github.com/google/zerocopy" }, { "authors": "Joshua Liebow-Feeser |Jack Wrenn ", "description": "Custom derive for traits from the zerocopy crate", "license": "Apache-2.0 OR BSD-2-Clause OR MIT", "license_file": null, "name": "zerocopy-derive", "repository": "https://github.com/google/zerocopy" }, { "authors": "Manish Goregaokar ", "description": "ZeroFrom trait for constructing", "license": "Unicode-3.0", "license_file": null, "name": "zerofrom", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "Manish Goregaokar ", "description": "Custom derive for the zerofrom crate", "license": "Unicode-3.0", "license_file": null, "name": "zerofrom-derive", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "The RustCrypto Project Developers", "description": "Securely clear secrets from memory with a simple trait built on stable Rust primitives which guarantee memory is zeroed using an operation will not be 'optimized away' by the compiler. Uses a portable pure Rust implementation that works everywhere, even WASM!", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "zeroize", "repository": "https://github.com/RustCrypto/utils/tree/master/zeroize" }, { "authors": "The ICU4X Project Developers", "description": "A data structure that efficiently maps strings to integers", "license": "Unicode-3.0", "license_file": null, "name": "zerotrie", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "The ICU4X Project Developers", "description": "Zero-copy vector backed by a byte array", "license": "Unicode-3.0", "license_file": null, "name": "zerovec", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "Manish Goregaokar ", "description": "Custom derive for the zerovec crate", "license": "Unicode-3.0", "license_file": null, "name": "zerovec-derive", "repository": "https://github.com/unicode-org/icu4x" }, { "authors": "Mathijs van de Nes |Marli Frost |Ryan Levick |Chris Hennick ", "description": "Library to support the reading and writing of zip files.", "license": "MIT", "license_file": null, "name": "zip", "repository": "https://github.com/zip-rs/zip2.git" }, { "authors": "Mathijs van de Nes |Marli Frost |Ryan Levick |Chris Hennick ", "description": "Library to support the reading and writing of zip files.", "license": "MIT", "license_file": null, "name": "zip", "repository": "https://github.com/zip-rs/zip2.git" }, { "authors": null, "description": "A memory-safe zlib implementation written in rust", "license": "Zlib", "license_file": null, "name": "zlib-rs", "repository": "https://github.com/trifectatechfoundation/zlib-rs" }, { "authors": null, "description": "A Rust implementation of the Zopfli compression algorithm.", "license": "Apache-2.0", "license_file": null, "name": "zopfli", "repository": "https://github.com/zopfli-rs/zopfli" }, { "authors": "Alexandre Bury ", "description": "Binding for the zstd compression library.", "license": "MIT", "license_file": null, "name": "zstd", "repository": "https://github.com/gyscos/zstd-rs" }, { "authors": "Alexandre Bury ", "description": "Safe low-level bindings for the zstd compression library.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "zstd-safe", "repository": "https://github.com/gyscos/zstd-rs" }, { "authors": "Alexandre Bury ", "description": "Low-level bindings for the zstd compression library.", "license": "Apache-2.0 OR MIT", "license_file": null, "name": "zstd-sys", "repository": "https://github.com/gyscos/zstd-rs" } ] ================================================ FILE: check ================================================ #!/bin/bash ./ninja format && ./ninja check ================================================ FILE: docs/architecture.md ================================================ # Anki Architecture Very brief notes for now. ## Backend/GUI At the highest level, Anki is logically separated into two parts. A neat visualization of the file layout is available here: (or go to and enter `ankitects/anki`). ### Library (rslib & pylib) The Python library (pylib) exports "backend" methods - opening collections, fetching and answering cards, and so on. It is used by Anki’s GUI, and can also be included in command line programs to access Anki decks without the GUI. The library is accessible in Python with "import anki". Its code lives in the `pylib/anki/` folder. These days, the majority of backend logic lives in a Rust library (rslib, located in `rslib/`). Calls to pylib proxy requests to rslib, and return the results. pylib contains a private Python module called rsbridge (`pylib/rsbridge/`) that wraps the Rust code, making it accessible in Python. ### GUI (aqt & ts) Anki's _GUI_ is a mix of Qt (via the PyQt Python bindings for Qt), and TypeScript/HTML/CSS. The Qt code lives in `qt/aqt/`, and is importable in Python with "import aqt". The web code is split between `qt/aqt/data/web/` and `ts/`, with the majority of new code being placed in the latter, and copied into the former at build time. ## Protobuf Anki uses Protocol Buffers to define backend methods, and the storage format of some items in a collection file. The definitions live in `proto/anki/`. The Python/Rust bridge uses them to pass data back and forth, and some of the TypeScript code also makes use of them, allowing data to be communicated in a type-safe manner between the different languages. At the moment, the protobuf is not considered public API. Some pylib methods expose a protobuf object directly to callers, but when they do so, they use a type alias, so callers outside pylib should never need to import a generated \_pb2.py file. ================================================ FILE: docs/build.md ================================================ # The build system ## Basic use Basic use is described in [development.md](./development.md). ## Architecture The build/ folder is made up of 4 packages: - build/configure defines the actions and inputs/outputs of the build graph - this is where you add new build steps or modify existing ones. The defined actions are converted at build time to a build.ninja file that Ninja executes. - build/ninja_gen is a library for writing a build.ninja file, and includes various rules like "build a Rust crate" or "run a command". - build/archives is a helper to download/checksum/extract a dependency as part of the build process. - build/runner serves a number of purposes: - it's the entrypoint to the build process, taking care of generating the build file and then invoking Ninja - it wraps executable invocations in the build file, swallowing their output if they exit successfully - it provides a few helpers for multi-step processes that can't be easily described in a cross-platform manner thanks to differences on Windows. ## Tracing build problems If you run into trouble with the build process: - You can see the executed commands with e.g. `./ninja pylib -v` - You can see the output of successful commands by defining OUTPUT_SUCCESS=1 - You can see what's triggering a rebuild of a target with e.g. `./ninja qt/anki -d explain`. - You can browse the build graph via e.g. `./ninja -- -t browse wheels` - You can profile build performance with https://discourse.cmake.org/t/profiling-build-performance/2443/3. ## Packaging considerations See [this page](./linux.md). ================================================ FILE: docs/contributing.md ================================================ # Contributing Code For info on contributing things other than code, such as translations, decks and add-ons, please see https://docs.ankiweb.net/contrib ## Help wanted If you'd like to contribute but don't know what to work on, please take a look at the [issues tab](https://github.com/ankitects/anki/issues) of the Anki repo on GitHub. ## Larger changes Before starting work on larger changes, especially ones that aren't listed on the issue tracker, please reach out on the forums before you begin work, so we can let you know whether they're likely to be accepted or not. When you spent a bunch of time on a PR that ends up getting rejected, it's no fun for either you or us. ## Refactoring Please avoid PRs that focus on refactoring. Every PR has a cost to review, and a chance of introducing accidental regressions, and often these costs are not worth it for slightly more elegant code. That's not to say there's no value in refactoring. But such changes are usually better done in a PR that happens to be working in the same area - for example, making small changes to the code as part of fixing a bug, or a larger refactor when introducing a new feature. ## Type hints Most of Anki's Python code now has type hints, which improve code completion, and make it easier to discover errors during development. When adding new code, please make sure you add type hints as well, or the tests will fail. Qt's stubs are not perfect, so you may sometimes need to use cast(), or silence a type error. When connecting signals, there's a qconnect() helper in aqt.utils that can be used to work around the type warnings without obscuring other errors such as a mistyped variable. In cases where you have two modules that reference each other, you can fix the import cycle by using fully qualified names in the types, and enabling annotations. For example, instead of ``` from aqt.browser import Browser def myfunc(b: Browser) -> None: pass ``` use the following instead: ``` from __future__ import annotations import aqt def myfunc(b: aqt.browser.Browser) -> None: pass ``` ## Hooks If you're writing an add-on and would like to extend a function that doesn't currently have a hook, a pull request that adds the required hooks would be welcome. If you could mention your use case in the pull request, that would be appreciated. The hooks try to follow one of two formats: [subject] [verb] - eg, note_type_added, card_will_render [module] [verb] [subject] - eg, browser_did_change_row, editor_did_update_tags The qt code tends to use the second form, as the hooks tend to focus on particular screens. The pylib code tends to use the first form, as the focus is usually subjects like cards, notes, etc. Using "did change" instead of the past tense "changed" can seem awkward, but makes it consistent with "will", and is similar to the naming style used in iOS's libraries. In most cases, hooks are better added in the GUI code than in pylib. The hook code is automatically generated using the definitions in pylib/tools/genhooks.py and qt/tools/genhooks_gui.py. Adding a new definition in one of those files will update the generated files. If you want to change an existing hook to, for example, receive an additional argument, you must leave the existing hook unchanged to preserve backwards compatibility. Create a new definition for your hook with a similar name and include the properties `replaces="name_of_old_hook"` and `replaced_hook_args=["..."]` in the definition of the new hook. If the old hook has a legacy hook, you must not add the legacy hook to the definition of the new hook. ## Translations For information on adding new translatable strings to Anki, please see https://translating.ankiweb.net/anki/developers ## Tests Must Pass Please make sure 'ninja check' completes successfully before submitting code. You can do this automatically by adding the following into .git/hooks/pre-commit or .git/hooks/pre-push and making it executable. ```sh #!/bin/bash ./ninja check ``` You may want to explicitly set PATH to your normal shell PATH in that script, as pre-commit does not use a login shell, and if your path differs Bazel will end up recompiling things unnecessarily. If your change is non-trivial and not covered by the existing unit tests, please consider adding a unit test at the same time. ## Code Style Please use standard Python snake_case variable names and functions in newly introduced code. Because add-ons often rely on existing function names, if renaming an existing function, please add a legacy alias to the old function. ## Do One Thing A patch or pull request should be the minimum necessary to address one issue. Please don't make a pull request for a bunch of unrelated changes, as they are difficult to review and will be rejected - split them up into separate requests instead. ## License Please add yourself to the CONTRIBUTORS file in your first pull request. ================================================ FILE: docs/development.md ================================================ # Anki development ## Packaged betas For non-developers who want to try beta versions, the easiest way is to use a packaged version - please see: https://betas.ankiweb.net/ ## Pre-built Python wheels Pre-built Python packages are available on PyPI. They are useful if you wish to: - Run Anki from a local Python installation without building it yourself - Get code completion when developing add-ons - Make command line scripts that modify .anki2 files via Anki's Python libraries You will need the 64 bit version of Python 3.9 or later installed. 3.9 is recommended, as Anki has only received minimal testing on 3.10+ so far, and some dependencies have not been fully updated yet. You can install Python from python.org or from your distro. For further instructions, please see https://betas.ankiweb.net/#via-pypipip. Note that in the provided commands, `--pre` tells pip to fetch alpha/beta versions. If you remove `--pre`, it will download the latest stable version instead. ## Building from source Clone the git repo into a folder of your choosing. The folder path must not contain spaces, and should not be too long if you are on Windows. On all platforms, you will need to install: - Rustup (https://rustup.rs/). The Rust version pinned in rust-toolchain.toml will be automatically downloaded if not yet installed. If removing that file to use a distro-provided Rust, newer Rust versions will typically work for building but may fail tests; older Rust versions may not work at all. - N2 or Ninja. N2 gives better status output. You can install it with `tools/install-n2`, or `bash tools\install-n2` on Windows. If you want to use Ninja, it can be downloaded from https://github.com/ninja-build/ninja/releases/tag/v1.11.1 and placed on your path, or from your distro/homebrew if it's 1.10+. Platform-specific requirements: - [Windows](./windows.md) - [Mac](./mac.md) - [Linux](./linux.md) ## Running Anki during development From the top level of Anki's source folder: ``` ./run ``` (`.\run` on Windows) This will build Anki and run it in place. The first build will take a while, as it downloads and builds a bunch of dependencies. When the build is complete, Anki will automatically start. If Anki fails to start, you may need to install [extra libraries](https://docs.ankiweb.net/platform/linux/missing-libraries.html). ## Running tests/checks To run all tests at once, from the top-level folder: ``` ./ninja check ``` (`tools\ninja check` on Windows). You can also run specific checks. For example, if you see during the checks that `check:svelte:editor` is failing, you can use `./ninja check:svelte:editor` to re-run that check, or `./ninja check:svelte` to re-run all Svelte checks. ## Fixing formatting When formatting issues are reported, they can be fixed with ``` ./ninja format ``` ## Fixing ruff/eslint/copyright header issues ``` ./ninja fix ``` ## Fixing clippy issues ``` cargo clippy --fix ``` ## Excluding your own untracked files from formatting and checks If you want to add files or folders to the project tree that should be excluded from version tracking and not be matched by formatters and checks, place them in an `extra` folder and they will automatically be ignored. ## Optimized builds The `./run` command will create a non-optimized build by default. This is faster to compile, but will mean Anki will run slower. To run Anki in optimized mode, use: ``` ./tools/runopt ``` Or set RELEASE=1 or RELEASE=2. The latter will further optimize the output, but make the build much slower. ## Building redistributable wheels The `./run` method described in the platform-specific instructions is a shortcut for starting Anki directly from the build folder. For regular study, it's recommended you build Python wheels and then install them into your own python venv. This is also a good idea if you wish to install extra tools from PyPi that Anki's build process does not use. To build wheels on Mac/Linux: ``` ./tools/build ``` (on Windows, `\tools\build.bat`) The generated wheels are in out/wheels. You can then install them by copying the paths into a pip install command. Follow the steps [on the beta site](https://betas.ankiweb.net/#via-pypipip), but replace the `pip install --upgrade --pre aqt` line with something like: ``` /my/pyenv/bin/pip install --upgrade out/wheels/*.whl ``` (On Windows you'll need to list out the filenames manually instead of using a wildcard). ## Cleaning up build files Apart from submodule checkouts, most build files go into the `out/` folder (and `node_modules` on Windows). You can delete that folder for a clean build, or to free space. Cargo, yarn and pip all cache downloads of dependencies in a shared cache that other builds on your system may use as well. If you wish to clear up those caches, they can be found in `~/.rustup`, `~/.cargo` and `~/.cache/{yarn,pip}`. If you invoke Rust outside of the build scripts (eg by running cargo, or with Rust Analyzer), output files will go into `target/` unless you have overriden the default output location. ## IDEs Please see [this separate page](./editing.md) for setting up an editor/IDE. ## Making changes to the build See [this page](./build.md) ## Generating documentation For Rust: ``` cargo doc --open ``` For Python: ``` ./ninja python:sphinx && open out/python/sphinx/html/py-modindex.html ``` ## Environmental Variables If ANKIDEV is set before starting Anki, some extra log messages will be printed on stdout, and automatic backups will be disabled - so please don't use this except on a test profile. It is automatically enabled when using ./run. If TRACESQL is set, all SQL statements will be printed as they are executed. If LOGTERM is set before starting Anki, warnings and error messages that are normally placed in the collection2.log file will also be printed on stdout. If ANKI_PROFILE_CODE is set, Python profiling data will be written on exit. # Installer/launcher - The anki-release package is created/published with the scripts in qt/release. - The installer/launcher is created with the build scripts in qt/launcher/{platform}. ## Building The steps to build the launcher vary slightly depending on your operating system. First, you have to navigate to the appropriate folder: | Operating System | Path | Env variables | | ---------------- | ------------------ | ------------- | | Linux | ./qt/launcher/lin/ | - | | MacOS | ./qt/launcher/mac/ | `NODMG=1` | | Windows | .\qt\launcher\win\ | `NOCOMP=1` | If you are on Windows or MacOS, you will now have to set the environment variables as outlined in the table above. `NOCOMP=1` skips code signing and compression, whereas `NODMG=1` skips the slow bundling / code signing. Next, run the `build.sh` script (on Linux and MacOS) or the `build.bat` script (on Windows). For example, on Linux, you can build the launcher by following these steps: ``` cd ./qt/launcher/lin/ ./build.sh ``` ## Issues During Building If you are experiencing issues building the launcher, make sure that all dependencies are installed. See [Building from source](#building-from-source) for more info. ## Running Once the launcher is built, you can find the executable under `out/launcher` (located in the project root). In that folder, you will find the binary file of the launcher. On linux, you will find a `launcher.amd64` and a `launcher.arm64` binary file. Select the one matching your architecture and run it to test your changes. For example, on Linux, after following the build steps above, you can run the amd64 launcher via this command: ``` ../../../out/launcher/anki-launcher-25.09.2-linux/launcher.amd64 ``` # Mixing development and study You may wish to create a separate profile with File>Switch Profile for use during development. You can pass the arguments "-p [profile name]" when starting Anki to load a specific profile. If you're using PyCharm: - right click on the "run" file in the root of the PyCharm Anki folder - click "Edit 'run'..." - in Script options and enter: "-p [dev profile name]" without the quotes - click "Ok" ================================================ FILE: docs/docker/Dockerfile ================================================ # This is a user-contributed Dockerfile. No official support is available. ARG DEBIAN_FRONTEND="noninteractive" FROM ubuntu:24.04 AS build WORKDIR /opt/anki ENV PYTHON_VERSION="3.13" # System deps RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ git \ build-essential \ pkg-config \ libssl-dev \ libbz2-dev \ libreadline-dev \ libsqlite3-dev \ libffi-dev \ zlib1g-dev \ liblzma-dev \ ca-certificates \ ninja-build \ rsync \ libglib2.0-0 \ libgl1 \ libx11-6 \ libxext6 \ libxrender1 \ libxkbcommon0 \ libxkbcommon-x11-0 \ libxcb1 \ libxcb-render0 \ libxcb-shm0 \ libxcb-icccm4 \ libxcb-image0 \ libxcb-keysyms1 \ libxcb-randr0 \ libxcb-shape0 \ libxcb-xfixes0 \ libxcb-xinerama0 \ libxcb-xinput0 \ libsm6 \ libice6 \ && rm -rf /var/lib/apt/lists/* # install rust with rustup RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y ENV PATH="/root/.cargo/bin:${PATH}" # Install uv and Python 3.13 with uv RUN curl -LsSf https://astral.sh/uv/install.sh | sh \ && ln -s /root/.local/bin/uv /usr/local/bin/uv ENV PATH="/root/.local/bin:${PATH}" RUN uv python install ${PYTHON_VERSION} --default COPY . . RUN ./tools/build # Install pre-compiled Anki. FROM python:3.13-slim AS installer WORKDIR /opt/anki/ COPY --from=build /opt/anki/out/wheels/ wheels/ # Use virtual environment. RUN python -m venv venv \ && ./venv/bin/python -m pip install --no-cache-dir setuptools wheel \ && ./venv/bin/python -m pip install --no-cache-dir /opt/anki/wheels/*.whl # We use another build stage here so we don't include the wheels in the final image. FROM python:3.13-slim AS final COPY --from=installer /opt/anki/venv /opt/anki/venv ENV PATH=/opt/anki/venv/bin:$PATH # Install run-time dependencies. RUN apt-get update \ && apt-get install --yes --no-install-recommends \ libasound2 \ libdbus-1-3 \ libfontconfig1 \ libfreetype6 \ libgl1 \ libglib2.0-0 \ libnss3 \ libxcb-icccm4 \ libxcb-image0 \ libxcb-keysyms1 \ libxcb-randr0 \ libxcb-render-util0 \ libxcb-shape0 \ libxcb-xinerama0 \ libxcb-xkb1 \ libxcomposite1 \ libxcursor1 \ libxi6 \ libxkbcommon0 \ libxkbcommon-x11-0 \ libxrandr2 \ libxrender1 \ libxtst6 \ && rm -rf /var/lib/apt/lists/* # Add non-root user. RUN useradd --create-home anki USER anki WORKDIR /work ENTRYPOINT ["/opt/anki/venv/bin/anki"] ================================================ FILE: docs/docker/README.md ================================================ # Building and running Anki in Docker This is an example Dockerfile contributed by an Anki user, which shows how Anki can be both built and run from within a container. It works by streaming the GUI over an X11 socket. Building and running Anki within a container has the advantage of fully isolating the build products and runtime dependencies from the rest of your system, but it is a somewhat niche approach, with some downsides such as an inability to display natively on Wayland, and a lack of integration with desktop icons/filetypes. But even if you do not use this Dockerfile as-is, you may find it useful as a reference. Anki's Linux CI is also implemented with Docker, and the Dockerfiles for that may also be useful for reference - they can be found in `.buildkite/linux/docker`. # Build the Docker image For best results, enable BuildKit (`export DOCKER_BUILDKIT=1`). When in this current directory, one can build the Docker image like this: ```bash docker build --tag anki --file Dockerfile ../../ ``` When this is done, run `docker image ls` to see that the image has been created. If one wants to build from the project's root directory, use this command: ```bash docker build --tag anki --file docs/docker/Dockerfile . ``` # Run the Docker image Anki starts a graphical user interface, and this requires some extra setup on the user's end. These instructions were tested on Linux (Debian 11) and will have to be adapted for other operating systems. To allow the Docker container to pull up a graphical user interface, we need to run the following: ```bash xhost +local:root ``` Once done using Anki, undo this with ```bash xhost -local:root ``` Then, we will construct our `docker run` command: ```bash docker run --rm -it \ --name anki \ --volume $HOME/.local/share:$HOME/.local/share:rw \ --volume /etc/passwd:/etc/passwd:ro \ --user $(id -u):$(id -g) \ --volume /tmp/.X11-unix:/tmp/.X11-unix:rw \ --env DISPLAY=$DISPLAY \ anki ``` Here is a breakdown of some of the arguments: - Mount the current user's `~/.local/share` directory onto the container. Anki saves things into this directory, and if we don't mount it, we will lose any changes once the container exits. We mount this as read-write (`rw`) because we want to make changes here. ```bash --volume $HOME/.local/share:$HOME/.local/share:rw ``` - Mount `/etc/passwd` so we can enter the container as ourselves. We mount this as read-only because we definitely do not want to modify this. ```bash --volume /etc/passwd:/etc/passwd:ro ``` - Enter the container with our user ID and group ID, so we stay as ourselves. ```bash --user $(id -u):$(id -g) ``` - Mount the X11 directory that allows us to open displays. ```bash --volume /tmp/.X11-unix:/tmp/.X11-unix:rw ``` - Pass the `DISPLAY` variable to the container, so it knows where to display graphics. ```bash --env DISPLAY=$DISPLAY ``` # Running Dockerized Anki easily from the command line One can create a shell function that executes the `docker run` command. Then one can simply run `anki` on the command line, and Anki will open in Docker. Make sure to change the image name to whatever you used when building Anki. ```bash anki() { docker run --rm -it \ --name anki \ --volume $HOME/.local/share:$HOME/.local/share:rw \ --volume /etc/passwd:/etc/passwd:ro \ --user $(id -u):$(id -g) \ --volume /tmp/.X11-unix:/tmp/.X11-unix:rw \ --env DISPLAY=$DISPLAY \ anki "$@" } ``` ================================================ FILE: docs/editing.md ================================================ # Editing/IDEs Visual Studio Code is recommended, since it provides decent support for all the languages Anki uses. To set up the recommended workspace settings for VS Code, please see below. For editing Python, PyCharm/IntelliJ's type checking/completion is a bit nicer than VS Code, but VS Code has improved considerably in a short span of time. There are a few steps you'll want to take before you start using an IDE. ## Initial Setup ### Python Environment For code completion of external Python modules, you can use the venv that is generated as part of the build process. After building Anki, the venv will be in `out/pyenv`. In VS Code, use ctrl/cmd+shift+p, then 'python: select interpreter'. ### Rust You'll need Rust to be installed, which is required as part of the build process. ### Build First Code completion partly depends on files that are generated as part of the regular build process, so for things to work correctly, use './run' or 'tools/build' prior to using code completion. ## Visual Studio Code ### Setting up Recommended Workspace Settings To start off with some default workspace settings that are optimized for Anki development, please head to the project root and then run: ``` mkdir .vscode && cd .vscode ln -sf ../.vscode.dist/* . ``` ### Installing Recommended Extensions Once the workspace settings are set up, open the root of the repo in VS Code to see and install a number of recommended extensions. ## PyCharm/IntelliJ ### Setting up Python environment To make PyCharm recognize `anki` and `aqt` imports, you need to add source paths to _Settings > Project Structure_. You can copy the provided .idea.dist directory to set up the paths automatically: ``` mkdir .idea && cd .idea ln -sf ../.idea.dist/* . ``` You also need to add a new Python interpreter under _Settings > Python > Interpreter_ pointing to the Python executable under `out/pyenv` (available after building Anki). ================================================ FILE: docs/language_bridge.md ================================================ Anki's codebase uses three layers. 1. The web frontend, created in Svelte and typescript, 2. The Python layer and 3. The core Rust layer. Each layer can can makes RPC (Remote Procedure Call) to the layers below it. While it should be avoided, Python can also invoke Typescript functions. The Rust layers never make calls to the other layers. Note that it can make RPC to AnkiWeb and other servers, which is out of scope of this document. In this document we'll provide examples of bridge between languages, explaining: - where the RPC is declared, - where it is called (with the appropriate imports) and - where it is implemented. Imitating those examples should allow you to make call and create new RPCs. ## Declaring RPCs Let's consider the method `NewDeck` of `DecksServices`. It's declared in [decks.proto](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/proto/anki/decks.proto#L14) as `rpc NewDeck(generic.Empty) returns (Deck);`. This means this methods takes no argument (technically, an argument containing no information), and returns a [`Deck`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/proto/anki/decks.proto#L54). Read [protobuf](./protobuf.md) to learn more about how those input and output types are defined. If the RPC implementation is in Python, it should be declared in the service [frontend.proto](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/proto/anki/frontend.proto#L24C3-L24C66)'s `FrontendService`. RPCs declared in any other services are implemented in Rust. ## Making a Remote Procedure Call In this section we'll consider how to make Remote Procedure Call (RPC) from languages used in Anki. Languages used for AnkiDroid and AnkiMobile are out of scope of this document. ### Making a RPC from Python Python can invoke the `NewDeck` method with [`col._backend.new_deck()`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/pylib/anki/decks.py#L168). This python method takes no argument and returns a `Deck` value. However, most Python code should not call this method directly. Instead it should call [`col.decks.new_deck()`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/pylib/anki/decks.py#L166). Generally speaking, all back-end functions called from Python should be called through a helper method defined in `pylib/anki/`. The `_backend` part is an implementation detail that most callers should ignore. This is especially important because add-ons should expect a relatively stable API independent of the implementation details of the RPC. ### Invoking method from TypeScript Let's consider the method [`rpc GetCsvMetadata(CsvMetadataRequest) returns (CsvMetadata);`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/proto/anki/import_export.proto#L20) from `ImportExportService`.. It's used in the TypeScript class [`ImportCsvState`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/ts/routes/import-csv/lib.ts#L102), as an asynchronous function. It's argument is a single javascript object, whose keys are as in [`CsvMetadataRequest`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/proto/anki/import_export.proto#L138) and it returns a `CsvMetadata`. The method was imported with `import { getCsvMetadata } from "@generated/backend";` and the types were imported with `import type { CsvMetadata } from "@generated/anki/import_export_pb";`. Note that it was not necessary to import the input type given that it's simply an untyped javascript object. ## Implementation Let's now look at implementations of those RPCs. ### Implementation in Rust The method NewDeck is implemented in Rust's [DecksService](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/rslib/src/decks/service.rs#L21) as `fn new_deck(&mut self) -> error::Result`. It should be noted that the method name was changed from Pascal case to snake case, and the rps's argument of type `generic.Empty` is ignored. ### Implementation in Python Let's consider the implementation of the method [DeckOptionsRequireClose](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/qt/aqt/mediasrv.py#L578). It's defined as `def deck_options_require_close() -> bytes:`. In this case, there should be a returned value. However, it'll be ignored, so returning `b""` is perfectly fine. Note that the incoming HTTP request is not processed on the main thread. In order to do any work with the GUI, we should call `aqt.mw.taskman.run_on_main`. ## Invoking a TypeScript method from Python This case should be avoided if possible, as we generally should avoid calls to the upper layer. Contrary to the previous cases, we don't use protobuf. ### Calling a TS function. Let's take as Example [`export function getTypedAnswer(): string | null`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/ts/reviewer/index.ts#L35). It's an exported function, and its return type can be encoded in JSON. It's called in the Reviewer class through [`self.web.evalWithCallback("getTypedAnswer();", self._onTypedAnswer)`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/qt/aqt/reviewer.py#L785). The result is then sent to [`_onTypedAnswer`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/qt/aqt/reviewer.py#L787). If no return value is needed, `web.eval` would have been sufficient. ### Calling a Svelte method Let's now consider the case where the method we want to call is implemented in a Svelte library. Let's take as example [`deckOptionsPendingChanges`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/ts/routes/deck-options/%5BdeckId%5D/%2Bpage.svelte#L17). We define it with: ```js globalThis.anki || = {}; globalThis.anki.methodName = async (): Promise=>{body} ``` Note that if the function is asynchronous, you can't directly send the result to a callback. Instead your function will have to call a post method that will be sent to Python or Rust. This method is called in [deckoptions.py](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/qt/aqt/deckoptions.py#L68) with `self.web.eval("anki.deckOptionsPendingChanges();"`. ================================================ FILE: docs/linux.md ================================================ # Linux-specific notes ## Requirements These instructions are written for Debian/Ubuntu; adjust for your distribution. Some extra notes have been provided by a forum member, though some of the things mentioned there no longer apply: https://forums.ankiweb.net/t/guide-how-to-build-and-run-anki-from-source-with-xubuntu-20-04/12865 You can see a full list of buildtime and runtime requirements by looking at the [Dockerfile](../.buildkite/linux/docker/Dockerfile) used to build the official releases. **Ensure some basic tools are installed**: ``` $ sudo apt install bash grep findutils curl gcc gcc-12 g++ make git rsync ``` - The 'find' utility is 'findutils' on Debian. ## Missing Libraries If you get errors during build or startup, try starting with QT_DEBUG_PLUGINS=1 ./run It will likely complain about missing libraries, which you can install with your package manager. Some of the libraries that might be required on Debian for example: ``` sudo apt install libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \ libxcb-randr0 libxcb-render-util0 libxkbfile1 ``` The libraries that might be required on Arch Linux: ``` sudo pacman -S nss libxkbfile ``` On some distros such as Fedora, you may need to install the `libxcrypt-compat` package if you get an error like this: ``` error while loading shared libraries: libcrypt.so.1: cannot open shared object file: No such file or directory ``` ## Dependencies for Building the Launcher If you want to build the launcher, you will need to install the following dependency: ``` sudo apt install gcc-aarch64-linux-gnu ``` ## Audio To play and record audio during development, install mpv and lame. ## Glibc and Qt Anki requires a recent glibc. If you are using a distro that uses musl, Anki will not work. You can use your system's Qt libraries if they are Qt 6.2 or later, if you wish. After installing the system libraries (eg: 'sudo apt install python3-pyqt6.qt{quick,webengine} python3-venv pyqt6-dev-tools'), find the place they are installed (eg '/usr/lib/python3/dist-packages'). On modern Ubuntu, you'll also need 'sudo apt remove python3-protobuf'. Then before running any commands like './run', tell Anki where the packages can be found: ``` export PYTHONPATH=/usr/lib/python3/dist-packages export PYTHON_BINARY=/usr/bin/python3 ``` ## Packaging considerations Python, node and protoc are downloaded as part of the build. You can optionally define PYTHON_BINARY, NODE_BINARY, YARN_BINARY and/or PROTOC_BINARY to use locally-installed versions instead. If rust-toolchain.toml is removed, newer Rust versions can be used. Older versions may or may not compile the code. To build Anki fully offline, set the following environment variables: - OFFLINE_BUILD: If set, the build does not run tools that may access the network. - NODE_BINARY, YARN_BINARY and PROTOC_BINARY must also be set. With OFFLINE_BUILD defined, manual intervention is required for the offline build to succeed. The following conditions must be met: 1. All required dependencies (node, Python, rust, yarn, etc.) must be present in the build environment. 2. The offline repositories for the translation files must be copied/linked to ftl/qt-repo and ftl/core-repo. 3. The Python pseudo venv must be set up: ``` mkdir out/pyenv/bin ln -s /path/to/python out/pyenv/bin/python ln -s /path/to/protoc-gen-mypy out/pyenv/bin/protoc-gen-mypy ``` Optionally, set up your environment to generate Sphinx documentation: ``` ln -s /path/to/sphinx-apidoc out/pyenv/bin/sphinx-apidoc ln -s /path/to/sphinx-build out/pyenv/bin/sphinx-build ``` Note that the PYTHON_BINARY environment variable need not be set, since it is only used when OFFLINE_BUILD is unset to automatically create a network-dependent Python venv. 4. Create the offline cache for yarn and use its own environment variable YARN_CACHE_FOLDER to it: ``` YARN_CACHE_FOLDER=/path/to/the/yarn/cache /path/to/yarn install --ignore-scripts ``` You are now ready to build wheels and Sphinx documentation fully offline. ## More For info on running tests, building wheels and so on, please see [Development](./development.md). ================================================ FILE: docs/mac.md ================================================ # Mac-specific notes ## Requirements **Xcode**: Install the latest XCode from the App Store. Open it at least once so it installs the command line tools. **Git/rsync** Install via Homebrew or similar tool. ## Audio To play audio, use Homebrew to install mpv and lame. ## More For info on running tests, building wheels and so on, please see [Development](./development.md). ================================================ FILE: docs/ninja.md ================================================ Brief notes for people used to the existing Bazel build system: - Put the ninja binary on your path: https://github.com/ninja-build/ninja/releases/tag/v1.11.1 (on Windows, if you have it installed in msys, make sure the native binary occurs earlier on the path) - Ensure Rust is installed via rustup: https://rustup.rs/ - Remove the .bazel and node_modules folders from your existing checkout - Run with ./run - Run tests with './ninja check' (tools\ninja on Windows) - Format files with './ninja format' - Fix eslint/copyright issues with './ninja fix' - Targets are hierarchical, so './ninja check:jest:deck-options' will run the Jest tests for ts/deck-options, and './ninja check:jest' will run all Jest tests. ================================================ FILE: docs/protobuf.md ================================================ ProtoBuf is a format used both to save data in storage and transmit data between services. You can think of it as similar to JSON with schemas, given that you can use basic types, list and records. Except that it's usually transmitted and saved in an efficient byteform and not in a human readable way. # Protocol Buffers Anki uses [different implementations of Protocol Buffers](./architecture.md#protobuf) and each has its own peculiarities. This document highlights some aspects relevant to Anki and hopefully helps to avoid some common pitfalls. For information about Protobuf's types and syntax, please see the official [language guide](https://developers.google.com/protocol-buffers/docs/proto3). ## General Notes ### Names Generated code follows the naming conventions of the targeted language. So to access the message field `foo_bar` you need to use `fooBar` in Typescript and the namespace created by the message `FooBar` is called `foo_bar` in Rust. ### Optional Values In Python and Typescript, unset optional values will contain the type's default value rather than `None`, `null` or `undefined`. Here's an example: ```protobuf message Foo { optional string name = 1; optional int32 number = 2; } ``` ```python message = Foo() assert message.number == 0 assert message name == "" ``` In Python, we can use the message's `HasField()` method to check whether a field is actually set: ```python message = Foo(name="") assert message.HasField("name") assert not message.HasField("number") ``` In Typescript, this is even less ergonomic and it can be easier to avoid using the default values in active fields. E.g. the `CsvMetadata` message uses 1-based indices instead of optional 0-based ones to avoid ambiguity when an index is `0`. ### Oneofs All fields in a oneof are implicitly optional, so the caveats [above](#optional-values) apply just as much to a message like this: ```protobuf message Foo { oneof bar { string name = 1; int32 number = 2; } } ``` In addition to `HasField()`, `WhichOneof()` can be used to get the name of the set field: ```python message = Foo(name="") assert message.WhichOneof("bar") == "name" ``` ### Backwards Compatibility The official [language guide](https://developers.google.com/protocol-buffers/docs/proto3) makes a lot of notes about backwards compatibility, but as Anki usually doesn't use Protobuf to communicate between different clients, things like shuffling around field numbers are usually not a concern. However, there are some messages, like `Deck`, which get stored in the database. If these are modified in an incompatible way, this can lead to serious issues if clients with a different protocol try to read them. Such modifications are only safe to make as part of a schema upgrade, because schema 11 (the targeted schema when choosing _Downgrade_), does not make use of Protobuf messages. ### Field Numbers Field numbers larger than 15 need an additional byte to encode, so `repeated` fields should preferably be assigned a number between 1 and 15. If a message contains `reserved` fields, this is usually to accommodate potential future `repeated` fields. ## Implementation-Specific Notes ### Python Protobuf has an official Python implementation with an extensive [reference](https://developers.google.com/protocol-buffers/docs/reference/python-generated). ### Typescript Anki uses [protobuf-es](https://github.com/bufbuild/protobuf-es), which offers some documentation. ### Rust Anki uses the [prost crate](https://docs.rs/prost/latest/prost/). Its documentation has some useful hints, but for working with the generated code, there is a better option: From within `anki/rslib` run `cargo doc --open --document-private-items`. Inside the `pb` module you will find all generated Rust types and their implementations. - Given an enum field `Foo foo = 1;`, `message.foo` is an `i32`. Use the accessor `message.foo()` instead to avoid having to manually convert to a `Foo`. - Protobuf does not guarantee any oneof field to be set or an enum field to contain a valid variant, so the Rust code needs to deal with a lot of `Option`s. As we don't expect other parts of Anki to send invalid messages, using an `InvalidInput` error or `unwrap_or_default()` is usually fine. ================================================ FILE: docs/syncserver/Dockerfile ================================================ FROM rust:1.85.0-alpine3.20 AS builder ARG ANKI_VERSION RUN apk update && apk add --no-cache build-base protobuf && rm -rf /var/cache/apk/* RUN cargo install --git https://github.com/ankitects/anki.git \ --tag ${ANKI_VERSION} \ --root /anki-server \ --locked \ anki-sync-server FROM alpine:3.21.0 # Default PUID and PGID values (can be overridden at runtime). Use these to # ensure the files on the volume have the permissions you need. ENV PUID=1000 ENV PGID=1000 COPY --from=builder /anki-server/bin/anki-sync-server /usr/local/bin/anki-sync-server RUN apk update && apk add --no-cache bash su-exec && rm -rf /var/cache/apk/* EXPOSE 8080 COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] CMD ["anki-sync-server"] # This health check will work for Anki versions 24.08.x and newer. # For older versions, it may incorrectly report an unhealthy status, which should not be the case. HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD wget -qO- http://127.0.0.1:8080/health || exit 1 VOLUME /anki_data LABEL maintainer="Jean Khawand " ================================================ FILE: docs/syncserver/Dockerfile.distroless ================================================ FROM rust:1.85.0 AS builder ARG ANKI_VERSION RUN apt-get update && apt-get install -y build-essential protobuf-compiler && apt-get clean && rm -rf /var/lib/apt/lists/* RUN cargo install --git https://github.com/ankitects/anki.git \ --tag ${ANKI_VERSION} \ --root /anki-server \ --locked \ anki-sync-server FROM gcr.io/distroless/cc-debian12 COPY --from=builder /anki-server/bin/anki-sync-server /usr/bin/anki-sync-server # Note that as a user of the container you should NOT overwrite these values # for safety and simplicity reasons ENV SYNC_PORT=8080 ENV SYNC_BASE=/anki_data EXPOSE ${SYNC_PORT} CMD ["anki-sync-server"] # This health check will work for Anki versions 24.08.x and newer. # For older versions, it may incorrectly report an unhealthy status, which should not be the case. HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD ["anki-sync-server", "--healthcheck"] VOLUME /anki_data LABEL maintainer="Jean Khawand " ================================================ FILE: docs/syncserver/README.md ================================================ # Building and running Anki sync server in Docker This is an example Dockerfile contributed by an Anki user, which shows how you can run a self-hosted sync server, similar to what AnkiWeb.net offers. Building and running the sync server within a container has the advantage of fully isolating the build products and runtime dependencies from the rest of your system. ## Requirements - [x] [Docker](https://docs.docker.com/get-started/) | **Aspect** | **Dockerfile** | **Dockerfile.distroless** | | ---------------------- | ---------------------------------------------------------- | --------------------------------------------------------- | | **Shell & Tools** | ✅ Includes shell and tools | ❌ Minimal, no shell or tools | | **Debugging** | ✅ Easier debugging with shell and tools | ❌ Harder to debug due to minimal environment | | **Health Checks** | ✅ Supports complex health checks | ❌ Health checks need to be simple or directly executable | | **Image Size** | ❌ Larger image size | ✅ Smaller image size | | **Customization** | ✅ Easier to customize with additional packages | ❌ Limited customization options | | **Attack Surface** | ❌ Larger attack surface due to more installed packages | ✅ Reduced attack surface | | **Libraries** | ✅ More libraries available | ❌ Limited libraries | | **Start-up Time** | ❌ Slower start-up time due to larger image size | ✅ Faster start-up time | | **Tool Compatibility** | ✅ Compatible with more tools and libraries | ❌ Compatibility limitations with certain tools | | **Maintenance** | ❌ Higher maintenance due to larger image and dependencies | ✅ Lower maintenance with minimal base image | | **Custom uid/gid** | ✅ It's possible to pass in PUID and PGID | ❌ PUID and PGID are not supported | # Building image To proceed with building, you must specify the Anki version you want, by replacing `` with something like `24.11` and `` with the chosen Dockerfile (e.g., `Dockerfile` or `Dockerfile.distroless`) ```bash # Execute this command from this directory docker build -f --no-cache --build-arg ANKI_VERSION= -t anki-sync-server . ``` # Run container Once done with build, you can proceed with running this image with the following command: ```bash # this will create anki server docker run -d \ -e "SYNC_USER1=admin:admin" \ -p 8080:8080 \ --mount type=volume,src=anki-sync-server-data,dst=/anki_data \ --name anki-sync-server \ anki-sync-server ``` If the image you are using was built with `Dockerfile` you can specify the `PUID` and `PGID` env variables for the user and group id of the process that will run the anki-sync-server process. This is valuable when you want the files written and read from the `/anki_data` volume to belong to a particular user/group e.g. to access it from the host or another container. Note the the ids chosen for `PUID` and `PGID` must not already be in use inside the container (1000 and above is fine). For example add `-e "PUID=1050"` and `-e "PGID=1050"` to the above command. If you want to have multiple Anki users that can sync their devices, you can specify multiple `SYNC_USER` as follows: ```bash # this will create anki server with multiple users docker run -d \ -e "SYNC_USER1=admin:admin" \ -e "SYNC_USER2=admin2:admin2" \ -p 8080:8080 \ --mount type=volume,src=anki-sync-server-data,dst=/anki_data \ --name anki-sync-server \ anki-sync-server ``` Moreover, you can pass additional env vars mentioned [here](https://docs.ankiweb.net/sync-server.html). Note that `SYNC_BASE` and `SYNC_PORT` will be ignored. In the first case for safety reasons, to avoid accidentally placing data outside the volume and the second for simplicity since the internal port of the container does not matter given that you can change the external one. # Upgrading If your image was built after January 2025 then you can just build a new image and start a new container with the same configuration as the previous container. Everything should work as expected. If the image you were running was built **before January 2025** then it did not contain a volume, meaning all syncserver data was stored inside the container. If you discard the container, for example because you want to build a new container using an updated image, then your syncserver data will be lost. The easiest way of working around this is by ensuring at least one of your devices is fully in sync with your syncserver before upgrading the Docker container. Then after upgrading the container when you try to sync your device it will tell you that the server has no data. You will then be given the option of uploading all local data from the device to syncserver. ================================================ FILE: docs/syncserver/entrypoint.sh ================================================ #!/bin/sh set -o errexit set -o nounset set -o pipefail # Default PUID and PGID if not provided export PUID=${PUID:-1000} export PGID=${PGID:-1000} # These values are fixed and cannot be overwritten from the outside for # convenience and safety reasons export SYNC_PORT=8080 export SYNC_BASE=/anki_data # Check if group exists, create if not if ! getent group anki-group > /dev/null 2>&1; then addgroup -g "$PGID" anki-group fi # Check if user exists, create if not if ! id -u anki > /dev/null 2>&1; then adduser -D -H -u "$PUID" -G anki-group anki fi # Fix ownership of mounted volumes mkdir -p /anki_data chown anki:anki-group /anki_data # Run the provided command as the `anki` user exec su-exec anki "$@" ================================================ FILE: docs/windows.md ================================================ # Windows ## Requirements **Windows**: You must be running 64 bit Windows 10, version 1703 or newer. **Rustup**: As mentioned in development.md, rustup must be installed. If you're on ARM Windows and install the ARM64 version of rust-up, from this project folder, run ``` rustup target add x86_64-pc-windows-msvc ``` **Visual Studio**: Install Visual Studio Community Edition from Microsoft. Once you've downloaded the installer, open it, and select "Desktop Development with C++" on the left, leaving the options shown on the right as is. **MSYS**: Install [msys2](https://www.msys2.org/) into the default folder location. After installation completes, run msys2, and run the following command: ``` $ pacman -S git rsync ``` Edit your PATH environmental variable and add c:\msys64\usr\bin to it, and reboot. If you have native Windows apps relying on Git, e.g. the PowerShell extension [posh-git](https://github.com/dahlbyk/posh-git), you may want to install [Git for Windows](https://gitforwindows.org/) and put it on the path instead, as msys Git may cause issues with them. You'll need to make sure rsync is available some other way. **Source folder**: Anki's source files do not need to be in a specific location, but it's best to avoid long paths, as they can cause problems. Spaces in the path may cause problems. ## Audio To play and record audio during development, mpv.exe and lame.exe must be on the path. ## More For info on running tests, building wheels and so on, please see [Development](./development.md). ================================================ FILE: ftl/.gitignore ================================================ usage/* !usage/no-deprecate.json mobile-repo ================================================ FILE: ftl/Cargo.toml ================================================ [package] name = "ftl" version.workspace = true authors.workspace = true edition.workspace = true license.workspace = true publish = false rust-version.workspace = true description = "Helpers for Anki's i18n system" [dependencies] anki_io.workspace = true anki_process.workspace = true anyhow.workspace = true camino.workspace = true clap.workspace = true fluent-syntax.workspace = true itertools.workspace = true regex.workspace = true serde_json.workspace = true snafu.workspace = true walkdir.workspace = true ================================================ FILE: ftl/README.md ================================================ Files related to Anki's translations. Please see https://translating.ankiweb.net/anki/developers ================================================ FILE: ftl/copy-core-string.sh ================================================ #!/bin/bash # - sync ftl # - ./copy-core-string.sh scheduling-review browsing-sidebar-card-state-review # - confirm changes in core-repo/ correct # - commit and push changes # - ensure string in template isn't in the 'no need to translate' section # - update submodule in main repo ./ftl string copy ftl/core-repo/core ftl/core-repo/core $1 $2 ================================================ FILE: ftl/core/actions.ftl ================================================ actions-add = Add # Action in context menu: # In the browser sidebar, when in "Select" mode, right-click on the # selected criteria elements. In the context menu, click on "Search" to open # a submenu. This entry in the submenu creates a search term that matches # cards/notes meeting ALL of the selected criteria. # https://github.com/ankitects/anki/pull/1044 actions-all-selected = All selected # Action in context menu: # In the browser sidebar, when in "Select" mode, right-click on the # selected criteria elements. In the context menu, click on "Search" to open # a submenu. This entry in the submenu creates a search term that matches # cards/notes meeting ANY of the selected criteria. # https://github.com/ankitects/anki/pull/1044 actions-any-selected = Any selected actions-cancel = Cancel actions-choose = Choose actions-close = Close actions-discard = Discard actions-copy = Copy actions-create-copy = Create Copy actions-custom-study = Custom Study actions-decks = Decks actions-decrement-value = Decrement value actions-delete = Delete actions-export = Export actions-empty-cards = Empty Cards actions-filter = Filter actions-help = Help actions-increment-value = Increment value actions-import = Import actions-manage = Manage... actions-name = Name: actions-new = New actions-new-name = New name: actions-options = Options actions-options-for = Options for { $val } actions-preview = Preview actions-rebuild = Rebuild actions-rename = Rename actions-rename-deck = Rename Deck actions-rename-tag = Rename Tag actions-rename-with-parents = Rename with Parents actions-remove-tag = Remove Tag actions-replay-audio = Replay Audio actions-reposition = Reposition actions-save = Save actions-search = Search actions-select = Select actions-shortcut-key = Shortcut key: { $val } actions-suspend-card = Suspend Card actions-set-due-date = Set Due Date actions-toggle-load-balancer = Toggle Load Balancer actions-grade-now = Grade Now actions-answer-card = Answer Card actions-unbury-unsuspend = Unbury/Unsuspend actions-add-deck = Add Deck actions-add-note = Add Note actions-update-tag = Update Tag actions-update-note = Update Note actions-update-card = Update Card actions-update-deck = Update Deck actions-forget-card = Reset Card actions-build-filtered-deck = Build Deck actions-add-notetype = Add Note Type actions-remove-notetype = Remove Note Type actions-update-notetype = Update Note Type actions-update-config = Update Config actions-card-info = Card Info actions-previous-card-info = Previous Card Info # By convention, the name of a menu action is suffixed with "..." if additional # input is required before it can be performed. E.g. "Export..." vs. "Delete". actions-with-ellipsis = { $action }... actions-fullscreen-unsupported = Full screen mode is not supported for your video driver. Try switching to a different one from the preferences screen. actions-flag-number = Flag { $number } ## The same translation may used for two independent actions: ## searching for cards with a flag of the specified color, and ## toggling the flag of the specified color on a card. actions-flag-red = Red actions-flag-orange = Orange actions-flag-green = Green actions-flag-blue = Blue actions-flag-pink = Pink actions-flag-turquoise = Turquoise actions-flag-purple = Purple ## actions-set-flag = Set Flag actions-nothing-to-undo = Nothing to undo actions-nothing-to-redo = Nothing to redo actions-auto-advance = Auto Advance actions-auto-advance-activated = Auto Advance enabled actions-auto-advance-deactivated = Auto Advance disabled actions-processing = Processing... ================================================ FILE: ftl/core/adding.ftl ================================================ adding-add-shortcut-ctrlandenter = Add (shortcut: ctrl+enter) adding-added = Added adding-discard-current-input = Discard current input? adding-keep-editing = Keep Editing adding-edit = Edit "{ $val }" adding-history = History adding-note-deleted = (Note deleted) adding-shortcut = Shortcut: { $val } adding-the-first-field-is-empty = The first field is empty. adding-you-have-a-cloze-deletion-note = You have a cloze note type but have not made any cloze deletions. Proceed? adding-cloze-outside-cloze-notetype = Cloze deletion can only be used on cloze note types. adding-cloze-outside-cloze-field = Cloze deletion can only be used in fields which use the 'cloze:' filter. This is typically the first field. ================================================ FILE: ftl/core/browsing.ftl ================================================ browsing-add-notes = Add Notes... browsing-add-tags2 = Add Tags... browsing-add-to-selected-notes = Add to Selected Notes browsing-remove-from-selected-notes = Remove from Selected Notes browsing-addon = Add-on browsing-all-fields = All Fields browsing-answer = Answer browsing-any-flag = Any Flag browsing-average-ease = Avg. Ease browsing-average-interval = Avg. Interval browsing-browser-appearance = Browser Appearance browsing-browser-options = Browser Options browsing-buried = Buried browsing-card = Card browsing-cards = Cards browsing-card-list = Card List browsing-cards-cant-be-manually-moved-into = Cards can't be manually moved into a filtered deck. browsing-cards-deleted = { $count -> [one] { $count } card deleted. *[other] { $count } cards deleted. } browsing-cards-deleted-with-deckname = { $count -> [one] { $count } card deleted from {$deck_name}. *[other] { $count } cards deleted from {$deck_name}. } browsing-change-deck = Change Deck browsing-change-deck2 = Change Deck... browsing-change-note-type = Change Note Type # Action in a context menu (right mouse-click on a card type) browsing-change-note-type2 = Change Note Type... browsing-change-notetype = Change Note Type browsing-clear-unused-tags = Clear Unused Tags browsing-confirm-saved-search-overwrite = A saved search with the name { $name } already exists. Do you want to overwrite it? browsing-created = Created browsing-current-deck = Current Deck browsing-current-note-type = Current note type: browsing-delete-notes = Delete Notes browsing-duplicate = duplicate browsing-ease = Ease browsing-enter-tags-to-add = Enter tags to add: browsing-enter-tags-to-delete = Enter tags to delete: browsing-filtered = (filtered) browsing-find = Find: browsing-find-and-replace = Find and Replace browsing-find-duplicates = Find Duplicates browsing-first-card = First Card browsing-flag = Flag browsing-font = Font: browsing-font-size = Font Size: browsing-found-as-across-bs = Found { $part } across { $whole }. browsing-ignore-case = Ignore case browsing-in = In: browsing-interval = Interval browsing-last-card = Last Card browsing-learning = (learning) browsing-line-size = Line Size: browsing-manage-note-types = Manage Note Types browsing-move-cards = Move Cards browsing-move-cards-to-deck = Move cards to deck: browsing-new = (new) browsing-new-note-type = New note type: browsing-no-flag = No Flag browsing-no-selection = No cards or notes selected. browsing-note = Note browsing-notes = Notes browsing-optional-filter = Optional filter: browsing-override-back-template = Override back template: browsing-override-font = Override font: browsing-override-front-template = Override front template: browsing-please-give-your-filter-a-name = Please give your filter a name: browsing-preview-selected-card = Preview Selected Card ({ $val }) browsing-question = Question browsing-queue-bottom = Queue bottom: { $val } browsing-queue-top = Queue top: { $val } browsing-randomize-order = Randomize order browsing-remove-tags = Remove Tags... browsing-replace-with = Replace With: browsing-reposition = Reposition... browsing-reposition-new-cards = Reposition New Cards browsing-reschedule = Reschedule browsing-search-bar-hint = Search cards/notes (type text, then press Enter) browsing-search-in = Search in: browsing-search-within-formatting-slow = Search within formatting (slow) browsing-select-deck = Select Deck browsing-selected-notes-only = Selected notes only browsing-shift-position-of-existing-cards = Shift position of existing cards browsing-sidebar = Sidebar browsing-sidebar-filter = Sidebar filter # The field that is used for sorting (sort is an adjective here, not a verb) browsing-sort-field = Sort Field browsing-sorting-on-this-column-is-not = Sorting on this column is not supported. Please choose another. browsing-start-position = Start position: browsing-step = Step: browsing-suspended = Suspended browsing-tag-duplicates = Tag Duplicates browsing-tag-rename-warning-empty = You can't rename a tag that has no notes. browsing-target-field = Target field: browsing-toggle-bury = Toggle Bury browsing-toggle-showing-cards-notes = Toggle Cards/Notes browsing-toggle-mark = Toggle Mark browsing-toggle-suspend = Toggle Suspend browsing-treat-input-as-regular-expression = Treat input as regular expression browsing-update-saved-search = Update with Current Search browsing-whole-collection = Whole Collection browsing-window-title-notes = Browse ({ $selected } of { $total } notes selected) browsing-you-must-have-at-least-one = You must have at least one column. browsing-group = { $count -> [one] { $count } group *[other] { $count } groups } browsing-note-count = { $count -> [one] { $count } note *[other] { $count } notes } browsing-notes-updated = { $count -> [one] { $count } note updated. *[other] { $count } notes updated. } browsing-cards-updated = { $count -> [one] { $count } card updated. *[other] { $count } cards updated. } browsing-window-title = Browse ({ $selected } of { $total } cards selected) browsing-sidebar-expand = Expand browsing-sidebar-collapse = Collapse browsing-sidebar-expand-children = Expand Children browsing-sidebar-collapse-children = Collapse Children browsing-sidebar-decks = Decks browsing-sidebar-tags = Tags browsing-sidebar-notetypes = Note Types browsing-sidebar-saved-searches = Saved Searches browsing-sidebar-save-current-search = Save Current Search browsing-sidebar-card-state = Card State browsing-sidebar-flags = Flags browsing-today = Today browsing-tooltip-card-modified = The last time changes were made to a card, including reviews, flags and deck changes browsing-tooltip-note-modified = The last time changes were made to a note, usually field content or tag edits browsing-tooltip-card = The name of a card's card template browsing-tooltip-cards = The number of cards a note has browsing-tooltip-notetype = The name of a note's note type browsing-tooltip-question = The front side of a card, customisable in the card template editor browsing-tooltip-answer = The back side of a card, customisable in the card template editor browsing-studied-today = Studied browsing-added-today = Added browsing-again-today = Again browsing-edited-today = Edited browsing-sidebar-first-review = First Review browsing-sidebar-rescheduled = Rescheduled browsing-sidebar-due-today = Due browsing-sidebar-untagged = Untagged browsing-sidebar-overdue = Overdue browsing-row-deleted = (deleted) browsing-removed-unused-tags-count = { $count -> [one] Removed { $count } unused tag. *[other] Removed { $count } unused tags. } browsing-changed-new-position = { $count -> [one] Changed position of { $count } new card. *[other] Changed position of { $count } new cards. } browsing-reparented-decks = { $count -> [one] Renamed { $count } deck. *[other] Renamed { $count } decks. } browsing-sidebar-card-state-review = Review ## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future. # Exactly one character representing 'Cards'; should differ from browsing-note-initial. browsing-card-initial = C # Exactly one character representing 'Notes'; should differ from browsing-card-initial. browsing-note-initial = N ================================================ FILE: ftl/core/card-stats.ftl ================================================ card-stats-added = Added card-stats-first-review = First Review card-stats-latest-review = Latest Review card-stats-interval = Interval card-stats-ease = Ease card-stats-review-count = Reviews card-stats-lapse-count = Lapses card-stats-average-time = Average Time card-stats-total-time = Total Time card-stats-new-card-position = Position card-stats-card-template = Card Type card-stats-note-type = Note Type card-stats-deck-name = Deck card-stats-preset = Preset card-stats-note-id = Note ID card-stats-card-id = Card ID card-stats-review-log-rating = Rating card-stats-review-log-type = Type card-stats-review-log-date = Date card-stats-review-log-time-taken = Time card-stats-review-log-type-learn = Learn card-stats-review-log-type-review = Review card-stats-review-log-type-relearn = Relearn card-stats-review-log-type-filtered = Filtered card-stats-review-log-type-manual = Manual card-stats-review-log-type-rescheduled = Rescheduled card-stats-review-log-elapsed-time = Elapsed Time card-stats-no-card = (No card to display.) card-stats-custom-data = Custom Data card-stats-fsrs-stability = Stability card-stats-fsrs-difficulty = Difficulty card-stats-fsrs-retrievability = Retrievability card-stats-fsrs-forgetting-curve-title = Forgetting Curve card-stats-fsrs-forgetting-curve-first-week = First Week card-stats-fsrs-forgetting-curve-first-month = First Month card-stats-fsrs-forgetting-curve-first-year = First Year card-stats-fsrs-forgetting-curve-all-time = All Time card-stats-fsrs-forgetting-curve-desired-retention = Desired Retention ## Window Titles card-stats-current-card = Current Card ({ $context }) card-stats-previous-card = Previous Card ({ $context }) ## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future. card-stats-fsrs-forgetting-curve-probability-of-recalling = Probability of Recall ================================================ FILE: ftl/core/card-template-rendering.ftl ================================================ ### These messages are shown on the review screen, preview screen, and ### card template screen when the user has made a mistake in their card ### template, or the front of the card is empty. # Label of link users can click on card-template-rendering-more-info = More information card-template-rendering-front-side-problem = Front template has a problem: card-template-rendering-back-side-problem = Back template has a problem: card-template-rendering-browser-front-side-problem = Browser-specific front template has a problem: card-template-rendering-browser-back-side-problem = Browser-specific back template has a problem: # when the user forgot to close a field reference, # eg, Missing '}}' in '{{Field' card-template-rendering-no-closing-brackets = Missing '{ $missing }' in '{ $tag }' # when the user opened a conditional, but forgot to close it # eg, Missing '{{/Conditional}}' card-template-rendering-conditional-not-closed = Missing '{ $missing }' # when the user closed the wrong conditional # eg, Found '{{/Something}}', but expected '{{/SomethingElse}}' card-template-rendering-wrong-conditional-closed = Found '{ $found }', but expected '{ $expected }' # when the user closed a conditional that wasn't open # eg, Found '{{/Something}}', but missing '{{#Something}}' or '{{^Something}}' card-template-rendering-conditional-not-open = Found '{ $found }', but missing '{ $missing1 }' or '{ $missing2 }' # when the user referenced a field that doesn't exist # eg, Found '{{Field}}', but there is not field called 'Field' card-template-rendering-no-such-field = Found '{ $found }', but there is no field called '{ $field }' # This message is shown when the front side of the card is blank, # either due to a badly-designed template, or because required fields # are missing. card-template-rendering-empty-front = The front of this card is blank. card-template-rendering-missing-cloze = No cloze { $number } found on card. Please either add a cloze deletion, or use the Empty Cards tool. ================================================ FILE: ftl/core/card-templates.ftl ================================================ # This word is used by TTS voices instead of the elided part of a cloze. card-templates-blank = blank card-templates-changes-will-affect-notes = { $count -> [one] Changes below will affect the { $count } note that uses this card type. *[other] Changes below will affect the { $count } notes that use this card type. } card-templates-card-type = Card Type: card-templates-front-template = Front Template card-templates-back-template = Back Template card-templates-template-styling = Styling card-templates-front-preview = Front Preview card-templates-back-preview = Back Preview card-templates-preview-box = Preview card-templates-template-box = Template card-templates-sample-cloze = This is a { "{{c1::" }sample{ "}}" } cloze deletion. card-templates-fill-empty = Fill Empty Fields card-templates-night-mode = Night Mode # Add "mobile" class to card preview, so the card appears like it would # on a mobile device. card-templates-add-mobile-class = Add Mobile Class card-templates-preview-settings = Options card-templates-invalid-template-number = Card template { $number } in note type '{ $notetype }' has a problem. card-templates-identical-front = The front side is identical to card template { $number }. card-templates-no-front-field = Expected to find a field replacement on the front of the card template. card-templates-missing-cloze = Expected to find '{ "{{" }cloze:Text{ "}}" }' or similar on the front and back of the card template. card-templates-extraneous-cloze = 'cloze:' can only be used on cloze note types. card-templates-see-preview = See the preview for more information. card-templates-field-not-found = Field '{ $field }' not found. card-templates-changes-saved = Changes saved. card-templates-discard-changes = Discard changes? card-templates-add-card-type = Add Card Type... card-templates-anki-couldnt-find-the-line-between = Anki couldn't find the line between the question and answer. Please adjust the template manually to switch the question and answer. card-templates-at-least-one-card-type-is = At least one card type is required. card-templates-browser-appearance = Browser Appearance... card-templates-card = Card { $val } card-templates-card-types-for = Card Types for { $val } card-templates-cloze = Cloze { $val } card-templates-deck-override = Deck Override... card-templates-copy-info = Copy Info to Clipboard card-templates-delete-the-as-card-type-and = Delete the '{ $template }' card type, and its { $cards }? card-templates-enter-deck-to-place-new = Enter deck to place new { $val } cards in, or leave blank: card-templates-enter-new-card-position-1 = Enter new card position (1...{ $val }): card-templates-flip = Flip card-templates-form = Form card-templates-off = (off) card-templates-on = (on) card-templates-remove-card-type = Remove Card Type... card-templates-rename-card-type = Rename Card Type... card-templates-reposition-card-type = Reposition Card Type... card-templates-card-count = { $count -> [one] { $count } card *[other] { $count } cards } card-templates-this-will-create-card-proceed = { $count -> [one] This will create { $count } card. Proceed? *[other] This will create { $count } cards. Proceed? } card-templates-type-boxes-warning = Only one typing box per card template is supported. card-templates-restore-to-default = Restore to Default card-templates-restore-to-default-confirmation = This will reset all fields and templates in this note type to their default values, removing any extra fields/templates and their content, and any custom styling. Do you wish to proceed? card-templates-restored-to-default = Note type has been restored to its original state. ================================================ FILE: ftl/core/change-notetype.ftl ================================================ change-notetype-current = Current change-notetype-new = New change-notetype-nothing = (Nothing) change-notetype-collapse = Collapse change-notetype-expand = Expand change-notetype-will-discard-content = Will discard content on the following fields: change-notetype-will-discard-cards = Will remove the following cards: change-notetype-fields = Fields change-notetype-templates = Templates change-notetype-to-from-cloze = When changing to or from a Cloze note type, card numbers remain unchanged. If changing to a regular note type, and there are more cloze deletions than available card templates, any extra cards will be removed. ================================================ FILE: ftl/core/custom-study.ftl ================================================ ### options related to the Custom Study window custom-study-increase-todays-new-card-limit = Increase today's new card limit # increase limit by {amount} cards custom-study-increase-todays-new-card-limit-by = Increase today's new card limit by # the last word in the sentence "increase today's [new/review] card limit by {amount} cards" custom-study-cards = { $count -> [one] card *[other] cards } custom-study-available-new-cards-2 = Available new cards: { $countString } custom-study-increase-todays-review-card-limit = Increase today's review card limit # increase limit by {amount} cards custom-study-increase-todays-review-limit-by = Increase today's review limit by custom-study-available-review-cards-2 = Available review cards: { $countString } custom-study-review-forgotten-cards = Review forgotten cards custom-study-review-cards-forgotten-in-last = Review cards forgotten in the last custom-study-days = { $count -> [one] day *[other] days } custom-study-review-ahead = Review ahead custom-study-review-ahead-by = Review ahead by custom-study-preview-new-cards = Preview new cards custom-study-preview-new-cards-added-in-the = Preview new cards added in the last ## options for the "study by card state or tag" subsection custom-study-study-by-card-state-or-tag = Study by card state or tag # verb, not noun. As in "Select {amount} cards from the deck" custom-study-select = Select # As in "select {amount} cards from the deck" custom-study-cards-from-the-deck = { $count -> [one] card from the deck *[other] cards from the deck } custom-study-new-cards-only = New cards only custom-study-due-cards-only = Due cards only custom-study-all-review-cards-in-random-order = All review cards in random order custom-study-all-cards-in-random-order-dont = All cards in random order (don't reschedule) custom-study-choose-tags = Choose Tags ## custom-study-ok = OK custom-study-no-cards-matched-the-criteria-you = No cards matched the criteria you provided. custom-study-must-rename-deck = Please rename the existing Custom Study deck first. custom-study-custom-study-session = Custom Study Session custom-study-available-child-count = ({ $count } in subdecks) ## inside the Selective Study window, accessible by selecting "Study by card state or tag" and then clicking "Choose Tags" custom-study-selective-study = Selective Study custom-study-require-one-or-more-of-these = Require one or more of these tags: custom-study-select-tags-to-exclude = Select tags to exclude: ## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future. custom-study-available-new-cards = Available new cards: { $count } custom-study-available-review-cards = Available review cards: { $count } ================================================ FILE: ftl/core/database-check.ftl ================================================ database-check-corrupt = Collection file is corrupt. Please restore from an automatic backup. database-check-rebuilt = Database rebuilt and optimized. database-check-card-properties = { $count -> [one] Fixed { $count } invalid card property. *[other] Fixed { $count } invalid card properties. } database-check-card-last-review-time-empty = { $count -> [one] Added last review time to { $count } card. *[other] Added last review time to { $count } cards. } database-check-missing-templates = { $count -> [one] Deleted { $count } card with missing template. *[other] Deleted { $count } cards with missing template. } database-check-field-count = { $count -> [one] Fixed { $count } note with wrong field count. *[other] Fixed { $count } notes with wrong field count. } database-check-new-card-high-due = { $count -> [one] Found { $count } new card with a due number >= 1,000,000 - consider repositioning it in the Browse screen. *[other] Found { $count } new cards with a due number >= 1,000,000 - consider repositioning them in the Browse screen. } database-check-card-missing-note = { $count -> [one] Deleted { $count } card with missing note. *[other] Deleted { $count } cards with missing note. } database-check-duplicate-card-ords = { $count -> [one] Deleted { $count } card with duplicate template. *[other] Deleted { $count } cards with duplicate template. } database-check-missing-decks = { $count -> [one] Fixed { $count } missing deck. *[other] Fixed { $count } missing decks. } database-check-revlog-properties = { $count -> [one] Fixed { $count } review entry with invalid properties. *[other] Fixed { $count } review entries with invalid properties. } database-check-notes-with-invalid-utf8 = { $count -> [one] Fixed { $count } note with invalid utf8 characters. *[other] Fixed { $count } notes with invalid utf8 characters. } database-check-fixed-invalid-ids = { $count -> [one] Fixed { $count } object with timestamps in the future. *[other] Fixed { $count } objects with timestamps in the future. } # "db-check" is always in English database-check-notetypes-recovered = One or more note types were missing. The notes that used them have been given new note types starting with "db-check", but field names and card design have been lost, so you may be better off restoring from an automatic backup. ## Progress info database-check-checking-integrity = Checking collection... database-check-rebuilding = Rebuilding... database-check-checking-cards = Checking cards... database-check-checking-notes = Checking notes... database-check-checking-history = Checking history... database-check-title = Check Database ================================================ FILE: ftl/core/deck-config.ftl ================================================ ### Text shown on the "Deck Options" screen ## Top section # Used in the deck configuration screen to show how many decks are used # by a particular configuration group, eg "Group1 (used by 3 decks)" deck-config-used-by-decks = used by { $decks -> [one] { $decks } deck *[other] { $decks } decks } deck-config-default-name = Default deck-config-title = Deck Options ## Daily limits section deck-config-daily-limits = Daily Limits deck-config-new-limit-tooltip = The maximum number of new cards to introduce in a day, if new cards are available. Because new material will increase your short-term review workload, this should typically be at least 10x smaller than your review limit. deck-config-review-limit-tooltip = The maximum number of review cards to show in a day, if cards are ready for review. deck-config-limit-deck-v3 = When studying a deck that has subdecks inside it, the limits set on each subdeck control the maximum number of cards gathered from that particular deck. The selected deck's limits control the total cards that will be shown. deck-config-limit-new-bound-by-reviews = The review limit affects the new limit. For example, if your review limit is set to 200, and you have 190 reviews waiting, a maximum of 10 new cards will be introduced. If your review limit has been reached, no new cards will be shown. deck-config-limit-interday-bound-by-reviews = The review limit also affects interday learning cards. When applying the limit, interday learning cards are gathered first, then review cards. deck-config-tab-description = - `Preset`: The limit applies to all decks using this preset. - `This deck`: The limit is specific to this deck. - `Today only`: Make a temporary change to this deck's limit. deck-config-new-cards-ignore-review-limit = New cards ignore review limit deck-config-new-cards-ignore-review-limit-tooltip = By default, the review limit also applies to new cards, and no new cards will be shown when the review limit has been reached. If this option is enabled, new cards will be shown regardless of the review limit. deck-config-apply-all-parent-limits = Limits start from top deck-config-apply-all-parent-limits-tooltip = By default, the daily limits of a higher-level deck do not apply if you're studying from its subdeck. If this option is enabled, the limits will start from the top-level deck instead, which can be useful if you wish to study individual subdecks, while enforcing a total limit on cards for the deck tree. deck-config-affects-entire-collection = Affects the entire collection. ## Daily limit tabs: please try to keep these as short as the English version, ## as longer text will not fit on small screens. deck-config-shared-preset = Preset deck-config-deck-only = This deck deck-config-today-only = Today only ## New Cards section deck-config-learning-steps = Learning steps # Please don't translate `1m`, `2d` -deck-config-delay-hint = Delays are typically minutes (e.g. `1m`) or days (e.g. `2d`), but hours (e.g. `1h`) and seconds (e.g. `30s`) are also supported. deck-config-learning-steps-tooltip = One or more delays, separated by spaces. The first delay will be used when you press the `Again` button on a new card, and is 1 minute by default. The `Good` button will advance to the next step, which is 10 minutes by default. Once all steps have been passed, the card will become a review card, and will appear on a different day. { -deck-config-delay-hint } deck-config-graduating-interval-tooltip = The number of days to wait before showing a card again, after the `Good` button is pressed on the final learning step. deck-config-easy-interval-tooltip = The number of days to wait before showing a card again, after the `Easy` button is used to immediately remove a card from learning. deck-config-new-insertion-order = Insertion order deck-config-new-insertion-order-tooltip = Controls the position (due #) new cards are assigned when you add new cards. Cards with a lower due number will be shown first when studying. Changing this option will automatically update the existing position of new cards. deck-config-new-insertion-order-sequential = Sequential (oldest cards first) deck-config-new-insertion-order-random = Random deck-config-new-insertion-order-random-with-v3 = With the v3 scheduler, it is better to leave this set to sequential, and adjust the new card gather order instead. ## Lapses section deck-config-relearning-steps = Relearning steps deck-config-relearning-steps-tooltip = Zero or more delays, separated by spaces. By default, pressing the `Again` button on a review card will show it again 10 minutes later. If no delays are provided, the card will have its interval changed, without entering relearning. { -deck-config-delay-hint } deck-config-leech-threshold-tooltip = The number of times `Again` needs to be pressed on a review card before it is marked as a leech. Leeches are cards that consume a lot of your time, and when a card is marked as a leech, it's a good idea to rewrite it, delete it, or think of a mnemonic to help you remember it. # See actions-suspend-card and scheduling-tag-only for the wording deck-config-leech-action-tooltip = `Tag Only`: Add a 'leech' tag to the note, and display a pop-up. `Suspend Card`: In addition to tagging the note, hide the card until it is manually unsuspended. ## Burying section deck-config-bury-title = Burying deck-config-bury-new-siblings = Bury new siblings deck-config-bury-review-siblings = Bury review siblings deck-config-bury-interday-learning-siblings = Bury interday learning siblings deck-config-bury-new-tooltip = Whether other `new` cards of the same note (e.g. reverse cards, adjacent cloze deletions) will be delayed until the next day. deck-config-bury-review-tooltip = Whether other `review` cards of the same note will be delayed until the next day. deck-config-bury-interday-learning-tooltip = Whether other `learning` cards of the same note with intervals > 1 day will be delayed until the next day. deck-config-bury-priority-tooltip = When Anki gathers cards, it first gathers intraday learning cards, then interday learning cards, then review cards, and finally new cards. This affects how burying works: - If you have all burying options enabled, the sibling that comes earliest in that list will be shown. For example, a review card will be shown in preference to a new card. - Siblings later in the list can not bury earlier card types. For example, if you disable burying of new cards, and study a new card, it will not bury any interday learning or review cards, and you may see both a review sibling and new sibling in the same session. ## Gather order and sort order of cards deck-config-ordering-title = Display Order deck-config-new-gather-priority = New card gather order deck-config-new-gather-priority-tooltip-2 = `Deck`: Gathers cards from each subdeck in order, starting from the top. Cards from each subdeck are gathered in ascending position. If the daily limit of the selected deck is reached, gathering can stop before all subdecks have been checked. This order is fastest in large collections, and allows you to prioritize subdecks that are closer to the top. `Ascending position`: Gathers cards by ascending position (due #), which is typically the oldest-added first. `Descending position`: Gathers cards by descending position (due #), which is typically the latest-added first. `Random notes`: Picks notes at random, then gathers all of its cards. `Random cards`: Gathers cards in a random order. deck-config-new-card-sort-order = New card sort order deck-config-new-card-sort-order-tooltip-2 = `Card type, then order gathered`: Shows cards in order of card type number. Cards of each card type number are shown in the order they were gathered. If you have sibling burying disabled, this will ensure all front→back cards are seen before any back→front cards. This is useful to have all cards of the same note shown in the same session, but not too close to one another. `Order gathered`: Shows cards exactly as they were gathered. If sibling burying is disabled, this will typically result in all cards of a note being seen one after the other. `Card type, then random`: Shows cards in order of card type number. Cards of each card type number are shown in a random order. This order is useful if you don't want sibling cards to appear too close to each other, but still want the cards to appear in a random order. `Random note, then card type`: Picks notes at random, then shows all of its cards in order. `Random`: Shows cards in a random order. deck-config-new-review-priority = New/review order deck-config-new-review-priority-tooltip = When to show new cards in relation to review cards. deck-config-interday-step-priority = Interday learning/review order deck-config-interday-step-priority-tooltip = When to show (re)learning cards that cross a day boundary. The review limit is always applied first to interday learning cards, and then review cards. This option will control the order the gathered cards are shown in, but interday learning cards will always be gathered first. deck-config-review-sort-order = Review sort order deck-config-review-sort-order-tooltip = The default order prioritizes cards that have been waiting longest, so that if you have a backlog of reviews, the longest-waiting ones will appear first. If you have a large backlog that will take more than a few days to clear, or wish to see cards in subdeck order, you may find the alternate sort orders preferable. deck-config-display-order-will-use-current-deck = Anki will use the display order from the deck you select to study, and not any subdecks it may have. ## Gather order and sort order of cards – Combobox entries # Gather new cards ordered by deck. deck-config-new-gather-priority-deck = Deck # Gather new cards ordered by deck, then ordered by random notes, ensuring all cards of the same note are grouped together. deck-config-new-gather-priority-deck-then-random-notes = Deck, then random notes # Gather new cards ordered by position number, ascending (lowest to highest). deck-config-new-gather-priority-position-lowest-first = Ascending position # Gather new cards ordered by position number, descending (highest to lowest). deck-config-new-gather-priority-position-highest-first = Descending position # Gather the cards ordered by random notes, ensuring all cards of the same note are grouped together. deck-config-new-gather-priority-random-notes = Random notes # Gather new cards randomly. deck-config-new-gather-priority-random-cards = Random cards # Sort the cards first by their type, in ascending order (alphabetically), then randomized within each type. deck-config-sort-order-card-template-then-random = Card type, then random # Sort the notes first randomly, then the cards by their type, in ascending order (alphabetically), within each note. deck-config-sort-order-random-note-then-template = Random note, then card type # Sort the cards randomly. deck-config-sort-order-random = Random # Sort the cards first by their type, in ascending order (alphabetically), then by the order they were gathered, in ascending order (oldest to newest). deck-config-sort-order-template-then-gather = Card type, then order gathered # Sort the cards by the order they were gathered, in ascending order (oldest to newest). deck-config-sort-order-gather = Order gathered # How new cards or interday learning cards are mixed with review cards. deck-config-review-mix-mix-with-reviews = Mix with reviews # How new cards or interday learning cards are mixed with review cards. deck-config-review-mix-show-after-reviews = Show after reviews # How new cards or interday learning cards are mixed with review cards. deck-config-review-mix-show-before-reviews = Show before reviews # Sort the cards first by due date, in ascending order (oldest due date to newest), then randomly within the same due date. deck-config-sort-order-due-date-then-random = Due date, then random # Sort the cards first by due date, in ascending order (oldest due date to newest), then by deck within the same due date. deck-config-sort-order-due-date-then-deck = Due date, then deck # Sort the cards first by deck, then by due date in ascending order (oldest due date to newest) within the same deck. deck-config-sort-order-deck-then-due-date = Deck, then due date # Sort the cards by the interval, in ascending order (shortest to longest). deck-config-sort-order-ascending-intervals = Ascending intervals # Sort the cards by the interval, in descending order (longest to shortest). deck-config-sort-order-descending-intervals = Descending intervals # Sort the cards by ease, in ascending order (lowest to highest ease). deck-config-sort-order-ascending-ease = Ascending ease # Sort the cards by ease, in descending order (highest to lowest ease). deck-config-sort-order-descending-ease = Descending ease # Sort the cards by difficulty, in ascending order (easiest to hardest). deck-config-sort-order-ascending-difficulty = Easy cards first # Sort the cards by difficulty, in descending order (hardest to easiest). deck-config-sort-order-descending-difficulty = Difficult cards first # Sort the cards by retrievability percentage, in ascending order (0% to 100%, least retrievable to most easily retrievable). deck-config-sort-order-retrievability-ascending = Ascending retrievability # Sort the cards by retrievability percentage, in descending order (100% to 0%, most easily retrievable to least retrievable). deck-config-sort-order-retrievability-descending = Descending retrievability ## Timer section deck-config-timer-title = Timers deck-config-maximum-answer-secs = Maximum answer seconds deck-config-maximum-answer-secs-tooltip = The maximum number of seconds to record for a single review. If an answer exceeds this time (because you stepped away from the screen for example), the time taken will be recorded as the limit you have set. deck-config-show-answer-timer-tooltip = On the Study screen, show a timer that counts the time you're taking to study each card. deck-config-stop-timer-on-answer = Stop on-screen timer on answer deck-config-stop-timer-on-answer-tooltip = Whether to stop the on-screen timer when the answer is revealed. This doesn't affect statistics. ## Auto Advance section deck-config-seconds-to-show-question = Seconds to show question for deck-config-seconds-to-show-question-tooltip-3 = When auto advance is activated, the number of seconds to wait before applying the question action. Set to 0 to disable. deck-config-seconds-to-show-answer = Seconds to show answer for deck-config-seconds-to-show-answer-tooltip-2 = When auto advance is activated, the number of seconds to wait before applying the answer action. Set to 0 to disable. deck-config-question-action-show-answer = Show Answer deck-config-question-action-show-reminder = Show Reminder deck-config-question-action = Question action deck-config-question-action-tool-tip = The action to perform after the question is shown, and time has elapsed. deck-config-answer-action = Answer action deck-config-answer-action-tooltip-2 = The action to perform after the answer is shown, and time has elapsed. deck-config-wait-for-audio-tooltip-2 = Wait for audio to finish before automatically applying the question action or answer action. ## Audio section deck-config-audio-title = Audio deck-config-disable-autoplay = Don't play audio automatically deck-config-disable-autoplay-tooltip = When enabled, Anki will not play audio automatically. It can be played manually by clicking/tapping on an audio icon, or by using the Replay action. deck-config-skip-question-when-replaying = Skip question when replaying answer deck-config-always-include-question-audio-tooltip = Whether the question audio should be included when the Replay action is used while looking at the answer side of a card. ## Advanced section deck-config-advanced-title = Advanced deck-config-maximum-interval-tooltip = The maximum number of days a review card will wait. When reviews have reached the limit, `Hard`, `Good` and `Easy` will all give the same delay. The shorter you set this, the greater your workload will be. deck-config-starting-ease-tooltip = The ease multiplier new cards start with. By default, the `Good` button on a newly-learned card will delay the next review by 2.5x the previous delay. deck-config-easy-bonus-tooltip = An extra multiplier that is applied to a review card's interval when you rate it `Easy`. deck-config-interval-modifier-tooltip = This multiplier is applied to all reviews, and minor adjustments can be used to make Anki more conservative or aggressive in its scheduling. Please see the manual before changing this option. deck-config-hard-interval-tooltip = The multiplier applied to a review interval when answering `Hard`. deck-config-new-interval-tooltip = The multiplier applied to a review interval when answering `Again`. deck-config-minimum-interval-tooltip = The minimum interval given to a review card after answering `Again`. deck-config-custom-scheduling = Custom scheduling deck-config-custom-scheduling-tooltip = Affects the entire collection. Use at your own risk! ## Easy Days section. deck-config-easy-days-title = Easy Days deck-config-easy-days-monday = Mon deck-config-easy-days-tuesday = Tue deck-config-easy-days-wednesday = Wed deck-config-easy-days-thursday = Thu deck-config-easy-days-friday = Fri deck-config-easy-days-saturday = Sat deck-config-easy-days-sunday = Sun deck-config-easy-days-normal = Normal deck-config-easy-days-reduced = Reduced deck-config-easy-days-minimum = Minimum deck-config-easy-days-no-normal-days = At least one day should be set to '{ deck-config-easy-days-normal }'. deck-config-easy-days-change = Existing reviews will not be rescheduled unless '{ deck-config-reschedule-cards-on-change }' is enabled in the FSRS options. ## Adding/renaming deck-config-add-group = Add Preset deck-config-name-prompt = Name deck-config-rename-group = Rename Preset deck-config-clone-group = Clone Preset ## Removing deck-config-remove-group = Remove Preset deck-config-will-require-full-sync = The requested change will require a one-way sync. If you have made changes on another device, and not synced them to this device yet, please do so before you proceed. deck-config-confirm-remove-name = Remove { $name }? ## Other Buttons deck-config-save-button = Save deck-config-save-to-all-subdecks = Save to All Subdecks deck-config-save-and-optimize = Optimize All Presets deck-config-revert-button-tooltip = Restore this setting to its default value? ## These strings are shown via the Description button at the bottom of the ## overview screen. deck-config-description-new-handling = Anki 2.1.41+ handling deck-config-description-new-handling-hint = Treats input as markdown, and cleans HTML input. When enabled, the description will also be shown on the congratulations screen. Markdown will appear as text on Anki 2.1.40 and below. ## Warnings shown to the user deck-config-daily-limit-will-be-capped = A parent deck has a limit of { $cards -> [one] { $cards } card *[other] { $cards } cards }, which will override this limit. deck-config-reviews-too-low = If adding { $cards -> [one] { $cards } new card each day *[other] { $cards } new cards each day }, your review limit should be at least { $expected }. deck-config-learning-step-above-graduating-interval = The graduating interval should be at least as long as your final learning step. deck-config-good-above-easy = The easy interval should be at least as long as the graduating interval. deck-config-relearning-steps-above-minimum-interval = The minimum lapse interval should be at least as long as your final relearning step. deck-config-maximum-answer-secs-above-recommended = Anki can schedule your reviews more efficiently when you keep each question short. deck-config-too-short-maximum-interval = A maximum interval less than 6 months is not recommended. deck-config-ignore-before-info = (Approximately) { $included }/{ $totalCards } cards will be used to optimize the FSRS parameters. ## Selecting a deck deck-config-which-deck = Which deck would you like to display options for? ## Messages related to the FSRS scheduler deck-config-updating-cards = Updating cards: { $current_cards_count }/{ $total_cards_count }... deck-config-invalid-parameters = The provided FSRS parameters are invalid. Leave them blank to use the default values. deck-config-placeholder-parameters = Default parameters (Press "{deck-config-optimize-button}" periodically to allow FSRS to better adjust to your memory) deck-config-manual-parameter-edit-warning = The parameters should only be modified using the optimize button. Manually editing them is heavily advised against. deck-config-not-enough-history = Insufficient review history to perform this operation. deck-config-must-have-400-reviews = { $count -> [one] Only { $count } review was found. *[other] Only { $count } reviews were found. } You must have at least 400 reviews for this operation. # Numbers that control how aggressively the FSRS algorithm schedules cards deck-config-weights = FSRS parameters deck-config-compute-optimal-weights = Optimize FSRS parameters deck-config-optimize-button = Optimize Current Preset # Indicates that a given function or label, provided via the "text" variable, operates slowly. deck-config-slow-suffix = { $text } (slow) deck-config-compute-button = Compute deck-config-ignore-before = Ignore cards reviewed before deck-config-time-to-optimize = It's been a while - using the Optimize All Presets button is recommended. deck-config-evaluate-button = Evaluate deck-config-desired-retention = Desired retention deck-config-historical-retention = Historical retention deck-config-smaller-is-better = Smaller numbers indicate a better fit to your review history. deck-config-steps-too-large-for-fsrs = When FSRS is enabled, steps of 1 day or more are not recommended. deck-config-get-params = Get Params deck-config-complete = { $num }% complete. deck-config-iterations = Iteration: { $count }... deck-config-reschedule-cards-on-change = Reschedule cards on change deck-config-fsrs-tooltip = Affects the entire collection. The Free Spaced Repetition Scheduler (FSRS) is an alternative to Anki's legacy SuperMemo 2 (SM-2) algorithm. By more accurately determining how likely you are to forget a card, it can help you remember more material in the same amount of time. This setting is shared by all presets. deck-config-desired-retention-tooltip = By default, Anki schedules cards so that you have a 90% chance of remembering them when they come up for review again. If you increase this value, Anki will show cards more frequently to increase the chances of you remembering them. If you decrease the value, Anki will show cards less frequently, and you will forget more of them. Be conservative when adjusting this - higher values will greatly increase your workload, and lower values can be demoralizing when you forget a lot of material. deck-config-desired-retention-tooltip2 = The workload values provided by the info box are a rough approximation. For a greater level of accuracy, use the simulator. deck-config-historical-retention-tooltip = When some of your review history is missing, FSRS needs to fill in the gaps. By default, it will assume that when you did those old reviews, you remembered 90% of the material. If your old retention was appreciably higher or lower than 90%, adjusting this option will allow FSRS to better approximate the missing reviews. Your review history may be incomplete for two reasons: 1. Because you're using the 'ignore cards reviewed before' option. 2. Because you previously deleted review logs to free up space, or imported material from a different SRS program. The latter is quite rare, so unless you're using the former option, you probably don't need to adjust this option. deck-config-weights-tooltip2 = FSRS parameters affect how cards are scheduled. Anki will start with default parameters. You can use the option below to optimize the parameters to best match your performance in decks using this preset. deck-config-reschedule-cards-on-change-tooltip = Affects the entire collection, and is not saved. This option controls whether the due dates of cards will be changed when you enable FSRS, or optimize the parameters. The default is not to reschedule cards: future reviews will use the new scheduling, but there will be no immediate change to your workload. If rescheduling is enabled, the due dates of cards will be changed. deck-config-reschedule-cards-warning = Depending on your desired retention, this can result in a large number of cards becoming due, so is not recommended when first switching from SM-2. Use this option sparingly, as it will add a review entry to each of your cards, and increase the size of your collection. deck-config-ignore-before-tooltip-2 = If set, cards reviewed before the provided date will be ignored when optimizing FSRS parameters. This can be useful if you imported someone else's scheduling data, or have changed the way you use the answer buttons. deck-config-compute-optimal-weights-tooltip2 = When you click the Optimize button, FSRS will analyze your review history, and generate parameters that are optimal for your memory and the content you're studying. If your decks vary wildly in subjective difficulty, it is recommended to assign them separate presets, as the parameters for easy decks and hard decks will be different. You don't need to optimize your parameters frequently - once every few months is sufficient. By default, parameters will be calculated from the review history of all decks using the current preset. You can optionally adjust the search before calculating the parameters, if you'd like to alter which cards are used for optimizing the parameters. deck-config-please-save-your-changes-first = Please save your changes first. deck-config-workload-factor-change = Approximate workload: {$factor}x (compared to {$previousDR}% desired retention) deck-config-workload-factor-unchanged = The higher this value, the more frequently cards will be shown to you. deck-config-desired-retention-too-low = Your desired retention is very low, which can lead to very long intervals. deck-config-desired-retention-too-high = Your desired retention is very high, which can lead to very short intervals. deck-config-percent-of-reviews = { $reviews -> [one] { $pct }% of { $reviews } review *[other] { $pct }% of { $reviews } reviews } deck-config-percent-input = { $pct }% # This message appears during FSRS parameter optimization. deck-config-checking-for-improvement = Checking for improvement... deck-config-optimizing-preset = Optimizing preset { $current_count }/{ $total_count }... deck-config-fsrs-must-be-enabled = FSRS must be enabled first. deck-config-fsrs-params-optimal = The FSRS parameters currently appear to be optimal. deck-config-fsrs-params-no-reviews = No reviews found. Make sure this preset is assigned to all decks (including subdecks) that you want to optimize, and try again. deck-config-wait-for-audio = Wait for audio deck-config-show-reminder = Show Reminder deck-config-answer-again = Answer Again deck-config-answer-hard = Answer Hard deck-config-answer-good = Answer Good deck-config-days-to-simulate = Days to simulate deck-config-desired-retention-below-optimal = Your desired retention is below optimal. Increasing it is recommended. # Description of the y axis in the FSRS simulation # diagram (Deck options -> FSRS) showing the total number of # cards that can be recalled or retrieved on a specific date. deck-config-fsrs-simulator-experimental = FSRS Simulator (Experimental) deck-config-fsrs-simulate-desired-retention-experimental = FSRS Desired Retention Simulator (Experimental) deck-config-fsrs-simulate-save-preset = After optimizing, please save your deck preset before running the simulator. deck-config-fsrs-desired-retention-help-me-decide-experimental = Help Me Decide (Experimental) deck-config-additional-new-cards-to-simulate = Additional new cards to simulate deck-config-simulate = Simulate deck-config-clear-last-simulate = Clear Last Simulation deck-config-fsrs-simulator-radio-count = Reviews deck-config-advanced-settings = Advanced Settings deck-config-smooth-graph = Smooth graph deck-config-suspend-leeches = Suspend leeches deck-config-save-options-to-preset = Save Changes to Preset deck-config-save-options-to-preset-confirm = Overwrite the options in your current preset with the options that are currently set in the simulator? # Radio button in the FSRS simulation diagram (Deck options -> FSRS) selecting # to show the total number of cards that can be recalled or retrieved on a # specific date. deck-config-fsrs-simulator-radio-memorized = Memorized deck-config-fsrs-simulator-radio-ratio = Time / Memorized Ratio # $time here is pre-formatted e.g. "10 Seconds" deck-config-fsrs-simulator-ratio-tooltip = { $time } per memorized card ## Messages related to the FSRS scheduler’s health check. The health check determines whether the correlation between FSRS predictions and your memory is good or bad. It can be optionally triggered as part of the "Optimize" function. # Checkbox deck-config-health-check = Check health when optimizing # Message box showing the result of the health check deck-config-fsrs-bad-fit-warning = Health Check: Your memory is difficult for FSRS to predict. Recommendations: - Suspend or reformulate any cards you constantly forget. - Use the answer buttons consistently. Keep in mind that "Hard" is a passing grade, not a failing grade. - Understand before you memorize. If you follow these suggestions, performance will usually improve over the next few months. # Message box showing the result of the health check deck-config-fsrs-good-fit = Health Check: FSRS can adapt to your memory well. ## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future. deck-config-unable-to-determine-desired-retention = Unable to determine a minimum recommended retention. deck-config-predicted-minimum-recommended-retention = Minimum recommended retention: { $num } deck-config-compute-minimum-recommended-retention = Minimum recommended retention deck-config-compute-optimal-retention-tooltip4 = This tool will attempt to find the desired retention value that will lead to the most material learnt, in the least amount of time. The calculated number can serve as a reference when deciding what to set your desired retention to. You may wish to choose a higher desired retention if you’re willing to invest more study time to achieve it. Setting your desired retention lower than the minimum is not recommended, as it will lead to a higher workload, because of the high forgetting rate. deck-config-plotted-on-x-axis = (Plotted on the X-axis) deck-config-a-100-day-interval = { $days -> [one] A 100 day interval will become { $days } day. *[other] A 100 day interval will become { $days } days. } deck-config-fsrs-simulator-y-axis-title-time = Review Time/Day deck-config-fsrs-simulator-y-axis-title-count = Review Count/Day deck-config-fsrs-simulator-y-axis-title-memorized = Memorized Total deck-config-bury-siblings = Bury siblings deck-config-do-not-bury = Do not bury siblings deck-config-bury-if-new = Bury if new deck-config-bury-if-new-or-review = Bury if new or review deck-config-bury-if-new-review-or-interday = Bury if new, review, or interday learning deck-config-bury-tooltip = Siblings are other cards from the same note (eg forward/reverse cards, or other cloze deletions from the same text). When this option is off, multiple cards from the same note may be seen on the same day. When enabled, Anki will automatically *bury* siblings, hiding them until the next day. This option allows you to choose which kinds of cards may be buried when you answer one of their siblings. When using the V3 scheduler, interday learning cards can also be buried. Interday learning cards are cards with a current learning step of one or more days. deck-config-seconds-to-show-question-tooltip = When auto advance is activated, the number of seconds to wait before revealing the answer. Set to 0 to disable. deck-config-answer-action-tooltip = The action to perform on the current card before automatically advancing to the next one. deck-config-wait-for-audio-tooltip = Wait for audio to finish before automatically revealing answer or next question. deck-config-ignore-before-tooltip = If set, reviews before the provided date will be ignored when optimizing & evaluating FSRS parameters. This can be useful if you imported someone else's scheduling data, or have changed the way you use the answer buttons. deck-config-compute-optimal-retention-tooltip = This tool assumes you're starting with 0 cards, and will attempt to calculate the amount of material you'll be able to retain in the given time frame. The estimated retention will greatly depend on your inputs, and if it significantly differs from 0.9, it's a sign that the time you've allocated each day is either too low or too high for the amount of cards you're trying to learn. This number can be useful as a reference, but it is not recommended to copy it into the desired retention field. deck-config-health-check-tooltip1 = This will show a warning if FSRS struggles to adapt to your memory. deck-config-health-check-tooltip2 = Health check is performed only when using Optimize Current Preset. deck-config-compute-optimal-retention = Compute minimum recommended retention deck-config-predicted-optimal-retention = Minimum recommended retention: { $num } deck-config-weights-tooltip = FSRS parameters affect how cards are scheduled. Anki will start with default parameters. Once you've accumulated 1000+ reviews, you can use the option below to optimize the parameters to best match your performance in decks using this preset. deck-config-compute-optimal-weights-tooltip = Once you've done 1000+ reviews in Anki, you can use the Optimize button to analyze your review history, and automatically generate parameters that are optimal for your memory and the content you're studying. If you have decks that vary wildly in difficulty, it is recommended to assign them separate presets, as the parameters for easy decks and hard decks will be different. There is no need to optimize your parameters frequently - once every few months is sufficient. By default, parameters will be calculated from the review history of all decks using the current preset. You can optionally adjust the search before calculating the parameters, if you'd like to alter which cards are used for optimizing the parameters. deck-config-compute-optimal-retention-tooltip2 = This tool assumes that you’re starting with 0 learned cards, and will attempt to find the desired retention value that will lead to the most material learnt, in the least amount of time. This number can be used as a reference when deciding what to set your desired retention to. You may wish to choose a higher desired retention, if you’re willing to trade more study time for a greater recall rate. Setting your desired retention lower than the minimum is not recommended, as it will lead to more work without benefit. deck-config-compute-optimal-retention-tooltip3 = This tool assumes that you’re starting with 0 learned cards, and will attempt to find the desired retention value that will lead to the most material learnt, in the least amount of time. To accurately simulate your learning process, this feature requires a minimum of 400+ reviews. The calculated number can serve as a reference when deciding what to set your desired retention to. You may wish to choose a higher desired retention, if you’re willing to trade more study time for a greater recall rate. Setting your desired retention lower than the minimum is not recommended, as it will lead to a higher workload, because of the high forgetting rate. deck-config-seconds-to-show-question-tooltip-2 = When auto advance is activated, the number of seconds to wait before revealing the answer. Set to 0 to disable. deck-config-invalid-weights = Parameters must be either left blank to use the defaults, or must be 17 comma-separated numbers. deck-config-fsrs-on-all-clients = Please ensure all of your Anki clients are Anki(Mobile) 23.10+ or AnkiDroid 2.17+. FSRS will not work correctly if one of your clients is older. deck-config-optimize-all-tip = You can optimize all presets at once by using the dropdown button next to "Save". ================================================ FILE: ftl/core/decks.ftl ================================================ ## In the options window of a filtered deck decks-limit-to = Limit to decks-cards-selected-by = cards selected by decks-reschedule-cards-based-on-my-answers = Reschedule cards based on my answers in this deck decks-enable-second-filter = Enable second filter decks_create_even_if_empty = Create/update this deck even if empty # e.g. "Delay for Again", "Delay for Hard", "Delay for Good" decks-delay-for-button = Delay for { $button } # The count of cards waiting to be reviewed decks-zero-minutes-hint = (0 = return card to original deck) # filter is a noun here decks-filter = Filter: decks-filter-2 = Filter 2 ## column names on the main "Decks" window decks-deck = Deck decks-learn-header = Learn decks-review-header = Due ## decks-unmovable-cards = Show any excluded cards decks-add-new-deck-ctrlandn = Add New Deck (Ctrl+N) decks-build = Build decks-create-deck = Create Deck decks-custom-steps-in-minutes = Custom steps (in minutes) decks-delete-deck = Delete Deck # a button that links to AnkiWeb for browsing shared decks decks-get-shared = Get Shared # import deck from file decks-import-file = Import File decks-minutes = minutes decks-new-deck-name = New deck name: decks-no-deck = [no deck] decks-please-select-something = Please select something. decks-repeat-failed-cards-after = Delay Repeat failed cards after decks-study = Study decks-study-deck = Study Deck decks-filtered-deck-search-empty = No cards matched the provided search. Some cards may have been excluded because they are in a different filtered deck, or suspended. ## Sort order of cards # Combobox entry: Sort the cards by the date they were added, in ascending order (oldest to newest) decks-order-added = Order added # Combobox entry: Sort the cards by the date they were added, in descending order (newest to oldest) decks-latest-added-first = Latest added first # Combobox entry: Sort the cards by due date, in ascending order (oldest due date to newest) decks-order-due = Order due # Combobox entry: Sort the cards by the number of lapses, in descending order (most lapses to least lapses) decks-most-lapses = Most lapses # Combobox entry: Sort the cards by the interval, in ascending order (shortest to longest) decks-increasing-intervals = Increasing intervals # Combobox entry: Sort the cards by the interval, in descending order (longest to shortest) decks-decreasing-intervals = Decreasing intervals # Combobox entry: Sort the cards by the last review date, in ascending order (oldest seen to newest seen) decks-oldest-seen-first = Oldest seen first # Combobox entry: Sort the cards in random order decks-random = Random # Combobox entry: Sort the cards by relative overdueness, in descending order (most overdue to least overdue) decks-relative-overdueness = Relative overdueness ## These strings are no longer used - you do not need to translate them if they ## are not already translated. ================================================ FILE: ftl/core/editing.ftl ================================================ editing-actual-size = Toggle actual size editing-add-media = Add Media editing-align-left = Align left editing-align-right = Align right editing-an-error-occurred-while-opening = An error occurred while opening { $val } editing-attach-picturesaudiovideo = Attach pictures/audio/video editing-bold-text = Bold text editing-cards = Cards editing-center = Center editing-change-color = Change color editing-cloze-deletion = Cloze deletion (new card) editing-cloze-deletion-repeat = Cloze deletion (same card) editing-copy-image = Copy image editing-couldnt-record-audio-have-you-installed = Couldn't record audio. Have you installed 'lame'? editing-customize-card-templates = Customize Card Templates editing-customize-fields = Customize Fields editing-cut = Cut editing-double-click-image = double-click image editing-double-click-to-expand = double-click to expand editing-double-click-to-collapse = double-click to collapse editing-edit-current = Edit Current editing-edit-html = Edit HTML editing-fields = Fields editing-float-left = Float left editing-float-right = Float right editing-float-none = No float editing-indent = Increase indent editing-italic-text = Italic text editing-jump-to-tags-with-ctrlandshiftandt = Jump to tags with Ctrl+Shift+T editing-justify = Justify editing-latex = LaTeX editing-latex-equation = LaTeX equation editing-latex-math-env = LaTeX math env. editing-mathjax-block = MathJax block editing-mathjax-chemistry = MathJax chemistry editing-mathjax-inline = MathJax inline editing-mathjax-placeholder = Press { $accept } to accept, { $newline } for new line. editing-media = Media editing-open-image = Open image editing-show-in-folder = Show in folder editing-ordered-list = Ordered list editing-outdent = Decrease indent editing-paste = Paste editing-record-audio = Record audio editing-remove-formatting = Remove formatting editing-restore-original-size = Restore original size editing-select-remove-formatting = Select formatting to remove editing-show-duplicates = Show Duplicates editing-subscript = Subscript editing-superscript = Superscript editing-tags = Tags editing-tags-add = Add tag editing-tags-copy = Copy tags editing-tags-remove = Remove tags editing-tags-select-all = Select all tags editing-text-color = Text color editing-text-highlight-color = Text highlight color editing-to-make-a-cloze-deletion-on = To make a cloze deletion on an existing note, you need to change it to a cloze type first, via 'Notes>Change Note Type' editing-toggle-html-editor = Toggle HTML Editor editing-toggle-visual-editor = Toggle Visual Editor editing-toggle-sticky = Toggle sticky editing-expand = Expand editing-collapse = Collapse editing-expand-field = Expand field editing-collapse-field = Collapse field editing-underline-text = Underline text editing-unordered-list = Unordered list editing-warning-cloze-deletions-will-not-work = Warning, cloze deletions will not work until you switch the type at the top to Cloze. editing-mathjax-preview = MathJax Preview editing-shrink-images = Shrink Images editing-close-html-tags = Auto-close HTML tags editing-from-clipboard = From Clipboard editing-alignment = Alignment editing-equations = Equations editing-no-image-found-on-clipboard = No image found on clipboard. editing-image-occlusion-mode = Image Occlusion Mode editing-image-occlusion-zoom-out = Zoom Out editing-image-occlusion-zoom-in = Zoom In editing-image-occlusion-zoom-reset = Reset Zoom editing-image-occlusion-toggle-translucent = Toggle Translucency editing-image-occlusion-delete = Delete editing-image-occlusion-duplicate = Duplicate editing-image-occlusion-group = Group Selection editing-image-occlusion-ungroup = Ungroup Selection editing-image-occlusion-select-all = Select All editing-image-occlusion-alignment = Alignment editing-image-occlusion-align-left = Align Left editing-image-occlusion-align-h-center = Align Horizontal Centers editing-image-occlusion-align-right = Align Right editing-image-occlusion-align-top = Align Top editing-image-occlusion-align-v-center = Align Vertical Centers editing-image-occlusion-align-bottom = Align Bottom editing-image-occlusion-select-tool = Select editing-image-occlusion-zoom-tool = Zoom editing-image-occlusion-rectangle-tool = Rectangle editing-image-occlusion-ellipse-tool = Ellipse editing-image-occlusion-polygon-tool = Polygon editing-image-occlusion-text-tool = Text editing-image-occlusion-fill-tool = Fill with colour editing-image-occlusion-toggle-mask-editor = Toggle Mask Editor editing-image-occlusion-reset = Reset Image Occlusion editing-image-occlusion-confirm-reset = Are you sure you want to reset this image occlusion? ## You don't need to translate these strings, as they will be replaced with different ones soon. editing-html-editor = HTML Editor ================================================ FILE: ftl/core/empty-cards.ftl ================================================ empty-cards-for-note-type = Empty cards for { $notetype }: empty-cards-count-line = { $empty_count } of { $existing_count } cards empty ({ $template_names }). empty-cards-window-title = Empty Cards empty-cards-preserve-notes-checkbox = Keep notes with no valid cards empty-cards-delete-button = Delete empty-cards-not-found = No empty cards. empty-cards-deleted-count = Deleted { $cards -> [one] { $cards } card. *[other] { $cards } cards. } empty-cards-delete-empty-cards = Delete Empty Cards empty-cards-delete-empty-notes = Delete Empty Notes empty-cards-deleting = Deleting... ================================================ FILE: ftl/core/errors.ftl ================================================ errors-parse-number-fail = A number was invalid or out of range. errors-filtered-parent-deck = Filtered decks can not have child decks. errors-filtered-deck-required = This action can only be used on a filtered deck. errors-100-tags-max = A maximum of 100 tags can be selected. Listing the tags you want instead of the ones you don't want is usually simpler, and there is no need to select child tags if you have selected a parent tag. errors-multiple-notetypes-selected = Please select notes from only one note type. errors-please-check-database = Please use the Check Database action, then try again. errors-please-check-media = Please use the Check Media action, then try again. errors-collection-too-new = This collection requires a newer version of Anki to open. errors-invalid-ids = This deck contains timestamps in the future. Please contact the deck author and ask them to fix the issue. errors-inconsistent-db-state = Your database appears to be in an inconsistent state. Please use the Check Database action. ## Card Rendering errors-bad-directive = Error in directive '{ $directive }': { $error } errors-option-not-set = '{ $option }' not set ## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future. errors-invalid-input-empty = Invalid input. errors-invalid-input-details = Invalid input: { $details } ================================================ FILE: ftl/core/exporting.ftl ================================================ exporting-all-decks = All Decks exporting-anki-20-deck = Anki 2.0 Deck exporting-anki-collection-package = Anki Collection Package exporting-anki-deck-package = Anki Deck Package exporting-cards-in-plain-text = Cards in Plain Text # used in the filename during the export of a collection package exporting-collection = collection exporting-collection-exported = Collection exported. exporting-colpkg-too-new = Please update to the latest Anki version, then import the .colpkg/.apkg file again. exporting-couldnt-save-file = Couldn't save file: { $val } exporting-export = Export... exporting-export-format = Export format: exporting-include = Include: exporting-include-html-and-media-references = Include HTML and media references exporting-include-media = Include media exporting-include-scheduling-information = Include scheduling information exporting-include-deck-configs = Include deck presets exporting-include-tags = Include tags exporting-support-older-anki-versions = Support older Anki versions (slower/larger files) exporting-notes-in-plain-text = Notes in Plain Text exporting-selected-notes = Selected Notes exporting-card-exported = { $count -> [one] { $count } card exported. *[other] { $count } cards exported. } exporting-exported-media-file = { $count -> [one] Exported { $count } media file *[other] Exported { $count } media files } exporting-note-exported = { $count -> [one] { $count } note exported. *[other] { $count } notes exported. } exporting-exporting-file = Exporting file... exporting-processed-media-files = { $count -> [one] Processed { $count } media file... *[other] Processed { $count } media files... } exporting-include-deck = Include deck name exporting-include-notetype = Include note type name exporting-include-guid = Include unique identifier ================================================ FILE: ftl/core/fields.ftl ================================================ fields-add-field = Add Field fields-delete-field-from = Delete field from { $val }? fields-editing-font = Editing Font fields-field = Field: fields-field-name = Field name: fields-description = Description fields-description-placeholder = Text to show inside the field when it's empty fields-fields-for = Fields for { $val } fields-font = Font: fields-new-position-1 = New position (1...{ $val }): fields-notes-require-at-least-one-field = Notes require at least one field. fields-reverse-text-direction-rtl = Reverse text direction (RTL) fields-collapse-by-default = Collapse by default fields-html-by-default = Use HTML editor by default fields-size = Size: fields-sort-by-this-field-in-the = Sort by this field in the browser fields-that-field-name-is-already-used = That field name is already used. fields-name-first-letter-not-valid = The field name should not start with #, ^ or /. fields-name-invalid-letter = The field name should not contain :, ", { "{" } or { "}" }. # If enabled, the field is not included when searching for 'text', 're:text' and so on, # but is when searching for a specific field, eg 'field:text'. fields-exclude-from-search = Exclude from unqualified searches (slower) fields-field-is-required = This is a required field, and can not be deleted. ================================================ FILE: ftl/core/findreplace.ftl ================================================ findreplace-notes-updated = { $total -> [one] { $changed } of { $total } note updated *[other] { $changed } of { $total } notes updated } ================================================ FILE: ftl/core/help.ftl ================================================ ### Text shown in Help pages ## Header/footer # Link to more detailed information in the manual help-for-more-info = For more information, see { $link } in the manual. # Tooltip for links to the manual help-open-manual-chapter = Open { $name } in the manual help-ok = OK ## Body # Newly introduced settings may not have an explanation yet help-no-explanation = Whoops! There doesn't seem to be an explanation for this setting yet. You can help us complete this help page on { $link }. ================================================ FILE: ftl/core/importing.ftl ================================================ importing-failed-debug-info = Import failed. Debugging info: importing-aborted = Aborted: { $val } importing-added-duplicate-with-first-field = Added duplicate with first field: { $val } importing-all-supported-formats = All supported formats { $val } importing-allow-html-in-fields = Allow HTML in fields importing-anki-files-are-from-a-very = .anki files are from a very old version of Anki. You can import them with add-on 175027074 or with Anki 2.0, available on the Anki website. importing-anki2-files-are-not-directly-importable = .anki2 files are not directly importable - please import the .apkg or .zip file you have received instead. importing-appeared-twice-in-file = Appeared twice in file: { $val } importing-by-default-anki-will-detect-the = By default, Anki will detect the character between fields, such as a tab, comma, and so on. If Anki is detecting the character incorrectly, you can enter it here. Use \t to represent tab. importing-cannot-merge-notetypes-of-different-kinds = Cloze note types cannot be merged with regular note types. You may still import the file with '{ importing-merge-notetypes }' disabled. importing-change = Change importing-colon = Colon importing-comma = Comma importing-empty-first-field = Empty first field: { $val } importing-field-separator = Field separator importing-field-separator-guessed = Field separator (guessed) importing-field-mapping = Field mapping importing-field-of-file-is = Field { $val } of file is: importing-fields-separated-by = Fields separated by: { $val } importing-file-must-contain-field-column = File must contain at least one column that can be mapped to a note field. importing-file-version-unknown-trying-import-anyway = File version unknown, trying import anyway. importing-first-field-matched = First field matched: { $val } importing-identical = Identical importing-ignore-field = Ignore field importing-ignore-lines-where-first-field-matches = Ignore lines where first field matches existing note importing-ignored = importing-import-even-if-existing-note-has = Import even if existing note has same first field importing-import-options = Import options importing-importing-complete = Importing complete. importing-invalid-file-please-restore-from-backup = Invalid file. Please restore from backup. importing-map-to = Map to { $val } importing-map-to-tags = Map to Tags importing-mapped-to = mapped to { $val } importing-mapped-to-tags = mapped to Tags # the action of combining two existing note types to create a new one importing-merge-notetypes = Merge note types importing-merge-notetypes-help = If checked, and you or the deck author altered the schema of a note type, Anki will merge the two versions instead of keeping both. Altering a note type's schema means adding, removing, or reordering fields or templates, or changing the sort field. As a counterexample, changing the front side of an existing template does *not* constitute a schema change. Warning: This will require a one-way sync, and may mark existing notes as modified. importing-mnemosyne-20-deck-db = Mnemosyne 2.0 Deck (*.db) importing-multicharacter-separators-are-not-supported-please = Multi-character separators are not supported. Please enter one character only. importing-new-deck-will-be-created = A new deck will be created: { $name } importing-notes-added-from-file = Notes added from file: { $val } importing-notes-found-in-file = Notes found in file: { $val } importing-notes-skipped-as-theyre-already-in = Notes skipped, as up-to-date copies are already in your collection: { $val } importing-notes-skipped-update-due-to-notetype = Notes not updated, as note type has been modified since you first imported the notes: { $val } importing-notes-updated-as-file-had-newer = Notes updated, as file had newer version: { $val } importing-include-reviews = Include reviews importing-also-import-progress = Import any learning progress importing-with-deck-configs = Import any deck presets importing-updates = Updates importing-include-reviews-help = If enabled, any previous reviews that the deck sharer included will also be imported. Otherwise, all cards will be imported as new cards, and any "leech" or "marked" tags will be removed. importing-with-deck-configs-help = If enabled, any deck options that the deck sharer included will also be imported. Otherwise, all decks will be assigned the default preset. importing-packaged-anki-deckcollection-apkg-colpkg-zip = Packaged Anki Deck/Collection (*.apkg *.colpkg *.zip) # the '|' character importing-pipe = Pipe # Warning displayed when the csv import preview table is clipped (some columns were hidden) # $count is intended to be a large number (1000 and above) importing-preview-truncated = { $count -> *[other] Only the first { $count } columns are shown. If this doesn't seem right, try changing the field separator. } importing-rows-had-num1d-fields-expected-num2d = '{ $row }' had { $found } fields, expected { $expected } importing-selected-file-was-not-in-utf8 = Selected file was not in UTF-8 format. Please see the importing section of the manual. importing-semicolon = Semicolon importing-skipped = Skipped importing-tab = Tab importing-tag-modified-notes = Tag modified notes: importing-text-separated-by-tabs-or-semicolons = Text separated by tabs or semicolons (*) importing-the-first-field-of-the-note = The first field of the note type must be mapped. importing-the-provided-file-is-not-a = The provided file is not a valid .apkg file. importing-this-file-does-not-appear-to = This file does not appear to be a valid .apkg file. If you're getting this error from a file downloaded from AnkiWeb, chances are that your download failed. Please try again, and if the problem persists, please try again with a different browser. importing-this-will-delete-your-existing-collection = This will delete your existing collection and replace it with the data in the file you're importing. Are you sure? importing-unable-to-import-from-a-readonly = Unable to import from a read-only file. importing-unknown-file-format = Unknown file format. importing-update-existing-notes-when-first-field = Update existing notes when first field matches importing-updated = Updated importing-update-if-newer = If newer importing-update-always = Always importing-update-never = Never importing-update-notes = Update notes importing-update-notes-help = When to update an existing note in your collection. By default, this is only done if the matching imported note was more recently modified. importing-update-notetypes = Update note types importing-update-notetypes-help = When to update an existing note type in your collection. By default, this is only done if the matching imported note type was more recently modified. Changes to template text and styling can always be imported, but for schema changes (e.g. the number or order of fields has changed), the '{ importing-merge-notetypes }' option will also need to be enabled. importing-note-added = { $count -> [one] { $count } note added *[other] { $count } notes added } importing-note-imported = { $count -> [one] { $count } note imported. *[other] { $count } notes imported. } importing-note-unchanged = { $count -> [one] { $count } note unchanged *[other] { $count } notes unchanged } importing-note-updated = { $count -> [one] { $count } note updated *[other] { $count } notes updated } importing-processed-media-file = { $count -> [one] Imported { $count } media file *[other] Imported { $count } media files } importing-importing-file = Importing file... importing-extracting = Extracting data... importing-gathering = Gathering data... importing-failed-to-import-media-file = Failed to import media file: { $debugInfo } importing-processed-notes = { $count -> [one] Processed { $count } note... *[other] Processed { $count } notes... } importing-processed-cards = { $count -> [one] Processed { $count } card... *[other] Processed { $count } cards... } importing-existing-notes = Existing notes # "Existing notes: Duplicate" (verb) importing-duplicate = Duplicate # "Existing notes: Preserve" (verb) importing-preserve = Preserve # "Existing notes: Update" (verb) importing-update = Update importing-tag-all-notes = Tag all notes importing-tag-updated-notes = Tag updated notes importing-file = File # "Match scope: notetype / notetype and deck". Controls how duplicates are matched. importing-match-scope = Match scope # Used with the 'match scope' option importing-notetype-and-deck = Note type and deck importing-cards-added = { $count -> [one] { $count } card added. *[other] { $count } cards added. } importing-file-empty = The file you selected is empty. importing-notes-added = { $count -> [one] { $count } new note imported. *[other] { $count } new notes imported. } importing-notes-updated = { $count -> [one] { $count } note was used to update existing ones. *[other] { $count } notes were used to update existing ones. } importing-existing-notes-skipped = { $count -> [one] { $count } note already present in your collection. *[other] { $count } notes already present in your collection. } importing-notes-failed = { $count -> [one] { $count } note could not be imported. *[other] { $count } notes could not be imported. } importing-conflicting-notes-skipped = { $count -> [one] { $count } note was not imported, because its note type has changed. *[other] { $count } notes were not imported, because their note type has changed. } importing-conflicting-notes-skipped2 = { $count -> [one] { $count } note was not imported, because its note type has changed, and '{ importing-merge-notetypes }' was not enabled. *[other] { $count } notes were not imported, because their note type has changed, and '{ importing-merge-notetypes }' was not enabled. } importing-import-log = Import Log importing-no-notes-in-file = No notes found in file. importing-notes-found-in-file2 = { $notes -> [one] { $notes } note *[other] { $notes } notes } found in file. Of those: importing-show = Show importing-details = Details importing-status = Status importing-duplicate-note-added = Duplicate note added importing-added-new-note = New note added importing-existing-note-skipped = Note skipped, as an up-to-date copy is already in your collection importing-note-skipped-update-due-to-notetype = Note not updated, as note type has been modified since you first imported the note importing-note-skipped-update-due-to-notetype2 = Note not updated, as note type has been modified since you first imported the note, and '{ importing-merge-notetypes }' was not enabled importing-note-updated-as-file-had-newer = Note updated, as file had newer version importing-note-skipped-due-to-missing-notetype = Note skipped, as its notetype was missing importing-note-skipped-due-to-missing-deck = Note skipped, as its deck was missing importing-note-skipped-due-to-empty-first-field = Note skipped, as its first field is empty importing-field-separator-help = The character separating fields in the text file. You can use the preview to check if the fields are separated correctly. Please note that if this character appears in any field itself, the field has to be quoted accordingly to the CSV standard. Spreadsheet programs like LibreOffice will do this automatically. It cannot be changed if the text file forces use of a specific separator via a file header. If a file header is not present, Anki will try to guess what the separator is. importing-allow-html-in-fields-help = Enable this if the file contains HTML formatting. E.g. if the file contains the string '<br>', it will appear as a line break on your card. On the other hand, with this option disabled, the literal characters '<br>' will be rendered. importing-notetype-help = Newly-imported notes will have this note type, and only existing notes with this note type will be updated. You can choose which fields in the file correspond to which note type fields with the mapping tool. importing-deck-help = Imported cards will be placed in this deck. importing-existing-notes-help = What to do if an imported note matches an existing one. - `{ importing-update }`: Update the existing note. - `{ importing-preserve }`: Do nothing. - `{ importing-duplicate }`: Create a new note. importing-match-scope-help = Only existing notes with the same note type will be checked for duplicates. This can additionally be restricted to notes with cards in the same deck. importing-tag-all-notes-help = These tags will be added to both newly-imported and updated notes. importing-tag-updated-notes-help = These tags will be added to any updated notes. importing-overview = Overview ## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future. importing-importing-collection = Importing collection... importing-unable-to-import-filename = Unable to import { $filename }: file type not supported importing-notes-that-could-not-be-imported = Notes that could not be imported as note type has changed: { $val } importing-added = Added importing-pauker-18-lesson-paugz = Pauker 1.8 Lesson (*.pau.gz) importing-supermemo-xml-export-xml = Supermemo XML export (*.xml) ================================================ FILE: ftl/core/keyboard.ftl ================================================ keyboard-ctrl = Ctrl keyboard-shift = Shift ================================================ FILE: ftl/core/launcher.ftl ================================================ launcher-title = Anki Launcher launcher-press-enter-to-install = Press the Enter/Return key on your keyboard to install or update Anki. launcher-press-enter-to-start = Press enter to start Anki. launcher-anki-will-start-shortly = Anki will start shortly. launcher-you-can-close-this-window = You can close this window. launcher-updating-anki = Updating Anki... launcher-latest-anki = Install Latest Anki (default) launcher-choose-a-version = Choose a version launcher-sync-project-changes = Sync project changes launcher-keep-existing-version = Keep existing version ({ $current }) launcher-revert-to-previous = Revert to previous version ({ $prev }) launcher-allow-betas = Allow betas: { $state } launcher-on = on launcher-off = off launcher-cache-downloads = Cache downloads: { $state } launcher-download-mirror = Download mirror: { $state } launcher-uninstall = Uninstall Anki launcher-invalid-input = Invalid input. Please try again. launcher-latest-releases = Latest releases: { $releases } launcher-enter-the-version-you-want = Enter the version you want to install: launcher-versions-before-cant-be-installed = Versions before 2.1.50 can't be installed. launcher-invalid-version = Invalid version. launcher-unable-to-check-for-versions = Unable to check for Anki versions. Please check your internet connection. launcher-checking-for-updates = Checking for updates... launcher-uninstall-confirm = Uninstall Anki's program files? (y/n) launcher-uninstall-cancelled = Uninstall cancelled. launcher-program-files-removed = Program files removed. launcher-remove-all-profiles-confirm = Remove all profiles/cards? (y/n) launcher-user-data-removed = User data removed. launcher-download-mirror-options = Download mirror options: launcher-mirror-no-mirror = No mirror launcher-mirror-china = China launcher-mirror-disabled = Mirror disabled. launcher-mirror-china-enabled = China mirror enabled. launcher-beta-releases-enabled = Beta releases enabled. launcher-beta-releases-disabled = Beta releases disabled. launcher-download-caching-enabled = Download caching enabled. launcher-download-caching-disabled = Download caching disabled and cache cleared. ================================================ FILE: ftl/core/media-check.ftl ================================================ ## Shown at the top of the media check screen media-check-window-title = Check Media # the number of files, and the total space used by files # that have been moved to the trash folder. eg, # "Trash folder: 3 files, 3.47MB" media-check-trash-count = Trash folder: { $count -> [one] { $count } file, { $megs }MB *[other] { $count } files, { $megs }MB } media-check-missing-count = Missing files: { $count } media-check-unused-count = Unused files: { $count } media-check-renamed-count = Renamed files: { $count } media-check-oversize-count = Over 100MB: { $count } media-check-subfolder-count = Subfolders: { $count } media-check-extracted-count = Extracted images: { $count } ## Shown at the top of each section media-check-renamed-header = Some files have been renamed for compatibility: media-check-oversize-header = Files over 100MB can not be synced with AnkiWeb. media-check-subfolder-header = Folders inside the media folder are not supported. media-check-missing-header = The following files are referenced by cards, but were not found in the media folder: media-check-unused-header = The following files were found in the media folder, but do not appear to be used on any cards: media-check-template-references-field-header = Anki can not detect used files when you use { "{{Field}}" } references in media/LaTeX tags. The media/LaTeX tags should be placed on individual notes instead. Referencing templates: ## Shown once for each file media-check-renamed-file = Renamed: { $old } -> { $new } media-check-oversize-file = Over 100MB: { $filename } media-check-subfolder-file = Folder: { $filename } media-check-missing-file = Missing: { $filename } media-check-unused-file = Unused: { $filename } ## # Eg "Basic: Card 1 (Front Template)" media-check-notetype-template = { $notetype }: { $card_type } ({ $side }) ## Progress media-check-checked = Checked { $count }... ## Deleting unused media media-check-delete-unused-confirm = Delete unused media? media-check-files-remaining = { $count -> [one] { $count } file *[other] { $count } files } remaining. media-check-delete-unused-complete = { $count -> [one] { $count } file *[other] { $count } files } moved to the trash. media-check-trash-emptied = The trash folder is now empty. media-check-trash-restored = Restored deleted files to the media folder. ## Rendering LaTeX media-check-all-latex-rendered = All LaTeX rendered. ## Buttons media-check-delete-unused = Delete Unused media-check-render-latex = Render LaTeX # button to permanently delete media files from the trash folder media-check-empty-trash = Empty Trash # button to move deleted files from the trash back into the media folder media-check-restore-trash = Restore Deleted media-check-check-media-action = Check Media # a tag for notes with missing media files (must not contain whitespace) media-check-missing-media-tag = missing-media # add a tag to notes with missing media media-check-add-tag = Tag Missing ================================================ FILE: ftl/core/media.ftl ================================================ media-error-executing = Error executing { $val }. media-error-running = Error running { $val } media-for-security-reasons-is-not = For security reasons, '{ $val }' is not allowed on cards. You can still use it by placing the command in a different package, and importing that package in the LaTeX header instead. media-generated-file = Generated file: { $val } media-have-you-installed-latex-and-dvipngdvisvgm = Have you installed latex and dvipng/dvisvgm? media-recordingtime = Recording...
Time: { $secs } media-sound-and-video-on-cards-will = Sound and video on cards will not function until mpv or mplayer is installed. ================================================ FILE: ftl/core/network.ftl ================================================ network-offline = Please check your internet connection. network-timeout = Connection timed out. Please try again. If you see frequent timeouts, please try a different network connection. network-proxy-auth = Your proxy requires authentication. network-other = A network error occurred. network-details = Error details: { $details } ================================================ FILE: ftl/core/notetypes.ftl ================================================ notetypes-notetype = Note Type ## Default field names in newly created note types notetypes-front-field = Front notetypes-back-field = Back notetypes-add-reverse-field = Add Reverse notetypes-text-field = Text notetypes-back-extra-field = Back Extra ## Default note type names notetypes-basic-name = Basic notetypes-basic-reversed-name = Basic (and reversed card) notetypes-basic-optional-reversed-name = Basic (optional reversed card) notetypes-basic-type-answer-name = Basic (type in the answer) notetypes-cloze-name = Cloze ## Default card template names notetypes-card-1-name = Card 1 notetypes-card-2-name = Card 2 notetypes-add = Add: { $val } notetypes-add-note-type = Add Note Type notetypes-cards = Cards notetypes-clone = Clone: { $val } notetypes-copy = { $val } copy notetypes-create-scalable-images-with-dvisvgm = Create scalable images with dvisvgm notetypes-delete-this-note-type-and-all = Delete this note type and all its cards? notetypes-delete-this-unused-note-type = Delete this unused note type? notetypes-fields = Fields notetypes-footer = Footer notetypes-header = Header notetypes-note-types = Note Types notetypes-options = Options notetypes-please-add-another-note-type-first = Please add another note type first. notetypes-type = Type ## Image Occlusion notetypes-image = Image notetypes-occlusion = Occlusion notetypes-occlusion-mask = Mask notetypes-occlusion-note = Note notetypes-comments-field = Comments notetypes-toggle-masks = Toggle Masks notetypes-image-occlusion-name = Image Occlusion notetypes-hide-all-guess-one = Hide All, Guess One notetypes-hide-one-guess-one = Hide One, Guess One notetypes-error-generating-cloze = An error occurred when generating an image occlusion note notetypes-error-getting-imagecloze = An error occurred while fetching an image occlusion note notetypes-error-loading-image-occlusion = Error loading image occlusion. Is your Anki version up to date? notetype-error-no-image-to-show = No image to show. notetypes-no-occlusion-created = You must make at least one occlusion. notetypes-no-occlusion-created2 = Unable to add. Either you have not added any occlusions, or the first field is empty. notetypes-io-select-image = Select Image notetypes-io-paste-image-from-clipboard = Paste Image from Clipboard ================================================ FILE: ftl/core/preferences.ftl ================================================ preferences-automatically-sync-on-profile-openclose = Automatically sync on profile open/close preferences-backups = Backups preferences-change-deck-depending-on-note-type = Change deck depending on note type preferences-changes-will-take-effect-when-you = Changes will take effect when you restart Anki. preferences-hours-past-midnight = hours past midnight preferences-language = Language preferences-interrupt-current-audio-when-answering = Interrupt current audio when answering preferences-learn-ahead-limit = Learn ahead limit preferences-mins = mins preferences-network = Syncing preferences-next-day-starts-at = Next day starts at preferences-media-is-not-backed-up = Media is not backed up. Please create a periodic backup of your Anki folder to be safe. preferences-on-next-sync-force-changes-in = On next sync, force changes in one direction preferences-paste-clipboard-images-as-png = Paste clipboard images as PNG preferences-paste-without-shift-key-strips-formatting = Paste without shift key strips formatting preferences-generate-latex-images-automatically = Generate LaTeX images (security risk) preferences-latex-generation-disabled = LaTeX image generation is disabled in the preferences. preferences-periodically-sync-media = Periodically sync media preferences-please-restart-anki-to-complete-language = Please restart Anki to complete language change. preferences-preferences = Preferences preferences-scheduling = Scheduling preferences-show-learning-cards-with-larger-steps = Show learning cards with larger steps before reviews preferences-show-next-review-time-above-answer = Show next review time above answer buttons preferences-spacebar-rates-card = Spacebar (or enter) also answers card preferences-show-play-buttons-on-cards-with = Show play buttons on cards with audio preferences-show-remaining-card-count = Show remaining card count preferences-some-settings-will-take-effect-after = Some settings will take effect after you restart Anki. preferences-tab-synchronisation = Synchronization preferences-synchronize-audio-and-images-too = Synchronize audio and images too preferences-login-successful-sync-now = Log-in successful. Save preferences and sync now? preferences-timebox-time-limit = Timebox time limit preferences-user-interface-size = User interface size preferences-when-adding-default-to-current-deck = When adding, default to current deck preferences-you-can-restore-backups-via-fileswitch = You can restore backups via File > Switch Profile. preferences-legacy-timezone-handling = Legacy timezone handling (buggy, but required for AnkiDroid <= 2.14) preferences-default-search-text = Default search text preferences-default-search-text-example = e.g. "deck:current" preferences-theme = Theme preferences-theme-follow-system = Follow System preferences-theme-light = Light preferences-theme-dark = Dark preferences-v3-scheduler = V3 scheduler preferences-updates = Updates preferences-check-for-updates = Check for program updates preferences-check-for-addon-updates = Check for add-on updates preferences-ignore-accents-in-search = Ignore accents in search (slower) preferences-backup-explanation = Anki periodically backs up your collection. After backups are more than 2 days old, Anki will start removing some of them to free up disk space. preferences-daily-backups = Daily backups to keep: preferences-weekly-backups = Weekly backups to keep: preferences-monthly-backups = Monthly backups to keep: preferences-minutes-between-backups = Minutes between automatic backups: preferences-reduce-motion = Reduce motion preferences-reduce-motion-tooltip = Disable various animations and transitions of the user interface preferences-custom-sync-url = Self-hosted sync server preferences-custom-sync-url-disclaimer = For advanced users - please see the manual preferences-hide-top-bar-during-review = Hide top bar during review preferences-hide-bottom-bar-during-review = Hide bottom bar during review preferences-always = Always preferences-full-screen-only = Full screen only preferences-appearance = Appearance preferences-general = General preferences-style = Style preferences-review = Review preferences-answer-keys = Answer keys preferences-distractions = Distractions preferences-minimalist-mode = Minimalist mode preferences-minimalist-mode-tooltip = Make the interface more compact/less fancy preferences-editing = Editing preferences-browsing = Browsing preferences-default-deck = Default deck preferences-account = AnkiWeb Account preferences-note = Note preferences-scheduler = Scheduler preferences-user-interface = User Interface preferences-import-export = Import/Export preferences-network-timeout = Network timeout preferences-reset-window-sizes = Reset Window Sizes preferences-reset-window-sizes-complete = Window sizes and locations have been reset. preferences-shortcut-placeholder = Enter an unused shortcut key, or leave empty to disable. preferences-third-party-services = Third-Party Services preferences-ankihub-not-logged-in = Not currently logged in to AnkiHub. preferences-ankiweb-intro = AnkiWeb is a free service that lets you keep your flashcard data in sync across your devices, and provides a way to recover the data if your device breaks or is lost. preferences-ankihub-intro = AnkiHub provides collaborative deck editing and additional study tools. A paid subscription is required to access certain features. preferences-third-party-description = Third-party services are unaffiliated with and not endorsed by Anki. Use of these services may require payment. ## URL scheme related preferences-url-schemes = URL Schemes preferences-url-scheme-prompt = Allowed URL Schemes (space-separated): preferences-url-scheme-warning = Blocked attempt to open `{ $link }`, which may be a security issue. If you trust the deck author and wish to proceed, you can add `{ $scheme }` to your allowed URL Schemes. preferences-url-scheme-allow-once = Allow Once preferences-url-scheme-always-allow = Always Allow ## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future. preferences-basic = Basic preferences-reviewer = Reviewer preferences-media = Media preferences-not-logged-in = Not currently logged in to AnkiWeb. ================================================ FILE: ftl/core/profiles.ftl ================================================ profiles-anki-could-not-read-your-profile = Anki could not read your profile data. Window sizes and your sync login details have been forgotten. profiles-anki-could-not-rename-your-profile = Anki could not rename your profile because it could not rename the profile folder on disk. Please ensure you have permission to write to Documents/Anki and no other programs are accessing your profile folders, then try again. profiles-folder-already-exists = Folder already exists. profiles-open = Open profiles-open-backup = Open Backup... profiles-please-remove-the-folder-and = Please remove the folder { $val } and try again. profiles-profile-corrupt = Profile Corrupt profiles-profiles = Profiles profiles-quit = Quit profiles-user-1 = User 1 profiles-confirm-lang-choice = Are you sure you wish to display Anki's interface in { $lang }? profiles-could-not-create-data-folder = Anki could not create its data folder. Please see the File Locations section of the manual, and ensure that location is not read-only. profiles-prefs-corrupt-title = Preferences Corrupt profiles-prefs-file-is-corrupt = Anki's prefs21.db file was corrupt and has been recreated. If you were using multiple profiles, please add them back using the same names to recover your cards. profiles-profile-does-not-exist = Requested profile does not exist. profiles-creating-backup = Creating Backup... profiles-backup-created = Backup created. profiles-backup-creation-failed = Backup creation failed: { $reason } profiles-backup-unchanged = No changes since latest backup. ================================================ FILE: ftl/core/scheduling.ftl ================================================ ## The next time a card will be shown, in a short form that will fit ## on the answer buttons. For example, English shows "4d" to ## represent the card will be due in 4 days, "3m" for 3 minutes, and ## "5mo" for 5 months. scheduling-answer-button-time-seconds = { $amount }s scheduling-answer-button-time-minutes = { $amount }m scheduling-answer-button-time-hours = { $amount }h scheduling-answer-button-time-days = { $amount }d scheduling-answer-button-time-months = { $amount }mo scheduling-answer-button-time-years = { $amount }y ## A span of time, such as the delay until a card is shown again, the ## amount of time taken to answer a card, and so on. It is used by itself, ## such as in the Interval column of the browse screen, ## and labels like "Total Time" in the card info screen. scheduling-time-span-seconds = { $amount -> [one] { $amount } second *[other] { $amount } seconds } scheduling-time-span-minutes = { $amount -> [one] { $amount } minute *[other] { $amount } minutes } scheduling-time-span-hours = { $amount -> [one] { $amount } hour *[other] { $amount } hours } scheduling-time-span-days = { $amount -> [one] { $amount } day *[other] { $amount } days } scheduling-time-span-months = { $amount -> [one] { $amount } month *[other] { $amount } months } scheduling-time-span-years = { $amount -> [one] { $amount } year *[other] { $amount } years } ## Shown in the "Congratulations!" message after study finishes. # eg "The next learning card will be ready in 5 minutes." scheduling-next-learn-due = The next learning card will be ready in { $unit -> [seconds] { $amount -> [one] { $amount } second *[other] { $amount } seconds } [minutes] { $amount -> [one] { $amount } minute *[other] { $amount } minutes } *[hours] { $amount -> [one] { $amount } hour *[other] { $amount } hours } }. scheduling-learn-remaining = { $remaining -> [one] There is one remaining learning card due later today. *[other] There are { $remaining } learning cards due later today. } scheduling-congratulations-finished = Congratulations! You have finished this deck for now. scheduling-today-review-limit-reached = Today's review limit has been reached, but there are still cards waiting to be reviewed. For optimum memory, consider increasing the daily limit in the options. scheduling-today-new-limit-reached = There are more new cards available, but the daily limit has been reached. You can increase the limit in the options, but please bear in mind that the more new cards you introduce, the higher your short-term review workload will become. scheduling-buried-cards-found = One or more cards were buried, and will be shown tomorrow. You can { $unburyThem } if you wish to see them immediately. # used in scheduling-buried-cards-found # "... you can unbury them if you wish to see..." scheduling-unbury-them = unbury them scheduling-how-to-custom-study = If you wish to study outside of the regular schedule, you can use the { $customStudy } feature. # used in scheduling-how-to-custom-study # "... you can use the custom study feature." scheduling-custom-study = custom study ## Scheduler upgrade scheduling-update-soon = Anki 2.1 comes with a new scheduler, which fixes a number of issues that previous Anki versions had. Updating to it is recommended. scheduling-update-done = Scheduler updated successfully. scheduling-update-button = Update scheduling-update-later-button = Later scheduling-update-more-info-button = Learn More scheduling-update-required = Your collection needs to be upgraded to the V2 scheduler. Please select { scheduling-update-more-info-button } before proceeding. ## Other scheduling strings scheduling-always-include-question-side-when-replaying = Always include question side when replaying audio scheduling-at-least-one-step-is-required = At least one step is required. scheduling-automatically-play-audio = Automatically play audio scheduling-bury-related-new-cards-until-the = Bury related new cards until the next day scheduling-bury-related-reviews-until-the-next = Bury related reviews until the next day scheduling-days = days scheduling-description = Description scheduling-easy-bonus = Easy bonus scheduling-easy-interval = Easy interval scheduling-end = (end) scheduling-general = General scheduling-graduating-interval = Graduating interval scheduling-hard-interval = Hard interval scheduling-ignore-answer-times-longer-than = Ignore answer times longer than scheduling-interval-modifier = Interval modifier scheduling-lapses = Lapses scheduling-lapses2 = lapses scheduling-learning = Learning scheduling-leech-action = Leech action scheduling-leech-threshold = Leech threshold scheduling-maximum-interval = Maximum interval scheduling-maximum-reviewsday = Maximum reviews/day scheduling-minimum-interval = Minimum interval scheduling-mix-new-cards-and-reviews = Mix new cards and reviews scheduling-new-cards = New Cards scheduling-new-cardsday = New cards/day scheduling-new-interval = New interval scheduling-new-options-group-name = New options group name: scheduling-options-group = Options group: scheduling-order = Order scheduling-parent-limit = (parent limit: { $val }) scheduling-reset-counts = Reset repetition and lapse counts scheduling-restore-position = Restore original position where possible scheduling-review = Review scheduling-reviews = Reviews scheduling-seconds = seconds scheduling-set-all-decks-below-to = Set all decks below { $val } to this option group? scheduling-set-for-all-subdecks = Set for all subdecks scheduling-show-answer-timer = Show on-screen timer scheduling-show-new-cards-after-reviews = Show new cards after reviews scheduling-show-new-cards-before-reviews = Show new cards before reviews scheduling-show-new-cards-in-order-added = Show new cards in order added scheduling-show-new-cards-in-random-order = Show new cards in random order scheduling-starting-ease = Starting ease scheduling-steps-in-minutes = Steps (in minutes) scheduling-steps-must-be-numbers = Steps must be numbers. scheduling-tag-only = Tag Only scheduling-the-default-configuration-cant-be-removed = The default configuration can't be removed. scheduling-your-changes-will-affect-multiple-decks = Your changes will affect multiple decks. If you wish to change only the current deck, please add a new options group first. scheduling-deck-updated = { $count -> [one] { $count } deck updated. *[other] { $count } decks updated. } scheduling-set-due-date-prompt = { $cards -> [one] Show card in how many days? *[other] Show cards in how many days? } scheduling-set-due-date-prompt-hint = 0 = today 1! = tomorrow + change interval to 1 3-7 = random choice of 3-7 days scheduling-set-due-date-done = { $cards -> [one] Set due date of { $cards } card. *[other] Set due date of { $cards } cards. } scheduling-graded-cards-done = { $cards -> [one] Graded { $cards } card. *[other] Graded { $cards } cards. } scheduling-forgot-cards = { $cards -> [one] Reset { $cards } card. *[other] Reset { $cards } cards. } ================================================ FILE: ftl/core/search.ftl ================================================ ## Errors shown when invalid search input is encountered. ## Backticks change the text formatting, so please don't change the backticks. ## Text inside backticks should not be changed unless noted. ## It's ok to change quotes outside of backticks however, eg: ## "`{ $context }`" => 「`{ $context }`」 search-invalid-search = Invalid search: { $reason } search-misplaced-and = an `and` was found but it is not connecting two search terms. If you want to search for the word itself, wrap it in double quotes: `"and"`. search-misplaced-or = an `or` was found but it is not connecting two search terms. If you want to search for the word itself, wrap it in double quotes: `"or"`. # Here, the ellipsis "..." may be localised. search-empty-group = a group `(...)` was found, but there was nothing between the brackets to search for. If you want to search for literal brackets, wrap them in double quotes: `"( )"`. search-unopened-group = a closing bracket `)` was found, but there was no opening bracket `(` preceding it. If you want to search for a literal `)`, wrap it in double quotes or prepend a backslash: `")"` or `\)`. search-unclosed-group = an opening bracket `(` was found, but there was no closing bracket `)` following it. If you want to search for a literal `(`, wrap it in double quotes or prepend a backslash: `"("` or `\(` . search-empty-quote = a pair of double quotes `""` was found, but there was nothing between them to search for. If you want to search for literal double quotes, prepend backslashes: `\"\"`. search-unclosed-quote = an opening double quote `"` was found, but there was no second one to close it. If you want to search for a literal `"`, prepend a backslash: `\"`. search-missing-key = a colon `:` was found, but there was no keyword preceding it. If you want to search for a literal `:`, prepend a backslash: `\:`. search-unknown-escape = the escape sequence `{ $val }` is not defined. If you want to search for a literal backslash `\`, prepend another one: `\\`. search-invalid-argument = `{ $term }` was given an invalid argument '`{ $argument }`'. search-invalid-flag-2 = `flag:` must be followed by a valid flag number: `1` (red), `2` (orange), `3` (green), `4` (blue), `5` (pink), `6` (turquoise), `7` (purple) or `0` (no flag). search-invalid-prop-operator = `prop:{ $val }` must be followed by one of the following comparison operators: `=`, `!=`, `<`, `>`, `<=` or `>=`. search-invalid-other = please check for typing mistakes. ## eg. expected a number in "due>5x", but found "5x" search-invalid-number = expected a number in "`{ $context }`", but found "`{ $provided }`". search-invalid-whole-number = expected a whole number in "`{ $context }`", but found "`{ $provided }`". search-invalid-positive-whole-number = expected a positive whole number in "`{ $context }`", but found "`{ $provided }`". search-invalid-negative-whole-number = expected a whole number less than or equal to 0 in "`{ $context }`", but found "`{ $provided }`". search-invalid-answer-button = expected an answer button between 1-4 in "`{ $context }`", but found "`{ $provided }`". ## Column labels in browse screen search-note-modified = Note Modified search-card-modified = Card Modified ## # Tooltip for search lines outside browser search-view-in-browser = View in browser ================================================ FILE: ftl/core/statistics.ftl ================================================ # The date a card will be ready to review statistics-due-date = Due # The count of cards waiting to be reviewed statistics-due-count = Due # Shown in the Due column of the Browse screen when the card is a new card statistics-due-for-new-card = New #{ $number } ## eg 16.8s (3.6 cards/minute) statistics-cards-per-min = { $cards-per-minute } cards/minute statistics-average-answer-time = { $average-seconds }s ({ statistics-cards-per-min }) ## A span of time studying took place in, for example ## "(studied 30 cards) in 3 minutes" statistics-in-time-span-seconds = { $amount -> [one] in { $amount } second *[other] in { $amount } seconds } statistics-in-time-span-minutes = { $amount -> [one] in { $amount } minute *[other] in { $amount } minutes } statistics-in-time-span-hours = { $amount -> [one] in { $amount } hour *[other] in { $amount } hours } statistics-in-time-span-days = { $amount -> [one] in { $amount } day *[other] in { $amount } days } statistics-in-time-span-months = { $amount -> [one] in { $amount } month *[other] in { $amount } months } statistics-in-time-span-years = { $amount -> [one] in { $amount } year *[other] in { $amount } years } # Shown at the bottom of the deck list, and in the statistics screen. # eg "Studied 3 cards in 13 seconds today (4.33s/card)." # The { statistics-in-time-span-seconds } part should be pasted in from the English # version unmodified. statistics-studied-today = Studied { statistics-cards } { $unit -> [seconds] { statistics-in-time-span-seconds } [minutes] { statistics-in-time-span-minutes } [hours] { statistics-in-time-span-hours } [days] { statistics-in-time-span-days } [months] { statistics-in-time-span-months } *[years] { statistics-in-time-span-years } } today ({ $secs-per-card }s/card) ## statistics-cards = { $cards -> [one] { $cards } card *[other] { $cards } cards } statistics-notes = { $notes -> [one] { $notes } note *[other] { $notes } notes } # a count of how many cards have been answered, eg "Total: 34 reviews" statistics-reviews = { $reviews -> [one] { $reviews } review *[other] { $reviews } reviews } # This fragment of the tooltip in the FSRS simulation # diagram (Deck options -> FSRS) shows the total number of # cards that can be recalled or retrieved on a specific date. statistics-memorized = {$memorized} cards memorized statistics-today-title = Today statistics-today-again-count = Again count: statistics-today-type-counts = Learn: { $learnCount }, Review: { $reviewCount }, Relearn: { $relearnCount }, Filtered: { $filteredCount } statistics-today-no-cards = No cards have been studied today. statistics-today-no-mature-cards = No mature cards were studied today. statistics-today-correct-mature = Correct answers on mature cards: { $correct }/{ $total } ({ $percent }%) statistics-counts-total-cards = Total statistics-counts-new-cards = New statistics-counts-young-cards = Young statistics-counts-mature-cards = Mature statistics-counts-suspended-cards = Suspended statistics-counts-buried-cards = Buried statistics-counts-filtered-cards = Filtered statistics-counts-learning-cards = Learning statistics-counts-relearning-cards = Relearning statistics-counts-title = Card Counts statistics-counts-separate-suspended-buried-cards = Separate suspended/buried cards ## Retention represents your actual retention from past reviews, in ## comparison to the "desired retention" setting of FSRS, which forecasts ## future retention. Retention is the percentage of all reviewed cards ## that were marked as "Hard," "Good," or "Easy" within a specific time period. ## ## Most of these strings are used as column / row headings in a table. ## (Excluding -title and -subtitle) ## It is important to keep these translations short so that they do not make ## the table too large to display on a single stats card. ## ## N.B. Stats cards may be very small on mobile devices and when the Stats ## window is certain sizes. statistics-true-retention-title = Retention statistics-true-retention-subtitle = Pass rate of cards with an interval ≥ 1 day. statistics-true-retention-tooltip = If you are using FSRS, your retention is expected to be close to your desired retention. Please keep in mind that data for a single day is noisy, so it's better to look at monthly data. statistics-true-retention-range = Range statistics-true-retention-pass = Pass statistics-true-retention-fail = Fail # This will usually be the same as statistics-counts-total-cards statistics-true-retention-total = Total statistics-true-retention-count = Count statistics-true-retention-retention = Retention # This will usually be the same as statistics-counts-young-cards statistics-true-retention-young = Young # This will usually be the same as statistics-counts-mature-cards statistics-true-retention-mature = Mature statistics-true-retention-all = All statistics-true-retention-today = Today statistics-true-retention-yesterday = Yesterday statistics-true-retention-week = Last week statistics-true-retention-month = Last month statistics-true-retention-year = Last year statistics-true-retention-all-time = All time # If there are no reviews within a specific time period, the retention # percentage cannot be calculated and is displayed as "N/A." statistics-true-retention-not-applicable = N/A ## statistics-range-all-time = all statistics-range-1-year-history = last 12 months statistics-range-all-history = all history statistics-range-deck = deck statistics-range-collection = collection statistics-range-search = Search statistics-card-ease-title = Card Ease statistics-card-difficulty-title = Card Difficulty statistics-card-stability-title = Card Stability statistics-card-stability-subtitle = The delay at which retrievability falls to 90%. statistics-median-stability = Median stability statistics-card-retrievability-title = Card Retrievability statistics-card-ease-subtitle = The lower the ease, the more frequently a card will appear. statistics-card-difficulty-subtitle2 = The higher the difficulty, the slower stability will increase. statistics-retrievability-subtitle = The probability of recalling a card today. # eg "3 cards with 150-170% ease" statistics-card-ease-tooltip = { $cards -> [one] { $cards } card with { $percent } ease *[other] { $cards } cards with { $percent } ease } statistics-card-difficulty-tooltip = { $cards -> [one] { $cards } card with { $percent } difficulty *[other] { $cards } cards with { $percent } difficulty } statistics-retrievability-tooltip = { $cards -> [one] { $cards } card with { $percent } retrievability *[other] { $cards } cards with { $percent } retrievability } statistics-future-due-title = Future Due statistics-future-due-subtitle = The number of reviews due in the future. statistics-added-title = Added statistics-added-subtitle = The number of new cards you have added. statistics-reviews-count-subtitle = The number of questions you have answered. statistics-reviews-time-subtitle = The time taken to answer the questions. statistics-answer-buttons-title = Answer Buttons # eg Button: 4 statistics-answer-buttons-button-number = Button # eg Times pressed: 123 statistics-answer-buttons-button-pressed = Times pressed statistics-answer-buttons-subtitle = The number of times you have pressed each button. statistics-reviews-title = Reviews statistics-reviews-time-checkbox = Time statistics-in-days-single = { $days -> [0] Today [1] Tomorrow *[other] In { $days } days } statistics-in-days-range = In { $daysStart }-{ $daysEnd } days statistics-days-ago-single = { $days -> [1] Yesterday *[other] { $days } days ago } statistics-days-ago-range = { $daysStart }-{ $daysEnd } days ago statistics-running-total = Running total statistics-cards-due = { $cards -> [one] { $cards } card due *[other] { $cards } cards due } statistics-backlog-checkbox = Backlog statistics-intervals-title = Review Intervals statistics-intervals-subtitle = Delays until review cards are shown again. statistics-intervals-day-range = { $cards -> [one] { $cards } card with a { $daysStart }~{ $daysEnd } day interval *[other] { $cards } cards with a { $daysStart }~{ $daysEnd } day interval } statistics-intervals-day-single = { $cards -> [one] { $cards } card with a { $day } day interval *[other] { $cards } cards with a { $day } day interval } statistics-stability-day-range = { $cards -> [one] { $cards } card with a { $daysStart }~{ $daysEnd } day stability *[other] { $cards } cards with a { $daysStart }~{ $daysEnd } day stability } statistics-stability-day-single = { $cards -> [one] { $cards } card with a { $day } day stability *[other] { $cards } cards with a { $day } day stability } # hour range, eg "From 14:00-15:00" statistics-hours-range = From { $hourStart }:00~{ $hourEnd }:00 statistics-hours-correct = { $correct }/{ $total } correct ({ $percent }%) statistics-hours-correct-info = → (not 'Again') # the emoji depicts the graph displaying this number statistics-hours-reviews = 📊 { $reviews } reviews # the emoji depicts the graph displaying this number statistics-hours-correct-reviews = 📈 { $percent }% correct ({ $reviews }) statistics-hours-title = Hourly Breakdown statistics-hours-subtitle = Review success rate for each hour of the day. # shown when graph is empty statistics-no-data = NO DATA statistics-calendar-title = Calendar ## An amount of elapsed time, used in the graphs to show the amount of ## time spent studying. For example, English would show "5s" for 5 seconds, ## "13.5m" for 13.5 minutes, and so on. ## ## Please try to keep the text short, as longer text may get cut off. statistics-elapsed-time-seconds = { $amount }s statistics-elapsed-time-minutes = { $amount }m statistics-elapsed-time-hours = { $amount }h statistics-elapsed-time-days = { $amount }d statistics-elapsed-time-months = { $amount }mo statistics-elapsed-time-years = { $amount }y ## statistics-average-for-days-studied = Average for days studied # This term is used in a variety of contexts to refers to the total amount of # items (e.g., cards, mature cards, etc) for a given period, rather than the # total of all existing items. statistics-total = Total statistics-days-studied = Days studied statistics-average-answer-time-label = Average answer time statistics-average = Average statistics-median-interval = Median interval statistics-due-tomorrow = Due tomorrow # This string, ‘Daily load,’ appears in the ‘Future due’ table and represents a # forecasted estimate of the number of cards expected to be reviewed daily in # the future. Unlike the other strings in the table that display actual data # derived from the current scheduling (e.g., ‘Average’, ‘Due tomorrow’), # ‘Daily load’ is a projection based on the given data. statistics-daily-load = Daily load # eg 5 of 15 (33.3%) statistics-amount-of-total-with-percentage = { $amount } of { $total } ({ $percent }%) statistics-average-over-period = Average over period statistics-reviews-per-day = { $count -> [one] { $count } review/day *[other] { $count } reviews/day } statistics-minutes-per-day = { $count -> [one] { $count } minute/day *[other] { $count } minutes/day } statistics-cards-per-day = { $count -> [one] { $count } card/day *[other] { $count } cards/day } statistics-median-ease = Median ease statistics-median-difficulty = Median difficulty statistics-average-retrievability = Average retrievability statistics-estimated-total-knowledge = Estimated total knowledge statistics-save-pdf = Save PDF statistics-saved = Saved. statistics-stats = stats statistics-title = Statistics ## These strings are no longer used - you do not need to translate them if they ## are not already translated. statistics-average-stability = Average stability statistics-average-interval = Average interval statistics-average-ease = Average ease statistics-average-difficulty = Average difficulty ================================================ FILE: ftl/core/studying.ftl ================================================ studying-again = Again studying-all-buried-cards = All Buried Cards studying-audio-5s = Audio -5s studying-audio-and5s = Audio +5s studying-buried-siblings = Buried Siblings studying-bury = Bury studying-bury-card = Bury Card studying-bury-note = Bury Note studying-card-suspended = Card suspended. studying-card-was-a-leech = Card was a leech. studying-cards-buried = { $count -> [one] { $count } card buried. *[other] { $count } cards buried. } studying-cards-will-be-automatically-returned-to = Cards will be automatically returned to their original decks after you review them. studying-continue = Continue studying-counts-differ = Counts differ from the deck list, because burying is enabled. Some cards have been excluded, and others may have taken their place. studying-delete-note = Delete Note studying-deleting-this-deck-from-the-deck = Deleting this deck from the deck list will return all remaining cards to their original deck. studying-easy = Easy studying-edit = Edit studying-empty = Empty studying-finish = Finish studying-flag-card = Flag Card studying-good = Good studying-hard = Hard studying-it-has-been-suspended = It has been suspended. studying-manually-buried-cards = Manually Buried Cards studying-mark-note = Mark Note studying-more = More studying-no-cards-are-due-yet = No cards are due yet. studying-note-suspended = Note suspended. studying-pause-audio = Pause Audio studying-please-run-toolsempty-cards = Please run Tools>Empty Cards studying-record-own-voice = Record Own Voice studying-replay-own-voice = Replay Own Voice studying-show-answer = Show Answer studying-space = Space studying-study-now = Study Now studying-suspend = Suspend studying-suspend-note = Suspend Note studying-this-is-a-special-deck-for = This is a special deck for studying outside of the normal schedule. studying-to-review = To Review studying-type-answer-unknown-field = Type answer: unknown field { $val } studying-unbury = Unbury studying-what-would-you-like-to-unbury = What would you like to unbury? studying-you-havent-recorded-your-voice-yet = You haven't recorded your voice yet. studying-card-studied-in-minute = { $cards -> [one] { $cards } card *[other] { $cards } cards } studied in { $minutes -> [one] { $minutes } minute. *[other] { $minutes } minutes. } studying-question-time-elapsed = Question time elapsed studying-answer-time-elapsed = Answer time elapsed ## OBSOLETE; you do not need to translate this studying-card-studied-in = { $count -> [one] { $count } card studied in *[other] { $count } cards studied in } studying-minute = { $count -> [one] { $count } minute. *[other] { $count } minutes. } ================================================ FILE: ftl/core/sync.ftl ================================================ ### Messages shown when synchronizing with AnkiWeb. ## Media synchronization sync-media-added-count = Added: { $up }↑ { $down }↓ sync-media-removed-count = Removed: { $up }↑ { $down }↓ sync-media-checked-count = Checked: { $count } sync-media-starting = Media sync starting... sync-media-complete = Media sync complete. sync-media-failed = Media sync failed. sync-media-aborting = Media sync aborting... sync-media-aborted = Media sync aborted. # Shown in the sync log to indicate media syncing will not be done, because it # was previously disabled by the user in the preferences screen. sync-media-disabled = Media sync disabled. # Title of the screen that shows syncing progress history sync-media-log-title = Media Sync Log ## Error messages / dialogs sync-conflict = Only one copy of Anki can sync to your account at once. Please wait a few minutes, then try again. sync-server-error = AnkiWeb encountered a problem. Please try again in a few minutes. sync-client-too-old = Your Anki version is too old. Please update to the latest version to continue syncing. sync-wrong-pass = Email or password was incorrect; please try again. sync-resync-required = Please sync again. If this message keeps appearing, please post on the support site. sync-must-wait-for-end = Anki is currently syncing. Please wait for the sync to complete, then try again. sync-confirm-empty-download = Local collection has no cards. Download from AnkiWeb? sync-confirm-empty-upload = AnkiWeb collection has no cards. Replace it with local collection? sync-conflict-explanation = Your decks here and on AnkiWeb differ in such a way that they can't be merged together, so it's necessary to overwrite the decks on one side with the decks from the other. If you choose download, Anki will fetch the collection from AnkiWeb, and any changes you have made on this device since the last sync will be lost. If you choose upload, Anki will send this device's data to AnkiWeb, and any changes that are waiting on AnkiWeb will be lost. After all devices are in sync, future reviews and added cards can be merged automatically. sync-conflict-explanation2 = There is a conflict between decks on this device and AnkiWeb. You must choose which version to keep: - Select **{ sync-download-from-ankiweb }** to replace decks here with AnkiWeb’s version. You will lose any changes you made on this device since your last sync. - Select **{ sync-upload-to-ankiweb }** to overwrite AnkiWeb’s versions with decks from this device, and delete any changes on AnkiWeb. Once the conflict is resolved, syncing will work as usual. sync-ankiweb-id-label = Email: sync-password-label = Password: sync-account-required =

Account Required

A free account is required to keep your collection synchronized. Please sign up for an account, then enter your details below. sync-sanity-check-failed = Please use the Check Database function, then sync again. If problems persist, please force a one-way sync in the preferences screen. sync-clock-off = Unable to sync - your clock is not set to the correct time. # “details” expands to a string such as “300.14 MB > 300.00 MB” sync-upload-too-large = Your collection file is too large to send to AnkiWeb. You can reduce its size by removing any unwanted decks (optionally exporting them first), and then using Check Database to shrink the file size down. { $details } (uncompressed) sync-sign-in = Sign in sync-ankihub-dialog-heading = AnkiHub Login sync-ankihub-username-label = Username or Email: sync-ankihub-login-failed = Unable to log in to AnkiHub with the provided credentials. sync-ankihub-addon-installation = AnkiHub Add-on Installation ## Buttons sync-media-log-button = Media Log sync-abort-button = Abort sync-download-from-ankiweb = Download from AnkiWeb sync-upload-to-ankiweb = Upload to AnkiWeb sync-cancel-button = Cancel ## Normal sync progress sync-downloading-from-ankiweb = Downloading from AnkiWeb... sync-uploading-to-ankiweb = Uploading to AnkiWeb... sync-syncing = Syncing... sync-checking = Checking... sync-connecting = Connecting... sync-added-updated-count = Added/modified: { $up }↑ { $down }↓ sync-log-in-button = Log In sync-log-out-button = Log Out sync-collection-complete = Collection sync complete. ================================================ FILE: ftl/core/undo.ftl ================================================ ### The strings in this file are currently in development, ### and you may want to skip translating them for now. undo-undo = Undo undo-redo = Redo # eg "Undo Answer Card" undo-undo-action = Undo { $val } # eg "Answer Card Undone" undo-action-undone = { $action } undone undo-redo-action = Redo { $action } undo-action-redone = { $action } redone ================================================ FILE: ftl/ftl ================================================ #!/bin/bash cd $(dirname $0)/.. cargo run -p ftl -- $* ================================================ FILE: ftl/move-from-ankimobile ================================================ #!/bin/bash # # Move a translation that previously only existed in AnkiMobile to the core translations. # ./ftl string move ftl/mobile-repo/mobile ftl/core-repo/core $* ================================================ FILE: ftl/qt/about.ftl ================================================ about-a-big-thanks-to-all-the = A big thanks to all the people who have provided suggestions, bug reports and donations. about-about-anki = About Anki about-anki-is-a-friendly-intelligent-spaced = Anki is a friendly, intelligent spaced learning system. It's free and open source. about-anki-is-licensed-under-the-agpl3 = Anki is licensed under the AGPL3 license. Please see the license file in the source distribution for more information. about-copied-to-clipboard = Copied to clipboard about-copy-debug-info = Copy Debug Info about-if-you-have-contributed-and-are = If you have contributed and are not on this list, please get in touch. about-version = Version { $val } about-visit-website = Visit website about-written-by-damien-elmes-with-patches = Written by Damien Elmes, with patches, translation, testing and design from:

{ $cont } # appended to the end of the contributor list in the about screen about-and-others = and others ================================================ FILE: ftl/qt/addons.ftl ================================================ addons-possibly-involved = Add-ons possibly involved: { $addons } addons-failed-to-load = An add-on you installed failed to load. If problems persist, please go to the Tools>Add-ons menu, and disable or delete the add-on. When loading '{ $name }': { $traceback } addons-failed-to-load2 = The following add-ons failed to load: { $addons } They may need to be updated to support this version of Anki. Click the { addons-check-for-updates } button to see if any updates are available. You can use the { about-copy-debug-info } button to get information that you can paste in a report to the add-on author. For add-ons that don't have an update available, you can disable or delete the add-on to prevent this message from appearing. addons-startup-failed = Add-on Startup Failed # Shown in the add-on configuration screen (Tools>Add-ons>Config), in the title bar addons-config-window-title = Configure '{ $name }' addons-config-validation-error = There was a problem with the provided configuration: { $problem }, at path { $path }, against schema { $schema }. addons-window-title = Add-ons addons-addon-has-no-configuration = Add-on has no configuration. addons-addon-installation-error = Add-on installation error addons-browse-addons = Browse Add-ons addons-changes-will-take-effect-when-anki = Changes will take effect when Anki is restarted. addons-check-for-updates = Check for Updates addons-checking = Checking... addons-code = Code: addons-config = Config addons-configuration = Configuration addons-corrupt-addon-file = Corrupt add-on file. addons-disabled = (disabled) addons-disabled2 = (disabled) addons-download-complete-please-restart-anki-to = Download complete. Please restart Anki to apply changes. addons-downloaded-fnames = Downloaded { $fname } addons-downloading-adbd-kb02fkb = Downloading { $part }/{ $total } ({ $kilobytes }KB)... addons-error-downloading-ids-errors = Error downloading { $id }: { $error } addons-error-installing-bases-errors = Error installing { $base }: { $error } addons-get-addons = Get Add-ons... addons-important-as-addons-are-programs-downloaded = Important: As add-ons are programs downloaded from the internet, they are potentially malicious.You should only install add-ons you trust.

Are you sure you want to proceed with the installation of the following Anki add-on(s)?

%(names)s addons-install-addon = Install Add-on addons-install-addons = Install Add-on(s) addons-install-anki-addon = Install Anki add-on addons-install-from-file = Install from file... addons-installation-complete = Installation complete addons-installed-names = Installed { $name } addons-installed-successfully = Installed successfully. addons-invalid-addon-manifest = Invalid add-on manifest. addons-invalid-code = Invalid code. addons-invalid-code-or-addon-not-available = Invalid code, or add-on not available for your version of Anki. addons-invalid-configuration = Invalid configuration: addons-invalid-configuration-top-level-object-must = Invalid configuration: top level object must be a map addons-no-updates-available = No updates available. addons-one-or-more-errors-occurred = One or more errors occurred: addons-packaged-anki-addon = Packaged Anki Add-on addons-please-check-your-internet-connection = Please check your internet connection. addons-please-report-this-to-the-respective = Please report this to the respective add-on author(s). addons-please-restart-anki-to-complete-the = Please restart Anki to complete the installation. addons-please-select-a-single-addon-first = Please select a single add-on first. addons-requires = (requires { $val }) addons-restored-defaults = Restored defaults addons-the-following-addons-are-incompatible-with = The following add-ons are incompatible with { $name } and have been disabled: { $found } addons-the-following-addons-have-updates-available = The following add-ons have updates available. Install them now? addons-the-following-conflicting-addons-were-disabled = The following conflicting add-ons were disabled: addons-this-addon-is-not-compatible-with = This add-on is not compatible with your version of Anki. addons-to-browse-addons-please-click-the = To browse add-ons, please click the browse button below.

When you've found an add-on you like, please paste its code below. You can paste multiple codes, separated by spaces. addons-toggle-enabled = Toggle Enabled addons-unable-to-update-or-delete-addon = Unable to update or delete add-on. Please start Anki while holding down the shift key to temporarily disable add-ons, then try again. Debug info: { $val } addons-unknown-error = Unknown error: { $val } addons-view-addon-page = View Add-on Page addons-view-files = View Files addons-delete-the-numd-selected-addon = { $count -> [one] Delete the { $count } selected add-on? *[other] Delete the { $count } selected add-ons? } addons-choose-update-window-title = Update Add-ons addons-choose-update-update-all = Update All ================================================ FILE: ftl/qt/errors.ftl ================================================ -errors-support-site = [support site](https://help.ankiweb.net) errors-standard-popup2 = Anki encountered a problem. Please follow the troubleshooting steps. errors-may-be-addon = The problem may be caused by an add-on. errors-troubleshooting-button = Troubleshooting errors-copy-debug-info-button = Copy Debug Info errors-copied-to-clipboard = Copied to clipboard errors-standard-popup = # Error An error occurred. Please use **Tools > Check Database** to see if that fixes the problem. If problems persist, please report the problem on our { -errors-support-site }. Please copy and paste the information below into your report. errors-addons-active-popup = # Error An error occurred. Please start Anki while holding down the shift key, which will temporarily disable the add-ons you have installed. If the issue only occurs when add-ons are enabled, please use the Tools > Add-ons menu item to disable some add-ons and restart Anki, repeating until you discover the add-on that is causing the problem. When you've discovered the add-on that is causing the problem, please report the issue to the add-on author. Debug info: errors-accessing-db = An error occurred while accessing the database. Possible causes: - Antivirus, firewall, backup, or synchronization software may be interfering with Anki. Try disabling such software and see if the problem goes away. - Your disk may be full. - The Documents/Anki folder may be on a network drive. - Files in the Documents/Anki folder may not be writeable. - Your hard disk may have errors. It's a good idea to run Tools>Check Database to ensure your collection is not corrupt. errors-unable-open-collection = Anki was unable to open your collection file. If problems persist after restarting your computer, please use the Open Backup button in the profile manager. Debug info: errors-windows-tts-runtime-error = The TTS service failed. Please ensure Windows updates are installed, try restarting your computer, or try a different voice. errors-windows-ssl-updates = Secure connection failed. Please ensure Windows updates are installed, then try again. ## OBSOLETE; you do not need to translate this ================================================ FILE: ftl/qt/preferences.ftl ================================================ ## Video drivers/hardware acceleration. Please avoid translating 'OpenGL' and 'ANGLE'. preferences-video-driver = Video driver preferences-video-driver-opengl-mac = OpenGL (recommended on Macs) preferences-video-driver-software-mac = Software (not recommended) preferences-video-driver-opengl-other = OpenGL (faster, may cause issues) preferences-video-driver-software-other = Software (slower) preferences-video-driver-angle = ANGLE (may work better than OpenGL) preferences-video-driver-default = default ================================================ FILE: ftl/qt/profiles.ftl ================================================ profiles-folder-readme = This folder stores all of your Anki data in a single location, to make backups easy. To tell Anki to use a different location, please see: { $link } # will appear as 'Downgrade & Quit' profiles-downgrade-and-quit = Downgrade && Quit ================================================ FILE: ftl/qt/qt-accel.ftl ================================================ qt-accel-about = &About qt-accel-about-mac = About Anki... qt-accel-cards = &Cards qt-accel-check-database = &Check Database qt-accel-check-media = Check &Media qt-accel-edit = &Edit qt-accel-exit = E&xit qt-accel-export = &Export... qt-accel-export-notes = &Export Notes... qt-accel-file = &File qt-accel-filter = Fil&ter qt-accel-find = &Find qt-accel-find-and-replace = Find and Re&place... qt-accel-find-duplicates = Find &Duplicates... qt-accel-go = &Go qt-accel-guide = &Guide qt-accel-help = &Help qt-accel-import = &Import... qt-accel-info = &Info... qt-accel-invert-selection = &Invert Selection qt-accel-next-card = &Next Card qt-accel-note = N&ote qt-accel-notes = &Notes qt-accel-preferences = &Preferences qt-accel-previous-card = &Previous Card qt-accel-select-all = Select &All qt-accel-select-notes = Select &Notes qt-accel-support-anki = &Support Anki qt-accel-switch-profile = &Switch Profile qt-accel-tools = &Tools qt-accel-undo = &Undo qt-accel-redo = &Redo qt-accel-set-due-date = Set &Due Date... qt-accel-forget = &Reset qt-accel-view = &View qt-accel-full-screen = Toggle &Full Screen qt-accel-layout = &Layout qt-accel-layout-auto = &Auto qt-accel-layout-vertical = &Vertical qt-accel-layout-horizontal = &Horizontal qt-accel-zoom-in = Zoom &In qt-accel-zoom-out = Zoom &Out qt-accel-reset-zoom = &Reset Zoom qt-accel-toggle-sidebar = Toggle Sidebar qt-accel-zoom-editor-in = Zoom Editor &In qt-accel-zoom-editor-out = Zoom Editor &Out qt-accel-create-backup = Create &Backup qt-accel-load-backup = &Revert to Backup qt-accel-upgrade-downgrade = Upgrade/Downgrade ================================================ FILE: ftl/qt/qt-misc.ftl ================================================ qt-misc-addon-will-be-installed-when-a = Add-on will be installed when a profile is opened. qt-misc-addons = Add-ons qt-misc-all-cards-notes-and-media-for = All cards, notes, and media for this profile will be deleted. Are you sure? qt-misc-all-cards-notes-and-media-for2 = All cards, notes, and media for the profile "{ $name }" will be deleted. Are you sure? qt-misc-anki-updatedanki-has-been-released =

Anki Updated

Anki { $val } has been released.

qt-misc-automatic-syncing-and-backups-have-been = Backup successfully restored. Automatic syncing and backups have been disabled for now. To enable them again, close the profile or restart Anki. qt-misc-back-side-only = Back Side Only qt-misc-backing-up = Backing Up... qt-misc-browse = Browse qt-misc-change-note-type-ctrlandn = Change Note Type (Ctrl+N) qt-misc-check-the-files-in-the-media = Check the files in the media directory qt-misc-choose-deck = Choose Deck qt-misc-choose-note-type = Choose Note Type qt-misc-closing = Closing... qt-misc-configure-interface-language-and-options = Configure interface language and options qt-misc-copy-to-clipboard = Copy to Clipboard qt-misc-create-filtered-deck = Create Filtered Deck... qt-misc-debug-console = Debug Console qt-misc-deck-will-be-imported-when-a = Deck will be imported when a profile is opened. qt-misc-empty-cards = Empty Cards... qt-misc-error-during-startup = Error during startup: { $val } qt-misc-ignore-this-update = Ignore this update qt-misc-in-order-to-ensure-your-collection = In order to ensure your collection works correctly when moved between devices, Anki requires your computer's internal clock to be set correctly. The internal clock can be wrong even if your system is showing the correct local time.

Please go to the time settings on your computer and check the following:

- AM/PM
- Clock drift
- Day, month and year
- Timezone
- Daylight savings

Difference to correct time: { $val }. qt-misc-invalid-property-found-on-card-please = Invalid property found on card. Please use Tools>Check Database, and if the problem comes up again, please ask on the support site. qt-misc-loading = Loading... qt-misc-manage = Manage qt-misc-manage-note-types = Manage Note Types qt-misc-name-exists = Name exists. qt-misc-non-unicode-text = qt-misc-optimizing = Optimizing... qt-misc-unable-to-record = Unable to record. Please ensure a microphone is connected, and Anki has permission to use the microphone. If other programs are using your microphone, closing them may help. Original error: { $error } qt-misc-please-ensure-a-profile-is-open = Please ensure a profile is open and Anki is not busy, then try again. qt-misc-please-select-1-card = (please select 1 card) qt-misc-please-select-a-deck = Please select a deck. qt-misc-please-use-fileimport-to-import-this = Please use File>Import to import this file. qt-misc-processing = Processing... qt-misc-replace-your-collection-with-an-earlier2 = Replace your collection with an earlier backup from { $val }? qt-misc-revert-to-backup = Revert to backup # please do not change the quote character, and please only change the font name if you have confirmed the new name is a valid Windows font qt-misc-segoe-ui = "Segoe UI" qt-misc-shift-key-was-held-down-skipping = Shift key was held down. Skipping automatic syncing and add-on loading. qt-misc-shortcut-key-left-arrow = Shortcut key: Left arrow qt-misc-shortcut-key-right-arrow-or-enter = Shortcut key: Right arrow or Enter qt-misc-stats = Stats qt-misc-study-deck = Study Deck... qt-misc-sync = Sync qt-misc-target-deck-ctrlandd = Target Deck (Ctrl+D) qt-misc-the-following-character-can-not-be = The following character can not be used: { $val } qt-misc-the-requested-change-will-require-a = The requested change will require a full upload of the database when you next synchronize your collection. If you have reviews or other changes waiting on another device that haven't been synchronized here yet, they will be lost. Continue? qt-misc-there-must-be-at-least-one = There must be at least one profile. qt-misc-this-file-exists-are-you-sure = This file exists. Are you sure you want to overwrite it? qt-misc-unable-to-access-anki-media-folder = Unable to access Anki media folder. The permissions on your system's temporary folder may be incorrect. qt-misc-unexpected-response-code = Unexpected response code: { $val } qt-misc-would-you-like-to-download-it = Would you like to download it now? qt-misc-your-collection-file-appears-to-be = Your collection file appears to be corrupt. This can happen when the file is copied or moved while Anki is open, or when the collection is stored on a network or cloud drive. If problems persist after restarting your computer, please open an automatic backup from the profile screen. qt-misc-your-computers-storage-may-be-full = Your computer's storage may be full. Please delete some unneeded files, then try again. qt-misc-your-firewall-or-antivirus-program-is = Your firewall or antivirus program is preventing Anki from creating a connection to itself. Please add an exception for Anki. qt-misc-error = Error qt-misc-no-temp-folder = No usable temporary folder found. Make sure C:\\temp exists or TEMP in your environment points to a valid, writable folder. qt-misc-incompatible-video-driver = Your video driver is incompatible. Please start Anki again, and Anki will switch to a slower, more compatible mode. qt-misc-error-loading-graphics-driver = Error loading '{ $mode }' graphics driver. Please start Anki again to try the next driver. { $context } qt-misc-anki-is-running = Anki Already Running qt-misc-if-instance-is-not-responding = If the existing instance of Anki is not responding, please close it using your task manager, or restart your computer. qt-misc-second = { $count -> [one] { $count } second *[other] { $count } seconds } qt-misc-layout-auto-enabled = Responsive layout enabled qt-misc-layout-vertical-enabled = Vertical layout enabled qt-misc-layout-horizontal-enabled = Horizontal layout enabled qt-misc-open-anki-launcher = Change to a different Anki version? ## deprecated- these strings will be removed in the future, and do not need ## to be translated qt-misc-replace-your-collection-with-an-earlier = Replace your collection with an earlier backup? ================================================ FILE: ftl/remove-unused.sh ================================================ #!/bin/bash # # To use, run: # # - ./update-ankimobile-usage.sh # - ./remove-unused.sh # # If you need to maintain compatibility with an older stable branch, you # can use ./update-desktop-usage.sh in the older release, then copy the # generated file into usage/ with a different name. # # Caveats: # - Messages are considered in use if they are referenced in other messages, # even if those messages themselves are not in use and going to be deleted. # - Usually, if there is a bug and a message is failed to be recognised as in # use, building will fail. However, this is not true for nested message, for # which only a runtime error will be printed. set -e root=$(realpath $(dirname $0)/..) # update currently used keys ./update-desktop-usage.sh head # then remove unused keys bazel run //rslib/i18n_helpers:garbage_collect_ftl_entries $root/ftl $root/ftl/usage ================================================ FILE: ftl/src/garbage_collection.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashSet; use std::fs; use std::io::BufReader; use std::iter::FromIterator; use std::path::PathBuf; use std::sync::LazyLock; use anki_io::create_file; use anyhow::Context; use anyhow::Result; use clap::Args; use fluent_syntax::ast; use fluent_syntax::ast::Resource; use fluent_syntax::parser; use regex::Regex; use walkdir::DirEntry; use walkdir::WalkDir; use crate::serialize; #[derive(Args)] pub struct WriteJsonArgs { target_filename: PathBuf, source_roots: Vec, } #[derive(Args)] pub struct GarbageCollectArgs { json_root: String, ftl_roots: Vec, } #[derive(Args)] pub struct DeprecateEntriesArgs { #[clap(long, num_args(1..), required(true))] ftl_roots: Vec, #[clap(long, num_args(1..), required(true))] source_roots: Vec, #[clap(long, num_args(1..), required(true))] json_roots: Vec, } const DEPCRATION_WARNING: &str = "NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future."; /// Extract references from all Rust, Python, TS, Svelte, Swift, Kotlin and /// Designer files in the `roots`, convert them to kebab case and write them as /// a json to the target file. pub fn write_ftl_json(args: WriteJsonArgs) -> Result<()> { let refs = gather_ftl_references(&args.source_roots); let mut refs = Vec::from_iter(refs); refs.sort(); serde_json::to_writer_pretty(create_file(args.target_filename)?, &refs) .context("writing json")?; Ok(()) } /// Delete every entry in `ftl_root` that is not mentioned in another message /// or any json in `json_root`. pub fn garbage_collect_ftl_entries(args: GarbageCollectArgs) -> Result<()> { let used_ftls = get_all_used_messages_and_terms(&args.json_root, &args.ftl_roots); strip_unused_ftl_messages_and_terms(&args.ftl_roots, &used_ftls); Ok(()) } /// Moves every entry in `ftl_roots` that is not mentioned in another message, a /// source file or any json in `json_roots` to the bottom of its file below a /// deprecation warning. pub fn deprecate_ftl_entries(args: DeprecateEntriesArgs) -> Result<()> { let mut used_ftls = gather_ftl_references(&args.source_roots); import_messages_from_json(&args.json_roots, &mut used_ftls); extract_nested_messages_and_terms(&args.ftl_roots, &mut used_ftls); deprecate_unused_ftl_messages_and_terms(&args.ftl_roots, &used_ftls); Ok(()) } fn get_all_used_messages_and_terms( json_root: &str, ftl_roots: &[impl AsRef], ) -> HashSet { let mut used_ftls = HashSet::new(); import_messages_from_json(&[json_root], &mut used_ftls); extract_nested_messages_and_terms(ftl_roots, &mut used_ftls); used_ftls } fn for_files_with_ending( roots: &[impl AsRef], file_ending: &str, mut op: impl FnMut(DirEntry), ) { for root in roots { for res in WalkDir::new(root.as_ref()) { let entry = res.expect("failed to visit dir"); if entry.file_type().is_file() && entry .file_name() .to_str() .expect("non-unicode filename") .ends_with(file_ending) { op(entry); } } } } fn gather_ftl_references(roots: &[impl AsRef]) -> HashSet { let mut refs = HashSet::new(); for_files_with_ending(roots, "", |entry| { extract_references_from_file(&mut refs, &entry) }); refs } /// Iterates over all .ftl files in `root`, parses them and rewrites the file if /// `op` decides to return a new AST. fn rewrite_ftl_files( roots: &[impl AsRef], mut op: impl FnMut(Resource<&str>) -> Option>, ) { for_files_with_ending(roots, ".ftl", |entry| { let ftl = fs::read_to_string(entry.path()).expect("failed to open file"); let ast = parser::parse(ftl.as_str()).expect("failed to parse ftl"); if let Some(ast) = op(ast) { fs::write(entry.path(), serialize::serialize(&ast)).expect("failed to write file"); } }); } fn import_messages_from_json(json_roots: &[impl AsRef], entries: &mut HashSet) { for_files_with_ending(json_roots, ".json", |entry| { let buffer = BufReader::new(fs::File::open(entry.path()).expect("failed to open file")); let refs: Vec = serde_json::from_reader(buffer).expect("failed to parse json"); entries.extend(refs); }) } fn extract_nested_messages_and_terms( ftl_roots: &[impl AsRef], used_ftls: &mut HashSet, ) { static REFERENCE: LazyLock = LazyLock::new(|| Regex::new(r"\{\s*-?([-0-9a-z]+)\s*\}").unwrap()); for_files_with_ending(ftl_roots, ".ftl", |entry| { let source = fs::read_to_string(entry.path()).expect("file not readable"); for caps in REFERENCE.captures_iter(&source) { used_ftls.insert(caps[1].to_string()); } }) } fn strip_unused_ftl_messages_and_terms(roots: &[impl AsRef], used_ftls: &HashSet) { rewrite_ftl_files(roots, |mut ast| { let num_entries = ast.body.len(); ast.body.retain(entry_use_check(used_ftls)); (ast.body.len() < num_entries).then_some(ast) }); } fn deprecate_unused_ftl_messages_and_terms(roots: &[impl AsRef], used_ftls: &HashSet) { rewrite_ftl_files(roots, |ast| { let (mut used, mut unused): (Vec<_>, Vec<_>) = ast.body.into_iter().partition(entry_use_check(used_ftls)); if unused.is_empty() { None } else { append_deprecation_warning(&mut used); used.append(&mut unused); Some(Resource { body: used }) } }); } fn append_deprecation_warning(entries: &mut Vec>) { entries.retain(|entry| match entry { ast::Entry::GroupComment(ast::Comment { content }) => { !matches!(content.first(), Some(&DEPCRATION_WARNING)) } _ => true, }); entries.push(ast::Entry::GroupComment(ast::Comment { content: vec![DEPCRATION_WARNING], })); } fn entry_use_check(used_ftls: &HashSet) -> impl Fn(&ast::Entry<&str>) -> bool + '_ { |entry: &ast::Entry<&str>| match entry { ast::Entry::Message(msg) => used_ftls.contains(msg.id.name), ast::Entry::Term(term) => used_ftls.contains(term.id.name), _ => true, } } fn extract_references_from_file(refs: &mut HashSet, entry: &DirEntry) { static SNAKECASE_TR: LazyLock = LazyLock::new(|| Regex::new(r"\Wtr\s*\.([0-9a-z_]+)\W").unwrap()); static CAMELCASE_TR: LazyLock = LazyLock::new(|| Regex::new(r"\Wtr2?\.([0-9A-Za-z_]+)\W").unwrap()); static DESIGNER_STYLE_TR: LazyLock = LazyLock::new(|| Regex::new(r"([0-9a-z_]+)").unwrap()); let file_name = entry.file_name().to_str().expect("non-unicode filename"); let (regex, case_conversion): (&Regex, fn(&str) -> String) = if file_name.ends_with(".rs") || file_name.ends_with(".py") { (&SNAKECASE_TR, snake_to_kebab_case) } else if file_name.ends_with(".ts") || file_name.ends_with(".svelte") || file_name.ends_with(".swift") || file_name.ends_with(".kt") { (&CAMELCASE_TR, camel_to_kebab_case) } else if file_name.ends_with(".ui") { (&DESIGNER_STYLE_TR, snake_to_kebab_case) } else { return; }; let source = fs::read_to_string(entry.path()).expect("file not readable"); for caps in regex.captures_iter(&source) { refs.insert(case_conversion(&caps[1])); } } fn snake_to_kebab_case(name: &str) -> String { name.replace('_', "-") } fn camel_to_kebab_case(name: &str) -> String { let mut kebab = String::with_capacity(name.len() + 8); for ch in name.chars() { if ch.is_ascii_uppercase() || ch == '_' { kebab.push('-'); } if ch != '_' { kebab.push(ch.to_ascii_lowercase()); } } kebab } #[cfg(test)] mod test { use super::*; #[test] fn case_conversion() { assert_eq!(snake_to_kebab_case("foo"), "foo"); assert_eq!(snake_to_kebab_case("foo_bar"), "foo-bar"); assert_eq!(snake_to_kebab_case("foo_123"), "foo-123"); assert_eq!(snake_to_kebab_case("foo123"), "foo123"); assert_eq!(camel_to_kebab_case("foo"), "foo"); assert_eq!(camel_to_kebab_case("fooBar"), "foo-bar"); assert_eq!(camel_to_kebab_case("foo_123"), "foo-123"); assert_eq!(camel_to_kebab_case("foo123"), "foo123"); assert_eq!(camel_to_kebab_case("123foo"), "123foo"); assert_eq!(camel_to_kebab_case("123Foo"), "123-foo"); } } ================================================ FILE: ftl/src/main.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod garbage_collection; mod serialize; mod string; mod sync; use anyhow::Result; use clap::Parser; use clap::Subcommand; use garbage_collection::deprecate_ftl_entries; use garbage_collection::garbage_collect_ftl_entries; use garbage_collection::write_ftl_json; use garbage_collection::DeprecateEntriesArgs; use garbage_collection::GarbageCollectArgs; use garbage_collection::WriteJsonArgs; use crate::string::string_operation; use crate::string::StringCommand; #[derive(Parser)] struct Cli { #[command(subcommand)] command: Command, } #[derive(Subcommand)] enum Command { /// Update commit references to the latest translations, /// and copy source files to the translation repos. Requires access to the /// i18n repos to run. Sync, /// Extract references from all Rust, Python, TS, Svelte and Designer files /// in the given roots, convert them to ftl names case and write them as /// a json to the target file. WriteJson(WriteJsonArgs), /// Delete every entry in the ftl files that is not mentioned in another /// message or a given json. GarbageCollect(GarbageCollectArgs), /// Deprecate unused ftl entries by moving them to the bottom of the file /// and adding a deprecation warning. An entry is considered unused if /// cannot be found in a source or JSON file. Deprecate(DeprecateEntriesArgs), /// Operations on individual messages and their translations. #[clap(subcommand)] String(StringCommand), } fn main() -> Result<()> { match Cli::parse().command { Command::Sync => sync::sync(), Command::WriteJson(args) => write_ftl_json(args), Command::GarbageCollect(args) => garbage_collect_ftl_entries(args), Command::Deprecate(args) => deprecate_ftl_entries(args), Command::String(args) => string_operation(args), } } ================================================ FILE: ftl/src/serialize.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // copied from https://github.com/projectfluent/fluent-rs/pull/241 use std::fmt; use std::fmt::Error; use std::fmt::Write; use fluent_syntax::ast::*; use fluent_syntax::parser::Slice; pub fn serialize<'s, S: Slice<'s>>(resource: &Resource) -> String { serialize_with_options(resource, Options::default()) } pub fn serialize_with_options<'s, S: Slice<'s>>( resource: &Resource, options: Options, ) -> String { let mut ser = Serializer::new(options); ser.serialize_resource(resource) .expect("Writing to an in-memory buffer never fails"); ser.into_serialized_text() } #[derive(Debug)] pub struct Serializer { writer: TextWriter, options: Options, state: State, } impl Serializer { pub fn new(options: Options) -> Self { Serializer { writer: TextWriter::default(), options, state: State::default(), } } pub fn serialize_resource<'s, S: Slice<'s>>(&mut self, res: &Resource) -> Result<(), Error> { for entry in &res.body { match entry { Entry::Message(msg) => self.serialize_message(msg)?, Entry::Term(term) => self.serialize_term(term)?, Entry::Comment(comment) => self.serialize_free_comment(comment, "#")?, Entry::GroupComment(comment) => self.serialize_free_comment(comment, "##")?, Entry::ResourceComment(comment) => self.serialize_free_comment(comment, "###")?, Entry::Junk { content } if self.options.with_junk => { self.serialize_junk(content.as_ref())? } Entry::Junk { .. } => continue, } self.state.has_entries = true; } Ok(()) } pub fn into_serialized_text(self) -> String { self.writer.buffer } fn serialize_junk(&mut self, junk: &str) -> Result<(), Error> { self.writer.write_literal(junk) } fn serialize_free_comment<'s, S: Slice<'s>>( &mut self, comment: &Comment, prefix: &str, ) -> Result<(), Error> { if self.state.has_entries { self.writer.newline(); } self.serialize_comment(comment, prefix)?; self.writer.newline(); Ok(()) } fn serialize_comment<'s, S: Slice<'s>>( &mut self, comment: &Comment, prefix: &str, ) -> Result<(), Error> { for line in &comment.content { self.writer.write_literal(prefix)?; if !line.as_ref().trim().is_empty() { self.writer.write_literal(" ")?; self.writer.write_literal(line.as_ref())?; } self.writer.newline(); } Ok(()) } fn serialize_message<'s, S: Slice<'s>>(&mut self, msg: &Message) -> Result<(), Error> { if let Some(comment) = msg.comment.as_ref() { self.serialize_comment(comment, "#")?; } self.writer.write_literal(msg.id.name.as_ref())?; self.writer.write_literal(" =")?; if let Some(value) = msg.value.as_ref() { self.serialize_pattern(value)?; } self.serialize_attributes(&msg.attributes)?; self.writer.newline(); Ok(()) } fn serialize_term<'s, S: Slice<'s>>(&mut self, term: &Term) -> Result<(), Error> { if let Some(comment) = term.comment.as_ref() { self.serialize_comment(comment, "#")?; } self.writer.write_literal("-")?; self.writer.write_literal(term.id.name.as_ref())?; self.writer.write_literal(" =")?; self.serialize_pattern(&term.value)?; self.serialize_attributes(&term.attributes)?; self.writer.newline(); Ok(()) } fn serialize_pattern<'s, S: Slice<'s>>(&mut self, pattern: &Pattern) -> Result<(), Error> { let start_on_newline = pattern.elements.iter().any(|elem| match elem { PatternElement::TextElement { value } => value.as_ref().contains('\n'), PatternElement::Placeable { expression } => is_select_expr(expression), }); if start_on_newline { self.writer.newline(); self.writer.indent(); } else { self.writer.write_literal(" ")?; } for element in &pattern.elements { self.serialize_element(element)?; } if start_on_newline { self.writer.dedent(); } Ok(()) } fn serialize_attributes<'s, S: Slice<'s>>( &mut self, attrs: &[Attribute], ) -> Result<(), Error> { if attrs.is_empty() { return Ok(()); } self.writer.indent(); for attr in attrs { self.writer.newline(); self.serialize_attribute(attr)?; } self.writer.dedent(); Ok(()) } fn serialize_attribute<'s, S: Slice<'s>>(&mut self, attr: &Attribute) -> Result<(), Error> { self.writer.write_literal(".")?; self.writer.write_literal(attr.id.name.as_ref())?; self.writer.write_literal(" =")?; self.serialize_pattern(&attr.value)?; Ok(()) } fn serialize_element<'s, S: Slice<'s>>( &mut self, elem: &PatternElement, ) -> Result<(), Error> { match elem { PatternElement::TextElement { value } => self.writer.write_literal(value.as_ref()), PatternElement::Placeable { expression } => match expression { Expression::Inline(InlineExpression::Placeable { expression }) => { // A placeable inside a placeable is a special case because we // don't want the braces to look silly (e.g. "{ { Foo() } }"). self.writer.write_literal("{{ ")?; self.serialize_expression(expression)?; self.writer.write_literal(" }}")?; Ok(()) } Expression::Select { .. } => { // select adds its own newline and indent, emit the brace // *without* a space so we don't get 5 spaces instead of 4 self.writer.write_literal("{ ")?; self.serialize_expression(expression)?; self.writer.write_literal("}")?; Ok(()) } Expression::Inline(_) => { self.writer.write_literal("{ ")?; self.serialize_expression(expression)?; self.writer.write_literal(" }")?; Ok(()) } }, } } fn serialize_expression<'s, S: Slice<'s>>( &mut self, expr: &Expression, ) -> Result<(), Error> { match expr { Expression::Inline(inline) => self.serialize_inline_expression(inline), Expression::Select { selector, variants } => { self.serialize_select_expression(selector, variants) } } } fn serialize_inline_expression<'s, S: Slice<'s>>( &mut self, expr: &InlineExpression, ) -> Result<(), Error> { match expr { InlineExpression::StringLiteral { value } => { self.writer.write_literal("\"")?; self.writer.write_literal(value.as_ref())?; self.writer.write_literal("\"")?; Ok(()) } InlineExpression::NumberLiteral { value } => self.writer.write_literal(value.as_ref()), InlineExpression::VariableReference { id: Identifier { name: value }, } => { self.writer.write_literal("$")?; self.writer.write_literal(value.as_ref())?; Ok(()) } InlineExpression::FunctionReference { id, arguments } => { self.writer.write_literal(id.name.as_ref())?; self.serialize_call_arguments(arguments)?; Ok(()) } InlineExpression::MessageReference { id, attribute } => { self.writer.write_literal(id.name.as_ref())?; if let Some(attr) = attribute.as_ref() { self.writer.write_literal(".")?; self.writer.write_literal(attr.name.as_ref())?; } Ok(()) } InlineExpression::TermReference { id, attribute, arguments, } => { self.writer.write_literal("-")?; self.writer.write_literal(id.name.as_ref())?; if let Some(attr) = attribute.as_ref() { self.writer.write_literal(".")?; self.writer.write_literal(attr.name.as_ref())?; } if let Some(args) = arguments.as_ref() { self.serialize_call_arguments(args)?; } Ok(()) } InlineExpression::Placeable { expression } => { self.writer.write_literal("{")?; self.serialize_expression(expression)?; self.writer.write_literal("}")?; Ok(()) } } } fn serialize_select_expression<'s, S: Slice<'s>>( &mut self, selector: &InlineExpression, variants: &[Variant], ) -> Result<(), Error> { self.serialize_inline_expression(selector)?; self.writer.write_literal(" ->")?; self.writer.newline(); self.writer.indent(); for variant in variants { self.serialize_variant(variant)?; self.writer.newline(); } self.writer.dedent(); Ok(()) } fn serialize_variant<'s, S: Slice<'s>>(&mut self, variant: &Variant) -> Result<(), Error> { if variant.default { self.writer.write_char_into_indent('*'); } self.writer.write_literal("[")?; self.serialize_variant_key(&variant.key)?; self.writer.write_literal("]")?; self.serialize_pattern(&variant.value)?; Ok(()) } fn serialize_variant_key<'s, S: Slice<'s>>( &mut self, key: &VariantKey, ) -> Result<(), Error> { match key { VariantKey::NumberLiteral { value } | VariantKey::Identifier { name: value } => { self.writer.write_literal(value.as_ref()) } } } fn serialize_call_arguments<'s, S: Slice<'s>>( &mut self, args: &CallArguments, ) -> Result<(), Error> { let mut argument_written = false; self.writer.write_literal("(")?; for positional in &args.positional { if argument_written { self.writer.write_literal(", ")?; } self.serialize_inline_expression(positional)?; argument_written = true; } for named in &args.named { if argument_written { self.writer.write_literal(", ")?; } self.writer.write_literal(named.name.name.as_ref())?; self.writer.write_literal(": ")?; self.serialize_inline_expression(&named.value)?; argument_written = true; } self.writer.write_literal(")")?; Ok(()) } } fn is_select_expr<'s, S: Slice<'s>>(expr: &Expression) -> bool { match expr { Expression::Select { .. } => true, Expression::Inline(InlineExpression::Placeable { expression }) => { is_select_expr(expression) } Expression::Inline(_) => false, } } #[derive(Debug, Default, Copy, Clone, PartialEq, Eq)] pub struct Options { pub with_junk: bool, } #[derive(Debug, Default, PartialEq)] struct State { has_entries: bool, } #[derive(Debug, Clone, Default)] struct TextWriter { buffer: String, indent_level: usize, } impl TextWriter { fn indent(&mut self) { self.indent_level += 1; } fn dedent(&mut self) { self.indent_level = self .indent_level .checked_sub(1) .expect("Dedenting without a corresponding indent"); } fn write_indent(&mut self) { for _ in 0..self.indent_level { self.buffer.push_str(" "); } } fn newline(&mut self) { self.buffer.push('\n'); } fn write_literal(&mut self, mut item: &str) -> fmt::Result { if self.buffer.ends_with('\n') { // we've just added a newline, make sure it's properly indented self.write_indent(); // we've just added indentation, so we don't care about leading // spaces item = item.trim_start_matches(' '); } write!(self.buffer, "{item}") } fn write_char_into_indent(&mut self, ch: char) { if self.buffer.ends_with('\n') { self.write_indent(); } self.buffer.pop(); self.buffer.push(ch); } } ================================================ FILE: ftl/src/string/copy.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::assert_ne; use std::collections::HashMap; use std::println; use camino::Utf8PathBuf; use clap::Args; use fluent_syntax::ast::Entry; use crate::string; #[derive(Args)] pub struct CopyOrMoveArgs { /// The folder which contains the different languages as subfolders, e.g. /// ftl/core-repo/core src_lang_folder: Utf8PathBuf, dst_lang_folder: Utf8PathBuf, /// E.g. 'actions-run'. File will be inferred from the prefix. src_key: String, /// If not specified, the key & file will be the same as the source key. dst_key: Option, } #[derive(Debug, Eq, PartialEq)] pub(super) enum CopyOrMove { Copy, Move, } pub(super) fn copy_or_move(mode: CopyOrMove, args: CopyOrMoveArgs) -> anyhow::Result<()> { let old_key = &args.src_key; let new_key = args.dst_key.as_ref().unwrap_or(old_key); let src_ftl_file = string::ftl_file_from_key(old_key); let dst_ftl_file = string::ftl_file_from_key(new_key); let mut entries: HashMap<&str, Entry> = HashMap::new(); // Fetch source strings let src_langs = string::all_langs(&args.src_lang_folder)?; for lang in &src_langs { let ftl_path = lang.join(&src_ftl_file); if !ftl_path.exists() { continue; } let entry = string::get_entry(&ftl_path, old_key); if let Some(entry) = entry { entries.insert(lang.file_name().unwrap(), entry); } else { // the key might be missing from some languages, but it should not be missing // from the template assert_ne!(lang, "templates"); } } // Apply to destination let dst_langs = string::all_langs(&args.dst_lang_folder)?; for lang in &dst_langs { let ftl_path = lang.join(&dst_ftl_file); if !ftl_path.exists() { continue; } if let Some(entry) = entries.get(lang.file_name().unwrap()) { println!("Updating {ftl_path}"); string::write_entry(&ftl_path, new_key, entry.clone())?; } } if let Some(template_dir) = string::additional_template_folder(&args.dst_lang_folder) { // Our templates are also stored in the source tree, and need to be updated too. let ftl_path = template_dir.join(&dst_ftl_file); println!("Updating {ftl_path}"); string::write_entry( &ftl_path, new_key, entries.get("templates").unwrap().clone(), )?; } if mode == CopyOrMove::Move { // Delete the old key for lang in &src_langs { let ftl_path = lang.join(&src_ftl_file); if !ftl_path.exists() { continue; } if string::delete_entry(&ftl_path, old_key)? { println!("Deleted entry from {ftl_path}"); } } if let Some(template_dir) = string::additional_template_folder(&args.src_lang_folder) { let ftl_path = template_dir.join(&src_ftl_file); if string::delete_entry(&ftl_path, old_key)? { println!("Deleted entry from {ftl_path}"); } } } Ok(()) } ================================================ FILE: ftl/src/string/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod copy; mod transform; use std::fs; use std::path::Path; use anki_io::read_to_string; use anki_io::write_file_if_changed; use anki_io::ToUtf8PathBuf; use anyhow::anyhow; use anyhow::Context; use anyhow::Result; use camino::Utf8Component; use camino::Utf8Path; use camino::Utf8PathBuf; use clap::Subcommand; use copy::CopyOrMoveArgs; use fluent_syntax::ast::Entry; use fluent_syntax::ast::Resource; use fluent_syntax::parser; use itertools::Itertools; use crate::serialize; use crate::string::copy::copy_or_move; use crate::string::copy::CopyOrMove; use crate::string::transform::transform; use crate::string::transform::TransformArgs; #[derive(Subcommand)] pub enum StringCommand { /// Copy a key from one ftl file to another, including all its /// translations. Source and destination should be e.g. /// ftl/core-repo/core. Copy(CopyOrMoveArgs), /// Move a key from one ftl file to another, including all its /// translations. Source and destination should be e.g. /// ftl/core-repo/core. Move(CopyOrMoveArgs), /// Apply a regex find&replace to the template and translations. Transform(TransformArgs), } pub fn string_operation(args: StringCommand) -> anyhow::Result<()> { match args { StringCommand::Copy(args) => copy_or_move(CopyOrMove::Copy, args), StringCommand::Move(args) => copy_or_move(CopyOrMove::Move, args), StringCommand::Transform(args) => transform(args), } } fn additional_template_folder(dst_folder: &Utf8Path) -> Option { // ftl/core-repo/core -> ftl/core // ftl/qt-repo/qt -> ftl/qt let adjusted_path = Utf8PathBuf::from_iter( [Utf8Component::Normal("ftl")] .into_iter() .chain(dst_folder.components().skip(2)), ); if adjusted_path.exists() { Some(adjusted_path) } else { None } } fn all_langs(lang_folder: &Utf8Path) -> Result> { std::fs::read_dir(lang_folder) .with_context(|| format!("reading {lang_folder:?}"))? .filter_map(Result::ok) .map(|e| Ok(e.path().utf8()?)) .collect() } fn ftl_file_from_key(old_key: &str) -> String { for prefix in [ "card-stats", "card-template-rendering", "card-templates", "change-notetype", "custom-study", "database-check", "deck-config", "empty-cards", "media-check", "qt-misc", ] { if old_key.starts_with(&format!("{prefix}-")) { return format!("{prefix}.ftl"); } } format!("{}.ftl", old_key.split('-').next().unwrap()) } fn parse_file(ftl_path: &Utf8Path) -> Result> { let content = read_to_string(ftl_path).unwrap(); parser::parse(content).map_err(|(_, errs)| { anyhow!( "while reading {ftl_path}: {}", errs.into_iter().map(|err| err.to_string()).join(", ") ) }) } /// True if changed. fn serialize_file(path: &Utf8Path, resource: &Resource) -> Result { let mut text = serialize::serialize(resource); // escape leading dots text = text.replace(" +.", " +{\".\"}"); // ensure the resulting serialized file is valid by parsing again let _ = parser::parse(text.clone()).unwrap(); // it's ok, write it out Ok(write_file_if_changed(path, text)?) } fn get_entry(fname: &Utf8Path, key: &str) -> Option> { let resource = parse_file(fname).unwrap(); for entry in resource.body { if let Entry::Message(message) = entry { if message.id.name == key { return Some(Entry::Message(message)); } } } None } fn write_entry(path: &Utf8Path, key: &str, mut entry: Entry) -> Result<()> { if let Entry::Message(message) = &mut entry { message.id.name = key.to_string(); } let content = if Path::new(path).exists() { fs::read_to_string(path).unwrap() } else { String::new() }; let mut resource = parser::parse(content).unwrap(); resource.body.push(entry); serialize_file(path, &resource)?; Ok(()) } fn delete_entry(path: &Utf8Path, key: &str) -> Result { let mut resource = parse_file(path)?; let mut did_change = false; resource.body.retain(|entry| { !if let Entry::Message(message) = entry { if message.id.name == key { did_change = true; true } else { false } } else { false } }); serialize_file(path, &resource) } ================================================ FILE: ftl/src/string/transform.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use anki_io::paths_in_dir; use anyhow::Result; use camino::Utf8Path; use camino::Utf8PathBuf; use clap::Args; use clap::ValueEnum; use fluent_syntax::ast::Entry; use fluent_syntax::ast::Expression; use fluent_syntax::ast::InlineExpression; use fluent_syntax::ast::Message; use fluent_syntax::ast::Pattern; use fluent_syntax::ast::PatternElement; use fluent_syntax::ast::Resource; use regex::Regex; use crate::string::parse_file; use crate::string::serialize_file; #[derive(Args)] pub struct TransformArgs { /// The folder which contains the different languages as subfolders, e.g. /// ftl/core-repo/core lang_folder: Utf8PathBuf, // What should be replaced. target: TransformTarget, regex: String, replacement: String, // limit replacement to a single key // #[clap(long)] // key: Option, } #[derive(ValueEnum, Clone, PartialEq, Eq)] pub enum TransformTarget { Text, Variable, } pub fn transform(args: TransformArgs) -> Result<()> { let regex = Regex::new(&args.regex)?; for lang in super::all_langs(&args.lang_folder)? { for ftl in paths_in_dir(&lang)? { transform_ftl(&ftl, ®ex, &args)?; } } if let Some(template_dir) = super::additional_template_folder(&args.lang_folder) { // Our templates are also stored in the source tree, and need to be updated too. for ftl in paths_in_dir(template_dir)? { transform_ftl(&ftl, ®ex, &args)?; } } Ok(()) } fn transform_ftl(ftl: &Utf8Path, regex: &Regex, args: &TransformArgs) -> Result<()> { let mut resource = parse_file(ftl)?; if transform_ftl_inner(&mut resource, regex, args) { println!("Updating {ftl}"); serialize_file(ftl, &resource)?; } Ok(()) } fn transform_ftl_inner( resource: &mut Resource, regex: &Regex, args: &TransformArgs, ) -> bool { let mut changed = false; for entry in &mut resource.body { if let Entry::Message(Message { value: Some(value), .. }) = entry { changed |= transform_pattern(value, regex, args); } } changed } /// True if changed. fn transform_pattern(pattern: &mut Pattern, regex: &Regex, args: &TransformArgs) -> bool { let mut changed = false; for element in &mut pattern.elements { match args.target { TransformTarget::Text => { changed |= transform_text(element, regex, args); } TransformTarget::Variable => { changed |= transform_variable(element, regex, args); } } } changed } fn transform_variable( pattern: &mut PatternElement, regex: &Regex, args: &TransformArgs, ) -> bool { let mut changed = false; let mut maybe_update = |val: &mut String| { if let Cow::Owned(new_val) = regex.replace_all(val, &args.replacement) { changed = true; *val = new_val; } }; if let PatternElement::Placeable { expression } = pattern { match expression { Expression::Select { selector, variants } => { if let InlineExpression::VariableReference { id } = selector { maybe_update(&mut id.name) } for variant in variants { changed |= transform_pattern(&mut variant.value, regex, args); } } Expression::Inline(expression) => { if let InlineExpression::VariableReference { id } = expression { maybe_update(&mut id.name) } } } } changed } fn transform_text( pattern: &mut PatternElement, regex: &Regex, args: &TransformArgs, ) -> bool { let mut changed = false; let mut maybe_update = |val: &mut String| { if let Cow::Owned(new_val) = regex.replace_all(val, &args.replacement) { changed = true; *val = new_val; } }; match pattern { PatternElement::TextElement { value } => { maybe_update(value); } PatternElement::Placeable { expression } => match expression { Expression::Inline(val) => match val { InlineExpression::StringLiteral { value } => maybe_update(value), InlineExpression::NumberLiteral { value } => maybe_update(value), InlineExpression::FunctionReference { .. } => {} InlineExpression::MessageReference { .. } => {} InlineExpression::TermReference { .. } => {} InlineExpression::VariableReference { .. } => {} InlineExpression::Placeable { .. } => {} }, Expression::Select { variants, .. } => { for variant in variants { changed |= transform_pattern(&mut variant.value, regex, args); } } }, } changed } #[cfg(test)] mod tests { use fluent_syntax::parser::parse; use super::*; use crate::serialize::serialize; #[test] fn transform() -> Result<()> { let mut resource = parse( r#"sample-1 = This is a sample sample-2 = { $sample -> [one] { $sample } sample done *[other] { $sample } samples done }"# .to_string(), ) .unwrap(); let mut args = TransformArgs { lang_folder: Default::default(), target: TransformTarget::Text, regex: "".to_string(), replacement: "replaced".to_string(), }; // no changes assert!(!transform_ftl_inner( &mut resource, &Regex::new("aoeu").unwrap(), &args )); // text change let regex = Regex::new("sample").unwrap(); let mut resource2 = resource.clone(); assert!(transform_ftl_inner(&mut resource2, ®ex, &args)); assert_eq!( &serialize(&resource2), r#"sample-1 = This is a replaced sample-2 = { $sample -> [one] { $sample } replaced done *[other] { $sample } replaceds done } "# ); // variable change let mut resource2 = resource.clone(); args.target = TransformTarget::Variable; assert!(transform_ftl_inner(&mut resource2, ®ex, &args)); assert_eq!( &serialize(&resource2), r#"sample-1 = This is a sample sample-2 = { $replaced -> [one] { $replaced } sample done *[other] { $replaced } samples done } "# ); Ok(()) } } ================================================ FILE: ftl/src/sync.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::process::Command; use anki_process::CommandExt; use anyhow::bail; use anyhow::Context; use anyhow::Result; use camino::Utf8Path; #[derive(Debug)] struct Module { template_folder: &'static Utf8Path, translation_repo: &'static Utf8Path, } /// Our ftl submodules are checked out over unauthenticated https; a separate /// remote is used to push via authenticated ssh. const GIT_REMOTE: &str = "ssh"; pub fn sync() -> Result<()> { let modules = [ Module { template_folder: "ftl/core".into(), translation_repo: "ftl/core-repo/core".into(), }, Module { template_folder: "ftl/qt".into(), translation_repo: "ftl/qt-repo/desktop".into(), }, ]; check_clean()?; for module in modules { fetch_new_translations(&module)?; push_new_templates(&module)?; } commit(".", "Update translations").context("failure expected if no translations changed")?; Ok(()) } fn check_clean() -> Result<()> { let output = Command::new("git") .arg("diff") .output() .context("git diff")?; if !output.status.success() { bail!("git diff"); } if !output.stdout.is_empty() { bail!("please commit any outstanding changes first"); } Ok(()) } fn fetch_new_translations(module: &Module) -> Result<()> { Command::new("git") .current_dir(module.translation_repo) .args(["checkout", "main"]) .ensure_success()?; Command::new("git") .current_dir(module.translation_repo) .args(["pull", "origin", "main"]) .ensure_success()?; Ok(()) } fn push_new_templates(module: &Module) -> Result<()> { Command::new("rsync") .args(["-ai", "--delete", "--no-perms", "--no-times", "-c"]) .args([ format!("{}/", module.template_folder), format!("{}/", module.translation_repo.join("templates")), ]) .ensure_success()?; let changes_pending = !Command::new("git") .current_dir(module.translation_repo) .args(["diff", "--exit-code"]) .status() .context("git")? .success(); if changes_pending { commit(module.translation_repo, "Update templates")?; push(module.translation_repo)?; } Ok(()) } fn push(repo: &Utf8Path) -> Result<()> { Command::new("git") .current_dir(repo) .args(["push", GIT_REMOTE, "main"]) .ensure_success()?; // ensure origin matches ssh remote Command::new("git") .current_dir(repo) .args(["fetch"]) .ensure_success()?; Ok(()) } fn commit(folder: F, message: &str) -> Result<()> where F: AsRef, { Command::new("git") .current_dir(folder.as_ref()) .args(["commit", "-a", "-m", message]) .ensure_success()?; Ok(()) } ================================================ FILE: ftl/update-ankidroid-usage.sh ================================================ #!/bin/bash cargo run --bin write_ftl_json ftl/usage/ankidroid.json ~/Local/droid/Anki-Android ================================================ FILE: ftl/update-ankimobile-usage.sh ================================================ #!/bin/bash # This script can only be run by Damien, as it requires a copy of AnkiMobile's sources. cargo run --bin write_ftl_json ftl/usage/ankimobile.json ../../mobile/ankimobile/src ================================================ FILE: ftl/usage/no-deprecate.json ================================================ [ "scheduling-update-soon", "scheduling-update-later-button" ] ================================================ FILE: justfile ================================================ set windows-shell := ["cmd.exe", "/c"] # Show available commands default: @just --list # Run all tests (Rust, Python, TypeScript) test: {{ ninja }} check:rust_test check:pytest check:vitest # Run format checks only (fast, no build needed) fmt: {{ ninja }} check:format # Run linting and type checking (requires build outputs) lint: {{ ninja }} \ check:clippy \ check:mypy \ check:ruff \ check:eslint \ check:svelte \ check:typescript # Run minilints (copyright, contributors, licenses) minilints: {{ ninja }} check:minilints # Build the project build: {{ ninja }} pylib qt # Build wheels (needed for some platforms) wheels: {{ ninja }} wheels # Build and run all checks (lint + test) - lets ninja handle dependencies check: {{ ninja }} pylib qt check # Helper to get the right ninja command for the platform ninja := if os() == "windows" { "tools\\ninja" } else { "./ninja" } ================================================ FILE: ninja ================================================ #!/bin/bash set -e if [ "$BUILD_ROOT" == "" ]; then out=$(pwd)/out else out="$BUILD_ROOT" fi export CARGO_TARGET_DIR=$out/rust export RECONFIGURE_KEY="${MAC_X86};${LIN_ARM64};${SOURCEMAP};${HMR}" if [ "$SKIP_RUNNER_BUILD" = "1" ]; then echo "Runner not rebuilt." else cargo build -p runner --release fi exec $out/rust/release/runner build -- $* ================================================ FILE: package.json ================================================ { "name": "anki", "version": "0.1.0", "private": true, "author": "Ankitects Pty Ltd and contributors", "license": "AGPL-3.0-or-later", "description": "Anki JS support files", "scripts": { "dev": "cd ts && vite dev", "build": "cd ts && vite build", "preview": "cd ts && vite preview", "svelte-check:once": "cd ts && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings --threshold warning", "svelte-check": "cd ts && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "vitest:once": "cd ts && vitest run", "vitest": "cd ts && vitest" }, "devDependencies": { "@bufbuild/protoc-gen-es": "^1.8.0", "@poppanator/sveltekit-svg": "^5.0.0", "@sqltools/formatter": "^1.2.2", "@sveltejs/adapter-static": "^3.0.0", "@sveltejs/kit": "^2.53.3", "@sveltejs/vite-plugin-svelte": "5.1", "@types/bootstrap": "^5.0.12", "@types/codemirror": "^5.60.0", "@types/d3": "^7.0.0", "@types/diff": "^5.0.0", "@types/fabric": "^5.3.7", "@types/jquery": "^3.5.0", "@types/jqueryui": "^1.12.13", "@types/lodash-es": "^4.17.4", "@types/marked": "^5.0.0", "@types/node": "^22", "@typescript-eslint/eslint-plugin": "^5.60.1", "@typescript-eslint/parser": "^5.60.1", "caniuse-lite": "^1.0.30001431", "cross-env": "^7.0.2", "diff": "^5.0.0", "dprint": "^0.47.2", "esbuild": "^0.25.3", "esbuild-sass-plugin": "^3.3.1", "esbuild-svelte": "^0.9.2", "eslint": "^8.44.0", "eslint-plugin-compat": "^4.1.4", "eslint-plugin-import": "^2.25.4", "eslint-plugin-svelte": "^2", "license-checker-rseidelsohn": "=4.3.0", "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.2", "sass": "<1.77", "svelte": "^5.53.5", "svelte-check": "^4.2.2", "svelte-preprocess": "^6.0.3", "svelte-preprocess-esbuild": "^3.0.1", "svgo": "^3.3.3", "tslib": "^2.0.3", "tsx": "^4.8.1", "typescript": "^5.0.4", "vite": "6", "vitest": "^3" }, "dependencies": { "@bufbuild/protobuf": "^1.2.1", "@floating-ui/dom": "^1.4.3", "@fluent/bundle": "^0.18.0", "@mdi/svg": "^7.0.96", "@popperjs/core": "^2.11.8", "bootstrap": "^5.3.0", "bootstrap-icons": "^1.10.5", "codemirror": "^5.63.1", "d3": "^7.0.0", "fabric": "^5.3.0", "hammerjs": "^2.0.8", "intl-pluralrules": "^2.0.0", "jquery": "^3.5.1", "jquery-ui-dist": "^1.12.1", "lodash-es": "^4.17.23", "lru-cache": "^10.2.0", "marked": "^5.1.0", "mathjax": "^3.1.2" }, "resolutions": { "canvas": "npm:empty-npm-package@1.0.0", "cookie": "0.7.0", "devalue": "^5.6.2", "tar": "^7.5.7", "vite": "6", "js-yaml": "^4.1.1", "glob": "^10.5.0" }, "browserslist": [ "defaults", "not op_mini all", "not < 1%", "Chrome 77", "iOS 14.5" ], "type": "module", "packageManager": "yarn@4.6.0" } ================================================ FILE: pkgkey.asc ================================================ -----BEGIN PGP PUBLIC KEY BLOCK----- mQINBFueX68BEAClpx+Szt1cSTWJTCTpn9E+tGhYUKVpj1O4KGAj7qYKs651LPOA en1Ng0MoK4Avq4zW3PpXxtp14q+CBpEP3AE3omJkKD42cmBvxqdMNiWnFZUbRal8 L7LrkVFVV/C1Cq7pJR5xAORc2GCKKTE6Ybqdqj2lQKwZEJpM+GQPQqSUQjWpmO2n YQ8OSftr58Nqm5N2j2i2BHvchpOUtoN4L5qlYtkPBFltBDVOKglnQE4N9pZjBX76 D2Q4/6khfIx1kJ3xt8b30cPlDMATdnB6bDUr17vsofhPIY1N07ztyDLl2PyeeqVa IrJEh9XvhwnN5RqM10PZDSEDVGLk4Mkbu5dwsbvXbdMrLbAaWoBDEmloVmM+rw+t g76ldYu2FIxIVHDdgqJWSw5+JTQk+GlAwxAve3yluxvWrxKR1kG66XB8rX0G47Qd DLmnj9PISi5rRJzQXTrIG93aKKBqz6vMS1n9V3BhEKSzJ2zH6Nhog/SIwFqfSvNL +vJLmjPlOdVL4cBSa7EltOGevkeU5DCTV0PNz78TpBMTaduKxFyFfepyxkYrFrjR hKR2HLFD0jecw36UoCETJm5/VbYE312qqWJxuuvsWtaU2I6SRv8rTRJ7prlo5zI1 6pfUk3QCt1zDZC3v3NszhYIhLBIVv72iVo3DEbuqOyjGJnF1IOUv6XRvbwARAQAB tCFBbmtpIFNpZ25hdHVyZXMgPGdwZ0Bhbmtpd2ViLm5ldD6JAk4EEwEKADgWIQSB TqTpDDSvOacS3nA/VWai0WiZ+wUCW55frwIbAQULCQgHAwUVCgkICwUWAgMBAAIe AQIXgAAKCRA/VWai0WiZ+yM6EACLvzNwwgXVE6KA9NA+Xn9z/5CEy894gNUXBdyP x2peUZmvqZYJsWrq1EdwvyNPVnfxerzRPzzO1/+UFs9lyrVJBOIXRe790xUDEAOt d67eIHk0/mwR8HA4EzBM3VhK90DzfdVl8CGjn7QMcgXZk8qp9ogSh4qoPq9slXjs Ay8pdDKBQthR3jFoAX8tX8x3vrQPBFIA1xRX0Pr9w6CTR7lto0HTP1o4weB2AFVM cshUnPWv7UkJsKDgsS0JpK46AS1y0z8TgGqZxPiXyiSw+r+uBOu5243ujfFKHfsR 26h23BO+9niHKIMkThTlYweUj0pqqcS9dZ1RBFFtW5/0+c/WA7Jg59XELg34jbvr DJjW0kXkvH3TP0rxDNlzQivh48PTHovng/m0Ah6XoW6APBK04xTOPPsW8mILolwi PYcd3frQx2gYCKiUDgXhn/0pHy35Qf2UMCpWMljNF3uaoeBDmRjmhaUDnldDWeYo pg48bwe4utKeJK9mIl1tg24jOiZPWB7Yg5UOSa9qG9Z8O/Vvgmi9z4ujp4g37jUZ PGAlsEagVUAenVbNpS07X2KtGuP3CKc1akN9I4YArH604lB6rJYVl7c1mZw6YZf1 lDuFs+sorr+Qh6ivBvhCOZwC+cAfUHXm0Td6mBsnnCJl3Pe0w49x2ODdabjyM/d+ 19lNz4hdBBMRCgAdFiEErE89EK5NqyDOk9jPuclDRDSScSsFAlufSloACgkQuclD RDSScStmmgCfZBxtwxhNHKDdSVbMUlFmPq3Ww5gAn1A0OzZEPJkj+gdq0bWbEePA Na/+iQEzBBMBCgAdFiEE5As5X+DAzGQR/QaHdD/TCLkKzHEFAlufSu4ACgkQdD/T CLkKzHH2HwgAj9jcRBL3Rsu4r7ZifbAPOlB/zQLos5Hmt70DzheWpU6hMcxkgnAs CB3gutZAQ36yKgBzOFWDfo5X3ivhAm23VXkFKswgHmA1DLypmFPh2rm/Sh3G9khr oogQmwErZRLNJ7QY9Q3sIxaSvZArWSRaysSaVG9CiSq+N4yXnhETS8uieWV9k+qk Rs1eaJCjOYPgaxQXXL18RgkzuDKuSqmWW0AvmVNaAsX27diAxqcVysGyoJIqA4Dw VaMJU+hSZZSryKTLWHZpGMMLLjt5oLyW3y1HvopVAuGRrAXLzqgvLv/WSXHWfSrL l+VeP2jDU3mF43BtjGRMrVgAf0DGH1zg57kCDQRbnmAYARAAsvvWoYPy13YFqOsR sgaJ1sW6hyGGOjhlHcfcc++CgYwowQ8jn0ZkYdcDs+zbJI8+BUCdVgO8kpJFVlmC vpBeO0bKoqc63W6NIG7qrhgDoODO3J3CV4LJm5ipj9tcuXCW2o7GzrgJMaps8NQK aUywSwZcV15aERrw9viHEPUHHAQkKBANv/cJ+YWD1SOZyI978yER0/qdby8cnLp+ vwzRo+OB3ubZL3iFKKd716eSOJQOO1XbxsfF9RagFmGn8lq4tii9nU9c7BS3ajC3 FJNNsNphe7DezAeV7IZZrmcSTl+h3n045yRJjisxqG74dSqJ8aIkuQvCTRn8MhIL ulgX3W0Bl4xLkRDhskIOdO8d2h8nOzoNJ2yDrJp1JHEG6G3J3ZsQ1H1uhVCwvFAM SMBo0kfnBfgsu7qyb3Lu5wOJ4Kh8+DZlgUnV8k/Gy21nvqjHCB4to/XSt5ZxWyBs myZTxbA3w4zmkKhIdXBXEHT1+giM2n/xA7vnAUHYdGcRUza3fKXZFoBe3sOp2f6y iF0kkO75Vkbshew8LymowU7eioiNLjIhVeOw0ICJVdHTreAFvkPo5e1N3z7Bov1E fqLZA1p7sUJ68sNrIo3UNeADsy5YfTsSB/2zxHi1WEbhcGA2fICJfhNCOjrFKTqR Sb+9EdKZj3Yl5fVRaMwYtqC2IKMAEQEAAYkEbAQYAQoAIBYhBIFOpOkMNK85pxLe cD9VZqLRaJn7BQJbnmAYAhsCAkAJED9VZqLRaJn7wXQgBBkBCgAdFiEEHKk0tAuE 9EgxbOL8wKNRl5s347kFAlueYBgACgkQwKNRl5s347lE/A/+Mxd1Cf8aRkE8Pq8m qAnMPNsuA0TpBxP7NMqi9VLpcQNU8fP5b2/7GPcz/bBsryGqQ0PxF/unHJh7Ei7V GM6lYKkboWQNRC8jgRgwGxmoRMQZHocKojPlAPqJ9RiiGM6Hj6QP1oyYGp596osP HW2FF8fNnoFFaghRVJmpNBnkUlRZhJDoJQmzQlSnbZlnphGMe4J6Eioj1dwoDPow aBOXdWbo7j6VXvVjz3WpQ1InHuYEW3/rkuuTaxsiyq0/Kn1MT6G1uDrg9BczUDSF txRoVzE3oR6gp+XfVSyfGQOeSXIi9pAENM0nsxLDf2mTqDFZGpP2Ja9T8RPJB2mh wdfIfHWQu8+Yywmmbb/BA4ebF0zlGx7NWTc12NYqe+bMlHvZSacm6PVkg6ob6DtD PAZbYg8oqzi7Mz4m3Z5lTRrTP5bul2Mx18XZH0gnKPHkFuSgSTh+0zVQi/OL8gED 63dbH+AHavua4ORhPXaOvcyGCzqoOMY3NXkYegB0lfj5uv71DzJWniabDfwMAC7T O2rnhPk+iss2dCIpTFI6EqFL0BgFXpAV07nTCVyAYkrXnwlBusqc1TdZt8cVGoww lD284T9WhDsuFST7iDpZWW4LyqsPlW/TMkHFtHfYnR+Ta1SgFCE3CVNhiym9x+BD T51zPo1vfCMlz7yXCXTRaZ5eRJb3Bw/8DF5LwrFDMMuzHaEJCQGue9N4+ls+zc6J EzKZNToerXWrn4S+dvu/1ZMscb8g9Cq5CqJ5ZxOgu0nitbskbrY9UvfVBUIRBfdV m8c3ZO7LES5glVxF3zc1wouCwbTBFMb+sufukeGyIs62crDS6jwhIoKUPZcLOv9o HVHo2pjymwy8lKqI3TH+uyCl8xxVmUTfSonmJXmZU00AOc3fVI6E7pmniqXywgHF xSHTg3OiAzpd19jR611rX1shHvh1NFjTj4eeOduOCSbyempFJIIYf2uBxF5Q0uJ6 YK1/bKWIxdJG/qsxTW1duE1Jf+uHs2LTMwNp7HgSQhVHywPK4uJnL6aZFOjLYXBP 8V9qJpBTQJfepCCuWZaeL6FV98cldKh3Eb6nEQbZVTN54RGDPYUH+diveZZVtbgD kfRcIp1XBi092REpDGyrA5FA2UQA2dj4aNu+ml1QLdb0GxECAdRoXpIpYHDxOv4s QXQZMF1O0bJmvUHaorqRD50K8L2B43yLspINbcv4+fuSHkq0mRaGQw6AVZiG9yO+ LYosc0PWkifPjGAsAZieN/Cm0oYnzkn2LOD3ugR0OivAvocPVPEO2xOlMV5hTUOv ebKxn40Zl7dNp/ZWjP3EMEXYB6K+Ol9uLXc5msvT8bw98xlCYMnuj0r3EBxcLN+T 8ZZrlhWMCzg= =clm/ -----END PGP PUBLIC KEY BLOCK----- ================================================ FILE: proto/.clang-format ================================================ BasedOnStyle: google ================================================ FILE: proto/.top_level ================================================ ================================================ FILE: proto/README.md ================================================ Protobuf files defining the interface the frontend and backend components use to talk to each other, and how Anki stores some of the data inside its SQLite database. These files are used to generate Rust, Python and TypeScript bindings. ================================================ FILE: proto/anki/ankidroid.proto ================================================ syntax = "proto3"; option java_multiple_files = true; import "anki/generic.proto"; import "anki/scheduler.proto"; package anki.ankidroid; service AnkidroidService { rpc RunDbCommand(generic.Json) returns (generic.Json); rpc RunDbCommandProto(generic.Json) returns (DbResponse); rpc InsertForId(generic.Json) returns (generic.Int64); rpc RunDbCommandForRowCount(generic.Json) returns (generic.Int64); rpc FlushAllQueries(generic.Empty) returns (generic.Empty); rpc FlushQuery(generic.Int32) returns (generic.Empty); rpc GetNextResultPage(GetNextResultPageRequest) returns (DbResponse); rpc GetColumnNamesFromQuery(generic.String) returns (generic.StringList); rpc GetActiveSequenceNumbers(generic.Empty) returns (GetActiveSequenceNumbersResponse); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendAnkidroidService { rpc SchedTimingTodayLegacy(SchedTimingTodayLegacyRequest) returns (scheduler.SchedTimingTodayResponse); rpc LocalMinutesWestLegacy(generic.Int64) returns (generic.Int32); rpc SetPageSize(generic.Int64) returns (generic.Empty); rpc DebugProduceError(generic.String) returns (generic.Empty); } message DebugActiveDatabaseSequenceNumbersResponse { repeated int32 sequence_numbers = 1; } message SchedTimingTodayLegacyRequest { int64 created_secs = 1; optional sint32 created_mins_west = 2; int64 now_secs = 3; sint32 now_mins_west = 4; sint32 rollover_hour = 5; } // We expect in Java: Null, String, Short, Int, Long, Float, Double, Boolean, // Blob (unused) We get: DbResult (Null, String, i64, f64, Vec), which // matches SQLite documentation message SqlValue { oneof Data { string stringValue = 1; int64 longValue = 2; double doubleValue = 3; bytes blobValue = 4; } } message Row { repeated SqlValue fields = 1; } message DbResult { repeated Row rows = 1; } message DbResponse { DbResult result = 1; int32 sequenceNumber = 2; int32 rowCount = 3; int64 startIndex = 4; } message GetNextResultPageRequest { int32 sequence = 1; int64 index = 2; } message GetActiveSequenceNumbersResponse { repeated int32 numbers = 1; } ================================================ FILE: proto/anki/ankihub.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; import "anki/generic.proto"; package anki.ankihub; service AnkiHubService {} service BackendAnkiHubService { rpc AnkihubLogin(LoginRequest) returns (LoginResponse); rpc AnkihubLogout(LogoutRequest) returns (generic.Empty); } message LoginResponse { string token = 1; } message LoginRequest { string id = 1; string password = 2; } message LogoutRequest { string token = 1; } ================================================ FILE: proto/anki/ankiweb.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.ankiweb; service AnkiwebService {} service BackendAnkiwebService { // Fetch info on add-ons from AnkiWeb. A maximum of 25 can be queried at one // time. If an add-on doesn't have a branch compatible with the provided // version, that add-on will not be included in the returned list. rpc GetAddonInfo(GetAddonInfoRequest) returns (GetAddonInfoResponse); rpc CheckForUpdate(CheckForUpdateRequest) returns (CheckForUpdateResponse); } message GetAddonInfoRequest { uint32 client_version = 1; repeated uint32 addon_ids = 2; } message GetAddonInfoResponse { repeated AddonInfo info = 1; } message AddonInfo { uint32 id = 1; int64 modified = 2; uint32 min_version = 3; uint32 max_version = 4; } message CheckForUpdateRequest { uint32 version = 1; string buildhash = 2; string os = 3; int64 install_id = 4; uint32 last_message_id = 5; } message CheckForUpdateResponse { optional string new_version = 1; int64 current_time = 2; optional string message = 3; uint32 last_message_id = 4; } ================================================ FILE: proto/anki/backend.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.backend; import "anki/links.proto"; message BackendInit { repeated string preferred_langs = 1; string locale_folder_path = 2; bool server = 3; } message I18nBackendInit { repeated string preferred_langs = 4; string locale_folder_path = 5; } message BackendError { enum Kind { INVALID_INPUT = 0; UNDO_EMPTY = 1; INTERRUPTED = 2; TEMPLATE_PARSE = 3; IO_ERROR = 4; DB_ERROR = 5; NETWORK_ERROR = 6; SYNC_AUTH_ERROR = 7; SYNC_SERVER_MESSAGE = 23; SYNC_OTHER_ERROR = 8; JSON_ERROR = 9; PROTO_ERROR = 10; NOT_FOUND_ERROR = 11; EXISTS = 12; FILTERED_DECK_ERROR = 13; SEARCH_ERROR = 14; CUSTOM_STUDY_ERROR = 15; IMPORT_ERROR = 16; DELETED = 17; CARD_TYPE_ERROR = 18; ANKIDROID_PANIC_ERROR = 19; // Originated from and usually specific to the OS. OS_ERROR = 20; SCHEDULER_UPGRADE_REQUIRED = 21; INVALID_CERTIFICATE_FORMAT = 22; } // error description, usually localized, suitable for displaying to the user string message = 1; // the error subtype Kind kind = 2; // optional page in the manual optional links.HelpPageLinkRequest.HelpPage help_page = 3; // additional information about the context in which the error occurred string context = 4; // a backtrace of the underlying error; requires RUST_BACKTRACE to be set string backtrace = 5; } ================================================ FILE: proto/anki/card_rendering.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.card_rendering; import "anki/generic.proto"; import "anki/notes.proto"; import "anki/notetypes.proto"; service CardRenderingService { rpc ExtractAvTags(ExtractAvTagsRequest) returns (ExtractAvTagsResponse); rpc ExtractLatex(ExtractLatexRequest) returns (ExtractLatexResponse); rpc GetEmptyCards(generic.Empty) returns (EmptyCardsReport); rpc RenderExistingCard(RenderExistingCardRequest) returns (RenderCardResponse); rpc RenderUncommittedCard(RenderUncommittedCardRequest) returns (RenderCardResponse); rpc RenderUncommittedCardLegacy(RenderUncommittedCardLegacyRequest) returns (RenderCardResponse); rpc StripAvTags(generic.String) returns (generic.String); rpc RenderMarkdown(RenderMarkdownRequest) returns (generic.String); rpc EncodeIriPaths(generic.String) returns (generic.String); rpc DecodeIriPaths(generic.String) returns (generic.String); rpc StripHtml(StripHtmlRequest) returns (generic.String); rpc HtmlToTextLine(HtmlToTextLineRequest) returns (generic.String); rpc CompareAnswer(CompareAnswerRequest) returns (generic.String); rpc ExtractClozeForTyping(ExtractClozeForTypingRequest) returns (generic.String); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendCardRenderingService { rpc StripHtml(StripHtmlRequest) returns (generic.String); rpc AllTtsVoices(AllTtsVoicesRequest) returns (AllTtsVoicesResponse); rpc WriteTtsStream(WriteTtsStreamRequest) returns (generic.Empty); } message ExtractAvTagsRequest { string text = 1; bool question_side = 2; } message ExtractAvTagsResponse { string text = 1; repeated AVTag av_tags = 2; } message AVTag { oneof value { string sound_or_video = 1; TTSTag tts = 2; } } message TTSTag { string field_text = 1; string lang = 2; repeated string voices = 3; float speed = 4; repeated string other_args = 5; } message ExtractLatexRequest { string text = 1; bool svg = 2; bool expand_clozes = 3; } message ExtractLatexResponse { string text = 1; repeated ExtractedLatex latex = 2; } message ExtractedLatex { string filename = 1; string latex_body = 2; } message EmptyCardsReport { message NoteWithEmptyCards { int64 note_id = 1; repeated int64 card_ids = 2; bool will_delete_note = 3; } string report = 1; repeated NoteWithEmptyCards notes = 2; } message RenderExistingCardRequest { int64 card_id = 1; bool browser = 2; // If true, rendering will stop when an unknown filter is encountered, // and caller will need to complete rendering. This is done to allow // Python code to modify the rendering. bool partial_render = 3; } message RenderUncommittedCardRequest { notes.Note note = 1; uint32 card_ord = 2; notetypes.Notetype.Template template = 3; bool fill_empty = 4; // If true, rendering will stop when an unknown filter is encountered, // and caller will need to complete rendering. This is done to allow // Python code to modify the rendering. bool partial_render = 5; } message RenderUncommittedCardLegacyRequest { notes.Note note = 1; uint32 card_ord = 2; bytes template = 3; bool fill_empty = 4; // If true, rendering will stop when an unknown filter is encountered, // and caller will need to complete rendering. This is done to allow // Python code to modify the rendering. bool partial_render = 5; } message RenderCardResponse { repeated RenderedTemplateNode question_nodes = 1; repeated RenderedTemplateNode answer_nodes = 2; string css = 3; bool latex_svg = 4; bool is_empty = 5; } message RenderedTemplateNode { oneof value { string text = 1; RenderedTemplateReplacement replacement = 2; } } message RenderedTemplateReplacement { string field_name = 1; string current_text = 2; repeated string filters = 3; } message RenderMarkdownRequest { string markdown = 1; bool sanitize = 2; } message StripHtmlRequest { enum Mode { NORMAL = 0; PRESERVE_MEDIA_FILENAMES = 1; } string text = 1; Mode mode = 2; } message HtmlToTextLineRequest { string text = 1; bool preserve_media_filenames = 2; } message CompareAnswerRequest { string expected = 1; string provided = 2; bool combining = 3; } message ExtractClozeForTypingRequest { string text = 1; uint32 ordinal = 2; } message AllTtsVoicesRequest { bool validate = 1; } message AllTtsVoicesResponse { message TtsVoice { string id = 1; string name = 2; string language = 3; optional bool available = 4; } repeated TtsVoice voices = 1; } message WriteTtsStreamRequest { string path = 1; string voice_id = 2; float speed = 3; string text = 4; } ================================================ FILE: proto/anki/cards.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.cards; import "anki/collection.proto"; service CardsService { rpc GetCard(CardId) returns (Card); rpc UpdateCards(UpdateCardsRequest) returns (collection.OpChanges); rpc RemoveCards(RemoveCardsRequest) returns (collection.OpChangesWithCount); rpc SetDeck(SetDeckRequest) returns (collection.OpChangesWithCount); rpc SetFlag(SetFlagRequest) returns (collection.OpChangesWithCount); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendCardsService {} message CardId { int64 cid = 1; } message CardIds { repeated int64 cids = 1; } message Card { int64 id = 1; int64 note_id = 2; int64 deck_id = 3; uint32 template_idx = 4; int64 mtime_secs = 5; sint32 usn = 6; uint32 ctype = 7; sint32 queue = 8; sint32 due = 9; uint32 interval = 10; uint32 ease_factor = 11; uint32 reps = 12; uint32 lapses = 13; uint32 remaining_steps = 14; sint32 original_due = 15; int64 original_deck_id = 16; uint32 flags = 17; optional uint32 original_position = 18; optional FsrsMemoryState memory_state = 20; optional float desired_retention = 21; optional float decay = 22; optional int64 last_review_time_secs = 23; string custom_data = 19; } message FsrsMemoryState { float stability = 1; float difficulty = 2; } message UpdateCardsRequest { repeated Card cards = 1; bool skip_undo_entry = 2; } message RemoveCardsRequest { repeated int64 card_ids = 1; } message SetDeckRequest { repeated int64 card_ids = 1; int64 deck_id = 2; } message SetFlagRequest { repeated int64 card_ids = 1; uint32 flag = 2; } ================================================ FILE: proto/anki/collection.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.collection; import "anki/generic.proto"; import "anki/sync.proto"; service CollectionService { rpc CheckDatabase(generic.Empty) returns (CheckDatabaseResponse); rpc GetUndoStatus(generic.Empty) returns (UndoStatus); rpc Undo(generic.Empty) returns (OpChangesAfterUndo); rpc Redo(generic.Empty) returns (OpChangesAfterUndo); rpc AddCustomUndoEntry(generic.String) returns (generic.UInt32); rpc MergeUndoEntries(generic.UInt32) returns (OpChanges); rpc LatestProgress(generic.Empty) returns (Progress); rpc SetWantsAbort(generic.Empty) returns (generic.Empty); rpc SetLoadBalancerEnabled(generic.Bool) returns (OpChanges); rpc GetCustomColours(generic.Empty) returns (GetCustomColoursResponse); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendCollectionService { rpc OpenCollection(OpenCollectionRequest) returns (generic.Empty); rpc CloseCollection(CloseCollectionRequest) returns (generic.Empty); // Create a no-media backup. Caller must ensure there is no active // transaction. Unlike a collection export, does not require reopening the DB, // as there is no downgrade step. // Returns false if it's not time to make a backup yet. rpc CreateBackup(CreateBackupRequest) returns (generic.Bool); // If a backup is running, wait for it to complete. Will return an error // if the backup encountered an error. rpc AwaitBackupCompletion(generic.Empty) returns (generic.Empty); rpc LatestProgress(generic.Empty) returns (Progress); rpc SetWantsAbort(generic.Empty) returns (generic.Empty); } message OpenCollectionRequest { string collection_path = 1; string media_folder_path = 2; string media_db_path = 3; } message CloseCollectionRequest { bool downgrade_to_schema11 = 1; } message CheckDatabaseResponse { repeated string problems = 1; } message OpChanges { bool card = 1; bool note = 2; bool deck = 3; bool tag = 4; bool notetype = 5; bool config = 6; bool deck_config = 11; bool mtime = 12; bool browser_table = 7; bool browser_sidebar = 8; // editor and displayed card in review screen bool note_text = 9; // whether to call .reset() and getCard() bool study_queues = 10; } // Allows frontend code to extract changes from other messages like // ImportResponse without decoding other potentially large fields. message OpChangesOnly { collection.OpChanges changes = 1; } message OpChangesWithCount { OpChanges changes = 1; uint32 count = 2; } message OpChangesWithId { OpChanges changes = 1; int64 id = 2; } message UndoStatus { string undo = 1; string redo = 2; uint32 last_step = 3; } message OpChangesAfterUndo { OpChanges changes = 1; string operation = 2; int64 reverted_to_timestamp = 3; UndoStatus new_status = 4; uint32 counter = 5; } message Progress { message FullSync { uint32 transferred = 1; uint32 total = 2; } message NormalSync { string stage = 1; string added = 2; string removed = 3; } message DatabaseCheck { string stage = 1; uint32 stage_total = 2; uint32 stage_current = 3; } oneof value { generic.Empty none = 1; sync.MediaSyncProgress media_sync = 2; string media_check = 3; FullSync full_sync = 4; NormalSync normal_sync = 5; DatabaseCheck database_check = 6; string importing = 7; string exporting = 8; ComputeParamsProgress compute_params = 9; ComputeRetentionProgress compute_retention = 10; ComputeMemoryProgress compute_memory = 11; } } message ComputeParamsProgress { // Current iteration uint32 current = 1; // Total iterations uint32 total = 2; uint32 reviews = 3; // Only used in 'compute all params' case uint32 current_preset = 4; // Only used in 'compute all params' case uint32 total_presets = 5; } message ComputeRetentionProgress { uint32 current = 1; uint32 total = 2; } message ComputeMemoryProgress { uint32 current_cards = 1; uint32 total_cards = 2; string label = 3; } message CreateBackupRequest { string backup_folder = 1; // Create a backup even if the configured interval hasn't elapsed yet. bool force = 2; bool wait_for_completion = 3; } message GetCustomColoursResponse { repeated string colours = 1; } ================================================ FILE: proto/anki/config.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.config; import "anki/generic.proto"; import "anki/collection.proto"; service ConfigService { rpc GetConfigJson(generic.String) returns (generic.Json); rpc SetConfigJson(SetConfigJsonRequest) returns (collection.OpChanges); rpc SetConfigJsonNoUndo(SetConfigJsonRequest) returns (generic.Empty); rpc RemoveConfig(generic.String) returns (collection.OpChanges); rpc GetAllConfig(generic.Empty) returns (generic.Json); rpc GetConfigBool(GetConfigBoolRequest) returns (generic.Bool); rpc SetConfigBool(SetConfigBoolRequest) returns (collection.OpChanges); rpc GetConfigString(GetConfigStringRequest) returns (generic.String); rpc SetConfigString(SetConfigStringRequest) returns (collection.OpChanges); rpc GetPreferences(generic.Empty) returns (Preferences); rpc SetPreferences(Preferences) returns (collection.OpChanges); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendConfigService {} message ConfigKey { enum Bool { BROWSER_TABLE_SHOW_NOTES_MODE = 0; PREVIEW_BOTH_SIDES = 3; COLLAPSE_TAGS = 4; COLLAPSE_NOTETYPES = 5; COLLAPSE_DECKS = 6; COLLAPSE_SAVED_SEARCHES = 7; COLLAPSE_TODAY = 8; COLLAPSE_CARD_STATE = 9; COLLAPSE_FLAGS = 10; SCHED_2021 = 11; ADDING_DEFAULTS_TO_CURRENT_DECK = 12; HIDE_AUDIO_PLAY_BUTTONS = 13; INTERRUPT_AUDIO_WHEN_ANSWERING = 14; PASTE_IMAGES_AS_PNG = 15; PASTE_STRIPS_FORMATTING = 16; NORMALIZE_NOTE_TEXT = 17; IGNORE_ACCENTS_IN_SEARCH = 18; RESTORE_POSITION_BROWSER = 19; RESTORE_POSITION_REVIEWER = 20; RESET_COUNTS_BROWSER = 21; RESET_COUNTS_REVIEWER = 22; RANDOM_ORDER_REPOSITION = 23; SHIFT_POSITION_OF_EXISTING_CARDS = 24; RENDER_LATEX = 25; LOAD_BALANCER_ENABLED = 26; FSRS_SHORT_TERM_WITH_STEPS_ENABLED = 27; FSRS_LEGACY_EVALUATE = 28; } enum String { SET_DUE_BROWSER = 0; SET_DUE_REVIEWER = 1; DEFAULT_SEARCH_TEXT = 2; CARD_STATE_CUSTOMIZER = 3; } } message GetConfigBoolRequest { ConfigKey.Bool key = 1; } message SetConfigBoolRequest { ConfigKey.Bool key = 1; bool value = 2; bool undoable = 3; } message GetConfigStringRequest { ConfigKey.String key = 1; } message SetConfigStringRequest { ConfigKey.String key = 1; string value = 2; bool undoable = 3; } message OptionalStringConfigKey { ConfigKey.String key = 1; } message SetConfigJsonRequest { string key = 1; bytes value_json = 2; bool undoable = 3; } message Preferences { message Scheduling { enum NewReviewMix { DISTRIBUTE = 0; REVIEWS_FIRST = 1; NEW_FIRST = 2; } uint32 rollover = 2; uint32 learn_ahead_secs = 3; NewReviewMix new_review_mix = 4; // v2 only bool new_timezone = 5; bool day_learn_first = 6; } message Reviewing { bool hide_audio_play_buttons = 1; bool interrupt_audio_when_answering = 2; bool show_remaining_due_counts = 3; bool show_intervals_on_buttons = 4; uint32 time_limit_secs = 5; bool load_balancer_enabled = 6; bool fsrs_short_term_with_steps_enabled = 7; } message Editing { bool adding_defaults_to_current_deck = 1; bool paste_images_as_png = 2; bool paste_strips_formatting = 3; string default_search_text = 4; bool ignore_accents_in_search = 5; bool render_latex = 6; } message BackupLimits { uint32 daily = 1; uint32 weekly = 2; uint32 monthly = 3; uint32 minimum_interval_mins = 4; } Scheduling scheduling = 1; Reviewing reviewing = 2; Editing editing = 3; BackupLimits backups = 4; } ================================================ FILE: proto/anki/deck_config.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; // the DeckConfig message clashes with the name of the file option java_outer_classname = "DeckConf"; package anki.deck_config; import "anki/generic.proto"; import "anki/collection.proto"; import "anki/decks.proto"; service DeckConfigService { rpc AddOrUpdateDeckConfigLegacy(generic.Json) returns (DeckConfigId); rpc GetDeckConfig(DeckConfigId) returns (DeckConfig); rpc AllDeckConfigLegacy(generic.Empty) returns (generic.Json); rpc GetDeckConfigLegacy(DeckConfigId) returns (generic.Json); rpc NewDeckConfigLegacy(generic.Empty) returns (generic.Json); rpc RemoveDeckConfig(DeckConfigId) returns (generic.Empty); rpc GetDeckConfigsForUpdate(decks.DeckId) returns (DeckConfigsForUpdate); rpc UpdateDeckConfigs(UpdateDeckConfigsRequest) returns (collection.OpChanges); rpc GetIgnoredBeforeCount(GetIgnoredBeforeCountRequest) returns (GetIgnoredBeforeCountResponse); rpc GetRetentionWorkload(GetRetentionWorkloadRequest) returns (GetRetentionWorkloadResponse); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendDeckConfigService {} message DeckConfigId { int64 dcid = 1; } message GetRetentionWorkloadRequest { repeated float w = 1; string search = 2; } message GetRetentionWorkloadResponse { map costs = 1; } message GetIgnoredBeforeCountRequest { string ignore_revlogs_before_date = 1; string search = 2; } message GetIgnoredBeforeCountResponse { uint64 included = 1; uint64 total = 2; } message DeckConfig { message Config { enum NewCardInsertOrder { NEW_CARD_INSERT_ORDER_DUE = 0; NEW_CARD_INSERT_ORDER_RANDOM = 1; } enum NewCardGatherPriority { // Decks in alphabetical order (preorder), then ascending position. // Siblings are consecutive, provided they have the same position. NEW_CARD_GATHER_PRIORITY_DECK = 0; // Notes are randomly picked from each deck in alphabetical order. // Siblings are consecutive, provided they have the same position. NEW_CARD_GATHER_PRIORITY_DECK_THEN_RANDOM_NOTES = 5; // Ascending position. // Siblings are consecutive, provided they have the same position. NEW_CARD_GATHER_PRIORITY_LOWEST_POSITION = 1; // Descending position. // Siblings are consecutive, provided they have the same position. NEW_CARD_GATHER_PRIORITY_HIGHEST_POSITION = 2; // Siblings are consecutive. NEW_CARD_GATHER_PRIORITY_RANDOM_NOTES = 3; // Siblings are neither grouped nor ordered. NEW_CARD_GATHER_PRIORITY_RANDOM_CARDS = 4; } enum NewCardSortOrder { // Ascending card template ordinal. // For a given ordinal, cards appear in gather order. NEW_CARD_SORT_ORDER_TEMPLATE = 0; // Preserves original gather order (eg deck order). NEW_CARD_SORT_ORDER_NO_SORT = 1; // Ascending card template ordinal. // For a given ordinal, cards appear in random order. NEW_CARD_SORT_ORDER_TEMPLATE_THEN_RANDOM = 2; // Random note order. For a given note, cards appear in template order. NEW_CARD_SORT_ORDER_RANDOM_NOTE_THEN_TEMPLATE = 3; // Fully randomized order. NEW_CARD_SORT_ORDER_RANDOM_CARD = 4; } enum ReviewCardOrder { REVIEW_CARD_ORDER_DAY = 0; REVIEW_CARD_ORDER_DAY_THEN_DECK = 1; REVIEW_CARD_ORDER_DECK_THEN_DAY = 2; REVIEW_CARD_ORDER_INTERVALS_ASCENDING = 3; REVIEW_CARD_ORDER_INTERVALS_DESCENDING = 4; REVIEW_CARD_ORDER_EASE_ASCENDING = 5; REVIEW_CARD_ORDER_EASE_DESCENDING = 6; REVIEW_CARD_ORDER_RETRIEVABILITY_ASCENDING = 7; REVIEW_CARD_ORDER_RETRIEVABILITY_DESCENDING = 11; REVIEW_CARD_ORDER_RELATIVE_OVERDUENESS = 12; REVIEW_CARD_ORDER_RANDOM = 8; REVIEW_CARD_ORDER_ADDED = 9; REVIEW_CARD_ORDER_REVERSE_ADDED = 10; } enum ReviewMix { REVIEW_MIX_MIX_WITH_REVIEWS = 0; REVIEW_MIX_AFTER_REVIEWS = 1; REVIEW_MIX_BEFORE_REVIEWS = 2; } enum LeechAction { LEECH_ACTION_SUSPEND = 0; LEECH_ACTION_TAG_ONLY = 1; } enum AnswerAction { ANSWER_ACTION_BURY_CARD = 0; ANSWER_ACTION_ANSWER_AGAIN = 1; ANSWER_ACTION_ANSWER_GOOD = 2; ANSWER_ACTION_ANSWER_HARD = 3; ANSWER_ACTION_SHOW_REMINDER = 4; } enum QuestionAction { QUESTION_ACTION_SHOW_ANSWER = 0; QUESTION_ACTION_SHOW_REMINDER = 1; } repeated float learn_steps = 1; repeated float relearn_steps = 2; repeated float fsrs_params_4 = 3; repeated float fsrs_params_5 = 5; repeated float fsrs_params_6 = 6; // consider saving remaining ones for fsrs param changes reserved 7 to 8; uint32 new_per_day = 9; uint32 reviews_per_day = 10; // not currently used uint32 new_per_day_minimum = 35; float initial_ease = 11; float easy_multiplier = 12; float hard_multiplier = 13; float lapse_multiplier = 14; float interval_multiplier = 15; uint32 maximum_review_interval = 16; uint32 minimum_lapse_interval = 17; uint32 graduating_interval_good = 18; uint32 graduating_interval_easy = 19; NewCardInsertOrder new_card_insert_order = 20; NewCardGatherPriority new_card_gather_priority = 34; NewCardSortOrder new_card_sort_order = 32; ReviewMix new_mix = 30; ReviewCardOrder review_order = 33; ReviewMix interday_learning_mix = 31; LeechAction leech_action = 21; uint32 leech_threshold = 22; bool disable_autoplay = 23; uint32 cap_answer_time_to_secs = 24; bool show_timer = 25; bool stop_timer_on_answer = 38; float seconds_to_show_question = 41; float seconds_to_show_answer = 42; QuestionAction question_action = 36; AnswerAction answer_action = 43; bool wait_for_audio = 44; bool skip_question_when_replaying_answer = 26; bool bury_new = 27; bool bury_reviews = 28; bool bury_interday_learning = 29; // for fsrs float desired_retention = 37; string ignore_revlogs_before_date = 46; repeated float easy_days_percentages = 4; // used for fsrs_reschedule in the past reserved 39; float historical_retention = 40; string param_search = 45; bytes other = 255; } int64 id = 1; string name = 2; int64 mtime_secs = 3; int32 usn = 4; Config config = 5; } message DeckConfigsForUpdate { message ConfigWithExtra { DeckConfig config = 1; uint32 use_count = 2; } message CurrentDeck { message Limits { optional uint32 review = 1; optional uint32 new = 2; optional uint32 review_today = 3; optional uint32 new_today = 4; // Whether review_today applies to today or a past day. bool review_today_active = 5; // Whether new_today applies to today or a past day. bool new_today_active = 6; // Deck-specific desired retention override optional float desired_retention = 7; } string name = 1; int64 config_id = 2; repeated int64 parent_config_ids = 3; Limits limits = 4; } repeated ConfigWithExtra all_config = 1; CurrentDeck current_deck = 2; DeckConfig defaults = 3; bool schema_modified = 4; // only applies to v3 scheduler string card_state_customizer = 6; // only applies to v3 scheduler bool new_cards_ignore_review_limit = 7; bool fsrs = 8; bool fsrs_health_check = 11; bool fsrs_legacy_evaluate = 12; bool apply_all_parent_limits = 9; uint32 days_since_last_fsrs_optimize = 10; } enum UpdateDeckConfigsMode { UPDATE_DECK_CONFIGS_MODE_NORMAL = 0; UPDATE_DECK_CONFIGS_MODE_APPLY_TO_CHILDREN = 1; UPDATE_DECK_CONFIGS_MODE_COMPUTE_ALL_PARAMS = 2; } message UpdateDeckConfigsRequest { int64 target_deck_id = 1; /// Unchanged, non-selected configs can be omitted. Deck will /// be set to whichever entry comes last. repeated DeckConfig configs = 2; repeated int64 removed_config_ids = 3; UpdateDeckConfigsMode mode = 4; string card_state_customizer = 5; DeckConfigsForUpdate.CurrentDeck.Limits limits = 6; bool new_cards_ignore_review_limit = 7; bool fsrs = 8; bool apply_all_parent_limits = 9; bool fsrs_reschedule = 10; bool fsrs_health_check = 11; } ================================================ FILE: proto/anki/decks.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.decks; import "anki/generic.proto"; import "anki/collection.proto"; service DecksService { rpc NewDeck(generic.Empty) returns (Deck); rpc AddDeck(Deck) returns (collection.OpChangesWithId); rpc AddDeckLegacy(generic.Json) returns (collection.OpChangesWithId); rpc AddOrUpdateDeckLegacy(AddOrUpdateDeckLegacyRequest) returns (DeckId); rpc DeckTree(DeckTreeRequest) returns (DeckTreeNode); rpc DeckTreeLegacy(generic.Empty) returns (generic.Json); rpc GetAllDecksLegacy(generic.Empty) returns (generic.Json); rpc GetDeckIdByName(generic.String) returns (DeckId); rpc GetDeck(DeckId) returns (Deck); rpc UpdateDeck(Deck) returns (collection.OpChanges); rpc UpdateDeckLegacy(generic.Json) returns (collection.OpChanges); rpc SetDeckCollapsed(SetDeckCollapsedRequest) returns (collection.OpChanges); rpc GetDeckLegacy(DeckId) returns (generic.Json); rpc GetDeckNames(GetDeckNamesRequest) returns (DeckNames); rpc GetDeckAndChildNames(DeckId) returns (DeckNames); rpc NewDeckLegacy(generic.Bool) returns (generic.Json); rpc RemoveDecks(DeckIds) returns (collection.OpChangesWithCount); rpc ReparentDecks(ReparentDecksRequest) returns (collection.OpChangesWithCount); rpc RenameDeck(RenameDeckRequest) returns (collection.OpChanges); rpc GetOrCreateFilteredDeck(DeckId) returns (FilteredDeckForUpdate); rpc AddOrUpdateFilteredDeck(FilteredDeckForUpdate) returns (collection.OpChangesWithId); rpc FilteredDeckOrderLabels(generic.Empty) returns (generic.StringList); rpc SetCurrentDeck(DeckId) returns (collection.OpChanges); rpc GetCurrentDeck(generic.Empty) returns (Deck); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendDecksService {} message DeckId { int64 did = 1; } message DeckIds { repeated int64 dids = 1; } message Deck { message Common { bool study_collapsed = 1; bool browser_collapsed = 2; uint32 last_day_studied = 3; int32 new_studied = 4; int32 review_studied = 5; int32 milliseconds_studied = 7; // previously set in the v1 scheduler, // but not currently used for anything int32 learning_studied = 6; reserved 8 to 13; bytes other = 255; } message Normal { message DayLimit { uint32 limit = 1; uint32 today = 2; } int64 config_id = 1; uint32 extend_new = 2; uint32 extend_review = 3; string description = 4; bool markdown_description = 5; optional uint32 review_limit = 6; optional uint32 new_limit = 7; DayLimit review_limit_today = 8; DayLimit new_limit_today = 9; // Deck-specific desired retention override optional float desired_retention = 10; reserved 12 to 15; } message Filtered { message SearchTerm { enum Order { OLDEST_REVIEWED_FIRST = 0; RANDOM = 1; INTERVALS_ASCENDING = 2; INTERVALS_DESCENDING = 3; LAPSES = 4; ADDED = 5; DUE = 6; REVERSE_ADDED = 7; RETRIEVABILITY_ASCENDING = 8; RETRIEVABILITY_DESCENDING = 9; RELATIVE_OVERDUENESS = 10; } string search = 1; uint32 limit = 2; Order order = 3; } bool reschedule = 1; repeated SearchTerm search_terms = 2; // v1 scheduler only repeated float delays = 3; // v2 and old v3 scheduler only uint32 preview_delay = 4; // recent v3 scheduler only; 0 means card will be returned uint32 preview_again_secs = 7; // recent v3 scheduler only; 0 means card will be returned uint32 preview_hard_secs = 5; // recent v3 scheduler only; 0 means card will be returned uint32 preview_good_secs = 6; } // a container to store the deck specifics in the DB // as a tagged enum message KindContainer { oneof kind { Normal normal = 1; Filtered filtered = 2; } } int64 id = 1; string name = 2; int64 mtime_secs = 3; int32 usn = 4; Common common = 5; // the specifics are inlined here when sending data to clients, // as otherwise an extra level of indirection would be required oneof kind { Normal normal = 6; Filtered filtered = 7; } } message AddOrUpdateDeckLegacyRequest { bytes deck = 1; bool preserve_usn_and_mtime = 2; } message DeckTreeRequest { // if non-zero, counts for the provided timestamp will be included int64 now = 1; } message DeckTreeNode { int64 deck_id = 1; string name = 2; uint32 level = 4; bool collapsed = 5; // counts after adding children+applying limits uint32 review_count = 6; uint32 learn_count = 7; uint32 new_count = 8; // card counts without children or limits applied uint32 intraday_learning = 9; uint32 interday_learning_uncapped = 10; uint32 new_uncapped = 11; uint32 review_uncapped = 12; uint32 total_in_deck = 13; // with children, without any limits uint32 total_including_children = 14; bool filtered = 16; // low index so key can be packed into a byte, but at bottom // to make debug output easier to read repeated DeckTreeNode children = 3; } message SetDeckCollapsedRequest { enum Scope { REVIEWER = 0; BROWSER = 1; } int64 deck_id = 1; bool collapsed = 2; Scope scope = 3; } message GetDeckNamesRequest { bool skip_empty_default = 1; // if unset, implies skip_empty_default bool include_filtered = 2; } message DeckNames { repeated DeckNameId entries = 1; } message DeckNameId { int64 id = 1; string name = 2; } message ReparentDecksRequest { repeated int64 deck_ids = 1; int64 new_parent = 2; } message RenameDeckRequest { int64 deck_id = 1; string new_name = 2; } message FilteredDeckForUpdate { int64 id = 1; string name = 2; Deck.Filtered config = 3; bool allow_empty = 4; } ================================================ FILE: proto/anki/frontend.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.frontend; import "anki/scheduler.proto"; import "anki/generic.proto"; import "anki/search.proto"; service FrontendService { // Returns values from the reviewer rpc GetSchedulingStatesWithContext(generic.Empty) returns (SchedulingStatesWithContext); // Updates reviewer state rpc SetSchedulingStates(SetSchedulingStatesRequest) returns (generic.Empty); // Notify Qt layer so window modality can be updated. rpc ImportDone(generic.Empty) returns (generic.Empty); rpc SearchInBrowser(search.SearchNode) returns (generic.Empty); // Force closing the deck options. rpc deckOptionsRequireClose(generic.Empty) returns (generic.Empty); // Warns python that the deck option web view is ready to receive requests. rpc deckOptionsReady(generic.Empty) returns (generic.Empty); // Save colour picker's custom colour palette rpc SaveCustomColours(generic.Empty) returns (generic.Empty); } service BackendFrontendService {} message SchedulingStatesWithContext { scheduler.SchedulingStates states = 1; scheduler.SchedulingContext context = 2; } message SetSchedulingStatesRequest { string key = 1; scheduler.SchedulingStates states = 2; } ================================================ FILE: proto/anki/generic.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.generic; message Empty {} message Int32 { sint32 val = 1; } message UInt32 { uint32 val = 1; } message Int64 { int64 val = 1; } message String { string val = 1; } message Json { bytes json = 1; } message Bool { bool val = 1; } message StringList { repeated string vals = 1; } ================================================ FILE: proto/anki/i18n.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.i18n; import "anki/generic.proto"; service I18nService { rpc TranslateString(TranslateStringRequest) returns (generic.String); rpc FormatTimespan(FormatTimespanRequest) returns (generic.String); rpc I18nResources(I18nResourcesRequest) returns (generic.Json); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendI18nService { rpc TranslateString(TranslateStringRequest) returns (generic.String); rpc FormatTimespan(FormatTimespanRequest) returns (generic.String); rpc I18nResources(I18nResourcesRequest) returns (generic.Json); } message TranslateStringRequest { uint32 module_index = 1; uint32 message_index = 2; map args = 3; } message TranslateArgValue { oneof value { string str = 1; double number = 2; } } message FormatTimespanRequest { enum Context { PRECISE = 0; ANSWER_BUTTONS = 1; INTERVALS = 2; } float seconds = 1; Context context = 2; } message I18nResourcesRequest { repeated string modules = 1; } ================================================ FILE: proto/anki/image_occlusion.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.image_occlusion; import "anki/collection.proto"; import "anki/generic.proto"; service ImageOcclusionService { rpc GetImageForOcclusion(GetImageForOcclusionRequest) returns (GetImageForOcclusionResponse); rpc GetImageOcclusionNote(GetImageOcclusionNoteRequest) returns (GetImageOcclusionNoteResponse); rpc GetImageOcclusionFields(GetImageOcclusionFieldsRequest) returns (GetImageOcclusionFieldsResponse); // Adds an I/O notetype if none exists in the collection. rpc AddImageOcclusionNotetype(generic.Empty) returns (collection.OpChanges); // These two are used by the standalone I/O page, but not used when using // I/O inside Anki's editor rpc AddImageOcclusionNote(AddImageOcclusionNoteRequest) returns (collection.OpChanges); rpc UpdateImageOcclusionNote(UpdateImageOcclusionNoteRequest) returns (collection.OpChanges); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendImageOcclusionService {} message GetImageForOcclusionRequest { string path = 1; } message GetImageForOcclusionResponse { bytes data = 1; string name = 2; } message AddImageOcclusionNoteRequest { string image_path = 1; string occlusions = 2; string header = 3; string back_extra = 4; repeated string tags = 5; int64 notetype_id = 6; } message GetImageOcclusionNoteRequest { int64 note_id = 1; } message GetImageOcclusionNoteResponse { message ImageOcclusionProperty { string name = 1; string value = 2; } message ImageOcclusionShape { string shape = 1; repeated ImageOcclusionProperty properties = 2; } message ImageOcclusion { repeated ImageOcclusionShape shapes = 1; uint32 ordinal = 2; } message ImageOcclusionNote { bytes image_data = 1; repeated ImageOcclusion occlusions = 2; string header = 3; string back_extra = 4; repeated string tags = 5; string image_file_name = 6; bool occlude_inactive = 7; } oneof value { ImageOcclusionNote note = 1; string error = 2; } } message UpdateImageOcclusionNoteRequest { int64 note_id = 1; string occlusions = 2; string header = 3; string back_extra = 4; repeated string tags = 5; } message GetImageOcclusionFieldsRequest { int64 notetype_id = 1; } message GetImageOcclusionFieldsResponse { ImageOcclusionFieldIndexes fields = 1; } message ImageOcclusionFieldIndexes { uint32 occlusions = 1; uint32 image = 2; uint32 header = 3; uint32 back_extra = 4; } ================================================ FILE: proto/anki/import_export.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.import_export; import "anki/cards.proto"; import "anki/collection.proto"; import "anki/notes.proto"; import "anki/generic.proto"; service ImportExportService { rpc ImportAnkiPackage(ImportAnkiPackageRequest) returns (ImportResponse); rpc GetImportAnkiPackagePresets(generic.Empty) returns (ImportAnkiPackageOptions); rpc ExportAnkiPackage(ExportAnkiPackageRequest) returns (generic.UInt32); rpc GetCsvMetadata(CsvMetadataRequest) returns (CsvMetadata); rpc ImportCsv(ImportCsvRequest) returns (ImportResponse); rpc ExportNoteCsv(ExportNoteCsvRequest) returns (generic.UInt32); rpc ExportCardCsv(ExportCardCsvRequest) returns (generic.UInt32); rpc ImportJsonFile(generic.String) returns (ImportResponse); rpc ImportJsonString(generic.String) returns (ImportResponse); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendImportExportService { rpc ImportCollectionPackage(ImportCollectionPackageRequest) returns (generic.Empty); rpc ExportCollectionPackage(ExportCollectionPackageRequest) returns (generic.Empty); } message ImportCollectionPackageRequest { string col_path = 1; string backup_path = 2; string media_folder = 3; string media_db = 4; } message ExportCollectionPackageRequest { string out_path = 1; bool include_media = 2; bool legacy = 3; } enum ImportAnkiPackageUpdateCondition { IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER = 0; IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_ALWAYS = 1; IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_NEVER = 2; } message ImportAnkiPackageOptions { bool merge_notetypes = 1; ImportAnkiPackageUpdateCondition update_notes = 2; ImportAnkiPackageUpdateCondition update_notetypes = 3; bool with_scheduling = 4; bool with_deck_configs = 5; } message ImportAnkiPackageRequest { string package_path = 1; ImportAnkiPackageOptions options = 2; } message ImportResponse { message Note { notes.NoteId id = 1; repeated string fields = 2; } message Log { repeated Note new = 1; repeated Note updated = 2; repeated Note duplicate = 3; repeated Note conflicting = 4; repeated Note first_field_match = 5; repeated Note missing_notetype = 6; repeated Note missing_deck = 7; repeated Note empty_first_field = 8; CsvMetadata.DupeResolution dupe_resolution = 9; uint32 found_notes = 10; } collection.OpChanges changes = 1; Log log = 2; } message ExportAnkiPackageRequest { string out_path = 1; ExportAnkiPackageOptions options = 2; ExportLimit limit = 3; } message ExportAnkiPackageOptions { bool with_scheduling = 1; bool with_deck_configs = 2; bool with_media = 3; bool legacy = 4; } message PackageMetadata { enum Version { VERSION_UNKNOWN = 0; // When `meta` missing, and collection.anki2 file present. VERSION_LEGACY_1 = 1; // When `meta` missing, and collection.anki21 file present. VERSION_LEGACY_2 = 2; // Implies MediaEntry media map, and zstd compression. // collection.21b file VERSION_LATEST = 3; } Version version = 1; } message MediaEntries { message MediaEntry { string name = 1; uint32 size = 2; bytes sha1 = 3; /// Legacy media maps may include gaps in the media list, so the original /// file index is recorded when importing from a HashMap. This field is not /// set when exporting. optional uint32 legacy_zip_filename = 255; } repeated MediaEntry entries = 1; } message ImportCsvRequest { string path = 1; CsvMetadata metadata = 2; } message CsvMetadataRequest { string path = 1; optional CsvMetadata.Delimiter delimiter = 2; optional int64 notetype_id = 3; optional int64 deck_id = 4; optional bool is_html = 5; } // Column indices are 1-based to make working with them in TS easier, where // unset numerical fields default to 0. message CsvMetadata { enum DupeResolution { UPDATE = 0; PRESERVE = 1; DUPLICATE = 2; // UPDATE_IF_NEWER = 3; } // Order roughly in ascending expected frequency in note text, because the // delimiter detection algorithm is stupidly picking the first one it // encounters. enum Delimiter { TAB = 0; PIPE = 1; SEMICOLON = 2; COLON = 3; COMMA = 4; SPACE = 5; } message MappedNotetype { int64 id = 1; // Source column indices for note fields. One-based. 0 means n/a. repeated uint32 field_columns = 2; } Delimiter delimiter = 1; bool is_html = 2; repeated string global_tags = 3; repeated string updated_tags = 4; // Column names as defined by the file or empty strings otherwise. Also used // to determine the number of columns. repeated string column_labels = 5; oneof deck { // id of an existing deck int64 deck_id = 6; // One-based. 0 means n/a. uint32 deck_column = 7; // name of new deck to be created string deck_name = 17; } oneof notetype { // One notetype for all rows with given column mapping. MappedNotetype global_notetype = 8; // Row-specific notetypes with automatic mapping by index. // One-based. 0 means n/a. uint32 notetype_column = 9; } enum MatchScope { NOTETYPE = 0; NOTETYPE_AND_DECK = 1; } // One-based. 0 means n/a. uint32 tags_column = 10; bool force_delimiter = 11; bool force_is_html = 12; repeated generic.StringList preview = 13; uint32 guid_column = 14; DupeResolution dupe_resolution = 15; MatchScope match_scope = 16; } message ExportCardCsvRequest { string out_path = 1; bool with_html = 2; ExportLimit limit = 3; } message ExportNoteCsvRequest { string out_path = 1; bool with_html = 2; bool with_tags = 3; bool with_deck = 4; bool with_notetype = 5; bool with_guid = 6; ExportLimit limit = 7; } message ExportLimit { oneof limit { generic.Empty whole_collection = 1; int64 deck_id = 2; notes.NoteIds note_ids = 3; cards.CardIds card_ids = 4; } } ================================================ FILE: proto/anki/links.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.links; import "anki/generic.proto"; service LinksService { rpc HelpPageLink(HelpPageLinkRequest) returns (generic.String); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendLinksService {} message HelpPageLinkRequest { enum HelpPage { NOTE_TYPE = 0; BROWSING = 1; BROWSING_FIND_AND_REPLACE = 2; BROWSING_NOTES_MENU = 3; KEYBOARD_SHORTCUTS = 4; EDITING = 5; ADDING_CARD_AND_NOTE = 6; ADDING_A_NOTE_TYPE = 7; LATEX = 8; PREFERENCES = 9; INDEX = 10; TEMPLATES = 11; FILTERED_DECK = 12; IMPORTING = 13; CUSTOMIZING_FIELDS = 14; DECK_OPTIONS = 15; EDITING_FEATURES = 16; FULL_SCREEN_ISSUE = 17; CARD_TYPE_DUPLICATE = 18; CARD_TYPE_NO_FRONT_FIELD = 19; CARD_TYPE_MISSING_CLOZE = 20; TROUBLESHOOTING = 21; CARD_TYPE_TEMPLATE_ERROR = 22; } HelpPage page = 1; } ================================================ FILE: proto/anki/media.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.media; import "anki/generic.proto"; import "anki/notetypes.proto"; service MediaService { rpc CheckMedia(generic.Empty) returns (CheckMediaResponse); rpc AddMediaFile(AddMediaFileRequest) returns (generic.String); rpc TrashMediaFiles(TrashMediaFilesRequest) returns (generic.Empty); rpc EmptyTrash(generic.Empty) returns (generic.Empty); rpc RestoreTrash(generic.Empty) returns (generic.Empty); rpc ExtractStaticMediaFiles(notetypes.NotetypeId) returns (generic.StringList); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendMediaService {} message CheckMediaResponse { repeated string unused = 1; repeated string missing = 2; repeated int64 missing_media_notes = 3; string report = 4; bool have_trash = 5; } message TrashMediaFilesRequest { repeated string fnames = 1; } message AddMediaFileRequest { string desired_name = 1; bytes data = 2; } ================================================ FILE: proto/anki/notes.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.notes; import "anki/notetypes.proto"; import "anki/collection.proto"; import "anki/decks.proto"; import "anki/cards.proto"; service NotesService { rpc NewNote(notetypes.NotetypeId) returns (Note); rpc AddNote(AddNoteRequest) returns (AddNoteResponse); rpc AddNotes(AddNotesRequest) returns (AddNotesResponse); rpc DefaultsForAdding(DefaultsForAddingRequest) returns (DeckAndNotetype); rpc DefaultDeckForNotetype(notetypes.NotetypeId) returns (decks.DeckId); rpc UpdateNotes(UpdateNotesRequest) returns (collection.OpChanges); rpc GetNote(NoteId) returns (Note); rpc RemoveNotes(RemoveNotesRequest) returns (collection.OpChangesWithCount); rpc ClozeNumbersInNote(Note) returns (ClozeNumbersInNoteResponse); rpc AfterNoteUpdates(AfterNoteUpdatesRequest) returns (collection.OpChangesWithCount); rpc FieldNamesForNotes(FieldNamesForNotesRequest) returns (FieldNamesForNotesResponse); rpc NoteFieldsCheck(Note) returns (NoteFieldsCheckResponse); rpc CardsOfNote(NoteId) returns (cards.CardIds); rpc GetSingleNotetypeOfNotes(notes.NoteIds) returns (notetypes.NotetypeId); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendNotesService {} message NoteId { int64 nid = 1; } message NoteIds { repeated int64 note_ids = 1; } message Note { int64 id = 1; string guid = 2; int64 notetype_id = 3; uint32 mtime_secs = 4; int32 usn = 5; repeated string tags = 6; repeated string fields = 7; } message AddNoteRequest { Note note = 1; int64 deck_id = 2; } message AddNoteResponse { collection.OpChangesWithCount changes = 1; int64 note_id = 2; } message AddNotesRequest { repeated AddNoteRequest requests = 1; } message AddNotesResponse { collection.OpChanges changes = 1; repeated int64 nids = 2; } message UpdateNotesRequest { repeated Note notes = 1; bool skip_undo_entry = 2; } message DefaultsForAddingRequest { int64 home_deck_of_current_review_card = 1; } message DeckAndNotetype { int64 deck_id = 1; int64 notetype_id = 2; } message RemoveNotesRequest { repeated int64 note_ids = 1; repeated int64 card_ids = 2; } message ClozeNumbersInNoteResponse { repeated uint32 numbers = 1; } message AfterNoteUpdatesRequest { repeated int64 nids = 1; bool mark_notes_modified = 2; bool generate_cards = 3; } message FieldNamesForNotesRequest { repeated int64 nids = 1; } message FieldNamesForNotesResponse { repeated string fields = 1; } message NoteFieldsCheckResponse { enum State { NORMAL = 0; EMPTY = 1; DUPLICATE = 2; MISSING_CLOZE = 3; NOTETYPE_NOT_CLOZE = 4; FIELD_NOT_CLOZE = 5; } State state = 1; } ================================================ FILE: proto/anki/notetypes.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.notetypes; import "anki/generic.proto"; import "anki/collection.proto"; service NotetypesService { rpc AddNotetype(Notetype) returns (collection.OpChangesWithId); rpc UpdateNotetype(Notetype) returns (collection.OpChanges); rpc AddNotetypeLegacy(generic.Json) returns (collection.OpChangesWithId); rpc UpdateNotetypeLegacy(UpdateNotetypeLegacyRequest) returns (collection.OpChanges); rpc AddOrUpdateNotetype(AddOrUpdateNotetypeRequest) returns (NotetypeId); rpc GetStockNotetypeLegacy(StockNotetype) returns (generic.Json); rpc GetNotetype(NotetypeId) returns (Notetype); rpc GetNotetypeLegacy(NotetypeId) returns (generic.Json); rpc GetNotetypeNames(generic.Empty) returns (NotetypeNames); rpc GetNotetypeNamesAndCounts(generic.Empty) returns (NotetypeUseCounts); rpc GetNotetypeIdByName(generic.String) returns (NotetypeId); rpc RemoveNotetype(NotetypeId) returns (collection.OpChanges); rpc GetAuxNotetypeConfigKey(GetAuxConfigKeyRequest) returns (generic.String); rpc GetAuxTemplateConfigKey(GetAuxTemplateConfigKeyRequest) returns (generic.String); rpc GetChangeNotetypeInfo(GetChangeNotetypeInfoRequest) returns (ChangeNotetypeInfo); rpc ChangeNotetype(ChangeNotetypeRequest) returns (collection.OpChanges); rpc GetFieldNames(NotetypeId) returns (generic.StringList); rpc RestoreNotetypeToStock(RestoreNotetypeToStockRequest) returns (collection.OpChanges); rpc GetClozeFieldOrds(NotetypeId) returns (GetClozeFieldOrdsResponse); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendNotetypesService {} message NotetypeId { int64 ntid = 1; } message Notetype { message Config { enum Kind { KIND_NORMAL = 0; KIND_CLOZE = 1; } message CardRequirement { enum Kind { KIND_NONE = 0; KIND_ANY = 1; KIND_ALL = 2; } uint32 card_ord = 1; Kind kind = 2; repeated uint32 field_ords = 3; } Kind kind = 1; uint32 sort_field_idx = 2; string css = 3; // This is now stored separately; retrieve with DefaultsForAdding() int64 target_deck_id_unused = 4; string latex_pre = 5; string latex_post = 6; bool latex_svg = 7; repeated CardRequirement reqs = 8; // Only set on notetypes created with Anki 2.1.62+. StockNotetype.OriginalStockKind original_stock_kind = 9; // the id in the source collection for imported notetypes (Anki 23.10) optional int64 original_id = 10; bytes other = 255; } message Field { message Config { bool sticky = 1; bool rtl = 2; string font_name = 3; uint32 font_size = 4; string description = 5; bool plain_text = 6; bool collapsed = 7; bool exclude_from_search = 8; // used for merging notetypes on import (Anki 23.10) optional int64 id = 9; // Can be used to uniquely identify required fields. optional uint32 tag = 10; bool prevent_deletion = 11; bytes other = 255; } generic.UInt32 ord = 1; string name = 2; Config config = 5; } message Template { message Config { string q_format = 1; string a_format = 2; string q_format_browser = 3; string a_format_browser = 4; int64 target_deck_id = 5; string browser_font_name = 6; uint32 browser_font_size = 7; // used for merging notetypes on import (Anki 23.10) optional int64 id = 8; bytes other = 255; } generic.UInt32 ord = 1; string name = 2; int64 mtime_secs = 3; sint32 usn = 4; Config config = 5; } int64 id = 1; string name = 2; int64 mtime_secs = 3; sint32 usn = 4; Config config = 7; repeated Field fields = 8; repeated Template templates = 9; } message AddOrUpdateNotetypeRequest { bytes json = 1; bool preserve_usn_and_mtime = 2; bool skip_checks = 3; } message UpdateNotetypeLegacyRequest { bytes json = 1; bool skip_checks = 2; } message StockNotetype { enum Kind { KIND_BASIC = 0; KIND_BASIC_AND_REVERSED = 1; KIND_BASIC_OPTIONAL_REVERSED = 2; KIND_BASIC_TYPING = 3; KIND_CLOZE = 4; KIND_IMAGE_OCCLUSION = 5; } // This is decoupled from Kind to allow us to evolve notetypes over time // (eg an older notetype might require different JS), and allow us to store // a type even for notetypes that we don't add by default. Code should not // assume that the entries here are always +1 from Kind. enum OriginalStockKind { ORIGINAL_STOCK_KIND_UNKNOWN = 0; ORIGINAL_STOCK_KIND_BASIC = 1; ORIGINAL_STOCK_KIND_BASIC_AND_REVERSED = 2; ORIGINAL_STOCK_KIND_BASIC_OPTIONAL_REVERSED = 3; ORIGINAL_STOCK_KIND_BASIC_TYPING = 4; ORIGINAL_STOCK_KIND_CLOZE = 5; ORIGINAL_STOCK_KIND_IMAGE_OCCLUSION = 6; } Kind kind = 1; } message NotetypeNames { repeated NotetypeNameId entries = 1; } message NotetypeUseCounts { repeated NotetypeNameIdUseCount entries = 1; } message NotetypeNameId { int64 id = 1; string name = 2; } message NotetypeNameIdUseCount { int64 id = 1; string name = 2; uint32 use_count = 3; } message GetAuxConfigKeyRequest { int64 id = 1; string key = 2; } message GetAuxTemplateConfigKeyRequest { int64 notetype_id = 1; uint32 card_ordinal = 2; string key = 3; } message GetChangeNotetypeInfoRequest { int64 old_notetype_id = 1; int64 new_notetype_id = 2; } message ChangeNotetypeRequest { repeated int64 note_ids = 1; // -1 is used to represent null, as nullable repeated fields // are unwieldy in protobuf repeated int32 new_fields = 2; repeated int32 new_templates = 3; int64 old_notetype_id = 4; int64 new_notetype_id = 5; int64 current_schema = 6; string old_notetype_name = 7; bool is_cloze = 8; } message ChangeNotetypeInfo { repeated string old_field_names = 1; repeated string old_template_names = 2; repeated string new_field_names = 3; repeated string new_template_names = 4; ChangeNotetypeRequest input = 5; string old_notetype_name = 6; } message RestoreNotetypeToStockRequest { NotetypeId notetype_id = 1; // Older notetypes did not store their original stock kind, so we allow the UI // to pass in an override to use when missing, or for tests. optional StockNotetype.Kind force_kind = 2; } enum ImageOcclusionField { IMAGE_OCCLUSION_FIELD_OCCLUSIONS = 0; IMAGE_OCCLUSION_FIELD_IMAGE = 1; IMAGE_OCCLUSION_FIELD_HEADER = 2; IMAGE_OCCLUSION_FIELD_BACK_EXTRA = 3; IMAGE_OCCLUSION_FIELD_COMMENTS = 4; } enum ClozeField { CLOZE_FIELD_TEXT = 0; CLOZE_FIELD_BACK_EXTRA = 1; } message GetClozeFieldOrdsResponse { repeated uint32 ords = 1; } ================================================ FILE: proto/anki/scheduler.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.scheduler; import "anki/generic.proto"; import "anki/cards.proto"; import "anki/decks.proto"; import "anki/collection.proto"; import "anki/config.proto"; import "anki/deck_config.proto"; service SchedulerService { rpc GetQueuedCards(GetQueuedCardsRequest) returns (QueuedCards); rpc AnswerCard(CardAnswer) returns (collection.OpChanges); rpc SchedTimingToday(generic.Empty) returns (SchedTimingTodayResponse); rpc StudiedToday(generic.Empty) returns (generic.String); rpc StudiedTodayMessage(StudiedTodayMessageRequest) returns (generic.String); rpc UpdateStats(UpdateStatsRequest) returns (generic.Empty); rpc ExtendLimits(ExtendLimitsRequest) returns (generic.Empty); rpc CountsForDeckToday(decks.DeckId) returns (CountsForDeckTodayResponse); rpc CongratsInfo(generic.Empty) returns (CongratsInfoResponse); rpc RestoreBuriedAndSuspendedCards(cards.CardIds) returns (collection.OpChanges); rpc UnburyDeck(UnburyDeckRequest) returns (collection.OpChanges); rpc BuryOrSuspendCards(BuryOrSuspendCardsRequest) returns (collection.OpChangesWithCount); rpc EmptyFilteredDeck(decks.DeckId) returns (collection.OpChanges); rpc RebuildFilteredDeck(decks.DeckId) returns (collection.OpChangesWithCount); rpc ScheduleCardsAsNew(ScheduleCardsAsNewRequest) returns (collection.OpChanges); rpc ScheduleCardsAsNewDefaults(ScheduleCardsAsNewDefaultsRequest) returns (ScheduleCardsAsNewDefaultsResponse); rpc SetDueDate(SetDueDateRequest) returns (collection.OpChanges); rpc GradeNow(GradeNowRequest) returns (collection.OpChanges); rpc SortCards(SortCardsRequest) returns (collection.OpChangesWithCount); rpc SortDeck(SortDeckRequest) returns (collection.OpChangesWithCount); rpc GetSchedulingStates(cards.CardId) returns (SchedulingStates); rpc DescribeNextStates(SchedulingStates) returns (generic.StringList); rpc StateIsLeech(SchedulingState) returns (generic.Bool); rpc UpgradeScheduler(generic.Empty) returns (generic.Empty); rpc CustomStudy(CustomStudyRequest) returns (collection.OpChanges); rpc CustomStudyDefaults(CustomStudyDefaultsRequest) returns (CustomStudyDefaultsResponse); rpc RepositionDefaults(generic.Empty) returns (RepositionDefaultsResponse); rpc ComputeFsrsParams(ComputeFsrsParamsRequest) returns (ComputeFsrsParamsResponse); rpc GetOptimalRetentionParameters(GetOptimalRetentionParametersRequest) returns (GetOptimalRetentionParametersResponse); rpc ComputeOptimalRetention(SimulateFsrsReviewRequest) returns (ComputeOptimalRetentionResponse); rpc SimulateFsrsReview(SimulateFsrsReviewRequest) returns (SimulateFsrsReviewResponse); rpc SimulateFsrsWorkload(SimulateFsrsReviewRequest) returns (SimulateFsrsWorkloadResponse); rpc EvaluateParams(EvaluateParamsRequest) returns (EvaluateParamsResponse); rpc EvaluateParamsLegacy(EvaluateParamsLegacyRequest) returns (EvaluateParamsResponse); rpc ComputeMemoryState(cards.CardId) returns (ComputeMemoryStateResponse); // The number of days the calculated interval was fuzzed by on the previous // review (if any). Utilized by the FSRS add-on. rpc FuzzDelta(FuzzDeltaRequest) returns (FuzzDeltaResponse); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendSchedulerService { rpc ComputeFsrsParamsFromItems(ComputeFsrsParamsFromItemsRequest) returns (ComputeFsrsParamsResponse); // Generates parameters used for FSRS's scheduler benchmarks. rpc FsrsBenchmark(FsrsBenchmarkRequest) returns (FsrsBenchmarkResponse); // Used for exporting revlogs for algorithm research. rpc ExportDataset(ExportDatasetRequest) returns (generic.Empty); } message SchedulingState { message New { uint32 position = 1; } message Learning { uint32 remaining_steps = 1; uint32 scheduled_secs = 2; uint32 elapsed_secs = 3; optional cards.FsrsMemoryState memory_state = 6; } message Review { uint32 scheduled_days = 1; uint32 elapsed_days = 2; float ease_factor = 3; uint32 lapses = 4; bool leeched = 5; optional cards.FsrsMemoryState memory_state = 6; } message Relearning { Review review = 1; Learning learning = 2; } message Normal { oneof kind { New new = 1; Learning learning = 2; Review review = 3; Relearning relearning = 4; } } message Preview { uint32 scheduled_secs = 1; bool finished = 2; } message ReschedulingFilter { Normal original_state = 1; } message Filtered { oneof kind { Preview preview = 1; ReschedulingFilter rescheduling = 2; } } oneof kind { Normal normal = 1; Filtered filtered = 2; } // The backend does not populate this field in GetQueuedCards; the front-end // is expected to populate it based on the provided Card. If it's not set when // answering a card, the existing custom data will not be updated. optional string custom_data = 3; } message QueuedCards { enum Queue { NEW = 0; LEARNING = 1; REVIEW = 2; } message QueuedCard { cards.Card card = 1; Queue queue = 2; SchedulingStates states = 3; SchedulingContext context = 4; } repeated QueuedCard cards = 1; uint32 new_count = 2; uint32 learning_count = 3; uint32 review_count = 4; } message GetQueuedCardsRequest { uint32 fetch_limit = 1; bool intraday_learning_only = 2; } message SchedTimingTodayResponse { uint32 days_elapsed = 1; int64 next_day_at = 2; } message StudiedTodayMessageRequest { uint32 cards = 1; double seconds = 2; } message UpdateStatsRequest { int64 deck_id = 1; int32 new_delta = 2; int32 review_delta = 4; int32 millisecond_delta = 5; } message ExtendLimitsRequest { int64 deck_id = 1; int32 new_delta = 2; int32 review_delta = 3; } message CountsForDeckTodayResponse { int32 new = 1; int32 review = 2; } message CongratsInfoResponse { uint32 learn_remaining = 1; uint32 secs_until_next_learn = 2; bool review_remaining = 3; bool new_remaining = 4; bool have_sched_buried = 5; bool have_user_buried = 6; bool is_filtered_deck = 7; bool bridge_commands_supported = 8; string deck_description = 9; } message UnburyDeckRequest { enum Mode { ALL = 0; SCHED_ONLY = 1; USER_ONLY = 2; } int64 deck_id = 1; Mode mode = 2; } message BuryOrSuspendCardsRequest { enum Mode { SUSPEND = 0; BURY_SCHED = 1; BURY_USER = 2; } repeated int64 card_ids = 1; repeated int64 note_ids = 2; Mode mode = 3; } message ScheduleCardsAsNewRequest { enum Context { BROWSER = 0; REVIEWER = 1; } repeated int64 card_ids = 1; bool log = 2; bool restore_position = 3; bool reset_counts = 4; optional Context context = 5; } message ScheduleCardsAsNewDefaultsRequest { ScheduleCardsAsNewRequest.Context context = 1; } message ScheduleCardsAsNewDefaultsResponse { bool restore_position = 1; bool reset_counts = 2; } message SetDueDateRequest { repeated int64 card_ids = 1; string days = 2; config.OptionalStringConfigKey config_key = 3; } message GradeNowRequest { repeated int64 card_ids = 1; CardAnswer.Rating rating = 2; } message SortCardsRequest { repeated int64 card_ids = 1; uint32 starting_from = 2; uint32 step_size = 3; bool randomize = 4; bool shift_existing = 5; } message SortDeckRequest { int64 deck_id = 1; bool randomize = 2; } message SchedulingStates { SchedulingState current = 1; SchedulingState again = 2; SchedulingState hard = 3; SchedulingState good = 4; SchedulingState easy = 5; } message CardAnswer { enum Rating { AGAIN = 0; HARD = 1; GOOD = 2; EASY = 3; } int64 card_id = 1; SchedulingState current_state = 2; SchedulingState new_state = 3; Rating rating = 4; int64 answered_at_millis = 5; uint32 milliseconds_taken = 6; } message CustomStudyRequest { message Cram { enum CramKind { // due cards in due order CRAM_KIND_DUE = 0; // new cards in added order CRAM_KIND_NEW = 1; // review cards in random order CRAM_KIND_REVIEW = 2; // all cards in random order; no rescheduling CRAM_KIND_ALL = 3; } CramKind kind = 1; // the maximum number of cards uint32 card_limit = 2; // cards must match one of these, if unempty repeated string tags_to_include = 3; // cards must not match any of these repeated string tags_to_exclude = 4; } int64 deck_id = 1; oneof value { // increase new limit by x int32 new_limit_delta = 2; // increase review limit by x int32 review_limit_delta = 3; // repeat cards forgotten in the last x days uint32 forgot_days = 4; // review cards due in the next x days uint32 review_ahead_days = 5; // preview new cards added in the last x days uint32 preview_days = 6; Cram cram = 7; } } message SchedulingContext { string deck_name = 1; uint64 seed = 2; } message CustomStudyDefaultsRequest { int64 deck_id = 1; } message CustomStudyDefaultsResponse { message Tag { string name = 1; bool include = 2; bool exclude = 3; } repeated Tag tags = 1; uint32 extend_new = 2; uint32 extend_review = 3; uint32 available_new = 4; uint32 available_review = 5; // in v3, counts for children are provided separately uint32 available_new_in_children = 6; uint32 available_review_in_children = 7; } message RepositionDefaultsResponse { bool random = 1; bool shift = 2; } message ComputeFsrsParamsRequest { /// The search used to gather cards for training string search = 1; repeated float current_params = 2; int64 ignore_revlogs_before_ms = 3; uint32 num_of_relearning_steps = 4; bool health_check = 5; } message ComputeFsrsParamsResponse { repeated float params = 1; uint32 fsrs_items = 2; optional bool health_check_passed = 3; } message ComputeFsrsParamsFromItemsRequest { repeated FsrsItem items = 1; } message FsrsBenchmarkRequest { repeated FsrsItem train_set = 1; } message FsrsBenchmarkResponse { repeated float params = 1; } message ExportDatasetRequest { uint32 min_entries = 1; string target_path = 2; } message FsrsItem { repeated FsrsReview reviews = 1; } message FsrsReview { uint32 rating = 1; uint32 delta_t = 2; } message SimulateFsrsReviewRequest { repeated float params = 1; float desired_retention = 2; uint32 deck_size = 3; uint32 days_to_simulate = 4; uint32 new_limit = 5; uint32 review_limit = 6; uint32 max_interval = 7; string search = 8; bool new_cards_ignore_review_limit = 9; repeated float easy_days_percentages = 10; deck_config.DeckConfig.Config.ReviewCardOrder review_order = 11; optional uint32 suspend_after_lapse_count = 12; float historical_retention = 13; uint32 learning_step_count = 14; uint32 relearning_step_count = 15; } message SimulateFsrsReviewResponse { repeated float accumulated_knowledge_acquisition = 1; repeated uint32 daily_review_count = 2; repeated uint32 daily_new_count = 3; repeated float daily_time_cost = 4; } message SimulateFsrsWorkloadResponse { map cost = 1; map memorized = 2; map review_count = 3; } message ComputeOptimalRetentionResponse { float optimal_retention = 1; } message GetOptimalRetentionParametersRequest { string search = 1; } message GetOptimalRetentionParametersResponse { uint32 deck_size = 1; uint32 learn_span = 2; float max_cost_perday = 3; float max_ivl = 4; repeated float first_rating_prob = 5; repeated float review_rating_prob = 6; float loss_aversion = 7; uint32 learn_limit = 8; uint32 review_limit = 9; repeated float learning_step_transitions = 10; repeated float relearning_step_transitions = 11; repeated float state_rating_costs = 12; uint32 learning_step_count = 13; uint32 relearning_step_count = 14; } message EvaluateParamsRequest { string search = 1; int64 ignore_revlogs_before_ms = 2; uint32 num_of_relearning_steps = 3; } message EvaluateParamsLegacyRequest { repeated float params = 1; string search = 2; int64 ignore_revlogs_before_ms = 3; } message EvaluateParamsResponse { float log_loss = 1; float rmse_bins = 2; } message ComputeMemoryStateResponse { optional cards.FsrsMemoryState state = 1; float desired_retention = 2; float decay = 3; } message FuzzDeltaRequest { int64 card_id = 1; uint32 interval = 2; } message FuzzDeltaResponse { sint32 delta_days = 1; } ================================================ FILE: proto/anki/search.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.search; import "anki/generic.proto"; import "anki/collection.proto"; service SearchService { rpc BuildSearchString(SearchNode) returns (generic.String); rpc SearchCards(SearchRequest) returns (SearchResponse); rpc SearchNotes(SearchRequest) returns (SearchResponse); rpc JoinSearchNodes(JoinSearchNodesRequest) returns (generic.String); rpc ReplaceSearchNode(ReplaceSearchNodeRequest) returns (generic.String); rpc FindAndReplace(FindAndReplaceRequest) returns (collection.OpChangesWithCount); rpc AllBrowserColumns(generic.Empty) returns (BrowserColumns); rpc BrowserRowForId(generic.Int64) returns (BrowserRow); rpc SetActiveBrowserColumns(generic.StringList) returns (generic.Empty); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendSearchService {} message SearchNode { message Dupe { int64 notetype_id = 1; string first_field = 2; } enum Flag { FLAG_NONE = 0; FLAG_ANY = 1; FLAG_RED = 2; FLAG_ORANGE = 3; FLAG_GREEN = 4; FLAG_BLUE = 5; FLAG_PINK = 6; FLAG_TURQUOISE = 7; FLAG_PURPLE = 8; } enum Rating { RATING_ANY = 0; RATING_AGAIN = 1; RATING_HARD = 2; RATING_GOOD = 3; RATING_EASY = 4; RATING_BY_RESCHEDULE = 5; } message Rated { uint32 days = 1; Rating rating = 2; } enum CardState { CARD_STATE_NEW = 0; CARD_STATE_LEARN = 1; CARD_STATE_REVIEW = 2; CARD_STATE_DUE = 3; CARD_STATE_SUSPENDED = 4; CARD_STATE_BURIED = 5; } message IdList { repeated int64 ids = 1; } message Group { enum Joiner { AND = 0; OR = 1; } repeated SearchNode nodes = 1; Joiner joiner = 2; } enum FieldSearchMode { FIELD_SEARCH_MODE_NORMAL = 0; FIELD_SEARCH_MODE_REGEX = 1; FIELD_SEARCH_MODE_NOCOMBINING = 2; } message Field { string field_name = 1; string text = 2; FieldSearchMode mode = 3; } oneof filter { Group group = 1; SearchNode negated = 2; string parsable_text = 3; uint32 template = 4; int64 nid = 5; Dupe dupe = 6; string field_name = 7; Rated rated = 8; uint32 added_in_days = 9; int32 due_in_days = 10; Flag flag = 11; CardState card_state = 12; IdList nids = 13; uint32 edited_in_days = 14; string deck = 15; int32 due_on_day = 16; string tag = 17; string note = 18; uint32 introduced_in_days = 19; Field field = 20; string literal_text = 21; } } message SearchRequest { string search = 1; SortOrder order = 2; } message SearchResponse { repeated int64 ids = 1; } message SortOrder { message Builtin { string column = 1; bool reverse = 2; } oneof value { generic.Empty none = 1; string custom = 2; Builtin builtin = 3; } } message JoinSearchNodesRequest { SearchNode.Group.Joiner joiner = 1; SearchNode existing_node = 2; SearchNode additional_node = 3; } message ReplaceSearchNodeRequest { SearchNode existing_node = 1; SearchNode replacement_node = 2; } message FindAndReplaceRequest { repeated int64 nids = 1; string search = 2; string replacement = 3; bool regex = 4; bool match_case = 5; string field_name = 6; } message BrowserColumns { enum Sorting { SORTING_NONE = 0; SORTING_ASCENDING = 1; SORTING_DESCENDING = 2; } enum Alignment { ALIGNMENT_START = 0; ALIGNMENT_CENTER = 1; } message Column { string key = 1; string cards_mode_label = 2; string notes_mode_label = 3; // The default sort order Sorting sorting_cards = 4; Sorting sorting_notes = 9; bool uses_cell_font = 5; Alignment alignment = 6; string cards_mode_tooltip = 7; string notes_mode_tooltip = 8; } repeated Column columns = 1; } message BrowserRow { message Cell { enum TextElideMode { ElideLeft = 0; ElideRight = 1; ElideMiddle = 2; ElideNone = 3; } string text = 1; bool is_rtl = 2; TextElideMode elide_mode = 3; } enum Color { COLOR_DEFAULT = 0; COLOR_MARKED = 1; COLOR_SUSPENDED = 2; COLOR_FLAG_RED = 3; COLOR_FLAG_ORANGE = 4; COLOR_FLAG_GREEN = 5; COLOR_FLAG_BLUE = 6; COLOR_FLAG_PINK = 7; COLOR_FLAG_TURQUOISE = 8; COLOR_FLAG_PURPLE = 9; COLOR_BURIED = 10; } repeated Cell cells = 1; Color color = 2; string font_name = 3; uint32 font_size = 4; } ================================================ FILE: proto/anki/stats.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.stats; import "anki/generic.proto"; import "anki/cards.proto"; service StatsService { rpc CardStats(cards.CardId) returns (CardStatsResponse); rpc GetReviewLogs(cards.CardId) returns (ReviewLogs); rpc Graphs(GraphsRequest) returns (GraphsResponse); rpc GetGraphPreferences(generic.Empty) returns (GraphPreferences); rpc SetGraphPreferences(GraphPreferences) returns (generic.Empty); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendStatsService {} message ReviewLogs { repeated CardStatsResponse.StatsRevlogEntry entries = 1; } message CardStatsResponse { message StatsRevlogEntry { int64 time = 1; RevlogEntry.ReviewKind review_kind = 2; uint32 button_chosen = 3; // seconds uint32 interval = 4; // per mill uint32 ease = 5; float taken_secs = 6; optional cards.FsrsMemoryState memory_state = 7; // seconds uint32 last_interval = 8; } repeated StatsRevlogEntry revlog = 1; int64 card_id = 2; int64 note_id = 3; string deck = 4; // Unix timestamps int64 added = 5; optional int64 first_review = 6; optional int64 latest_review = 7; optional int64 due_date = 8; optional int32 due_position = 9; // days uint32 interval = 10; // per mill uint32 ease = 11; uint32 reviews = 12; uint32 lapses = 13; float average_secs = 14; float total_secs = 15; string card_type = 16; string notetype = 17; optional cards.FsrsMemoryState memory_state = 18; // not set if due date/state not available optional float fsrs_retrievability = 19; string custom_data = 20; string preset = 21; optional string original_deck = 22; optional float desired_retention = 23; repeated float fsrs_params = 24; } message GraphsRequest { string search = 1; uint32 days = 2; } message GraphsResponse { message Added { map added = 1; } message Intervals { map intervals = 1; } message Eases { map eases = 1; float average = 2; } message Retrievability { map retrievability = 1; float average = 2; float sum_by_card = 3; float sum_by_note = 4; } message FutureDue { map future_due = 1; bool have_backlog = 2; uint32 daily_load = 3; } message Today { uint32 answer_count = 1; uint32 answer_millis = 2; uint32 correct_count = 3; uint32 mature_correct = 4; uint32 mature_count = 5; uint32 learn_count = 6; uint32 review_count = 7; uint32 relearn_count = 8; uint32 early_review_count = 9; } // each bucket is a 24 element vec message Hours { message Hour { uint32 total = 1; uint32 correct = 2; } repeated Hour one_month = 1; repeated Hour three_months = 2; repeated Hour one_year = 3; repeated Hour all_time = 4; } message ReviewCountsAndTimes { message Reviews { uint32 learn = 1; uint32 relearn = 2; uint32 young = 3; uint32 mature = 4; uint32 filtered = 5; } map count = 1; map time = 2; } // 4 element vecs for buttons 1-4 message Buttons { message ButtonCounts { repeated uint32 learning = 1; repeated uint32 young = 2; repeated uint32 mature = 3; } ButtonCounts one_month = 1; ButtonCounts three_months = 2; ButtonCounts one_year = 3; ButtonCounts all_time = 4; } message CardCounts { message Counts { uint32 newCards = 1; uint32 learn = 2; uint32 relearn = 3; uint32 young = 4; uint32 mature = 5; uint32 suspended = 6; uint32 buried = 7; } // Buried/suspended cards are included in counts; suspended/buried counts // are 0. Counts including_inactive = 1; // Buried/suspended cards are counted separately. Counts excluding_inactive = 2; } message TrueRetentionStats { message TrueRetention { uint32 young_passed = 1; uint32 young_failed = 2; uint32 mature_passed = 3; uint32 mature_failed = 4; } TrueRetention today = 1; TrueRetention yesterday = 2; TrueRetention week = 3; TrueRetention month = 4; TrueRetention year = 5; TrueRetention all_time = 6; } Buttons buttons = 1; CardCounts card_counts = 2; Hours hours = 3; Today today = 4; Eases eases = 5; Eases difficulty = 11; Intervals intervals = 6; FutureDue future_due = 7; Added added = 8; ReviewCountsAndTimes reviews = 9; uint32 rollover_hour = 10; Retrievability retrievability = 12; bool fsrs = 13; Intervals stability = 14; TrueRetentionStats true_retention = 15; } message GraphPreferences { enum Weekday { SUNDAY = 0; MONDAY = 1; FRIDAY = 5; SATURDAY = 6; } Weekday calendar_first_day_of_week = 1; bool card_counts_separate_inactive = 2; bool browser_links_supported = 3; bool future_due_show_backlog = 4; } message RevlogEntry { enum ReviewKind { LEARNING = 0; REVIEW = 1; RELEARNING = 2; FILTERED = 3; MANUAL = 4; RESCHEDULED = 5; } int64 id = 1; int64 cid = 2; int32 usn = 3; uint32 button_chosen = 4; int32 interval = 5; int32 last_interval = 6; uint32 ease_factor = 7; uint32 taken_millis = 8; ReviewKind review_kind = 9; } message CardEntry { int64 id = 1; int64 note_id = 2; int64 deck_id = 3; } message DeckEntry { int64 id = 1; int64 parent_id = 2; int64 preset_id = 3; } message Dataset { repeated RevlogEntry revlogs = 1; repeated CardEntry cards = 2; repeated DeckEntry decks = 3; int64 next_day_at = 4; } ================================================ FILE: proto/anki/sync.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.sync; import "anki/generic.proto"; // Syncing methods are only available with a Backend handle. service SyncService {} service BackendSyncService { rpc SyncMedia(SyncAuth) returns (generic.Empty); rpc AbortMediaSync(generic.Empty) returns (generic.Empty); // Can be used by the frontend to detect an active sync. If the sync aborted // with an error, the next call to this method will return the error. rpc MediaSyncStatus(generic.Empty) returns (MediaSyncStatusResponse); rpc SyncLogin(SyncLoginRequest) returns (SyncAuth); rpc SyncStatus(SyncAuth) returns (SyncStatusResponse); rpc SyncCollection(SyncCollectionRequest) returns (SyncCollectionResponse); rpc FullUploadOrDownload(FullUploadOrDownloadRequest) returns (generic.Empty); rpc AbortSync(generic.Empty) returns (generic.Empty); rpc SetCustomCertificate(generic.String) returns (generic.Bool); } message SyncAuth { string hkey = 1; optional string endpoint = 2; optional uint32 io_timeout_secs = 3; } message SyncLoginRequest { string username = 1; string password = 2; optional string endpoint = 3; } message SyncStatusResponse { enum Required { NO_CHANGES = 0; NORMAL_SYNC = 1; FULL_SYNC = 2; } Required required = 1; optional string new_endpoint = 4; } message SyncCollectionRequest { SyncAuth auth = 1; bool sync_media = 2; } message SyncCollectionResponse { enum ChangesRequired { NO_CHANGES = 0; NORMAL_SYNC = 1; FULL_SYNC = 2; // local collection has no cards; upload not an option FULL_DOWNLOAD = 3; // remote collection has no cards; download not an option FULL_UPLOAD = 4; } uint32 host_number = 1; string server_message = 2; ChangesRequired required = 3; optional string new_endpoint = 4; int32 server_media_usn = 5; } message MediaSyncStatusResponse { bool active = 1; MediaSyncProgress progress = 2; } message MediaSyncProgress { string checked = 1; string added = 2; string removed = 3; } message FullUploadOrDownloadRequest { SyncAuth auth = 1; bool upload = 2; // if not provided, media syncing will be skipped optional int32 server_usn = 3; } ================================================ FILE: proto/anki/tags.proto ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html syntax = "proto3"; option java_multiple_files = true; package anki.tags; import "anki/generic.proto"; import "anki/collection.proto"; service TagsService { rpc ClearUnusedTags(generic.Empty) returns (collection.OpChangesWithCount); rpc AllTags(generic.Empty) returns (generic.StringList); rpc RemoveTags(generic.String) returns (collection.OpChangesWithCount); rpc SetTagCollapsed(SetTagCollapsedRequest) returns (collection.OpChanges); rpc TagTree(generic.Empty) returns (TagTreeNode); rpc ReparentTags(ReparentTagsRequest) returns (collection.OpChangesWithCount); rpc RenameTags(RenameTagsRequest) returns (collection.OpChangesWithCount); rpc AddNoteTags(NoteIdsAndTagsRequest) returns (collection.OpChangesWithCount); rpc RemoveNoteTags(NoteIdsAndTagsRequest) returns (collection.OpChangesWithCount); rpc FindAndReplaceTag(FindAndReplaceTagRequest) returns (collection.OpChangesWithCount); rpc CompleteTag(CompleteTagRequest) returns (CompleteTagResponse); } // Implicitly includes any of the above methods that are not listed in the // backend service. service BackendTagsService {} message SetTagCollapsedRequest { string name = 1; bool collapsed = 2; } message TagTreeNode { string name = 1; repeated TagTreeNode children = 2; uint32 level = 3; bool collapsed = 4; } message ReparentTagsRequest { repeated string tags = 1; string new_parent = 2; } message RenameTagsRequest { string current_prefix = 1; string new_prefix = 2; } message NoteIdsAndTagsRequest { repeated int64 note_ids = 1; string tags = 2; } message FindAndReplaceTagRequest { repeated int64 note_ids = 1; string search = 2; string replacement = 3; bool regex = 4; bool match_case = 5; } message CompleteTagRequest { // a partial tag, optionally delimited with :: string input = 1; uint32 match_limit = 2; } message CompleteTagResponse { repeated string tags = 1; } ================================================ FILE: pylib/.gitignore ================================================ *.mo *.pyc *\# *~ .*.swp .build .coverage .DS_Store .mypy_cache .pytype __pycache__ anki.egg-info build dist ================================================ FILE: pylib/README.md ================================================ Anki's Python library code is in anki/. The Rust/Python extension module is in rsbridge/; it references the library defined in ../rslib. ================================================ FILE: pylib/anki/_backend.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import sys import time import traceback from collections.abc import Iterable, Sequence from threading import current_thread, main_thread from typing import TYPE_CHECKING, Any from weakref import ref from markdown import markdown import anki.buildinfo from anki import _rsbridge, backend_pb2, i18n_pb2 from anki._backend_generated import RustBackendGenerated from anki._fluent import GeneratedTranslations from anki.dbproxy import Row as DBRow from anki.dbproxy import ValueForDB from anki.utils import from_json_bytes, to_json_bytes if TYPE_CHECKING: from anki.collection import FsrsItem from .errors import ( BackendError, BackendIOError, CardTypeError, CustomStudyError, DBError, ExistsError, FilteredDeckError, Interrupted, InvalidInput, NetworkError, NotFoundError, SchedulerUpgradeRequired, SearchError, SyncError, SyncErrorKind, TemplateError, UndoEmpty, ) # the following comment is required to suppress a warning that only shows up # when there are other pylint failures if _rsbridge.buildhash() != anki.buildinfo.buildhash: raise Exception( f"""rsbridge and anki build hashes do not match: {_rsbridge.buildhash()} vs {anki.buildinfo.buildhash}""" ) class RustBackend(RustBackendGenerated): """ Python bindings for Anki's Rust libraries. Please do not access methods on the backend directly - they may be changed or removed at any time. Instead, please use the methods on the collection instead. Eg, don't use col._backend.all_deck_config(), instead use col.decks.all_config() If you need to access a backend method that is not currently accessible via the collection, please send through a pull request that adds a public method. """ @staticmethod def initialize_logging(path: str | None = None) -> None: _rsbridge.initialize_logging(path) def __init__( self, langs: list[str] | None = None, server: bool = False, ) -> None: # pick up global defaults if not provided import anki.lang if langs is None: langs = [anki.lang.current_lang] init_msg = backend_pb2.BackendInit( preferred_langs=langs, server=server, ) self._backend = _rsbridge.open_backend(init_msg.SerializeToString()) @staticmethod def syncserver() -> None: _rsbridge.syncserver() def db_query( self, sql: str, args: Sequence[ValueForDB], first_row_only: bool ) -> list[DBRow]: return self._db_command( dict(kind="query", sql=sql, args=args, first_row_only=first_row_only) ) def db_execute_many(self, sql: str, args: list[list[ValueForDB]]) -> list[DBRow]: return self._db_command(dict(kind="executemany", sql=sql, args=args)) def db_begin(self) -> None: return self._db_command(dict(kind="begin")) def db_commit(self) -> None: return self._db_command(dict(kind="commit")) def db_rollback(self) -> None: return self._db_command(dict(kind="rollback")) def _db_command(self, input: dict[str, Any]) -> Any: bytes_input = to_json_bytes(input) try: return from_json_bytes(self._backend.db_command(bytes_input)) except Exception as error: err_bytes = bytes(error.args[0]) err = backend_pb2.BackendError() err.ParseFromString(err_bytes) raise backend_exception_to_pylib(err) def translate( self, module_index: int, message_index: int, **kwargs: str | int | float ) -> str: args = { k: ( i18n_pb2.TranslateArgValue(str=v) if isinstance(v, str) else i18n_pb2.TranslateArgValue(number=v) ) for k, v in kwargs.items() } return self.translate_string( module_index=module_index, message_index=message_index, args=args ) def format_time_span( self, seconds: Any, context: Any = 2, ) -> str: traceback.print_stack(file=sys.stdout) print( "please use col.format_timespan() instead of col.backend.format_time_span()" ) return self.format_timespan(seconds=seconds, context=context) def compute_params_from_items(self, items: Iterable[FsrsItem]) -> Sequence[float]: return self.compute_fsrs_params_from_items(items).params def benchmark(self, train_set: Iterable[FsrsItem]) -> Sequence[float]: return self.fsrs_benchmark(train_set=train_set) def _run_command(self, service: int, method: int, input: bytes) -> bytes: start = time.time() try: return self._backend.command(service, method, input) except Exception as error: error_bytes = bytes(error.args[0]) finally: elapsed = time.time() - start if current_thread() is main_thread() and elapsed > 0.2: print(f"blocked main thread for {int(elapsed * 1000)}ms:") print("".join(traceback.format_stack())) err = backend_pb2.BackendError() err.ParseFromString(error_bytes) raise backend_exception_to_pylib(err) class Translations(GeneratedTranslations): def __init__(self, backend: ref[RustBackend] | None): self.backend = backend def __call__(self, key: tuple[int, int], **kwargs: Any) -> str: "Mimic the old col.tr / TR interface" if "pytest" not in sys.modules: traceback.print_stack(file=sys.stdout) print("please use tr.message_name() instead of tr(TR.MESSAGE_NAME)") (module, message) = key return self.backend().translate( module_index=module, message_index=message, **kwargs ) def _translate( self, module: int, message: int, args: dict[str, str | int | float] ) -> str: return self.backend().translate( module_index=module, message_index=message, **args ) def backend_exception_to_pylib(err: backend_pb2.BackendError) -> Exception: kind = backend_pb2.BackendError val = err.kind help_page = err.help_page if err.HasField("help_page") else None context = err.context if err.context else None backtrace = err.backtrace if err.backtrace else None if val == kind.INTERRUPTED: return Interrupted(err.message, help_page, context, backtrace) elif val == kind.NETWORK_ERROR: return NetworkError(err.message, help_page, context, backtrace) elif val == kind.SYNC_AUTH_ERROR: return SyncError(err.message, help_page, context, backtrace, SyncErrorKind.AUTH) elif val == kind.SYNC_OTHER_ERROR: return SyncError( err.message, help_page, context, backtrace, SyncErrorKind.OTHER ) elif val == kind.IO_ERROR: return BackendIOError(err.message, help_page, context, backtrace) elif val == kind.DB_ERROR: return DBError(err.message, help_page, context, backtrace) elif val == kind.CARD_TYPE_ERROR: return CardTypeError(err.message, help_page, context, backtrace) elif val == kind.TEMPLATE_PARSE: return TemplateError(err.message, help_page, context, backtrace) elif val == kind.INVALID_INPUT: return InvalidInput(err.message, help_page, context, backtrace) elif val == kind.JSON_ERROR: return BackendError(err.message, help_page, context, backtrace) elif val == kind.NOT_FOUND_ERROR: return NotFoundError(err.message, help_page, context, backtrace) elif val == kind.EXISTS: return ExistsError(err.message, help_page, context, backtrace) elif val == kind.FILTERED_DECK_ERROR: return FilteredDeckError(err.message, help_page, context, backtrace) elif val == kind.PROTO_ERROR: return BackendError(err.message, help_page, context, backtrace) elif val == kind.SEARCH_ERROR: return SearchError(err.message, help_page, context, backtrace) elif val == kind.UNDO_EMPTY: return UndoEmpty(err.message, help_page, context, backtrace) elif val == kind.CUSTOM_STUDY_ERROR: return CustomStudyError(err.message, help_page, context, backtrace) elif val == kind.SCHEDULER_UPGRADE_REQUIRED: return SchedulerUpgradeRequired(err.message, help_page, context, backtrace) else: # sadly we can't do exhaustiveness checking on protobuf enums # assert_exhaustive(val) return BackendError(err.message, help_page, context, backtrace) ================================================ FILE: pylib/anki/_legacy.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import functools import os import pathlib import sys import traceback from collections.abc import Callable from typing import TYPE_CHECKING, Any, Union from anki._vendor import stringcase # type: ignore sys.modules["stringcase"] = stringcase VariableTarget = tuple[Any, str] DeprecatedAliasTarget = Union[Callable, VariableTarget] def _target_to_string(target: DeprecatedAliasTarget | None) -> str: if target is None: return "" if name := getattr(target, "__name__", None): return name return target[1] # type: ignore def partial_path(full_path: str, components: int) -> str: path = pathlib.Path(full_path) return os.path.join(*path.parts[-components:]) def print_deprecation_warning(msg: str, frame: int = 1) -> None: # skip one frame to get to caller # then by default, skip one more frame as caller themself usually wants to # print their own caller path, linenum, _, _ = traceback.extract_stack(limit=frame + 2)[0] path = partial_path(path, components=3) print(f"{path}:{linenum}:{msg}") def _print_warning(old: str, doc: str, frame: int = 1) -> None: return print_deprecation_warning(f"{old} is deprecated: {doc}", frame=frame + 1) def _print_replacement_warning(old: str, new: str, frame: int = 1) -> None: doc = f"please use '{new}'" if new else "please implement your own" _print_warning(old, doc, frame=frame + 1) def _get_remapped_and_replacement( mixin: DeprecatedNamesMixin | DeprecatedNamesMixinForModule, name: str ) -> tuple[str, str | None]: if some_tuple := mixin._deprecated_attributes.get(name): return some_tuple remapped = mixin._deprecated_aliases.get(name) or stringcase.snakecase(name) if remapped == name: raise AttributeError return (remapped, remapped) class DeprecatedNamesMixin: "Expose instance methods/vars as camelCase for legacy callers." # deprecated name -> new name _deprecated_aliases: dict[str, str] = {} # deprecated name -> [new internal name, new name shown to user] _deprecated_attributes: dict[str, tuple[str, str | None]] = {} # TYPE_CHECKING check is required for https://github.com/python/mypy/issues/13319 if not TYPE_CHECKING: def __getattr__(self, name: str) -> Any: try: remapped, replacement = _get_remapped_and_replacement(self, name) out = getattr(self, remapped) except AttributeError: raise AttributeError( f"'{self.__class__.__name__}' object has no attribute '{name}'" ) from None _print_replacement_warning(name, replacement) return out @classmethod def register_deprecated_aliases(cls, **kwargs: DeprecatedAliasTarget) -> None: """Manually add aliases that are not a simple transform. Either pass in a method, or a tuple of (variable, "variable"). The latter is required because we want to ensure the provided arguments are valid symbols, and we can't get a variable's name easily. """ cls._deprecated_aliases = {k: _target_to_string(v) for k, v in kwargs.items()} @classmethod def register_deprecated_attributes( cls, **kwargs: tuple[DeprecatedAliasTarget, DeprecatedAliasTarget | None], ) -> None: """Manually add deprecated attributes without exact substitutes. Pass a tuple of (alias, replacement), where alias is the attribute's new name (by convention: snakecase, prepended with '_legacy_'), and replacement is any callable to be used instead in new code or None. Also note the docstring of `register_deprecated_aliases`. E.g. given `def oldFunc(args): return new_func(additionalLogic(args))`, rename `oldFunc` to `_legacy_old_func` and call `register_deprecated_attributes(oldFunc=(_legacy_old_func, new_func))`. """ cls._deprecated_attributes = { k: (_target_to_string(v[0]), _target_to_string(v[1])) for k, v in kwargs.items() } class DeprecatedNamesMixinForModule: """Provides the functionality of DeprecatedNamesMixin for modules. It can be invoked like this: ``` _deprecated_names = DeprecatedNamesMixinForModule(globals()) _deprecated_names.register_deprecated_aliases(... _deprecated_names.register_deprecated_attributes(... if not TYPE_CHECKING: def __getattr__(name: str) -> Any: return _deprecated_names.__getattr__(name) ``` See DeprecatedNamesMixin for more documentation. """ def __init__(self, module_globals: dict[str, Any]) -> None: self.module_globals = module_globals self._deprecated_aliases: dict[str, str] = {} self._deprecated_attributes: dict[str, tuple[str, str | None]] = {} if not TYPE_CHECKING: def __getattr__(self, name: str) -> Any: try: remapped, replacement = _get_remapped_and_replacement(self, name) out = self.module_globals[remapped] except (AttributeError, KeyError): raise AttributeError( f"Module '{self.module_globals['__name__']}' has no attribute '{name}'" ) from None # skip an additional frame as we are called from the module `__getattr__` _print_replacement_warning(name, replacement, frame=2) return out def register_deprecated_aliases(self, **kwargs: DeprecatedAliasTarget) -> None: self._deprecated_aliases = {k: _target_to_string(v) for k, v in kwargs.items()} def register_deprecated_attributes( self, **kwargs: tuple[DeprecatedAliasTarget, DeprecatedAliasTarget | None], ) -> None: self._deprecated_attributes = { k: (_target_to_string(v[0]), _target_to_string(v[1])) for k, v in kwargs.items() } def deprecated(replaced_by: Callable | None = None, info: str = "") -> Callable: """Print a deprecation warning, telling users to use `replaced_by`, or show `doc`.""" def decorator(func: Callable) -> Callable: @functools.wraps(func) def decorated_func(*args: Any, **kwargs: Any) -> Any: if info: _print_warning(f"{func.__name__}()", info) else: _print_replacement_warning(func.__name__, replaced_by.__name__) return func(*args, **kwargs) return decorated_func return decorator def deprecated_keywords(**replaced_keys: str) -> Callable: """Pass `oldKey="new_key"` to map the former to the latter, if passed to the decorated function as a key word, and print a deprecation warning. """ def decorator(func: Callable) -> Callable: @functools.wraps(func) def decorated_func(*args: Any, **kwargs: Any) -> Any: updated_kwargs = {} for key, val in kwargs.items(): if replacement := replaced_keys.get(key): _print_replacement_warning(key, replacement) updated_kwargs[replacement or key] = val return func(*args, **updated_kwargs) return decorated_func return decorator ================================================ FILE: pylib/anki/_rsbridge.pyi ================================================ from typing import Union class Backend: @classmethod def command(cls, service: int, method: int, data: bytes) -> bytes: ... def db_command(self, data: bytes) -> bytes: ... def buildhash() -> str: ... def open_backend(data: bytes) -> Backend: ... def initialize_logging(log_file: Union[str, None]) -> Backend: ... def syncserver() -> None: ... ================================================ FILE: pylib/anki/_vendor/stringcase.py ================================================ # stringcase 1.2.0 with python warning fix applied # MIT: https://github.com/okunishinishi/python-stringcase """ String convert functions """ import re def camelcase(string): """Convert string into camel case. Args: string: String to convert. Returns: string: Camel case string. """ string = re.sub(r"\w[\s\W]+\w", "", str(string)) if not string: return string return lowercase(string[0]) + re.sub( r"[\-_\.\s]([a-z])", lambda matched: uppercase(matched.group(1)), string[1:] ) def capitalcase(string): """Convert string into capital case. First letters will be uppercase. Args: string: String to convert. Returns: string: Capital case string. """ string = str(string) if not string: return string return uppercase(string[0]) + string[1:] def constcase(string): """Convert string into upper snake case. Join punctuation with underscore and convert letters into uppercase. Args: string: String to convert. Returns: string: Const cased string. """ return uppercase(snakecase(string)) def lowercase(string): """Convert string into lower case. Args: string: String to convert. Returns: string: Lowercase case string. """ return str(string).lower() def pascalcase(string): """Convert string into pascal case. Args: string: String to convert. Returns: string: Pascal case string. """ return capitalcase(camelcase(string)) def pathcase(string): """Convert string into path case. Join punctuation with slash. Args: string: String to convert. Returns: string: Path cased string. """ string = snakecase(string) if not string: return string return re.sub(r"_", "/", string) def backslashcase(string): """Convert string into spinal case. Join punctuation with backslash. Args: string: String to convert. Returns: string: Spinal cased string. """ str1 = re.sub(r"_", r"\\", snakecase(string)) return str1 # return re.sub(r"\\n", "", str1)) # TODO: make regex for \t ... def sentencecase(string): """Convert string into sentence case. First letter capped and each punctuations are joined with space. Args: string: String to convert. Returns: string: Sentence cased string. """ joiner = " " string = re.sub(r"[\-_\.\s]", joiner, str(string)) if not string: return string return capitalcase( trimcase( re.sub( r"[A-Z]", lambda matched: joiner + lowercase(matched.group(0)), string ) ) ) def snakecase(string): """Convert string into snake case. Join punctuation with underscore Args: string: String to convert. Returns: string: Snake cased string. """ string = re.sub(r"[\-\.\s]", "_", str(string)) if not string: return string return lowercase(string[0]) + re.sub( r"[A-Z]", lambda matched: "_" + lowercase(matched.group(0)), string[1:] ) def spinalcase(string): """Convert string into spinal case. Join punctuation with hyphen. Args: string: String to convert. Returns: string: Spinal cased string. """ return re.sub(r"_", "-", snakecase(string)) def dotcase(string): """Convert string into dot case. Join punctuation with dot. Args: string: String to convert. Returns: string: Dot cased string. """ return re.sub(r"_", ".", snakecase(string)) def titlecase(string): """Convert string into sentence case. First letter capped while each punctuations is capitalsed and joined with space. Args: string: String to convert. Returns: string: Title cased string. """ return " ".join([capitalcase(word) for word in snakecase(string).split("_")]) def trimcase(string): """Convert string into trimmed string. Args: string: String to convert. Returns: string: Trimmed case string """ return str(string).strip() def uppercase(string): """Convert string into upper case. Args: string: String to convert. Returns: string: Uppercase case string. """ return str(string).upper() def alphanumcase(string): """Cuts all non-alphanumeric symbols, i.e. cuts all expect except 0-9, a-z and A-Z. Args: string: String to convert. Returns: string: String with cut non-alphanumeric symbols. """ # return filter(str.isalnum, str(string)) return re.sub(r"\W+", "", string) ================================================ FILE: pylib/anki/browser.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html class BrowserConfig: ACTIVE_CARD_COLUMNS_KEY = "activeCols" ACTIVE_NOTE_COLUMNS_KEY = "activeNoteCols" CARDS_SORT_COLUMN_KEY = "sortType" NOTES_SORT_COLUMN_KEY = "noteSortType" CARDS_SORT_BACKWARDS_KEY = "sortBackwards" NOTES_SORT_BACKWARDS_KEY = "browserNoteSortBackwards" @staticmethod def active_columns_key(is_notes_mode: bool) -> str: if is_notes_mode: return BrowserConfig.ACTIVE_NOTE_COLUMNS_KEY return BrowserConfig.ACTIVE_CARD_COLUMNS_KEY @staticmethod def sort_column_key(is_notes_mode: bool) -> str: if is_notes_mode: return BrowserConfig.NOTES_SORT_COLUMN_KEY return BrowserConfig.CARDS_SORT_COLUMN_KEY @staticmethod def sort_backwards_key(is_notes_mode: bool) -> str: if is_notes_mode: return BrowserConfig.NOTES_SORT_BACKWARDS_KEY return BrowserConfig.CARDS_SORT_BACKWARDS_KEY class BrowserDefaults: CARD_COLUMNS = ["noteFld", "template", "cardDue", "deck"] NOTE_COLUMNS = ["noteFld", "note", "template", "noteTags"] ================================================ FILE: pylib/anki/cards.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import pprint import time from typing import NewType import anki import anki.collection import anki.decks import anki.notes import anki.template from anki import cards_pb2, hooks from anki._legacy import DeprecatedNamesMixin, deprecated from anki.consts import * from anki.models import NotetypeDict, TemplateDict from anki.notes import Note from anki.sound import AVTag # Cards ########################################################################## # Type: 0=new, 1=learning, 2=due # Queue: same as above, and: # -1=suspended, -2=user buried, -3=sched buried # Due is used differently for different queues. # - new queue: position # - rev queue: integer day # - lrn queue: integer timestamp # types CardId = NewType("CardId", int) BackendCard = cards_pb2.Card FSRSMemoryState = cards_pb2.FsrsMemoryState class Card(DeprecatedNamesMixin): _note: Note | None lastIvl: int ord: int nid: anki.notes.NoteId id: CardId did: anki.decks.DeckId odid: anki.decks.DeckId queue: CardQueue type: CardType memory_state: FSRSMemoryState | None desired_retention: float | None decay: float | None last_review_time: int | None def __init__( self, col: anki.collection.Collection, id: CardId | None = None, backend_card: BackendCard | None = None, ) -> None: self.col = col.weakref() self.timer_started: float | None = None self._render_output: anki.template.TemplateRenderOutput | None = None if id: # existing card self.id = id self.load() elif backend_card: self._load_from_backend_card(backend_card) else: # new card with defaults self._load_from_backend_card(cards_pb2.Card()) def load(self) -> None: card = self.col._backend.get_card(self.id) assert card self._load_from_backend_card(card) def _load_from_backend_card(self, card: cards_pb2.Card) -> None: self._render_output = None self._note = None self.id = CardId(card.id) self.nid = anki.notes.NoteId(card.note_id) self.did = anki.decks.DeckId(card.deck_id) self.ord = card.template_idx self.mod = card.mtime_secs self.usn = card.usn self.type = CardType(card.ctype) self.queue = CardQueue(card.queue) self.due = card.due self.ivl = card.interval self.factor = card.ease_factor self.reps = card.reps self.lapses = card.lapses self.left = card.remaining_steps self.odue = card.original_due self.odid = anki.decks.DeckId(card.original_deck_id) self.flags = card.flags self.original_position = ( card.original_position if card.HasField("original_position") else None ) self.custom_data = card.custom_data self.memory_state = card.memory_state if card.HasField("memory_state") else None self.desired_retention = ( card.desired_retention if card.HasField("desired_retention") else None ) self.decay = card.decay if card.HasField("decay") else None self.last_review_time = ( card.last_review_time_secs if card.HasField("last_review_time_secs") else None ) def _to_backend_card(self) -> cards_pb2.Card: # mtime & usn are set by backend return cards_pb2.Card( id=self.id, note_id=self.nid, deck_id=self.did, template_idx=self.ord, ctype=self.type, queue=self.queue, due=self.due, interval=self.ivl, ease_factor=self.factor, reps=self.reps, lapses=self.lapses, remaining_steps=self.left, original_due=self.odue, original_deck_id=self.odid, flags=self.flags, original_position=self.original_position, custom_data=self.custom_data, memory_state=self.memory_state, desired_retention=self.desired_retention, decay=self.decay, last_review_time_secs=self.last_review_time, ) @deprecated(info="please use col.update_card()") def flush(self) -> None: hooks.card_will_flush(self) if self.id != 0: self.col._backend.update_cards( cards=[self._to_backend_card()], skip_undo_entry=True ) else: raise Exception("card.flush() expects an existing card") def question(self, reload: bool = False, browser: bool = False) -> str: return self.render_output(reload, browser).question_and_style() def answer(self) -> str: return self.render_output().answer_and_style() def question_av_tags(self) -> list[AVTag]: return self.render_output().question_av_tags def answer_av_tags(self) -> list[AVTag]: return self.render_output().answer_av_tags def render_output( self, reload: bool = False, browser: bool = False ) -> anki.template.TemplateRenderOutput: if not self._render_output or reload: self._render_output = ( anki.template.TemplateRenderContext.from_existing_card( self, browser ).render() ) return self._render_output def set_render_output(self, output: anki.template.TemplateRenderOutput) -> None: self._render_output = output def note(self, reload: bool = False) -> Note: if not self._note or reload: self._note = self.col.get_note(self.nid) return self._note def note_type(self) -> NotetypeDict: return self.col.models.get(self.note().mid) def template(self) -> TemplateDict: notetype = self.note_type() templates = notetype["tmpls"] if notetype["type"] == MODEL_STD: return templates[self.ord] else: return templates[0] def start_timer(self) -> None: self.timer_started = time.time() def current_deck_id(self) -> anki.decks.DeckId: return anki.decks.DeckId(self.odid or self.did) def time_limit(self) -> int: "Time limit for answering in milliseconds." conf = self.col.decks.config_dict_for_deck_id(self.current_deck_id()) return conf["maxTaken"] * 1000 def should_show_timer(self) -> bool: conf = self.col.decks.config_dict_for_deck_id(self.current_deck_id()) return conf["timer"] def replay_question_audio_on_answer_side(self) -> bool: conf = self.col.decks.config_dict_for_deck_id(self.current_deck_id()) return conf.get("replayq", True) def autoplay(self) -> bool: return self.col.decks.config_dict_for_deck_id(self.current_deck_id())[ "autoplay" ] def time_taken(self, capped: bool = True) -> int: """Time taken since card timer started, in integer MS. If `capped` is true, returned time is limited to deck preset setting.""" total = int((time.time() - self.timer_started) * 1000) if capped: total = min(total, self.time_limit()) return total def description(self) -> str: dict_copy = dict(self.__dict__) # remove non-useful elements del dict_copy["_note"] del dict_copy["_render_output"] del dict_copy["col"] del dict_copy["timer_started"] return f"{super().__repr__()} {pprint.pformat(dict_copy, width=300)}" def user_flag(self) -> int: return self.flags & 0b111 def set_user_flag(self, flag: int) -> None: print("use col.set_user_flag_for_cards() instead") if not 0 <= flag <= 7: raise Exception("invalid flag") self.flags = (self.flags & ~0b111) | flag @deprecated(info="use card.render_output() directly") def css(self) -> str: return f"" @deprecated(info="handled by template rendering") def is_empty(self) -> bool: return False Card.register_deprecated_aliases( flushSched=Card.flush, q=Card.question, a=Card.answer, model=Card.note_type, ) ================================================ FILE: pylib/anki/collection.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Generator, Iterable, Sequence from typing import Any, Literal, Union, cast from anki import ( ankiweb_pb2, card_rendering_pb2, collection_pb2, config_pb2, generic_pb2, image_occlusion_pb2, import_export_pb2, links_pb2, notes_pb2, scheduler_pb2, search_pb2, stats_pb2, sync_pb2, ) from anki._legacy import DeprecatedNamesMixin, deprecated from anki.sync_pb2 import SyncLoginRequest # protobuf we publicly export - listed first to avoid circular imports HelpPage = links_pb2.HelpPageLinkRequest.HelpPage SearchNode = search_pb2.SearchNode Progress = collection_pb2.Progress EmptyCardsReport = card_rendering_pb2.EmptyCardsReport GraphPreferences = stats_pb2.GraphPreferences CardStats = stats_pb2.CardStatsResponse Preferences = config_pb2.Preferences UndoStatus = collection_pb2.UndoStatus OpChanges = collection_pb2.OpChanges OpChangesOnly = collection_pb2.OpChangesOnly OpChangesWithCount = collection_pb2.OpChangesWithCount OpChangesWithId = collection_pb2.OpChangesWithId OpChangesAfterUndo = collection_pb2.OpChangesAfterUndo BrowserRow = search_pb2.BrowserRow BrowserColumns = search_pb2.BrowserColumns StripHtmlMode = card_rendering_pb2.StripHtmlRequest ImportLogWithChanges = import_export_pb2.ImportResponse ImportAnkiPackageRequest = import_export_pb2.ImportAnkiPackageRequest ImportAnkiPackageOptions = import_export_pb2.ImportAnkiPackageOptions ExportAnkiPackageOptions = import_export_pb2.ExportAnkiPackageOptions ImportCsvRequest = import_export_pb2.ImportCsvRequest CsvMetadata = import_export_pb2.CsvMetadata DupeResolution = CsvMetadata.DupeResolution Delimiter = import_export_pb2.CsvMetadata.Delimiter TtsVoice = card_rendering_pb2.AllTtsVoicesResponse.TtsVoice GetImageForOcclusionResponse = image_occlusion_pb2.GetImageForOcclusionResponse AddImageOcclusionNoteRequest = image_occlusion_pb2.AddImageOcclusionNoteRequest GetImageOcclusionNoteResponse = image_occlusion_pb2.GetImageOcclusionNoteResponse AddonInfo = ankiweb_pb2.AddonInfo CheckForUpdateResponse = ankiweb_pb2.CheckForUpdateResponse MediaSyncStatus = sync_pb2.MediaSyncStatusResponse FsrsItem = scheduler_pb2.FsrsItem FsrsReview = scheduler_pb2.FsrsReview import os import sys import time import traceback import weakref from dataclasses import dataclass import anki.latex from anki import hooks from anki._backend import RustBackend, Translations from anki.browser import BrowserConfig, BrowserDefaults from anki.cards import Card, CardId from anki.config import Config, ConfigManager from anki.consts import * from anki.dbproxy import DBProxy from anki.decks import DeckId, DeckManager from anki.errors import AbortSchemaModification, DBError from anki.lang import FormatTimeSpan from anki.media import MediaManager, media_paths_from_col_path from anki.models import ModelManager, NotetypeDict, NotetypeId from anki.notes import Note, NoteId from anki.scheduler.dummy import DummyScheduler from anki.scheduler.v3 import Scheduler as V3Scheduler from anki.sync import SyncAuth, SyncOutput, SyncStatus from anki.tags import TagManager from anki.utils import ( from_json_bytes, ids2str, int_time, split_fields, strip_html_media, to_json_bytes, ) anki.latex.setup_hook() SearchJoiner = Literal["AND", "OR"] @dataclass class DeckIdLimit: deck_id: DeckId @dataclass class NoteIdsLimit: note_ids: Sequence[NoteId] @dataclass class CardIdsLimit: card_ids: Sequence[CardId] ExportLimit = Union[DeckIdLimit, NoteIdsLimit, CardIdsLimit, None] @dataclass class ComputedMemoryState: desired_retention: float stability: float | None = None difficulty: float | None = None decay: float | None = None @dataclass class AddNoteRequest: note: Note deck_id: DeckId class Collection(DeprecatedNamesMixin): sched: V3Scheduler | DummyScheduler @staticmethod def initialize_backend_logging() -> None: """Enable terminal logging. Must be called only once.""" RustBackend.initialize_logging(None) def __init__( self, path: str, backend: RustBackend | None = None, server: bool = False, ) -> None: self._backend = backend or RustBackend(server=server) self.db: DBProxy | None = None self.server = server self.path = os.path.abspath(path) self.reopen() self.tr = Translations(weakref.ref(self._backend)) self.media = MediaManager(self, server) self.models = ModelManager(self) self.decks = DeckManager(self) self.tags = TagManager(self) self.conf = ConfigManager(self) self._load_scheduler() self._startReps = 0 def name(self) -> Any: return os.path.splitext(os.path.basename(self.path))[0] def weakref(self) -> Collection: "Shortcut to create a weak reference that doesn't break code completion." return weakref.proxy(self) @property def backend(self) -> RustBackend: traceback.print_stack(file=sys.stdout) print() print( "Accessing the backend directly will break in the future. Please use the public methods on Collection instead." ) return self._backend # I18n/messages ########################################################################## def format_timespan( self, seconds: float, context: FormatTimeSpan.Context.V = FormatTimeSpan.INTERVALS, ) -> str: return self._backend.format_timespan(seconds=seconds, context=context) # Progress ########################################################################## def latest_progress(self) -> Progress: return self._backend.latest_progress() # Scheduler ########################################################################## _supported_scheduler_versions = (1, 2) def sched_ver(self) -> Literal[1, 2]: """For backwards compatibility, the v3 scheduler currently returns 2. Use the separate v3_scheduler() method to check if it is active.""" # for backwards compatibility, v3 is represented as 2 ver = self.conf.get("schedVer", 1) if ver in self._supported_scheduler_versions: return ver else: raise Exception("Unsupported scheduler version") def _load_scheduler(self) -> None: ver = self.sched_ver() if ver == 1: self.sched = DummyScheduler(self) elif ver == 2: if self.v3_scheduler(): self.sched = V3Scheduler(self) # enable new timezone if not already enabled if self.conf.get("creationOffset") is None: prefs = self._backend.get_preferences() prefs.scheduling.new_timezone = True self._backend.set_preferences(prefs) else: self.sched = DummyScheduler(self) def upgrade_to_v2_scheduler(self) -> None: self._backend.upgrade_scheduler() self._load_scheduler() def v3_scheduler(self) -> bool: return self.sched_ver() == 2 and self.get_config_bool(Config.Bool.SCHED_2021) def set_v3_scheduler(self, enabled: bool) -> None: if self.v3_scheduler() != enabled: if enabled and self.sched_ver() != 2: raise Exception("must upgrade to v2 scheduler first") self.set_config_bool(Config.Bool.SCHED_2021, enabled) self._load_scheduler() # DB-related ########################################################################## # legacy properties; these will likely go away in the future @property def crt(self) -> int: return self.db.scalar("select crt from col") @crt.setter def crt(self, crt: int) -> None: self.db.execute("update col set crt = ?", crt) @property def mod(self) -> int: return self.db.scalar("select mod from col") @deprecated(info="saving is automatic") def save(self, **args: Any) -> None: pass @deprecated(info="saving is automatic") def autosave(self) -> None: pass def close( self, downgrade: bool = False, ) -> None: "Disconnect from DB." if self.db: self._clear_caches() self._backend.close_collection( downgrade_to_schema11=downgrade, ) self.db = None def close_for_full_sync(self) -> None: # save and cleanup, but backend will take care of collection close if self.db: self._clear_caches() self.db = None def _clear_caches(self) -> None: self.models._clear_cache() def reopen(self, after_full_sync: bool = False) -> None: if self.db: raise Exception("reopen() called with open db") (media_dir, media_db) = media_paths_from_col_path(self.path) # connect if not after_full_sync: self._backend.open_collection( collection_path=self.path, media_folder_path=media_dir, media_db_path=media_db, ) self.db = DBProxy(weakref.proxy(self._backend)) if after_full_sync: self._load_scheduler() def set_schema_modified(self) -> None: self.db.execute("update col set scm=?", int_time(1000)) def mod_schema(self, check: bool) -> None: "Mark schema modified. GUI catches this and will ask user if required." if not self.schema_changed(): if check and not hooks.schema_will_change(proceed=True): raise AbortSchemaModification() self.set_schema_modified() def schema_changed(self) -> bool: "True if schema changed since last sync." return self.db.scalar("select scm > ls from col") def usn(self) -> int: if self.server: return self.db.scalar("select usn from col") else: return -1 # Import/export ########################################################################## def create_backup( self, *, backup_folder: str, force: bool, wait_for_completion: bool, ) -> bool: """Create a backup if enough time has elapsed, and rotate old backups. If `force` is true, the user's configured backup interval is ignored. Returns true if backup created. This may be false in the force=True case, if no changes have been made to the collection. Throws on failure of current backup, or the previous backup if it was not awaited. """ # ensure any pending transaction from legacy code/add-ons has been committed created = self._backend.create_backup( backup_folder=backup_folder, force=force, wait_for_completion=wait_for_completion, ) return created def await_backup_completion(self) -> None: "Throws if backup creation failed." self._backend.await_backup_completion() def export_collection_package( self, out_path: str, include_media: bool, legacy: bool ) -> None: self.close_for_full_sync() self._backend.export_collection_package( out_path=out_path, include_media=include_media, legacy=legacy ) def import_anki_package( self, request: ImportAnkiPackageRequest ) -> ImportLogWithChanges: log = self._backend.import_anki_package_raw(request.SerializeToString()) return ImportLogWithChanges.FromString(log) def export_anki_package( self, *, out_path: str, options: ExportAnkiPackageOptions, limit: ExportLimit ) -> int: return self._backend.export_anki_package( out_path=out_path, options=options, limit=pb_export_limit(limit), ) def get_csv_metadata(self, path: str, delimiter: Delimiter.V | None) -> CsvMetadata: request = import_export_pb2.CsvMetadataRequest(path=path, delimiter=delimiter) return self._backend.get_csv_metadata(request) def import_csv(self, request: ImportCsvRequest) -> ImportLogWithChanges: log = self._backend.import_csv_raw(request.SerializeToString()) return ImportLogWithChanges.FromString(log) def export_note_csv( self, *, out_path: str, limit: ExportLimit, with_html: bool, with_tags: bool, with_deck: bool, with_notetype: bool, with_guid: bool, ) -> int: return self._backend.export_note_csv( out_path=out_path, with_html=with_html, with_tags=with_tags, with_deck=with_deck, with_notetype=with_notetype, with_guid=with_guid, limit=pb_export_limit(limit), ) def export_card_csv( self, *, out_path: str, limit: ExportLimit, with_html: bool, ) -> int: return self._backend.export_card_csv( out_path=out_path, with_html=with_html, limit=pb_export_limit(limit), ) def import_json_file(self, path: str) -> ImportLogWithChanges: return self._backend.import_json_file(path) def import_json_string(self, json: str) -> ImportLogWithChanges: return self._backend.import_json_string(json) def export_dataset_for_research( self, target_path: str, min_entries: int = 0 ) -> None: self._backend.export_dataset(min_entries=min_entries, target_path=target_path) # Image Occlusion ########################################################################## def get_image_for_occlusion(self, path: str | None) -> GetImageForOcclusionResponse: return self._backend.get_image_for_occlusion(path=path) def add_image_occlusion_notetype(self) -> None: "Add notetype if missing." self._backend.add_image_occlusion_notetype() def add_image_occlusion_note( self, notetype_id: int, image_path: str, occlusions: str, header: str, back_extra: str, tags: list[str], ) -> OpChanges: return self._backend.add_image_occlusion_note( notetype_id=notetype_id, image_path=image_path, occlusions=occlusions, header=header, back_extra=back_extra, tags=tags, ) def get_image_occlusion_note( self, note_id: int | None ) -> GetImageOcclusionNoteResponse: return self._backend.get_image_occlusion_note(note_id=note_id) def update_image_occlusion_note( self, note_id: int | None, occlusions: str | None, header: str | None, back_extra: str | None, tags: list[str] | None, ) -> OpChanges: return self._backend.update_image_occlusion_note( note_id=note_id, occlusions=occlusions, header=header, back_extra=back_extra, tags=tags, ) # Object helpers ########################################################################## def get_card(self, id: CardId | None) -> Card: return Card(self, id) def update_cards( self, cards: Sequence[Card], skip_undo_entry: bool = False ) -> OpChanges: """Save card changes to database.""" return self._backend.update_cards( cards=[c._to_backend_card() for c in cards], skip_undo_entry=skip_undo_entry ) def update_card(self, card: Card, skip_undo_entry: bool = False) -> OpChanges: """Save card changes to database.""" return self.update_cards([card], skip_undo_entry=skip_undo_entry) def get_note(self, id: NoteId) -> Note: return Note(self, id=id) def update_notes( self, notes: Sequence[Note], skip_undo_entry: bool = False ) -> OpChanges: """Save note changes to database.""" return self._backend.update_notes( notes=[n._to_backend_note() for n in notes], skip_undo_entry=skip_undo_entry ) def update_note(self, note: Note, skip_undo_entry: bool = False) -> OpChanges: """Save note changes to database.""" return self.update_notes([note], skip_undo_entry=skip_undo_entry) # Utils ########################################################################## def nextID(self, type: str, inc: bool = True) -> Any: type = f"next{type.capitalize()}" id = self.conf.get(type, 1) if inc: self.conf[type] = id + 1 return id @deprecated(info="no longer required") def reset(self) -> None: pass # Notes ########################################################################## def new_note(self, notetype: NotetypeDict) -> Note: return Note(self, notetype) def add_note(self, note: Note, deck_id: DeckId) -> OpChangesWithCount: hooks.note_will_be_added(self, note, deck_id) out = self._backend.add_note(note=note._to_backend_note(), deck_id=deck_id) note.id = NoteId(out.note_id) return out.changes def add_notes(self, requests: Iterable[AddNoteRequest]) -> OpChanges: for request in requests: hooks.note_will_be_added(self, request.note, request.deck_id) out = self._backend.add_notes( requests=[ notes_pb2.AddNoteRequest( note=request.note._to_backend_note(), deck_id=request.deck_id ) for request in requests ] ) for idx, request in enumerate(requests): request.note.id = NoteId(out.nids[idx]) return out.changes def remove_notes(self, note_ids: Sequence[NoteId]) -> OpChangesWithCount: hooks.notes_will_be_deleted(self, note_ids) return self._backend.remove_notes(note_ids=note_ids, card_ids=[]) def remove_notes_by_card(self, card_ids: list[CardId]) -> None: if hooks.notes_will_be_deleted.count(): nids = self.db.list( f"select nid from cards where id in {ids2str(card_ids)}" ) hooks.notes_will_be_deleted(self, nids) self._backend.remove_notes(note_ids=[], card_ids=card_ids) def card_ids_of_note(self, note_id: NoteId) -> Sequence[CardId]: return [CardId(id) for id in self._backend.cards_of_note(note_id)] def defaults_for_adding( self, *, current_review_card: Card | None ) -> anki.notes.DefaultsForAdding: """Get starting deck and notetype for add screen. An option in the preferences controls whether this will be based on the current deck or current notetype. """ if card := current_review_card: home_deck = card.current_deck_id() else: home_deck = DeckId(0) return self._backend.defaults_for_adding( home_deck_of_current_review_card=home_deck, ) def default_deck_for_notetype(self, notetype_id: NotetypeId) -> DeckId | None: """If 'change deck depending on notetype' is enabled in the preferences, return the last deck used with the provided notetype, if any..""" if self.get_config_bool(Config.Bool.ADDING_DEFAULTS_TO_CURRENT_DECK): return None return ( DeckId( self._backend.default_deck_for_notetype( ntid=notetype_id, ) ) or None ) def note_count(self) -> int: return self.db.scalar("select count() from notes") # Cards ########################################################################## def is_empty(self) -> bool: return not self.db.scalar("select 1 from cards limit 1") def card_count(self) -> Any: return self.db.scalar("select count() from cards") def remove_cards_and_orphaned_notes( self, card_ids: Sequence[CardId] ) -> OpChangesWithCount: "You probably want .remove_notes_by_card() instead." return self._backend.remove_cards(card_ids=card_ids) def set_deck(self, card_ids: Sequence[CardId], deck_id: int) -> OpChangesWithCount: return self._backend.set_deck(card_ids=card_ids, deck_id=deck_id) def get_empty_cards(self) -> EmptyCardsReport: return self._backend.get_empty_cards() # Card generation & field checksums/sort fields ########################################################################## def after_note_updates( self, nids: list[NoteId], mark_modified: bool, generate_cards: bool = True ) -> None: "If notes modified directly in database, call this afterwards." self._backend.after_note_updates( nids=nids, generate_cards=generate_cards, mark_notes_modified=mark_modified ) # Finding cards ########################################################################## def find_cards( self, query: str, order: bool | str | BrowserColumns.Column = False, reverse: bool = False, ) -> Sequence[CardId]: """Return card ids matching the provided search. To programmatically construct a search string, see .build_search_string(). If order=True, use the sort order stored in the collection config If order=False, do no ordering If order is a string, that text is added after 'order by' in the sql statement. You must add ' asc' or ' desc' to the order, as Anki will replace asc with desc and vice versa when reverse is set in the collection config, eg order="c.ivl asc, c.due desc". If order is a BrowserColumns.Column that supports sorting, sort using that column. All available columns are available through col.all_browser_columns() or browser.table._model.columns and support sorting cards unless column.sorting_cards is set to BrowserColumns.SORTING_NONE, .SORTING_NOTES_ASCENDING, or .SORTING_NOTES_DESCENDING. The reverse argument only applies when a BrowserColumns.Column is provided; otherwise the collection config defines whether reverse is set or not. """ mode = self._build_sort_mode(order, reverse, False) return cast( Sequence[CardId], self._backend.search_cards(search=query, order=mode) ) def find_notes( self, query: str, order: bool | str | BrowserColumns.Column = False, reverse: bool = False, ) -> Sequence[NoteId]: """Return note ids matching the provided search. To programmatically construct a search string, see .build_search_string(). The order parameter is documented in .find_cards(). """ mode = self._build_sort_mode(order, reverse, True) return cast( Sequence[NoteId], self._backend.search_notes(search=query, order=mode) ) def _build_sort_mode( self, order: bool | str | BrowserColumns.Column, reverse: bool, finding_notes: bool, ) -> search_pb2.SortOrder: if isinstance(order, str): return search_pb2.SortOrder(custom=order) if isinstance(order, bool): if order is False: return search_pb2.SortOrder(none=generic_pb2.Empty()) # order=True: set args to sort column and reverse from config sort_key = BrowserConfig.sort_column_key(finding_notes) order = self.get_browser_column(self.get_config(sort_key)) reverse_key = BrowserConfig.sort_backwards_key(finding_notes) reverse = self.get_config(reverse_key) if ( isinstance(order, BrowserColumns.Column) and (order.sorting_notes if finding_notes else order.sorting_cards) is not BrowserColumns.SORTING_NONE ): return search_pb2.SortOrder( builtin=search_pb2.SortOrder.Builtin(column=order.key, reverse=reverse) ) # eg, user is ordering on an add-on field with the add-on not installed print(f"{order} is not a valid sort order.") return search_pb2.SortOrder(none=generic_pb2.Empty()) def find_and_replace( self, *, note_ids: Sequence[NoteId], search: str, replacement: str, regex: bool = False, field_name: str | None = None, match_case: bool = False, ) -> OpChangesWithCount: "Find and replace fields in a note. Returns changed note count." return self._backend.find_and_replace( nids=note_ids, search=search, replacement=replacement, regex=regex, match_case=match_case, field_name=field_name or "", ) def field_names_for_note_ids(self, nids: Sequence[int]) -> Sequence[str]: return self._backend.field_names_for_notes(nids) # returns array of ("dupestr", [nids]) def find_dupes(self, field_name: str, search: str = "") -> list[tuple[str, list]]: nids = self.find_notes( self.build_search_string(search, SearchNode(field_name=field_name)) ) # go through notes vals: dict[str, list[int]] = {} dupes = [] fields: dict[int, int] = {} def ord_for_mid(mid: NotetypeId) -> int: if mid not in fields: model = self.models.get(mid) for idx, field in enumerate(model["flds"]): if field["name"].lower() == field_name.lower(): fields[mid] = idx break return fields[mid] for nid, mid, flds in self.db.all( f"select id, mid, flds from notes where id in {ids2str(nids)}" ): flds = split_fields(flds) ord = ord_for_mid(mid) if ord is None: continue val = flds[ord] val = strip_html_media(val) # empty does not count as duplicate if not val: continue vals.setdefault(val, []).append(nid) if len(vals[val]) == 2: dupes.append((val, vals[val])) return dupes # Search Strings ########################################################################## def build_search_string( self, *nodes: str | SearchNode, joiner: SearchJoiner = "AND", ) -> str: """Join one or more searches, and return a normalized search string. To negate, wrap in a negated search term: term = SearchNode(negated=col.group_searches(...)) Invalid searches will throw an exception. """ term = self.group_searches(*nodes, joiner=joiner) return self._backend.build_search_string(term) def group_searches( self, *nodes: str | SearchNode, joiner: SearchJoiner = "AND", ) -> SearchNode: """Join provided search nodes and strings into a single SearchNode. If a single SearchNode is provided, it is returned as-is. At least one node must be provided. """ assert nodes # convert raw text to SearchNodes search_nodes = [ node if isinstance(node, SearchNode) else SearchNode(parsable_text=node) for node in nodes ] # if there's more than one, wrap them in a group if len(search_nodes) > 1: return SearchNode( group=SearchNode.Group( nodes=search_nodes, joiner=self._pb_search_separator(joiner) ) ) else: return search_nodes[0] def join_searches( self, existing_node: SearchNode, additional_node: SearchNode, operator: Literal["AND", "OR"], ) -> str: """ AND or OR `additional_term` to `existing_term`, without wrapping `existing_term` in brackets. Used by the Browse screen to avoid adding extra brackets when joining. If you're building a search query yourself, you probably don't need this. """ search_string = self._backend.join_search_nodes( joiner=self._pb_search_separator(operator), existing_node=existing_node, additional_node=additional_node, ) return search_string def replace_in_search_node( self, existing_node: SearchNode, replacement_node: SearchNode ) -> str: """If nodes of the same type as `replacement_node` are found in existing_node, replace them. You can use this to replace any "deck" clauses in a search with a different deck for example. """ return self._backend.replace_search_node( existing_node=existing_node, replacement_node=replacement_node ) def _pb_search_separator(self, operator: SearchJoiner) -> SearchNode.Group.Joiner.V: if operator == "AND": return SearchNode.Group.Joiner.AND else: return SearchNode.Group.Joiner.OR # Browser Table ########################################################################## def all_browser_columns(self) -> Sequence[BrowserColumns.Column]: return self._backend.all_browser_columns() def get_browser_column(self, key: str) -> BrowserColumns.Column | None: for column in self._backend.all_browser_columns(): if column.key == key: return column return None def browser_row_for_id( self, id_: int ) -> tuple[ Generator[tuple[str, bool, BrowserRow.Cell.TextElideMode.V], None, None], BrowserRow.Color.V, str, int, ]: row = self._backend.browser_row_for_id(id_) return ( ((cell.text, cell.is_rtl, cell.elide_mode) for cell in row.cells), row.color, row.font_name, row.font_size, ) def load_browser_card_columns(self) -> list[str]: """Return the stored card column names and ensure the backend columns are set and in sync.""" columns = self.get_config( BrowserConfig.ACTIVE_CARD_COLUMNS_KEY, BrowserDefaults.CARD_COLUMNS ) self._backend.set_active_browser_columns(columns) return columns def set_browser_card_columns(self, columns: list[str]) -> None: self.set_config(BrowserConfig.ACTIVE_CARD_COLUMNS_KEY, columns) self._backend.set_active_browser_columns(columns) def load_browser_note_columns(self) -> list[str]: """Return the stored note column names and ensure the backend columns are set and in sync.""" columns = self.get_config( BrowserConfig.ACTIVE_NOTE_COLUMNS_KEY, BrowserDefaults.NOTE_COLUMNS ) self._backend.set_active_browser_columns(columns) return columns def set_browser_note_columns(self, columns: list[str]) -> None: self.set_config(BrowserConfig.ACTIVE_NOTE_COLUMNS_KEY, columns) self._backend.set_active_browser_columns(columns) # Config ########################################################################## def get_config(self, key: str, default: Any | None = None) -> Any: try: return self.conf.get_immutable(key) except KeyError: return default def set_config(self, key: str, val: Any, *, undoable: bool = False) -> OpChanges: """Set a single config variable to any JSON-serializable value. The config is currently sent on every sync, so please don't store more than a few kilobytes in it. By default, no undo entry will be created, but the existing undo history will be preserved. Set `undoable=True` to allow the change to be undone; see undo code for how you can merge multiple undo entries.""" return self._backend.set_config_json( key=key, value_json=to_json_bytes(val), undoable=undoable ) def remove_config(self, key: str) -> OpChanges: return self.conf.remove(key) def all_config(self) -> dict[str, Any]: "This is a debugging aid. Prefer .get_config() when you know the key you need." return from_json_bytes(self._backend.get_all_config()) def get_config_bool(self, key: Config.Bool.V) -> bool: return self._backend.get_config_bool(key) def set_config_bool( self, key: Config.Bool.V, value: bool, *, undoable: bool = False ) -> OpChanges: return self._backend.set_config_bool(key=key, value=value, undoable=undoable) def get_config_string(self, key: Config.String.V) -> str: return self._backend.get_config_string(key) def set_config_string( self, key: Config.String.V, value: str, undoable: bool = False ) -> OpChanges: return self._backend.set_config_string(key=key, value=value, undoable=undoable) def get_aux_notetype_config( self, id: NotetypeId, key: str, default: Any | None = None ) -> Any: key = self._backend.get_aux_notetype_config_key(id=id, key=key) return self.get_config(key, default=default) def set_aux_notetype_config( self, id: NotetypeId, key: str, value: Any, *, undoable: bool = False ) -> OpChanges: key = self._backend.get_aux_notetype_config_key(id=id, key=key) return self.set_config(key, value, undoable=undoable) def get_aux_template_config( self, id: NotetypeId, card_ordinal: int, key: str, default: Any | None = None ) -> Any: key = self._backend.get_aux_template_config_key( notetype_id=id, card_ordinal=card_ordinal, key=key ) return self.get_config(key, default=default) def set_aux_template_config( self, id: NotetypeId, card_ordinal: int, key: str, value: Any, *, undoable: bool = False, ) -> OpChanges: key = self._backend.get_aux_template_config_key( notetype_id=id, card_ordinal=card_ordinal, key=key ) return self.set_config(key, value, undoable=undoable) def _get_load_balancer_enabled(self) -> bool: return self.get_config_bool(Config.Bool.LOAD_BALANCER_ENABLED) def _set_load_balancer_enabled(self, value: bool) -> None: self._backend.set_load_balancer_enabled(value) load_balancer_enabled = property( fget=_get_load_balancer_enabled, fset=_set_load_balancer_enabled ) def _get_enable_fsrs_short_term_with_steps(self) -> bool: return self.get_config_bool(Config.Bool.FSRS_SHORT_TERM_WITH_STEPS_ENABLED) def _set_enable_fsrs_short_term_with_steps(self, value: bool) -> None: self.set_config_bool(Config.Bool.FSRS_SHORT_TERM_WITH_STEPS_ENABLED, value) fsrs_short_term_with_steps_enabled = property( fget=_get_enable_fsrs_short_term_with_steps, fset=_set_enable_fsrs_short_term_with_steps, ) # Stats ########################################################################## def stats(self) -> anki.stats.CollectionStats: from anki.stats import CollectionStats return CollectionStats(self) def card_stats_data(self, card_id: CardId) -> stats_pb2.CardStatsResponse: """Returns the data required to show card stats. If you wish to display the stats in a HTML table like Anki does, you can use the .js file directly - see this add-on for an example: https://ankiweb.net/shared/info/2179254157 """ return self._backend.card_stats(card_id) def get_review_logs( self, card_id: CardId ) -> Sequence[stats_pb2.CardStatsResponse.StatsRevlogEntry]: return self._backend.get_review_logs(card_id) def studied_today(self) -> str: return self._backend.studied_today() # Undo ########################################################################## def undo_status(self) -> UndoStatus: "Return the undo status." return self._check_backend_undo_status() or UndoStatus() def add_custom_undo_entry(self, name: str) -> int: """Add an empty undo entry with the given name. The return value can be used to merge subsequent changes with `merge_undo_entries()`. You should only use this with your own custom actions - when extending default Anki behaviour, you should merge into an existing undo entry instead, so the existing undo name is preserved, and changes are processed correctly. """ return self._backend.add_custom_undo_entry(name) def merge_undo_entries(self, target: int) -> OpChanges: """Combine multiple undoable operations into one. After a standard Anki action, you can use col.undo_status().last_step to retrieve the target to merge into. When defining your own custom actions, you can use `add_custom_undo_entry()` to define a custom undo name. """ return self._backend.merge_undo_entries(target) def undo(self) -> OpChangesAfterUndo: """Returns result of backend undo operation, or throws UndoEmpty.""" out = self._backend.undo() if out.changes.notetype: self.models._clear_cache() return out def redo(self) -> OpChangesAfterUndo: """Returns result of backend redo operation, or throws UndoEmpty.""" out = self._backend.redo() if out.changes.notetype: self.models._clear_cache() return out def op_made_changes(self, changes: OpChanges) -> bool: for field in changes.DESCRIPTOR.fields: if field.name != "kind": if getattr(changes, field.name, False): return True return False def _check_backend_undo_status(self) -> UndoStatus | None: """Return undo status if undo available on backend. If backend has undo available, clear the Python undo state.""" status = self._backend.get_undo_status() if status.undo or status.redo: return status else: return None # DB maintenance ########################################################################## def fix_integrity(self) -> tuple[str, bool]: """Fix possible problems and rebuild caches. Returns tuple of (error: str, ok: bool). 'ok' will be true if no problems were found. """ try: problems = list(self._backend.check_database()) ok = not problems problems.append(self.tr.database_check_rebuilt()) except DBError as err: problems = [str(err)] ok = False return ("\n".join(problems), ok) def optimize(self) -> None: self.db.execute("vacuum") self.db.execute("analyze") ########################################################################## def set_user_flag_for_cards( self, flag: int, cids: Sequence[CardId] ) -> OpChangesWithCount: return self._backend.set_flag(card_ids=cids, flag=flag) def set_wants_abort(self) -> None: self._backend.set_wants_abort() def i18n_resources(self, modules: Sequence[str]) -> bytes: return self._backend.i18n_resources(modules=modules) def abort_media_sync(self) -> None: self._backend.abort_media_sync() def abort_sync(self) -> None: self._backend.abort_sync() def full_upload_or_download( self, *, auth: SyncAuth | None, server_usn: int | None, upload: bool ) -> None: self._backend.full_upload_or_download( sync_pb2.FullUploadOrDownloadRequest( auth=auth, server_usn=server_usn, upload=upload ) ) def sync_login( self, username: str, password: str, endpoint: str | None ) -> SyncAuth: return self._backend.sync_login( SyncLoginRequest(username=username, password=password, endpoint=endpoint) ) def sync_collection(self, auth: SyncAuth, sync_media: bool) -> SyncOutput: return self._backend.sync_collection(auth=auth, sync_media=sync_media) def sync_media(self, auth: SyncAuth) -> None: self._backend.sync_media(auth) def sync_status(self, auth: SyncAuth) -> SyncStatus: return self._backend.sync_status(auth) def media_sync_status(self) -> MediaSyncStatus: "This will throw if the sync failed with an error." return self._backend.media_sync_status() def ankihub_login(self, id: str, password: str) -> str: return self._backend.ankihub_login(id=id, password=password) def ankihub_logout(self, token: str) -> None: self._backend.ankihub_logout(token=token) def get_preferences(self) -> Preferences: return self._backend.get_preferences() def set_preferences(self, prefs: Preferences) -> OpChanges: return self._backend.set_preferences(prefs) def render_markdown(self, text: str, sanitize: bool = True) -> str: "Not intended for public consumption at this time." return self._backend.render_markdown(markdown=text, sanitize=sanitize) def compare_answer( self, expected: str, provided: str, combining: bool = True ) -> str: return self._backend.compare_answer( expected=expected, provided=provided, combining=combining ) def extract_cloze_for_typing(self, text: str, ordinal: int) -> str: return self._backend.extract_cloze_for_typing(text=text, ordinal=ordinal) def compute_memory_state(self, card_id: CardId) -> ComputedMemoryState: resp = self._backend.compute_memory_state(card_id) if resp.HasField("state"): return ComputedMemoryState( desired_retention=resp.desired_retention, stability=resp.state.stability, difficulty=resp.state.difficulty, decay=resp.decay, ) else: return ComputedMemoryState( desired_retention=resp.desired_retention, decay=resp.decay, ) def fuzz_delta(self, card_id: CardId, interval: int) -> int: "The delta days of fuzz applied if reviewing the card in v3." return self._backend.fuzz_delta(card_id=card_id, interval=interval) # Timeboxing ########################################################################## # fixme: there doesn't seem to be a good reason why this code is in main.py # instead of covered in reviewer, and the reps tracking is covered by both # the scheduler and reviewer.py. in the future, we should probably move # reps tracking to reviewer.py, and remove the startTimebox() calls from # other locations like overview.py. We just need to make sure not to reset # the count on things like edits, which we probably could do by checking # the previous state in moveToState. def startTimebox(self) -> None: self._startTime = time.time() self._startReps = self.sched.reps def timeboxReached(self) -> Literal[False] | tuple[Any, int]: "Return (elapsedTime, reps) if timebox reached, or False." if not self.conf["timeLim"]: # timeboxing disabled return False elapsed = time.time() - self._startTime if elapsed > self.conf["timeLim"]: return (self.conf["timeLim"], self.sched.reps - self._startReps) return False # Legacy ########################################################################## @deprecated(info="no longer used") def log(self, *args: Any, **kwargs: Any) -> None: print(args, kwargs) @deprecated(replaced_by=undo_status) def undo_name(self) -> str | None: "Undo menu item name, or None if undo unavailable." status = self.undo_status() return status.undo or None # @deprecated(replaced_by=new_note) def newNote(self, forDeck: bool = True) -> Note: "Return a new note with the current model." return Note(self, self.models.current(forDeck)) # @deprecated(replaced_by=add_note) def addNote(self, note: Note) -> int: self.add_note(note, note.note_type()["did"]) return len(note.cards()) @deprecated(replaced_by=remove_notes) def remNotes(self, ids: Sequence[NoteId]) -> None: self.remove_notes(ids) @deprecated(replaced_by=remove_notes) def _remNotes(self, ids: list[NoteId]) -> None: pass @deprecated(replaced_by=card_stats_data) def card_stats(self, card_id: CardId, include_revlog: bool) -> str: from anki.stats import _legacy_card_stats return _legacy_card_stats(self, card_id, include_revlog) @deprecated(replaced_by=card_stats_data) def cardStats(self, card: Card) -> str: from anki.stats import _legacy_card_stats return _legacy_card_stats(self, card.id, False) @deprecated(replaced_by=after_note_updates) def updateFieldCache(self, nids: list[NoteId]) -> None: self.after_note_updates(nids, mark_modified=False, generate_cards=False) @deprecated(replaced_by=after_note_updates) def genCards(self, nids: list[NoteId]) -> list[int]: self.after_note_updates(nids, mark_modified=False, generate_cards=True) # previously returned empty cards, no longer does return [] @deprecated(info="no longer used") def emptyCids(self) -> list[CardId]: return [] @deprecated(info="handled by backend") def _logRem(self, ids: list[int | NoteId], type: int) -> None: self.db.executemany( "insert into graves values (%d, ?, %d)" % (self.usn(), type), ([x] for x in ids), ) @deprecated(info="no longer required") def setMod(self) -> None: pass @deprecated(info="no longer required") def flush(self) -> None: pass Collection.register_deprecated_aliases( findReplace=Collection.find_and_replace, remCards=Collection.remove_cards_and_orphaned_notes, ) # legacy name _Collection = Collection def pb_export_limit(limit: ExportLimit) -> import_export_pb2.ExportLimit: message = import_export_pb2.ExportLimit() if isinstance(limit, DeckIdLimit): message.deck_id = limit.deck_id elif isinstance(limit, NoteIdsLimit): message.note_ids.note_ids.extend(limit.note_ids) elif isinstance(limit, CardIdsLimit): message.card_ids.cids.extend(limit.card_ids) else: message.whole_collection.SetInParent() return message ================================================ FILE: pylib/anki/config.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """ Config handling - To set a config value, use col.set_config(key, val). - To get a config value, use col.get_config(key, default=None). In the case of lists and dictionaries, any changes you make to the returned value will not be saved unless you call set_config(). - To remove a config value, use col.remove_config(key). For legacy reasons, the config is also exposed as a dict interface as col.conf. To support old code that was mutating inner values, using col.conf["key"] needs to wrap lists and dicts when returning them. As this is less efficient, please use the col.*_config() API in new code. The legacy set also does not support the new undo handling. """ from __future__ import annotations import copy import weakref from typing import Any from weakref import ref import anki import anki.collection from anki import config_pb2 from anki.collection import OpChanges from anki.errors import NotFoundError from anki.utils import from_json_bytes, to_json_bytes Config = config_pb2.ConfigKey class ConfigManager: def __init__(self, col: anki.collection.Collection): self.col = col.weakref() def get_immutable(self, key: str) -> Any: try: return from_json_bytes(self.col._backend.get_config_json(key)) except NotFoundError as exc: raise KeyError from exc def set(self, key: str, val: Any) -> None: self.col._backend.set_config_json_no_undo( key=key, value_json=to_json_bytes(val), # this argument is ignored undoable=True, ) def remove(self, key: str) -> OpChanges: return self.col._backend.remove_config(key) # Legacy dict interface ######################### def __getitem__(self, key: str) -> Any: val = self.get_immutable(key) if isinstance(val, list): print( f"conf key {key} should be fetched with col.get_config(), and saved with col.set_config()" ) return WrappedList(weakref.ref(self), key, val) elif isinstance(val, dict): print( f"conf key {key} should be fetched with col.get_config(), and saved with col.set_config()" ) return WrappedDict(weakref.ref(self), key, val) else: return val def __setitem__(self, key: str, value: Any) -> None: self.set(key, value) def get(self, key: str, default: Any | None = None) -> Any: try: return self[key] except KeyError: return default def setdefault(self, key: str, default: Any) -> Any: if key not in self: self[key] = default return self[key] def __contains__(self, key: str) -> bool: try: self.get_immutable(key) return True except KeyError: return False def __delitem__(self, key: str) -> None: self.remove(key) # Tracking changes to mutable objects ######################################### # Because we previously allowed mutation of the conf # structure directly, to allow col.conf["foo"]["bar"] = xx # to continue to function, we apply changes as the object # is dropped. class WrappedList(list): def __init__(self, conf: ref[ConfigManager], key: str, val: Any) -> None: self.key = key self.conf = conf self.orig = copy.deepcopy(val) super().__init__(val) def __del__(self) -> None: cur = list(self) conf = self.conf() if conf and self.orig != cur: conf[self.key] = cur class WrappedDict(dict): def __init__(self, conf: ref[ConfigManager], key: str, val: Any) -> None: self.key = key self.conf = conf self.orig = copy.deepcopy(val) super().__init__(val) def __del__(self) -> None: cur = dict(self) conf = self.conf() if conf and self.orig != cur: conf[self.key] = cur ================================================ FILE: pylib/anki/consts.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import sys from typing import TYPE_CHECKING, Any, NewType from anki._legacy import DeprecatedNamesMixinForModule # whether new cards should be mixed with reviews, or shown first or last NEW_CARDS_DISTRIBUTE = 0 NEW_CARDS_LAST = 1 NEW_CARDS_FIRST = 2 # new card insertion order NEW_CARDS_RANDOM = 0 NEW_CARDS_DUE = 1 # Queue types CardQueue = NewType("CardQueue", int) QUEUE_TYPE_MANUALLY_BURIED = CardQueue(-3) QUEUE_TYPE_SIBLING_BURIED = CardQueue(-2) QUEUE_TYPE_SUSPENDED = CardQueue(-1) QUEUE_TYPE_NEW = CardQueue(0) QUEUE_TYPE_LRN = CardQueue(1) QUEUE_TYPE_REV = CardQueue(2) QUEUE_TYPE_DAY_LEARN_RELEARN = CardQueue(3) QUEUE_TYPE_PREVIEW = CardQueue(4) # Card types CardType = NewType("CardType", int) CARD_TYPE_NEW = CardType(0) CARD_TYPE_LRN = CardType(1) CARD_TYPE_REV = CardType(2) CARD_TYPE_RELEARNING = CardType(3) # removal types REM_CARD = 0 REM_NOTE = 1 REM_DECK = 2 # count display COUNT_ANSWERED = 0 COUNT_REMAINING = 1 # media log MEDIA_ADD = 0 MEDIA_REM = 1 # Kind of decks DECK_STD = 0 DECK_DYN = 1 # dynamic deck order DYN_OLDEST = 0 DYN_RANDOM = 1 DYN_SMALLINT = 2 DYN_BIGINT = 3 DYN_LAPSES = 4 DYN_ADDED = 5 DYN_DUE = 6 DYN_REVADDED = 7 DYN_DUEPRIORITY = 8 DYN_MAX_SIZE = 99999 # model types MODEL_STD = 0 MODEL_CLOZE = 1 STARTING_FACTOR = 2500 STARTING_FACTOR_FRACTION = STARTING_FACTOR / 1000 HELP_SITE = "https://docs.ankiweb.net/" # Leech actions LEECH_SUSPEND = 0 LEECH_TAGONLY = 1 # Buttons BUTTON_ONE = 1 BUTTON_TWO = 2 BUTTON_THREE = 3 BUTTON_FOUR = 4 # Revlog types REVLOG_LRN = 0 REVLOG_REV = 1 REVLOG_RELRN = 2 REVLOG_CRAM = 3 REVLOG_RESCHED = 4 # Labels ########################################################################## import anki.collection def _tr(col: anki.collection.Collection | None) -> Any: if col: return col.tr else: print("routine in consts.py should be passed col") import traceback traceback.print_stack(file=sys.stdout) from anki.lang import tr_legacyglobal return tr_legacyglobal def new_card_order_labels(col: anki.collection.Collection | None) -> dict[int, Any]: tr = _tr(col) return { 0: tr.scheduling_show_new_cards_in_random_order(), 1: tr.scheduling_show_new_cards_in_order_added(), } _deprecated_names = DeprecatedNamesMixinForModule(globals()) if not TYPE_CHECKING: def __getattr__(name: str) -> Any: return _deprecated_names.__getattr__(name) ================================================ FILE: pylib/anki/db.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """ A convenience wrapper over pysqlite. Anki's Collection class now uses dbproxy.py instead of this class, but this class is still used by aqt's profile manager, and a number of add-ons rely on it. """ from __future__ import annotations import os import pprint import time from sqlite3 import Cursor from sqlite3 import dbapi2 as sqlite from typing import Any from anki._legacy import DeprecatedNamesMixin DBError = sqlite.Error class DB(DeprecatedNamesMixin): def __init__(self, path: str, timeout: int = 0) -> None: self._db = sqlite.connect(path, timeout=timeout) self._db.text_factory = self._text_factory self._path = path self.echo = os.environ.get("DBECHO") self.mod = False def __repr__(self) -> str: dict_ = dict(self.__dict__) del dict_["_db"] return f"{super().__repr__()} {pprint.pformat(dict_, width=300)}" def execute(self, sql: str, *a: Any, **ka: Any) -> Cursor: canonized = sql.strip().lower() # mark modified? for stmt in "insert", "update", "delete": if canonized.startswith(stmt): self.mod = True start_time = time.time() if ka: # execute("...where id = :id", id=5) res = self._db.execute(sql, ka) else: # execute("...where id = ?", 5) res = self._db.execute(sql, a) if self.echo: # print a, ka print(sql, f"{(time.time() - start_time) * 1000:0.3f}ms") if self.echo == "2": print(a, ka) return res def executemany(self, sql: str, iterable: Any) -> None: self.mod = True start_time = time.time() self._db.executemany(sql, iterable) if self.echo: print(sql, f"{(time.time() - start_time) * 1000:0.3f}ms") if self.echo == "2": print(iterable) def commit(self) -> None: start_time = time.time() self._db.commit() if self.echo: print(f"commit {(time.time() - start_time) * 1000:0.3f}ms") def executescript(self, sql: str) -> None: self.mod = True if self.echo: print(sql) self._db.executescript(sql) def rollback(self) -> None: self._db.rollback() def scalar(self, *a: Any, **kw: Any) -> Any: res = self.execute(*a, **kw).fetchone() if res: return res[0] return None def all(self, *a: Any, **kw: Any) -> list: return self.execute(*a, **kw).fetchall() def first(self, *a: Any, **kw: Any) -> Any: cursor = self.execute(*a, **kw) res = cursor.fetchone() cursor.close() return res def list(self, *a: Any, **kw: Any) -> list: return [x[0] for x in self.execute(*a, **kw)] def close(self) -> None: self._db.text_factory = None self._db.close() def set_progress_handler(self, *args: Any) -> None: self._db.set_progress_handler(*args) def __enter__(self) -> "DB": self._db.execute("begin") return self def __exit__(self, *args: Any) -> None: self._db.close() def total_changes(self) -> Any: return self._db.total_changes def interrupt(self) -> None: self._db.interrupt() def set_autocommit(self, autocommit: bool) -> None: if autocommit: self._db.isolation_level = None else: self._db.isolation_level = "" # strip out invalid utf-8 when reading from db def _text_factory(self, data: bytes) -> str: return str(data, errors="ignore") def cursor(self, factory: type[Cursor] = Cursor) -> Cursor: return self._db.cursor(factory) ================================================ FILE: pylib/anki/dbproxy.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import re from collections.abc import Callable, Iterable, Sequence from re import Match from typing import TYPE_CHECKING, Any, Union if TYPE_CHECKING: import anki._backend from anki.collection import Collection # DBValue is actually Union[str, int, float, None], but if defined # that way, every call site needs to do a type check prior to using # the return values. ValueFromDB = Any Row = Sequence[ValueFromDB] ValueForDB = Union[str, int, float, None] class DBProxy: # Lifecycle ############### def __init__(self, backend: anki._backend.RustBackend) -> None: self._backend = backend # Transactions ############### def transact(self, op: Callable[[], None]) -> None: """Run the provided operation inside a transaction. Please note that all backend methods automatically wrap changes in a transaction, so there is no need to use this when calling methods like update_cards(), unless you are making other changes at the same time and want to ensure they are applied completely or not at all. If the operation throws an exception, the changes will be automatically rolled back. """ try: self._backend.db_begin() op() self._backend.db_commit() except BaseException as e: self._backend.db_rollback() raise e # Querying ################ def _query( self, sql: str, *args: ValueForDB, first_row_only: bool = False, **kwargs: ValueForDB, ) -> list[Row]: sql, args2 = emulate_named_args(sql, args, kwargs) # fetch rows return self._backend.db_query(sql, args2, first_row_only) # Query shortcuts ################### def all(self, sql: str, *args: ValueForDB, **kwargs: ValueForDB) -> list[Row]: return self._query(sql, *args, first_row_only=False, **kwargs) def list( self, sql: str, *args: ValueForDB, **kwargs: ValueForDB ) -> list[ValueFromDB]: return [x[0] for x in self._query(sql, *args, first_row_only=False, **kwargs)] def first(self, sql: str, *args: ValueForDB, **kwargs: ValueForDB) -> Row | None: rows = self._query(sql, *args, first_row_only=True, **kwargs) if rows: return rows[0] else: return None def scalar(self, sql: str, *args: ValueForDB, **kwargs: ValueForDB) -> ValueFromDB: rows = self._query(sql, *args, first_row_only=True, **kwargs) if rows: return rows[0][0] else: return None # execute used to return a pysqlite cursor, but now is synonymous # with .all() execute = all # Updates ################ def executemany(self, sql: str, args: Iterable[Sequence[ValueForDB]]) -> None: if isinstance(args, list): list_args = args else: list_args = list(args) self._backend.db_execute_many(sql, list_args) # convert kwargs to list format def emulate_named_args( sql: str, args: tuple, kwargs: dict[str, Any] ) -> tuple[str, Sequence[ValueForDB]]: # nothing to do? if not kwargs: return sql, args print("named arguments in queries will go away in the future:", sql) # map args to numbers arg_num = {} args2 = list(args) for key, val in kwargs.items(): args2.append(val) number = len(args2) arg_num[key] = number # update refs def repl(match: Match) -> str: arg = match.group(1) return f"?{arg_num[arg]}" sql = re.sub(":([a-zA-Z_0-9]+)", repl, sql) return sql, args2 ================================================ FILE: pylib/anki/decks.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import copy from collections.abc import Iterable, Sequence from typing import TYPE_CHECKING, Any, NewType if TYPE_CHECKING: import anki import anki.cards import anki.collection from anki import deck_config_pb2, decks_pb2 from anki._legacy import DeprecatedNamesMixin, deprecated, print_deprecation_warning from anki.collection import OpChanges, OpChangesWithCount, OpChangesWithId from anki.consts import * from anki.errors import NotFoundError from anki.utils import from_json_bytes, ids2str, int_time, to_json_bytes # public exports DeckTreeNode = decks_pb2.DeckTreeNode DeckNameId = decks_pb2.DeckNameId FilteredDeckConfig = decks_pb2.Deck.Filtered DeckCollapseScope = decks_pb2.SetDeckCollapsedRequest.Scope DeckConfigsForUpdate = deck_config_pb2.DeckConfigsForUpdate UpdateDeckConfigs = deck_config_pb2.UpdateDeckConfigsRequest Deck = decks_pb2.Deck # type aliases until we can move away from dicts DeckDict = dict[str, Any] DeckConfigDict = dict[str, Any] DeckId = NewType("DeckId", int) DeckConfigId = NewType("DeckConfigId", int) DEFAULT_DECK_ID = DeckId(1) DEFAULT_DECK_CONF_ID = DeckConfigId(1) class DecksDictProxy: def __init__(self, col: anki.collection.Collection): self._col = col.weakref() def _warn(self) -> None: print_deprecation_warning( "add-on should use methods on col.decks, not col.decks.decks dict" ) def __getitem__(self, item: Any) -> Any: self._warn() return self._col.decks.get(DeckId(int(item))) def __setitem__(self, key: Any, val: Any) -> None: self._warn() self._col.decks.save(val) def __len__(self) -> int: self._warn() return len(self._col.decks.all_names_and_ids()) def keys(self) -> Any: self._warn() return [str(nt.id) for nt in self._col.decks.all_names_and_ids()] def values(self) -> Any: self._warn() return self._col.decks.all() def items(self) -> Any: self._warn() return [(str(nt["id"]), nt) for nt in self._col.decks.all()] def __contains__(self, item: Any) -> bool: self._warn() return self._col.decks.have(item) class DeckManager(DeprecatedNamesMixin): # Registry save/load ############################################################# def __init__(self, col: anki.collection.Collection) -> None: self.col = col.weakref() self.decks = DecksDictProxy(col) def save(self, deck_or_config: DeckDict | DeckConfigDict | None = None) -> None: "Can be called with either a deck or a deck configuration." if not deck_or_config: print("col.decks.save() should be passed the changed deck") return # deck conf? if "maxTaken" in deck_or_config: self.update_config(deck_or_config) return else: self.update(deck_or_config, preserve_usn=False) # Deck save/load ############################################################# def add_normal_deck_with_name(self, name: str) -> OpChangesWithId: "If deck exists, return existing id." if id := self.col.decks.id_for_name(name): return OpChangesWithId(id=id) else: deck = self.col.decks.new_deck() deck.name = name return self.add_deck(deck) def add_deck_legacy(self, deck: DeckDict) -> OpChangesWithId: "Add a deck created with new_deck_legacy(). Must have id of 0." if not deck["id"] == 0: raise Exception("id should be 0") return self.col._backend.add_deck_legacy(to_json_bytes(deck)) def id( self, name: str, create: bool = True, type: DeckConfigId = DeckConfigId(0), ) -> DeckId | None: "Add a deck with NAME. Reuse deck if already exists. Return id as int." id = self.id_for_name(name) if id: return id elif not create: return None deck = self.new_deck_legacy(bool(type)) deck["name"] = name out = self.add_deck_legacy(deck) return DeckId(out.id) def remove(self, dids: Sequence[DeckId]) -> OpChangesWithCount: return self.col._backend.remove_decks(dids) def all_names_and_ids( self, skip_empty_default: bool = False, include_filtered: bool = True ) -> Sequence[DeckNameId]: "A sorted sequence of deck names and IDs." return self.col._backend.get_deck_names( skip_empty_default=skip_empty_default, include_filtered=include_filtered ) def id_for_name(self, name: str) -> DeckId | None: try: return DeckId(self.col._backend.get_deck_id_by_name(name)) except NotFoundError: return None def get_legacy(self, did: DeckId) -> DeckDict | None: try: return from_json_bytes(self.col._backend.get_deck_legacy(did)) except NotFoundError: return None def have(self, id: DeckId) -> bool: return bool(self.get_legacy(id)) def get_all_legacy(self) -> list[DeckDict]: return list(from_json_bytes(self.col._backend.get_all_decks_legacy()).values()) def new_deck(self) -> Deck: "Return a new normal deck. It must be added with .add_deck() after a name assigned." return self.col._backend.new_deck() def add_deck(self, deck: Deck) -> OpChangesWithId: return self.col._backend.add_deck(message=deck) def new_deck_legacy(self, filtered: bool) -> DeckDict: deck = from_json_bytes(self.col._backend.new_deck_legacy(filtered)) if deck["dyn"]: # Filtered decks are now created via a scheduler method, but old unit # tests still use this method. Set the default values to what the tests # expect: one empty search term, and ordering by oldest first. del deck["terms"][1] deck["terms"][0][0] = "" deck["terms"][0][2] = 0 return deck def deck_tree(self) -> DeckTreeNode: return self.col._backend.deck_tree(now=0) @classmethod def find_deck_in_tree( cls, node: DeckTreeNode, deck_id: DeckId ) -> DeckTreeNode | None: if node.deck_id == deck_id: return node for child in node.children: match = cls.find_deck_in_tree(child, deck_id) if match: return match return None def all(self) -> list[DeckDict]: "All decks. Expensive; prefer all_names_and_ids()" return self.get_all_legacy() def set_collapsed( self, deck_id: DeckId, collapsed: bool, scope: DeckCollapseScope.V ) -> OpChanges: return self.col._backend.set_deck_collapsed( deck_id=deck_id, collapsed=collapsed, scope=scope ) def collapse(self, did: DeckId) -> None: deck = self.get(did) deck["collapsed"] = not deck["collapsed"] self.save(deck) def collapse_browser(self, did: DeckId) -> None: deck = self.get(did) collapsed = deck.get("browserCollapsed", False) deck["browserCollapsed"] = not collapsed self.save(deck) def count(self) -> int: return len(self.all_names_and_ids()) def card_count( self, dids: DeckId | Iterable[DeckId], include_subdecks: bool ) -> Any: if isinstance(dids, int): dids = {dids} else: dids = set(dids) if include_subdecks: dids.update([child[1] for did in dids for child in self.children(did)]) str_ids = ids2str(dids) count = self.col.db.scalar( f"select count() from cards where did in {str_ids} or odid in {str_ids}" ) return count def get(self, did: DeckId | str, default: bool = True) -> DeckDict | None: if not did: if default: return self.get_legacy(DEFAULT_DECK_ID) else: return None id = DeckId(int(did)) deck = self.get_legacy(id) if deck: return deck elif default: return self.get_legacy(DEFAULT_DECK_ID) else: return None def by_name(self, name: str) -> DeckDict | None: """Get deck with NAME, ignoring case.""" id = self.id_for_name(name) if id: return self.get_legacy(id) return None def update(self, deck: DeckDict, preserve_usn: bool = True) -> None: "Add or update an existing deck. Used for syncing and merging." deck["id"] = self.col._backend.add_or_update_deck_legacy( deck=to_json_bytes(deck), preserve_usn_and_mtime=preserve_usn ) def update_dict(self, deck: DeckDict) -> OpChanges: return self.col._backend.update_deck_legacy(json=to_json_bytes(deck)) def rename(self, deck: DeckDict | DeckId, new_name: str) -> OpChanges: "Rename deck prefix to NAME if not exists. Updates children." if isinstance(deck, int): deck_id = deck else: deck_id = deck["id"] return self.col._backend.rename_deck(deck_id=deck_id, new_name=new_name) # Drag/drop ############################################################# def reparent( self, deck_ids: Sequence[DeckId], new_parent: DeckId ) -> OpChangesWithCount: """Rename one or more source decks that were dropped on `new_parent`. If new_parent is 0, decks will be placed at the top level.""" return self.col._backend.reparent_decks( deck_ids=deck_ids, new_parent=new_parent ) # Deck configurations ############################################################# def get_deck_configs_for_update(self, deck_id: DeckId) -> DeckConfigsForUpdate: return self.col._backend.get_deck_configs_for_update(deck_id) def update_deck_configs(self, input: UpdateDeckConfigs) -> OpChanges: op_bytes = self.col._backend.update_deck_configs_raw(input.SerializeToString()) return OpChanges.FromString(op_bytes) def all_config(self) -> list[DeckConfigDict]: "A list of all deck config." return list(from_json_bytes(self.col._backend.all_deck_config_legacy())) def config_dict_for_deck_id(self, did: DeckId) -> DeckConfigDict: deck = self.get(did, default=False) assert deck if "conf" in deck: dcid = DeckConfigId(int(deck["conf"])) # may be a string conf = self.get_config(dcid) if not conf: # fall back on default conf = self.get_config(DEFAULT_DECK_CONF_ID) conf["dyn"] = False return conf # dynamic decks have embedded conf return deck def get_config(self, conf_id: DeckConfigId) -> DeckConfigDict | None: try: return from_json_bytes(self.col._backend.get_deck_config_legacy(conf_id)) except NotFoundError: return None def update_config(self, conf: DeckConfigDict, preserve_usn: bool = False) -> None: "preserve_usn is ignored" conf["id"] = self.col._backend.add_or_update_deck_config_legacy( json=to_json_bytes(conf) ) def add_config( self, name: str, clone_from: DeckConfigDict | None = None ) -> DeckConfigDict: if clone_from is not None: conf = copy.deepcopy(clone_from) conf["id"] = 0 else: conf = from_json_bytes(self.col._backend.new_deck_config_legacy()) conf["name"] = name self.update_config(conf) return conf def add_config_returning_id( self, name: str, clone_from: DeckConfigDict | None = None ) -> DeckConfigId: return self.add_config(name, clone_from)["id"] def remove_config(self, id: DeckConfigId) -> None: "Remove a configuration and update all decks using it." self.col.mod_schema(check=True) for deck in self.all(): # ignore cram decks if "conf" not in deck: continue if str(deck["conf"]) == str(id): deck["conf"] = 1 self.save(deck) self.col._backend.remove_deck_config(id) def set_config_id_for_deck_dict(self, deck: DeckDict, id: DeckConfigId) -> None: deck["conf"] = id self.save(deck) def decks_using_config(self, conf: DeckConfigDict) -> list[DeckId]: dids = [] for deck in self.all(): if "conf" in deck and deck["conf"] == conf["id"]: dids.append(deck["id"]) return dids def restore_to_default(self, conf: DeckConfigDict) -> None: old_order = conf["new"]["order"] new = from_json_bytes(self.col._backend.new_deck_config_legacy()) new["id"] = conf["id"] new["name"] = conf["name"] self.update_config(new) # if it was previously randomized, re-sort if not old_order: self.col.sched.resort_conf(new) # Deck utils ############################################################# def name(self, did: DeckId, default: bool = False) -> str: deck = self.get(did, default=default) if deck: return deck["name"] return self.col.tr.decks_no_deck() def name_if_exists(self, did: DeckId) -> str | None: deck = self.get(did, default=False) if deck: return deck["name"] return None def cids(self, did: DeckId, children: bool = False) -> list[anki.cards.CardId]: if not children: return self.col.db.list("select id from cards where did=?", did) dids = [did] for name, id in self.children(did): dids.append(id) return self.col.db.list(f"select id from cards where did in {ids2str(dids)}") def for_card_ids(self, cids: list[anki.cards.CardId]) -> list[DeckId]: return self.col.db.list(f"select did from cards where id in {ids2str(cids)}") # Deck selection ############################################################# def set_current(self, deck: DeckId) -> OpChanges: return self.col._backend.set_current_deck(deck) def get_current_id(self) -> DeckId: "The currently selected deck ID." return DeckId(self.col._backend.get_current_deck().id) def current(self) -> DeckDict: return self.get(self.selected()) def active(self) -> list[DeckId]: # some add-ons assume this will always be non-empty return self.col.sched.active_decks or [DeckId(1)] def select(self, did: DeckId) -> None: # make sure arg is an int; legacy callers may be passing in a string did = DeckId(did) self.set_current(did) selected = get_current_id # Parents/children ############################################################# @staticmethod def path(name: str) -> list[str]: return name.split("::") @classmethod def basename(cls, name: str) -> str: return cls.path(name)[-1] @classmethod def immediate_parent_path(cls, name: str) -> list[str]: return cls.path(name)[:-1] @classmethod def immediate_parent(cls, name: str) -> str | None: parent_path = cls.immediate_parent_path(name) if parent_path: return "::".join(parent_path) return None @classmethod def key(cls, deck: DeckDict) -> list[str]: return cls.path(deck["name"]) def deck_and_child_name_ids(self, deck_id: DeckId) -> Iterable[tuple[str, DeckId]]: """The deck of did and all its children, as (name, id).""" return ( (entry.name, DeckId(entry.id)) for entry in self.col._backend.get_deck_and_child_names(deck_id) ) def children(self, did: DeckId) -> list[tuple[str, DeckId]]: "All children of did, as (name, id)." return [ name_id for name_id in self.deck_and_child_name_ids(did) if name_id[1] != did ] def child_ids(self, parent_name: str) -> Iterable[DeckId]: if not (parent_id := self.id_for_name(parent_name)): return [] return (name_id[1] for name_id in self.children(parent_id)) def deck_and_child_ids(self, deck_id: DeckId) -> list[DeckId]: return [ DeckId(entry.id) for entry in self.col._backend.get_deck_and_child_names(deck_id) ] def parents( self, did: DeckId, name_map: dict[str, DeckDict] | None = None ) -> list[DeckDict]: "All parents of did." # get parent and grandparent names parents_names: list[str] = [] for part in self.immediate_parent_path(self.get(did)["name"]): if not parents_names: parents_names.append(part) else: parents_names.append(f"{parents_names[-1]}::{part}") parents: list[DeckDict] = [] # convert to objects for parent_name in parents_names: if name_map: deck = name_map[parent_name] else: deck = self.get(self.id(parent_name)) parents.append(deck) return parents def parents_by_name(self, name: str) -> list[DeckDict]: "All existing parents of name" if "::" not in name: return [] names = self.immediate_parent_path(name) head = [] parents: list[DeckDict] = [] while names: head.append(names.pop(0)) deck = self.by_name("::".join(head)) if deck: parents.append(deck) return parents # Filtered decks ########################################################################## def new_filtered(self, name: str) -> DeckId: "For new code, prefer col.sched.get_or_create_filtered_deck()." did = self.id(name, type=DEFAULT_DECK_CONF_ID) self.select(did) return did def is_filtered(self, did: DeckId | str) -> bool: return bool(self.get(did)["dyn"]) # Legacy ############# @deprecated(info="no longer required") def flush(self) -> None: pass @deprecated(replaced_by=remove) def rem( self, did: DeckId, **legacy_args: bool, ) -> None: "Remove the deck. If cardsToo, delete any cards inside." if isinstance(did, str): did = int(did) self.remove([did]) @deprecated(replaced_by=all_names_and_ids) def name_map(self) -> dict[str, DeckDict]: return {d["name"]: d for d in self.all()} @deprecated(info="use col.set_deck() instead") def set_deck(self, cids: list[anki.cards.CardId], did: DeckId) -> None: self.col.set_deck(card_ids=cids, deck_id=did) self.col.db.execute( f"update cards set did=?,usn=?,mod=? where id in {ids2str(cids)}", did, self.col.usn(), int_time(), ) @deprecated(replaced_by=all_names_and_ids) def all_ids(self) -> list[str]: return [str(x.id) for x in self.all_names_and_ids()] @deprecated(replaced_by=all_names_and_ids) def all_names(self, dyn: bool = True, force_default: bool = True) -> list[str]: return [ x.name for x in self.all_names_and_ids( skip_empty_default=not force_default, include_filtered=dyn ) ] DeckManager.register_deprecated_aliases( confForDid=DeckManager.config_dict_for_deck_id, setConf=DeckManager.set_config_id_for_deck_dict, didsForConf=DeckManager.decks_using_config, allConf=DeckManager.all_config, getConf=DeckManager.get_config, updateConf=DeckManager.update_config, remConf=DeckManager.remove_config, confId=DeckManager.add_config_returning_id, newDyn=DeckManager.new_filtered, isDyn=DeckManager.is_filtered, nameOrNone=DeckManager.name_if_exists, ) if not TYPE_CHECKING: def __getattr__(name): if name == "defaultDeck": print_deprecation_warning( "defaultDeck is deprecated; call decks.id() without it" ) return 0 elif name == "defaultDynamicDeck": print_deprecation_warning( "defaultDynamicDeck is replaced with new_filtered()" ) return 1 else: raise AttributeError(f"module {__name__} has no attribute {name}") ================================================ FILE: pylib/anki/errors.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from enum import Enum from typing import TYPE_CHECKING if TYPE_CHECKING: import anki.collection class AnkiException(Exception): """ General Anki exception that all custom exceptions raised by Anki should inherit from. Allows add-ons to easily identify Anki-native exceptions. When inheriting from a Python built-in exception other than `Exception`, please supply `AnkiException` as an additional inheritance: ``` class MyNewAnkiException(ValueError, AnkiException): pass ``` """ class BackendError(AnkiException): "An error originating from Anki's backend." def __init__( self, message: str, help_page: anki.collection.HelpPage.V | None, context: str | None, backtrace: str | None, ) -> None: super().__init__() self._message = message self.help_page = help_page self.context = context self.backtrace = backtrace def __str__(self) -> str: return self._message class Interrupted(BackendError): pass class NetworkError(BackendError): pass class SyncErrorKind(Enum): AUTH = 1 OTHER = 2 class SyncError(BackendError): def __init__( self, message: str, help_page: anki.collection.HelpPage.V | None, context: str | None, backtrace: str | None, kind: SyncErrorKind, ): self.kind = kind super().__init__(message, help_page, context, backtrace) class BackendIOError(BackendError): pass class CustomStudyError(BackendError): pass class DBError(BackendError): pass class CardTypeError(BackendError): pass class TemplateError(BackendError): pass class NotFoundError(BackendError): pass class DeletedError(BackendError): pass class ExistsError(BackendError): pass class UndoEmpty(BackendError): pass class FilteredDeckError(BackendError): pass class InvalidInput(BackendError): pass class SearchError(BackendError): pass class SchedulerUpgradeRequired(BackendError): pass class AbortSchemaModification(AnkiException): pass # legacy DeckRenameError = FilteredDeckError AnkiError = AbortSchemaModification ================================================ FILE: pylib/anki/exporting.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import json import os import re import shutil import threading import time import unicodedata import zipfile from collections.abc import Sequence from io import BufferedWriter from typing import Any from zipfile import ZipFile from anki import hooks from anki.cards import CardId from anki.collection import Collection from anki.decks import DeckId from anki.utils import ids2str, namedtmp, split_fields, strip_html class Exporter: includeHTML: bool | None = None ext: str | None = None includeTags: bool | None = None includeSched: bool | None = None includeMedia: bool | None = None def __init__( self, col: Collection, did: DeckId | None = None, cids: list[CardId] | None = None, ) -> None: self.col = col.weakref() self.did = did self.cids = cids @staticmethod def key(col: Collection) -> str: return "" def doExport(self, path) -> None: raise Exception("not implemented") def exportInto(self, path: str) -> None: self._escapeCount = 0 file = open(path, "wb") self.doExport(file) file.close() def processText(self, text: str) -> str: if self.includeHTML is False: text = self.stripHTML(text) text = self.escapeText(text) return text def escapeText(self, text: str) -> str: "Escape newlines, tabs, CSS and quotechar." # fixme: we should probably quote fields with newlines # instead of converting them to spaces text = text.replace("\n", " ") text = text.replace("\r", "") text = text.replace("\t", " " * 8) text = re.sub("(?i)", "", text) text = re.sub(r"\[\[type:[^]]+\]\]", "", text) if '"' in text or "'" in text: text = '"' + text.replace('"', '""') + '"' return text def stripHTML(self, text: str) -> str: # very basic conversion to text s = text s = re.sub(r"(?i)<(br ?/?|div|p)>", " ", s) s = re.sub(r"\[sound:[^]]+\]", "", s) s = strip_html(s) s = re.sub(r"[ \n\t]+", " ", s) s = s.strip() return s def cardIds(self) -> Any: if self.cids is not None: cids = self.cids elif not self.did: cids = self.col.db.list("select id from cards") else: cids = self.col.decks.cids(self.did, children=True) self.count = len(cids) return cids # Cards as TSV ###################################################################### class TextCardExporter(Exporter): ext = ".txt" includeHTML = True def __init__(self, col) -> None: Exporter.__init__(self, col) @staticmethod def key(col: Collection) -> str: return col.tr.exporting_cards_in_plain_text() def doExport(self, file) -> None: ids = sorted(self.cardIds()) strids = ids2str(ids) def esc(s): # strip off the repeated question in answer if exists s = re.sub("(?si)^.*
\n*", "", s) return self.processText(s) out = "" for cid in ids: c = self.col.get_card(cid) out += esc(c.question()) out += "\t" + esc(c.answer()) + "\n" file.write(out.encode("utf-8")) # Notes as TSV ###################################################################### class TextNoteExporter(Exporter): ext = ".txt" includeTags = True includeHTML = True def __init__(self, col: Collection) -> None: Exporter.__init__(self, col) self.includeID = False @staticmethod def key(col: Collection) -> str: return col.tr.exporting_notes_in_plain_text() def doExport(self, file: BufferedWriter) -> None: cardIds = self.cardIds() data = [] for id, flds, tags in self.col.db.execute( """ select guid, flds, tags from notes where id in (select nid from cards where cards.id in %s)""" % ids2str(cardIds) ): row = [] # note id if self.includeID: row.append(str(id)) # fields row.extend([self.processText(f) for f in split_fields(flds)]) # tags if self.includeTags: row.append(tags.strip()) data.append("\t".join(row)) self.count = len(data) out = "\n".join(data) file.write(out.encode("utf-8")) # Anki decks ###################################################################### # media files are stored in self.mediaFiles, but not exported. class AnkiExporter(Exporter): ext = ".anki2" includeSched: bool | None = False includeMedia = True def __init__(self, col: Collection) -> None: Exporter.__init__(self, col) @staticmethod def key(col: Collection) -> str: return col.tr.exporting_anki_20_deck() def deckIds(self) -> list[DeckId]: if self.cids: return self.col.decks.for_card_ids(self.cids) elif self.did: return self.src.decks.deck_and_child_ids(self.did) else: return [] def exportInto(self, path: str) -> None: # create a new collection at the target try: os.unlink(path) except OSError: pass self.dst = Collection(path) self.src = self.col # find cards cids = self.cardIds() # copy cards, noting used nids nids = {} data: list[Sequence] = [] for row in self.src.db.execute( "select * from cards where id in " + ids2str(cids) ): # clear flags row = list(row) row[-2] = 0 nids[row[1]] = True data.append(row) self.dst.db.executemany( "insert into cards values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", data ) # notes strnids = ids2str(list(nids.keys())) notedata = [] for row in self.src.db.all("select * from notes where id in " + strnids): # remove system tags if not exporting scheduling info if not self.includeSched: row = list(row) row[5] = self.removeSystemTags(row[5]) notedata.append(row) self.dst.db.executemany( "insert into notes values (?,?,?,?,?,?,?,?,?,?,?)", notedata ) # models used by the notes mids = self.dst.db.list("select distinct mid from notes where id in " + strnids) # card history and revlog if self.includeSched: data = self.src.db.all("select * from revlog where cid in " + ids2str(cids)) self.dst.db.executemany( "insert into revlog values (?,?,?,?,?,?,?,?,?)", data ) else: # need to reset card state self.dst.sched.reset_cards(cids) # models - start with zero self.dst.mod_schema(check=False) self.dst.models.remove_all_notetypes() for m in self.src.models.all(): if int(m["id"]) in mids: self.dst.models.update(m) # decks dids = self.deckIds() dconfs = {} for d in self.src.decks.all(): if str(d["id"]) == "1": continue if dids and d["id"] not in dids: continue if not d["dyn"] and d["conf"] != 1: if self.includeSched: dconfs[d["conf"]] = True if not self.includeSched: # scheduling not included, so reset deck settings to default d = dict(d) d["conf"] = 1 d["reviewLimit"] = d["newLimit"] = None d["reviewLimitToday"] = d["newLimitToday"] = None self.dst.decks.update(d) # copy used deck confs for dc in self.src.decks.all_config(): if dc["id"] in dconfs: self.dst.decks.update_config(dc) # find used media media = {} self.mediaDir = self.src.media.dir() if self.includeMedia: for row in notedata: flds = row[6] mid = row[2] for file in self.src.media.files_in_str(mid, flds): # skip files in subdirs if file != os.path.basename(file): continue media[file] = True if self.mediaDir: for fname in os.listdir(self.mediaDir): path = os.path.join(self.mediaDir, fname) if os.path.isdir(path): continue if fname.startswith("_"): # Scan all models in mids for reference to fname for m in self.src.models.all(): if int(m["id"]) in mids: if self._modelHasMedia(m, fname): media[fname] = True break self.mediaFiles = list(media.keys()) self.dst.crt = self.src.crt # todo: tags? self.count = self.dst.card_count() self.postExport() self.dst.close(downgrade=True) def postExport(self) -> None: # overwrite to apply customizations to the deck before it's closed, # such as update the deck description pass def removeSystemTags(self, tags: str) -> str: return self.src.tags.rem_from_str("marked leech", tags) def _modelHasMedia(self, model, fname) -> bool: # First check the styling if fname in model["css"]: return True # If no reference to fname then check the templates as well for t in model["tmpls"]: if fname in t["qfmt"] or fname in t["afmt"]: return True return False # Packaged Anki decks ###################################################################### class AnkiPackageExporter(AnkiExporter): ext = ".apkg" def __init__(self, col: Collection) -> None: AnkiExporter.__init__(self, col) @staticmethod def key(col: Collection) -> str: return col.tr.exporting_anki_deck_package() def exportInto(self, path: str) -> None: # open a zip file z = zipfile.ZipFile( path, "w", zipfile.ZIP_DEFLATED, allowZip64=True, strict_timestamps=False ) media = self.doExport(z, path) # media map z.writestr("media", json.dumps(media)) z.close() def doExport(self, z: ZipFile, path: str) -> dict[str, str]: # type: ignore # export into the anki2 file colfile = path.replace(".apkg", ".anki2") AnkiExporter.exportInto(self, colfile) # prevent older clients from accessing self._addDummyCollection(z) z.write(colfile, "collection.anki21") # and media self.prepareMedia() media = self._exportMedia(z, self.mediaFiles, self.mediaDir) # tidy up intermediate files os.unlink(colfile) p = path.replace(".apkg", ".media.db2") if os.path.exists(p): os.unlink(p) shutil.rmtree(path.replace(".apkg", ".media")) return media def _exportMedia(self, z: ZipFile, files: list[str], fdir: str) -> dict[str, str]: media = {} for c, file in enumerate(files): cStr = str(c) file = hooks.media_file_filter(file) mpath = os.path.join(fdir, file) if os.path.isdir(mpath): continue if os.path.exists(mpath): if re.search(r"\.svg$", file, re.IGNORECASE): z.write(mpath, cStr, zipfile.ZIP_DEFLATED) else: z.write(mpath, cStr, zipfile.ZIP_STORED) media[cStr] = unicodedata.normalize("NFC", file) hooks.media_files_did_export(c) return media def prepareMedia(self) -> None: # chance to move each file in self.mediaFiles into place before media # is zipped up pass # create a dummy collection to ensure older clients don't try to read # data they don't understand def _addDummyCollection(self, zip) -> None: path = namedtmp("dummy.anki2") c = Collection(path) n = c.newNote() n.fields[0] = "This file requires a newer version of Anki." c.addNote(n) c.close(downgrade=True) zip.write(path, "collection.anki2") os.unlink(path) # Collection package ###################################################################### class AnkiCollectionPackageExporter(AnkiPackageExporter): ext = ".colpkg" verbatim = True includeSched = None LEGACY = True def __init__(self, col): AnkiPackageExporter.__init__(self, col) @staticmethod def key(col: Collection) -> str: return col.tr.exporting_anki_collection_package() def exportInto(self, path: str) -> None: """Export collection. Caller must re-open afterwards.""" def exporting_media() -> bool: return any( hook.__name__ == "exported_media" for hook in hooks.legacy_export_progress._hooks ) def progress() -> None: while exporting_media(): progress = self.col._backend.latest_progress() if progress.HasField("exporting"): hooks.legacy_export_progress(progress.exporting) time.sleep(0.1) threading.Thread(target=progress).start() self.col.export_collection_package(path, self.includeMedia, self.LEGACY) class AnkiCollectionPackage21bExporter(AnkiCollectionPackageExporter): LEGACY = False @staticmethod def key(_col: Collection) -> str: return "Anki 2.1.50+ Collection Package" # Export modules ########################################################################## def exporters(col: Collection) -> list[tuple[str, Any]]: def id(obj) -> tuple[str, Exporter]: if callable(obj.key): key_str = obj.key(col) else: key_str = obj.key return (f"{key_str} (*{obj.ext})", obj) exps = [ id(AnkiCollectionPackageExporter), id(AnkiCollectionPackage21bExporter), id(AnkiPackageExporter), id(TextNoteExporter), id(TextCardExporter), ] hooks.exporters_list_created(exps) return exps ================================================ FILE: pylib/anki/find.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from typing import TYPE_CHECKING, Any from anki.notes import NoteId if TYPE_CHECKING: from anki.collection import Collection class Finder: def __init__(self, col: Collection | None) -> None: self.col = col.weakref() print("Finder() is deprecated, please use col.find_cards() or .find_notes()") def findCards(self, query: Any, order: Any) -> Any: return self.col.find_cards(query, order) def findNotes(self, query: Any) -> Any: return self.col.find_notes(query) # Find and replace ########################################################################## def findReplace( col: Collection, nids: list[NoteId], src: str, dst: str, regex: bool = False, field: str | None = None, fold: bool = True, ) -> int: "Find and replace fields in a note. Returns changed note count." print("use col.find_and_replace() instead of findReplace()") return col.find_and_replace( note_ids=nids, search=src, replacement=dst, regex=regex, match_case=not fold, field_name=field, ).count def fieldNamesForNotes(col: Collection, nids: list[NoteId]) -> list[str]: return list(col.field_names_for_note_ids(nids)) # Find duplicates ########################################################################## def fieldNames(col: Collection, downcase: bool = True) -> list[str]: fields: set[str] = set() for m in col.models.all(): for f in m["flds"]: name = f["name"].lower() if downcase else f["name"] if name not in fields: # slower w/o fields.add(name) return list(fields) ================================================ FILE: pylib/anki/foreign_data/__init__.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """Helpers for serializing third-party collections to a common JSON form.""" from __future__ import annotations import json from dataclasses import asdict, dataclass, field from typing import Union from anki.consts import STARTING_FACTOR_FRACTION from anki.decks import DeckId from anki.models import NotetypeId @dataclass class ForeignCardType: name: str qfmt: str afmt: str @staticmethod def front_back() -> ForeignCardType: return ForeignCardType( "Card 1", qfmt="{{Front}}", afmt="{{FrontSide}}\n\n
\n\n{{Back}}", ) @staticmethod def back_front() -> ForeignCardType: return ForeignCardType( "Card 2", qfmt="{{Back}}", afmt="{{FrontSide}}\n\n
\n\n{{Front}}", ) @staticmethod def cloze() -> ForeignCardType: return ForeignCardType( "Cloze", qfmt="{{cloze:Text}}", afmt="{{cloze:Text}}
\n{{Back Extra}}" ) @dataclass class ForeignNotetype: name: str fields: list[str] templates: list[ForeignCardType] is_cloze: bool = False @staticmethod def basic(name: str) -> ForeignNotetype: return ForeignNotetype(name, ["Front", "Back"], [ForeignCardType.front_back()]) @staticmethod def basic_reverse(name: str) -> ForeignNotetype: return ForeignNotetype( name, ["Front", "Back"], [ForeignCardType.front_back(), ForeignCardType.back_front()], ) @staticmethod def cloze(name: str) -> ForeignNotetype: return ForeignNotetype( name, ["Text", "Back Extra"], [ForeignCardType.cloze()], is_cloze=True ) @dataclass class ForeignCard: """Data for creating an Anki card. Usually a review card, as the default card generation routine will take care of missing new cards. due -- UNIX timestamp interval -- days ease_factor -- decimal fraction (2.5 corresponds to default ease) """ # TODO: support new and learning cards? due: int = 0 interval: int = 1 ease_factor: float = STARTING_FACTOR_FRACTION reps: int = 0 lapses: int = 0 @dataclass class ForeignNote: fields: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list) notetype: str | NotetypeId = "" deck: str | DeckId = "" cards: list[ForeignCard] = field(default_factory=list) @dataclass class ForeignData: notes: list[ForeignNote] = field(default_factory=list) notetypes: list[ForeignNotetype] = field(default_factory=list) default_deck: str | DeckId = "" def serialize(self) -> str: return json.dumps(self, cls=ForeignDataEncoder, separators=(",", ":")) class ForeignDataEncoder(json.JSONEncoder): def default(self, obj: object) -> dict: if isinstance( obj, (ForeignData, ForeignNote, ForeignCard, ForeignNotetype, ForeignCardType), ): return asdict(obj) return json.JSONEncoder.default(self, obj) ================================================ FILE: pylib/anki/foreign_data/mnemosyne.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """Serializer for Mnemosyne collections. Some notes about their structure: https://github.com/mnemosyne-proj/mnemosyne/blob/master/mnemosyne/libmnemosyne/docs/source/index.rst Anki | Mnemosyne ----------+----------- Note | Fact Card Type | Fact View Card | Card Notetype | Card Type """ import re from abc import ABC, abstractmethod from dataclasses import dataclass, field from anki.db import DB from anki.decks import DeckId from anki.foreign_data import ( ForeignCard, ForeignCardType, ForeignData, ForeignNote, ForeignNotetype, ) def serialize(db_path: str, deck_id: DeckId) -> str: db = open_mnemosyne_db(db_path) return gather_data(db, deck_id).serialize() def gather_data(db: DB, deck_id: DeckId) -> ForeignData: facts = gather_facts(db) gather_cards_into_facts(db, facts) used_fact_views: dict[type[MnemoFactView], bool] = {} notes = [fact.foreign_note(used_fact_views) for fact in facts.values()] notetypes = [fact_view.foreign_notetype() for fact_view in used_fact_views] return ForeignData(notes, notetypes, deck_id) def open_mnemosyne_db(db_path: str) -> DB: db = DB(db_path) ver = db.scalar("SELECT value FROM global_variables WHERE key='version'") if not ver.startswith("Mnemosyne SQL 1") and ver not in ("2", "3"): print("Mnemosyne version unknown, trying to import anyway") return db class MnemoFactView(ABC): notetype: str field_keys: tuple[str, ...] @classmethod @abstractmethod def foreign_notetype(cls) -> ForeignNotetype: pass class FrontOnly(MnemoFactView): notetype = "Mnemosyne-FrontOnly" field_keys = ("f", "b") @classmethod def foreign_notetype(cls) -> ForeignNotetype: return ForeignNotetype.basic(cls.notetype) class FrontBack(MnemoFactView): notetype = "Mnemosyne-FrontBack" field_keys = ("f", "b") @classmethod def foreign_notetype(cls) -> ForeignNotetype: return ForeignNotetype.basic_reverse(cls.notetype) class Vocabulary(MnemoFactView): notetype = "Mnemosyne-Vocabulary" field_keys = ("f", "p_1", "m_1", "n") @classmethod def foreign_notetype(cls) -> ForeignNotetype: return ForeignNotetype( cls.notetype, ["Expression", "Pronunciation", "Meaning", "Notes"], [cls._recognition_card_type(), cls._production_card_type()], ) @staticmethod def _recognition_card_type() -> ForeignCardType: return ForeignCardType( name="Recognition", qfmt="{{Expression}}", afmt="{{Expression}}\n\n
\n\n{{{{Pronunciation}}}}" "
\n{{{{Meaning}}}}
\n{{{{Notes}}}}", ) @staticmethod def _production_card_type() -> ForeignCardType: return ForeignCardType( name="Production", qfmt="{{Meaning}}", afmt="{{Meaning}}\n\n
\n\n{{{{Expression}}}}" "
\n{{{{Pronunciation}}}}
\n{{{{Notes}}}}", ) class Cloze(MnemoFactView): notetype = "Mnemosyne-Cloze" field_keys = ("text",) @classmethod def foreign_notetype(cls) -> ForeignNotetype: return ForeignNotetype.cloze(cls.notetype) @dataclass class MnemoCard: fact_view_id: str tags: str next_rep: int last_rep: int easiness: float reps: int lapses: int def card_ord(self) -> int: ord = self.fact_view_id.rsplit(".", maxsplit=1)[-1] try: return int(ord) - 1 except ValueError as err: raise Exception( f"Fact view id '{self.fact_view_id}' has unknown format" ) from err def is_new(self) -> bool: return self.last_rep == -1 def foreign_card(self) -> ForeignCard: return ForeignCard( ease_factor=self.easiness, reps=self.reps, lapses=self.lapses, interval=self.anki_interval(), due=int(self.next_rep), ) def anki_interval(self) -> int: return int(max(1, (self.next_rep - self.last_rep) // 86400)) @dataclass class MnemoFact: id: int fields: dict[str, str] = field(default_factory=dict) cards: list[MnemoCard] = field(default_factory=list) def foreign_note( self, used_fact_views: dict[type[MnemoFactView], bool] ) -> ForeignNote: fact_view = self.fact_view() used_fact_views[fact_view] = True return ForeignNote( fields=self.anki_fields(fact_view), tags=self.anki_tags(), notetype=fact_view.notetype, cards=self.foreign_cards(), ) def fact_view(self) -> type[MnemoFactView]: try: fact_view = self.cards[0].fact_view_id except IndexError: return FrontOnly if fact_view.startswith("1.") or fact_view.startswith("1::"): return FrontOnly elif fact_view.startswith("2.") or fact_view.startswith("2::"): return FrontBack elif fact_view.startswith("3.") or fact_view.startswith("3::"): return Vocabulary elif fact_view.startswith("5.1"): return Cloze raise Exception(f"Fact {self.id} has unknown fact view: {fact_view}") def anki_fields(self, fact_view: type[MnemoFactView]) -> list[str]: return [munge_field(self.fields.get(k, "")) for k in fact_view.field_keys] def anki_tags(self) -> list[str]: tags: list[str] = [] for card in self.cards: if not card.tags: continue tags.extend( t.replace(" ", "_").replace("\u3000", "_") for t in card.tags.split(", ") ) return tags def foreign_cards(self) -> list[ForeignCard]: # generate defaults for new cards return [card.foreign_card() for card in self.cards if not card.is_new()] def munge_field(field: str) -> str: # \n -> br field = re.sub("\r?\n", "
", field) # latex differences field = re.sub(r"(?i)<(/?(\$|\$\$|latex))>", "[\\1]", field) # audio differences field = re.sub(')?', "[sound:\\1]", field) return field def gather_facts(db: DB) -> dict[int, MnemoFact]: facts: dict[int, MnemoFact] = {} for id, key, value in db.execute( """ SELECT _id, key, value FROM facts, data_for_fact WHERE facts._id=data_for_fact._fact_id""" ): if not (fact := facts.get(id)): facts[id] = fact = MnemoFact(id) fact.fields[key] = value return facts def gather_cards_into_facts(db: DB, facts: dict[int, MnemoFact]) -> None: for fact_id, *row in db.execute( """ SELECT _fact_id, fact_view_id, tags, next_rep, last_rep, easiness, acq_reps + ret_reps, lapses FROM cards""" ): facts[fact_id].cards.append(MnemoCard(*row)) for fact in facts.values(): fact.cards.sort(key=lambda c: c.card_ord()) ================================================ FILE: pylib/anki/hooks.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """ Tools for extending Anki. A hook takes a function that does not return a value. A filter takes a function that returns its first argument, optionally modifying it. """ from __future__ import annotations from collections.abc import Callable from typing import Any import decorator # You can find the definitions in ../tools/genhooks.py from anki.hooks_gen import * # Legacy hook handling ############################################################################## _hooks: dict[str, list[Callable[..., Any]]] = {} def runHook(hook: str, *args: Any) -> None: "Run all functions on hook." hookFuncs = _hooks.get(hook, None) if hookFuncs: for func in hookFuncs: try: func(*args) except Exception: hookFuncs.remove(func) raise def runFilter(hook: str, arg: Any, *args: Any) -> Any: hookFuncs = _hooks.get(hook, None) if hookFuncs: for func in hookFuncs: try: arg = func(arg, *args) except Exception: hookFuncs.remove(func) raise return arg def addHook(hook: str, func: Callable) -> None: "Add a function to hook. Ignore if already on hook." if not _hooks.get(hook, None): _hooks[hook] = [] if func not in _hooks[hook]: _hooks[hook].append(func) def remHook(hook: Any, func: Any) -> None: "Remove a function if is on hook." hook = _hooks.get(hook, []) if func in hook: hook.remove(func) # Monkey patching ############################################################################## # Please only use this for prototyping or for when hooks are not practical, # as add-ons that use monkey patching are more likely to break when Anki is # updated. # # If you call wrap() with pos='around', the original function will not be called # automatically but can be called with _old(). def wrap(old: Any, new: Any, pos: str = "after") -> Callable: "Override an existing function." def repl(*args: Any, **kwargs: Any) -> Any: if pos == "after": old(*args, **kwargs) return new(*args, **kwargs) elif pos == "before": new(*args, **kwargs) return old(*args, **kwargs) else: return new(_old=old, *args, **kwargs) def decorator_wrapper(f: Any, *args: Any, **kwargs: Any) -> Any: return repl(*args, **kwargs) return decorator.decorator(decorator_wrapper)(old) ================================================ FILE: pylib/anki/httpclient.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """ Wrapper for requests that adds a callback for tracking upload/download progress. """ from __future__ import annotations import io import os from collections.abc import Callable from typing import Any import requests from requests import Response from anki._legacy import DeprecatedNamesMixin HTTP_BUF_SIZE = 64 * 1024 ProgressCallback = Callable[[int, int], None] class HttpClient(DeprecatedNamesMixin): verify = True timeout = 60 # args are (upload_bytes_in_chunk, download_bytes_in_chunk) progress_hook: ProgressCallback | None = None def __init__(self, progress_hook: ProgressCallback | None = None) -> None: self.progress_hook = progress_hook self.session = requests.Session() def __enter__(self) -> HttpClient: return self def __exit__(self, *args: Any) -> None: self.close() def close(self) -> None: if self.session: self.session.close() self.session = None def __del__(self) -> None: self.close() def post(self, url: str, data: bytes, headers: dict[str, str] | None) -> Response: headers["User-Agent"] = self._agent_name() return self.session.post( url, data=data, headers=headers, stream=True, timeout=self.timeout, verify=self.verify, ) # pytype: disable=wrong-arg-types def get(self, url: str, headers: dict[str, str] | None = None) -> Response: if headers is None: headers = {} headers["User-Agent"] = self._agent_name() return self.session.get( url, stream=True, headers=headers, timeout=self.timeout, verify=self.verify ) def stream_content(self, resp: Response) -> bytes: resp.raise_for_status() buf = io.BytesIO() for chunk in resp.iter_content(chunk_size=HTTP_BUF_SIZE): if self.progress_hook: self.progress_hook(0, len(chunk)) buf.write(chunk) return buf.getvalue() def _agent_name(self) -> str: from anki.buildinfo import version return f"Anki {version}" # allow user to accept invalid certs in work/school settings if os.environ.get("ANKI_NOVERIFYSSL"): HttpClient.verify = False import warnings warnings.filterwarnings("ignore") ================================================ FILE: pylib/anki/importing/__init__.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from collections.abc import Callable, Sequence from typing import Any, Type, Union import anki from anki.collection import Collection from anki.importing.anki2 import Anki2Importer from anki.importing.apkg import AnkiPackageImporter from anki.importing.base import Importer from anki.importing.csvfile import TextImporter from anki.importing.mnemo import MnemosyneImporter from anki.lang import TR def importers(col: Collection) -> Sequence[tuple[str, type[Importer]]]: importers = [ (col.tr.importing_text_separated_by_tabs_or_semicolons(), TextImporter), ( col.tr.importing_packaged_anki_deckcollection_apkg_colpkg_zip(), AnkiPackageImporter, ), (col.tr.importing_mnemosyne_20_deck_db(), MnemosyneImporter), ] anki.hooks.importing_importers(importers) return importers ================================================ FILE: pylib/anki/importing/anki2.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import os import unicodedata from typing import Any from anki.cards import CardId from anki.collection import Collection from anki.consts import * from anki.decks import DeckId, DeckManager from anki.importing.base import Importer from anki.models import NotetypeId from anki.notes import NoteId from anki.utils import int_time, join_fields, split_fields, strip_html_media GUID = 1 MID = 2 MOD = 3 class V2ImportIntoV1(Exception): pass class MediaMapInvalid(Exception): pass class Anki2Importer(Importer): needMapper = False deckPrefix: str | None = None allowUpdate = True src: Collection dst: Collection def __init__(self, col: Collection, file: str) -> None: super().__init__(col, file) # set later, defined here for typechecking self._decks: dict[DeckId, DeckId] = {} self.source_needs_upgrade = False def run(self, media: None = None, importing_v2: bool = True) -> None: self._importing_v2 = importing_v2 self._prepareFiles() if media is not None: # Anki1 importer has provided us with a custom media folder self.src.media._dir = media try: self._import() finally: self.src.close(downgrade=False) def _prepareFiles(self) -> None: self.source_needs_upgrade = False self.dst = self.col self.src = Collection(self.file) if not self._importing_v2: # any scheduling included? if self.src.db.scalar("select 1 from cards where queue != 0 limit 1"): self.source_needs_upgrade = True elif self._importing_v2 and self.col.sched_ver() == 1: raise V2ImportIntoV1() def _import(self) -> None: self._decks = {} if self.deckPrefix: id = self.dst.decks.id(self.deckPrefix) self.dst.decks.select(id) self._prepareTS() self._prepareModels() self._importNotes() self._importCards() self._importStaticMedia() self._postImport() self.dst.optimize() # Notes ###################################################################### def _logNoteRow(self, action: str, noteRow: list[str]) -> None: self.log.append( "[{}] {}".format(action, strip_html_media(noteRow[6].replace("\x1f", ", "))) ) def _importNotes(self) -> None: # build guid -> (id,mod,mid) hash & map of existing note ids self._notes: dict[str, tuple[NoteId, int, NotetypeId]] = {} existing = {} for id, guid, mod, mid in self.dst.db.execute( "select id, guid, mod, mid from notes" ): self._notes[guid] = (id, mod, mid) existing[id] = True # we ignore updates to changed schemas. we need to note the ignored # guids, so we avoid importing invalid cards self._ignoredGuids: dict[str, bool] = {} # iterate over source collection add = [] update = [] dirty = [] usn = self.dst.usn() dupesIdentical = [] dupesIgnored = [] total = 0 for note in self.src.db.execute("select * from notes"): total += 1 # turn the db result into a mutable list note = list(note) shouldAdd = self._uniquifyNote(note) if shouldAdd: # ensure id is unique while note[0] in existing: note[0] += 999 existing[note[0]] = True # bump usn note[4] = usn # update media references in case of dupes note[6] = self._mungeMedia(note[MID], note[6]) add.append(note) dirty.append(note[0]) # note we have the added the guid self._notes[note[GUID]] = (note[0], note[3], note[MID]) else: # a duplicate or changed schema - safe to update? if self.allowUpdate: oldNid, oldMod, oldMid = self._notes[note[GUID]] # will update if incoming note more recent if oldMod < note[MOD]: # safe if note types identical if oldMid == note[MID]: # incoming note should use existing id note[0] = oldNid note[4] = usn note[6] = self._mungeMedia(note[MID], note[6]) update.append(note) dirty.append(note[0]) else: dupesIgnored.append(note) self._ignoredGuids[note[GUID]] = True else: dupesIdentical.append(note) self.log.append(self.dst.tr.importing_notes_found_in_file(val=total)) if dupesIgnored: self.log.append( self.dst.tr.importing_notes_skipped_update_due_to_notetype( val=len(dupesIgnored) ) ) if update: self.log.append( self.dst.tr.importing_notes_updated_as_file_had_newer(val=len(update)) ) if add: self.log.append(self.dst.tr.importing_notes_added_from_file(val=len(add))) if dupesIdentical: self.log.append( self.dst.tr.importing_notes_skipped_as_theyre_already_in( val=len(dupesIdentical), ) ) self.log.append("") if dupesIgnored: for row in dupesIgnored: self._logNoteRow(self.dst.tr.importing_skipped(), row) if update: for row in update: self._logNoteRow(self.dst.tr.importing_updated(), row) if add: for row in add: self._logNoteRow(self.dst.tr.importing_added(), row) if dupesIdentical: for row in dupesIdentical: self._logNoteRow(self.dst.tr.importing_identical(), row) # export info for calling code self.dupes = len(dupesIdentical) self.added = len(add) self.updated = len(update) # add to col self.dst.db.executemany( "insert or replace into notes values (?,?,?,?,?,?,?,?,?,?,?)", add ) self.dst.db.executemany( "insert or replace into notes values (?,?,?,?,?,?,?,?,?,?,?)", update ) self.dst.after_note_updates(dirty, mark_modified=False, generate_cards=False) # determine if note is a duplicate, and adjust mid and/or guid as required # returns true if note should be added def _uniquifyNote(self, note: list[Any]) -> bool: origGuid = note[GUID] srcMid = note[MID] dstMid = self._mid(srcMid) # duplicate schemas? if srcMid == dstMid: return origGuid not in self._notes # differing schemas and note doesn't exist? note[MID] = dstMid if origGuid not in self._notes: return True # schema changed; don't import self._ignoredGuids[origGuid] = True return False # Models ###################################################################### # Models in the two decks may share an ID but not a schema, so we need to # compare the field & template signature rather than just rely on ID. If # the schemas don't match, we increment the mid and try again, creating a # new model if necessary. def _prepareModels(self) -> None: "Prepare index of schema hashes." self._modelMap: dict[NotetypeId, NotetypeId] = {} def _mid(self, srcMid: NotetypeId) -> Any: "Return local id for remote MID." # already processed this mid? if srcMid in self._modelMap: return self._modelMap[srcMid] mid = srcMid srcModel = self.src.models.get(srcMid) srcScm = self.src.models.scmhash(srcModel) while True: # missing from target col? if not self.dst.models.have(mid): # copy it over model = srcModel.copy() model["id"] = mid model["usn"] = self.col.usn() self.dst.models.update(model, skip_checks=True) break # there's an existing model; do the schemas match? dstModel = self.dst.models.get(mid) dstScm = self.dst.models.scmhash(dstModel) if srcScm == dstScm: # copy styling changes over if newer if srcModel["mod"] > dstModel["mod"]: model = srcModel.copy() model["id"] = mid model["usn"] = self.col.usn() self.dst.models.update(model, skip_checks=True) break # as they don't match, try next id mid = NotetypeId(mid + 1) # save map and return new mid self._modelMap[srcMid] = mid return mid # Decks ###################################################################### def _did(self, did: DeckId) -> Any: "Given did in src col, return local id." # already converted? if did in self._decks: return self._decks[did] # get the name in src g = self.src.decks.get(did) name = g["name"] # if there's a prefix, replace the top level deck if self.deckPrefix: tmpname = "::".join(DeckManager.path(name)[1:]) name = self.deckPrefix if tmpname: name += f"::{tmpname}" # manually create any parents so we can pull in descriptions head = "" for parent in DeckManager.immediate_parent_path(name): if head: head += "::" head += parent idInSrc = self.src.decks.id(head) self._did(idInSrc) # if target is a filtered deck, we'll need a new deck name deck = self.dst.decks.by_name(name) if deck and deck["dyn"]: name = "%s %d" % (name, int_time()) # create in local newid = self.dst.decks.id(name) # pull conf over if "conf" in g and g["conf"] != 1: conf = self.src.decks.get_config(g["conf"]) self.dst.decks.save(conf) self.dst.decks.update_config(conf) g2 = self.dst.decks.get(newid) g2["conf"] = g["conf"] self.dst.decks.save(g2) # save desc deck = self.dst.decks.get(newid) deck["desc"] = g["desc"] self.dst.decks.save(deck) # add to deck map and return self._decks[did] = newid return newid # Cards ###################################################################### def _importCards(self) -> None: if self.source_needs_upgrade: self.src.upgrade_to_v2_scheduler() # build map of (guid, ord) -> cid and used id cache self._cards: dict[tuple[str, int], CardId] = {} existing = {} for guid, ord, cid in self.dst.db.execute( "select f.guid, c.ord, c.id from cards c, notes f where c.nid = f.id" ): existing[cid] = True self._cards[(guid, ord)] = cid # loop through src cards = [] revlog = [] cnt = 0 usn = self.dst.usn() aheadBy = self.src.sched.today - self.dst.sched.today for card in self.src.db.execute( "select f.guid, f.mid, c.* from cards c, notes f where c.nid = f.id" ): guid = card[0] if guid in self._ignoredGuids: continue # does the card's note exist in dst col? if guid not in self._notes: continue # does the card already exist in the dst col? ord = card[5] if (guid, ord) in self._cards: # fixme: in future, could update if newer mod time continue # doesn't exist. strip off note info, and save src id for later card = list(card[2:]) scid = card[0] # ensure the card id is unique while card[0] in existing: card[0] += 999 existing[card[0]] = True # update cid, nid, etc card[1] = self._notes[guid][0] card[2] = self._did(card[2]) card[4] = int_time() card[5] = usn # review cards have a due date relative to collection if ( card[7] in (QUEUE_TYPE_REV, QUEUE_TYPE_DAY_LEARN_RELEARN) or card[6] == CARD_TYPE_REV ): card[8] -= aheadBy # odue needs updating too if card[14]: card[14] -= aheadBy # if odid true, convert card from filtered to normal if card[15]: # odid card[15] = 0 # odue card[8] = card[14] card[14] = 0 # queue if card[6] == CARD_TYPE_LRN: # type card[7] = QUEUE_TYPE_NEW else: card[7] = card[6] # type if card[6] == CARD_TYPE_LRN: card[6] = CARD_TYPE_NEW cards.append(card) # we need to import revlog, rewriting card ids and bumping usn for rev in self.src.db.execute("select * from revlog where cid = ?", scid): rev = list(rev) rev[1] = card[0] rev[2] = self.dst.usn() revlog.append(rev) cnt += 1 # apply self.dst.db.executemany( """ insert or ignore into cards values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", cards, ) self.dst.db.executemany( """ insert or ignore into revlog values (?,?,?,?,?,?,?,?,?)""", revlog, ) # Media ###################################################################### # note: this func only applies to imports of .anki2. for .apkg files, the # apkg importer does the copying def _importStaticMedia(self) -> None: # Import any '_foo' prefixed media files regardless of whether # they're used on notes or not dir = self.src.media.dir() if not os.path.exists(dir): return for fname in os.listdir(dir): if fname.startswith("_") and not self.dst.media.have(fname): self._writeDstMedia(fname, self._srcMediaData(fname)) def _mediaData(self, fname: str, dir: str | None = None) -> bytes: if not dir: dir = self.src.media.dir() path = os.path.join(dir, fname) try: with open(path, "rb") as f: return f.read() except OSError: return b"" def _srcMediaData(self, fname: str) -> bytes: "Data for FNAME in src collection." return self._mediaData(fname, self.src.media.dir()) def _dstMediaData(self, fname: str) -> bytes: "Data for FNAME in dst collection." return self._mediaData(fname, self.dst.media.dir()) def _writeDstMedia(self, fname: str, data: bytes) -> None: path = os.path.join(self.dst.media.dir(), unicodedata.normalize("NFC", fname)) try: with open(path, "wb") as f: f.write(data) except OSError: # the user likely used subdirectories pass def _mungeMedia(self, mid: NotetypeId, fieldsStr: str) -> str: fields = split_fields(fieldsStr) def repl(match): fname = match.group("fname") srcData = self._srcMediaData(fname) dstData = self._dstMediaData(fname) if not srcData: # file was not in source, ignore return match.group(0) # if model-local file exists from a previous import, use that name, ext = os.path.splitext(fname) lname = f"{name}_{mid}{ext}" if self.dst.media.have(lname): return match.group(0).replace(fname, lname) # if missing or the same, pass unmodified elif not dstData or srcData == dstData: # need to copy? if not dstData: self._writeDstMedia(fname, srcData) return match.group(0) # exists but does not match, so we need to dedupe self._writeDstMedia(lname, srcData) return match.group(0).replace(fname, lname) for idx, field in enumerate(fields): fields[idx] = self.dst.media.transform_names(field, repl) return join_fields(fields) # Post-import cleanup ###################################################################### def _postImport(self) -> None: for did in list(self._decks.values()): self.col.sched.maybe_randomize_deck(did) # make sure new position is correct self.dst.conf["nextPos"] = ( self.dst.db.scalar("select max(due)+1 from cards where type = 0") or 0 ) self.dst.save() ================================================ FILE: pylib/anki/importing/apkg.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import json import os import unicodedata import zipfile from typing import Any from anki.importing.anki2 import Anki2Importer, MediaMapInvalid from anki.utils import tmpfile class AnkiPackageImporter(Anki2Importer): nameToNum: dict[str, str] zip: zipfile.ZipFile | None def run(self) -> None: # type: ignore # extract the deck from the zip file self.zip = z = zipfile.ZipFile(self.file) # v2 scheduler? try: z.getinfo("collection.anki21") suffix = ".anki21" except KeyError: suffix = ".anki2" col = z.read(f"collection{suffix}") colpath = tmpfile(suffix=".anki2") with open(colpath, "wb") as f: f.write(col) self.file = colpath # we need the media dict in advance, and we'll need a map of fname -> # number to use during the import self.nameToNum = {} dir = self.col.media.dir() try: media_dict = json.loads(z.read("media").decode("utf8")) except Exception as exc: raise MediaMapInvalid() from exc for k, v in list(media_dict.items()): path = os.path.abspath(os.path.join(dir, v)) if os.path.commonprefix([path, dir]) != dir: raise Exception("Invalid file") self.nameToNum[unicodedata.normalize("NFC", v)] = k # run anki2 importer Anki2Importer.run(self, importing_v2=suffix == ".anki21") # import static media for file, c in list(self.nameToNum.items()): if not file.startswith("_") and not file.startswith("latex-"): continue path = os.path.join(self.col.media.dir(), file) if not os.path.exists(path): with open(path, "wb") as f: f.write(z.read(c)) def _srcMediaData(self, fname: str) -> Any: if fname in self.nameToNum: return self.zip.read( self.nameToNum[fname] ) # pytype: disable=attribute-error return None ================================================ FILE: pylib/anki/importing/base.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from typing import Any from anki.collection import Collection from anki.utils import max_id # Base importer ########################################################################## class Importer: needMapper = False needDelimiter = False dst: Collection | None def __init__(self, col: Collection, file: str) -> None: self.file = file self.log: list[str] = [] self.col = col.weakref() self.total = 0 self.dst = None def run(self) -> None: pass def open(self) -> None: "Open file and ensure it's in the right format." return def close(self) -> None: "Closes the open file." return # Timestamps ###################################################################### # It's too inefficient to check for existing ids on every object, # and a previous import may have created timestamps in the future, so we # need to make sure our starting point is safe. def _prepareTS(self) -> None: self._ts = max_id(self.dst.db) def ts(self) -> Any: self._ts += 1 return self._ts ================================================ FILE: pylib/anki/importing/csvfile.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import csv import re from typing import Any, TextIO from anki.collection import Collection from anki.importing.noteimp import ForeignNote, NoteImporter class TextImporter(NoteImporter): needDelimiter = True patterns = "\t|,;:" def __init__(self, col: Collection, file: str) -> None: NoteImporter.__init__(self, col, file) self.lines = None self.fileobj: TextIO | None = None self.delimiter: str | None = None self.tagsToAdd: list[str] = [] self.numFields = 0 self.dialect: Any | None self.data: str | list[str] | None def foreignNotes(self) -> list[ForeignNote]: self.open() # process all lines log = [] notes = [] lineNum = 0 ignored = 0 if self.delimiter: reader = csv.reader(self.data, delimiter=self.delimiter, doublequote=True) else: reader = csv.reader(self.data, self.dialect, doublequote=True) try: for row in reader: if len(row) != self.numFields: if row: log.append( self.col.tr.importing_rows_had_num1d_fields_expected_num2d( row=" ".join(row), found=len(row), expected=self.numFields, ) ) ignored += 1 continue note = self.noteFromFields(row) notes.append(note) except csv.Error as e: log.append(self.col.tr.importing_aborted(val=str(e))) self.log = log self.ignored = ignored self.close() return notes def open(self) -> None: "Parse the top line and determine the pattern and number of fields." # load & look for the right pattern self.cacheFile() def cacheFile(self) -> None: "Read file into self.lines if not already there." if not self.fileobj: self.openFile() def openFile(self) -> None: self.dialect = None self.fileobj = open(self.file, encoding="utf-8-sig") self.data = self.fileobj.read() def sub(s): return re.sub(r"^\#.*$", "__comment", s) self.data = [ f"{sub(x)}\n" for x in self.data.split("\n") if sub(x) != "__comment" ] if self.data: if self.data[0].startswith("tags:"): tags = str(self.data[0][5:]).strip() self.tagsToAdd = tags.split(" ") del self.data[0] self.updateDelimiter() if not self.dialect and not self.delimiter: raise Exception("unknownFormat") def updateDelimiter(self) -> None: def err(): raise Exception("unknownFormat") self.dialect = None sniffer = csv.Sniffer() if not self.delimiter: try: self.dialect = sniffer.sniff("\n".join(self.data[:10]), self.patterns) except Exception: try: self.dialect = sniffer.sniff(self.data[0], self.patterns) except Exception: pass if self.dialect: try: reader = csv.reader(self.data, self.dialect, doublequote=True) except Exception: err() else: if not self.delimiter: if "\t" in self.data[0]: self.delimiter = "\t" elif ";" in self.data[0]: self.delimiter = ";" elif "," in self.data[0]: self.delimiter = "," else: self.delimiter = " " reader = csv.reader(self.data, delimiter=self.delimiter, doublequote=True) try: while True: row = next(reader) if row: self.numFields = len(row) break except Exception: err() self.initMapping() def fields(self) -> int: "Number of fields." self.open() return self.numFields def close(self): if self.fileobj: self.fileobj.close() self.fileobj = None def __del__(self): self.close() zuper = super() if hasattr(zuper, "__del__"): zuper.__del__(self) # type: ignore def noteFromFields(self, fields: list[str]) -> ForeignNote: note = ForeignNote() note.fields.extend([x for x in fields]) note.tags.extend(self.tagsToAdd) return note ================================================ FILE: pylib/anki/importing/mnemo.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import re import time from typing import cast from anki.db import DB from anki.importing.noteimp import ForeignCard, ForeignNote, NoteImporter from anki.stdmodels import _legacy_add_basic_model, _legacy_add_cloze_model class MnemosyneImporter(NoteImporter): needMapper = False update = False allowHTML = True def run(self): db = DB(self.file) ver = db.scalar("select value from global_variables where key='version'") if not ver.startswith("Mnemosyne SQL 1") and ver not in ("2", "3"): self.log.append( self.col.tr.importing_file_version_unknown_trying_import_anyway() ) # gather facts into temp objects curid = None notes = {} note = None for _id, id, k, v in db.execute( """ select _id, id, key, value from facts f, data_for_fact d where f._id=d._fact_id""" ): if id != curid: if note: notes[note["_id"]] = note note = {"_id": _id} curid = id assert note note[k] = v if note: notes[note["_id"]] = note # gather cards front = [] frontback = [] vocabulary = [] cloze = {} for row in db.execute( """ select _fact_id, fact_view_id, tags, next_rep, last_rep, easiness, acq_reps+ret_reps, lapses, card_type_id from cards""" ): # categorize note note = notes[row[0]] if row[1].endswith(".1"): if row[1].startswith("1.") or row[1].startswith("1::"): front.append(note) elif row[1].startswith("2.") or row[1].startswith("2::"): frontback.append(note) elif row[1].startswith("3.") or row[1].startswith("3::"): vocabulary.append(note) elif row[1].startswith("5.1"): cloze[row[0]] = note # check for None to fix issue where import can error out rawTags = row[2] if rawTags is None: rawTags = "" # merge tags into note tags = rawTags.replace(", ", "\x1f").replace(" ", "_") tags = tags.replace("\x1f", " ") if "tags" not in note: note["tags"] = [] note["tags"] += self.col.tags.split(tags) # if it's a new card we can go with the defaults if row[3] == -1: continue # add the card c = ForeignCard() c.factor = int(row[5] * 1000) c.reps = row[6] c.lapses = row[7] # ivl is inferred in mnemosyne next, prev = row[3:5] c.ivl = max(1, (next - prev) // 86400) # work out how long we've got left rem = int((next - time.time()) / 86400) c.due = self.col.sched.today + rem # get ord m = re.search(r".(\d+)$", row[1]) assert m ord = int(m.group(1)) - 1 if "cards" not in note: note["cards"] = {} note["cards"][ord] = c self._addFronts(front) total = self.total self._addFrontBacks(frontback) total += self.total self._addVocabulary(vocabulary) self.total += total self._addCloze(cloze) self.total += total self.log.append(self.col.tr.importing_note_imported(count=self.total)) def fields(self): return self._fields def _mungeField(self, fld): # \n -> br fld = re.sub("\r?\n", "
", fld) # latex differences fld = re.sub(r"(?i)<(/?(\$|\$\$|latex))>", "[\\1]", fld) # audio differences fld = re.sub(')?', "[sound:\\1]", fld) return fld def _addFronts(self, notes, model=None, fields=("f", "b")): data = [] for orig in notes: # create a foreign note object n = ForeignNote() n.fields = [] for f in fields: fld = self._mungeField(orig.get(f, "")) n.fields.append(fld) n.tags = orig["tags"] n.cards = orig.get("cards", {}) data.append(n) # add a basic model if not model: model = _legacy_add_basic_model(self.col) model["name"] = "Mnemosyne-FrontOnly" mm = self.col.models mm.save(model) mm.set_current(model) self.model = model self._fields = len(model["flds"]) self.initMapping() # import self.importNotes(data) def _addFrontBacks(self, notes): m = _legacy_add_basic_model(self.col) m["name"] = "Mnemosyne-FrontBack" mm = self.col.models t = mm.new_template("Back") t["qfmt"] = "{{Back}}" t["afmt"] = f"{t['qfmt']}\n\n
\n\n{{{{Front}}}}" # type: ignore mm.add_template(m, t) self._addFronts(notes, m) def _addVocabulary(self, notes): mm = self.col.models m = mm.new("Mnemosyne-Vocabulary") for f in "Expression", "Pronunciation", "Meaning", "Notes": fm = mm.new_field(f) mm.addField(m, fm) t = mm.new_template("Recognition") t["qfmt"] = "{{Expression}}" t["afmt"] = ( f"{cast(str, t['qfmt'])}\n\n
\n\n{{{{Pronunciation}}}}
\n{{{{Meaning}}}}
\n{{{{Notes}}}}" ) mm.add_template(m, t) t = mm.new_template("Production") t["qfmt"] = "{{Meaning}}" t["afmt"] = ( f"{cast(str, t['qfmt'])}\n\n
\n\n{{{{Expression}}}}
\n{{{{Pronunciation}}}}
\n{{{{Notes}}}}" ) mm.add_template(m, t) mm.add(m) self._addFronts(notes, m, fields=("f", "p_1", "m_1", "n")) def _addCloze(self, notes): data = [] notes = list(notes.values()) for orig in notes: # create a foreign note object n = ForeignNote() n.fields = [] fld = orig.get("text", "") fld = re.sub("\r?\n", "
", fld) state = dict(n=1) def repl(match): # replace [...] with cloze refs res = "{{c%d::%s}}" % (state["n"], match.group(1)) state["n"] += 1 return res fld = re.sub(r"\[(.+?)\]", repl, fld) fld = self._mungeField(fld) n.fields.append(fld) n.fields.append("") # extra n.tags = orig["tags"] n.cards = orig.get("cards", {}) data.append(n) # add cloze model model = _legacy_add_cloze_model(self.col) model["name"] = "Mnemosyne-Cloze" mm = self.col.models mm.save(model) mm.set_current(model) self.model = model self._fields = len(model["flds"]) self.initMapping() self.importNotes(data) ================================================ FILE: pylib/anki/importing/noteimp.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import html import unicodedata from typing import Union from anki.collection import Collection from anki.config import Config from anki.consts import NEW_CARDS_RANDOM, STARTING_FACTOR from anki.importing.base import Importer from anki.models import NotetypeId from anki.notes import NoteId from anki.utils import ( field_checksum, guid64, int_time, join_fields, split_fields, timestamp_id, ) TagMappedUpdate = tuple[int, int, str, str, NoteId, str, str] TagModifiedUpdate = tuple[int, int, str, str, NoteId, str] NoTagUpdate = tuple[int, int, str, NoteId, str] Updates = Union[TagMappedUpdate, TagModifiedUpdate, NoTagUpdate] # Stores a list of fields, tags and deck ###################################################################### class ForeignNote: "An temporary object storing fields and attributes." def __init__(self) -> None: self.fields: list[str] = [] self.tags: list[str] = [] self.deck = None self.cards: dict[int, ForeignCard] = {} # map of ord -> card self.fieldsStr = "" class ForeignCard: def __init__(self) -> None: self.due = 0 self.ivl = 1 self.factor = STARTING_FACTOR self.reps = 0 self.lapses = 0 # Base class for CSV and similar text-based imports ###################################################################### # The mapping is list of input fields, like: # ['Expression', 'Reading', '_tags', None] # - None means that the input should be discarded # - _tags maps to note tags # If the first field of the model is not in the map, the map is invalid. # The import mode is one of: # UPDATE_MODE: update if first field matches existing note # IGNORE_MODE: ignore if first field matches existing note # ADD_MODE: import even if first field matches existing note UPDATE_MODE = 0 IGNORE_MODE = 1 ADD_MODE = 2 class NoteImporter(Importer): needMapper = True needDelimiter = False allowHTML = False importMode = UPDATE_MODE mapping: list[str] | None tagModified: str | None def __init__(self, col: Collection, file: str) -> None: Importer.__init__(self, col, file) self.model = col.models.current() self.mapping = None self.tagModified = None self._tagsMapped = False def run(self) -> None: "Import." assert self.mapping c = self.foreignNotes() self.importNotes(c) def fields(self) -> int: "The number of fields." return 0 def initMapping(self) -> None: flds = [f["name"] for f in self.model["flds"]] # truncate to provided count flds = flds[0 : self.fields()] # if there's room left, add tags if self.fields() > len(flds): flds.append("_tags") # and if there's still room left, pad flds = flds + [None] * (self.fields() - len(flds)) self.mapping = flds def mappingOk(self) -> bool: return self.model["flds"][0]["name"] in self.mapping def foreignNotes(self) -> list: "Return a list of foreign notes for importing." return [] def importNotes(self, notes: list[ForeignNote]) -> None: "Convert each card into a note, apply attributes and add to col." if not self.mappingOk(): raise Exception("mapping not ok") # note whether tags are mapped self._tagsMapped = False for f in self.mapping: if f == "_tags": self._tagsMapped = True # gather checks for duplicate comparison csums: dict[str, list[NoteId]] = {} for csum, id in self.col.db.execute( "select csum, id from notes where mid = ?", self.model["id"] ): if csum in csums: csums[csum].append(id) else: csums[csum] = [id] firsts: dict[str, bool] = {} fld0idx = self.mapping.index(self.model["flds"][0]["name"]) self._fmap = self.col.models.field_map(self.model) self._nextID = NoteId(timestamp_id(self.col.db, "notes")) # loop through the notes updates: list[Updates] = [] updateLog = [] new = [] self._ids: list[NoteId] = [] self._cards: list[tuple] = [] dupeCount = 0 dupes: list[str] = [] for n in notes: for c, field in enumerate(n.fields): if not self.allowHTML: n.fields[c] = html.escape(field, quote=False) n.fields[c] = field.strip() if not self.allowHTML: n.fields[c] = field.replace("\n", "
") fld0 = unicodedata.normalize("NFC", n.fields[fld0idx]) # first field must exist if not fld0: self.log.append( self.col.tr.importing_empty_first_field(val=" ".join(n.fields)) ) continue csum = field_checksum(fld0) # earlier in import? if fld0 in firsts and self.importMode != ADD_MODE: # duplicates in source file; log and ignore self.log.append(self.col.tr.importing_appeared_twice_in_file(val=fld0)) continue firsts[fld0] = True # already exists? found = False if csum in csums: # type: ignore[comparison-overlap] # csum is not a guarantee; have to check for id in csums[csum]: # type: ignore[index] flds = self.col.db.scalar("select flds from notes where id = ?", id) sflds = split_fields(flds) if fld0 == sflds[0]: # duplicate found = True if self.importMode == UPDATE_MODE: data = self.updateData(n, id, sflds) if data: updates.append(data) updateLog.append( self.col.tr.importing_first_field_matched(val=fld0) ) dupeCount += 1 found = True elif self.importMode == IGNORE_MODE: dupeCount += 1 elif self.importMode == ADD_MODE: # allow duplicates in this case if fld0 not in dupes: # only show message once, no matter how many # duplicates are in the collection already updateLog.append( self.col.tr.importing_added_duplicate_with_first_field( val=fld0, ) ) dupes.append(fld0) found = False # newly add if not found: new_data = self.newData(n) if new_data: new.append(new_data) # note that we've seen this note once already firsts[fld0] = True self.addNew(new) self.addUpdates(updates) # generate cards + update field cache self.col.after_note_updates(self._ids, mark_modified=False) # apply scheduling updates self.updateCards() # we randomize or order here, to ensure that siblings # have the same due# did = self.col.decks.selected() conf = self.col.decks.config_dict_for_deck_id(did) # in order due? if not conf["dyn"] and conf["new"]["order"] == NEW_CARDS_RANDOM: self.col.sched.randomize_cards(did) part1 = self.col.tr.importing_note_added(count=len(new)) part2 = self.col.tr.importing_note_updated(count=self.updateCount) if self.importMode == UPDATE_MODE: unchanged = dupeCount - self.updateCount elif self.importMode == IGNORE_MODE: unchanged = dupeCount else: unchanged = 0 part3 = self.col.tr.importing_note_unchanged(count=unchanged) self.log.append(f"{part1}, {part2}, {part3}.") self.log.extend(updateLog) self.total = len(self._ids) def newData( self, n: ForeignNote ) -> tuple[NoteId, str, NotetypeId, int, int, str, str, str, int, int, str]: id = self._nextID self._nextID = NoteId(self._nextID + 1) self._ids.append(id) self.processFields(n) # note id for card updates later for ord, c in list(n.cards.items()): self._cards.append((id, ord, c)) return ( id, guid64(), self.model["id"], int_time(), self.col.usn(), self.col.tags.join(n.tags), n.fieldsStr, "", 0, 0, "", ) def addNew( self, rows: list[ tuple[NoteId, str, NotetypeId, int, int, str, str, str, int, int, str] ], ) -> None: self.col.db.executemany( "insert or replace into notes values (?,?,?,?,?,?,?,?,?,?,?)", rows ) def updateData( self, n: ForeignNote, id: NoteId, sflds: list[str] ) -> Updates | None: self._ids.append(id) self.processFields(n, sflds) if self._tagsMapped: tags = self.col.tags.join(n.tags) return ( int_time(), self.col.usn(), n.fieldsStr, tags, id, n.fieldsStr, tags, ) elif self.tagModified: tags = self.col.db.scalar("select tags from notes where id = ?", id) tagList = self.col.tags.split(tags) + self.tagModified.split() tags = self.col.tags.join(tagList) return (int_time(), self.col.usn(), n.fieldsStr, tags, id, n.fieldsStr) else: return (int_time(), self.col.usn(), n.fieldsStr, id, n.fieldsStr) def addUpdates(self, rows: list[Updates]) -> None: changes = self.col.db.scalar("select total_changes()") if self._tagsMapped: self.col.db.executemany( """ update notes set mod = ?, usn = ?, flds = ?, tags = ? where id = ? and (flds != ? or tags != ?)""", rows, ) elif self.tagModified: self.col.db.executemany( """ update notes set mod = ?, usn = ?, flds = ?, tags = ? where id = ? and flds != ?""", rows, ) else: self.col.db.executemany( """ update notes set mod = ?, usn = ?, flds = ? where id = ? and flds != ?""", rows, ) changes2 = self.col.db.scalar("select total_changes()") self.updateCount = changes2 - changes def processFields(self, note: ForeignNote, fields: list[str] | None = None) -> None: if not fields: fields = [""] * len(self.model["flds"]) for c, f in enumerate(self.mapping): if not f: continue elif f == "_tags": note.tags.extend(self.col.tags.split(note.fields[c])) else: sidx = self._fmap[f][0] fields[sidx] = note.fields[c] note.fieldsStr = join_fields(fields) # temporary fix for the following issue until we can update the code: # https://forums.ankiweb.net/t/python-checksum-rust-checksum/8195/16 if self.col.get_config_bool(Config.Bool.NORMALIZE_NOTE_TEXT): note.fieldsStr = unicodedata.normalize("NFC", note.fieldsStr) def updateCards(self) -> None: data = [] for nid, ord, c in self._cards: data.append((c.ivl, c.due, c.factor, c.reps, c.lapses, nid, ord)) # we assume any updated cards are reviews self.col.db.executemany( """ update cards set type = 2, queue = 2, ivl = ?, due = ?, factor = ?, reps = ?, lapses = ? where nid = ? and ord = ?""", data, ) ================================================ FILE: pylib/anki/lang.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import locale import re import warnings import weakref from typing import TYPE_CHECKING, Any import anki import anki._backend import anki.i18n_pb2 as _pb from anki._legacy import DeprecatedNamesMixinForModule # public exports TR = anki._fluent.LegacyTranslationEnum FormatTimeSpan = _pb.FormatTimespanRequest # When adding new languages here, check lang_to_disk_lang() below langs = sorted( [ ("Afrikaans", "af_ZA"), ("Bahasa Melayu", "ms_MY"), ("Català", "ca_ES"), ("Dansk", "da_DK"), ("Deutsch", "de_DE"), ("Eesti", "et_EE"), ("English (United States)", "en_US"), ("English (United Kingdom)", "en_GB"), ("Español", "es_ES"), ("Esperanto", "eo_UY"), ("Euskara", "eu_ES"), ("Français", "fr_FR"), ("Galego", "gl_ES"), ("Hrvatski", "hr_HR"), ("Italiano", "it_IT"), ("lo jbobau", "jbo_EN"), ("Lenga d'òc", "oc_FR"), ("Қазақша", "kk_KZ"), ("Magyar", "hu_HU"), ("Nederlands", "nl_NL"), ("Norsk", "nb_NO"), ("Polski", "pl_PL"), ("Português Brasileiro", "pt_BR"), ("Português", "pt_PT"), ("Română", "ro_RO"), ("Slovenčina", "sk_SK"), ("Slovenščina", "sl_SI"), ("Suomi", "fi_FI"), ("Svenska", "sv_SE"), ("Tiếng Việt", "vi_VN"), ("Türkçe", "tr_TR"), ("简体中文", "zh_CN"), ("日本語", "ja_JP"), ("繁體中文", "zh_TW"), ("한국어", "ko_KR"), ("Čeština", "cs_CZ"), ("Ελληνικά", "el_GR"), ("Български", "bg_BG"), ("Монгол хэл", "mn_MN"), ("Pусский язык", "ru_RU"), ("Српски", "sr_SP"), ("Українська мова", "uk_UA"), ("Հայերեն", "hy_AM"), ("עִבְרִית", "he_IL"), ("ייִדיש", "yi"), ("العربية", "ar_SA"), ("فارسی", "fa_IR"), ("ภาษาไทย", "th_TH"), ("Latin", "la_LA"), ("Gaeilge", "ga_IE"), ("Беларуская мова", "be_BY"), ("ଓଡ଼ିଆ", "or_OR"), ("Filipino", "tl"), ("ئۇيغۇر", "ug"), ("Oʻzbekcha", "uz_UZ"), ] ) # compatibility with old versions compatMap = { "af": "af_ZA", "ar": "ar_SA", "be": "be_BY", "bg": "bg_BG", "ca": "ca_ES", "cs": "cs_CZ", "da": "da_DK", "de": "de_DE", "el": "el_GR", "en": "en_US", "eo": "eo_UY", "es": "es_ES", "et": "et_EE", "eu": "eu_ES", "fa": "fa_IR", "fi": "fi_FI", "fr": "fr_FR", "gl": "gl_ES", "he": "he_IL", "hr": "hr_HR", "hu": "hu_HU", "hy": "hy_AM", "it": "it_IT", "ja": "ja_JP", "jbo": "jbo_EN", "kk": "kk_KZ", "ko": "ko_KR", "la": "la_LA", "mn": "mn_MN", "ms": "ms_MY", "nl": "nl_NL", "nb": "nb_NL", "no": "nb_NL", "oc": "oc_FR", "or": "or_OR", "pl": "pl_PL", "pt": "pt_PT", "ro": "ro_RO", "ru": "ru_RU", "sk": "sk_SK", "sl": "sl_SI", "sr": "sr_SP", "sv": "sv_SE", "th": "th_TH", "tr": "tr_TR", "uk": "uk_UA", "uz": "uz_UZ", "vi": "vi_VN", "yi": "yi", } def lang_to_disk_lang(lang: str) -> str: """Normalize lang, then convert it to name used on disk.""" # convert it into our canonical representation first lang = lang.replace("-", "_") if lang in compatMap: lang = compatMap[lang] # these language/region combinations are fully qualified, but with a hyphen if lang in ( "en_GB", "ga_IE", "hy_AM", "nb_NO", "nn_NO", "pt_BR", "pt_PT", "sv_SE", "zh_CN", "zh_TW", ): return lang.replace("_", "-") # other languages have the region portion stripped match = re.match("(.*)_", lang) if match: return match.group(1) else: return lang # the currently set interface language current_lang = "en" # the current Fluent translation instance. Code in pylib/ should # not reference this, and should use col.tr instead. The global # instance exists for legacy reasons, and as a convenience for the # Qt code. current_i18n: anki._backend.RustBackend | None = None tr_legacyglobal = anki._backend.Translations(None) def _(str: str) -> str: print(f"gettext _() is deprecated: {str}") return str def ngettext(single: str, plural: str, num: int) -> str: print(f"ngettext() is deprecated: {plural}") return plural def set_lang(lang: str) -> None: global current_lang, current_i18n current_lang = lang current_i18n = anki._backend.RustBackend(langs=[lang]) tr_legacyglobal.backend = weakref.ref(current_i18n) def get_def_lang(user_lang: str | None = None) -> tuple[int, str]: """Return user_lang converted to name used on disk and its index, defaulting to system language or English if not available.""" def get_index_of_language(wanted_locale: str) -> int | None: for i, (_, locale_) in enumerate(langs): if locale_ == wanted_locale: return i return None try: # getdefaultlocale() is deprecated since Python 3.11, but we need to keep using it as getlocale() behaves differently: https://bugs.python.org/issue38805 with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) (sys_lang, enc) = locale.getdefaultlocale() except AttributeError: # this will return a different format on Windows (e.g. Italian_Italy), resulting in us falling back to en_US # further below (sys_lang, enc) = locale.getlocale() except Exception: # fails on osx sys_lang = "en_US" if user_lang in compatMap: user_lang = compatMap[user_lang] idx = None lang = None for preferred_lang in (user_lang, sys_lang): idx = get_index_of_language(preferred_lang) is_language_supported = idx is not None if is_language_supported: assert preferred_lang is not None lang = preferred_lang break # if the specified language and the system language aren't available, revert to english is_preferred_language_supported = idx is not None if not is_preferred_language_supported: lang = "en_US" idx = get_index_of_language(lang) is_english_supported = idx is not None if not is_english_supported: raise AssertionError("English is supposed to be a supported language.") assert idx is not None and lang is not None return (idx, lang) def is_rtl(lang: str) -> bool: return lang in ("he", "ar", "fa", "ug", "yi") # strip off unicode isolation markers from a translated string # for testing purposes def without_unicode_isolation(string: str) -> str: return string.replace("\u2068", "").replace("\u2069", "") def with_collapsed_whitespace(string: str) -> str: return re.sub(r"\s+", " ", string) _deprecated_names = DeprecatedNamesMixinForModule(globals()) if not TYPE_CHECKING: def __getattr__(name: str) -> Any: return _deprecated_names.__getattr__(name) ================================================ FILE: pylib/anki/latex.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import html import os from dataclasses import dataclass import anki import anki.collection from anki import card_rendering_pb2, hooks from anki.config import Config from anki.models import NotetypeDict from anki.template import TemplateRenderContext, TemplateRenderOutput from anki.utils import call, is_mac, namedtmp, tmpdir pngCommands = [ ["latex", "-interaction=nonstopmode", "tmp.tex"], [ "dvipng", "-bg", "Transparent", "-D", "200", "-T", "tight", "tmp.dvi", "-o", "tmp.png", ], ] svgCommands = [ ["latex", "-interaction=nonstopmode", "tmp.tex"], ["dvisvgm", "--no-fonts", "--exact", "-Z", "2", "tmp.dvi", "-o", "tmp.svg"], ] # add standard tex install location to osx if is_mac: os.environ["PATH"] += ":/usr/texbin:/Library/TeX/texbin" @dataclass class ExtractedLatex: filename: str latex_body: str @dataclass class ExtractedLatexOutput: html: str latex: list[ExtractedLatex] @staticmethod def from_proto( proto: card_rendering_pb2.ExtractLatexResponse, ) -> ExtractedLatexOutput: return ExtractedLatexOutput( html=proto.text, latex=[ ExtractedLatex(filename=l.filename, latex_body=l.latex_body) for l in proto.latex ], ) def on_card_did_render( output: TemplateRenderOutput, ctx: TemplateRenderContext ) -> None: output.question_text = render_latex( output.question_text, ctx.note_type(), ctx.col() ) output.answer_text = render_latex(output.answer_text, ctx.note_type(), ctx.col()) def render_latex( html: str, model: NotetypeDict, col: anki.collection.Collection ) -> str: "Convert embedded latex tags in text to image links." html, err = render_latex_returning_errors(html, model, col) if err: html += "\n".join(err) return html def render_latex_returning_errors( html: str, model: NotetypeDict, col: anki.collection.Collection, expand_clozes: bool = False, ) -> tuple[str, list[str]]: """Returns (text, errors). errors will be non-empty if LaTeX failed to render.""" svg = model.get("latexsvg", False) header = model["latexPre"] footer = model["latexPost"] proto = col._backend.extract_latex(text=html, svg=svg, expand_clozes=expand_clozes) out = ExtractedLatexOutput.from_proto(proto) errors = [] html = out.html render_latex = col.get_config_bool(Config.Bool.RENDER_LATEX) for latex in out.latex: # don't need to render? if col.media.have(latex.filename): continue if not render_latex: errors.append(col.tr.preferences_latex_generation_disabled()) return html, errors err = _save_latex_image(col, latex, header, footer, svg) if err is not None: errors.append(err) return html, errors def _save_latex_image( col: anki.collection.Collection, extracted: ExtractedLatex, header: str, footer: str, svg: bool, ) -> str | None: # add header/footer latex = f"{header}\n{extracted.latex_body}\n{footer}" # commands to use if svg: latex_cmds = svgCommands ext = "svg" else: latex_cmds = pngCommands ext = "png" # write into a temp file log = open(namedtmp("latex_log.txt"), "w", encoding="utf8") texpath = namedtmp("tmp.tex") texfile = open(texpath, "w", encoding="utf8") texfile.write(latex) texfile.close() oldcwd = os.getcwd() png_or_svg = namedtmp(f"tmp.{ext}") try: # generate png/svg os.chdir(tmpdir()) for latex_cmd in latex_cmds: if call(latex_cmd, stdout=log, stderr=log): return _err_msg(col, latex_cmd[0], texpath) # add to media with open(png_or_svg, "rb") as file: data = file.read() col.media.write_data(extracted.filename, data) os.unlink(png_or_svg) return None finally: os.chdir(oldcwd) log.close() def _err_msg(col: anki.collection.Collection, type: str, texpath: str) -> str: msg = f"{col.tr.media_error_executing(val=type)}
" msg += f"{col.tr.media_generated_file(val=texpath)}
" try: with open(namedtmp("latex_log.txt", remove=False), encoding="utf8") as file: log = file.read() if not log: raise Exception() msg += f"
{html.escape(log)}
" except Exception: msg += col.tr.media_have_you_installed_latex_and_dvipngdvisvgm() return msg def setup_hook() -> None: hooks.card_did_render.append(on_card_did_render) ================================================ FILE: pylib/anki/media.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import os import pprint import re import sys import time from collections.abc import Callable, Sequence from anki import media_pb2 from anki._legacy import DeprecatedNamesMixin, deprecated_keywords from anki.consts import * from anki.latex import render_latex, render_latex_returning_errors from anki.models import NotetypeId from anki.sound import SoundOrVideoTag from anki.template import av_tags_to_native from anki.utils import int_time def media_paths_from_col_path(col_path: str) -> tuple[str, str]: media_folder = re.sub(r"(?i)\.(anki2)$", ".media", col_path) media_db = f"{media_folder}.db2" return (media_folder, media_db) CheckMediaResponse = media_pb2.CheckMediaResponse class MediaManager(DeprecatedNamesMixin): sound_regexps = [r"(?i)(\[sound:(?P[^]]+)\])"] html_media_regexps = [ # src element quoted case r"(?i)(<(?:img|audio|source)\b[^>]* src=(?P[\"'])(?P[^>]+?)(?P=str)[^>]*>)", # unquoted case r"(?i)(<(?:img|audio|source)\b[^>]* src=(?!['\"])(?P[^ >]+)[^>]*?>)", # src element quoted case r"(?i)(]* data=(?P[\"'])(?P[^>]+?)(?P=str)[^>]*>)", # unquoted case r"(?i)(]* data=(?!['\"])(?P[^ >]+)[^>]*?>)", ] regexps = sound_regexps + html_media_regexps def __init__(self, col: anki.collection.Collection, server: bool) -> None: self.col = col.weakref() if server: return # media directory self._dir = media_paths_from_col_path(self.col.path)[0] if not os.path.exists(self._dir): os.makedirs(self._dir) def __repr__(self) -> str: dict_ = dict(self.__dict__) del dict_["col"] return f"{super().__repr__()} {pprint.pformat(dict_, width=300)}" def dir(self) -> str: return self._dir def force_resync(self) -> None: try: os.unlink(media_paths_from_col_path(self.col.path)[1]) except FileNotFoundError: pass def empty_trash(self) -> None: self.col._backend.empty_trash() def restore_trash(self) -> None: self.col._backend.restore_trash() def strip_av_tags(self, text: str) -> str: return self.col._backend.strip_av_tags(text) def _extract_filenames(self, text: str) -> list[str]: "This only exists to support a legacy function; do not use." out = self.col._backend.extract_av_tags(text=text, question_side=True) return [ x.filename for x in av_tags_to_native(out.av_tags) if isinstance(x, SoundOrVideoTag) ] # File manipulation ########################################################################## def add_file(self, path: str) -> str: """Add basename of path to the media folder, renaming if not unique. Returns possibly-renamed filename.""" with open(path, "rb") as file: return self.write_data(os.path.basename(path), file.read()) def write_data(self, desired_fname: str, data: bytes) -> str: """Write the file to the media folder, renaming if not unique. Returns possibly-renamed filename.""" return self.col._backend.add_media_file(desired_name=desired_fname, data=data) def add_extension_based_on_mime(self, fname: str, content_type: str) -> str: "Add extension based on mime for common audio and image format if missing extension." if not os.path.splitext(fname)[1]: # mimetypes is returning '.jpe' even after calling .init(), so we'll do # it manually instead type_map = { "audio/mpeg": ".mp3", "audio/ogg": ".oga", "audio/opus": ".opus", "audio/wav": ".wav", "audio/webm": ".weba", "audio/aac": ".aac", "image/jpeg": ".jpg", "image/png": ".png", "image/svg+xml": ".svg", "image/webp": ".webp", "image/avif": ".avif", } if content_type in type_map: fname += type_map[content_type] return fname def have(self, fname: str) -> bool: return os.path.exists(os.path.join(self.dir(), fname)) def trash_files(self, fnames: list[str]) -> None: "Move provided files to the trash." self.col._backend.trash_media_files(fnames) # String manipulation ########################################################################## @deprecated_keywords(includeRemote="include_remote") def files_in_str( self, mid: NotetypeId, string: str, include_remote: bool = False ) -> list[str]: files = [] model = self.col.models.get(mid) # handle latex string = render_latex(string, model, self.col) # extract filenames for reg in self.regexps: for match in re.finditer(reg, string): fname = match.group("fname") is_local = not re.match("(https?|ftp)://", fname.lower()) if is_local or include_remote: files.append(fname) return files def extract_static_media_files(self, mid: NotetypeId) -> Sequence[str]: return self.col._backend.extract_static_media_files(mid) def transform_names(self, txt: str, func: Callable) -> str: for reg in self.regexps: txt = re.sub(reg, func, txt) return txt def strip(self, txt: str) -> str: "Return text with sound and image tags removed." for reg in self.regexps: txt = re.sub(reg, "", txt) return txt def escape_images(self, string: str, unescape: bool = False) -> str: "escape_media_filenames alias for compatibility with add-ons." return self.escape_media_filenames(string, unescape) def escape_media_filenames(self, string: str, unescape: bool = False) -> str: "Apply or remove percent encoding to filenames in html tags (audio, image, object)." if unescape: return self.col._backend.decode_iri_paths(string) else: return self.col._backend.encode_iri_paths(string) # Checking media ########################################################################## def check(self) -> CheckMediaResponse: output = self.col._backend.check_media() return output def render_all_latex( self, progress_cb: Callable[[int], bool] | None = None ) -> tuple[int, str] | None: """Render any LaTeX that is missing. If a progress callback is provided and it returns false, the operation will be aborted. If an error is encountered, returns (note_id, error_message) """ last_progress = time.time() checked = 0 for nid, mid, flds in self.col.db.execute( "select id, mid, flds from notes where flds like '%[%'" ): model = self.col.models.get(mid) _html, errors = render_latex_returning_errors( flds, model, self.col, expand_clozes=True ) if errors: return (nid, "\n".join(errors)) checked += 1 elap = time.time() - last_progress if elap >= 0.3 and progress_cb is not None: last_progress = int_time() if not progress_cb(checked): return None return None # Legacy ########################################################################## _illegalCharReg = re.compile(r'[][><:"/?*^\\|\0\r\n]') def _legacy_strip_illegal(self, str: str) -> str: # currently used by ankiconnect return re.sub(self._illegalCharReg, "", str) def _legacy_has_illegal(self, string: str) -> bool: if re.search(self._illegalCharReg, string): return True try: string.encode(sys.getfilesystemencoding()) except UnicodeEncodeError: return True return False def _legacy_find_changes(self) -> None: pass @deprecated_keywords(typeHint="type_hint") def _legacy_write_data( self, opath: str, data: bytes, type_hint: str | None = None ) -> str: fname = os.path.basename(opath) if type_hint: fname = self.add_extension_based_on_mime(fname, type_hint) return self.write_data(fname, data) MediaManager.register_deprecated_attributes( stripIllegal=(MediaManager._legacy_strip_illegal, None), hasIllegal=(MediaManager._legacy_has_illegal, None), findChanges=(MediaManager._legacy_find_changes, None), writeData=(MediaManager._legacy_write_data, MediaManager.write_data), ) ================================================ FILE: pylib/anki/models.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import copy import pprint import sys import time from collections.abc import Sequence from typing import Any, NewType, Union import anki import anki.collection import anki.notes from anki import notetypes_pb2 from anki._legacy import DeprecatedNamesMixin, deprecated, print_deprecation_warning from anki.collection import OpChanges, OpChangesWithId from anki.consts import * from anki.errors import NotFoundError from anki.lang import without_unicode_isolation from anki.stdmodels import StockNotetypeKind from anki.utils import checksum, from_json_bytes, to_json_bytes # public exports NotetypeNameId = notetypes_pb2.NotetypeNameId NotetypeNameIdUseCount = notetypes_pb2.NotetypeNameIdUseCount NotetypeNames = notetypes_pb2.NotetypeNames ChangeNotetypeInfo = notetypes_pb2.ChangeNotetypeInfo ChangeNotetypeRequest = notetypes_pb2.ChangeNotetypeRequest StockNotetype = notetypes_pb2.StockNotetype # legacy types NotetypeDict = dict[str, Any] NoteType = NotetypeDict FieldDict = dict[str, Any] TemplateDict = dict[str, Union[str, int, None]] NotetypeId = NewType("NotetypeId", int) sys.modules["anki.models"].NoteType = NotetypeDict # type: ignore class ModelsDictProxy: def __init__(self, col: anki.collection.Collection): self._col = col.weakref() def _warn(self) -> None: print_deprecation_warning( "add-on should use methods on col.models, not col.models.models dict" ) def __getitem__(self, item: Any) -> Any: self._warn() return self._col.models.get(NotetypeId(int(item))) def __setitem__(self, key: str, val: Any) -> None: self._warn() self._col.models.save(val) def __len__(self) -> int: self._warn() return len(self._col.models.all_names_and_ids()) def keys(self) -> Any: self._warn() return [str(nt.id) for nt in self._col.models.all_names_and_ids()] def values(self) -> Any: self._warn() return self._col.models.all() def items(self) -> Any: self._warn() return [(str(nt["id"]), nt) for nt in self._col.models.all()] def __contains__(self, item: Any) -> bool: self._warn() return self._col.models.have(item) class ModelManager(DeprecatedNamesMixin): # Saving/loading registry ############################################################# def __init__(self, col: anki.collection.Collection) -> None: self.col = col.weakref() self.models = ModelsDictProxy(col) # do not access this directly! self._cache = {} def __repr__(self) -> str: attrs = dict(self.__dict__) del attrs["col"] return f"{super().__repr__()} {pprint.pformat(attrs, width=300)}" # Caching ############################################################# # A lot of existing code expects to be able to quickly and # frequently obtain access to an entire notetype, so we currently # need to cache responses from the backend. Please do not # access the cache directly! _cache: dict[NotetypeId, NotetypeDict] = {} def _update_cache(self, notetype: NotetypeDict) -> None: self._cache[notetype["id"]] = notetype def _remove_from_cache(self, ntid: NotetypeId) -> None: if ntid in self._cache: del self._cache[ntid] def _get_cached(self, ntid: NotetypeId) -> NotetypeDict | None: return self._cache.get(ntid) def _clear_cache(self) -> None: self._cache = {} # Listing note types ############################################################# def all_names_and_ids(self) -> Sequence[NotetypeNameId]: return self.col._backend.get_notetype_names() def all_use_counts(self) -> Sequence[NotetypeNameIdUseCount]: return self.col._backend.get_notetype_names_and_counts() # only used by importing code def have(self, id: NotetypeId) -> bool: if isinstance(id, str): id = int(id) return any(True for e in self.all_names_and_ids() if e.id == id) # Current note type ############################################################# def current(self, for_deck: bool = True) -> NotetypeDict: "Get current model. In new code, prefer col.defaults_for_adding()" notetype = self.get(self.col.decks.current().get("mid")) if not for_deck or not notetype: notetype = self.get(self.col.conf["curModel"]) if notetype: return notetype return self.get(NotetypeId(self.all_names_and_ids()[0].id)) # Retrieving and creating models ############################################################# def id_for_name(self, name: str) -> NotetypeId | None: try: return NotetypeId(self.col._backend.get_notetype_id_by_name(name)) except NotFoundError: return None def get(self, id: NotetypeId) -> NotetypeDict | None: """Get model with ID, or None. This returns a reference to a cached dict. Copy the returned model before modifying it if you're not calling .update_dict() afterward. """ # deal with various legacy input types if id is None: return None elif isinstance(id, str): id = int(id) notetype = self._get_cached(id) if not notetype: try: notetype = from_json_bytes(self.col._backend.get_notetype_legacy(id)) self._update_cache(notetype) except NotFoundError: return None return notetype def all(self) -> list[NotetypeDict]: "Get all models." return [self.get(NotetypeId(nt.id)) for nt in self.all_names_and_ids()] def by_name(self, name: str) -> NotetypeDict | None: "Get model with NAME." id = self.id_for_name(name) if id: return self.get(id) else: return None def new(self, name: str) -> NotetypeDict: "Create a new model, and return it." # caller should call save() after modifying notetype = from_json_bytes( self.col._backend.get_stock_notetype_legacy(StockNotetypeKind.KIND_BASIC) ) notetype["flds"] = [] notetype["tmpls"] = [] notetype["name"] = name return notetype def remove_all_notetypes(self) -> None: for notetype in self.all_names_and_ids(): self._remove_from_cache(NotetypeId(notetype.id)) self.col._backend.remove_notetype(notetype.id) def remove(self, id: NotetypeId) -> OpChanges: "Modifies schema." self._remove_from_cache(id) return self.col._backend.remove_notetype(id) def add(self, notetype: NotetypeDict) -> OpChangesWithId: "Replaced with add_dict()" self.ensure_name_unique(notetype) out = self.col._backend.add_notetype_legacy(to_json_bytes(notetype)) notetype["id"] = out.id self._mutate_after_write(notetype) return out def add_dict(self, notetype: NotetypeDict) -> OpChangesWithId: "Notetype needs to be fetched from DB after adding." self.ensure_name_unique(notetype) return self.col._backend.add_notetype_legacy(to_json_bytes(notetype)) def ensure_name_unique(self, notetype: NotetypeDict) -> None: existing_id = self.id_for_name(notetype["name"]) if existing_id is not None and existing_id != notetype["id"]: notetype["name"] += f"-{checksum(str(time.time()))[:5]}" def update_dict( self, notetype: NotetypeDict, skip_checks: bool = False ) -> OpChanges: "Update a NotetypeDict. Caller will need to re-load notetype if new fields/cards added." self._remove_from_cache(notetype["id"]) self.ensure_name_unique(notetype) return self.col._backend.update_notetype_legacy( json=to_json_bytes(notetype), skip_checks=skip_checks ) def _mutate_after_write(self, notetype: NotetypeDict) -> None: # existing code expects the note type to be mutated to reflect # the changes made when adding, such as ordinal assignment :-( updated = self.get(notetype["id"]) notetype.update(updated) # Tools ################################################## def nids(self, ntid: NotetypeId) -> list[anki.notes.NoteId]: "Note ids for M." if isinstance(ntid, dict): # legacy callers passed in note type ntid = ntid["id"] return self.col.db.list("select id from notes where mid = ?", ntid) def use_count(self, notetype: NotetypeDict) -> int: "Number of note using M." return self.col.db.scalar( "select count() from notes where mid = ?", notetype["id"] ) # Copying ################################################## def copy(self, notetype: NotetypeDict, add: bool = True) -> NotetypeDict: "Copy, save and return." cloned = copy.deepcopy(notetype) cloned["name"] = without_unicode_isolation( self.col.tr.notetypes_copy(val=cloned["name"]) ) cloned["id"] = 0 cloned["originalId"] = None if add: self.add(cloned) return cloned # Fields ################################################## def field_map(self, notetype: NotetypeDict) -> dict[str, tuple[int, FieldDict]]: "Mapping of field name -> (ord, field)." return {f["name"]: (f["ord"], f) for f in notetype["flds"]} def field_names(self, notetype: NotetypeDict) -> list[str]: return [f["name"] for f in notetype["flds"]] def sort_idx(self, notetype: NotetypeDict) -> int: return notetype["sortf"] def cloze_fields(self, mid: NotetypeId) -> Sequence[int]: """The list of index of fields that are used by cloze deletion in the note type with id `mid`.""" return self.col._backend.get_cloze_field_ords(mid) # Adding & changing fields ################################################## def new_field(self, name: str) -> FieldDict: assert isinstance(name, str) notetype = from_json_bytes( self.col._backend.get_stock_notetype_legacy(StockNotetypeKind.KIND_BASIC) ) field = notetype["flds"][0] field["name"] = name field["ord"] = None return field def add_field(self, notetype: NotetypeDict, field: FieldDict) -> None: "Modifies schema." notetype["flds"].append(field) def remove_field(self, notetype: NotetypeDict, field: FieldDict) -> None: "Modifies schema." notetype["flds"].remove(field) def reposition_field( self, notetype: NotetypeDict, field: FieldDict, idx: int ) -> None: "Modifies schema." oldidx = notetype["flds"].index(field) if oldidx == idx: return notetype["flds"].remove(field) notetype["flds"].insert(idx, field) def rename_field( self, notetype: NotetypeDict, field: FieldDict, new_name: str ) -> None: if field not in notetype["flds"]: raise Exception("invalid field") field["name"] = new_name def set_sort_index(self, notetype: NotetypeDict, idx: int) -> None: "Modifies schema." if not 0 <= idx < len(notetype["flds"]): raise Exception("invalid sort index") notetype["sortf"] = idx # Adding & changing templates ################################################## def new_template(self, name: str) -> TemplateDict: notetype = from_json_bytes( self.col._backend.get_stock_notetype_legacy(StockNotetypeKind.KIND_BASIC) ) template = notetype["tmpls"][0] template["name"] = name template["qfmt"] = "" template["afmt"] = "" template["ord"] = None return template def add_template(self, notetype: NotetypeDict, template: TemplateDict) -> None: "Modifies schema." notetype["tmpls"].append(template) def remove_template(self, notetype: NotetypeDict, template: TemplateDict) -> None: "Modifies schema." if not len(notetype["tmpls"]) > 1: raise Exception("must have 1 template") notetype["tmpls"].remove(template) def reposition_template( self, notetype: NotetypeDict, template: TemplateDict, idx: int ) -> None: "Modifies schema." oldidx = notetype["tmpls"].index(template) if oldidx == idx: return notetype["tmpls"].remove(template) notetype["tmpls"].insert(idx, template) def template_use_count(self, ntid: NotetypeId, ord: int) -> int: return self.col.db.scalar( """ select count() from cards, notes where cards.nid = notes.id and notes.mid = ? and cards.ord = ?""", ntid, ord, ) # Changing notetypes of notes ########################################################################## def get_single_notetype_of_notes( self, note_ids: Sequence[anki.notes.NoteId] ) -> NotetypeId: return NotetypeId( self.col._backend.get_single_notetype_of_notes(note_ids=note_ids) ) def change_notetype_info( self, *, old_notetype_id: NotetypeId, new_notetype_id: NotetypeId ) -> ChangeNotetypeInfo: return self.col._backend.get_change_notetype_info( old_notetype_id=old_notetype_id, new_notetype_id=new_notetype_id ) def change_notetype_of_notes(self, input: ChangeNotetypeRequest) -> OpChanges: """Assign a new notetype, optionally altering field/template order. To get defaults, use info = col.models.change_notetype_info(...) input = info.input input.note_ids.extend([...]) The new_fields and new_templates lists are relative to the new notetype's field/template count. Each value represents the index in the previous notetype. -1 indicates the original value will be discarded. """ op_bytes = self.col._backend.change_notetype_raw(input.SerializeToString()) return OpChanges.FromString(op_bytes) def restore_notetype_to_stock( self, notetype_id: NotetypeId, force_kind: StockNotetypeKind.V | None ) -> OpChanges: msg = notetypes_pb2.RestoreNotetypeToStockRequest( notetype_id=notetypes_pb2.NotetypeId(ntid=notetype_id), ) if force_kind is not None: msg.force_kind = force_kind return self.col._backend.restore_notetype_to_stock(msg) # legacy API - used by unit tests and add-ons def change( self, notetype: NotetypeDict, nids: list[anki.notes.NoteId], newModel: NotetypeDict, fmap: dict[int, int | None], cmap: dict[int, int | None] | None, ) -> None: # - maps are ord->ord, and there should not be duplicate targets self.col.mod_schema(check=True) assert fmap field_map = self._convert_legacy_map(fmap, len(newModel["flds"])) is_cloze = newModel["type"] == MODEL_CLOZE or notetype["type"] == MODEL_CLOZE if not cmap or is_cloze: template_map = [] else: template_map = self._convert_legacy_map(cmap, len(newModel["tmpls"])) self.col._backend.change_notetype( note_ids=nids, new_fields=field_map, new_templates=template_map, old_notetype_name=notetype["name"], old_notetype_id=notetype["id"], new_notetype_id=newModel["id"], current_schema=self.col.db.scalar("select scm from col"), is_cloze=is_cloze, ) def _convert_legacy_map( self, old_to_new: dict[int, int | None], new_count: int ) -> list[int]: "Convert old->new map to list of old indexes" new_to_old = {v: k for k, v in old_to_new.items() if v is not None} out = [] for idx in range(new_count): try: val = new_to_old[idx] except KeyError: val = -1 out.append(val) return out # Schema hash ########################################################################## def scmhash(self, notetype: NotetypeDict) -> str: "Return a hash of the schema, to see if models are compatible." buf = "" for field in notetype["flds"]: buf += field["name"] for template in notetype["tmpls"]: buf += template["name"] return checksum(buf) # Legacy ########################################################################## @deprecated(info="use note.cloze_numbers_in_fields()") def _availClozeOrds( self, notetype: NotetypeDict, flds: str, allow_empty: bool = True ) -> list[int]: import anki.notes_pb2 note = anki.notes_pb2.Note(fields=[flds]) return list(self.col._backend.cloze_numbers_in_note(note)) # @deprecated(replaced_by=add_template) def addTemplate(self, notetype: NotetypeDict, template: TemplateDict) -> None: self.add_template(notetype, template) if notetype["id"]: self.update(notetype) # @deprecated(replaced_by=remove_template) def remTemplate(self, notetype: NotetypeDict, template: TemplateDict) -> None: self.remove_template(notetype, template) self.update(notetype) # @deprecated(replaced_by=reposition_template) def move_template( self, notetype: NotetypeDict, template: TemplateDict, idx: int ) -> None: self.reposition_template(notetype, template, idx) self.update(notetype) # @deprecated(replaced_by=add_field) def addField(self, notetype: NotetypeDict, field: FieldDict) -> None: self.add_field(notetype, field) if notetype["id"]: self.update(notetype) # @deprecated(replaced_by=remove_field) def remField(self, notetype: NotetypeDict, field: FieldDict) -> None: self.remove_field(notetype, field) self.update(notetype) # @deprecated(replaced_by=reposition_field) def moveField(self, notetype: NotetypeDict, field: FieldDict, idx: int) -> None: self.reposition_field(notetype, field, idx) self.update(notetype) # @deprecated(replaced_by=rename_field) def renameField( self, notetype: NotetypeDict, field: FieldDict, new_name: str ) -> None: self.rename_field(notetype, field, new_name) self.update(notetype) @deprecated(replaced_by=remove) def rem(self, m: NotetypeDict) -> None: "Delete model, and all its cards/notes." self.remove(m["id"]) # @deprecated(info="not needed; is updated on note add") def set_current(self, m: NotetypeDict) -> None: self.col.set_config("curModel", m["id"]) @deprecated(replaced_by=all_names_and_ids) def all_names(self) -> list[str]: return [n.name for n in self.all_names_and_ids()] @deprecated(replaced_by=all_names_and_ids) def ids(self) -> list[NotetypeId]: return [NotetypeId(n.id) for n in self.all_names_and_ids()] @deprecated(info="no longer required") def flush(self) -> None: pass # @deprecated(replaced_by=update_dict) def update( self, notetype: NotetypeDict, preserve_usn: bool = True, skip_checks: bool = False, ) -> None: "Add or update an existing model. Use .update_dict() instead." self._remove_from_cache(notetype["id"]) self.ensure_name_unique(notetype) notetype["id"] = self.col._backend.add_or_update_notetype( json=to_json_bytes(notetype), preserve_usn_and_mtime=preserve_usn, skip_checks=skip_checks, ) self.set_current(notetype) self._mutate_after_write(notetype) # @deprecated(replaced_by=update_dict) def save(self, notetype: NotetypeDict | None = None, **legacy_kwargs: bool) -> None: "Save changes made to provided note type." if not notetype: print_deprecation_warning( "col.models.save() should be passed the changed notetype" ) return self.update(notetype, preserve_usn=False) ================================================ FILE: pylib/anki/notes.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import copy from collections.abc import Sequence from typing import NewType import anki import anki.cards import anki.collection import anki.decks import anki.template from anki import hooks, notes_pb2 from anki._legacy import DeprecatedNamesMixin, deprecated from anki.consts import MODEL_STD from anki.models import NotetypeDict, NotetypeId, TemplateDict from anki.utils import join_fields DuplicateOrEmptyResult = notes_pb2.NoteFieldsCheckResponse.State NoteFieldsCheckResult = notes_pb2.NoteFieldsCheckResponse.State DefaultsForAdding = notes_pb2.DeckAndNotetype # types NoteId = NewType("NoteId", int) class Note(DeprecatedNamesMixin): # not currently exposed flags = 0 data = "" id: NoteId mid: NotetypeId def __init__( self, col: anki.collection.Collection, model: NotetypeDict | NotetypeId | None = None, id: NoteId | None = None, ) -> None: if model and id: raise Exception("only model or id should be provided") notetype_id = model["id"] if isinstance(model, dict) else model self.col = col.weakref() if id: # existing note self.id = id self.load() else: # new note for provided notetype self._load_from_backend_note(self.col._backend.new_note(notetype_id)) def load(self) -> None: note = self.col._backend.get_note(self.id) assert note self._load_from_backend_note(note) def _load_from_backend_note(self, note: notes_pb2.Note) -> None: self.id = NoteId(note.id) self.guid = note.guid self.mid = NotetypeId(note.notetype_id) self.mod = note.mtime_secs self.usn = note.usn self.tags = list(note.tags) self.fields = list(note.fields) self._fmap = self.col.models.field_map(self.note_type()) def _to_backend_note(self) -> notes_pb2.Note: hooks.note_will_flush(self) return notes_pb2.Note( id=self.id, guid=self.guid, notetype_id=self.mid, mtime_secs=self.mod, usn=self.usn, tags=self.tags, fields=self.fields, ) @deprecated(info="please use col.update_note()") def flush(self) -> None: """For an undo entry, use col.update_note() instead.""" if self.id == 0: raise Exception("can't flush a new note") self.col._backend.update_notes( notes=[self._to_backend_note()], skip_undo_entry=True ) def joined_fields(self) -> str: return join_fields(self.fields) def ephemeral_card( self, ord: int = 0, *, custom_note_type: NotetypeDict | None = None, custom_template: TemplateDict | None = None, fill_empty: bool = False, ) -> anki.cards.Card: card = anki.cards.Card(self.col) card.ord = ord card.did = anki.decks.DEFAULT_DECK_ID if custom_note_type is None: model = self.note_type() else: model = custom_note_type if model is None: raise NotImplementedError if custom_template is not None: template = custom_template elif model["type"] == MODEL_STD: template = model["tmpls"][ord] else: template = model["tmpls"][0] template = copy.copy(template) # may differ in cloze case template["ord"] = card.ord output = anki.template.TemplateRenderContext.from_card_layout( self, card, notetype=model, template=template, fill_empty=fill_empty, ).render() card.set_render_output(output) card._note = self return card def cards(self) -> list[anki.cards.Card]: return [self.col.get_card(id) for id in self.card_ids()] def card_ids(self) -> Sequence[anki.cards.CardId]: return self.col.card_ids_of_note(self.id) def note_type(self) -> NotetypeDict | None: return self.col.models.get(self.mid) _note_type = property(note_type) def cloze_numbers_in_fields(self) -> Sequence[int]: return self.col._backend.cloze_numbers_in_note(self._to_backend_note()) # Dict interface ################################################## def keys(self) -> list[str]: return list(self._fmap.keys()) def values(self) -> list[str]: return self.fields def items(self) -> list[tuple[str, str]]: return [(f["name"], self.fields[ord]) for ord, f in sorted(self._fmap.values())] def _field_index(self, key: str) -> int: try: return self._fmap[key][0] except Exception as exc: raise KeyError(key) from exc def __getitem__(self, key: str) -> str: return self.fields[self._field_index(key)] def __setitem__(self, key: str, value: str) -> None: self.fields[self._field_index(key)] = value def __contains__(self, key: str) -> bool: return key in self._fmap # Tags ################################################## def has_tag(self, tag: str) -> bool: return self.col.tags.in_list(tag, self.tags) def remove_tag(self, tag: str) -> None: rem = [tag_ for tag_ in self.tags if tag_.lower() == tag.lower()] for tag_ in rem: self.tags.remove(tag_) def add_tag(self, tag: str) -> None: "Add tag. Duplicates will be stripped on save." self.tags.append(tag) def string_tags(self) -> str: return self.col.tags.join(self.tags) def set_tags_from_str(self, tags: str) -> None: self.tags = self.col.tags.split(tags) # Unique/duplicate/cloze check ################################################## def fields_check(self) -> NoteFieldsCheckResult.V: return self.col._backend.note_fields_check(self._to_backend_note()).state dupeOrEmpty = duplicate_or_empty = fields_check Note.register_deprecated_aliases( delTag=Note.remove_tag, _fieldOrd=Note._field_index, model=Note.note_type ) ================================================ FILE: pylib/anki/py.typed ================================================ ================================================ FILE: pylib/anki/rsbackend.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # # The backend code has moved into _backend; this file exists only to avoid breaking # some add-ons. They should be updated to point to the correct location in the # future. # ruff: noqa: F401 from anki.decks import DeckTreeNode from anki.errors import InvalidInput, NotFoundError from anki.lang import TR from anki.lang import FormatTimeSpan as FormatTimeSpanContext from anki.utils import from_json_bytes, to_json_bytes ================================================ FILE: pylib/anki/scheduler/__init__.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import anki.scheduler.base as _base UnburyDeck = _base.UnburyDeck CongratsInfo = _base.CongratsInfo BuryOrSuspend = _base.BuryOrSuspend FilteredDeckForUpdate = _base.FilteredDeckForUpdate CustomStudyRequest = _base.CustomStudyRequest ================================================ FILE: pylib/anki/scheduler/base.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import anki import anki.collection from anki import decks_pb2, scheduler_pb2 from anki._legacy import DeprecatedNamesMixin from anki.cards import Card from anki.collection import OpChanges, OpChangesWithCount, OpChangesWithId from anki.config import Config SchedTimingToday = scheduler_pb2.SchedTimingTodayResponse CongratsInfo = scheduler_pb2.CongratsInfoResponse UnburyDeck = scheduler_pb2.UnburyDeckRequest BuryOrSuspend = scheduler_pb2.BuryOrSuspendCardsRequest CustomStudyRequest = scheduler_pb2.CustomStudyRequest CustomStudyDefaults = scheduler_pb2.CustomStudyDefaultsResponse ScheduleCardsAsNew = scheduler_pb2.ScheduleCardsAsNewRequest ScheduleCardsAsNewDefaults = scheduler_pb2.ScheduleCardsAsNewDefaultsResponse FilteredDeckForUpdate = decks_pb2.FilteredDeckForUpdate RepositionDefaults = scheduler_pb2.RepositionDefaultsResponse from collections.abc import Sequence from typing import overload from anki import config_pb2 from anki.cards import CardId from anki.consts import ( CARD_TYPE_NEW, NEW_CARDS_RANDOM, QUEUE_TYPE_DAY_LEARN_RELEARN, QUEUE_TYPE_LRN, QUEUE_TYPE_NEW, QUEUE_TYPE_PREVIEW, ) from anki.decks import DeckConfigDict, DeckId, DeckTreeNode from anki.notes import NoteId from anki.utils import ids2str, int_time class SchedulerBase(DeprecatedNamesMixin): "Actions shared between schedulers." version = 0 def __init__(self, col: anki.collection.Collection) -> None: self.col = col.weakref() def _timing_today(self) -> SchedTimingToday: return self.col._backend.sched_timing_today() @property def today(self) -> int: return self._timing_today().days_elapsed @property def day_cutoff(self) -> int: return self._timing_today().next_day_at def countIdx(self, card: Card) -> int: if card.queue in (QUEUE_TYPE_DAY_LEARN_RELEARN, QUEUE_TYPE_PREVIEW): return QUEUE_TYPE_LRN return card.queue # Deck list ########################################################################## @overload def deck_due_tree(self, top_deck_id: None = None) -> DeckTreeNode: ... @overload def deck_due_tree(self, top_deck_id: DeckId) -> DeckTreeNode | None: ... def deck_due_tree(self, top_deck_id: DeckId | None = None) -> DeckTreeNode | None: """Returns a tree of decks with counts. If top_deck_id provided, only the according subtree is returned.""" tree = self.col._backend.deck_tree(now=int_time()) if top_deck_id: return self.col.decks.find_deck_in_tree(tree, top_deck_id) return tree # Deck finished state & custom study ########################################################################## def congratulations_info(self) -> CongratsInfo: return self.col._backend.congrats_info() def have_buried_siblings(self) -> bool: return self.congratulations_info().have_sched_buried def have_manually_buried(self) -> bool: return self.congratulations_info().have_user_buried def have_buried(self) -> bool: info = self.congratulations_info() return info.have_sched_buried or info.have_user_buried def custom_study(self, request: CustomStudyRequest) -> OpChanges: return self.col._backend.custom_study(request) def custom_study_defaults(self, deck_id: DeckId) -> CustomStudyDefaults: return self.col._backend.custom_study_defaults(deck_id=deck_id) def extend_limits(self, new: int, rev: int) -> None: did = self.col.decks.current()["id"] self.col._backend.extend_limits(deck_id=did, new_delta=new, review_delta=rev) # fixme: only used by total_rev_for_current_deck and old deck stats; # schedv2 defines separate version def _deck_limit(self) -> str: return ids2str( self.col.decks.deck_and_child_ids(self.col.decks.get_current_id()) ) # Filtered deck handling ########################################################################## def rebuild_filtered_deck(self, deck_id: DeckId) -> OpChangesWithCount: return self.col._backend.rebuild_filtered_deck(deck_id) def empty_filtered_deck(self, deck_id: DeckId) -> OpChanges: return self.col._backend.empty_filtered_deck(deck_id) def get_or_create_filtered_deck(self, deck_id: DeckId) -> FilteredDeckForUpdate: return self.col._backend.get_or_create_filtered_deck(deck_id) def add_or_update_filtered_deck( self, deck: FilteredDeckForUpdate ) -> OpChangesWithId: return self.col._backend.add_or_update_filtered_deck(deck) def filtered_deck_order_labels(self) -> Sequence[str]: return self.col._backend.filtered_deck_order_labels() # Suspending & burying ########################################################################## def unsuspend_cards(self, ids: Sequence[CardId]) -> OpChanges: return self.col._backend.restore_buried_and_suspended_cards(ids) def unbury_cards(self, ids: Sequence[CardId]) -> OpChanges: return self.col._backend.restore_buried_and_suspended_cards(ids) def unbury_deck( self, deck_id: DeckId, mode: UnburyDeck.Mode.V = UnburyDeck.ALL, ) -> OpChanges: return self.col._backend.unbury_deck(deck_id=deck_id, mode=mode) def suspend_cards(self, ids: Sequence[CardId]) -> OpChangesWithCount: return self.col._backend.bury_or_suspend_cards( card_ids=ids, note_ids=[], mode=BuryOrSuspend.SUSPEND ) def suspend_notes(self, ids: Sequence[NoteId]) -> OpChangesWithCount: return self.col._backend.bury_or_suspend_cards( card_ids=[], note_ids=ids, mode=BuryOrSuspend.SUSPEND ) def bury_cards( self, ids: Sequence[CardId], manual: bool = True ) -> OpChangesWithCount: if manual: mode = BuryOrSuspend.BURY_USER else: mode = BuryOrSuspend.BURY_SCHED return self.col._backend.bury_or_suspend_cards( card_ids=ids, note_ids=[], mode=mode ) def bury_notes(self, note_ids: Sequence[NoteId]) -> OpChangesWithCount: return self.col._backend.bury_or_suspend_cards( card_ids=[], note_ids=note_ids, mode=BuryOrSuspend.BURY_USER ) # Resetting/rescheduling ########################################################################## def schedule_cards_as_new( self, card_ids: Sequence[CardId], *, restore_position: bool = False, reset_counts: bool = False, context: ScheduleCardsAsNew.Context.V | None = None, ) -> OpChanges: "Place cards back into the new queue." request = ScheduleCardsAsNew( card_ids=card_ids, log=True, restore_position=restore_position, reset_counts=reset_counts, context=context, ) return self.col._backend.schedule_cards_as_new(request) def schedule_cards_as_new_defaults( self, context: ScheduleCardsAsNew.Context.V ) -> ScheduleCardsAsNewDefaults: return self.col._backend.schedule_cards_as_new_defaults(context) def set_due_date( self, card_ids: Sequence[CardId], days: str, config_key: Config.String.V | None = None, ) -> OpChanges: """Set cards to be due in `days`, turning them into review cards if necessary. `days` can be of the form '5' or '5-7' If `config_key` is provided, provided days will be remembered in config.""" key: config_pb2.OptionalStringConfigKey | None if config_key is not None: key = config_pb2.OptionalStringConfigKey(key=config_key) else: key = None return self.col._backend.set_due_date( card_ids=card_ids, days=days, # this value is optional; the auto-generated typing is wrong config_key=key, # type: ignore ) def reset_cards(self, ids: list[CardId]) -> None: "Completely reset cards for export." sids = ids2str(ids) assert self.col.db # we want to avoid resetting due number of existing new cards on export non_new = self.col.db.list( f"select id from cards where id in %s and (queue != {QUEUE_TYPE_NEW} or type != {CARD_TYPE_NEW})" % sids ) # reset all cards self.col.db.execute( f"update cards set reps=0,lapses=0,odid=0,odue=0,queue={QUEUE_TYPE_NEW}" " where id in %s" % sids ) # and forget any non-new cards, changing their due numbers request = ScheduleCardsAsNew(card_ids=non_new, log=False, restore_position=True) self.col._backend.schedule_cards_as_new(request) # Repositioning new cards ########################################################################## def reposition_new_cards( self, card_ids: Sequence[CardId], starting_from: int, step_size: int, randomize: bool, shift_existing: bool, ) -> OpChangesWithCount: return self.col._backend.sort_cards( card_ids=card_ids, starting_from=starting_from, step_size=step_size, randomize=randomize, shift_existing=shift_existing, ) def reposition_defaults(self) -> RepositionDefaults: return self.col._backend.reposition_defaults() def randomize_cards(self, did: DeckId) -> None: self.col._backend.sort_deck(deck_id=did, randomize=True) def order_cards(self, did: DeckId) -> None: self.col._backend.sort_deck(deck_id=did, randomize=False) def resort_conf(self, conf: DeckConfigDict) -> None: for did in self.col.decks.decks_using_config(conf): if conf["new"]["order"] == 0: self.randomize_cards(did) else: self.order_cards(did) # for post-import def maybe_randomize_deck(self, did: DeckId | None = None) -> None: if not did: did = self.col.decks.selected() conf = self.col.decks.config_dict_for_deck_id(did) # in order due? if conf["new"]["order"] == NEW_CARDS_RANDOM: self.randomize_cards(did) def _legacy_sort_cards( self, cids: list[CardId], start: int = 1, step: int = 1, shuffle: bool = False, shift: bool = False, ) -> None: self.reposition_new_cards(cids, start, step, shuffle, shift) SchedulerBase.register_deprecated_attributes( sortCards=(SchedulerBase._legacy_sort_cards, SchedulerBase.reposition_new_cards) ) ================================================ FILE: pylib/anki/scheduler/dummy.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from anki.cards import Card from anki.decks import DeckId from anki.scheduler.legacy import SchedulerBaseWithLegacy class DummyScheduler(SchedulerBaseWithLegacy): reps = 0 def reset(self) -> None: pass def getCard(self) -> Card | None: raise Exception("v1/v2 scheduler no longer supported") def answerCard(self, card: Card, ease: int) -> None: raise Exception("v1/v2 scheduler no longer supported") def _is_finished(self) -> bool: return False @property def active_decks(self) -> list[DeckId]: return [] def counts(self) -> tuple[int, int, int]: return (0, 0, 0) ================================================ FILE: pylib/anki/scheduler/legacy.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from anki._legacy import deprecated from anki.cards import Card, CardId from anki.consts import ( CARD_TYPE_RELEARNING, QUEUE_TYPE_DAY_LEARN_RELEARN, QUEUE_TYPE_REV, ) from anki.decks import DeckConfigDict, DeckId from anki.notes import NoteId from anki.scheduler.base import SchedulerBase, UnburyDeck from anki.utils import from_json_bytes, ids2str class SchedulerBaseWithLegacy(SchedulerBase): "Legacy aliases and helpers. These will go away in the future." def reschedCards( self, card_ids: list[CardId], min_interval: int, max_interval: int ) -> None: self.set_due_date(card_ids, f"{min_interval}-{max_interval}!") def buryNote(self, nid: NoteId) -> None: note = self.col.get_note(nid) self.bury_cards(note.card_ids()) def unburyCards(self) -> None: print("please use unbury_cards() or unbury_deck() instead of unburyCards()") self.unbury_deck(self.col.decks.get_current_id()) def unburyCardsForDeck(self, type: str = "all") -> None: print("please use unbury_deck() instead of unburyCardsForDeck()") if type == "all": mode = UnburyDeck.ALL elif type == "manual": mode = UnburyDeck.USER_ONLY else: # elif type == "siblings": mode = UnburyDeck.SCHED_ONLY self.unbury_deck(self.col.decks.get_current_id(), mode) def finishedMsg(self) -> str: print("finishedMsg() is obsolete") return "" def _nextDueMsg(self) -> str: print("_nextDueMsg() is obsolete") return "" def rebuildDyn(self, did: DeckId | None = None) -> int | None: did = did or self.col.decks.selected() count = self.rebuild_filtered_deck(did).count or None if not count: return None # and change to our new deck self.col.decks.select(did) return count def emptyDyn(self, did: DeckId | None, lim: str | None = None) -> None: if lim is None: self.empty_filtered_deck(did) return queue = f""" queue = (case when queue < 0 then queue when type in (1,{CARD_TYPE_RELEARNING}) then (case when (case when odue then odue else due end) > 1000000000 then 1 else {QUEUE_TYPE_DAY_LEARN_RELEARN} end) else type end) """ self.col.db.execute( f""" update cards set did = odid, {queue}, due = (case when odue>0 then odue else due end), odue = 0, odid = 0, usn = ? where {lim}""", self.col.usn(), ) def remFromDyn(self, cids: list[CardId]) -> None: self.emptyDyn(None, f"id in {ids2str(cids)} and odid") # used by v2 scheduler and some add-ons def update_stats( self, deck_id: DeckId, new_delta: int = 0, review_delta: int = 0, milliseconds_delta: int = 0, ) -> None: self.col._backend.update_stats( deck_id=deck_id, new_delta=new_delta, review_delta=review_delta, millisecond_delta=milliseconds_delta, ) def _updateStats(self, card: Card, type: str, cnt: int = 1) -> None: did = card.did if type == "new": self.update_stats(did, new_delta=cnt) elif type == "rev": self.update_stats(did, review_delta=cnt) elif type == "time": self.update_stats(did, milliseconds_delta=cnt) def deckDueTree(self) -> list: "List of (base name, did, rev, lrn, new, children)" print( "deckDueTree() is deprecated; use decks.deck_tree() for a tree without counts, or sched.deck_due_tree()" ) return from_json_bytes(self.col._backend.deck_tree_legacy())[5] @deprecated(info="no longer used by Anki; will be removed in the future") def total_rev_for_current_deck(self) -> int: assert self.col.db return self.col.db.scalar( f""" select count() from cards where id in ( select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? limit 9999)""" % self._deck_limit(), self.today, ) def answerButtons(self, card: Card) -> int: return 4 # legacy in v3 but used by unit tests; redefined in v2/v1 def _cardConf(self, card: Card) -> DeckConfigDict: return self.col.decks.config_dict_for_deck_id(card.did) def _fuzzIvlRange(self, ivl: int) -> tuple[int, int]: return (ivl, ivl) # simple aliases unsuspendCards = SchedulerBase.unsuspend_cards buryCards = SchedulerBase.bury_cards suspendCards = SchedulerBase.suspend_cards forgetCards = SchedulerBase.schedule_cards_as_new ================================================ FILE: pylib/anki/scheduler/v3.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """ The V3/2021 scheduler. https://faqs.ankiweb.net/the-2021-scheduler.html It uses the same DB schema as the V2 scheduler, and 'schedVer' remains as '2' internally. """ from __future__ import annotations from collections.abc import Sequence from typing import Any, Literal from anki import frontend_pb2, scheduler_pb2 from anki._legacy import deprecated from anki.cards import Card from anki.collection import OpChanges from anki.consts import * from anki.decks import DeckId from anki.errors import DBError from anki.scheduler.legacy import SchedulerBaseWithLegacy from anki.types import assert_exhaustive from anki.utils import int_time QueuedCards = scheduler_pb2.QueuedCards SchedulingState = scheduler_pb2.SchedulingState SchedulingStates = scheduler_pb2.SchedulingStates SchedulingContext = scheduler_pb2.SchedulingContext SchedulingStatesWithContext = frontend_pb2.SchedulingStatesWithContext SetSchedulingStatesRequest = frontend_pb2.SetSchedulingStatesRequest CardAnswer = scheduler_pb2.CardAnswer class Scheduler(SchedulerBaseWithLegacy): version = 3 # don't rely on this, it will likely be removed in the future reps = 0 # Fetching the next card ########################################################################## def get_queued_cards( self, *, fetch_limit: int = 1, intraday_learning_only: bool = False, ) -> QueuedCards: "Returns zero or more pending cards, and the remaining counts. Idempotent." return self.col._backend.get_queued_cards( fetch_limit=fetch_limit, intraday_learning_only=intraday_learning_only ) def describe_next_states(self, next_states: SchedulingStates) -> Sequence[str]: "Labels for each of the answer buttons." return self.col._backend.describe_next_states(next_states) # Answering a card ########################################################################## def build_answer( self, *, card: Card, states: SchedulingStates, rating: CardAnswer.Rating.V, ) -> CardAnswer: "Build input for answer_card()." if rating == CardAnswer.AGAIN: new_state = states.again elif rating == CardAnswer.HARD: new_state = states.hard elif rating == CardAnswer.GOOD: new_state = states.good elif rating == CardAnswer.EASY: new_state = states.easy else: raise Exception("invalid rating") return CardAnswer( card_id=card.id, current_state=states.current, new_state=new_state, rating=rating, answered_at_millis=int_time(1000), milliseconds_taken=card.time_taken(capped=False), ) def answer_card(self, input: CardAnswer) -> OpChanges: "Update card to provided state, and remove it from queue." self.reps += 1 op_bytes = self.col._backend.answer_card_raw(input.SerializeToString()) return OpChanges.FromString(op_bytes) def state_is_leech(self, new_state: SchedulingState) -> bool: "True if new state marks the card as a leech." return self.col._backend.state_is_leech(new_state) # Fetching the next card (legacy API) ########################################################################## @deprecated(info="no longer required") def reset(self) -> None: # backend automatically resets queues as operations are performed pass def getCard(self) -> Card | None: """Fetch the next card from the queue. None if finished.""" try: queued_card = self.get_queued_cards().cards[0] except IndexError: return None card = Card(self.col) card._load_from_backend_card(queued_card.card) card.start_timer() return card def _is_finished(self) -> bool: "Don't use this, it is a stop-gap until this code is refactored." return not self.get_queued_cards().cards def counts(self, card: Card | None = None) -> tuple[int, int, int]: info = self.get_queued_cards() return (info.new_count, info.learning_count, info.review_count) @property def newCount(self) -> int: return self.counts()[0] @property def lrnCount(self) -> int: return self.counts()[1] @property def reviewCount(self) -> int: return self.counts()[2] def nextIvlStr(self, card: Card, ease: int, short: bool = False) -> str: "Return the next interval for CARD as a string." states = self.col._backend.get_scheduling_states(card.id) return self.col._backend.describe_next_states(states)[ease - 1] # Answering a card (legacy API) ########################################################################## def answerCard(self, card: Card, ease: Literal[1, 2, 3, 4]) -> OpChanges: if ease == BUTTON_ONE: rating = CardAnswer.AGAIN elif ease == BUTTON_TWO: rating = CardAnswer.HARD elif ease == BUTTON_THREE: rating = CardAnswer.GOOD elif ease == BUTTON_FOUR: rating = CardAnswer.EASY else: raise Exception("invalid ease") states = self.col._backend.get_scheduling_states(card.id) changes = self.answer_card( self.build_answer(card=card, states=states, rating=rating) ) # tests assume card will be mutated, so we need to reload it card.load() return changes # Next times (legacy API) ########################################################################## # fixme: move these into tests_schedv2 in the future def _interval_for_state(self, state: scheduler_pb2.SchedulingState) -> int: kind = state.WhichOneof("kind") if kind == "normal": return self._interval_for_normal_state(state.normal) elif kind == "filtered": return self._interval_for_filtered_state(state.filtered) else: assert_exhaustive(kind) return 0 def _interval_for_normal_state( self, normal: scheduler_pb2.SchedulingState.Normal ) -> int: kind = normal.WhichOneof("kind") if kind == "new": return 0 elif kind == "review": return normal.review.scheduled_days * 86400 elif kind == "learning": return normal.learning.scheduled_secs elif kind == "relearning": return normal.relearning.learning.scheduled_secs else: assert_exhaustive(kind) return 0 def _interval_for_filtered_state( self, filtered: scheduler_pb2.SchedulingState.Filtered ) -> int: kind = filtered.WhichOneof("kind") if kind == "preview": return filtered.preview.scheduled_secs elif kind == "rescheduling": return self._interval_for_normal_state(filtered.rescheduling.original_state) else: assert_exhaustive(kind) return 0 def nextIvl(self, card: Card, ease: int) -> Any: "Don't use this - it is only required by tests, and will be moved in the future." states = self.col._backend.get_scheduling_states(card.id) if ease == BUTTON_ONE: new_state = states.again elif ease == BUTTON_TWO: new_state = states.hard elif ease == BUTTON_THREE: new_state = states.good elif ease == BUTTON_FOUR: new_state = states.easy else: raise Exception("invalid ease") return self._interval_for_state(new_state) # Other legacy ################### # called by col.decks.active(), which add-ons are using @property def active_decks(self) -> list[DeckId]: try: return self.col.db.list("select id from active_decks") except DBError: return [] ================================================ FILE: pylib/anki/sound.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """ Sound/TTS references extracted from card text. These can be accessed via eg card.question_av_tags() """ from __future__ import annotations import os import os.path import re from dataclasses import dataclass from typing import Union from anki import hooks @dataclass class TTSTag: """Records information about a text to speech tag. See tts.py for more information. """ field_text: str lang: str voices: list[str] speed: float # each arg should be in the form 'foo=bar' other_args: list[str] @dataclass class SoundOrVideoTag: """Contains the filename inside a [sound:...] tag. Video files also use [sound:...]. SECURITY: We should only ever construct this with basename(filename), as passing arbitrary paths to mpv from a shared deck is a security issue. Anki add-ons can supply an absolute file path to play any file on disk using the built-in media player. """ filename: str def path(self, media_folder: str) -> str: "Prepend the media folder to the filename." if os.path.basename(self.filename) == self.filename: # Path in the current collection's media folder. # Turn it into a fully-qualified path so mpv can find it, and to # ensure the filename doesn't get treated like a non-file scheme. head, tail = media_folder, self.filename else: # Add-ons can use absolute paths to play arbitrary files on disk. # Example: sound.av_player.play_tags([SoundOrVideoTag("/path/to/file")]) head, tail = os.path.split(os.path.abspath(self.filename)) tail = hooks.media_file_filter(tail) return os.path.join(head, tail) # note this does not include image tags, which are handled with HTML. AVTag = Union[SoundOrVideoTag, TTSTag] AV_REF_RE = re.compile(r"\[anki:(play:(.):(\d+))\]") def strip_av_refs(text: str) -> str: return AV_REF_RE.sub("", text) ================================================ FILE: pylib/anki/stats.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import json import random import time from collections.abc import Sequence from typing import Any import anki.cards import anki.collection from anki.consts import * from anki.lang import FormatTimeSpan from anki.utils import base62, ids2str # Card stats ########################################################################## _legacy_nightmode = False def _legacy_card_stats( col: anki.collection.Collection, card_id: anki.cards.CardId, include_revlog: bool ) -> str: "A quick hack to preserve compatibility with the old HTML string API." random_id = f"cardinfo-{base62(random.randint(0, 2**64 - 1))}" varName = random_id.replace("-", "") return f"""
""" class CardStats: """Do not use - this class is only left around for backwards compatibility.""" def __init__(self, col: anki.collection.Collection, card: anki.cards.Card) -> None: if col: self.col = col.weakref() self.card = card self.txt = "" def report(self, include_revlog: bool = False) -> str: return _legacy_card_stats(self.col, self.card.id, include_revlog) # legacy def addLine(self, k: str, v: int | str) -> None: self.txt += self.makeLine(k, v) def makeLine(self, k: str, v: str | int) -> str: txt = "" txt += f"{k}{v}" return txt def date(self, tm: float) -> str: return time.strftime("%Y-%m-%d", time.localtime(tm)) def time(self, tm: float) -> str: return self.col.format_timespan(tm, context=FormatTimeSpan.PRECISE) # Collection stats ########################################################################## PERIOD_MONTH = 0 PERIOD_YEAR = 1 PERIOD_LIFE = 2 colYoung = "#7c7" colMature = "#070" colCum = "rgba(0,0,0,0.9)" colLearn = "#00F" colRelearn = "#c00" colCram = "#ff0" colIvl = "#077" colHour = "#ccc" colTime = "#770" colUnseen = "#000" colSusp = "#ff0" class CollectionStats: def __init__(self, col: anki.collection.Collection) -> None: self.col = col.weakref() self._stats = None self.type = PERIOD_MONTH self.width = 600 self.height = 200 self.wholeCollection = False # assumes jquery & plot are available in document def report(self, type: int = PERIOD_MONTH) -> str: # 0=month, 1=year, 2=deck life self.type = type from .statsbg import bg txt = self.css % bg txt += self._section(self.todayStats()) txt += self._section(self.dueGraph()) txt += self.repsGraphs() txt += self._section(self.introductionGraph()) txt += self._section(self.ivlGraph()) txt += self._section(self.hourGraph()) txt += self._section(self.easeGraph()) txt += self._section(self.cardGraph()) txt += self._section(self.footer()) return "
%s
" % txt def _section(self, txt: str) -> str: return "
%s
" % txt css = """ """ # Today stats ###################################################################### def todayStats(self) -> str: b = self._title("Today") # studied today lim = self._revlogLimit() if lim: lim = " and " + lim cards, thetime, failed, lrn, rev, relrn, filt = self.col.db.first( f""" select count(), sum(time)/1000, sum(case when ease = 1 then 1 else 0 end), /* failed */ sum(case when type = {REVLOG_LRN} then 1 else 0 end), /* learning */ sum(case when type = {REVLOG_REV} then 1 else 0 end), /* review */ sum(case when type = {REVLOG_RELRN} then 1 else 0 end), /* relearn */ sum(case when type = {REVLOG_CRAM} then 1 else 0 end) /* filter */ from revlog where type != {REVLOG_RESCHED} and id > ? """ + lim, (self.col.sched.day_cutoff - 86400) * 1000, ) cards = cards or 0 thetime = thetime or 0 failed = failed or 0 lrn = lrn or 0 rev = rev or 0 relrn = relrn or 0 filt = filt or 0 # studied def bold(s: str) -> str: return "" + str(s) + "" if cards: b += self.col._backend.studied_today_message( cards=cards, seconds=float(thetime) ) # again/pass count b += "
" + "Again count: %s" % bold(str(failed)) if cards: b += " " + "(%s correct)" % bold( "%0.1f%%" % ((1 - failed / float(cards)) * 100) ) # type breakdown b += "
" b += "Learn: %(a)s, Review: %(b)s, Relearn: %(c)s, Filtered: %(d)s" % dict( a=bold(str(lrn)), b=bold(str(rev)), c=bold(str(relrn)), d=bold(str(filt)), ) # mature today mcnt, msum = self.col.db.first( """ select count(), sum(case when ease = 1 then 0 else 1 end) from revlog where lastIvl >= 21 and id > ?""" + lim, (self.col.sched.day_cutoff - 86400) * 1000, ) b += "
" if mcnt: b += "Correct answers on mature cards: %(a)d/%(b)d (%(c).1f%%)" % dict( a=msum, b=mcnt, c=(msum / float(mcnt) * 100) ) else: b += "No mature cards were studied today." else: b += "No cards have been studied today." return b # Due and cumulative due ###################################################################### def get_start_end_chunk(self, by: str = "review") -> tuple[int, int | None, int]: start = 0 if self.type == PERIOD_MONTH: end, chunk = 31, 1 elif self.type == PERIOD_YEAR: end, chunk = 52, 7 else: # self.type == 2: end = None if self._deckAge(by) <= 100: chunk = 1 elif self._deckAge(by) <= 700: chunk = 7 else: chunk = 31 return start, end, chunk def dueGraph(self) -> str: start, end, chunk = self.get_start_end_chunk() d = self._due(start, end, chunk) yng = [] mtr = [] tot = 0 totd = [] for day in d: yng.append((day[0], day[1])) mtr.append((day[0], day[2])) tot += day[1] + day[2] totd.append((day[0], tot)) data: Any = [ dict(data=mtr, color=colMature, label="Mature"), dict(data=yng, color=colYoung, label="Young"), ] if len(totd) > 1: data.append( dict( data=totd, color=colCum, label="Cumulative", yaxis=2, bars={"show": False}, lines=dict(show=True), stack=False, ) ) txt = self._title("Forecast", "The number of reviews due in the future.") xaxis = dict(tickDecimals=0, min=-0.5) if end is not None: xaxis["max"] = end - 0.5 txt += self._graph( id="due", data=data, xunit=chunk, ylabel2="Cumulative Cards", conf=dict( xaxis=xaxis, yaxes=[dict(min=0), dict(min=0, tickDecimals=0, position="right")], ), ) txt += self._dueInfo(tot, len(totd) * chunk) return txt def _dueInfo(self, tot: int, num: int) -> str: i: list[str] = [] self._line( i, "Total", self.col.tr.statistics_reviews(reviews=tot), ) self._line(i, "Average", self._avgDay(tot, num, "reviews")) tomorrow = self.col.db.scalar( f""" select count() from cards where did in %s and queue in ({QUEUE_TYPE_REV},{QUEUE_TYPE_DAY_LEARN_RELEARN}) and due = ?""" % self._limit(), self.col.sched.today + 1, ) tomorrow = "%d cards" % tomorrow self._line(i, "Due tomorrow", tomorrow) return self._lineTbl(i) def _due( self, start: int | None = None, end: int | None = None, chunk: int = 1 ) -> Any: lim = "" if start is not None: lim += " and due-%d >= %d" % (self.col.sched.today, start) if end is not None: lim += " and day < %d" % end return self.col.db.all( f""" select (due-?)/? as day, sum(case when ivl < 21 then 1 else 0 end), -- yng sum(case when ivl >= 21 then 1 else 0 end) -- mtr from cards where did in %s and queue in ({QUEUE_TYPE_REV},{QUEUE_TYPE_DAY_LEARN_RELEARN}) %s group by day order by day""" % (self._limit(), lim), self.col.sched.today, chunk, ) # Added, reps and time spent ###################################################################### def introductionGraph(self) -> str: start, days, chunk = self.get_start_end_chunk() data = self._added(days, chunk) if not data: return "" conf: dict[str, Any] = dict( xaxis=dict(tickDecimals=0, max=0.5), yaxes=[dict(min=0), dict(position="right", min=0)], ) if days is not None: conf["xaxis"]["min"] = -days + 0.5 def plot(id: str, data: Any, ylabel: str, ylabel2: str) -> str: return self._graph( id, data=data, conf=conf, xunit=chunk, ylabel=ylabel, ylabel2=ylabel2 ) # graph repdata, repsum = self._splitRepData(data, ((1, colLearn, ""),)) txt = self._title("Added", "The number of new cards you have added.") txt += plot("intro", repdata, ylabel="Cards", ylabel2="Cumulative Cards") # total and per day average tot = sum(i[1] for i in data) period = self._periodDays() if not period: # base off date of earliest added card period = self._deckAge("add") i: list[str] = [] self._line(i, "Total", "%d cards" % tot) self._line(i, "Average", self._avgDay(tot, period, "cards")) txt += self._lineTbl(i) return txt def repsGraphs(self) -> str: start, days, chunk = self.get_start_end_chunk() data = self._done(days, chunk) if not data: return "" conf: dict[str, Any] = dict( xaxis=dict(tickDecimals=0, max=0.5), yaxes=[dict(min=0), dict(position="right", min=0)], ) if days is not None: conf["xaxis"]["min"] = -days + 0.5 def plot(id: str, data: Any, ylabel: str, ylabel2: str) -> str: return self._graph( id, data=data, conf=conf, xunit=chunk, ylabel=ylabel, ylabel2=ylabel2 ) # reps (repdata, repsum) = self._splitRepData( data, ( (3, colMature, "Mature"), (2, colYoung, "Young"), (4, colRelearn, "Relearn"), (1, colLearn, "Learn"), (5, colCram, "Cram"), ), ) txt1 = self._title("Review Count", "The number of questions you have answered.") txt1 += plot("reps", repdata, ylabel="Answers", ylabel2="Cumulative Answers") (daysStud, fstDay) = self._daysStudied() rep, tot = self._ansInfo(repsum, daysStud, fstDay, "reviews") txt1 += rep # time (timdata, timsum) = self._splitRepData( data, ( (8, colMature, "Mature"), (7, colYoung, "Young"), (9, colRelearn, "Relearn"), (6, colLearn, "Learn"), (10, colCram, "Cram"), ), ) if self.type == PERIOD_MONTH: t = "Minutes" convHours = False else: t = "Hours" convHours = True txt2 = self._title("Review Time", "The time taken to answer the questions.") txt2 += plot("time", timdata, ylabel=t, ylabel2="Cumulative %s" % t) rep, tot2 = self._ansInfo( timsum, daysStud, fstDay, "minutes", convHours, total=tot ) txt2 += rep return self._section(txt1) + self._section(txt2) def _ansInfo( self, totd: list[tuple[int, float]], studied: int, first: int, unit: str, convHours: bool = False, total: int | None = None, ) -> tuple[str, int]: assert totd tot = totd[-1][1] period = self._periodDays() if not period: # base off earliest repetition date period = self._deckAge("review") i: list[str] = [] self._line( i, "Days studied", "%(pct)d%% (%(x)s of %(y)s)" % dict(x=studied, y=period, pct=studied / float(period) * 100), bold=False, ) if convHours: tunit = "hours" else: tunit = unit # T: unit: can be hours, minutes, reviews... tot: the number of unit. self._line(i, "Total", "%(tot)s %(unit)s" % dict(unit=tunit, tot=int(tot))) if convHours: # convert to minutes tot *= 60 self._line(i, "Average for days studied", self._avgDay(tot, studied, unit)) if studied != period: # don't display if you did study every day self._line(i, "If you studied every day", self._avgDay(tot, period, unit)) if total and tot: perMin = total / float(tot) average_secs = (tot * 60) / total self._line( i, "Average answer time", self.col.tr.statistics_average_answer_time( average_seconds=average_secs, cards_per_minute=perMin ), ) return self._lineTbl(i), int(tot) def _splitRepData( self, data: list[tuple[Any, ...]], spec: Sequence[tuple[int, str, str]], ) -> tuple[list[dict[str, Any]], list[tuple[Any, Any]]]: sep: dict[int, Any] = {} totcnt = {} totd: dict[int, Any] = {} alltot = [] allcnt: float = 0 for n, col, lab in spec: totcnt[n] = 0.0 totd[n] = [] for row in data: for n, col, lab in spec: if n not in sep: sep[n] = [] sep[n].append((row[0], row[n])) totcnt[n] += row[n] allcnt += row[n] totd[n].append((row[0], totcnt[n])) alltot.append((row[0], allcnt)) ret = [] for n, col, lab in spec: if len(totd[n]) and totcnt[n]: # bars ret.append(dict(data=sep[n], color=col, label=lab)) # lines ret.append( dict( data=totd[n], color=col, label=None, yaxis=2, bars={"show": False}, lines=dict(show=True), stack=-n, ) ) return (ret, alltot) def _added(self, num: int | None = 7, chunk: int = 1) -> Any: lims = [] if num is not None: lims.append( "id > %d" % ((self.col.sched.day_cutoff - (num * chunk * 86400)) * 1000) ) lims.append("did in %s" % self._limit()) if lims: lim = "where " + " and ".join(lims) else: lim = "" if self.type == PERIOD_MONTH: tf = 60.0 # minutes else: tf = 3600.0 # hours return self.col.db.all( """ select (cast((id/1000.0 - ?) / 86400.0 as int))/? as day, count(id) from cards %s group by day order by day""" % lim, self.col.sched.day_cutoff, chunk, ) def _done(self, num: int | None = 7, chunk: int = 1) -> Any: lims = [] if num is not None: lims.append( "id > %d" % ((self.col.sched.day_cutoff - (num * chunk * 86400)) * 1000) ) lim = self._revlogLimit() if lim: lims.append(lim) if lims: lim = "where " + " and ".join(lims) else: lim = "" if self.type == PERIOD_MONTH: tf = 60.0 # minutes else: tf = 3600.0 # hours return self.col.db.all( f""" select (cast((id/1000.0 - ?) / 86400.0 as int))/? as day, sum(case when type = {REVLOG_LRN} then 1 else 0 end), -- lrn count sum(case when type = {REVLOG_REV} and lastIvl < 21 then 1 else 0 end), -- yng count sum(case when type = {REVLOG_REV} and lastIvl >= 21 then 1 else 0 end), -- mtr count sum(case when type = {REVLOG_RELRN} then 1 else 0 end), -- lapse count sum(case when type = {REVLOG_CRAM} then 1 else 0 end), -- cram count sum(case when type = {REVLOG_LRN} then time/1000.0 else 0 end)/?, -- lrn time -- yng + mtr time sum(case when type = {REVLOG_REV} and lastIvl < 21 then time/1000.0 else 0 end)/?, sum(case when type = {REVLOG_REV} and lastIvl >= 21 then time/1000.0 else 0 end)/?, sum(case when type = {REVLOG_RELRN} then time/1000.0 else 0 end)/?, -- lapse time sum(case when type = {REVLOG_CRAM} then time/1000.0 else 0 end)/? -- cram time from revlog %s group by day order by day""" % lim, self.col.sched.day_cutoff, chunk, tf, tf, tf, tf, tf, ) def _daysStudied(self) -> Any: lims = [] num = self._periodDays() if num: lims.append( "id > %d" % ((self.col.sched.day_cutoff - (num * 86400)) * 1000) ) rlim = self._revlogLimit() if rlim: lims.append(rlim) if lims: lim = "where " + " and ".join(lims) else: lim = "" ret = self.col.db.first( """ select count(), abs(min(day)) from (select (cast((id/1000 - ?) / 86400.0 as int)+1) as day from revlog %s group by day order by day)""" % lim, self.col.sched.day_cutoff, ) assert ret return ret # Intervals ###################################################################### def ivlGraph(self) -> str: (ivls, all, avg, max_), chunk = self._ivls() tot = 0 totd = [] if not ivls or not all: return "" for grp, cnt in ivls: tot += cnt totd.append((grp, tot / float(all) * 100)) if self.type == PERIOD_MONTH: ivlmax = 31 elif self.type == PERIOD_YEAR: ivlmax = 52 else: ivlmax = max(5, ivls[-1][0]) txt = self._title("Intervals", "Delays until reviews are shown again.") txt += self._graph( id="ivl", ylabel2="Percentage", xunit=chunk, data=[ dict(data=ivls, color=colIvl), dict( data=totd, color=colCum, yaxis=2, bars={"show": False}, lines=dict(show=True), stack=False, ), ], conf=dict( xaxis=dict(min=-0.5, max=ivlmax + 0.5), yaxes=[dict(), dict(position="right", max=105)], ), ) i: list[str] = [] self._line(i, "Average interval", self.col.format_timespan(avg * 86400)) self._line(i, "Longest interval", self.col.format_timespan(max_ * 86400)) return txt + self._lineTbl(i) def _ivls(self) -> tuple[list[Any], int]: start, end, chunk = self.get_start_end_chunk() lim = "and grp <= %d" % end if end else "" data = [ self.col.db.all( f""" select ivl / ? as grp, count() from cards where did in %s and queue = {QUEUE_TYPE_REV} %s group by grp order by grp""" % (self._limit(), lim), chunk, ) ] return ( data + list( self.col.db.first( f""" select count(), avg(ivl), max(ivl) from cards where did in %s and queue = {QUEUE_TYPE_REV}""" % self._limit() ) ), chunk, ) # Eases ###################################################################### def easeGraph(self) -> str: # 3 + 4 + 4 + spaces on sides and middle = 15 # yng starts at 1+3+1 = 5 # mtr starts at 5+4+1 = 10 d: dict[str, list] = {"lrn": [], "yng": [], "mtr": []} types = ("lrn", "yng", "mtr") eases = self._eases() for type, ease, cnt in eases: if type == CARD_TYPE_LRN: ease += 5 elif type == CARD_TYPE_REV: ease += 10 n = types[type] d[n].append((ease, cnt)) ticks = [ [1, 1], [2, 2], [3, 3], # [4,4] [6, 1], [7, 2], [8, 3], [9, 4], [11, 1], [12, 2], [13, 3], [14, 4], ] ticks.insert(3, [4, 4]) txt = self._title( "Answer Buttons", "The number of times you have pressed each button." ) txt += self._graph( id="ease", data=[ dict(data=d["lrn"], color=colLearn, label="Learning"), dict(data=d["yng"], color=colYoung, label="Young"), dict(data=d["mtr"], color=colMature, label="Mature"), ], type="bars", conf=dict(xaxis=dict(ticks=ticks, min=0, max=15)), ylabel="Answers", ) txt += self._easeInfo(eases) return txt def _easeInfo(self, eases: list[tuple[int, int, int]]) -> str: types = {PERIOD_MONTH: [0, 0], PERIOD_YEAR: [0, 0], PERIOD_LIFE: [0, 0]} for type, ease, cnt in eases: if ease == 1: types[type][0] += cnt else: types[type][1] += cnt i = [] for type in range(3): (bad, good) = types[type] tot = bad + good try: pct = good / float(tot) * 100 except Exception: pct = 0 i.append( "Correct: %(pct)0.2f%%
(%(good)d of %(tot)d)" % dict(pct=pct, good=good, tot=tot) ) return ( """
""" % self.width + "".join(i) + "
" ) def _eases(self) -> Any: lims = [] lim = self._revlogLimit() if lim: lims.append(lim) days = self._periodDays() if days is not None: lims.append( "id > %d" % ((self.col.sched.day_cutoff - (days * 86400)) * 1000) ) if lims: lim = "and " + " and ".join(lims) else: lim = "" ease4repl = "ease" return self.col.db.all( f""" select (case when type in ({REVLOG_LRN},{REVLOG_RELRN}) then 0 when lastIvl < 21 then 1 else 2 end) as thetype, (case when type in ({REVLOG_LRN},{REVLOG_RELRN}) and ease = 4 then %s else ease end), count() from revlog where type != {REVLOG_RESCHED} %s group by thetype, ease order by thetype, ease""" % (ease4repl, lim) ) # Hourly retention ###################################################################### def hourGraph(self) -> str: data = self._hourRet() if not data: return "" shifted = [] counts = [] mcount = 0 trend: list[tuple[int, int]] = [] peak = 0 for d in data: hour = (d[0] - 4) % 24 pct = d[1] if pct > peak: peak = pct shifted.append((hour, pct)) counts.append((hour, d[2])) if d[2] > mcount: mcount = d[2] shifted.sort() counts.sort() if len(counts) < 4: return "" for d in shifted: hour = d[0] pct = d[1] if not trend: trend.append((hour, pct)) else: prev = trend[-1][1] diff = pct - prev diff /= 3.0 diff = round(diff, 1) trend.append((hour, prev + diff)) txt = self._title( "Hourly Breakdown", "Review success rate for each hour of the day." ) txt += self._graph( id="hour", data=[ dict(data=shifted, color=colCum, label="% Correct"), dict( data=counts, color=colHour, label="Answers", yaxis=2, bars=dict(barWidth=0.2), stack=False, ), ], conf=dict( xaxis=dict( ticks=[ [0, "4AM"], [6, "10AM"], [12, "4PM"], [18, "10PM"], [23, "3AM"], ] ), yaxes=[dict(max=peak), dict(position="right", max=mcount)], ), ylabel="% Correct", ylabel2="Reviews", ) txt += "Hours with less than 30 reviews are not shown." return txt def _hourRet(self) -> Any: lim = self._revlogLimit() if lim: lim = " and " + lim rolloverHour = self.col.conf.get("rollover", 4) pd = self._periodDays() if pd: lim += " and id > %d" % ((self.col.sched.day_cutoff - (86400 * pd)) * 1000) return self.col.db.all( f""" select 23 - ((cast((? - id/1000) / 3600.0 as int)) %% 24) as hour, sum(case when ease = 1 then 0 else 1 end) / cast(count() as float) * 100, count() from revlog where type in ({REVLOG_LRN},{REVLOG_REV},{REVLOG_RELRN}) %s group by hour having count() > 30 order by hour""" % lim, self.col.sched.day_cutoff - (rolloverHour * 3600), ) # Cards ###################################################################### def cardGraph(self) -> str: # graph data div = self._cards() d = [] for c, (t, col) in enumerate( ( ("Mature", colMature), ("Young+Learn", colYoung), ("Unseen", colUnseen), ("Suspended+Buried", colSusp), ) ): d.append(dict(data=div[c], label=f"{t}: {div[c]}", color=col)) # text data i: list[str] = [] (c, f) = self.col.db.first( """ select count(id), count(distinct nid) from cards where did in %s """ % self._limit() ) self._line(i, "Total cards", c) self._line(i, "Total notes", f) (low, avg, high) = self._factors() if low: self._line(i, "Lowest ease", "%d%%" % low) self._line(i, "Average ease", "%d%%" % avg) self._line(i, "Highest ease", "%d%%" % high) info = "" + "".join(i) + "

" info += """\ A card's ease is the size of the next interval \ when you answer "good" on a review.""" txt = self._title("Card Types", "The division of cards in your deck(s).") txt += "
%s%s
" % ( self.width, self._graph(id="cards", data=d, type="pie"), info, ) return txt def _line(self, i: list[str], a: str, b: int | str, bold: bool = True) -> None: # T: Symbols separating first and second column in a statistics table. Eg in "Total: 3 reviews". colon = ":" if bold: i.append( ("%s%s%s") % (a, colon, b) ) else: i.append( ("%s%s%s") % (a, colon, b) ) def _lineTbl(self, i: list[str]) -> str: return "" + "".join(i) + "
" def _factors(self) -> Any: return self.col.db.first( f""" select min(factor) / 10.0, avg(factor) / 10.0, max(factor) / 10.0 from cards where did in %s and queue = {QUEUE_TYPE_REV}""" % self._limit() ) def _cards(self) -> Any: return self.col.db.first( f""" select sum(case when queue={QUEUE_TYPE_REV} and ivl >= 21 then 1 else 0 end), -- mtr sum(case when queue in ({QUEUE_TYPE_LRN},{QUEUE_TYPE_DAY_LEARN_RELEARN}) or (queue={QUEUE_TYPE_REV} and ivl < 21) then 1 else 0 end), -- yng/lrn sum(case when queue={QUEUE_TYPE_NEW} then 1 else 0 end), -- new sum(case when queue<{QUEUE_TYPE_NEW} then 1 else 0 end) -- susp from cards where did in %s""" % self._limit() ) # Footer ###################################################################### def footer(self) -> str: b = "

" b += "Generated on %s" % time.asctime(time.localtime(time.time())) b += "
" if self.wholeCollection: deck = "whole collection" else: deck = self.col.decks.current()["name"] b += "Scope: %s" % deck b += "
" b += "Period: %s" % ["1 month", "1 year", "deck life"][self.type] return b # Tools ###################################################################### def _graph( self, id: str, data: Any, conf: Any | None = None, type: str = "bars", xunit: int = 1, ylabel: str = "Cards", ylabel2: str = "", ) -> str: if conf is None: conf = {} # display settings if type == "pie": conf["legend"] = {"container": "#%sLegend" % id, "noColumns": 2} else: conf["legend"] = {"container": "#%sLegend" % id, "noColumns": 10} conf["series"] = dict(stack=True) if "yaxis" not in conf: conf["yaxis"] = {} conf["yaxis"]["labelWidth"] = 40 if "xaxis" not in conf: conf["xaxis"] = {} if xunit is None: conf["timeTicks"] = False else: # T: abbreviation of day d = "d" # T: abbreviation of week w = "w" # T: abbreviation of month mo = "mo" conf["timeTicks"] = {1: d, 7: w, 31: mo}[xunit] # types width = self.width height = self.height if type == "bars": conf["series"]["bars"] = dict( show=True, barWidth=0.8, align="center", fill=0.7, lineWidth=0 ) # pytype: disable=unsupported-operands elif type == "barsLine": print("deprecated - use 'bars' instead") conf["series"]["bars"] = dict( show=True, barWidth=0.8, align="center", fill=0.7, lineWidth=3 ) elif type == "fill": conf["series"]["lines"] = dict(show=True, fill=True) elif type == "pie": width = int(float(width) / 2.3) height = int(float(height) * 1.5) ylabel = "" conf["series"]["pie"] = dict( show=True, radius=1, stroke=dict(color="#fff", width=5), label=dict( show=True, radius=0.8, threshold=0.01, background=dict(opacity=0.5, color="#000"), ), ) return """
%(ylab)s
%(ylab2)s
""" % dict( id=id, w=width, h=height, ylab=ylabel, ylab2=ylabel2, data=json.dumps(data), conf=json.dumps(conf), ) def _limit(self) -> Any: if self.wholeCollection: return ids2str([d["id"] for d in self.col.decks.all()]) return self.col.sched._deck_limit() def _revlogLimit(self) -> str: if self.wholeCollection: return "" return "cid in (select id from cards where did in %s)" % ids2str( self.col.decks.active() ) def _title(self, title: str, subtitle: str = "") -> str: return f"

{title}

{subtitle}" def _deckAge(self, by: str) -> int: lim = self._revlogLimit() if lim: lim = " where " + lim t = 0 if by == "review": t = self.col.db.scalar("select id from revlog %s order by id limit 1" % lim) elif by == "add": if self.wholeCollection: lim = "" else: lim = "where did in %s" % ids2str(self.col.decks.active()) t = self.col.db.scalar("select id from cards %s order by id limit 1" % lim) if not t: period = 1 else: period = max(1, int(1 + ((self.col.sched.day_cutoff - (t / 1000)) / 86400))) return period def _periodDays(self) -> int | None: start, end, chunk = self.get_start_end_chunk() if end is None: return None return end * chunk def _avgDay(self, tot: float, num: int, unit: str) -> str: vals = [] try: vals.append("%(a)0.1f %(b)s/day" % dict(a=tot / float(num), b=unit)) return ", ".join(vals) except ZeroDivisionError: return "" ================================================ FILE: pylib/anki/statsbg.py ================================================ # from subtlepatterns.com; CC BY 4.0. # by Daniel Beaton # https://www.toptal.com/designers/subtlepatterns/fancy-deboss/ bg = """\ iVBORw0KGgoAAAANSUhEUgAAABIAAAANCAMAAACTkM4rAAAAM1BMVEXy8vLz8/P5+fn19fXt7e329vb4+Pj09PTv7+/u7u739/fw8PD7+/vx8fHr6+v6+vrs7Oz2LjW2AAAAkUlEQVR42g3KyXHAQAwDQYAQj12ItvOP1qqZZwMMPVnd06XToQvz4L2HDQ2iRgkvA7yPPB+JD+OUPnfzZ0JNZh6kkQus5NUmR7g4Jpxv5XN6nYWNmtlq9o3zuK6w3XRsE1pQIEGPIsdtTP3m2cYwlPv6MbL8/QASsKppZefyDmJPbxvxa/NrX1TJ1yp20fhj9D+SiAWWLU8myQAAAABJRU5ErkJggg== """ ================================================ FILE: pylib/anki/stdmodels.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Callable from typing import TYPE_CHECKING, Any import anki.collection import anki.models from anki import notetypes_pb2 from anki._legacy import DeprecatedNamesMixinForModule from anki.utils import from_json_bytes StockNotetypeKind = notetypes_pb2.StockNotetype.Kind # add-on authors can add ("note type name", function) # to this list to have it shown in the add/clone note type screen models: list[tuple] = [] def _get_stock_notetype( col: anki.collection.Collection, kind: StockNotetypeKind.V ) -> anki.models.NotetypeDict: return from_json_bytes(col._backend.get_stock_notetype_legacy(kind)) def get_stock_notetypes( col: anki.collection.Collection, ) -> list[tuple[str, Callable[[anki.collection.Collection], anki.models.NotetypeDict]]]: out: list[ tuple[str, Callable[[anki.collection.Collection], anki.models.NotetypeDict]] ] = [] # add standard - this order should match the one in notetypes.proto for kind in [ StockNotetypeKind.KIND_BASIC, StockNotetypeKind.KIND_BASIC_AND_REVERSED, StockNotetypeKind.KIND_BASIC_OPTIONAL_REVERSED, StockNotetypeKind.KIND_BASIC_TYPING, StockNotetypeKind.KIND_CLOZE, StockNotetypeKind.KIND_IMAGE_OCCLUSION, ]: note_type = from_json_bytes(col._backend.get_stock_notetype_legacy(kind)) def instance_getter( model: Any, ) -> Callable[[anki.collection.Collection], anki.models.NotetypeDict]: return lambda col: model out.append((note_type["name"], instance_getter(note_type))) # add extras from add-ons for name_or_func, func in models: if not isinstance(name_or_func, str): name = name_or_func() else: name = name_or_func out.append((name, func)) return out # # Legacy functions that added the notetype before returning it # def _legacy_add_basic_model( col: anki.collection.Collection, ) -> anki.models.NotetypeDict: note_type = _get_stock_notetype(col, StockNotetypeKind.KIND_BASIC) col.models.add(note_type) return note_type def _legacy_add_basic_typing_model( col: anki.collection.Collection, ) -> anki.models.NotetypeDict: note_type = _get_stock_notetype(col, StockNotetypeKind.KIND_BASIC_TYPING) col.models.add(note_type) return note_type def _legacy_add_forward_reverse( col: anki.collection.Collection, ) -> anki.models.NotetypeDict: note_type = _get_stock_notetype(col, StockNotetypeKind.KIND_BASIC_AND_REVERSED) col.models.add(note_type) return note_type def _legacy_add_forward_optional_reverse( col: anki.collection.Collection, ) -> anki.models.NotetypeDict: note_type = _get_stock_notetype(col, StockNotetypeKind.KIND_BASIC_OPTIONAL_REVERSED) col.models.add(note_type) return note_type def _legacy_add_cloze_model( col: anki.collection.Collection, ) -> anki.models.NotetypeDict: note_type = _get_stock_notetype(col, StockNotetypeKind.KIND_CLOZE) col.models.add(note_type) return note_type _deprecated_names = DeprecatedNamesMixinForModule(globals()) _deprecated_names.register_deprecated_attributes( addBasicModel=(_legacy_add_basic_model, get_stock_notetypes), addBasicTypingModel=(_legacy_add_basic_typing_model, get_stock_notetypes), addForwardReverse=(_legacy_add_forward_reverse, get_stock_notetypes), addForwardOptionalReverse=( _legacy_add_forward_optional_reverse, get_stock_notetypes, ), addClozeModel=(_legacy_add_cloze_model, get_stock_notetypes), ) if not TYPE_CHECKING: def __getattr__(name: str) -> Any: return _deprecated_names.__getattr__(name) ================================================ FILE: pylib/anki/storage.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # Legacy code expects to find Collection in this module. from anki.collection import Collection _Collection = Collection ================================================ FILE: pylib/anki/sync.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from anki import sync_pb2 # public exports SyncAuth = sync_pb2.SyncAuth SyncOutput = sync_pb2.SyncCollectionResponse SyncStatus = sync_pb2.SyncStatusResponse # Legacy attributes some add-ons may be using from .httpclient import HttpClient AnkiRequestsClient = HttpClient class Syncer: def sync(self) -> str: pass ================================================ FILE: pylib/anki/syncserver.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html def run_sync_server() -> None: import sys from os import environ as env from anki._backend import RustBackend env["RUST_LOG"] = env.get("RUST_LOG", "anki=info") try: RustBackend.syncserver() except Exception as exc: print("Sync server failed:", exc) sys.exit(1) sys.exit(0) if __name__ == "__main__": run_sync_server() ================================================ FILE: pylib/anki/tags.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """ Anki maintains a cache of used tags so it can quickly present a list of tags for autocomplete and in the browser. For efficiency, deletions are not tracked, so unused tags can only be removed from the list with a DB check. This module manages the tag cache and tags for notes. """ from __future__ import annotations import pprint import re from collections.abc import Collection, Sequence from typing import Match import anki import anki.collection from anki import tags_pb2 from anki._legacy import DeprecatedNamesMixin, deprecated from anki.collection import OpChanges, OpChangesWithCount from anki.decks import DeckId from anki.notes import NoteId from anki.utils import ids2str # public exports TagTreeNode = tags_pb2.TagTreeNode CompleteTagRequest = tags_pb2.CompleteTagRequest MARKED_TAG = "marked" class TagManager(DeprecatedNamesMixin): def __init__(self, col: anki.collection.Collection) -> None: self.col = col.weakref() # legacy add-on code expects a List return type def all(self) -> list[str]: return list(self.col._backend.all_tags()) def __repr__(self) -> str: dict_ = dict(self.__dict__) del dict_["col"] return f"{super().__repr__()} {pprint.pformat(dict_, width=300)}" def tree(self) -> TagTreeNode: return self.col._backend.tag_tree() # Registering and fetching tags ############################################################# def clear_unused_tags(self) -> OpChangesWithCount: return self.col._backend.clear_unused_tags() def set_collapsed(self, tag: str, collapsed: bool) -> OpChanges: "Set browser expansion state for tag, registering the tag if missing." return self.col._backend.set_tag_collapsed(name=tag, collapsed=collapsed) # Bulk addition/removal from specific notes ############################################################# def bulk_add(self, note_ids: Sequence[NoteId], tags: str) -> OpChangesWithCount: """Add space-separate tags to provided notes, returning changed count.""" return self.col._backend.add_note_tags(note_ids=note_ids, tags=tags) def bulk_remove(self, note_ids: Sequence[NoteId], tags: str) -> OpChangesWithCount: return self.col._backend.remove_note_tags(note_ids=note_ids, tags=tags) # Find&replace ############################################################# def find_and_replace( self, note_ids: Sequence[int], search: str, replacement: str, regex: bool, match_case: bool, ) -> OpChangesWithCount: """Replace instances of 'search' with 'replacement' in tags. Each tag is matched separately. If the replacement results in an empty string, the tag will be removed.""" return self.col._backend.find_and_replace_tag( note_ids=note_ids, search=search, replacement=replacement, regex=regex, match_case=match_case, ) # Bulk addition/removal based on tag ############################################################# def rename(self, old: str, new: str) -> OpChangesWithCount: "Rename provided tag and its children, returning number of changed notes." return self.col._backend.rename_tags(current_prefix=old, new_prefix=new) def remove(self, space_separated_tags: str) -> OpChangesWithCount: "Remove the provided tag(s) and their children from notes and the tag list." return self.col._backend.remove_tags(val=space_separated_tags) def reparent(self, tags: Sequence[str], new_parent: str) -> OpChangesWithCount: """Change the parent of the provided tags. If new_parent is empty, tags will be reparented to the top-level.""" return self.col._backend.reparent_tags(tags=tags, new_parent=new_parent) # String-based utilities ########################################################################## def split(self, tags: str) -> list[str]: "Parse a string and return a list of tags." return [t for t in tags.replace("\u3000", " ").split(" ") if t] def join(self, tags: list[str]) -> str: "Join tags into a single string, with leading and trailing spaces." if not tags: return "" return f" {' '.join(tags)} " def rem_from_str(self, deltags: str, tags: str) -> str: "Delete tags if they exist." def wildcard(pat: str, repl: str) -> Match: pat = re.escape(pat).replace("\\*", ".*") return re.match(f"^{pat}$", repl, re.IGNORECASE) current_tags = self.split(tags) for del_tag in self.split(deltags): # find tags, ignoring case remove = [] for cur_tag in current_tags: if (del_tag.lower() == cur_tag.lower()) or wildcard(del_tag, cur_tag): remove.append(cur_tag) # remove them for rem in remove: current_tags.remove(rem) return self.join(current_tags) # List-based utilities ########################################################################## @deprecated(info="no-op - tags are now canonified when note is saved") def canonify(self, tag_list: list[str]) -> list[str]: return tag_list def in_list(self, tag: str, tags: list[str]) -> bool: "True if TAG is in TAGS. Ignore case." return tag.lower() in [t.lower() for t in tags] # legacy ########################################################################## def _legacy_register_notes(self, nids: list[int] | None = None) -> None: self.clear_unused_tags() def register( self, tags: Collection[str], usn: int | None = None, clear: bool = False ) -> None: print("tags.register() is deprecated and no longer works") def _legacy_bulk_add(self, ids: list[NoteId], tags: str, add: bool = True) -> None: "Add tags in bulk. TAGS is space-separated." if add: self.bulk_add(ids, tags) else: self.bulk_remove(ids, tags) def _legacy_bulk_rem(self, ids: list[NoteId], tags: str) -> None: self._legacy_bulk_add(ids, tags, False) @deprecated(info="no longer used by Anki, and will be removed in the future") def by_deck(self, did: DeckId, children: bool = False) -> list[str]: basequery = "select n.tags from cards c, notes n WHERE c.nid = n.id" if not children: query = f"{basequery} AND c.did=?" res = self.col.db.list(query, did) return list(set(self.split(" ".join(res)))) dids = [did] for name, id in self.col.decks.children(did): dids.append(id) query = f"{basequery} AND c.did IN {ids2str(dids)}" res = self.col.db.list(query) return list(set(self.split(" ".join(res)))) TagManager.register_deprecated_attributes( registerNotes=(TagManager._legacy_register_notes, TagManager.clear_unused_tags), bulkAdd=(TagManager._legacy_bulk_add, TagManager.bulk_add), bulkRem=(TagManager._legacy_bulk_rem, TagManager.bulk_remove), ) ================================================ FILE: pylib/anki/template.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """ This file contains the Python portion of the template rendering code. Templates can have filters applied to field replacements. The Rust template rendering code will apply any built in filters, and stop at the first unrecognized filter. The remaining filters are returned to Python, and applied using the hook system. For example, {{myfilter:hint:text:Field}} will apply the built in text and hint filters, and then attempt to apply myfilter. If no add-ons have provided the filter, the filter is skipped. Add-ons can register a filter with the following code: from anki import hooks hooks.field_filter.append(myfunc) This will call myfunc, passing the field text in as the first argument. Your function should decide if it wants to modify the text by checking the filter_name argument, and then return the text whether it has been modified or not. A Python implementation of the standard filters is currently available in the template_legacy.py file, using the legacy addHook() system. """ from __future__ import annotations import os.path from collections.abc import Sequence from dataclasses import dataclass from typing import Any, Union import anki import anki.cards import anki.collection import anki.notes from anki import card_rendering_pb2, hooks from anki.decks import DeckManager from anki.errors import TemplateError from anki.models import NotetypeDict from anki.sound import AVTag, SoundOrVideoTag, TTSTag from anki.utils import to_json_bytes @dataclass class TemplateReplacement: field_name: str current_text: str filters: list[str] TemplateReplacementList = list[Union[str, TemplateReplacement]] @dataclass class PartiallyRenderedCard: qnodes: TemplateReplacementList anodes: TemplateReplacementList css: str latex_svg: bool is_empty: bool @classmethod def from_proto( cls, out: card_rendering_pb2.RenderCardResponse ) -> PartiallyRenderedCard: qnodes = cls.nodes_from_proto(out.question_nodes) anodes = cls.nodes_from_proto(out.answer_nodes) return PartiallyRenderedCard( qnodes, anodes, out.css, out.latex_svg, out.is_empty ) @staticmethod def nodes_from_proto( nodes: Sequence[card_rendering_pb2.RenderedTemplateNode], ) -> TemplateReplacementList: results: TemplateReplacementList = [] for node in nodes: if node.WhichOneof("value") == "text": results.append(node.text) else: results.append( TemplateReplacement( field_name=node.replacement.field_name, current_text=node.replacement.current_text, filters=list(node.replacement.filters), ) ) return results def av_tag_to_native(tag: card_rendering_pb2.AVTag) -> AVTag: val = tag.WhichOneof("value") if val == "sound_or_video": return SoundOrVideoTag(filename=os.path.basename(tag.sound_or_video)) else: return TTSTag( field_text=tag.tts.field_text, lang=tag.tts.lang, voices=list(tag.tts.voices), other_args=list(tag.tts.other_args), speed=tag.tts.speed, ) def av_tags_to_native(tags: Sequence[card_rendering_pb2.AVTag]) -> list[AVTag]: return list(map(av_tag_to_native, tags)) class TemplateRenderContext: """Holds information for the duration of one card render. This may fetch information lazily in the future, so please avoid using the _private fields directly.""" @staticmethod def from_existing_card( card: anki.cards.Card, browser: bool ) -> TemplateRenderContext: return TemplateRenderContext(card.col, card, card.note(), browser) @classmethod def from_card_layout( cls, note: anki.notes.Note, card: anki.cards.Card, notetype: NotetypeDict, template: dict, fill_empty: bool, ) -> TemplateRenderContext: return TemplateRenderContext( note.col, card, note, notetype=notetype, template=template, fill_empty=fill_empty, ) def __init__( self, col: anki.collection.Collection, card: anki.cards.Card, note: anki.notes.Note, browser: bool = False, notetype: NotetypeDict | None = None, template: dict | None = None, fill_empty: bool = False, ) -> None: self._col = col.weakref() self._card = card self._note = note self._browser = browser self._template = template self._fill_empty = fill_empty self._fields: dict | None = None self._latex_svg = False self._question_side: bool = True if not notetype: self._note_type = note.note_type() else: self._note_type = notetype # if you need to store extra state to share amongst rendering # hooks, you can insert it into this dictionary self.extra_state: dict[str, Any] = {} @property def question_side(self) -> bool: return self._question_side def col(self) -> anki.collection.Collection: return self._col def fields(self) -> dict[str, str]: print(".fields() is obsolete, use .note() or .card()") if not self._fields: # fields from note fields = dict(self._note.items()) # add (most) special fields fields["Tags"] = self._note.string_tags().strip() fields["Type"] = self._note_type["name"] fields["Deck"] = self._col.decks.name(self._card.current_deck_id()) fields["Subdeck"] = DeckManager.basename(fields["Deck"]) if self._template: fields["Card"] = self._template["name"] else: fields["Card"] = "" flag = self._card.user_flag() fields["CardFlag"] = flag and f"flag{flag}" or "" self._fields = fields return self._fields def card(self) -> anki.cards.Card: """Returns the card being rendered. Be careful not to call .question() or .answer() on the card, or you'll create an infinite loop.""" return self._card def note(self) -> anki.notes.Note: return self._note def note_type(self) -> NotetypeDict: return self._note_type def latex_svg(self) -> bool: return self._latex_svg # legacy def qfmt(self) -> str: return templates_for_card(self.card(), self._browser)[0] # legacy def afmt(self) -> str: return templates_for_card(self.card(), self._browser)[1] def render(self) -> TemplateRenderOutput: try: partial = self._partially_render() except TemplateError as error: return TemplateRenderOutput( question_text=str(error), answer_text=str(error), question_av_tags=[], answer_av_tags=[], ) self._question_side = True qtext = apply_custom_filters(partial.qnodes, self, front_side=None) qout = self.col()._backend.extract_av_tags(text=qtext, question_side=True) self._question_side = False atext = apply_custom_filters(partial.anodes, self, front_side=qout.text) aout = self.col()._backend.extract_av_tags(text=atext, question_side=False) output = TemplateRenderOutput( question_text=qout.text, answer_text=aout.text, question_av_tags=av_tags_to_native(qout.av_tags), answer_av_tags=av_tags_to_native(aout.av_tags), css=partial.css, ) self._latex_svg = partial.latex_svg if not self._browser: hooks.card_did_render(output, self) return output def _partially_render(self) -> PartiallyRenderedCard: if self._template: # card layout screen out = self._col._backend.render_uncommitted_card_legacy( note=self._note._to_backend_note(), card_ord=self._card.ord, template=to_json_bytes(self._template), fill_empty=self._fill_empty, partial_render=True, ) # when rendering card layout, the css changes have not been # committed; we need the current notetype instance instead out.css = self._note_type["css"] else: # existing card (eg study mode) out = self._col._backend.render_existing_card( card_id=self._card.id, browser=self._browser, partial_render=True ) return PartiallyRenderedCard.from_proto(out) @dataclass class TemplateRenderOutput: "Stores the rendered templates and extracted AV tags." question_text: str answer_text: str question_av_tags: list[AVTag] answer_av_tags: list[AVTag] css: str = "" def question_and_style(self) -> str: return f"{self.question_text}" def answer_and_style(self) -> str: return f"{self.answer_text}" # legacy def templates_for_card(card: anki.cards.Card, browser: bool) -> tuple[str, str]: template = card.template() if browser: question, answer = template.get("bqfmt"), template.get("bafmt") else: question, answer = None, None question = question or template.get("qfmt") answer = answer or template.get("afmt") return question, answer # type: ignore def apply_custom_filters( rendered: TemplateReplacementList, ctx: TemplateRenderContext, front_side: str | None, ) -> str: "Complete rendering by applying any pending custom filters." # template already fully rendered? if len(rendered) == 1 and isinstance(rendered[0], str): return rendered[0] res = "" for node in rendered: if isinstance(node, str): res += node else: # do we need to inject in FrontSide? if node.field_name == "FrontSide" and front_side is not None: node.current_text = front_side field_text = node.current_text for filter_name in node.filters: field_text = hooks.field_filter( field_text, node.field_name, filter_name, ctx ) # legacy hook - the second and fifth argument are no longer used. field_text = hooks.runFilter( f"fmod_{filter_name}", field_text, "", ctx.note().items(), node.field_name, "", ) res += field_text return res ================================================ FILE: pylib/anki/types.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from typing import NoReturn def assert_exhaustive(arg: NoReturn) -> NoReturn: """The type definition will cause mypy to tell us if we've missed an enum case.""" raise Exception(f"unexpected arg received: {type(arg)} {arg}") ================================================ FILE: pylib/anki/utils.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import json as _json import os import platform import random import shutil import string import subprocess import sys import tempfile import time from collections.abc import Callable, Iterable, Iterator from contextlib import contextmanager from hashlib import sha1 from typing import TYPE_CHECKING, Any from anki._legacy import DeprecatedNamesMixinForModule from anki.dbproxy import DBProxy _tmpdir: str | None try: import orjson to_json_bytes: Callable[[Any], bytes] = orjson.dumps from_json_bytes = orjson.loads except Exception: print("orjson is missing; DB operations will be slower") def to_json_bytes(obj: Any) -> bytes: return _json.dumps(obj).encode("utf8") from_json_bytes = _json.loads # Time handling ############################################################################## def int_time(scale: int = 1) -> int: "The time in integer seconds. Pass scale=1000 to get milliseconds." return int(time.time() * scale) # HTML ############################################################################## def strip_html(txt: str) -> str: import anki.lang from anki.collection import StripHtmlMode return anki.lang.current_i18n.strip_html(text=txt, mode=StripHtmlMode.NORMAL) def strip_html_media(txt: str) -> str: "Strip HTML but keep media filenames" import anki.lang from anki.collection import StripHtmlMode return anki.lang.current_i18n.strip_html( text=txt, mode=StripHtmlMode.PRESERVE_MEDIA_FILENAMES ) def html_to_text_line(txt: str) -> str: import anki.lang return anki.lang.current_i18n.html_to_text_line( text=txt, preserve_media_filenames=True ) # IDs ############################################################################## def ids2str(ids: Iterable[int | str]) -> str: """Given a list of integers, return a string '(int1,int2,...)'.""" return f"({','.join(str(i) for i in ids)})" def timestamp_id(db: DBProxy, table: str) -> int: "Return a non-conflicting timestamp for table." # be careful not to create multiple objects without flushing them, or they # may share an ID. timestamp = int_time(1000) while db.scalar(f"select id from {table} where id = ?", timestamp): timestamp += 1 return timestamp def max_id(db: DBProxy) -> int: "Return the first safe ID to use." now = int_time(1000) for tbl in "cards", "notes": now = max(now, db.scalar(f"select max(id) from {tbl}") or 0) return now + 1 # used in ankiweb def base62(num: int, extra: str = "") -> str: table = string.ascii_letters + string.digits + extra buf = "" while num: num, mod = divmod(num, len(table)) buf = table[mod] + buf return buf _BASE91_EXTRA_CHARS = "!#$%&()*+,-./:;<=>?@[]^_`{|}~" def base91(num: int) -> str: # all printable characters minus quotes, backslash and separators return base62(num, _BASE91_EXTRA_CHARS) def guid64() -> str: "Return a base91-encoded 64bit random number." return base91(random.randint(0, 2**64 - 1)) # Fields ############################################################################## def join_fields(list: list[str]) -> str: return "\x1f".join(list) def split_fields(string: str) -> list[str]: return string.split("\x1f") # Checksums ############################################################################## def checksum(data: bytes | str) -> str: if isinstance(data, str): data = data.encode("utf-8") return sha1(data).hexdigest() def field_checksum(data: str) -> int: # 32 bit unsigned number from first 8 digits of sha1 hash return int(checksum(strip_html_media(data).encode("utf-8"))[:8], 16) # Temp files ############################################################################## _tmpdir = None def tmpdir() -> str: "A reusable temp folder which we clean out on each program invocation." global _tmpdir if not _tmpdir: def cleanup() -> None: if os.path.exists(_tmpdir): shutil.rmtree(_tmpdir) import atexit atexit.register(cleanup) _tmpdir = os.path.join(tempfile.gettempdir(), "anki_temp") try: os.mkdir(_tmpdir) except FileExistsError: pass return _tmpdir def tmpfile(prefix: str = "", suffix: str = "") -> str: (descriptor, name) = tempfile.mkstemp(dir=tmpdir(), prefix=prefix, suffix=suffix) os.close(descriptor) return name def namedtmp(name: str, remove: bool = True) -> str: "Return tmpdir+name. Deletes any existing file." path = os.path.join(tmpdir(), name) if remove: try: os.unlink(path) except OSError: pass return path # Cmd invocation ############################################################################## @contextmanager def no_bundled_libs() -> Iterator[None]: oldlpath = os.environ.pop("LD_LIBRARY_PATH", None) yield if oldlpath is not None: os.environ["LD_LIBRARY_PATH"] = oldlpath def call(argv: list[str], wait: bool = True, **kwargs: Any) -> int: "Execute a command. If WAIT, return exit code." # ensure we don't open a separate window for forking process on windows if is_win: info = subprocess.STARTUPINFO() # type: ignore try: info.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore except Exception: info.dwFlags |= subprocess._subprocess.STARTF_USESHOWWINDOW # type: ignore else: info = None # run try: with no_bundled_libs(): process = subprocess.Popen(argv, startupinfo=info, **kwargs) except OSError: # command not found return -1 # wait for command to finish if wait: while 1: try: ret = process.wait() except OSError: # interrupted system call continue break else: ret = 0 return ret # OS helpers ############################################################################## is_mac = sys.platform == "darwin" is_win = sys.platform == "win32" # also covers *BSD is_lin = not is_mac and not is_win is_gnome = ( "gnome" in os.getenv("XDG_CURRENT_DESKTOP", "").lower() or "gnome" in os.getenv("DESKTOP_SESSION", "").lower() ) dev_mode = os.getenv("ANKIDEV", "") hmr_mode = os.getenv("HMR", "") INVALID_FILENAME_CHARS = ':*?"<>|' def invalid_filename(str: str, dirsep: bool = True) -> str | None: for char in INVALID_FILENAME_CHARS: if char in str: return char if (dirsep or is_win) and "/" in str: return "/" elif (dirsep or not is_win) and "\\" in str: return "\\" elif str.strip().startswith("."): return "." return None def plat_desc() -> str: # we may get an interrupted system call, so try this in a loop theos = "unknown" for _ in range(100): try: system = platform.system() if is_mac: theos = f"mac:{platform.mac_ver()[0]}" elif is_win: theos = f"win:{platform.win32_ver()[0]}" elif system == "Linux": import distro # pytype: disable=import-error dist_id = distro.id() dist_version = distro.version() theos = f"lin:{dist_id}:{dist_version}" else: theos = system break except Exception: continue return theos # Version ############################################################################## def version_with_build() -> str: from anki.buildinfo import buildhash, version return f"{version} ({buildhash})" def int_version() -> int: """Anki's version as an integer in the form YYMMPP, e.g. 230900. (year, month, patch). In 2.1.x releases, this was just the last number.""" import re from anki.buildinfo import version # Strip non-numeric characters (handles beta/rc suffixes like '25.02b1' or 'rc3') numeric_version = re.sub(r"[^0-9.]", "", version) try: [year, month, patch] = numeric_version.split(".") except ValueError: [year, month] = numeric_version.split(".") patch = "0" year_num = int(year) month_num = int(month) patch_num = int(patch) return year_num * 10_000 + month_num * 100 + patch_num def int_version_to_str(ver: int) -> str: if ver <= 99: return f"2.1.{ver}" else: year = ver // 10_000 month = (ver // 100) % 100 patch = ver % 100 out = f"{year:02}.{month:02}" if patch: out += f".{patch}" return out # these two legacy aliases are provided without deprecation warnings, as add-ons that want to support # old versions could not use the new name without catching cases where it doesn't exist point_version = int_version pointVersion = int_version _deprecated_names = DeprecatedNamesMixinForModule(globals()) _deprecated_names.register_deprecated_aliases( stripHTML=strip_html, stripHTMLMedia=strip_html_media, timestampID=timestamp_id, maxID=max_id, invalidFilenameChars=(INVALID_FILENAME_CHARS, "INVALID_FILENAME_CHARS"), ) _deprecated_names.register_deprecated_attributes(json=((_json, "_json"), None)) if not TYPE_CHECKING: def __getattr__(name: str) -> Any: return _deprecated_names.__getattr__(name) ================================================ FILE: pylib/hatch_build.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import os import platform import sys from pathlib import Path from typing import Any, Dict from hatchling.builders.hooks.plugin.interface import BuildHookInterface class CustomBuildHook(BuildHookInterface): """Build hook to include compiled rsbridge from out/pylib.""" PLUGIN_NAME = "custom" def initialize(self, version: str, build_data: Dict[str, Any]) -> None: """Initialize the build hook.""" force_include = build_data.setdefault("force_include", {}) # Set platform-specific wheel tag if not (platform_tag := os.environ.get("ANKI_WHEEL_TAG")): # On Windows, uv invokes this build hook during the initial uv sync, # when the tag has not been declared by our build script. return build_data.setdefault("tag", platform_tag) # Mark as non-pure Python since we include compiled extension build_data["pure_python"] = False # Look for generated files in out/pylib/anki project_root = Path(self.root).parent generated_root = project_root / "out" / "pylib" / "anki" assert generated_root.exists(), "you should build with --wheel" for path in generated_root.rglob("*"): if path.is_file() and not self._should_exclude(path): relative_path = path.relative_to(generated_root) # Place files under anki/ in the distribution dist_path = "anki" / relative_path force_include[str(path)] = str(dist_path) def _should_exclude(self, path: Path) -> bool: """Check if a file should be excluded from the wheel.""" # Exclude __pycache__ path_str = str(path) if "/__pycache__/" in path_str: return True return False ================================================ FILE: pylib/pyproject.toml ================================================ [project] name = "anki" dynamic = ["version"] requires-python = ">=3.9" license = "AGPL-3.0-or-later" dependencies = [ "decorator", "markdown", "orjson", "protobuf>=6.0,<8.0", "requests[socks]", # remove after we update to min python 3.11+ "typing_extensions", # platform-specific dependencies "distro; sys_platform != 'darwin' and sys_platform != 'win32'", ] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["anki"] [tool.hatch.version] source = "code" path = "../python/version.py" [tool.hatch.build.hooks.custom] path = "hatch_build.py" ================================================ FILE: pylib/rsbridge/.gitignore ================================================ target Cargo.lock ================================================ FILE: pylib/rsbridge/Cargo.toml ================================================ [package] name = "rsbridge" version.workspace = true authors.workspace = true edition.workspace = true license.workspace = true publish = false rust-version.workspace = true description = "Anki's Rust library code Python bindings" [lib] name = "rsbridge" crate-type = ["cdylib"] path = "lib.rs" test = false [dependencies] anki.workspace = true pyo3.workspace = true [features] rustls = ["anki/rustls"] native-tls = ["anki/native-tls"] ================================================ FILE: pylib/rsbridge/build.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html fn main() { // macOS needs special link flags for PyO3 if cfg!(target_os = "macos") { println!("cargo:rustc-link-arg=-undefined"); println!("cargo:rustc-link-arg=dynamic_lookup"); println!("cargo:rustc-link-arg=-mmacosx-version-min=11"); } // On Windows, we need to be able to link with python3.lib if cfg!(windows) { use std::process::Command; // Run Python to get sysconfig paths let output = Command::new("../../out/pyenv/scripts/python") .args([ "-c", "import sysconfig; print(sysconfig.get_paths()['stdlib'])", ]) .output() .expect("Failed to execute Python"); let stdlib_path = String::from_utf8(output.stdout) .expect("Failed to parse Python output") .trim() .to_string(); let libs_path = stdlib_path + "s"; println!("cargo:rustc-link-search={libs_path}"); } } ================================================ FILE: pylib/rsbridge/lib.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki::backend::init_backend; use anki::backend::Backend as RustBackend; use anki::log::set_global_logger; use anki::sync::http_server::SimpleServer; use pyo3::create_exception; use pyo3::exceptions::PyException; use pyo3::prelude::*; use pyo3::types::PyBytes; use pyo3::wrap_pyfunction; #[pyclass(module = "_rsbridge")] struct Backend { backend: RustBackend, } create_exception!(_rsbridge, BackendError, PyException); #[pyfunction] fn buildhash() -> &'static str { anki::version::buildhash() } #[pyfunction] #[pyo3(signature = (path=None))] fn initialize_logging(path: Option<&str>) -> PyResult<()> { set_global_logger(path).map_err(|e| PyException::new_err(e.to_string())) } #[pyfunction] fn syncserver() -> PyResult<()> { set_global_logger(None).unwrap(); let err = SimpleServer::run(); Err(PyException::new_err(err.to_string())) } #[pyfunction] fn open_backend(init_msg: &Bound<'_, PyBytes>) -> PyResult { match init_backend(init_msg.as_bytes()) { Ok(backend) => Ok(Backend { backend }), Err(e) => Err(PyException::new_err(e)), } } #[pymethods] impl Backend { fn command( &self, py: Python, service: u32, method: u32, input: &Bound<'_, PyBytes>, ) -> PyResult { let in_bytes = input.as_bytes(); py.allow_threads(|| self.backend.run_service_method(service, method, in_bytes)) .map(|out_bytes| { let out_obj = PyBytes::new(py, &out_bytes); out_obj.into() }) .map_err(BackendError::new_err) } /// This takes and returns JSON, due to Python's slow protobuf /// encoding/decoding. fn db_command(&self, py: Python, input: &Bound<'_, PyBytes>) -> PyResult { let in_bytes = input.as_bytes(); let out_res = py.allow_threads(|| { self.backend .run_db_command_bytes(in_bytes) .map_err(BackendError::new_err) }); let out_bytes = out_res?; let out_obj = PyBytes::new(py, &out_bytes); Ok(out_obj.into()) } } // Module definition ////////////////////////////////// #[pymodule] fn _rsbridge(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_wrapped(wrap_pyfunction!(buildhash)).unwrap(); m.add_wrapped(wrap_pyfunction!(open_backend)).unwrap(); m.add_wrapped(wrap_pyfunction!(initialize_logging)).unwrap(); m.add_wrapped(wrap_pyfunction!(syncserver)).unwrap(); Ok(()) } ================================================ FILE: pylib/tests/__init__.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from anki.lang import set_lang set_lang("en_US") ================================================ FILE: pylib/tests/shared.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import os import shutil import tempfile import time from anki.collection import Collection as aopen # Between 2-4AM, shift the time back so test assumptions hold. lt = time.localtime() if lt.tm_hour >= 2 and lt.tm_hour < 4: orig_time = time.time def adjusted_time(): return orig_time() - 60 * 60 * 2 time.time = adjusted_time else: orig_time = None def assertException(exception, func): found = False try: func() except exception: found = True assert found # Creating new decks is expensive. Just do it once, and then spin off # copies from the master. _emptyCol: str | None = None def getEmptyCol(): global _emptyCol if not _emptyCol: (fd, path) = tempfile.mkstemp(suffix=".anki2") os.close(fd) os.unlink(path) col = aopen(path) col.close(downgrade=False) _emptyCol = path (fd, path) = tempfile.mkstemp(suffix=".anki2") shutil.copy(_emptyCol, path) col = aopen(path) return col # Fallback for when the DB needs options passed in. def getEmptyDeckWith(**kwargs): (fd, nam) = tempfile.mkstemp(suffix=".anki2") os.close(fd) os.unlink(nam) return aopen(nam, **kwargs) def getUpgradeDeckPath(name="anki12.anki"): src = os.path.join(testDir, "support", name) (fd, dst) = tempfile.mkstemp(suffix=".anki2") shutil.copy(src, dst) return dst testDir = os.path.dirname(__file__) def errorsAfterMidnight(func): def wrapper(): lt = time.localtime() if lt.tm_hour < 4: print("test disabled around cutoff", func) else: func() return wrapper def isNearCutoff(): return orig_time is not None ================================================ FILE: pylib/tests/support/supermemo1.xml ================================================ 3572 1 Topic 40326 aoeu Topic 40327 1-400 Topic 40615 aoeu Topic 10247 Item aoeu aoeu 1844 7 0 19.09.2002 5,701 2,452 Topic aoeu 0 0 0 04.08.2000 3,000 0,000 ================================================ FILE: pylib/tests/support/text-2fields.txt ================================================ # this is a test file 食べる to eat 飲む to drink テスト test to eat 食べる 飲む to drink 多すぎる too many fields not, enough, fields 遊ぶ to play ================================================ FILE: pylib/tests/support/text-tags.txt ================================================ foo bar baz,qux foo2 bar2 baz2 ================================================ FILE: pylib/tests/support/text-update.txt ================================================ 1 x ================================================ FILE: pylib/tests/test_cards.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # coding: utf-8 from tests.shared import getEmptyCol def test_delete(): col = getEmptyCol() note = col.newNote() note["Front"] = "1" note["Back"] = "2" col.addNote(note) cid = note.cards()[0].id col.sched.answerCard(col.sched.getCard(), 2) col.remove_cards_and_orphaned_notes([cid]) assert col.card_count() == 0 assert col.note_count() == 0 assert col.db.scalar("select count() from notes") == 0 assert col.db.scalar("select count() from cards") == 0 assert col.db.scalar("select count() from graves") == 2 def test_misc(): col = getEmptyCol() note = col.newNote() note["Front"] = "1" note["Back"] = "2" col.addNote(note) c = note.cards()[0] id = col.models.current()["id"] assert c.template()["ord"] == 0 def test_genrem(): col = getEmptyCol() note = col.newNote() note["Front"] = "1" note["Back"] = "" col.addNote(note) assert len(note.cards()) == 1 m = col.models.current() mm = col.models # adding a new template should automatically create cards t = mm.new_template("rev") t["qfmt"] = "{{Front}}2" t["afmt"] = "" mm.add_template(m, t) mm.save(m, templates=True) assert len(note.cards()) == 2 # if the template is changed to remove cards, they'll be removed t = m["tmpls"][1] t["qfmt"] = "{{Back}}" mm.save(m, templates=True) rep = col._backend.get_empty_cards() rep = col._backend.get_empty_cards() for n in rep.notes: col.remove_cards_and_orphaned_notes(n.card_ids) assert len(note.cards()) == 1 # if we add to the note, a card should be automatically generated note.load() note["Back"] = "1" note.flush() assert len(note.cards()) == 2 def test_gendeck(): col = getEmptyCol() cloze = col.models.by_name("Cloze") note = col.new_note(cloze) note["Text"] = "{{c1::one}}" col.addNote(note) assert col.card_count() == 1 assert note.cards()[0].did == 1 # set the model to a new default col newId = col.decks.id("new") col.set_aux_notetype_config(cloze["id"], "lastDeck", newId) col.models.save(cloze, updateReqs=False) # a newly generated card should share the first card's col note["Text"] += "{{c2::two}}" note.flush() assert note.cards()[1].did == 1 # and same with multiple cards note["Text"] += "{{c3::three}}" note.flush() assert note.cards()[2].did == 1 # if one of the cards is in a different col, it should revert to the # model default c = note.cards()[1] c.did = newId c.flush() note["Text"] += "{{c4::four}}" note.flush() assert note.cards()[3].did == newId ================================================ FILE: pylib/tests/test_collection.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # coding: utf-8 import os import tempfile from typing import Any from anki.collection import Collection as aopen from anki.dbproxy import emulate_named_args from anki.lang import TR, without_unicode_isolation from anki.stdmodels import _legacy_add_basic_model, get_stock_notetypes from anki.utils import is_win from tests.shared import assertException, getEmptyCol def test_create_open(): (fd, path) = tempfile.mkstemp(suffix=".anki2", prefix="test_attachNew") try: os.close(fd) os.unlink(path) except OSError: pass col = aopen(path) # for open() newPath = col.path newMod = col.mod col.close() del col # reopen col = aopen(newPath) assert col.mod == newMod col.close() # non-writeable dir if is_win: dir = "c:\root.anki2" else: dir = "/attachroot.anki2" assertException(Exception, lambda: aopen(dir)) # reuse tmp file from before, test non-writeable file os.chmod(newPath, 0) assertException(Exception, lambda: aopen(newPath)) os.chmod(newPath, 0o666) os.unlink(newPath) def test_noteAddDelete(): col = getEmptyCol() # add a note note = col.newNote() note["Front"] = "one" note["Back"] = "two" n = col.addNote(note) assert n == 1 # test multiple cards - add another template m = col.models.current() mm = col.models t = mm.new_template("Reverse") t["qfmt"] = "{{Back}}" t["afmt"] = "{{Front}}" mm.add_template(m, t) mm.save(m) assert col.card_count() == 2 # creating new notes should use both cards note = col.newNote() note["Front"] = "three" note["Back"] = "four" n = col.addNote(note) assert n == 2 assert col.card_count() == 4 # check q/a generation c0 = note.cards()[0] assert "three" in c0.question() # it should not be a duplicate assert not note.fields_check() # now let's make a duplicate note2 = col.newNote() note2["Front"] = "one" note2["Back"] = "" assert note2.fields_check() # empty first field should not be permitted either note2["Front"] = " " assert note2.fields_check() def test_fieldChecksum(): col = getEmptyCol() note = col.newNote() note["Front"] = "new" note["Back"] = "new2" col.addNote(note) assert col.db.scalar("select csum from notes") == int("c2a6b03f", 16) # changing the val should change the checksum note["Front"] = "newx" note.flush() assert col.db.scalar("select csum from notes") == int("302811ae", 16) def test_addDelTags(): col = getEmptyCol() note = col.newNote() note["Front"] = "1" col.addNote(note) note2 = col.newNote() note2["Front"] = "2" col.addNote(note2) # adding for a given id col.tags.bulk_add([note.id], "foo") note.load() note2.load() assert "foo" in note.tags assert "foo" not in note2.tags # should be canonified col.tags.bulk_add([note.id], "foo aaa") note.load() assert note.tags[0] == "aaa" assert len(note.tags) == 2 def test_timestamps(): col = getEmptyCol() assert len(col.models.all_names_and_ids()) == len(get_stock_notetypes(col)) for i in range(100): _legacy_add_basic_model(col) assert len(col.models.all_names_and_ids()) == 100 + len(get_stock_notetypes(col)) def test_furigana(): col = getEmptyCol() mm = col.models m = mm.current() # filter should work m["tmpls"][0]["qfmt"] = "{{kana:Front}}" mm.save(m) n = col.newNote() n["Front"] = "foo[abc]" col.addNote(n) c = n.cards()[0] assert c.question().endswith("abc") # and should avoid sound n["Front"] = "foo[sound:abc.mp3]" n.flush() assert "anki:play" in c.question(reload=True) # it shouldn't throw an error while people are editing m["tmpls"][0]["qfmt"] = "{{kana:}}" mm.save(m) c.question(reload=True) def test_translate(): col = getEmptyCol() no_uni = without_unicode_isolation assert ( col.tr.card_template_rendering_front_side_problem() == "Front template has a problem:" ) assert no_uni(col.tr.statistics_reviews(reviews=1)) == "1 review" assert no_uni(col.tr.statistics_reviews(reviews=2)) == "2 reviews" def test_db_named_args(capsys): sql = "select a, 2+:test5 from b where arg =:foo and x = :test5" args: tuple = tuple() kwargs = dict(test5=5, foo="blah") s, a = emulate_named_args(sql, args, kwargs) assert s == "select a, 2+?1 from b where arg =?2 and x = ?1" assert a == [5, "blah"] # swallow the warning _ = capsys.readouterr() ================================================ FILE: pylib/tests/test_decks.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # coding: utf-8 from anki.errors import DeckRenameError from tests.shared import assertException, getEmptyCol def test_basic(): col = getEmptyCol() col.set_v3_scheduler(False) # we start with a standard col assert len(col.decks.all_names_and_ids()) == 1 # it should have an id of 1 assert col.decks.name(1) # create a new col parentId = col.decks.id("new deck") assert parentId assert len(col.decks.all_names_and_ids()) == 2 # should get the same id assert col.decks.id("new deck") == parentId # we start with the default col selected assert col.decks.selected() == 1 # we can select a different col col.decks.select(parentId) assert col.decks.selected() == parentId # let's create a child childId = col.decks.id("new deck::child") # it should have been added to the active list assert col.decks.selected() == parentId # we can select the child individually too col.decks.select(childId) assert col.decks.selected() == childId # parents with a different case should be handled correctly col.decks.id("ONE") m = col.models.current() m["did"] = col.decks.id("one::two") col.models.save(m, updateReqs=False) n = col.newNote() n["Front"] = "abc" col.addNote(n) def test_remove(): col = getEmptyCol() # create a new col, and add a note/card to it deck1 = col.decks.id("deck1") note = col.newNote() note["Front"] = "1" note_type = note.note_type() note_type["did"] = deck1 col.models.update_dict(note_type) col.addNote(note) c = note.cards()[0] assert c.did == deck1 assert col.card_count() == 1 col.decks.remove([deck1]) assert col.card_count() == 0 # if we try to get it, we get the default assert col.decks.name(c.did) == "[no deck]" def test_rename(): col = getEmptyCol() id = col.decks.id("hello::world") # should be able to rename into a completely different branch, creating # parents as necessary col.decks.rename(col.decks.get(id), "foo::bar") names = [n.name for n in col.decks.all_names_and_ids()] assert "foo" in names assert "foo::bar" in names assert "hello::world" not in names # create another col id = col.decks.id("tmp") # automatically adjusted if a duplicate name col.decks.rename(col.decks.get(id), "FOO") names = [n.name for n in col.decks.all_names_and_ids()] assert "FOO+" in names # when renaming, the children should be renamed too col.decks.id("one::two::three") id = col.decks.id("one") col.decks.rename(col.decks.get(id), "yo") names = [n.name for n in col.decks.all_names_and_ids()] for n in "yo", "yo::two", "yo::two::three": assert n in names # over filtered filteredId = col.decks.new_filtered("filtered") filtered = col.decks.get(filteredId) childId = col.decks.id("child") child = col.decks.get(childId) assertException(DeckRenameError, lambda: col.decks.rename(child, "filtered::child")) assertException(DeckRenameError, lambda: col.decks.rename(child, "FILTERED::child")) ================================================ FILE: pylib/tests/test_exporting.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import os import tempfile from anki.collection import Collection as aopen from anki.exporting import * from anki.importing import Anki2Importer from tests.shared import errorsAfterMidnight from tests.shared import getEmptyCol as getEmptyColOrig def getEmptyCol(): col = getEmptyColOrig() col.upgrade_to_v2_scheduler() return col col: Collection | None = None testDir = os.path.dirname(__file__) def setup1(): global col col = getEmptyCol() note = col.newNote() note["Front"] = "foo" note["Back"] = "bar
" note.tags = ["tag", "tag2"] col.addNote(note) # with a different col note = col.newNote() note["Front"] = "baz" note["Back"] = "qux" note_type = note.note_type() note_type["did"] = col.decks.id("new col") col.models.update_dict(note_type) col.addNote(note) ########################################################################## def test_export_anki(): setup1() # create a new col with its own conf to test conf copying did = col.decks.id("test") dobj = col.decks.get(did) confId = col.decks.add_config_returning_id("newconf") conf = col.decks.get_config(confId) conf["new"]["perDay"] = 5 col.decks.save(conf) col.decks.set_config_id_for_deck_dict(dobj, confId) # export e = AnkiExporter(col) fd, newname = tempfile.mkstemp(prefix="ankitest", suffix=".anki2") newname = str(newname) os.close(fd) os.unlink(newname) e.exportInto(newname) # exporting should not have changed conf for original deck conf = col.decks.config_dict_for_deck_id(did) assert conf["id"] != 1 # connect to new deck col2 = aopen(newname) assert col2.card_count() == 2 # as scheduling was reset, should also revert decks to default conf did = col2.decks.id("test", create=False) assert did conf2 = col2.decks.config_dict_for_deck_id(did) assert conf2["new"]["perDay"] == 20 dobj = col2.decks.get(did) # conf should be 1 assert dobj["conf"] == 1 # try again, limited to a deck fd, newname = tempfile.mkstemp(prefix="ankitest", suffix=".anki2") newname = str(newname) os.close(fd) os.unlink(newname) e.did = DeckId(1) e.exportInto(newname) col2 = aopen(newname) assert col2.card_count() == 1 def test_export_ankipkg(): setup1() # add a test file to the media folder with open(os.path.join(col.media.dir(), "今日.mp3"), "w") as note: note.write("test") n = col.newNote() n["Front"] = "[sound:今日.mp3]" col.addNote(n) e = AnkiPackageExporter(col) fd, newname = tempfile.mkstemp(prefix="ankitest", suffix=".apkg") newname = str(newname) os.close(fd) os.unlink(newname) e.exportInto(newname) @errorsAfterMidnight def test_export_anki_due(): setup1() col = getEmptyCol() note = col.newNote() note["Front"] = "foo" col.addNote(note) col.crt -= 86400 * 10 c = col.sched.getCard() col.sched.answerCard(c, 3) col.sched.answerCard(c, 3) # should have ivl of 1, due on day 11 assert c.ivl == 1 assert c.due == 11 assert col.sched.today == 10 assert c.due - col.sched.today == 1 # export e = AnkiExporter(col) e.includeSched = True fd, newname = tempfile.mkstemp(prefix="ankitest", suffix=".anki2") newname = str(newname) os.close(fd) os.unlink(newname) e.exportInto(newname) # importing into a new deck, the due date should be equivalent col2 = getEmptyCol() imp = Anki2Importer(col2, newname) imp.run() c = col2.getCard(c.id) assert c.due - col2.sched.today == 1 # def test_export_textcard(): # setup1() # e = TextCardExporter(col) # note = unicode(tempfile.mkstemp(prefix="ankitest")[1]) # os.unlink(note) # e.exportInto(note) # e.includeTags = True # e.exportInto(note) def test_export_textnote(): setup1() e = TextNoteExporter(col) fd, note = tempfile.mkstemp(prefix="ankitest") note = str(note) os.close(fd) os.unlink(note) e.exportInto(note) with open(note) as file: assert file.readline() == "foo\tbar
\ttag tag2\n" e.includeTags = False e.includeHTML = False e.exportInto(note) with open(note) as file: assert file.readline() == "foo\tbar\n" def test_exporters(): assert "*.apkg" in str(exporters(getEmptyCol())) ================================================ FILE: pylib/tests/test_find.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # coding: utf-8 import pytest from anki.browser import BrowserConfig from anki.consts import * from tests.shared import getEmptyCol, isNearCutoff class DummyCollection: def weakref(self): return None def test_find_cards(): col = getEmptyCol() note = col.newNote() note["Front"] = "dog" note["Back"] = "cat" note.tags.append("monkey animal_1 * %") col.addNote(note) n1id = note.id firstCardId = note.cards()[0].id note = col.newNote() note["Front"] = "goats are fun" note["Back"] = "sheep" note.tags.append("sheep goat horse animal11") col.addNote(note) n2id = note.id note = col.newNote() note["Front"] = "cat" note["Back"] = "sheep" note.tags.append("conjunção größte") col.addNote(note) catCard = note.cards()[0] m = col.models.current() m = col.models.copy(m) mm = col.models t = mm.new_template("Reverse") t["qfmt"] = "{{Back}}" t["afmt"] = "{{Front}}" mm.add_template(m, t) mm.save(m) note = col.newNote() note["Front"] = "test" note["Back"] = "foo bar" col.addNote(note) latestCardIds = [c.id for c in note.cards()] # tag searches assert len(col.find_cards("tag:*")) == 5 assert len(col.find_cards("tag:\\*")) == 1 assert len(col.find_cards("tag:%")) == 1 assert len(col.find_cards("tag:sheep_goat")) == 0 assert len(col.find_cards('"tag:sheep goat"')) == 0 assert len(col.find_cards('"tag:* *"')) == 0 assert len(col.find_cards("tag:animal_1")) == 2 assert len(col.find_cards("tag:animal\\_1")) == 1 assert not col.find_cards("tag:donkey") assert len(col.find_cards("tag:sheep")) == 1 assert len(col.find_cards("tag:sheep tag:goat")) == 1 assert len(col.find_cards("tag:sheep tag:monkey")) == 0 assert len(col.find_cards("tag:monkey")) == 1 assert len(col.find_cards("tag:sheep -tag:monkey")) == 1 assert len(col.find_cards("-tag:sheep")) == 4 col.tags.bulk_add(col.db.list("select id from notes"), "foo bar") assert len(col.find_cards("tag:foo")) == len(col.find_cards("tag:bar")) == 5 col.tags.bulk_remove(col.db.list("select id from notes"), "foo") assert len(col.find_cards("tag:foo")) == 0 assert len(col.find_cards("tag:bar")) == 5 assert len(col.find_cards("tag:conjuncao tag:groste")) == 0 assert len(col.find_cards("tag:nc:conjuncao tag:nc:groste")) == 1 # text searches assert len(col.find_cards("cat")) == 2 assert len(col.find_cards("cat -dog")) == 1 assert len(col.find_cards("cat -dog")) == 1 assert len(col.find_cards("are goats")) == 1 assert len(col.find_cards('"are goats"')) == 0 assert len(col.find_cards('"goats are"')) == 1 # card states c = note.cards()[0] c.queue = c.type = CARD_TYPE_REV assert col.find_cards("is:review") == [] c.flush() assert col.find_cards("is:review") == [c.id] assert col.find_cards("is:due") == [] c.due = 0 c.queue = QUEUE_TYPE_REV c.flush() assert col.find_cards("is:due") == [c.id] assert len(col.find_cards("-is:due")) == 4 c.queue = QUEUE_TYPE_SUSPENDED # ensure this card gets a later mod time c.flush() col.db.execute("update cards set mod = mod + 1 where id = ?", c.id) assert col.find_cards("is:suspended") == [c.id] # nids assert col.find_cards("nid:54321") == [] assert len(col.find_cards(f"nid:{note.id}")) == 2 assert len(col.find_cards(f"nid:{n1id},{n2id}")) == 2 # templates assert len(col.find_cards("card:foo")) == 0 assert len(col.find_cards('"card:card 1"')) == 4 assert len(col.find_cards("card:reverse")) == 1 assert len(col.find_cards("card:1")) == 4 assert len(col.find_cards("card:2")) == 1 # fields assert len(col.find_cards("front:dog")) == 1 assert len(col.find_cards("-front:dog")) == 4 assert len(col.find_cards("front:sheep")) == 0 assert len(col.find_cards("back:sheep")) == 2 assert len(col.find_cards("-back:sheep")) == 3 assert len(col.find_cards("front:do")) == 0 assert len(col.find_cards("front:*")) == 5 # ordering col.conf["sortType"] = "noteCrt" assert col.find_cards("front:*", order=True)[-1] in latestCardIds assert col.find_cards("", order=True)[-1] in latestCardIds col.conf["sortType"] = "noteFld" assert col.find_cards("", order=True)[0] == catCard.id assert col.find_cards("", order=True)[-1] in latestCardIds col.conf["sortType"] = "cardMod" assert col.find_cards("", order=True)[-1] in latestCardIds assert col.find_cards("", order=True)[0] == firstCardId col.set_config(BrowserConfig.CARDS_SORT_BACKWARDS_KEY, True) assert col.find_cards("", order=True)[0] in latestCardIds assert ( col.find_cards("", order=col.get_browser_column("cardDue"), reverse=False)[0] == firstCardId ) assert ( col.find_cards("", order=col.get_browser_column("cardDue"), reverse=True)[0] != firstCardId ) # model assert len(col.find_cards("note:basic")) == 3 assert len(col.find_cards("-note:basic")) == 2 assert len(col.find_cards("-note:foo")) == 5 # col assert len(col.find_cards("deck:default")) == 5 assert len(col.find_cards("-deck:default")) == 0 assert len(col.find_cards("-deck:foo")) == 5 assert len(col.find_cards("deck:def*")) == 5 assert len(col.find_cards("deck:*EFAULT")) == 5 assert len(col.find_cards("deck:*cefault")) == 0 # full search note = col.newNote() note["Front"] = "helloworld" note["Back"] = "abc" col.addNote(note) # as it's the sort field, it matches assert len(col.find_cards("helloworld")) == 2 # assert len(col.find_cards("helloworld", full=True)) == 2 # if we put it on the back, it won't (note["Front"], note["Back"]) = (note["Back"], note["Front"]) note.flush() assert len(col.find_cards("helloworld")) == 0 # assert len(col.find_cards("helloworld", full=True)) == 2 # assert len(col.find_cards("back:helloworld", full=True)) == 2 # searching for an invalid special tag should not error with pytest.raises(Exception): len(col.find_cards("is:invalid")) # should be able to limit to parent col, no children id = col.db.scalar("select id from cards limit 1") col.db.execute( "update cards set did = ? where id = ?", col.decks.id("Default::Child"), id ) assert len(col.find_cards("deck:default")) == 7 assert len(col.find_cards("deck:default::child")) == 1 assert len(col.find_cards("deck:default -deck:default::*")) == 6 # properties id = col.db.scalar("select id from cards limit 1") col.db.execute( "update cards set queue=2, ivl=10, reps=20, due=30, factor=2200 where id = ?", id, ) assert len(col.find_cards("prop:ivl>5")) == 1 assert len(col.find_cards("prop:ivl<5")) > 1 assert len(col.find_cards("prop:ivl>=5")) == 1 assert len(col.find_cards("prop:ivl=9")) == 0 assert len(col.find_cards("prop:ivl=10")) == 1 assert len(col.find_cards("prop:ivl!=10")) > 1 assert len(col.find_cards("prop:due>0")) == 1 # due dates should work assert len(col.find_cards("prop:due=29")) == 0 assert len(col.find_cards("prop:due=30")) == 1 # ease factors assert len(col.find_cards("prop:ease=2.3")) == 0 assert len(col.find_cards("prop:ease=2.2")) == 1 assert len(col.find_cards("prop:ease>2")) == 1 assert len(col.find_cards("-prop:ease>2")) > 1 # recently failed if not isNearCutoff(): # rated assert len(col.find_cards("rated:1:1")) == 0 assert len(col.find_cards("rated:1:2")) == 0 c = col.sched.getCard() col.sched.answerCard(c, 2) assert len(col.find_cards("rated:1:1")) == 0 assert len(col.find_cards("rated:1:2")) == 1 c = col.sched.getCard() col.sched.answerCard(c, 1) assert len(col.find_cards("rated:1:1")) == 1 assert len(col.find_cards("rated:1:2")) == 1 assert len(col.find_cards("rated:1")) == 2 assert len(col.find_cards("rated:2:2")) == 1 assert len(col.find_cards("rated:0")) == len(col.find_cards("rated:1")) # added col.db.execute("update cards set id = id - 86400*1000 where id = ?", id) assert len(col.find_cards("added:1")) == col.card_count() - 1 assert len(col.find_cards("added:2")) == col.card_count() assert len(col.find_cards("added:0")) == len(col.find_cards("added:1")) else: print("some find tests disabled near cutoff") # empty field assert len(col.find_cards("front:")) == 0 note = col.newNote() note["Front"] = "" note["Back"] = "abc2" assert col.addNote(note) == 1 assert len(col.find_cards("front:")) == 1 # OR searches and nesting assert len(col.find_cards("tag:monkey or tag:sheep")) == 2 assert len(col.find_cards("(tag:monkey OR tag:sheep)")) == 2 assert len(col.find_cards("-(tag:monkey OR tag:sheep)")) == 6 assert len(col.find_cards("tag:monkey or (tag:sheep sheep)")) == 2 assert len(col.find_cards("tag:monkey or (tag:sheep octopus)")) == 1 # flag with pytest.raises(Exception): col.find_cards("flag:12") def test_findReplace(): col = getEmptyCol() note = col.newNote() note["Front"] = "foo" note["Back"] = "bar" col.addNote(note) note2 = col.newNote() note2["Front"] = "baz" note2["Back"] = "foo" col.addNote(note2) nids = [note.id, note2.id] # should do nothing assert ( col.find_and_replace(note_ids=nids, search="abc", replacement="123").count == 0 ) # global replace assert ( col.find_and_replace(note_ids=nids, search="foo", replacement="qux").count == 2 ) note.load() assert note["Front"] == "qux" note2.load() assert note2["Back"] == "qux" # single field replace assert ( col.find_and_replace( note_ids=nids, search="qux", replacement="foo", field_name="Front" ).count == 1 ) note.load() assert note["Front"] == "foo" note2.load() assert note2["Back"] == "qux" # regex replace assert ( col.find_and_replace(note_ids=nids, search="B.r", replacement="reg").count == 0 ) note.load() assert note["Back"] != "reg" assert ( col.find_and_replace( note_ids=nids, search="B.r", replacement="reg", regex=True ).count == 1 ) note.load() assert note["Back"] == "reg" def test_findDupes(): col = getEmptyCol() note = col.newNote() note["Front"] = "foo" note["Back"] = "bar" col.addNote(note) note2 = col.newNote() note2["Front"] = "baz" note2["Back"] = "bar" col.addNote(note2) note3 = col.newNote() note3["Front"] = "quux" note3["Back"] = "bar" col.addNote(note3) note4 = col.newNote() note4["Front"] = "quuux" note4["Back"] = "nope" col.addNote(note4) r = col.find_dupes("Back") assert r[0][0] == "bar" assert len(r[0][1]) == 3 # valid search r = col.find_dupes("Back", "bar") assert r[0][0] == "bar" assert len(r[0][1]) == 3 # excludes everything r = col.find_dupes("Back", "invalid") assert not r # front isn't dupe assert col.find_dupes("Front") == [] ================================================ FILE: pylib/tests/test_flags.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from tests.shared import getEmptyCol def test_flags(): col = getEmptyCol() n = col.newNote() n["Front"] = "one" n["Back"] = "two" cnt = col.addNote(n) c = n.cards()[0] # make sure higher bits are preserved origBits = 0b101 << 3 c.flags = origBits c.flush() # no flags to start with assert c.user_flag() == 0 assert len(col.find_cards("flag:0")) == 1 assert len(col.find_cards("flag:1")) == 0 # set flag 2 col.set_user_flag_for_cards(2, [c.id]) c.load() assert c.user_flag() == 2 assert c.flags & origBits == origBits assert len(col.find_cards("flag:0")) == 0 assert len(col.find_cards("flag:2")) == 1 assert len(col.find_cards("flag:3")) == 0 # change to 3 col.set_user_flag_for_cards(3, [c.id]) c.load() assert c.user_flag() == 3 # unset col.set_user_flag_for_cards(0, [c.id]) c.load() assert c.user_flag() == 0 ================================================ FILE: pylib/tests/test_importing.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # coding: utf-8 import os from tempfile import NamedTemporaryFile import pytest from anki.consts import * from anki.importing import ( Anki2Importer, AnkiPackageImporter, MnemosyneImporter, TextImporter, ) from tests.shared import getEmptyCol, getUpgradeDeckPath testDir = os.path.dirname(__file__) def clear_tempfile(tf): """https://stackoverflow.com/questions/23212435/permission-denied-to-write-to-my-temporary-file""" try: tf.close() os.unlink(tf.name) except Exception: pass def test_anki2_mediadupes(): col = getEmptyCol() # add a note that references a sound n = col.newNote() n["Front"] = "[sound:foo.mp3]" mid = n.note_type()["id"] col.addNote(n) # add that sound to media folder with open(os.path.join(col.media.dir(), "foo.mp3"), "w") as note: note.write("foo") col.close() # it should be imported correctly into an empty deck empty = getEmptyCol() imp = Anki2Importer(empty, col.path) imp.run() assert os.listdir(empty.media.dir()) == ["foo.mp3"] # and importing again will not duplicate, as the file content matches empty.remove_cards_and_orphaned_notes(empty.db.list("select id from cards")) imp = Anki2Importer(empty, col.path) imp.run() assert os.listdir(empty.media.dir()) == ["foo.mp3"] n = empty.get_note(empty.db.scalar("select id from notes")) assert "foo.mp3" in n.fields[0] # if the local file content is different, and import should trigger a # rename empty.remove_cards_and_orphaned_notes(empty.db.list("select id from cards")) with open(os.path.join(empty.media.dir(), "foo.mp3"), "w") as note: note.write("bar") imp = Anki2Importer(empty, col.path) imp.run() assert sorted(os.listdir(empty.media.dir())) == ["foo.mp3", f"foo_{mid}.mp3"] n = empty.get_note(empty.db.scalar("select id from notes")) assert "_" in n.fields[0] # if the localized media file already exists, we rewrite the note and # media empty.remove_cards_and_orphaned_notes(empty.db.list("select id from cards")) with open(os.path.join(empty.media.dir(), "foo.mp3"), "w") as note: note.write("bar") imp = Anki2Importer(empty, col.path) imp.run() assert sorted(os.listdir(empty.media.dir())) == ["foo.mp3", f"foo_{mid}.mp3"] assert sorted(os.listdir(empty.media.dir())) == ["foo.mp3", f"foo_{mid}.mp3"] n = empty.get_note(empty.db.scalar("select id from notes")) assert "_" in n.fields[0] def test_apkg(): col = getEmptyCol() apkg = str(os.path.join(testDir, "support", "media.apkg")) imp = AnkiPackageImporter(col, apkg) assert os.listdir(col.media.dir()) == [] imp.run() assert os.listdir(col.media.dir()) == ["foo.wav"] # importing again should be idempotent in terms of media col.remove_cards_and_orphaned_notes(col.db.list("select id from cards")) imp = AnkiPackageImporter(col, apkg) imp.run() assert os.listdir(col.media.dir()) == ["foo.wav"] # but if the local file has different data, it will rename col.remove_cards_and_orphaned_notes(col.db.list("select id from cards")) with open(os.path.join(col.media.dir(), "foo.wav"), "w") as note: note.write("xyz") imp = AnkiPackageImporter(col, apkg) imp.run() assert len(os.listdir(col.media.dir())) == 2 def test_anki2_diffmodel_templates(): # different from the above as this one tests only the template text being # changed, not the number of cards/fields dst = getEmptyCol() # import the first version of the model col = getUpgradeDeckPath("diffmodeltemplates-1.apkg") imp = AnkiPackageImporter(dst, col) imp.dupeOnSchemaChange = True # type: ignore imp.run() # then the version with updated template col = getUpgradeDeckPath("diffmodeltemplates-2.apkg") imp = AnkiPackageImporter(dst, col) imp.dupeOnSchemaChange = True # type: ignore imp.run() # collection should contain the note we imported assert dst.note_count() == 1 # the front template should contain the text added in the 2nd package tcid = dst.find_cards("")[0] # only 1 note in collection tnote = dst.getCard(tcid).note() assert "Changed Front Template" in tnote.cards()[0].template()["qfmt"] def test_anki2_updates(): # create a new empty deck dst = getEmptyCol() col = getUpgradeDeckPath("update1.apkg") imp = AnkiPackageImporter(dst, col) imp.run() assert imp.dupes == 0 assert imp.added == 1 assert imp.updated == 0 # importing again should be idempotent imp = AnkiPackageImporter(dst, col) imp.run() assert imp.dupes == 1 assert imp.added == 0 assert imp.updated == 0 # importing a newer note should update assert dst.note_count() == 1 assert dst.db.scalar("select flds from notes").startswith("hello") col = getUpgradeDeckPath("update2.apkg") imp = AnkiPackageImporter(dst, col) imp.run() assert imp.dupes == 0 assert imp.added == 0 assert imp.updated == 1 assert dst.note_count() == 1 assert dst.db.scalar("select flds from notes").startswith("goodbye") def test_csv(): col = getEmptyCol() file = str(os.path.join(testDir, "support", "text-2fields.txt")) i = TextImporter(col, file) i.initMapping() i.run() # four problems - too many & too few fields, a missing front, and a # duplicate entry assert len(i.log) == 5 assert i.total == 5 # if we run the import again, it should update instead i.run() assert len(i.log) == 10 assert i.total == 5 # but importing should not clobber tags if they're unmapped n = col.get_note(col.db.scalar("select id from notes")) n.add_tag("test") n.flush() i.run() n.load() assert n.tags == ["test"] # if add-only mode, count will be 0 i.importMode = 1 i.run() assert i.total == 0 # and if dupes mode, will reimport everything assert col.card_count() == 5 i.importMode = 2 i.run() # includes repeated field assert i.total == 6 assert col.card_count() == 11 col.close() def test_csv2(): col = getEmptyCol() mm = col.models m = mm.current() note = mm.new_field("Three") mm.addField(m, note) mm.save(m) n = col.newNote() n["Front"] = "1" n["Back"] = "2" n["Three"] = "3" col.addNote(n) # an update with unmapped fields should not clobber those fields file = str(os.path.join(testDir, "support", "text-update.txt")) i = TextImporter(col, file) i.initMapping() i.run() n.load() assert n["Front"] == "1" assert n["Back"] == "x" assert n["Three"] == "3" col.close() def test_tsv_tag_modified(): col = getEmptyCol() mm = col.models m = mm.current() note = mm.new_field("Top") mm.addField(m, note) mm.save(m) n = col.newNote() n["Front"] = "1" n["Back"] = "2" n["Top"] = "3" n.add_tag("four") col.addNote(n) # https://stackoverflow.com/questions/23212435/permission-denied-to-write-to-my-temporary-file with NamedTemporaryFile(mode="w", delete=False) as tf: tf.write("1\tb\tc\n") tf.flush() i = TextImporter(col, tf.name) i.initMapping() i.tagModified = "boom" i.run() clear_tempfile(tf) n.load() assert n["Front"] == "1" assert n["Back"] == "b" assert n["Top"] == "c" assert "four" in n.tags assert "boom" in n.tags assert len(n.tags) == 2 assert i.updateCount == 1 col.close() def test_tsv_tag_multiple_tags(): col = getEmptyCol() mm = col.models m = mm.current() note = mm.new_field("Top") mm.addField(m, note) mm.save(m) n = col.newNote() n["Front"] = "1" n["Back"] = "2" n["Top"] = "3" n.add_tag("four") n.add_tag("five") col.addNote(n) # https://stackoverflow.com/questions/23212435/permission-denied-to-write-to-my-temporary-file with NamedTemporaryFile(mode="w", delete=False) as tf: tf.write("1\tb\tc\n") tf.flush() i = TextImporter(col, tf.name) i.initMapping() i.tagModified = "five six" i.run() clear_tempfile(tf) n.load() assert n["Front"] == "1" assert n["Back"] == "b" assert n["Top"] == "c" assert list(sorted(n.tags)) == list(sorted(["four", "five", "six"])) col.close() def test_csv_tag_only_if_modified(): col = getEmptyCol() mm = col.models m = mm.current() note = mm.new_field("Left") mm.addField(m, note) mm.save(m) n = col.newNote() n["Front"] = "1" n["Back"] = "2" n["Left"] = "3" col.addNote(n) # https://stackoverflow.com/questions/23212435/permission-denied-to-write-to-my-temporary-file with NamedTemporaryFile(mode="w", delete=False) as tf: tf.write("1,2,3\n") tf.flush() i = TextImporter(col, tf.name) i.initMapping() i.tagModified = "right" i.run() clear_tempfile(tf) n.load() assert n.tags == [] assert i.updateCount == 0 col.close() def test_mnemo(): col = getEmptyCol() file = str(os.path.join(testDir, "support", "mnemo.db")) i = MnemosyneImporter(col, file) i.run() assert col.card_count() == 7 assert "a_longer_tag" in col.tags.all() assert col.db.scalar(f"select count() from cards where type = {CARD_TYPE_NEW}") == 1 col.close() ================================================ FILE: pylib/tests/test_latex.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # coding: utf-8 import os import shutil from anki.config import Config from anki.lang import without_unicode_isolation from tests.shared import getEmptyCol def test_latex(): col = getEmptyCol() col.set_config_bool(Config.Bool.RENDER_LATEX, True) # change latex cmd to simulate broken build import anki.latex anki.latex.pngCommands[0][0] = "nolatex" # add a note with latex note = col.newNote() note["Front"] = "[latex]hello[/latex]" col.addNote(note) # but since latex couldn't run, there's nothing there assert len(os.listdir(col.media.dir())) == 0 # check the error message msg = note.cards()[0].question() assert "executing nolatex" in without_unicode_isolation(msg) assert "installed" in msg # check if we have latex installed, and abort test if we don't if not shutil.which("latex") or not shutil.which("dvipng"): print("aborting test; latex or dvipng is not installed") return # fix path anki.latex.pngCommands[0][0] = "latex" # check media db should cause latex to be generated col.media.render_all_latex() assert len(os.listdir(col.media.dir())) == 1 assert ".png" in note.cards()[0].question() # adding new notes should cause generation on question display note = col.newNote() note["Front"] = "[latex]world[/latex]" col.addNote(note) note.cards()[0].question() assert len(os.listdir(col.media.dir())) == 2 # another note with the same media should reuse note = col.newNote() note["Front"] = " [latex]world[/latex]" col.addNote(note) assert len(os.listdir(col.media.dir())) == 2 oldcard = note.cards()[0] assert ".png" in oldcard.question() # if we turn off building, then previous cards should work, but cards with # missing media will show a broken image col.set_config_bool(Config.Bool.RENDER_LATEX, False) note = col.newNote() note["Front"] = "[latex]foo[/latex]" col.addNote(note) assert len(os.listdir(col.media.dir())) == 2 assert ".png" in oldcard.question() ================================================ FILE: pylib/tests/test_media.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # coding: utf-8 import os import tempfile from tests.shared import getEmptyCol, testDir # copying files to media folder def test_add(): col = getEmptyCol() dir = tempfile.mkdtemp(prefix="anki") path = os.path.join(dir, "foo.jpg") with open(path, "w") as note: note.write("hello") # new file, should preserve name assert col.media.add_file(path) == "foo.jpg" # adding the same file again should not create a duplicate assert col.media.add_file(path) == "foo.jpg" # but if it has a different sha1, it should with open(path, "w") as note: note.write("world") assert ( col.media.add_file(path) == "foo-7c211433f02071597741e6ff5a8ea34789abbf43.jpg" ) def test_strings(): col = getEmptyCol() mf = col.media.files_in_str mid = col.models.current()["id"] assert mf(mid, "aoeu") == [] assert mf(mid, "aoeuao") == ["foo.jpg"] assert mf(mid, "aoeuao") == ["foo.jpg"] assert mf(mid, "aoeuao") == [ "foo.jpg", "bar.jpg", ] assert mf(mid, "aoeuao") == ["foo.jpg"] assert mf(mid, "") == ["one", "two"] assert mf(mid, 'aoeuao') == ["foo.jpg"] assert mf(mid, 'aoeuao') == [ "foo.jpg", "fo", ] assert mf(mid, "aou[sound:foo.mp3]aou") == ["foo.mp3"] sp = col.media.strip assert sp("aoeu") == "aoeu" assert sp("aoeu[sound:foo.mp3]aoeu") == "aoeuaoeu" assert sp("aoeu") == "aoeu" es = col.media.escape_media_filenames assert es("aoeu") == "aoeu" assert es("") == "" assert es('') == '' def test_deckIntegration(): col = getEmptyCol() # create a media dir col.media.dir() # put a file into it file = str(os.path.join(testDir, "support", "fake.png")) col.media.add_file(file) # add a note which references it note = col.newNote() note["Front"] = "one" note["Back"] = "" col.addNote(note) # and one which references a non-existent file note = col.newNote() note["Front"] = "one" note["Back"] = "" col.addNote(note) # and add another file which isn't used with open(os.path.join(col.media.dir(), "foo.jpg"), "w") as note: note.write("test") # check media ret = col.media.check() assert ret.missing == ["fake2.png"] assert ret.unused == ["foo.jpg"] ================================================ FILE: pylib/tests/test_models.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # coding: utf-8 import html import re import time from anki.consts import MODEL_CLOZE from anki.errors import NotFoundError from anki.utils import is_win, strip_html from tests.shared import getEmptyCol def encode_attribute(s): return "".join( c if c.isalnum() else "&#x{:X};".format(ord(c)) for c in html.escape(s) ) def test_modelDelete(): col = getEmptyCol() note = col.newNote() note["Front"] = "1" note["Back"] = "2" col.addNote(note) assert col.card_count() == 1 col.models.remove(col.models.current()["id"]) assert col.card_count() == 0 def test_modelCopy(): col = getEmptyCol() m = col.models.current() m2 = col.models.copy(m) assert m2["name"] == "Basic copy" assert m2["id"] != m["id"] assert len(m2["flds"]) == 2 assert len(m["flds"]) == 2 assert len(m2["flds"]) == len(m["flds"]) assert len(m["tmpls"]) == 1 assert len(m2["tmpls"]) == 1 assert col.models.scmhash(m) == col.models.scmhash(m2) def test_fields(): col = getEmptyCol() note = col.newNote() note["Front"] = "1" note["Back"] = "2" col.addNote(note) m = col.models.current() # make sure renaming a field updates the templates col.models.renameField(m, m["flds"][0], "NewFront") assert "{{NewFront}}" in m["tmpls"][0]["qfmt"] h = col.models.scmhash(m) # add a field field = col.models.new_field("foo") col.models.addField(m, field) assert col.get_note(col.models.nids(m)[0]).fields == ["1", "2", ""] assert col.models.scmhash(m) != h # rename it field = m["flds"][2] col.models.renameField(m, field, "bar") assert col.get_note(col.models.nids(m)[0])["bar"] == "" # delete back col.models.remField(m, m["flds"][1]) assert col.get_note(col.models.nids(m)[0]).fields == ["1", ""] # move 0 -> 1 col.models.moveField(m, m["flds"][0], 1) assert col.get_note(col.models.nids(m)[0]).fields == ["", "1"] # move 1 -> 0 col.models.moveField(m, m["flds"][1], 0) assert col.get_note(col.models.nids(m)[0]).fields == ["1", ""] # add another and put in middle field = col.models.new_field("baz") col.models.addField(m, field) note = col.get_note(col.models.nids(m)[0]) note["baz"] = "2" note.flush() assert col.get_note(col.models.nids(m)[0]).fields == ["1", "", "2"] # move 2 -> 1 col.models.moveField(m, m["flds"][2], 1) assert col.get_note(col.models.nids(m)[0]).fields == ["1", "2", ""] # move 0 -> 2 col.models.moveField(m, m["flds"][0], 2) assert col.get_note(col.models.nids(m)[0]).fields == ["2", "", "1"] # move 0 -> 1 col.models.moveField(m, m["flds"][0], 1) assert col.get_note(col.models.nids(m)[0]).fields == ["", "2", "1"] def test_templates(): col = getEmptyCol() m = col.models.current() mm = col.models t = mm.new_template("Reverse") t["qfmt"] = "{{Back}}" t["afmt"] = "{{Front}}" mm.add_template(m, t) mm.save(m) note = col.newNote() note["Front"] = "1" note["Back"] = "2" col.addNote(note) assert col.card_count() == 2 (c, c2) = note.cards() # first card should have first ord assert c.ord == 0 assert c2.ord == 1 # switch templates col.models.reposition_template(m, c.template(), 1) col.models.update(m) c.load() c2.load() assert c.ord == 1 assert c2.ord == 0 # removing a template should delete its cards col.models.remove_template(m, m["tmpls"][0]) col.models.update(m) assert col.card_count() == 1 # and should have updated the other cards' ordinals c = note.cards()[0] assert c.ord == 0 assert strip_html(c.question()) == "1" # it shouldn't be possible to orphan notes by removing templates t = mm.new_template("template name") t["qfmt"] = "{{Front}}2" mm.add_template(m, t) col.models.remove_template(m, m["tmpls"][0]) col.models.update(m) assert ( col.db.scalar( "select count() from cards where nid not in (select id from notes)" ) == 0 ) def test_cloze_ordinals(): col = getEmptyCol() m = col.models.by_name("Cloze") mm = col.models # We replace the default Cloze template t = mm.new_template("ChainedCloze") t["qfmt"] = "{{text:cloze:Text}}" t["afmt"] = "{{text:cloze:Text}}" mm.add_template(m, t) mm.save(m) col.models.remove_template(m, m["tmpls"][0]) col.models.update(m) note = col.newNote() note["Text"] = "{{c1::firstQ::firstA}}{{c2::secondQ::secondA}}" col.addNote(note) assert col.card_count() == 2 (c, c2) = note.cards() # first card should have first ord assert c.ord == 0 assert c2.ord == 1 def test_text(): col = getEmptyCol() m = col.models.current() m["tmpls"][0]["qfmt"] = "{{text:Front}}" col.models.save(m) note = col.newNote() note["Front"] = "helloworld" col.addNote(note) assert "helloworld" in note.cards()[0].question() def test_cloze(): col = getEmptyCol() m = col.models.by_name("Cloze") note = col.new_note(m) assert note.note_type()["name"] == "Cloze" # a cloze model with no clozes is not empty note["Text"] = "nothing" assert col.addNote(note) # try with one cloze note = col.new_note(m) note["Text"] = "hello {{c1::world}}" assert col.addNote(note) == 1 assert ( f'hello [...]' in note.cards()[0].question() ) assert ( 'hello world' in note.cards()[0].answer() ) # and with a comment note = col.new_note(m) note["Text"] = "hello {{c1::world::typical}}" assert col.addNote(note) == 1 assert ( f'[typical]' in note.cards()[0].question() ) assert ( 'world' in note.cards()[0].answer() ) # and with 2 clozes note = col.new_note(m) note["Text"] = "hello {{c1::world}} {{c2::bar}}" assert col.addNote(note) == 2 (c1, c2) = note.cards() assert ( f'[...] bar' in c1.question() ) assert ( 'world bar' in c1.answer() ) assert ( f'world [...]' in c2.question() ) assert ( 'world bar' in c2.answer() ) # if there are multiple answers for a single cloze, they are given in a # list note = col.new_note(m) note["Text"] = "a {{c1::b}} {{c1::c}}" assert col.addNote(note) == 1 assert ( 'b c' in (note.cards()[0].answer()) ) # if we add another cloze, a card should be generated cnt = col.card_count() note["Text"] = "{{c2::hello}} {{c1::foo}}" note.flush() assert col.card_count() == cnt + 1 # 0 or negative indices are not supported note["Text"] += "{{c0::zero}} {{c-1:foo}}" note.flush() assert len(note.cards()) == 2 def test_cloze_mathjax(): col = getEmptyCol() m = col.models.by_name("Cloze") note = col.new_note(m) q1 = "ok" q2 = "not ok" q3 = "2" q4 = "blah" q5 = "text with \(x^2\) jax" note["Text"] = ( "{{{{c1::{}}}}} \(2^2\) {{{{c2::{}}}}} \(2^{{{{c3::{}}}}}\) \(x^3\) {{{{c4::{}}}}} {{{{c5::{}}}}}".format( q1, q2, q3, q4, q5, ) ) assert col.addNote(note) assert len(note.cards()) == 5 assert ( f'class="cloze" data-cloze="{encode_attribute(q1)}"' in note.cards()[0].question() ) assert ( f'class="cloze" data-cloze="{encode_attribute(q2)}"' in note.cards()[1].question() ) assert ( f'class="cloze" data-cloze="{encode_attribute(q3)}"' not in note.cards()[2].question() ) assert ( f'class="cloze" data-cloze="{encode_attribute(q4)}"' in note.cards()[3].question() ) assert ( f'class="cloze" data-cloze="{encode_attribute(q5)}"' in note.cards()[4].question() ) note = col.new_note(m) note["Text"] = r"\(a\) {{c1::b}} \[ {{c1::c}} \]" assert col.addNote(note) assert len(note.cards()) == 1 assert ( note.cards()[0] .question() .endswith( r'\(a\) [...] \[ [...] \]' ) ) def test_typecloze(): col = getEmptyCol() m = col.models.by_name("Cloze") m["tmpls"][0]["qfmt"] = "{{cloze:Text}}{{type:cloze:Text}}" col.models.save(m) note = col.new_note(m) note["Text"] = "hello {{c1::world}}" col.addNote(note) assert "[[type:cloze:Text]]" in note.cards()[0].question() def test_chained_mods(): col = getEmptyCol() m = col.models.by_name("Cloze") mm = col.models # We replace the default Cloze template t = mm.new_template("ChainedCloze") t["qfmt"] = "{{cloze:text:Text}}" t["afmt"] = "{{cloze:text:Text}}" mm.add_template(m, t) mm.save(m) col.models.remove_template(m, m["tmpls"][0]) col.models.update(m) note = col.newNote() a1 = 'phrase' h1 = "sentence" a2 = 'en chaine' h2 = "chained" note["Text"] = ( "This {{{{c1::{}::{}}}}} demonstrates {{{{c1::{}::{}}}}} clozes.".format( a1, h1, a2, h2, ) ) assert col.addNote(note) == 1 assert ( 'This [sentence]' f' demonstrates [chained] clozes.' in note.cards()[0].question() ) assert ( 'This phrase demonstrates en chaine clozes.' in note.cards()[0].answer() ) def test_modelChange(): col = getEmptyCol() cloze = col.models.by_name("Cloze") # enable second template and add a note m = col.models.current() mm = col.models t = mm.new_template("Reverse") t["qfmt"] = "{{Back}}" t["afmt"] = "{{Front}}" mm.add_template(m, t) mm.save(m) basic = m note = col.newNote() note["Front"] = "note" note["Back"] = "b123" col.addNote(note) # switch fields map = {0: 1, 1: 0} noop = {0: 0, 1: 1} col.models.change(basic, [note.id], basic, map, None) note.load() assert note["Front"] == "b123" assert note["Back"] == "note" # switch cards c0 = note.cards()[0] c1 = note.cards()[1] assert "b123" in c0.question() assert "note" in c1.question() assert c0.ord == 0 assert c1.ord == 1 col.models.change(basic, [note.id], basic, noop, map) note.load() c0.load() c1.load() assert "note" in c0.question() assert "b123" in c1.question() assert c0.ord == 1 assert c1.ord == 0 # .cards() returns cards in order assert note.cards()[0].id == c1.id # delete first card map = {0: None, 1: 1} time.sleep(0.25) col.models.change(basic, [note.id], basic, noop, map) note.load() c0.load() # the card was deleted try: c1.load() assert 0 except NotFoundError: pass # but we have two cards, as a new one was generated assert len(note.cards()) == 2 # an unmapped field becomes blank assert note["Front"] == "b123" assert note["Back"] == "note" col.models.change(basic, [note.id], basic, map, None) note.load() assert note["Front"] == "" assert note["Back"] == "note" # another note to try model conversion note = col.newNote() note["Front"] = "f2" note["Back"] = "b2" col.addNote(note) counts = col.models.all_use_counts() assert next(c.use_count for c in counts if c.name == "Basic") == 2 assert next(c.use_count for c in counts if c.name == "Cloze") == 0 map = {0: 0, 1: 1} col.models.change(basic, [note.id], cloze, map, map) note.load() assert note["Text"] == "f2" assert len(note.cards()) == 2 # back the other way, with deletion of second ord col.models.remove_template(basic, basic["tmpls"][1]) col.models.update(basic) assert col.db.scalar("select count() from cards where nid = ?", note.id) == 2 map = {0: 0} col.models.change(cloze, [note.id], basic, map, map) assert col.db.scalar("select count() from cards where nid = ?", note.id) == 1 def test_req(): def reqSize(model): if model["type"] == MODEL_CLOZE: return assert len(model["tmpls"]) == len(model["req"]) col = getEmptyCol() mm = col.models basic = mm.by_name("Basic") assert "req" in basic reqSize(basic) r = basic["req"][0] assert r[0] == 0 assert r[1] in ("any", "all") assert r[2] == [0] opt = mm.by_name("Basic (optional reversed card)") reqSize(opt) r = opt["req"][0] assert r[1] in ("any", "all") assert r[2] == [0] assert opt["req"][1] == [1, "all", [1, 2]] # testing any opt["tmpls"][1]["qfmt"] = "{{Back}}{{Add Reverse}}" mm.save(opt, templates=True) assert opt["req"][1] == [1, "any", [1, 2]] # testing None opt["tmpls"][1]["qfmt"] = "{{^Add Reverse}}{{Tags}}{{/Add Reverse}}" mm.save(opt, templates=True) assert opt["req"][1] == [1, "none", []] opt = mm.by_name("Basic (type in the answer)") reqSize(opt) r = opt["req"][0] assert r[1] in ("any", "all") assert r[2] == [0, 1] ================================================ FILE: pylib/tests/test_schedv3.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import copy import os import time from collections.abc import Callable from typing import Dict import pytest from anki import hooks from anki.consts import * from anki.lang import without_unicode_isolation from anki.scheduler import UnburyDeck from anki.utils import int_time from tests.shared import getEmptyCol as getEmptyColOrig def getEmptyCol(): col = getEmptyColOrig() return col def test_clock(): col = getEmptyCol() if (col.sched.day_cutoff - int_time()) < 10 * 60: raise Exception("Unit tests will fail around the day rollover.") def test_basics(): col = getEmptyCol() assert not col.sched.getCard() def test_new(): col = getEmptyCol() assert col.sched.newCount == 0 # add a note note = col.newNote() note["Front"] = "one" note["Back"] = "two" col.addNote(note) assert col.sched.newCount == 1 # fetch it c = col.sched.getCard() assert c assert c.queue == QUEUE_TYPE_NEW assert c.type == CARD_TYPE_NEW # if we answer it, it should become a learn card t = int_time() col.sched.answerCard(c, 1) assert c.queue == QUEUE_TYPE_LRN assert c.type == CARD_TYPE_LRN assert c.due >= t # disabled for now, as the learn fudging makes this randomly fail # # the default order should ensure siblings are not seen together, and # # should show all cards # m = col.models.current(); mm = col.models # t = mm.new_template("Reverse") # t['qfmt'] = "{{Back}}" # t['afmt'] = "{{Front}}" # mm.add_template(m, t) # mm.save(m) # note = col.newNote() # note['Front'] = u"2"; note['Back'] = u"2" # col.addNote(note) # note = col.newNote() # note['Front'] = u"3"; note['Back'] = u"3" # col.addNote(note) # col.reset() # qs = ("2", "3", "2", "3") # for n in range(4): # c = col.sched.getCard() # assert qs[n] in c.question() # col.sched.answerCard(c, 2) def test_newLimits(): col = getEmptyCol() # add some notes deck2 = col.decks.id("Default::foo") for i in range(30): note = col.newNote() note["Front"] = str(i) if i > 4: note_type = note.note_type() note_type["did"] = deck2 col.models.update_dict(note_type) col.addNote(note) # give the child deck a different configuration c2 = col.decks.add_config_returning_id("new conf") col.decks.set_config_id_for_deck_dict(col.decks.get(deck2), c2) # both confs have defaulted to a limit of 20 assert col.sched.newCount == 20 # first card we get comes from parent c = col.sched.getCard() assert c.did == 1 # limit the parent to 10 cards, meaning we get 10 in total conf1 = col.decks.config_dict_for_deck_id(1) conf1["new"]["perDay"] = 10 col.decks.save(conf1) assert col.sched.newCount == 10 # if we limit child to 4, we should get 9 conf2 = col.decks.config_dict_for_deck_id(deck2) conf2["new"]["perDay"] = 4 col.decks.save(conf2) assert col.sched.newCount == 9 def test_newBoxes(): col = getEmptyCol() note = col.newNote() note["Front"] = "one" col.addNote(note) c = col.sched.getCard() conf = col.sched._cardConf(c) conf["new"]["delays"] = [1, 2, 3, 4, 5] col.decks.save(conf) col.sched.answerCard(c, 2) # should handle gracefully conf["new"]["delays"] = [1] col.decks.save(conf) col.sched.answerCard(c, 2) def test_learn(): col = getEmptyCol() # add a note note = col.newNote() note["Front"] = "one" note["Back"] = "two" col.addNote(note) # set as a new card and rebuild queues col.db.execute(f"update cards set queue={QUEUE_TYPE_NEW}, type={CARD_TYPE_NEW}") # sched.getCard should return it, since it's due in the past c = col.sched.getCard() assert c conf = col.sched._cardConf(c) conf["new"]["delays"] = [0.5, 3, 10] col.decks.save(conf) # fail it col.sched.answerCard(c, 1) # it should have three reps left to graduation assert c.left % 1000 == 3 # it should be due in 30 seconds t = round(c.due - time.time()) assert t >= 25 and t <= 40 # pass it once col.sched.answerCard(c, 3) # it should be due in 3 minutes dueIn = c.due - time.time() assert 178 <= dueIn <= 180 * 1.25 assert c.left % 1000 == 2 # check log is accurate log = col.db.first("select * from revlog order by id desc") assert log[3] == 3 assert log[4] == -180 assert log[5] == -30 # pass again col.sched.answerCard(c, 3) # it should be due in 10 minutes dueIn = c.due - time.time() assert 598 <= dueIn <= 600 * 1.25 assert c.left % 1000 == 1 # the next pass should graduate the card assert c.queue == QUEUE_TYPE_LRN assert c.type == CARD_TYPE_LRN col.sched.answerCard(c, 3) assert c.queue == QUEUE_TYPE_REV assert c.type == CARD_TYPE_REV # should be due tomorrow, with an interval of 1 assert c.due == col.sched.today + 1 assert c.ivl == 1 # or normal removal c.type = CARD_TYPE_NEW c.queue = QUEUE_TYPE_LRN c.flush() col.sched.answerCard(c, 4) assert c.type == CARD_TYPE_REV assert c.queue == QUEUE_TYPE_REV # revlog should have been updated each time assert col.db.scalar("select count() from revlog where type = 0") == 5 def test_relearn(): col = getEmptyCol() note = col.newNote() note["Front"] = "one" col.addNote(note) c = note.cards()[0] c.ivl = 100 c.due = col.sched.today c.queue = CARD_TYPE_REV c.type = QUEUE_TYPE_REV c.flush() # fail the card c = col.sched.getCard() col.sched.answerCard(c, 1) assert c.queue == QUEUE_TYPE_LRN assert c.type == CARD_TYPE_RELEARNING assert c.ivl == 1 # immediately graduate it col.sched.answerCard(c, 4) assert c.queue == CARD_TYPE_REV and c.type == QUEUE_TYPE_REV assert c.ivl == 2 assert c.due == col.sched.today + c.ivl def test_relearn_no_steps(): col = getEmptyCol() note = col.newNote() note["Front"] = "one" col.addNote(note) c = note.cards()[0] c.ivl = 100 c.due = col.sched.today c.queue = CARD_TYPE_REV c.type = QUEUE_TYPE_REV c.flush() conf = col.decks.config_dict_for_deck_id(1) conf["lapse"]["delays"] = [] col.decks.save(conf) # fail the card c = col.sched.getCard() col.sched.answerCard(c, 1) assert c.queue == CARD_TYPE_REV and c.type == QUEUE_TYPE_REV def test_learn_collapsed(): col = getEmptyCol() # add 2 notes note = col.newNote() note["Front"] = "1" col.addNote(note) note = col.newNote() note["Front"] = "2" col.addNote(note) # set as a new card and rebuild queues col.db.execute(f"update cards set queue={QUEUE_TYPE_NEW}, type={CARD_TYPE_NEW}") # should get '1' first c = col.sched.getCard() assert c.question().endswith("1") # pass it so it's due in 10 minutes col.sched.answerCard(c, 3) # get the other card c = col.sched.getCard() assert c.question().endswith("2") # fail it so it's due in 1 minute col.sched.answerCard(c, 1) # we shouldn't get the same card again c = col.sched.getCard() assert not c.question().endswith("2") def test_learn_day(): col = getEmptyCol() # add a note note = col.newNote() note["Front"] = "one" col.addNote(note) note = col.newNote() note["Front"] = "two" col.addNote(note) c = col.sched.getCard() conf = col.sched._cardConf(c) conf["new"]["delays"] = [1, 10, 1440, 2880] col.decks.save(conf) # pass it col.sched.answerCard(c, 3) # two reps to graduate, 1 more today assert c.left % 1000 == 3 assert col.sched.counts() == (1, 1, 0) c.load() ni = col.sched.nextIvl assert ni(c, 3) == 86400 # answer the other dummy card col.sched.answerCard(col.sched.getCard(), 4) # answering the first one will place it in queue 3 c = col.sched.getCard() col.sched.answerCard(c, 3) assert c.due == col.sched.today + 1 assert c.queue == QUEUE_TYPE_DAY_LEARN_RELEARN assert not col.sched.getCard() # for testing, move it back a day c.due -= 1 c.flush() assert col.sched.counts() == (0, 1, 0) c = col.sched.getCard() # nextIvl should work assert ni(c, 3) == 86400 * 2 # if we fail it, it should be back in the correct queue col.sched.answerCard(c, 1) assert c.queue == QUEUE_TYPE_LRN col.undo() c = col.sched.getCard() col.sched.answerCard(c, 3) # simulate the passing of another two days c.due -= 2 c.flush() # the last pass should graduate it into a review card assert ni(c, 3) == 86400 col.sched.answerCard(c, 3) assert c.queue == CARD_TYPE_REV and c.type == QUEUE_TYPE_REV # if the lapse step is tomorrow, failing it should handle the counts # correctly c.due = 0 c.flush() assert col.sched.counts() == (0, 0, 1) conf = col.sched._cardConf(c) conf["lapse"]["delays"] = [1440] col.decks.save(conf) c = col.sched.getCard() col.sched.answerCard(c, 1) assert c.queue == QUEUE_TYPE_DAY_LEARN_RELEARN assert col.sched.counts() == (0, 0, 0) def test_reviews(): col = getEmptyCol() # add a note note = col.newNote() note["Front"] = "one" note["Back"] = "two" col.addNote(note) # set the card up as a review card, due 8 days ago c = note.cards()[0] c.type = CARD_TYPE_REV c.queue = QUEUE_TYPE_REV c.due = col.sched.today - 8 c.factor = STARTING_FACTOR c.reps = 3 c.lapses = 1 c.ivl = 100 c.start_timer() c.flush() # save it for later use as well cardcopy = copy.copy(c) # try with an ease of 2 ################################################## c = copy.copy(cardcopy) c.flush() col.sched.answerCard(c, 2) assert c.queue == QUEUE_TYPE_REV # the new interval should be (100) * 1.2 = 120 assert c.due == col.sched.today + c.ivl # factor should have been decremented assert c.factor == 2350 # check counters assert c.lapses == 1 assert c.reps == 4 # ease 3 ################################################## c = copy.copy(cardcopy) c.flush() col.sched.answerCard(c, 3) # the new interval should be (100 + 8/2) * 2.5 = 260 assert c.due == col.sched.today + c.ivl # factor should have been left alone assert c.factor == STARTING_FACTOR # ease 4 ################################################## c = copy.copy(cardcopy) c.flush() col.sched.answerCard(c, 4) # the new interval should be (100 + 8) * 2.5 * 1.3 = 351 assert c.due == col.sched.today + c.ivl # factor should have been increased assert c.factor == 2650 # leech handling ################################################## conf = col.decks.get_config(1) conf["lapse"]["leechAction"] = LEECH_SUSPEND col.decks.save(conf) c = copy.copy(cardcopy) c.lapses = 7 c.flush() col.sched.answerCard(c, 1) assert c.queue == QUEUE_TYPE_SUSPENDED c.load() assert c.queue == QUEUE_TYPE_SUSPENDED assert "leech" in c.note().tags def review_limits_setup() -> tuple[anki.collection.Collection, dict]: col = getEmptyCol() parent = col.decks.get(col.decks.id("parent")) child = col.decks.get(col.decks.id("parent::child")) pconf = col.decks.get_config(col.decks.add_config_returning_id("parentConf")) cconf = col.decks.get_config(col.decks.add_config_returning_id("childConf")) pconf["rev"]["perDay"] = 5 col.decks.update_config(pconf) col.decks.set_config_id_for_deck_dict(parent, pconf["id"]) cconf["rev"]["perDay"] = 10 col.decks.update_config(cconf) col.decks.set_config_id_for_deck_dict(child, cconf["id"]) m = col.models.current() m["did"] = child["id"] col.models.save(m, updateReqs=False) # add some cards for i in range(20): note = col.newNote() note["Front"] = "one" note["Back"] = "two" col.addNote(note) # make them reviews c = note.cards()[0] c.queue = CARD_TYPE_REV c.type = QUEUE_TYPE_REV c.due = 0 c.flush() return col, child def test_review_limits(): col, child = review_limits_setup() tree = col.sched.deck_due_tree().children # (('parent', 1514457677462, 5, 0, 0, (('child', 1514457677463, 5, 0, 0, ()),))) assert tree[0].review_count == 5 # parent assert tree[0].children[0].review_count == 10 # child # .counts() should match col.decks.select(child["id"]) col.sched.reset() assert col.sched.counts() == (0, 0, 10) # answering a card in the child should decrement parent count c = col.sched.getCard() col.sched.answerCard(c, 3) assert col.sched.counts() == (0, 0, 9) tree = col.sched.deck_due_tree().children assert tree[0].review_count == 4 # parent assert tree[0].children[0].review_count == 9 # child def test_button_spacing(): col = getEmptyCol() note = col.newNote() note["Front"] = "one" col.addNote(note) # 1 day ivl review card due now c = note.cards()[0] c.type = CARD_TYPE_REV c.queue = QUEUE_TYPE_REV c.due = col.sched.today c.reps = 1 c.ivl = 1 c.start_timer() c.flush() ni = col.sched.nextIvlStr wo = without_unicode_isolation assert wo(ni(c, 2)) == "2d" assert wo(ni(c, 3)) == "3d" assert wo(ni(c, 4)) == "4d" # if hard factor is <= 1, then hard may not increase conf = col.decks.config_dict_for_deck_id(1) conf["rev"]["hardFactor"] = 1 col.decks.save(conf) assert wo(ni(c, 2)) == "1d" def test_nextIvl(): col = getEmptyCol() note = col.newNote() note["Front"] = "one" note["Back"] = "two" col.addNote(note) conf = col.decks.config_dict_for_deck_id(1) conf["new"]["delays"] = [0.5, 3, 10] conf["lapse"]["delays"] = [1, 5, 9] col.decks.save(conf) c = col.sched.getCard() # new cards ################################################## ni = col.sched.nextIvl assert ni(c, 1) == 30 assert ni(c, 2) == (30 + 180) // 2 assert ni(c, 3) == 180 assert ni(c, 4) == 4 * 86400 col.sched.answerCard(c, 1) # cards in learning ################################################## assert ni(c, 1) == 30 assert ni(c, 2) == (30 + 180) // 2 assert ni(c, 3) == 180 assert ni(c, 4) == 4 * 86400 col.sched.answerCard(c, 3) assert ni(c, 1) == 30 assert ni(c, 2) == 180 assert ni(c, 3) == 600 assert ni(c, 4) == 4 * 86400 col.sched.answerCard(c, 3) # normal graduation is tomorrow assert ni(c, 3) == 1 * 86400 assert ni(c, 4) == 4 * 86400 # lapsed cards ################################################## c.type = CARD_TYPE_RELEARNING c.ivl = 100 c.factor = STARTING_FACTOR c.flush() assert ni(c, 1) == 60 assert ni(c, 3) == 100 * 86400 assert ni(c, 4) == 101 * 86400 # review cards ################################################## c.type = CARD_TYPE_REV c.queue = QUEUE_TYPE_REV c.ivl = 100 c.factor = STARTING_FACTOR c.flush() # failing it should put it at 60s assert ni(c, 1) == 60 # or 1 day if relearn is false conf["lapse"]["delays"] = [] col.decks.save(conf) assert ni(c, 1) == 1 * 86400 # (* 100 1.2 86400)10368000.0 assert ni(c, 2) == 10368000 # (* 100 2.5 86400)21600000.0 assert ni(c, 3) == 21600000 # (* 100 2.5 1.3 86400)28080000.0 assert ni(c, 4) == 28080000 assert without_unicode_isolation(col.sched.nextIvlStr(c, 4)) == "10.7mo" def test_bury(): col = getEmptyCol() note = col.newNote() note["Front"] = "one" col.addNote(note) c = note.cards()[0] note = col.newNote() note["Front"] = "two" col.addNote(note) c2 = note.cards()[0] # burying col.sched.bury_cards([c.id], manual=True) c.load() assert c.queue == QUEUE_TYPE_MANUALLY_BURIED col.sched.bury_cards([c2.id], manual=False) c2.load() assert c2.queue == QUEUE_TYPE_SIBLING_BURIED assert not col.sched.getCard() col.sched.unbury_deck(deck_id=col.decks.get_current_id(), mode=UnburyDeck.USER_ONLY) c.load() assert c.queue == QUEUE_TYPE_NEW c2.load() assert c2.queue == QUEUE_TYPE_SIBLING_BURIED col.sched.unbury_deck( deck_id=col.decks.get_current_id(), mode=UnburyDeck.SCHED_ONLY ) c2.load() assert c2.queue == QUEUE_TYPE_NEW col.sched.bury_cards([c.id, c2.id]) col.sched.unbury_deck(deck_id=col.decks.get_current_id()) assert col.sched.counts() == (2, 0, 0) def test_suspend(): col = getEmptyCol() note = col.newNote() note["Front"] = "one" col.addNote(note) c = note.cards()[0] # suspending assert col.sched.getCard() col.sched.suspend_cards([c.id]) assert not col.sched.getCard() # unsuspending col.sched.unsuspend_cards([c.id]) assert col.sched.getCard() # should cope with rev cards being relearnt c.due = 0 c.ivl = 100 c.type = CARD_TYPE_REV c.queue = QUEUE_TYPE_REV c.flush() c = col.sched.getCard() col.sched.answerCard(c, 1) assert c.due >= time.time() due = c.due assert c.queue == QUEUE_TYPE_LRN assert c.type == CARD_TYPE_RELEARNING col.sched.suspend_cards([c.id]) col.sched.unsuspend_cards([c.id]) c.load() assert c.queue == QUEUE_TYPE_LRN assert c.type == CARD_TYPE_RELEARNING assert c.due == due # should cope with cards in cram decks c.due = 1 c.flush() did = col.decks.new_filtered("tmp") col.sched.rebuild_filtered_deck(did) c.load() assert c.due != 1 assert c.did != 1 col.sched.suspend_cards([c.id]) c.load() assert c.due != 1 assert c.did != 1 assert c.odue == 1 def test_filt_reviewing_early_normal(): col = getEmptyCol() note = col.newNote() note["Front"] = "one" col.addNote(note) c = note.cards()[0] c.ivl = 100 c.queue = CARD_TYPE_REV c.type = QUEUE_TYPE_REV # due in 25 days, so it's been waiting 75 days c.due = col.sched.today + 25 c.mod = 1 c.factor = STARTING_FACTOR c.start_timer() c.flush() assert col.sched.counts() == (0, 0, 0) # create a dynamic deck and refresh it did = col.decks.new_filtered("Cram") col.sched.rebuild_filtered_deck(did) # should appear as normal in the deck list assert sorted(col.sched.deck_due_tree().children)[0].review_count == 1 # and should appear in the counts assert col.sched.counts() == (0, 0, 1) # grab it and check estimates c = col.sched.getCard() assert col.sched.answerButtons(c) == 4 assert col.sched.nextIvl(c, 1) == 600 assert col.sched.nextIvl(c, 2) == round(75 * 1.2) * 86400 assert col.sched.nextIvl(c, 3) == round(75 * 2.5) * 86400 assert col.sched.nextIvl(c, 4) == round(75 * 2.5 * 1.15) * 86400 # answer 'good' col.sched.answerCard(c, 3) assert c.due == col.sched.today + c.ivl # should not be in learning assert c.queue == QUEUE_TYPE_REV # should be logged as a cram rep assert col.db.scalar("select type from revlog order by id desc limit 1") == 3 # due in 75 days, so it's been waiting 25 days c.ivl = 100 c.due = col.sched.today + 75 c.flush() col.sched.rebuild_filtered_deck(did) c = col.sched.getCard() assert col.sched.nextIvl(c, 2) == 100 * 1.2 / 2 * 86400 assert col.sched.nextIvl(c, 3) == 100 * 86400 assert col.sched.nextIvl(c, 4) == round(100 * (1.3 - (1.3 - 1) / 2)) * 86400 def test_filt_keep_lrn_state(): col = getEmptyCol() note = col.newNote() note["Front"] = "one" col.addNote(note) # fail the card outside filtered deck c = col.sched.getCard() conf = col.sched._cardConf(c) conf["new"]["delays"] = [1, 10, 61] col.decks.save(conf) col.sched.answerCard(c, 1) assert c.type == CARD_TYPE_LRN and c.queue == QUEUE_TYPE_LRN assert c.left % 1000 == 3 col.sched.answerCard(c, 3) assert c.type == CARD_TYPE_LRN and c.queue == QUEUE_TYPE_LRN # create a dynamic deck and refresh it did = col.decks.new_filtered("Cram") col.sched.rebuild_filtered_deck(did) # card should still be in learning state c.load() assert c.type == CARD_TYPE_LRN and c.queue == QUEUE_TYPE_LRN assert c.left % 1000 == 2 # should be able to advance learning steps col.sched.answerCard(c, 3) # should be due at least an hour in the future assert c.due - int_time() > 60 * 60 # emptying the deck preserves learning state col.sched.empty_filtered_deck(did) c.load() assert c.type == CARD_TYPE_LRN and c.queue == QUEUE_TYPE_LRN assert c.left % 1000 == 1 assert c.due - int_time() > 60 * 60 def test_preview(): # add cards col = getEmptyCol() note = col.newNote() note["Front"] = "one" col.addNote(note) c = note.cards()[0] note2 = col.newNote() note2["Front"] = "two" col.addNote(note2) # cram deck did = col.decks.new_filtered("Cram") cram = col.decks.get(did) cram["resched"] = False col.decks.save(cram) col.sched.rebuild_filtered_deck(did) # grab the first card c = col.sched.getCard() passing_grade = 4 assert col.sched.answerButtons(c) == passing_grade assert col.sched.nextIvl(c, 1) == 60 assert col.sched.nextIvl(c, passing_grade) == 0 # failing it will push its due time back due = c.due col.sched.answerCard(c, 1) assert c.due != due # the other card should come next c2 = col.sched.getCard() assert c2.id != c.id # passing it will remove it col.sched.answerCard(c2, passing_grade) assert c2.queue == QUEUE_TYPE_NEW assert c2.reps == 0 assert c2.type == CARD_TYPE_NEW # emptying the filtered deck should restore card col.sched.empty_filtered_deck(did) c.load() assert c.queue == QUEUE_TYPE_NEW assert c.reps == 0 assert c.type == CARD_TYPE_NEW def test_ordcycle(): col = getEmptyCol() # add two more templates and set second active m = col.models.current() mm = col.models t = mm.new_template("Reverse") t["qfmt"] = "{{Back}}" t["afmt"] = "{{Front}}" mm.add_template(m, t) t = mm.new_template("f2") t["qfmt"] = "{{Front}}2" t["afmt"] = "{{Back}}" mm.add_template(m, t) mm.save(m) # create a new note; it should have 3 cards note = col.newNote() note["Front"] = "1" note["Back"] = "1" col.addNote(note) assert col.card_count() == 3 conf = col.decks.get_config(1) conf["new"]["bury"] = False col.decks.save(conf) # ordinals should arrive in order for i in range(3): c = col.sched.getCard() assert c.ord == i col.sched.answerCard(c, 4) def test_counts_idx_new(): col = getEmptyCol() note = col.newNote() note["Front"] = "one" note["Back"] = "two" col.addNote(note) note = col.newNote() note["Front"] = "two" note["Back"] = "two" col.addNote(note) assert col.sched.counts() == (2, 0, 0) c = col.sched.getCard() # getCard does not decrement counts assert col.sched.counts() == (2, 0, 0) assert col.sched.countIdx(c) == 0 # answer to move to learn queue col.sched.answerCard(c, 1) assert col.sched.counts() == (1, 1, 0) assert col.sched.countIdx(c) == 1 # fetching next will not decrement the count c = col.sched.getCard() assert col.sched.counts() == (1, 1, 0) assert col.sched.countIdx(c) == 0 def test_repCounts(): col = getEmptyCol() note = col.newNote() note["Front"] = "one" col.addNote(note) note = col.newNote() note["Front"] = "two" col.addNote(note) # lrnReps should be accurate on pass/fail assert col.sched.counts() == (2, 0, 0) col.sched.answerCard(col.sched.getCard(), 1) assert col.sched.counts() == (1, 1, 0) col.sched.answerCard(col.sched.getCard(), 1) assert col.sched.counts() == (0, 2, 0) col.sched.answerCard(col.sched.getCard(), 3) assert col.sched.counts() == (0, 2, 0) col.sched.answerCard(col.sched.getCard(), 1) assert col.sched.counts() == (0, 2, 0) col.sched.answerCard(col.sched.getCard(), 3) assert col.sched.counts() == (0, 1, 0) col.sched.answerCard(col.sched.getCard(), 4) assert col.sched.counts() == (0, 0, 0) note = col.newNote() note["Front"] = "three" col.addNote(note) note = col.newNote() note["Front"] = "four" col.addNote(note) # initial pass and immediate graduate should be correct too assert col.sched.counts() == (2, 0, 0) col.sched.answerCard(col.sched.getCard(), 3) assert col.sched.counts() == (1, 1, 0) col.sched.answerCard(col.sched.getCard(), 4) assert col.sched.counts() == (0, 1, 0) col.sched.answerCard(col.sched.getCard(), 4) assert col.sched.counts() == (0, 0, 0) # and failing a review should too note = col.newNote() note["Front"] = "five" col.addNote(note) c = note.cards()[0] c.type = CARD_TYPE_REV c.queue = QUEUE_TYPE_REV c.due = col.sched.today c.flush() note = col.newNote() note["Front"] = "six" col.addNote(note) assert col.sched.counts() == (1, 0, 1) col.sched.answerCard(col.sched.getCard(), 1) assert col.sched.counts() == (1, 1, 0) def test_timing(): col = getEmptyCol() # add a few review cards, due today for i in range(5): note = col.newNote() note["Front"] = f"num{str(i)}" col.addNote(note) c = note.cards()[0] c.type = CARD_TYPE_REV c.queue = QUEUE_TYPE_REV c.due = 0 c.flush() # fail the first one c = col.sched.getCard() col.sched.answerCard(c, 1) # the next card should be another review c2 = col.sched.getCard() assert c2.queue == QUEUE_TYPE_REV # if the failed card becomes due, it should show first c.due = int_time() - 1 c.flush() c = col.sched.getCard() assert c.queue == QUEUE_TYPE_LRN def test_collapse(): col = getEmptyCol() # add a note note = col.newNote() note["Front"] = "one" col.addNote(note) # and another, so we don't get the same twice in a row note = col.newNote() note["Front"] = "two" col.addNote(note) # first note c = col.sched.getCard() col.sched.answerCard(c, 1) # second note c2 = col.sched.getCard() assert c2.nid != c.nid col.sched.answerCard(c2, 1) # first should become available again, despite it being due in the future c3 = col.sched.getCard() assert c3.due > int_time() col.sched.answerCard(c3, 4) # answer other c4 = col.sched.getCard() col.sched.answerCard(c4, 4) assert not col.sched.getCard() def test_deckDue(): col = getEmptyCol() # add a note with default deck note = col.newNote() note["Front"] = "one" col.addNote(note) # and one that's a child note = col.newNote() note["Front"] = "two" note_type = note.note_type() default1 = note_type["did"] = col.decks.id("Default::1") col.models.update_dict(note_type) col.addNote(note) # make it a review card c = note.cards()[0] c.queue = QUEUE_TYPE_REV c.due = 0 c.flush() # add one more with a new deck note = col.newNote() note["Front"] = "two" note_type = note.note_type() note_type["did"] = col.decks.id("foo::bar") col.models.update_dict(note_type) col.addNote(note) # and one that's a sibling note = col.newNote() note["Front"] = "three" note_type = note.note_type() note_type["did"] = col.decks.id("foo::baz") col.models.update_dict(note_type) col.addNote(note) assert len(col.decks.all_names_and_ids()) == 5 tree = col.sched.deck_due_tree().children assert tree[0].name == "Default" # sum of child and parent assert tree[0].deck_id == 1 assert tree[0].review_count == 1 assert tree[0].new_count == 1 # child count is just review child = tree[0].children[0] assert child.name == "1" assert child.deck_id == default1 assert child.review_count == 1 assert child.new_count == 0 # code should not fail if a card has an invalid deck c.did = 12345 c.flush() col.sched.deck_due_tree() def test_deckTree(): col = getEmptyCol() col.decks.id("new::b::c") col.decks.id("new2") # new should not appear twice in tree names = [x.name for x in col.sched.deck_due_tree().children] names.remove("new") assert "new" not in names def test_deckFlow(): col = getEmptyCol() # add a note with default deck note = col.newNote() note["Front"] = "one" col.addNote(note) # and one that's a child note = col.newNote() note["Front"] = "two" note_type = note.note_type() note_type["did"] = col.decks.id("Default::2") col.models.update_dict(note_type) col.addNote(note) # and another that's higher up note = col.newNote() note["Front"] = "three" note_type = note.note_type() default1 = note_type["did"] = col.decks.id("Default::1") col.models.update_dict(note_type) col.addNote(note) assert col.sched.counts() == (3, 0, 0) # should get top level one first, then ::1, then ::2 for i in "one", "three", "two": c = col.sched.getCard() assert c.note()["Front"] == i col.sched.answerCard(c, 3) def test_reorder(): col = getEmptyCol() # add a note with default deck note = col.newNote() note["Front"] = "one" col.addNote(note) note2 = col.newNote() note2["Front"] = "two" col.addNote(note2) assert note2.cards()[0].due == 2 found = False # 50/50 chance of being reordered for i in range(20): col.sched.randomize_cards(1) if note.cards()[0].due != note.id: found = True break assert found col.sched.order_cards(1) assert note.cards()[0].due == 1 # shifting note3 = col.newNote() note3["Front"] = "three" col.addNote(note3) note4 = col.newNote() note4["Front"] = "four" col.addNote(note4) assert note.cards()[0].due == 1 assert note2.cards()[0].due == 2 assert note3.cards()[0].due == 3 assert note4.cards()[0].due == 4 col.sched.reposition_new_cards( [note3.cards()[0].id, note4.cards()[0].id], starting_from=1, shift_existing=True, step_size=1, randomize=False, ) assert note.cards()[0].due == 3 assert note2.cards()[0].due == 4 assert note3.cards()[0].due == 1 assert note4.cards()[0].due == 2 def test_forget(): col = getEmptyCol() note = col.newNote() note["Front"] = "one" col.addNote(note) c = note.cards()[0] c.queue = QUEUE_TYPE_REV c.type = CARD_TYPE_REV c.ivl = 100 c.due = 0 c.flush() assert col.sched.counts() == (0, 0, 1) col.sched.forgetCards([c.id]) assert col.sched.counts() == (1, 0, 0) def test_resched(): col = getEmptyCol() note = col.newNote() note["Front"] = "one" col.addNote(note) c = note.cards()[0] col.sched.set_due_date([c.id], "0") c.load() assert c.due == col.sched.today assert c.ivl == 1 assert c.queue == QUEUE_TYPE_REV and c.type == CARD_TYPE_REV # make it due tomorrow col.sched.set_due_date([c.id], "1") c.load() assert c.due == col.sched.today + 1 assert c.ivl == 1 def test_norelearn(): col = getEmptyCol() # add a note note = col.newNote() note["Front"] = "one" col.addNote(note) c = note.cards()[0] c.type = CARD_TYPE_REV c.queue = QUEUE_TYPE_REV c.due = 0 c.factor = STARTING_FACTOR c.reps = 3 c.lapses = 1 c.ivl = 100 c.start_timer() c.flush() col.sched.answerCard(c, 1) col.sched._cardConf(c)["lapse"]["delays"] = [] col.sched.answerCard(c, 1) def test_failmult(): col = getEmptyCol() note = col.newNote() note["Front"] = "one" note["Back"] = "two" col.addNote(note) c = note.cards()[0] c.type = CARD_TYPE_REV c.queue = QUEUE_TYPE_REV c.ivl = 100 c.due = col.sched.today - c.ivl c.factor = STARTING_FACTOR c.reps = 3 c.lapses = 1 c.start_timer() c.flush() conf = col.sched._cardConf(c) conf["lapse"]["mult"] = 0.5 col.decks.save(conf) c = col.sched.getCard() col.sched.answerCard(c, 1) assert c.ivl == 50 col.sched.answerCard(c, 1) assert c.ivl == 25 # cards with a due date earlier than the collection should retain # their due date when removed def test_negativeDueFilter(): col = getEmptyCol() # card due prior to collection date note = col.newNote() note["Front"] = "one" note["Back"] = "two" col.addNote(note) c = note.cards()[0] c.due = -5 c.queue = QUEUE_TYPE_REV c.ivl = 5 c.flush() # into and out of filtered deck did = col.decks.new_filtered("Cram") col.sched.rebuild_filtered_deck(did) col.sched.empty_filtered_deck(did) c.load() assert c.due == -5 # hard on the first step should be the average of again and good, # and it should be logged properly def test_initial_repeat(): col = getEmptyCol() note = col.newNote() note["Front"] = "one" note["Back"] = "two" col.addNote(note) c = col.sched.getCard() col.sched.answerCard(c, 2) # should be due in ~ 5.5 mins expected = time.time() + 5.5 * 60 assert expected - 10 < c.due < expected * 1.25 ivl = col.db.scalar("select ivl from revlog") assert ivl == -5.5 * 60 ================================================ FILE: pylib/tests/test_stats.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import os import tempfile from anki.collection import CardStats from tests.shared import getEmptyCol def test_stats(): col = getEmptyCol() note = col.newNote() note["Front"] = "foo" col.addNote(note) c = note.cards()[0] # card stats card_stats = col.card_stats_data(c.id) assert card_stats.note_id == note.id c = col.sched.getCard() col.sched.answerCard(c, 3) col.sched.answerCard(c, 2) card_stats = col.card_stats_data(c.id) assert len(card_stats.revlog) == 2 def test_graphs_empty(): col = getEmptyCol() assert col.stats().report() def test_graphs(): dir = tempfile.gettempdir() col = getEmptyCol() g = col.stats() rep = g.report() with open(os.path.join(dir, "test.html"), "w", encoding="UTF-8") as note: note.write(rep) return ================================================ FILE: pylib/tests/test_template.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from tests.shared import getEmptyCol def test_deferred_frontside(): col = getEmptyCol() m = col.models.current() m["tmpls"][0]["qfmt"] = "{{custom:Front}}" col.models.save(m) note = col.newNote() note["Front"] = "xxtest" note["Back"] = "" col.addNote(note) assert "xxtest" in note.cards()[0].answer() ================================================ FILE: pylib/tests/test_utils.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from anki.utils import int_version_to_str def test_int_version_to_str(): assert int_version_to_str(23) == "2.1.23" assert int_version_to_str(230900) == "23.09" assert int_version_to_str(230901) == "23.09.1" ================================================ FILE: pylib/tools/genbuildinfo.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import sys version_file = sys.argv[1] buildhash_file = sys.argv[2] outpath = sys.argv[3] with open(version_file, "r", encoding="utf8") as f: version = f.read().strip() with open(buildhash_file, "r", encoding="utf8") as f: buildhash = f.read().strip() with open(outpath, "w", encoding="utf8") as f: # if we switch to uppercase we'll need to add legacy aliases f.write(f"version = '{version}'\n") f.write(f"buildhash = '{buildhash}'\n") ================================================ FILE: pylib/tools/genhooks.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """ Generate code for hook handling, and insert it into anki/hooks.py. To add a new hook, update the hooks list below, then send a pull request that includes the changes to this file. In most cases, hooks are better placed in genhooks_gui.py. """ import sys from hookslib import Hook, write_file # Hook/filter list ###################################################################### hooks = [ Hook(name="card_odue_was_invalid"), Hook(name="schema_will_change", args=["proceed: bool"], return_type="bool"), Hook( name="notes_will_be_deleted", args=["col: anki.collection.Collection", "ids: Sequence[anki.notes.NoteId]"], legacy_hook="remNotes", ), Hook( name="note_will_be_added", args=[ "col: anki.collection.Collection", "note: anki.notes.Note", "deck_id: anki.decks.DeckId", ], doc="""Allows modifying a note before it's added to the collection. This hook may be called both when users use the Add screen, and when add-ons like AnkiConnect add notes. It is not called when importing. If you wish to alter the Add screen, use gui_hooks.add_cards_will_add_note instead.""", ), Hook( name="media_files_did_export", args=["count: int"], doc="Only used by legacy .apkg exporter. Will be deprecated in the future.", ), Hook( name="legacy_export_progress", args=["progress: str"], doc="Temporary hook used in transition to new import/export code.", ), Hook( name="exporters_list_created", args=["exporters: list[tuple[str, Any]]"], legacy_hook="exportersList", ), Hook( name="media_file_filter", args=["txt: str"], return_type="str", doc="""Allows manipulating the file path that media will be read from""", ), Hook( name="field_filter", args=[ "field_text: str", "field_name: str", "filter_name: str", "ctx: anki.template.TemplateRenderContext", ], return_type="str", doc="""Allows you to define custom {{filters:..}} Your add-on can check filter_name to decide whether it should modify field_text or not before returning it.""", ), Hook( name="note_will_flush", args=["note: Note"], doc="Allow to change a note before it is added/updated in the database.", ), Hook( name="card_will_flush", args=["card: Card"], doc="Allow to change a card before it is added/updated in the database.", ), Hook( name="card_did_render", args=[ "output: anki.template.TemplateRenderOutput", "ctx: anki.template.TemplateRenderContext", ], doc="Can modify the resulting text after rendering completes.", ), Hook( name="importing_importers", args=["importers: list[tuple[str, Any]]"], doc="""Allows updating the list of importers. The resulting list is not saved and should be changed each time the filter is called. NOTE: Updates to the import/export code are expected in the coming months, and this hook may be replaced with another solution at that time. Tracked on https://github.com/ankitects/anki/issues/1018""", ), # obsolete Hook( name="deck_added", args=["deck: anki.decks.DeckDict"], doc="Obsolete, do not use.", ), Hook( name="note_type_added", args=["notetype: anki.models.NotetypeDict"], doc="Obsolete, do not use.", ), Hook( name="sync_stage_did_change", args=["stage: str"], doc="Obsolete, do not use.", ), Hook( name="sync_progress_did_change", args=["msg: str"], doc="Obsolete, do not use.", ), ] prefix = """\ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # This file is automatically generated; edit tools/genhooks.py instead. # Please import from anki.hooks instead of this file. # ruff: noqa: F401 from __future__ import annotations from collections.abc import Callable, Sequence from typing import Any import anki import anki.hooks from anki.cards import Card from anki.notes import Note """ suffix = "" if __name__ == "__main__": path = sys.argv[1] write_file(path, hooks, prefix, suffix) ================================================ FILE: pylib/tools/hookslib.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """ Code for generating hooks. """ from __future__ import annotations import subprocess import sys from dataclasses import dataclass from operator import attrgetter sys.path.append("pylib/anki/_vendor") import stringcase @dataclass class Hook: # the name of the hook. _filter or _hook is appending automatically. name: str # string of the typed arguments passed to the callback, eg # ["kind: str", "val: int"] args: list[str] | None = None # string of the return type. if set, hook is a filter. return_type: str | None = None # if add-ons may be relying on the legacy hook name, add it here legacy_hook: str | None = None # if legacy hook takes no arguments but the new hook does, set this legacy_no_args: bool = False # if the hook replaces a deprecated one, add its name here replaces: str | None = None # arguments that the hook being replaced took replaced_hook_args: list[str] | None = None # docstring to add to hook class doc: str | None = None def callable(self) -> str: "Convert args into a Callable." types = [] for arg in self.args or []: (name, type) = arg.split(":") type = f'"{type.strip()}"' types.append(type) types_str = ", ".join(types) return f"Callable[[{types_str}], {self.return_type or 'None'}]" def arg_names(self, args: list[str] | None) -> list[str]: names = [] for arg in args or []: if not arg: continue (name, type) = arg.split(":") names.append(name.strip()) return names def full_name(self) -> str: return f"{self.name}_{self.kind()}" def kind(self) -> str: if self.return_type is not None: return "filter" else: return "hook" def classname(self) -> str: return f"_{stringcase.pascalcase(self.full_name())}" def list_code(self) -> str: return f"""\ _hooks: list[{self.callable()}] = [] """ def code(self) -> str: appenddoc = f"({', '.join(self.args or [])})" if self.doc: classdoc = f" '''{self.doc}'''\n" else: classdoc = "" code = f"""\ class {self.classname()}: {classdoc}{self.list_code()} def append(self, callback: {self.callable()}) -> None: '''{appenddoc}''' self._hooks.append(callback) def remove(self, callback: {self.callable()}) -> None: if callback in self._hooks: self._hooks.remove(callback) def count(self) -> int: return len(self._hooks) {self.fire_code()} {self.name} = {self.classname()}() """ return code def fire_code(self) -> str: if self.return_type is not None: # filter return self.filter_fire_code() else: # hook return self.hook_fire_code() def legacy_args(self) -> str: if self.legacy_no_args: # hook name only return f'"{self.legacy_hook}"' else: return ", ".join([f'"{self.legacy_hook}"'] + self.arg_names(self.args)) def replaced_args(self) -> str: args = ", ".join(self.arg_names(self.replaced_hook_args)) return f"{self.replaces}({args})" def hook_fire_code(self) -> str: arg_names = self.arg_names(self.args) args_including_self = ["self"] + (self.args or []) out = f"""\ def __call__({", ".join(args_including_self)}) -> None: for hook in self._hooks: try: hook({", ".join(arg_names)}) except Exception: # if the hook fails, remove it self._hooks.remove(hook) raise """ if self.replaces and self.legacy_hook: raise Exception( f"Hook {self.name} replaces {self.replaces} and " "must therefore not define a legacy hook." ) elif self.replaces: out += f"""\ if {self.replaces}.count() > 0: print( "The hook {self.replaces} is deprecated.\\n" "Use {self.name} instead." ) {self.replaced_args()} """ elif self.legacy_hook: # don't run legacy hook if replaced hook exists # otherwise the legacy hook will be run twice out += f"""\ # legacy support anki.hooks.runHook({self.legacy_args()}) """ return f"{out}\n\n" def filter_fire_code(self) -> str: arg_names = self.arg_names(self.args) args_including_self = ["self"] + (self.args or []) out = f"""\ def __call__({", ".join(args_including_self)}) -> {self.return_type}: for filter in self._hooks: try: {arg_names[0]} = filter({", ".join(arg_names)}) except Exception: # if the hook fails, remove it self._hooks.remove(filter) raise """ if self.replaces and self.legacy_hook: raise Exception( f"Hook {self.name} replaces {self.replaces} and " "must therefore not define a legacy hook." ) elif self.replaces: out += f"""\ if {self.replaces}.count() > 0: print( "The hook {self.replaces} is deprecated.\\n" "Use {self.name} instead." ) {arg_names[0]} = {self.replaced_args()} """ elif self.legacy_hook: # don't run legacy hook if replaced hook exists # otherwise the legacy hook will be run twice out += f"""\ # legacy support {arg_names[0]} = anki.hooks.runFilter({self.legacy_args()}) """ out += f"""\ return {arg_names[0]} """ return f"{out}\n\n" def write_file(path: str, hooks: list[Hook], prefix: str, suffix: str): hooks.sort(key=attrgetter("name")) code = f"{prefix}\n" for hook in hooks: code += hook.code() code += f"\n{suffix}" with open(path, "wb") as file: file.write(code.encode("utf8")) subprocess.run([sys.executable, "-m", "ruff", "format", "-q", path], check=True) ================================================ FILE: pyproject.toml ================================================ [project] name = "anki-dev" version = "0.0.0" description = "Local-only environment" requires-python = ">=3.9" classifiers = ["Private :: Do Not Upload"] [dependency-groups] dev = [ "mypy", "mypy-protobuf", "ruff", "pytest", "PyChromeDevTools", "wheel", "hatchling", # for type checking hatch_build.py files "mock", "types-protobuf", "types-requests", "types-orjson", "types-decorator", "types-flask", "types-flask-cors", "types-markdown", "types-waitress", "types-pywin32", ] [project.optional-dependencies] sphinx = [ "sphinx", "sphinx_rtd_theme", "sphinx-autoapi", ] [tool.uv.workspace] members = ["pylib", "qt"] [[tool.uv.index]] name = "testpypi" url = "https://test.pypi.org/simple/" publish-url = "https://test.pypi.org/legacy/" explicit = true ================================================ FILE: python/mkempty.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import sys open(sys.argv[1], "w", encoding="utf8").close() ================================================ FILE: python/sphinx/build.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import os import subprocess os.environ["REPO_ROOT"] = os.path.abspath(".") subprocess.run(["out/pyenv/bin/sphinx-apidoc", "-o", "out/python/sphinx", "pylib", "qt"], check=True) subprocess.run(["out/pyenv/bin/sphinx-build", "out/python/sphinx", "out/python/sphinx/html"], check=True) ================================================ FILE: python/sphinx/conf.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # Configuration file for the Sphinx documentation builder. # # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html import os project = 'Anki' copyright = '2023, Ankitects Pty Ltd and contributors' author = 'Ankitects Pty Ltd and contributors' REPO_ROOT = os.environ["REPO_ROOT"] extensions = ['sphinx_rtd_theme', 'autoapi.extension'] html_theme = 'sphinx_rtd_theme' autoapi_python_use_implicit_namespaces = True autoapi_dirs = [os.path.join(REPO_ROOT, x) for x in ["pylib/anki", "out/pylib/anki", "qt/aqt", "out/qt/_aqt"]] ================================================ FILE: python/sphinx/index.rst ================================================ .. Anki documentation master file, created by sphinx-quickstart on Tue Sep 26 09:41:18 2023. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Welcome to Anki's documentation! ================================ .. toctree:: :maxdepth: 2 :caption: Contents: Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` ================================================ FILE: python/version.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """Version helper for wheel builds.""" import pathlib # Read version from .version file in project root _version_file = pathlib.Path(__file__).parent.parent / ".version" __version__ = _version_file.read_text().strip() ================================================ FILE: qt/README.md ================================================ Python's Qt GUI is in aqt/ ================================================ FILE: qt/aqt/__init__.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations # ruff: noqa: F401 import atexit import logging import os import sys from collections.abc import Callable from typing import TYPE_CHECKING, Any, Union, cast if "ANKI_FIRST_RUN" in os.environ: from .package import first_run_setup first_run_setup() try: import pip_system_certs.wrapt_requests except ModuleNotFoundError: print( "Python module pip_system_certs is not installed. System certificate store and custom SSL certificates may not work. See: https://github.com/ankitects/anki/issues/3016" ) if sys.version_info[0] < 3 or sys.version_info[1] < 9: raise Exception("Anki requires Python 3.9+") # ensure unicode filenames are supported try: "テスト".encode(sys.getfilesystemencoding()) except UnicodeEncodeError: print("Anki requires a UTF-8 locale.") print("Please Google 'how to change locale on [your Linux distro]'") sys.exit(1) # if sync server enabled, bypass the rest of the startup if "--syncserver" in sys.argv: from anki.syncserver import run_sync_server from anki.utils import is_mac # does not return run_sync_server() if sys.platform == "win32": from win32com.shell import shell shell.SetCurrentProcessExplicitAppUserModelID("Ankitects.Anki") import argparse import builtins import cProfile import getpass import locale import tempfile import traceback from pathlib import Path import anki.lang from anki._backend import RustBackend from anki.buildinfo import version as _version from anki.collection import Collection from anki.consts import HELP_SITE from anki.utils import checksum, is_gnome, is_lin, is_mac from aqt import gui_hooks from aqt.log import setup_logging from aqt.qt import * from aqt.qt import sip from aqt.utils import TR, tr if TYPE_CHECKING: import aqt.profiles # compat aliases anki.version = _version # type: ignore anki.Collection = Collection # type: ignore # we want to be able to print unicode debug info to console without # fear of a traceback on systems with the console set to ASCII try: sys.stdout.reconfigure(encoding="utf-8") # type: ignore sys.stderr.reconfigure(encoding="utf-8") # type: ignore except AttributeError: if is_win: # On Windows without console; add a mock writer. The stderr # writer will be overwritten when ErrorHandler is initialized. sys.stderr = sys.stdout = open(os.devnull, "w", encoding="utf8") appVersion = _version appWebsite = "https://apps.ankiweb.net/" appWebsiteDownloadSection = "https://apps.ankiweb.net/#download" appDonate = "https://docs.ankiweb.net/contrib.html" appShared = "https://ankiweb.net/shared/" appUpdate = "https://ankiweb.net/update/desktop" appHelpSite = HELP_SITE from aqt.main import AnkiQt # isort:skip from aqt.profiles import ProfileManager, VideoDriver # isort:skip profiler: cProfile.Profile | None = None mw: AnkiQt = None # type: ignore # set on init import aqt.forms # Dialog manager ########################################################################## # ensures only one copy of the window is open at once, and provides # a way for dialogs to clean up asynchronously when collection closes # to integrate a new window: # - add it to _dialogs # - define close behaviour, by either: # -- setting silentlyClose=True to have it close immediately # -- define a closeWithCallback() method # - have the window opened via aqt.dialogs.open(, self) # - have a method reopen(*args), called if the user ask to open the window a second time. Arguments passed are the same than for original opening. # - make preferences modal? cmd+q does wrong thing from aqt import addcards, addons, browser, editcurrent, filtered_deck # isort:skip from aqt import stats, about, preferences, mediasync # isort:skip class DialogManager: _dialogs: dict[str, list] = { "AddCards": [addcards.AddCards, None], "AddonsDialog": [addons.AddonsDialog, None], "Browser": [browser.Browser, None], "EditCurrent": [editcurrent.EditCurrent, None], "FilteredDeckConfigDialog": [filtered_deck.FilteredDeckConfigDialog, None], "DeckStats": [stats.DeckStats, None], "NewDeckStats": [stats.NewDeckStats, None], "About": [about.show, None], "Preferences": [preferences.Preferences, None], "sync_log": [mediasync.MediaSyncDialog, None], } def open(self, name: str, *args: Any, **kwargs: Any) -> Any: (creator, instance) = self._dialogs[name] if instance: if instance.windowState() & Qt.WindowState.WindowMinimized: instance.setWindowState( instance.windowState() & ~Qt.WindowState.WindowMinimized ) instance.activateWindow() instance.raise_() if hasattr(instance, "reopen"): instance.reopen(*args, **kwargs) else: instance = creator(*args, **kwargs) self._dialogs[name][1] = instance gui_hooks.dialog_manager_did_open_dialog(self, name, instance) return instance def markClosed(self, name: str) -> None: self._dialogs[name] = [self._dialogs[name][0], None] def allClosed(self) -> bool: return not any(x[1] for x in self._dialogs.values()) def closeAll(self, onsuccess: Callable[[], None]) -> bool | None: # can we close immediately? if self.allClosed(): onsuccess() return None # ask all windows to close and await a reply for name, (creator, instance) in self._dialogs.items(): if not instance: continue def callback() -> None: if self.allClosed(): onsuccess() else: # still waiting for others to close pass if getattr(instance, "silentlyClose", False): instance.close() callback() else: instance.closeWithCallback(callback) return True def register_dialog( self, name: str, creator: Callable | type, instance: Any | None = None ) -> None: """Allows add-ons to register a custom dialog to be managed by Anki's dialog manager, which ensures that only one copy of the window is open at once, and that the dialog cleans up asynchronously when the collection closes Please note that dialogs added in this manner need to define a close behavior by either: - setting `dialog.silentlyClose = True` to have it close immediately - define a `dialog.closeWithCallback()` method that is called when closed by the dialog manager TODO?: Implement more restrictive type check to ensure these requirements are met Arguments: name {str} -- Name/identifier of the dialog in question creator {Union[Callable, type]} -- A class or function to create new dialog instances with Keyword Arguments: instance {Optional[Any]} -- An optional existing instance of the dialog (default: {None}) """ self._dialogs[name] = [creator, instance] dialogs = DialogManager() # Language handling ########################################################################## # Qt requires its translator to be installed before any GUI widgets are # loaded, and we need the Qt language to match the i18n language or # translated shortcuts will not work. # A reference to the Qt translator needs to be held to prevent it from # being immediately deallocated. _qtrans: QTranslator | None = None def setupLangAndBackend( pm: ProfileManager, app: QApplication, force: str | None = None, firstTime: bool = False, ) -> RustBackend: global _qtrans try: locale.setlocale(locale.LC_ALL, "") except Exception: pass # add _ and ngettext globals used by legacy code def fn__(arg) -> None: # type: ignore print("".join(traceback.format_stack()[-2])) print("_ global will break in the future; please see anki/lang.py") return arg def fn_ngettext(a, b, c) -> None: # type: ignore print("".join(traceback.format_stack()[-2])) print("ngettext global will break in the future; please see anki/lang.py") return b builtins.__dict__["_"] = fn__ builtins.__dict__["ngettext"] = fn_ngettext # get lang and normalize into ja/zh-CN form if firstTime: lang = pm.meta["defaultLang"] else: lang = force or pm.meta["defaultLang"] lang = anki.lang.lang_to_disk_lang(lang) # set active language anki.lang.set_lang(lang) # switch direction for RTL languages if anki.lang.is_rtl(lang): app.setLayoutDirection(Qt.LayoutDirection.RightToLeft) else: app.setLayoutDirection(Qt.LayoutDirection.LeftToRight) # load qt translations _qtrans = QTranslator() qt_dir = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath) qt_lang = lang.replace("-", "_") if _qtrans.load(f"qtbase_{qt_lang}", qt_dir): app.installTranslator(_qtrans) backend = anki.lang.current_i18n assert backend is not None return backend # App initialisation ########################################################################## class NativeEventFilter(QAbstractNativeEventFilter): def nativeEventFilter( self, eventType: Any, message: Any ) -> tuple[bool, Any | None]: if eventType == "windows_generic_MSG": import ctypes.wintypes msg = ctypes.wintypes.MSG.from_address(int(message)) if msg.message == 17: # WM_QUERYENDSESSION assert mw is not None if mw.can_auto_sync(): mw.app._set_windows_shutdown_block_reason(tr.sync_syncing()) mw.progress.single_shot(100, mw.unloadProfileAndExit) return (True, 0) return (False, 0) class AnkiApp(QApplication): # Single instance support on Win32/Linux ################################################## appMsg = pyqtSignal(str) KEY = f"anki{checksum(getpass.getuser())}" TMOUT = 30000 def __init__(self, argv: list[str]) -> None: QApplication.__init__(self, argv) self.installEventFilter(self) self._argv = argv self._native_event_filter = NativeEventFilter() if is_win: self.installNativeEventFilter(self._native_event_filter) def _set_windows_shutdown_block_reason(self, reason: str) -> None: if is_win: import ctypes from ctypes import windll, wintypes # type: ignore assert mw is not None windll.user32.ShutdownBlockReasonCreate( wintypes.HWND.from_param(int(mw.effectiveWinId())), ctypes.c_wchar_p(reason), ) def _unset_windows_shutdown_block_reason(self) -> None: if is_win: from ctypes import windll, wintypes # type: ignore assert mw is not None windll.user32.ShutdownBlockReasonDestroy( wintypes.HWND.from_param(int(mw.effectiveWinId())), ) def secondInstance(self) -> bool: # we accept only one command line argument. if it's missing, send # a blank screen to just raise the existing window opts, args = parseArgs(self._argv) buf = "raise" if args and args[0]: buf = os.path.abspath(args[0]) if self.sendMsg(buf): print("Already running; reusing existing instance.") return True else: # send failed, so we're the first instance or the # previous instance died QLocalServer.removeServer(self.KEY) self._srv = QLocalServer(self) qconnect(self._srv.newConnection, self.onRecv) self._srv.listen(self.KEY) return False def sendMsg(self, txt: str) -> bool: sock = QLocalSocket(self) sock.connectToServer(self.KEY, QIODevice.OpenModeFlag.WriteOnly) if not sock.waitForConnected(self.TMOUT): # first instance or previous instance dead return False sock.write(txt.encode("utf8")) if not sock.waitForBytesWritten(self.TMOUT): # existing instance running but hung QMessageBox.warning( None, tr.qt_misc_anki_is_running(), tr.qt_misc_if_instance_is_not_responding(), ) sys.exit(1) sock.disconnectFromServer() return True def onRecv(self) -> None: sock = self._srv.nextPendingConnection() if sock is None: return if not sock.waitForReadyRead(self.TMOUT): sys.stderr.write(sock.errorString()) return path = bytes(cast(bytes, sock.readAll())).decode("utf8") self.appMsg.emit(path) # type: ignore sock.disconnectFromServer() # OS X file/url handler ################################################## def event(self, evt: QEvent | None) -> bool: assert evt is not None if evt.type() == QEvent.Type.FileOpen: self.appMsg.emit(evt.file() or "raise") # type: ignore return True return QApplication.event(self, evt) # Global cursor: pointer for Qt buttons ################################################## def eventFilter(self, src: Any, evt: QEvent | None) -> bool: assert evt is not None # Handle Close shortcut here because modal dialogs disable main-window shortcuts if (is_mac or is_lin) and evt.type() == QEvent.Type.KeyPress: key_event = cast(QKeyEvent, evt) if not key_event.isAutoRepeat(): mods = cast(int, key_event.modifiers().value) seq = QKeySequence(mods | key_event.key()) if any( seq == binding for binding in QKeySequence.keyBindings( QKeySequence.StandardKey.Close ) ): if mw is not None: mw._close_active_window() return True pointer_classes = ( QPushButton, QCheckBox, QRadioButton, QMenu, QSlider, QToolButton, QTabBar, ) if evt.type() in [QEvent.Type.Enter, QEvent.Type.HoverEnter]: if (isinstance(src, pointer_classes) and src.isEnabled()) or ( isinstance(src, QComboBox) and not src.isEditable() ): self.setOverrideCursor(QCursor(Qt.CursorShape.PointingHandCursor)) else: self.restoreOverrideCursor() return False elif evt.type() in [QEvent.Type.HoverLeave, QEvent.Type.Leave] or isinstance( evt, QCloseEvent ): self.restoreOverrideCursor() return False return False def parseArgs(argv: list[str]) -> tuple[argparse.Namespace, list[str]]: "Returns (opts, args)." # py2app fails to strip this in some instances, then anki dies # as there's no such profile if is_mac and len(argv) > 1 and argv[1].startswith("-psn"): argv = [argv[0]] parser = argparse.ArgumentParser(description=f"Anki {appVersion}") parser.usage = "%(prog)s [OPTIONS] [file to import/add-on to install]" parser.add_argument("-b", "--base", help="path to base folder", default="") parser.add_argument("-p", "--profile", help="profile name to load", default="") parser.add_argument("-l", "--lang", help="interface language (en, de, etc)") parser.add_argument( "-v", "--version", help="print the Anki version and exit", action="store_true" ) parser.add_argument( "--safemode", help="disable add-ons and automatic syncing", action="store_true" ) parser.add_argument( "--syncserver", help="skip GUI and start a local sync server", action="store_true", ) return parser.parse_known_args(argv[1:]) def setupGL(pm: aqt.profiles.ProfileManager) -> None: driver = pm.video_driver() # RHI errors are emitted multiple times so make sure we only handle them once driver_failed = False # work around pyqt loading wrong GL library if is_lin and not sys.platform.startswith("freebsd"): import ctypes ctypes.CDLL("libGL.so.1", ctypes.RTLD_GLOBAL) # catch opengl errors def msgHandler(category: Any, ctx: Any, msg: Any) -> None: if category == QtMsgType.QtDebugMsg: category = "debug" elif category == QtMsgType.QtInfoMsg: category = "info" elif category == QtMsgType.QtWarningMsg: category = "warning" elif category == QtMsgType.QtCriticalMsg: category = "critical" elif category == QtMsgType.QtDebugMsg: category = "debug" elif category == QtMsgType.QtFatalMsg: category = "fatal" else: category = "unknown" context = "" if ctx.file: context += f"{ctx.file}:" if ctx.line: context += f"{ctx.line}," if ctx.function: context += f"{ctx.function}" if context: context = f"'{context}'" nonlocal driver_failed if not driver_failed and ( "Failed to create OpenGL context" in msg # Based on the message Qt6 shows to the user; have not tested whether # we can actually capture this or not. or "Failed to initialize graphics backend" in msg # RHI backend or "Failed to create QRhi" in msg or "Failed to get a QRhi" in msg ): QMessageBox.critical( None, tr.qt_misc_error(), tr.qt_misc_error_loading_graphics_driver( mode=driver.value, context=context, ), ) pm.set_video_driver(driver.next()) driver_failed = True return else: print(f"Qt {category}: {msg} {context}") qInstallMessageHandler(msgHandler) atexit.register(qInstallMessageHandler, None) if driver == VideoDriver.OpenGL: # Leaving QT_OPENGL unset appears to sometimes produce different results # to explicitly setting it to 'auto'; the former seems to be more compatible. if qtmajor > 5: QQuickWindow.setGraphicsApi(QSGRendererInterface.GraphicsApi.OpenGL) elif driver in (VideoDriver.Software, VideoDriver.ANGLE): if is_win: # on Windows, this appears to be sufficient # On Qt6, ANGLE is excluded by the enum. os.environ["QT_OPENGL"] = driver.value elif is_mac: QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_UseSoftwareOpenGL) elif is_lin: if "QTWEBENGINE_CHROMIUM_FLAGS" not in os.environ: os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" if qtmajor > 5: QQuickWindow.setGraphicsApi(QSGRendererInterface.GraphicsApi.Software) elif driver == VideoDriver.Metal: QQuickWindow.setGraphicsApi(QSGRendererInterface.GraphicsApi.Metal) elif driver == VideoDriver.Vulkan: QQuickWindow.setGraphicsApi(QSGRendererInterface.GraphicsApi.Vulkan) elif driver == VideoDriver.Direct3D: QQuickWindow.setGraphicsApi(QSGRendererInterface.GraphicsApi.Direct3D11) PROFILE_CODE = os.environ.get("ANKI_PROFILE_CODE") def write_profile_results() -> None: assert profiler is not None profiler.disable() profile = "out/anki.prof" profiler.dump_stats(profile) def run() -> None: print(f"Starting Anki {_version}...") try: _run() except Exception: traceback.print_exc() QMessageBox.critical( None, "Startup Error", f"Please notify support of this error:\n\n{traceback.format_exc()}", ) def _run(argv: list[str] | None = None, exec: bool = True) -> AnkiApp | None: """Start AnkiQt application or reuse an existing instance if one exists. If the function is invoked with exec=False, the AnkiQt will not enter the main event loop - instead the application object will be returned. The 'exec' and 'argv' arguments will be useful for testing purposes. If no 'argv' is supplied then 'sys.argv' will be used. """ global mw global profiler if argv is None: argv = sys.argv # parse args opts, args = parseArgs(argv) if opts.version: print(f"Anki {appVersion}") return None if PROFILE_CODE: profiler = cProfile.Profile() profiler.enable() x11_available = os.getenv("DISPLAY") wayland_configured = qtmajor > 5 and ( os.getenv("QT_QPA_PLATFORM") == "wayland" or os.getenv("WAYLAND_DISPLAY") ) wayland_forced = os.getenv("ANKI_WAYLAND") if is_gnome and wayland_configured: if wayland_forced or not x11_available: # Work around broken fractional scaling in Wayland # https://bugreports.qt.io/browse/QTBUG-113574 os.environ["QT_SCALE_FACTOR_ROUNDING_POLICY"] = "RoundPreferFloor" if not x11_available: print( "Trying to use X11, but it is not available. Falling back to Wayland, which has some bugs:" ) print("https://github.com/ankitects/anki/issues/1767") else: # users need to opt in to wayland support, given the issues it has print("Wayland support is disabled by default due to bugs:") print("https://github.com/ankitects/anki/issues/1767") print("You can force it on with an env var: ANKI_WAYLAND=1") os.environ["QT_QPA_PLATFORM"] = "xcb" # profile manager i18n_setup = False pm = None try: base_folder = ProfileManager.get_created_base_folder(opts.base) # default to specified/system language before getting user's preference so that we can localize some more strings lang = anki.lang.get_def_lang(opts.lang) anki.lang.set_lang(lang[1]) i18n_setup = True pm = ProfileManager(base_folder) pmLoadResult = pm.setupMeta() Collection.initialize_backend_logging() except Exception: # will handle below traceback.print_exc() pm = None if pm: # gl workarounds setupGL(pm) # apply user-provided scale factor os.environ["QT_SCALE_FACTOR"] = str(pm.uiScale()) # Opt-in to full HiDPI support? if not os.environ.get("ANKI_NOHIGHDPI") and qtmajor == 5: QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling) # type: ignore QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps) # type: ignore os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1" os.environ["QT_SCALE_FACTOR_ROUNDING_POLICY"] = "PassThrough" # Opt-in to software rendering? if os.environ.get("ANKI_SOFTWAREOPENGL"): QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_UseSoftwareOpenGL) # Fix an issue on Windows, where Ctrl+Alt shortcuts are triggered by AltGr, # preventing users from typing things like "@" through AltGr+Q on a German # keyboard. if is_win and "QT_QPA_PLATFORM" not in os.environ: os.environ["QT_QPA_PLATFORM"] = "windows:altgr" # create the app QCoreApplication.setApplicationName("Anki") QGuiApplication.setDesktopFileName("anki") app = AnkiApp(argv) if app.secondInstance(): # we've signaled the primary instance, so we should close return None if not pm: if i18n_setup: QMessageBox.critical( None, tr.qt_misc_error(), tr.profiles_could_not_create_data_folder(), ) else: QMessageBox.critical(None, "Startup Failed", "Unable to create data folder") return None setup_logging( pm.addon_logs(), level=logging.DEBUG if int(os.getenv("ANKIDEV", "0")) else logging.INFO, ) # disable icons on mac; this must be done before window created if is_mac: app.setAttribute(Qt.ApplicationAttribute.AA_DontShowIconsInMenus) # disable help button in title bar on qt versions that support it if is_win and qtmajor == 5 and qtminor >= 10: QApplication.setAttribute(Qt.AA_DisableWindowContextHelpButton) # type: ignore # proxy configured? from urllib.request import getproxies, proxy_bypass disable_proxies = False try: if "http" in getproxies(): # if it's not set up to bypass localhost, we'll # need to disable proxies in the webviews if not proxy_bypass("127.0.0.1"): disable_proxies = True except UnicodeDecodeError: # proxy_bypass can't handle unicode in hostnames; assume we need # to disable proxies disable_proxies = True if disable_proxies: print("webview proxy use disabled") proxy = QNetworkProxy() proxy.setType(QNetworkProxy.ProxyType.NoProxy) QNetworkProxy.setApplicationProxy(proxy) # we must have a usable temp dir try: tempfile.gettempdir() except Exception: QMessageBox.critical( None, tr.qt_misc_error(), tr.qt_misc_no_temp_folder(), ) return None # make image resources available from aqt.utils import aqt_data_folder QDir.addSearchPath("icons", os.path.join(aqt_data_folder(), "qt", "icons")) if pmLoadResult.firstTime: pm.setDefaultLang(lang[0]) if pmLoadResult.loadError: QMessageBox.warning( None, tr.profiles_prefs_corrupt_title(), tr.profiles_prefs_file_is_corrupt(), ) if opts.profile: pm.openProfile(opts.profile) # i18n & backend backend = setupLangAndBackend(pm, app, opts.lang, pmLoadResult.firstTime) driver = pm.video_driver() if is_lin and driver == VideoDriver.OpenGL: from aqt.utils import gfxDriverIsBroken if gfxDriverIsBroken(): pm.set_video_driver(driver.next()) QMessageBox.critical( None, tr.qt_misc_error(), tr.qt_misc_incompatible_video_driver(), ) sys.exit(1) # load the main window import aqt.main mw = aqt.main.AnkiQt(app, pm, backend, opts, args) if exec: print("Starting main loop...") app.exec() else: return app if PROFILE_CODE: write_profile_results() return None ================================================ FILE: qt/aqt/_macos_helper.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import sys if sys.platform == "darwin": from anki_mac_helper import macos_helper else: macos_helper = None ================================================ FILE: qt/aqt/about.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import platform from collections.abc import Callable import aqt.forms from anki.lang import without_unicode_isolation from anki.utils import version_with_build from aqt.errors import addon_debug_info from aqt.qt import * from aqt.utils import disable_help_button, supportText, tooltip, tr class ClosableQDialog(QDialog): def reject(self) -> None: aqt.dialogs.markClosed("About") QDialog.reject(self) def accept(self) -> None: aqt.dialogs.markClosed("About") QDialog.accept(self) def closeWithCallback(self, callback: Callable[[], None]) -> None: self.reject() callback() def show(mw: aqt.AnkiQt) -> QDialog: dialog = ClosableQDialog(mw) disable_help_button(dialog) mw.garbage_collect_on_dialog_finish(dialog) abt = aqt.forms.about.Ui_About() abt.setupUi(dialog) def on_copy() -> None: txt = supportText() if mw.addonManager.dirty: txt += "\n" + addon_debug_info() clipboard = QApplication.clipboard() assert clipboard is not None clipboard.setText(txt) tooltip(tr.about_copied_to_clipboard(), parent=dialog) btn = QPushButton(tr.about_copy_debug_info()) qconnect(btn.clicked, on_copy) abt.buttonBox.addButton(btn, QDialogButtonBox.ButtonRole.ActionRole) ok_button = abt.buttonBox.button(QDialogButtonBox.StandardButton.Ok) assert ok_button is not None ok_button.setFocus() btnLayout = abt.buttonBox.layout() assert btnLayout is not None btnLayout.setContentsMargins(12, 12, 12, 12) # WebView cleanup ###################################################################### def on_dialog_destroyed() -> None: abt.label.cleanup() abt.label = None # type: ignore qconnect(dialog.destroyed, on_dialog_destroyed) # WebView contents ###################################################################### abouttext = "
" lede = tr.about_anki_is_a_friendly_intelligent_spaced().replace("Anki", "Anki®") abouttext += f"

{lede}" abouttext += f"

{tr.about_anki_is_licensed_under_the_agpl3()}" abouttext += f"

{tr.about_version(val=version_with_build())}
" abouttext += ("Python %s Qt %s Chromium %s
") % ( platform.python_version(), qVersion(), (qWebEngineChromiumVersion() or "").split(".")[0], ) abouttext += ( without_unicode_isolation(tr.about_visit_website(val=aqt.appWebsite)) + "" ) # Automatically sorted; add new lines at the end. # This is a list of users who want to appear in the dialog, and includes people who have # contributed in non-code ways, like providing support on the forums, so it cannot be # generated from the CONTRIBUTORS file. allusers = sorted( ( "Aaron Harsh", "Alex Fraser", "Andreas Klauer", "Andrew Wright", "Aristotelis P.", "Ben Nguyen", "Bernhard Ibertsberger", "C. van Rooyen", "Cenaris Mori", "Charlene Barina", "Christian Krause", "Christian Rusche", "Dave Druelinger", "David Culley", "David Smith", "Dmitry Mikheev", "Dotan Cohen", "Emilio Wuerges", "Emmanuel Jarri", "Frank Harper", "Gregor Skumavc", "Guillem Palau Salvà", "H. Mijail", "Henrik Enggaard Hansen", "Houssam Salem", "Ian Lewis", "Immanuel Asmus", "Iroiro", "Jarvik7", "Jin Eun-Deok", "Jo Nakashima", "Johanna Lindh", "Joseph Lorimer", "Julien Baley", "Jussi Määttä", "Kieran Clancy", "LaC", "Laurent Steffan", "Luca Ban", "Luciano Esposito", "Marco Giancotti", "Marcus Rubeus", "Mari Egami", "Mark Wilbur", "Matthew Duggan", "Matthew Holtz", "Meelis Vasser", "Michael Jürges", "Michael Keppler", "Michael Montague", "Michael Penkov", "Michal Čadil", "Morteza Salehi", "Nathanael Law", "Nguyễn Hào Khôi", "Nick Cook", "Niklas Laxström", "Norbert Nagold", "Ole Guldberg", "Pcsl88", "Petr Michalec", "Piotr Kubowicz", "Richard Colley", "Roland Sieker", "Samson Melamed", "Silja Ijas", "Snezana Lukic", "Soren Bjornstad", "Stefaan De Pooter", "Susanna Björverud", "Sylvain Durand", "Tacutu", "Taylor Obyen", "Timm Preetz", "Timo Paulssen", "Ursus", "Victor Suba", "Volker Jansen", "Volodymyr Goncharenko", "Xtru", "Ádám Szegi", "赵金鹏", "黃文龍", "Valerie Enfys", "Arman High", "Arthur Milchior", "Rai (Michael Pokorny)", "AMBOSS MD Inc.", "Erez Volk", "Tobias Predel", "Thomas Kahn", "zjosua", "Ijgnd", "Evandro Coan", "Alan Du", "Abdo", "Junseo Park", "Gustavo Costa", "余时行", "叶峻峣", "RumovZ", "学习骇客", "ready-research", "Henrik Giesel", "Yoonchae Lee", "Hikaru Yoshiga", "Matthias Metelka", "Sergio Quintero", "Nicholas Flint", "Daniel Vieira Memoria10X", "Luka Warren", "Christos Longros", "hafatsat anki", "Carlos Duarte", "Edgar Benavent Català", "Kieran Black", "Mateusz Wojewoda", "Jarrett Ye", "Gustavo Sales", "Akash Reddy", "Marko Sisovic", "Lucas Scharenbroch", "Antoine Q.", "Ian Samir Yep Manzano", "Asuka Minato", "Eros Cardoso", "Gregory Abrasaldo", "Danika_Dakika", "Marcelo Vasconcelos", "Mumtaz Hajjo Alrifai", "Luc Mcgrady", "Brayan Oliveira", "Market345", "Yuki", "🦙 (siid)", "Mukunda Madhav Dey", "Adnane Taghi", "Anon_0000", "Bilolbek Normuminov", "Sagiv Marzini", "Zhanibek Rassululy", ) ) allusers = [user.replace(" ", " ") for user in allusers] abouttext += "

" + tr.about_written_by_damien_elmes_with_patches( cont=", ".join(allusers) + f", {tr.about_and_others()}" ) abouttext += f"

{tr.about_if_you_have_contributed_and_are()}" abouttext += f"

{tr.about_a_big_thanks_to_all_the()}" abt.label.setMinimumWidth(800) abt.label.setMinimumHeight(600) dialog.show() abt.label.stdHtml(abouttext, js=[]) return dialog ================================================ FILE: qt/aqt/addcards.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Callable import aqt.editor import aqt.forms from anki._legacy import deprecated from anki.collection import OpChanges, OpChangesWithCount, SearchNode from anki.decks import DeckId from anki.models import NotetypeId from anki.notes import Note, NoteFieldsCheckResult, NoteId from anki.utils import html_to_text_line, is_mac from aqt import AnkiQt, gui_hooks from aqt.deckchooser import DeckChooser from aqt.notetypechooser import NotetypeChooser from aqt.operations.note import add_note from aqt.qt import * from aqt.sound import av_player from aqt.utils import ( HelpPage, ask_user_dialog, askUser, downArrow, openHelp, restoreGeom, saveGeom, shortcut, showWarning, tooltip, tr, ) class AddCards(QMainWindow): def __init__(self, mw: AnkiQt) -> None: super().__init__(None, Qt.WindowType.Window) self._close_event_has_cleaned_up = False self.mw = mw self.col = mw.col form = aqt.forms.addcards.Ui_Dialog() form.setupUi(self) self.form = form self.setWindowTitle(tr.actions_add()) self.setMinimumHeight(300) self.setMinimumWidth(400) self.setup_choosers() self.setupEditor() self._load_new_note() self.setupButtons() self.history: list[NoteId] = [] self._last_added_note: Note | None = None gui_hooks.operation_did_execute.append(self.on_operation_did_execute) restoreGeom(self, "add") gui_hooks.add_cards_did_init(self) if not is_mac: self.setMenuBar(None) self.show() def set_deck(self, deck_id: DeckId) -> None: self.deck_chooser.selected_deck_id = deck_id def set_note_type(self, note_type_id: NotetypeId) -> None: self.notetype_chooser.selected_notetype_id = note_type_id def set_note(self, note: Note, deck_id: DeckId | None = None) -> None: """Set tags, field contents and notetype according to `note`. Deck is set to `deck_id` or the deck last used with the notetype. """ self.notetype_chooser.selected_notetype_id = note.mid if deck_id or (deck_id := self.col.default_deck_for_notetype(note.mid)): self.deck_chooser.selected_deck_id = deck_id new_note = self._new_note() new_note.fields = note.fields[:] new_note.tags = note.tags[:] self.editor.orig_note_id = note.id self.setAndFocusNote(new_note) def setupEditor(self) -> None: self.editor = aqt.editor.Editor( self.mw, self.form.fieldsArea, self, editor_mode=aqt.editor.EditorMode.ADD_CARDS, ) def setup_choosers(self) -> None: defaults = self.col.defaults_for_adding( current_review_card=self.mw.reviewer.card ) self.notetype_chooser = NotetypeChooser( mw=self.mw, widget=self.form.modelArea, starting_notetype_id=NotetypeId(defaults.notetype_id), on_button_activated=self.show_notetype_selector, on_notetype_changed=self.on_notetype_change, ) self.deck_chooser = DeckChooser( self.mw, self.form.deckArea, starting_deck_id=DeckId(defaults.deck_id), on_deck_changed=self.on_deck_changed, ) def reopen(self, mw: AnkiQt) -> None: if not self.editor.fieldsAreBlank(): return defaults = self.col.defaults_for_adding( current_review_card=self.mw.reviewer.card ) self.set_note_type(NotetypeId(defaults.notetype_id)) self.set_deck(DeckId(defaults.deck_id)) def helpRequested(self) -> None: openHelp(HelpPage.ADDING_CARD_AND_NOTE) def setupButtons(self) -> None: bb = self.form.buttonBox ar = QDialogButtonBox.ButtonRole.ActionRole # add self.addButton = bb.addButton(tr.actions_add(), ar) qconnect(self.addButton.clicked, self.add_current_note) self.addButton.setShortcut(QKeySequence("Ctrl+Return")) # qt5.14+ doesn't handle numpad enter on Windows self.compat_add_shorcut = QShortcut(QKeySequence("Ctrl+Enter"), self) qconnect(self.compat_add_shorcut.activated, self.addButton.click) self.addButton.setToolTip(shortcut(tr.adding_add_shortcut_ctrlandenter())) # close self.closeButton = QPushButton(tr.actions_close()) self.closeButton.setAutoDefault(False) bb.addButton(self.closeButton, QDialogButtonBox.ButtonRole.RejectRole) qconnect(self.closeButton.clicked, self.close) # help self.helpButton = QPushButton(tr.actions_help(), clicked=self.helpRequested) # type: ignore self.helpButton.setAutoDefault(False) bb.addButton(self.helpButton, QDialogButtonBox.ButtonRole.HelpRole) # history b = bb.addButton(f"{tr.adding_history()} {downArrow()}", ar) if is_mac: sc = "Ctrl+Shift+H" else: sc = "Ctrl+H" b.setShortcut(QKeySequence(sc)) b.setToolTip(tr.adding_shortcut(val=shortcut(sc))) qconnect(b.clicked, self.onHistory) b.setEnabled(False) self.historyButton = b def setAndFocusNote(self, note: Note) -> None: self.editor.set_note(note, focusTo=0) def show_notetype_selector(self) -> None: self.editor.call_after_note_saved(self.notetype_chooser.choose_notetype) def on_deck_changed(self, deck_id: int) -> None: gui_hooks.add_cards_did_change_deck(deck_id) def on_notetype_change( self, notetype_id: NotetypeId, update_deck: bool = True ) -> None: # need to adjust current deck? if update_deck: if deck_id := self.col.default_deck_for_notetype(notetype_id): self.deck_chooser.selected_deck_id = deck_id # only used for detecting changed sticky fields on close self._last_added_note = None # copy fields into new note with the new notetype old_note = self.editor.note new_note = self._new_note() if old_note: old_field_names = list(old_note.keys()) new_field_names = list(new_note.keys()) copied_field_names = set() for f in new_note.note_type()["flds"]: field_name = f["name"] # copy identical non-empty fields if field_name in old_field_names and old_note[field_name]: new_note[field_name] = old_note[field_name] copied_field_names.add(field_name) new_idx = 0 for old_idx, old_field_value in enumerate(old_field_names): # skip previously copied identical fields in new note while ( new_idx < len(new_field_names) and new_field_names[new_idx] in copied_field_names ): new_idx += 1 if new_idx >= len(new_field_names): break # copy non-empty old fields if ( old_field_value not in copied_field_names and old_note.fields[old_idx] ): new_note.fields[new_idx] = old_note.fields[old_idx] new_idx += 1 new_note.tags = old_note.tags # and update editor state self.editor.note = new_note self.editor.loadNote( focusTo=min(self.editor.last_field_index or 0, len(new_note.fields) - 1) ) gui_hooks.addcards_did_change_note_type( self, old_note.note_type(), new_note.note_type() ) def _load_new_note(self, sticky_fields_from: Note | None = None) -> None: note = self._new_note() if old_note := sticky_fields_from: flds = note.note_type()["flds"] # copy fields from old note if old_note: for n in range(min(len(note.fields), len(old_note.fields))): if flds[n]["sticky"]: note.fields[n] = old_note.fields[n] # and tags note.tags = old_note.tags self.setAndFocusNote(note) def on_operation_did_execute( self, changes: OpChanges, handler: object | None ) -> None: if (changes.notetype or changes.deck) and handler is not self.editor: self.on_notetype_change( NotetypeId( self.col.defaults_for_adding( current_review_card=self.mw.reviewer.card ).notetype_id ), update_deck=False, ) def _new_note(self) -> Note: return self.col.new_note( self.col.models.get(self.notetype_chooser.selected_notetype_id) ) def addHistory(self, note: Note) -> None: self.history.insert(0, note.id) self.history = self.history[:15] self.historyButton.setEnabled(True) def onHistory(self) -> None: m = QMenu(self) for nid in self.history: if self.col.find_notes(self.col.build_search_string(SearchNode(nid=nid))): note = self.col.get_note(nid) fields = note.fields txt = html_to_text_line(", ".join(fields)) if len(txt) > 30: txt = f"{txt[:30]}..." line = tr.adding_edit(val=txt) line = gui_hooks.addcards_will_add_history_entry(line, note) line = line.replace("&", "&&") # In qt action "&i" means "underline i, trigger this line when i is pressed". # except for "&&" which is replaced by a single "&" a = m.addAction(line) qconnect(a.triggered, lambda b, nid=nid: self.editHistory(nid)) else: a = m.addAction(tr.adding_note_deleted()) a.setEnabled(False) gui_hooks.add_cards_will_show_history_menu(self, m) m.exec(self.historyButton.mapToGlobal(QPoint(0, 0))) def editHistory(self, nid: NoteId) -> None: aqt.dialogs.open("Browser", self.mw, search=(SearchNode(nid=nid),)) def add_current_note(self) -> None: if self.editor.current_notetype_is_image_occlusion(): self.editor.update_occlusions_field() self.editor.call_after_note_saved(self._add_current_note) self.editor.reset_image_occlusion() else: self.editor.call_after_note_saved(self._add_current_note) def _add_current_note(self) -> None: note = self.editor.note # Prevent adding a note that has already been added (e.g., from double-clicking) if note.id != 0: return if not self._note_can_be_added(note): return target_deck_id = self.deck_chooser.selected_deck_id def on_success(changes: OpChangesWithCount) -> None: # only used for detecting changed sticky fields on close self._last_added_note = note self.addHistory(note) tooltip(tr.importing_cards_added(count=changes.count), period=500) av_player.stop_and_clear_queue() self._load_new_note(sticky_fields_from=note) gui_hooks.add_cards_did_add_note(note) add_note(parent=self, note=note, target_deck_id=target_deck_id).success( on_success ).run_in_background() def _note_can_be_added(self, note: Note) -> bool: result = note.fields_check() # no problem, duplicate, and confirmed cloze cases problem = None if result == NoteFieldsCheckResult.EMPTY: if self.editor.current_notetype_is_image_occlusion(): problem = tr.notetypes_no_occlusion_created2() else: problem = tr.adding_the_first_field_is_empty() elif result == NoteFieldsCheckResult.MISSING_CLOZE: if not askUser(tr.adding_you_have_a_cloze_deletion_note()): return False elif result == NoteFieldsCheckResult.NOTETYPE_NOT_CLOZE: problem = tr.adding_cloze_outside_cloze_notetype() elif result == NoteFieldsCheckResult.FIELD_NOT_CLOZE: problem = tr.adding_cloze_outside_cloze_field() # filter problem through add-ons problem = gui_hooks.add_cards_will_add_note(problem, note) if problem is not None: showWarning(problem, help=HelpPage.ADDING_CARD_AND_NOTE) return False optional_problems: list[str] = [] gui_hooks.add_cards_might_add_note(optional_problems, note) if not all(askUser(op) for op in optional_problems): return False return True def keyPressEvent(self, evt: QKeyEvent) -> None: if evt.key() == Qt.Key.Key_Escape: self.close() else: super().keyPressEvent(evt) def closeEvent(self, evt: QCloseEvent) -> None: if self._close_event_has_cleaned_up: evt.accept() return self.ifCanClose(self._close) evt.ignore() def _close(self) -> None: self.editor.cleanup() self.notetype_chooser.cleanup() self.deck_chooser.cleanup() gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) self.mw.maybeReset() saveGeom(self, "add") aqt.dialogs.markClosed("AddCards") self._close_event_has_cleaned_up = True self.mw.deferred_delete_and_garbage_collect(self) self.close() def ifCanClose(self, onOk: Callable) -> None: def callback(choice: int) -> None: if choice == 0: onOk() def afterSave() -> None: if self.editor.fieldsAreBlank(self._last_added_note): return onOk() ask_user_dialog( tr.adding_discard_current_input(), callback=callback, buttons=[ QMessageBox.StandardButton.Discard, (tr.adding_keep_editing(), QMessageBox.ButtonRole.RejectRole), ], ) self.editor.call_after_note_saved(afterSave) def closeWithCallback(self, cb: Callable[[], None]) -> None: def doClose() -> None: self._close() cb() self.ifCanClose(doClose) # legacy aliases @property def deckChooser(self) -> DeckChooser: if getattr(self, "form", None): # show this warning only after Qt form has been initialized, # or PyQt's introspection triggers it print("deckChooser is deprecated; use deck_chooser instead") return self.deck_chooser addCards = add_current_note _addCards = _add_current_note onModelChange = on_notetype_change @deprecated(info="obsolete") def addNote(self, note: Note) -> None: pass @deprecated(info="does nothing; will go away") def removeTempNote(self, note: Note) -> None: pass ================================================ FILE: qt/aqt/addons.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import html import io import json import logging import os import re import sys import traceback import zipfile from collections import defaultdict from collections.abc import Callable, Iterable, Sequence from concurrent.futures import Future from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import IO, Any, Union from urllib.parse import parse_qs, urlparse from zipfile import ZipFile import jsonschema import markdown from jsonschema.exceptions import ValidationError from markdown.extensions import md_in_html import anki import anki.utils import aqt import aqt.forms import aqt.main from anki.collection import AddonInfo from anki.httpclient import HttpClient from anki.lang import without_unicode_isolation from anki.utils import int_version_to_str from aqt import gui_hooks from aqt.log import ADDON_LOGGER_PREFIX, find_addon_logger, get_addon_logs_folder from aqt.qt import * from aqt.utils import ( askUser, disable_help_button, getFile, openFolder, openLink, restoreGeom, restoreSplitter, saveGeom, saveSplitter, send_to_trash, show_info, showInfo, showText, showWarning, supportText, tooltip, tr, ) class AbortAddonImport(Exception): """If raised during add-on import, Anki will silently ignore this exception. This allows you to terminate loading without an error being shown.""" @dataclass class InstallOk: name: str conflicts: set[str] compatible: bool @dataclass class InstallError: errmsg: str @dataclass class DownloadOk: data: bytes filename: str mod_time: int min_point_version: int max_point_version: int branch_index: int @dataclass class DownloadError: # set if result was not 200 status_code: int | None = None # set if an exception occurred exception: Exception | None = None # first arg is add-on id DownloadLogEntry = tuple[int, Union[DownloadError, InstallError, InstallOk]] ANKIWEB_ID_RE = re.compile(r"^\d+$") _current_version = anki.utils.int_version() @dataclass class AddonMeta: dir_name: str provided_name: str | None enabled: bool installed_at: int conflicts: list[str] min_version: int max_version: int branch_index: int human_version: str | None update_enabled: bool homepage: str | None def human_name(self) -> str: return self.provided_name or self.dir_name def ankiweb_id(self) -> int | None: m = ANKIWEB_ID_RE.match(self.dir_name) if m: return int(m.group(0)) else: return None def compatible(self) -> bool: min = self.min_version if min is not None and _current_version < min: return False max = self.max_version if max is not None and max < 0 and _current_version > abs(max): return False return True def is_latest(self, server_update_time: int) -> bool: return self.installed_at >= server_update_time def page(self) -> str | None: if self.ankiweb_id(): return f"{aqt.appShared}info/{self.dir_name}" return self.homepage @staticmethod def from_json_meta(dir_name: str, json_meta: dict[str, Any]) -> AddonMeta: return AddonMeta( dir_name=dir_name, provided_name=json_meta.get("name"), enabled=not json_meta.get("disabled"), installed_at=json_meta.get("mod", 0), conflicts=json_meta.get("conflicts", []), min_version=json_meta.get("min_point_version", 0) or 0, max_version=json_meta.get("max_point_version", 0) or 0, branch_index=json_meta.get("branch_index", 0) or 0, human_version=json_meta.get("human_version"), update_enabled=json_meta.get("update_enabled", True), homepage=json_meta.get("homepage"), ) def package_name_valid(name: str) -> bool: # embedded /? base = os.path.basename(name) if base != name: return False # tries to escape to parent? root = os.getcwd() subfolder = os.path.abspath(os.path.join(root, name)) if root.startswith(subfolder): return False return True # fixme: this class should not have any GUI code in it class AddonManager: exts: list[str] = [".ankiaddon", ".zip"] _manifest_schema: dict = { "type": "object", "properties": { # the name of the folder "package": {"type": "string", "minLength": 1, "meta": False}, # the displayed name to the user "name": {"type": "string", "meta": True}, # the time the add-on was last modified "mod": {"type": "number", "meta": True}, # a list of other packages that conflict "conflicts": {"type": "array", "items": {"type": "string"}, "meta": True}, # x for anki 2.1.x; int_version() for more recent releases "min_point_version": {"type": "number", "meta": True}, # x for anki 2.1.x; int_version() for more recent releases # if negative, abs(n) is the maximum version this add-on supports # if positive, indicates version tested on, and is ignored "max_point_version": {"type": "number", "meta": True}, # AnkiWeb sends this to indicate which branch the user downloaded. "branch_index": {"type": "number", "meta": True}, # version string set by the add-on creator "human_version": {"type": "string", "meta": True}, # add-on page on AnkiWeb or some other webpage "homepage": {"type": "string", "meta": True}, }, "required": ["package", "name"], } def __init__(self, mw: aqt.main.AnkiQt) -> None: self.mw = mw self.dirty = False f = self.mw.form qconnect(f.actionAdd_ons.triggered, self.onAddonsDialog) sys.path.insert(0, self.addonsFolder()) # in new code, you may want all_addon_meta() instead def allAddons(self) -> list[str]: l = [] for d in os.listdir(self.addonsFolder()): path = self.addonsFolder(d) if not os.path.exists(os.path.join(path, "__init__.py")): continue l.append(d) l.sort() if os.getenv("ANKIREVADDONS", ""): l = list(reversed(l)) return l def all_addon_meta(self) -> Iterable[AddonMeta]: return map(self.addon_meta, self.allAddons()) def addonsFolder(self, module: str | None = None) -> str: root = self.mw.pm.addonFolder() if module is None: return root return os.path.join(root, module) def loadAddons(self) -> None: from aqt import mw broken: list[str] = [] error_text = "" for addon in self.all_addon_meta(): if not addon.enabled: continue if not addon.compatible(): continue self.dirty = True try: __import__(addon.dir_name) except AbortAddonImport: pass except Exception: name = html.escape(addon.human_name()) page = addon.page() if page: broken.append(f"{name}") else: broken.append(name) tb = traceback.format_exc() print(tb) error_text += f"When loading {name}:\n{tb}\n" if broken: addons = "\n\n- " + "\n- ".join(broken) error = tr.addons_failed_to_load2( addons=addons, ) txt = f"# {tr.addons_startup_failed()}\n{error}" html2 = markdown.markdown(txt) box: QDialogButtonBox (diag, box) = showText( html2, type="html", run=False, ) def on_check() -> None: tooltip(tr.addons_checking()) def on_done(log: list[DownloadLogEntry]) -> None: if not log: tooltip(tr.addons_no_updates_available()) mw.check_for_addon_updates(by_user=True, on_done=on_done) def on_copy() -> None: txt = supportText() + "\n" + error_text QApplication.clipboard().setText(txt) tooltip(tr.about_copied_to_clipboard(), parent=diag) check = box.addButton( tr.addons_check_for_updates(), QDialogButtonBox.ButtonRole.ActionRole ) check.clicked.connect(on_check) copy = box.addButton( tr.about_copy_debug_info(), QDialogButtonBox.ButtonRole.ActionRole ) copy.clicked.connect(on_copy) # calling show immediately appears to crash mw.progress.single_shot(1000, diag.show) def onAddonsDialog(self) -> None: aqt.dialogs.open("AddonsDialog", self) # Metadata ###################################################################### def addon_meta(self, dir_name: str) -> AddonMeta: """Get info about an installed add-on.""" json_obj = self.addonMeta(dir_name) return AddonMeta.from_json_meta(dir_name, json_obj) def write_addon_meta(self, addon: AddonMeta) -> None: # preserve any unknown attributes json_obj = self.addonMeta(addon.dir_name) if addon.provided_name is not None: json_obj["name"] = addon.provided_name json_obj["disabled"] = not addon.enabled json_obj["mod"] = addon.installed_at json_obj["conflicts"] = addon.conflicts json_obj["max_point_version"] = addon.max_version json_obj["min_point_version"] = addon.min_version json_obj["branch_index"] = addon.branch_index if addon.human_version is not None: json_obj["human_version"] = addon.human_version json_obj["update_enabled"] = addon.update_enabled self.writeAddonMeta(addon.dir_name, json_obj) def _addonMetaPath(self, module: str) -> str: return os.path.join(self.addonsFolder(module), "meta.json") # in new code, use self.addon_meta() instead def addonMeta(self, module: str) -> dict[str, Any]: path = self._addonMetaPath(module) try: with open(path, encoding="utf8") as f: return json.load(f) except json.JSONDecodeError as e: print(f"json error in add-on {module}:\n{e}") return dict() except Exception: # missing meta file, etc return dict() # in new code, use write_addon_meta() instead def writeAddonMeta(self, module: str, meta: dict[str, Any]) -> None: path = self._addonMetaPath(module) with open(path, "w", encoding="utf8") as f: json.dump(meta, f) def toggleEnabled(self, module: str, enable: bool | None = None) -> None: addon = self.addon_meta(module) should_enable = enable if enable is not None else not addon.enabled if should_enable is True: conflicting = self._disableConflicting(module) if conflicting: addons = ", ".join(self.addonName(f) for f in conflicting) showInfo( tr.addons_the_following_addons_are_incompatible_with( name=addon.human_name(), found=addons, ), textFormat="plain", ) addon.enabled = should_enable self.write_addon_meta(addon) def ankiweb_addons(self) -> list[int]: ids = [] for meta in self.all_addon_meta(): if meta.ankiweb_id() is not None: ids.append(meta.ankiweb_id()) return ids # Legacy helpers ###################################################################### def isEnabled(self, module: str) -> bool: return self.addon_meta(module).enabled def addonName(self, module: str) -> str: return self.addon_meta(module).human_name() def addonConflicts(self, module: str) -> list[str]: return self.addon_meta(module).conflicts def annotatedName(self, module: str) -> str: meta = self.addon_meta(module) name = meta.human_name() if not meta.enabled: name += f" {tr.addons_disabled()}" return name # Conflict resolution ###################################################################### def allAddonConflicts(self) -> dict[str, list[str]]: all_conflicts: dict[str, list[str]] = defaultdict(list) for addon in self.all_addon_meta(): if not addon.enabled: continue for other_dir in addon.conflicts: all_conflicts[other_dir].append(addon.dir_name) return all_conflicts def _disableConflicting( self, module: str, conflicts: list[str] | None = None ) -> set[str]: if not self.isEnabled(module): # disabled add-ons should not trigger conflict handling return set() conflicts = conflicts or self.addonConflicts(module) installed = self.allAddons() found = {d for d in conflicts if d in installed and self.isEnabled(d)} found.update(self.allAddonConflicts().get(module, [])) for package in found: self.toggleEnabled(package, enable=False) return found # Installing and deleting add-ons ###################################################################### def readManifestFile(self, zfile: ZipFile) -> dict[Any, Any]: try: with zfile.open("manifest.json") as f: data = json.loads(f.read()) jsonschema.validate(data, self._manifest_schema) # build new manifest from recognized keys schema = self._manifest_schema["properties"] manifest = {key: data[key] for key in data.keys() & schema.keys()} except (KeyError, json.decoder.JSONDecodeError, ValidationError): # raised for missing manifest, invalid json, missing/invalid keys return {} return manifest def install( self, file: IO | str, manifest: dict[str, Any] | None = None, force_enable: bool = False, ) -> InstallOk | InstallError: """Install add-on from path or file-like object. Metadata is read from the manifest file, with keys overridden by supplying a 'manifest' dictionary""" try: zfile = ZipFile(file) except zipfile.BadZipfile: return InstallError(errmsg="zip") with zfile: file_manifest = self.readManifestFile(zfile) if manifest: file_manifest.update(manifest) manifest = file_manifest if not manifest: return InstallError(errmsg="manifest") package = manifest["package"] if not package_name_valid(package): return InstallError(errmsg="invalid package") conflicts = manifest.get("conflicts", []) found_conflicts = self._disableConflicting(package, conflicts) meta = self.addonMeta(package) gui_hooks.addon_manager_will_install_addon(self, package) self._install(package, zfile) gui_hooks.addon_manager_did_install_addon(self, package) schema = self._manifest_schema["properties"] manifest_meta = { k: v for k, v in manifest.items() if k in schema and schema[k]["meta"] } meta.update(manifest_meta) if force_enable: meta["disabled"] = False self.writeAddonMeta(package, meta) meta2 = self.addon_meta(package) return InstallOk( name=meta["name"], conflicts=found_conflicts, compatible=meta2.compatible() ) def _install(self, module: str, zfile: ZipFile) -> None: # previously installed? base = self.addonsFolder(module) if os.path.exists(base): self.backupUserFiles(module) try: self.deleteAddon(module) except Exception: self.restoreUserFiles(module) raise os.mkdir(base) self.restoreUserFiles(module) # extract for n in zfile.namelist(): if n.endswith("/"): # folder; ignore continue path = os.path.join(base, n) # skip existing user files if os.path.exists(path) and n.startswith("user_files/"): continue zfile.extract(n, base) def deleteAddon(self, module: str) -> None: send_to_trash(Path(self.addonsFolder(module))) # Processing local add-on files ###################################################################### def processPackages( self, paths: list[str], parent: QWidget | None = None, force_enable: bool = False, ) -> tuple[list[str], list[str]]: log = [] errs = [] self.mw.progress.start(parent=parent) try: for path in paths: base = os.path.basename(path) result = self.install(path, force_enable=force_enable) if isinstance(result, InstallError): errs.extend( self._installationErrorReport(result, base, mode="local") ) else: log.extend( self._installationSuccessReport(result, base, mode="local") ) finally: self.mw.progress.finish() return log, errs # Installation messaging ###################################################################### def _installationErrorReport( self, result: InstallError, base: str, mode: str = "download" ) -> list[str]: messages = { "zip": tr.addons_corrupt_addon_file(), "manifest": tr.addons_invalid_addon_manifest(), } msg = messages.get(result.errmsg, tr.addons_unknown_error(val=result.errmsg)) if mode == "download": template = tr.addons_error_downloading_ids_errors(id=base, error=msg) else: template = tr.addons_error_installing_bases_errors(base=base, error=msg) return [template] def _installationSuccessReport( self, result: InstallOk, base: str, mode: str = "download" ) -> list[str]: name = result.name or base if mode == "download": template = tr.addons_downloaded_fnames(fname=name) else: template = tr.addons_installed_names(name=name) strings = [template] if result.conflicts: strings.append( tr.addons_the_following_conflicting_addons_were_disabled() + " " + ", ".join(self.addonName(f) for f in result.conflicts) ) if not result.compatible: strings.append(tr.addons_this_addon_is_not_compatible_with()) return strings # Updating ###################################################################### def update_supported_versions(self, items: list[AddonInfo]) -> None: """Adjust the supported version range after an update check. AnkiWeb will not have sent us any add-ons that don't support our version, so this cannot disable add-ons that users are using. It does allow the add-on author to mark an add-on as not supporting a future release, causing the add-on to be disabled when the user upgrades. """ for item in items: addon = self.addon_meta(str(item.id)) updated = False if addon.max_version != item.max_version: addon.max_version = item.max_version updated = True if addon.min_version != item.min_version: addon.min_version = item.min_version updated = True if updated: self.write_addon_meta(addon) def get_updated_addons(self, items: list[AddonInfo]) -> list[AddonInfo]: """Return ids of add-ons requiring an update.""" need_update = [] for item in items: addon = self.addon_meta(str(item.id)) # update if server mtime is newer if not addon.is_latest(item.modified): need_update.append(item) elif not addon.compatible(): # Addon is currently disabled, and a suitable branch was found on the # server. Ignore our stored mtime (which may have been set incorrectly # in the past) and require an update. need_update.append(item) return need_update # Add-on Config ###################################################################### _configButtonActions: dict[str, Callable[[], bool | None]] = {} _configUpdatedActions: dict[str, Callable[[Any], None]] = {} _config_help_actions: dict[str, Callable[[], str]] = {} def addonConfigDefaults(self, module: str) -> dict[str, Any] | None: path = os.path.join(self.addonsFolder(module), "config.json") try: with open(path, encoding="utf8") as f: return json.load(f) except Exception: return None def set_config_help_action(self, module: str, action: Callable[[], str]) -> None: "Set a callback used to produce config help." addon = self.addonFromModule(module) self._config_help_actions[addon] = action def addonConfigHelp(self, module: str) -> str: if action := self._config_help_actions.get(module, None): contents = action() else: path = os.path.join(self.addonsFolder(module), "config.md") if os.path.exists(path): with open(path, encoding="utf-8") as f: contents = f.read() else: return "" return markdown.markdown(contents, extensions=[md_in_html.makeExtension()]) def addonFromModule(self, module: str) -> str: # softly deprecated return module.split(".")[0] @staticmethod def addon_from_module(module: str) -> str: return module.split(".")[0] def configAction(self, module: str) -> Callable[[], bool | None]: return self._configButtonActions.get(module) def configUpdatedAction(self, module: str) -> Callable[[Any], None]: return self._configUpdatedActions.get(module) # Schema ###################################################################### def _addon_schema_path(self, module: str) -> str: return os.path.join(self.addonsFolder(module), "config.schema.json") def _addon_schema(self, module: str) -> Any: path = self._addon_schema_path(module) try: if not os.path.exists(path): # True is a schema accepting everything return True with open(path, encoding="utf-8") as f: return json.load(f) except json.decoder.JSONDecodeError as e: print("The schema is not valid:") print(e) # Add-on Config API ###################################################################### def getConfig(self, module: str) -> dict[str, Any] | None: addon = self.addonFromModule(module) # get default config config = self.addonConfigDefaults(addon) if config is None: return None # merge in user's keys meta = self.addonMeta(addon) userConf = meta.get("config", {}) config.update(userConf) return config def setConfigAction(self, module: str, fn: Callable[[], bool | None]) -> None: addon = self.addonFromModule(module) self._configButtonActions[addon] = fn def setConfigUpdatedAction(self, module: str, fn: Callable[[Any], None]) -> None: addon = self.addonFromModule(module) self._configUpdatedActions[addon] = fn def writeConfig(self, module: str, conf: dict) -> None: addon = self.addonFromModule(module) meta = self.addonMeta(addon) meta["config"] = conf self.writeAddonMeta(addon, meta) # user_files ###################################################################### def _userFilesPath(self, sid: str) -> str: return os.path.join(self.addonsFolder(sid), "user_files") def _userFilesBackupPath(self) -> str: return os.path.join(self.addonsFolder(), "files_backup") def backupUserFiles(self, module: str) -> None: p = self._userFilesPath(module) if os.path.exists(p): os.rename(p, self._userFilesBackupPath()) def restoreUserFiles(self, sid: str) -> None: p = self._userFilesPath(sid) bp = self._userFilesBackupPath() # did we back up userFiles? if not os.path.exists(bp): return os.rename(bp, p) # Web Exports ###################################################################### _webExports: dict[str, str] = {} def setWebExports(self, module: str, pattern: str) -> None: addon = self.addonFromModule(module) self._webExports[addon] = pattern def getWebExports(self, module: str) -> str: return self._webExports.get(module) # Logging ###################################################################### @classmethod def get_logger(cls, module: str) -> logging.Logger: """Return a logger for the given add-on module. NOTE: This method is static to allow it to be called outside of a running Anki instance, e.g. in add-on unit tests. """ return logging.getLogger( f"{ADDON_LOGGER_PREFIX}{cls.addon_from_module(module)}" ) def has_logger(self, module: str) -> bool: return find_addon_logger(self.addon_from_module(module)) is not None def is_debug_logging_enabled(self, module: str) -> bool: if not (logger := find_addon_logger(self.addon_from_module(module))): return False return logger.isEnabledFor(logging.DEBUG) def toggle_debug_logging(self, module: str, enable: bool) -> None: if not (logger := find_addon_logger(self.addon_from_module(module))): return logger.setLevel(logging.DEBUG if enable else logging.INFO) def logs_folder(self, module: str) -> Path: return get_addon_logs_folder( self.mw.pm.addon_logs(), self.addon_from_module(module) ) # Add-ons Dialog ###################################################################### class AddonsDialog(QDialog): def __init__(self, addonsManager: AddonManager) -> None: self.mgr = addonsManager self.mw = addonsManager.mw self._require_restart = False super().__init__(self.mw) f = self.form = aqt.forms.addons.Ui_Dialog() f.setupUi(self) qconnect(f.getAddons.clicked, self.onGetAddons) qconnect(f.installFromFile.clicked, self.onInstallFiles) qconnect(f.checkForUpdates.clicked, self.check_for_updates) qconnect(f.toggleEnabled.clicked, self.onToggleEnabled) qconnect(f.viewPage.clicked, self.onViewPage) qconnect(f.viewFiles.clicked, self.onViewFiles) qconnect(f.delete_2.clicked, self.onDelete) qconnect(f.config.clicked, self.onConfig) qconnect(self.form.addonList.itemDoubleClicked, self.onConfig) qconnect(self.form.addonList.currentRowChanged, self._onAddonItemSelected) qconnect( self.form.addonList.itemSelectionChanged, self._onAddonSelectionChanged ) self.setWindowTitle(tr.addons_window_title()) disable_help_button(self) self.setAcceptDrops(True) self.redrawAddons() restoreGeom(self, "addons") gui_hooks.addons_dialog_will_show(self) self._onAddonSelectionChanged() self.show() def dragEnterEvent(self, event: QDragEnterEvent) -> None: mime = event.mimeData() if not mime.hasUrls(): return None urls = mime.urls() exts = self.mgr.exts if all(any(url.toLocalFile().endswith(ext) for ext in exts) for url in urls): event.acceptProposedAction() def dropEvent(self, event: QDropEvent) -> None: mime = event.mimeData() paths = [] for url in mime.urls(): path = url.toLocalFile() if os.path.exists(path): paths.append(path) self.onInstallFiles(paths) def reject(self) -> None: if self._require_restart: tooltip(tr.addons_changes_will_take_effect_when_anki(), parent=self.mw) saveGeom(self, "addons") aqt.dialogs.markClosed("AddonsDialog") return QDialog.reject(self) silentlyClose = True def name_for_addon_list(self, addon: AddonMeta) -> str: name = addon.human_name() if not addon.enabled: return f"{name} {tr.addons_disabled2()}" elif not addon.compatible(): return f"{name} {tr.addons_requires(val=self.compatible_string(addon))}" return name def compatible_string(self, addon: AddonMeta) -> str: min = addon.min_version if min is not None and min > _current_version: ver = int_version_to_str(min) return f"Anki >= {ver}" else: max = abs(addon.max_version) ver = int_version_to_str(max) return f"Anki <= {ver}" def should_grey(self, addon: AddonMeta) -> bool: return not addon.enabled or not addon.compatible() def redrawAddons( self, ) -> None: addonList = self.form.addonList mgr = self.mgr self.addons = list(mgr.all_addon_meta()) self.addons.sort(key=lambda a: a.human_name().lower()) self.addons.sort(key=self.should_grey) selected = set(self.selectedAddons()) addonList.clear() for addon in self.addons: name = self.name_for_addon_list(addon) item = QListWidgetItem(name, addonList) if self.should_grey(addon): item.setForeground(Qt.GlobalColor.gray) if addon.dir_name in selected: item.setSelected(True) addonList.reset() def _onAddonSelectionChanged(self) -> None: self.form.viewFiles.setEnabled(False) self.form.viewPage.setEnabled(False) self.form.config.setEnabled(False) selected_count = len(self.selectedAddons()) if selected_count == 0: # View Files button shows top-level add-ons directory when nothing is selected self.form.viewFiles.setEnabled(True) elif selected_count == 1: addon = self.addons[self.form.addonList.currentRow()] self.form.viewFiles.setEnabled(True) self.form.viewPage.setEnabled(addon.page() is not None) self.form.config.setEnabled( bool( self.mgr.getConfig(addon.dir_name) or self.mgr.configAction(addon.dir_name) ) ) def _onAddonItemSelected(self, row_int: int) -> None: try: addon = self.addons[row_int] except IndexError: return gui_hooks.addons_dialog_did_change_selected_addon(self, addon) return def selectedAddons(self) -> list[str]: idxs = [x.row() for x in self.form.addonList.selectedIndexes()] return [self.addons[idx].dir_name for idx in idxs] def onlyOneSelected(self) -> str | None: dirs = self.selectedAddons() if len(dirs) != 1: show_info(tr.addons_please_select_a_single_addon_first()) return None return dirs[0] def selected_addon_meta(self) -> AddonMeta | None: idxs = [x.row() for x in self.form.addonList.selectedIndexes()] if len(idxs) != 1: show_info(tr.addons_please_select_a_single_addon_first()) return None return self.addons[idxs[0]] def onToggleEnabled(self) -> None: for module in self.selectedAddons(): self.mgr.toggleEnabled(module) self._require_restart = True self.redrawAddons() def onViewPage(self) -> None: addon = self.selected_addon_meta() if not addon: return if page := addon.page(): openLink(page) def onViewFiles(self) -> None: # if nothing selected, open top-level folder selected = self.selectedAddons() if not selected: openFolder(self.mgr.addonsFolder()) return # otherwise require a single selection addon = self.onlyOneSelected() if not addon: return path = self.mgr.addonsFolder(addon) openFolder(path) def onDelete(self) -> None: selected = self.selectedAddons() if not selected: return if not askUser(tr.addons_delete_the_numd_selected_addon(count=len(selected))): return gui_hooks.addons_dialog_will_delete_addons(self, selected) try: for module in selected: # doing this before deleting, as `enabled` is always True afterwards if self.mgr.addon_meta(module).enabled: self._require_restart = True self.mgr.deleteAddon(module) except OSError as e: showWarning( tr.addons_unable_to_update_or_delete_addon(val=str(e)), textFormat="plain", ) self.form.addonList.clearSelection() self.redrawAddons() def onGetAddons(self) -> None: obj = GetAddons(self) if obj.ids: download_addons( self, self.mgr, obj.ids, self.after_downloading, force_enable=True ) def after_downloading(self, log: list[DownloadLogEntry]) -> None: self.redrawAddons() if log: show_log_to_user(self, log) else: tooltip(tr.addons_no_updates_available()) def onInstallFiles(self, paths: list[str] | None = None) -> bool | None: if not paths: filter = f"{tr.addons_packaged_anki_addon()} " + "({})".format( " ".join(f"*{ext}" for ext in self.mgr.exts) ) paths_ = getFile( self, tr.addons_install_addons(), None, filter, key="addons", multi=True ) paths = paths_ # type: ignore if not paths: return False installAddonPackages(self.mgr, paths, parent=self, force_enable=True) self.redrawAddons() return None def check_for_updates(self) -> None: tooltip(tr.addons_checking()) check_and_prompt_for_updates(self, self.mgr, self.after_downloading) def onConfig(self) -> None: addon = self.onlyOneSelected() if not addon: return # does add-on manage its own config? act = self.mgr.configAction(addon) if act: ret = act() if ret is not False: return conf = self.mgr.getConfig(addon) if conf is None: showInfo(tr.addons_addon_has_no_configuration()) return ConfigEditor(self, addon, conf) # Fetching Add-ons ###################################################################### class GetAddons(QDialog): def __init__(self, dlg: AddonsDialog) -> None: QDialog.__init__(self, dlg) self.addonsDlg = dlg self.mgr = dlg.mgr self.mw = self.mgr.mw self.ids: list[int] = [] self.form = aqt.forms.getaddons.Ui_Dialog() self.form.setupUi(self) b = self.form.buttonBox.addButton( tr.addons_browse_addons(), QDialogButtonBox.ButtonRole.ActionRole ) qconnect(b.clicked, self.onBrowse) disable_help_button(self) restoreGeom(self, "getaddons", adjustSize=True) self.exec() saveGeom(self, "getaddons") def onBrowse(self) -> None: openLink(f"{aqt.appShared}addons/2.1") def accept(self) -> None: # get codes try: sids = self.form.code.text().split() sids = [ re.sub(r"^https://ankiweb.net/shared/info/(\d+)$", r"\1", id_) for id_ in sids ] ids = [int(id_) for id_ in sids] except ValueError: showWarning(tr.addons_invalid_code()) return self.ids = ids QDialog.accept(self) # Downloading ###################################################################### def download_addon(client: HttpClient, id: int) -> DownloadOk | DownloadError: "Fetch a single add-on from AnkiWeb." try: resp = client.get(f"{aqt.appShared}download/{id}?v=2.1&p={_current_version}") if resp.status_code != 200: return DownloadError(status_code=resp.status_code) data = client.stream_content(resp) match = re.match( "attachment; filename=(.+)", resp.headers["content-disposition"] ) assert match is not None fname = match.group(1) meta = extract_meta_from_download_url(resp.url) return DownloadOk( data=data, filename=fname, mod_time=meta.mod_time, min_point_version=meta.min_point_version, max_point_version=meta.max_point_version, branch_index=meta.branch_index, ) except Exception as e: return DownloadError(exception=e) @dataclass class ExtractedDownloadMeta: mod_time: int min_point_version: int max_point_version: int branch_index: int def extract_meta_from_download_url(url: str) -> ExtractedDownloadMeta: urlobj = urlparse(url) query = parse_qs(urlobj.query) def get_first_element(elements: list[str]) -> int: return int(elements[0]) meta = ExtractedDownloadMeta( mod_time=get_first_element(query["t"]), min_point_version=get_first_element(query["minpt"]), max_point_version=get_first_element(query["maxpt"]), branch_index=get_first_element(query["bidx"]), ) return meta def download_log_to_html(log: list[DownloadLogEntry]) -> str: return "
".join(map(describe_log_entry, log)) def describe_log_entry(id_and_entry: DownloadLogEntry) -> str: (id, entry) = id_and_entry buf = f"{id}: " if isinstance(entry, DownloadError): if entry.status_code is not None: if entry.status_code in (403, 404): buf += tr.addons_invalid_code_or_addon_not_available() else: buf += tr.qt_misc_unexpected_response_code(val=entry.status_code) else: buf += ( tr.addons_please_check_your_internet_connection() + "\n\n" + str(entry.exception) ) elif isinstance(entry, InstallError): buf += entry.errmsg else: buf += tr.addons_installed_successfully() return buf def download_encountered_problem(log: list[DownloadLogEntry]) -> bool: return any(not isinstance(e[1], InstallOk) for e in log) def download_and_install_addon( mgr: AddonManager, client: HttpClient, id: int, force_enable: bool = False ) -> DownloadLogEntry: "Download and install a single add-on." result = download_addon(client, id) if isinstance(result, DownloadError): return (id, result) fname = result.filename.replace("_", " ") name = os.path.splitext(fname)[0].strip() if not name: name = str(id) manifest = dict( package=str(id), name=name, mod=result.mod_time, min_point_version=result.min_point_version, max_point_version=result.max_point_version, branch_index=result.branch_index, ) result2 = mgr.install( io.BytesIO(result.data), manifest=manifest, force_enable=force_enable ) return (id, result2) class DownloaderInstaller(QObject): progressSignal = pyqtSignal(int, int) def __init__(self, parent: QWidget, mgr: AddonManager, client: HttpClient) -> None: QObject.__init__(self, parent) self.mgr = mgr self.client = client qconnect(self.progressSignal, self._progress_callback) def bg_thread_progress(up: int, down: int) -> None: self.progressSignal.emit(up, down) # type: ignore self.client.progress_hook = bg_thread_progress def download( self, ids: list[int], on_done: Callable[[list[DownloadLogEntry]], None], force_enable: bool = False, ) -> None: self.ids = ids self.log: list[DownloadLogEntry] = [] self.dl_bytes = 0 self.last_tooltip = 0 self.on_done = on_done parent = self.parent() assert isinstance(parent, QWidget) self.mgr.mw.progress.start(immediate=True, parent=parent) self.mgr.mw.taskman.run_in_background( lambda: self._download_all(force_enable), self._download_done ) def _progress_callback(self, up: int, down: int) -> None: self.dl_bytes += down self.mgr.mw.progress.update( label=tr.addons_downloading_adbd_kb02fkb( part=len(self.log) + 1, total=len(self.ids), kilobytes=self.dl_bytes // 1024, ) ) def _download_all(self, force_enable: bool = False) -> None: for id in self.ids: self.log.append( download_and_install_addon( self.mgr, self.client, id, force_enable=force_enable ) ) def _download_done(self, future: Future) -> None: self.mgr.mw.progress.finish() future.result() # qt gets confused if on_done() opens new windows while the progress # modal is still cleaning up self.mgr.mw.progress.single_shot(50, lambda: self.on_done(self.log)) def show_log_to_user( parent: QWidget, log: list[DownloadLogEntry], title: str = "Anki" ) -> None: have_problem = download_encountered_problem(log) if have_problem: text = tr.addons_one_or_more_errors_occurred() else: text = tr.addons_download_complete_please_restart_anki_to() text += f"

{download_log_to_html(log)}" if have_problem: showWarning(text, textFormat="rich", parent=parent, title=title) else: showInfo(text, parent=parent, title=title) def download_addons( parent: QWidget, mgr: AddonManager, ids: list[int], on_done: Callable[[list[DownloadLogEntry]], None], client: HttpClient | None = None, force_enable: bool = False, ) -> None: if client is None: client = HttpClient() downloader = DownloaderInstaller(parent, mgr, client) downloader.download(ids, on_done=on_done, force_enable=force_enable) # Update checking ###################################################################### class ChooseAddonsToUpdateList(QListWidget): ADDON_ID_ROLE = 101 def __init__( self, parent: QWidget, mgr: AddonManager, updated_addons: list[AddonInfo], ) -> None: QListWidget.__init__(self, parent) self.mgr = mgr self.updated_addons = sorted(updated_addons, key=lambda addon: addon.modified) self.ignore_check_evt = False self.setup() self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) qconnect(self.itemClicked, self.on_click) qconnect(self.itemChanged, self.on_check) qconnect(self.itemDoubleClicked, self.on_double_click) qconnect(self.customContextMenuRequested, self.on_context_menu) def setup(self) -> None: header_item = QListWidgetItem(tr.addons_choose_update_update_all(), self) header_item.setFlags( Qt.ItemFlag(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled) ) self.header_item = header_item for update_info in self.updated_addons: addon_id = update_info.id addon_meta = self.mgr.addon_meta(str(addon_id)) update_enabled = addon_meta.update_enabled addon_name = addon_meta.human_name() update_timestamp = update_info.modified update_time = datetime.fromtimestamp(update_timestamp) addon_label = f"{update_time:%Y-%m-%d} {addon_name}" item = QListWidgetItem(addon_label, self) # Not user checkable because it overlaps with itemClicked signal item.setFlags(Qt.ItemFlag(Qt.ItemFlag.ItemIsEnabled)) if update_enabled: item.setCheckState(Qt.CheckState.Checked) else: item.setCheckState(Qt.CheckState.Unchecked) item.setData(self.ADDON_ID_ROLE, addon_id) self.refresh_header_check_state() def bool_to_check(self, check_bool: bool) -> Qt.CheckState: if check_bool: return Qt.CheckState.Checked else: return Qt.CheckState.Unchecked def checked(self, item: QListWidgetItem) -> bool: return item.checkState() == Qt.CheckState.Checked def on_click(self, item: QListWidgetItem) -> None: if item == self.header_item: return checked = self.checked(item) self.check_item(item, self.bool_to_check(not checked)) self.refresh_header_check_state() def on_check(self, item: QListWidgetItem) -> None: if self.ignore_check_evt: return if item == self.header_item: self.header_checked(item.checkState()) def on_double_click(self, item: QListWidgetItem) -> None: if item == self.header_item: checked = self.checked(item) self.check_item(self.header_item, self.bool_to_check(not checked)) self.header_checked(self.bool_to_check(not checked)) def on_context_menu(self, point: QPoint) -> None: if not (item := self.itemAt(point)): return addon_id = item.data(self.ADDON_ID_ROLE) m = QMenu() a = m.addAction(tr.addons_view_addon_page()) qconnect(a.triggered, lambda _: openLink(f"{aqt.appShared}info/{addon_id}")) m.exec(QCursor.pos()) def check_item(self, item: QListWidgetItem, check: Qt.CheckState) -> None: "call item.setCheckState without triggering on_check" self.ignore_check_evt = True item.setCheckState(check) self.ignore_check_evt = False def header_checked(self, check: Qt.CheckState) -> None: for i in range(1, self.count()): self.check_item(self.item(i), check) def refresh_header_check_state(self) -> None: for i in range(1, self.count()): item = self.item(i) if not self.checked(item): self.check_item(self.header_item, Qt.CheckState.Unchecked) return self.check_item(self.header_item, Qt.CheckState.Checked) def get_selected_addon_ids(self) -> list[int]: addon_ids = [] for i in range(1, self.count()): item = self.item(i) if self.checked(item): addon_id = item.data(self.ADDON_ID_ROLE) addon_ids.append(addon_id) return addon_ids def save_check_state(self) -> None: for i in range(1, self.count()): item = self.item(i) addon_id = item.data(self.ADDON_ID_ROLE) addon_meta = self.mgr.addon_meta(str(addon_id)) addon_meta.update_enabled = self.checked(item) self.mgr.write_addon_meta(addon_meta) class ChooseAddonsToUpdateDialog(QDialog): _on_done: Callable[[list[int]], None] def __init__( self, parent: QWidget, mgr: AddonManager, updated_addons: list[AddonInfo] ) -> None: QDialog.__init__(self, parent) self.setWindowTitle(tr.addons_choose_update_window_title()) self.setWindowModality(Qt.WindowModality.NonModal) self.mgr = mgr self.updated_addons = updated_addons self.setup() restoreGeom(self, "addonsChooseUpdate") def setup(self) -> None: layout = QVBoxLayout() label = QLabel(tr.addons_the_following_addons_have_updates_available()) layout.addWidget(label) addons_list_widget = ChooseAddonsToUpdateList( self, self.mgr, self.updated_addons ) layout.addWidget(addons_list_widget) self.addons_list_widget = addons_list_widget button_box = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) # type: ignore qconnect( button_box.button(QDialogButtonBox.StandardButton.Ok).clicked, self.accept ) qconnect( button_box.button(QDialogButtonBox.StandardButton.Cancel).clicked, self.reject, ) layout.addWidget(button_box) self.setLayout(layout) def ask(self, on_done: Callable[[list[int]], None]) -> None: self._on_done = on_done self.show() def accept(self) -> None: saveGeom(self, "addonsChooseUpdate") self.addons_list_widget.save_check_state() self._on_done(self.addons_list_widget.get_selected_addon_ids()) QDialog.accept(self) def fetch_update_info(ids: list[int]) -> list[AddonInfo]: """Fetch update info from AnkiWeb in one or more batches.""" all_info: list[AddonInfo] = [] while ids: # get another chunk chunk = ids[:25] del ids[:25] batch_results = _fetch_update_info_batch(chunk) all_info.extend(batch_results) return all_info def _fetch_update_info_batch(chunk: Iterable[int]) -> Sequence[AddonInfo]: return aqt.mw.backend.get_addon_info( client_version=_current_version, addon_ids=chunk ) def check_and_prompt_for_updates( parent: QWidget, mgr: AddonManager, on_done: Callable[[list[DownloadLogEntry]], None], requested_by_user: bool = True, ) -> None: def on_updates_received(items: list[AddonInfo]) -> None: handle_update_info(parent, mgr, items, on_done, requested_by_user) check_for_updates(mgr, on_updates_received) def check_for_updates( mgr: AddonManager, on_done: Callable[[list[AddonInfo]], None] ) -> None: def check() -> list[AddonInfo]: return fetch_update_info(mgr.ankiweb_addons()) def update_info_received(future: Future) -> None: # if syncing/in profile screen, defer message delivery if not mgr.mw.col: mgr.mw.progress.single_shot( 1000, lambda: update_info_received(future), False, ) return if future.exception(): # swallow network errors print(str(future.exception())) result = [] else: result = future.result() on_done(result) mgr.mw.taskman.run_in_background(check, update_info_received) def handle_update_info( parent: QWidget, mgr: AddonManager, items: list[AddonInfo], on_done: Callable[[list[DownloadLogEntry]], None], requested_by_user: bool = True, ) -> None: mgr.update_supported_versions(items) updated_addons = mgr.get_updated_addons(items) if not updated_addons: on_done([]) return prompt_to_update(parent, mgr, updated_addons, on_done, requested_by_user) def prompt_to_update( parent: QWidget, mgr: AddonManager, updated_addons: list[AddonInfo], on_done: Callable[[list[DownloadLogEntry]], None], requested_by_user: bool = True, ) -> None: client = HttpClient() if not requested_by_user: prompt_update = False for addon in updated_addons: if mgr.addon_meta(str(addon.id)).update_enabled: prompt_update = True if not prompt_update: return def after_choosing(ids: list[int]) -> None: if ids: download_addons(parent, mgr, ids, on_done, client) ChooseAddonsToUpdateDialog(parent, mgr, updated_addons).ask(after_choosing) def install_or_update_addon( parent: QWidget, mgr: AddonManager, addon_id: int, on_done: Callable[[list[DownloadLogEntry]], None], ) -> None: def check() -> list[AddonInfo]: return fetch_update_info([addon_id]) def update_info_received(future: Future) -> None: try: items = future.result() updated_addons = mgr.get_updated_addons(items) if not updated_addons: on_done([]) return client = HttpClient() download_addons( parent, mgr, [addon.id for addon in updated_addons], on_done, client ) except Exception as exc: on_done([(addon_id, DownloadError(exception=exc))]) mgr.mw.taskman.run_in_background(check, update_info_received) # Editing config ###################################################################### class ConfigEditor(QDialog): def __init__(self, dlg: AddonsDialog, addon: str, conf: dict) -> None: super().__init__(dlg) self.addon = addon self.conf = conf self.mgr = dlg.mgr self.form = aqt.forms.addonconf.Ui_Dialog() self.form.setupUi(self) restore = self.form.buttonBox.button( QDialogButtonBox.StandardButton.RestoreDefaults ) qconnect(restore.clicked, self.onRestoreDefaults) ok = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Ok) ok.setShortcut(QKeySequence("Ctrl+Return")) self.setupFonts() self.updateHelp() self.updateText(self.conf) restoreGeom(self, "addonconf") self.form.splitter.setSizes([2 * self.width() // 3, self.width() // 3]) restoreSplitter(self.form.splitter, "addonconf") self.setWindowTitle( without_unicode_isolation( tr.addons_config_window_title( name=self.mgr.addon_meta(addon).human_name(), ) ) ) disable_help_button(self) self.show() def onRestoreDefaults(self) -> None: default_conf = self.mgr.addonConfigDefaults(self.addon) self.updateText(default_conf) tooltip(tr.addons_restored_defaults(), parent=self) def setupFonts(self) -> None: font_mono = QFont("Consolas") if not font_mono.exactMatch(): font_mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) font_mono.setPointSize(font_mono.pointSize()) self.form.editor.setFont(font_mono) def updateHelp(self) -> None: txt = self.mgr.addonConfigHelp(self.addon) if txt: self.form.help.stdHtml(txt, js=[], css=["css/addonconf.css"], context=self) else: self.form.help.setVisible(False) def updateText(self, conf: dict[str, Any]) -> None: text = json.dumps( conf, ensure_ascii=False, sort_keys=True, indent=4, separators=(",", ": "), ) text = gui_hooks.addon_config_editor_will_display_json(text) self.form.editor.setPlainText(text) if is_mac: self.form.editor.repaint() def onClose(self) -> None: saveGeom(self, "addonconf") saveSplitter(self.form.splitter, "addonconf") def reject(self) -> None: self.onClose() super().reject() def accept(self) -> None: txt = self.form.editor.toPlainText() txt = gui_hooks.addon_config_editor_will_update_json(txt, self.addon) try: new_conf = json.loads(txt) jsonschema.validate(new_conf, self.mgr._addon_schema(self.addon)) except ValidationError as e: # The user did edit the configuration and entered a value # which can not be interpreted. schema = e.schema erroneous_conf = new_conf for link in e.path: erroneous_conf = erroneous_conf[link] path = "/".join(str(path) for path in e.path) if "error_msg" in schema: msg = schema["error_msg"].format( problem=e.message, path=path, schema=str(schema), erroneous_conf=erroneous_conf, ) else: msg = tr.addons_config_validation_error( problem=e.message, path=path, schema=str(schema), ) showInfo(msg) return except Exception as e: showInfo(f"{tr.addons_invalid_configuration()} {repr(e)}") return if not isinstance(new_conf, dict): showInfo(tr.addons_invalid_configuration_top_level_object_must()) return if new_conf != self.conf: self.mgr.writeConfig(self.addon, new_conf) # does the add-on define an action to be fired? act = self.mgr.configUpdatedAction(self.addon) if act: act(new_conf) self.onClose() super().accept() # .ankiaddon installation wizard ###################################################################### def installAddonPackages( addonsManager: AddonManager, paths: list[str], parent: QWidget | None = None, warn: bool = False, strictly_modal: bool = False, advise_restart: bool = False, force_enable: bool = False, ) -> bool: if warn: names = ",
".join(f"{os.path.basename(p)}" for p in paths) q = tr.addons_important_as_addons_are_programs_downloaded() % dict(names=names) if ( not showInfo( q, parent=parent, title=tr.addons_install_anki_addon(), type="warning", customBtns=[ QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes, ], ) == QMessageBox.StandardButton.Yes ): return False log, errs = addonsManager.processPackages( paths, parent=parent, force_enable=force_enable ) if log: log_html = "
".join(log) if advise_restart: log_html += f"

{tr.addons_please_restart_anki_to_complete_the()}" if len(log) == 1 and not strictly_modal: tooltip(log_html, parent=parent) else: showInfo( log_html, parent=parent, textFormat="rich", title=tr.addons_installation_complete(), ) if errs: msg = tr.addons_please_report_this_to_the_respective() showWarning( "

".join(errs + [msg]), parent=parent, textFormat="rich", title=tr.addons_addon_installation_error(), ) return not errs ================================================ FILE: qt/aqt/ankihub.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import functools from concurrent.futures import Future from typing import Callable import aqt import aqt.main from aqt.addons import ( AddonManager, DownloadLogEntry, install_or_update_addon, show_log_to_user, ) from aqt.qt import ( QDialog, QDialogButtonBox, QGridLayout, QLabel, QLineEdit, QPushButton, Qt, QVBoxLayout, QWidget, qconnect, ) from aqt.utils import disable_help_button, showWarning, tr def ankihub_login( mw: aqt.main.AnkiQt, on_success: Callable[[], None], username: str = "", password: str = "", ) -> None: def on_future_done(fut: Future[str], username: str, password: str) -> None: try: token = fut.result() except Exception as exc: showWarning(str(exc)) return if not token: showWarning(tr.sync_ankihub_login_failed(), parent=mw) ankihub_login(mw, on_success, username, password) return mw.pm.set_ankihub_token(token) mw.pm.set_ankihub_username(username) install_ankihub_addon(mw, mw.addonManager) on_success() def callback(username: str, password: str) -> None: if not username and not password: return if username and password: mw.taskman.with_progress( lambda: mw.col.ankihub_login(id=username, password=password), functools.partial(on_future_done, username=username, password=password), parent=mw, ) else: ankihub_login(mw, on_success, username, password) get_id_and_pass_from_user(mw, callback, username, password) def ankihub_logout( mw: aqt.main.AnkiQt, on_success: Callable[[], None], token: str, ) -> None: def logout() -> None: mw.pm.set_ankihub_username(None) mw.pm.set_ankihub_token(None) mw.col.ankihub_logout(token=token) mw.taskman.with_progress( logout, # We don't need to wait for the response lambda _: on_success(), parent=mw, ) def get_id_and_pass_from_user( mw: aqt.main.AnkiQt, callback: Callable[[str, str], None], username: str = "", password: str = "", ) -> None: diag = QDialog(mw) diag.setWindowTitle("Anki") disable_help_button(diag) diag.setWindowModality(Qt.WindowModality.WindowModal) diag.setMinimumWidth(600) vbox = QVBoxLayout() info_label = QLabel(f"

{tr.sync_ankihub_dialog_heading()}

") info_label.setOpenExternalLinks(True) info_label.setWordWrap(True) vbox.addWidget(info_label) vbox.addSpacing(20) g = QGridLayout() l1 = QLabel(tr.sync_ankihub_username_label()) g.addWidget(l1, 0, 0) user = QLineEdit() user.setText(username) g.addWidget(user, 0, 1) l2 = QLabel(tr.sync_password_label()) g.addWidget(l2, 1, 0) passwd = QLineEdit() passwd.setText(password) passwd.setEchoMode(QLineEdit.EchoMode.Password) g.addWidget(passwd, 1, 1) vbox.addLayout(g) vbox.addSpacing(20) bb = QDialogButtonBox() # type: ignore sign_in_button = QPushButton(tr.sync_sign_in()) sign_in_button.setAutoDefault(True) bb.addButton( QPushButton(tr.actions_cancel()), QDialogButtonBox.ButtonRole.RejectRole, ) bb.addButton( sign_in_button, QDialogButtonBox.ButtonRole.AcceptRole, ) qconnect(bb.accepted, diag.accept) qconnect(bb.rejected, diag.reject) vbox.addWidget(bb) diag.setLayout(vbox) diag.adjustSize() diag.show() user.setFocus() def on_finished(result: int) -> None: if result == QDialog.DialogCode.Rejected: callback("", "") else: callback(user.text().strip(), passwd.text()) qconnect(diag.finished, on_finished) diag.open() def install_ankihub_addon(parent: QWidget, mgr: AddonManager) -> None: def on_done(log: list[DownloadLogEntry]) -> None: if log: show_log_to_user(parent, log, title=tr.sync_ankihub_addon_installation()) install_or_update_addon(parent, mgr, 1322529746, on_done) ================================================ FILE: qt/aqt/browser/__init__.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations # ruff: noqa: F401 import sys import aqt from .browser import Browser, PreviewDialog # aliases for legacy pathnames from .sidebar import ( SidebarItem, SidebarItemType, SidebarModel, SidebarSearchBar, SidebarStage, SidebarTool, SidebarToolbar, SidebarTreeView, ) from .table import ( CardState, Cell, CellRow, Column, Columns, DataModel, ItemId, ItemList, ItemState, NoteState, SearchContext, StatusDelegate, Table, ) sys.modules["aqt.sidebar"] = sys.modules["aqt.browser.sidebar"] aqt.sidebar = sys.modules["aqt.browser.sidebar"] # type: ignore sys.modules["aqt.previewer"] = sys.modules["aqt.browser.previewer"] aqt.previewer = sys.modules["aqt.browser.previewer"] # type: ignore ================================================ FILE: qt/aqt/browser/browser.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import functools import json import math import re from collections.abc import Callable, Sequence from typing import Any, cast from markdown import markdown import aqt import aqt.browser import aqt.editor import aqt.forms import aqt.operations from anki._legacy import deprecated from anki.cards import Card, CardId from anki.collection import Collection, Config, OpChanges, SearchNode from anki.consts import * from anki.decks import DeckId from anki.errors import NotFoundError, SearchError from anki.lang import without_unicode_isolation from anki.models import NotetypeId from anki.notes import NoteId from anki.scheduler.base import ScheduleCardsAsNew from anki.tags import MARKED_TAG from anki.utils import is_mac from aqt import AnkiQt, gui_hooks from aqt.editor import Editor, EditorWebView from aqt.errors import show_exception from aqt.exporting import ExportDialog as LegacyExportDialog from aqt.import_export.exporting import ExportDialog from aqt.operations.card import set_card_deck, set_card_flag from aqt.operations.collection import redo, undo from aqt.operations.note import remove_notes from aqt.operations.scheduling import ( bury_cards, forget_cards, grade_now, reposition_new_cards_dialog, set_due_date_dialog, suspend_cards, unbury_cards, unsuspend_cards, ) from aqt.operations.tag import ( add_tags_to_notes, clear_unused_tags, remove_tags_from_notes, ) from aqt.qt import * from aqt.sound import av_player from aqt.switch import Switch from aqt.theme import WidgetStyle from aqt.undo import UndoActionsInfo from aqt.utils import ( HelpPage, KeyboardModifiersPressed, add_ellipsis_to_action_label, current_window, ensure_editor_saved, getTag, no_arg_trigger, openHelp, qtMenuShortcutWorkaround, restoreGeom, restoreSplitter, restoreState, saveGeom, saveSplitter, saveState, showWarning, skip_if_selection_is_empty, tooltip, tr, ) from ..addcards import AddCards from ..changenotetype import change_notetype_dialog from .card_info import BrowserCardInfo from .find_and_replace import FindAndReplaceDialog from .layout import BrowserLayout, QSplitterHandleEventFilter from .previewer import BrowserPreviewer as PreviewDialog from .previewer import Previewer from .sidebar import SidebarTreeView from .table import Table class MockModel: """This class only exists to support some legacy aliases.""" def __init__(self, browser: aqt.browser.Browser) -> None: self.browser = browser @deprecated(replaced_by=aqt.operations.CollectionOp) def beginReset(self) -> None: self.browser.begin_reset() @deprecated(replaced_by=aqt.operations.CollectionOp) def endReset(self) -> None: self.browser.end_reset() @deprecated(replaced_by=aqt.operations.CollectionOp) def reset(self) -> None: self.browser.begin_reset() self.browser.end_reset() class Browser(QMainWindow): mw: AnkiQt col: Collection editor: Editor | None table: Table def __init__( self, mw: AnkiQt, card: Card | None = None, search: tuple[str | SearchNode] | None = None, ) -> None: """ card -- try to select the provided card after executing "search" or "deck:current" (if "search" was None) search -- set and perform search; caller must ensure validity """ QMainWindow.__init__(self, None, Qt.WindowType.Window) self.mw = mw self.col = self.mw.col self.lastFilter = "" self.focusTo: int | None = None self._previewer: Previewer | None = None self._card_info = BrowserCardInfo(self.mw) self._closeEventHasCleanedUp = False self.auto_layout = True self.aspect_ratio = 0.0 self.form = aqt.forms.browser.Ui_Dialog() self.form.setupUi(self) self.form.splitter.setChildrenCollapsible(False) splitter_handle_event_filter = QSplitterHandleEventFilter(self.form.splitter) splitter_handle = self.form.splitter.handle(1) assert splitter_handle is not None splitter_handle.installEventFilter(splitter_handle_event_filter) # set if exactly 1 row is selected; used by the previewer self.card: Card | None = None self.current_card: Card | None = None self.setupSidebar() self.setup_table() self.setupMenus() self.setupHooks() self.setupEditor() gui_hooks.browser_will_show(self) # restoreXXX() should be called after all child widgets have been created # and attached to QMainWindow self._editor_state_key = ( "editorRTL" if self.layoutDirection() == Qt.LayoutDirection.RightToLeft else "editor" ) restoreGeom(self, self._editor_state_key) restoreSplitter(self.form.splitter, "editor3") restoreState(self, self._editor_state_key) # responsive layout if self.height() != 0: self.aspect_ratio = self.width() / self.height() self.set_layout(self.mw.pm.browser_layout(), True) self.onSidebarVisibilityChange(not self.sidebarDockWidget.isHidden()) # disable undo/redo self.on_undo_state_change(mw.undo_actions_info()) # legacy alias self.model = MockModel(self) self.setupSearch(card, search) self.show() def on_operation_did_execute( self, changes: OpChanges, handler: object | None ) -> None: focused = current_window() == self self.table.op_executed(changes, handler, focused) self.sidebar.op_executed(changes, handler, focused) if changes.note_text: if handler is not self.editor: # fixme: this will leave the splitter shown, but with no current # note being edited assert self.editor is not None note = self.editor.note if note: try: note.load() except NotFoundError: self.editor.set_note(None) return self.editor.set_note(note) if changes.browser_table and changes.card: self.card = self.table.get_single_selected_card() self.current_card = self.table.get_current_card() self._update_card_info() self._update_current_actions() # changes.card is required for updating flag icon if changes.note_text or changes.card: self._renderPreview() def on_focus_change(self, new: QWidget | None, old: QWidget | None) -> None: if current_window() == self: self.setUpdatesEnabled(True) self.table.redraw_cells() self.sidebar.refresh_if_needed() def set_layout(self, mode: BrowserLayout, init: bool = False) -> None: self.mw.pm.set_browser_layout(mode) if mode == BrowserLayout.AUTO: self.auto_layout = True self.maybe_update_layout(self.aspect_ratio, True) self.form.actionLayoutAuto.setChecked(True) self.form.actionLayoutVertical.setChecked(False) self.form.actionLayoutHorizontal.setChecked(False) if not init: tooltip(tr.qt_misc_layout_auto_enabled()) else: self.auto_layout = False self.form.actionLayoutAuto.setChecked(False) if mode == BrowserLayout.VERTICAL: self.form.splitter.setOrientation(Qt.Orientation.Vertical) self.form.actionLayoutVertical.setChecked(True) self.form.actionLayoutHorizontal.setChecked(False) if not init: tooltip(tr.qt_misc_layout_vertical_enabled()) elif mode == BrowserLayout.HORIZONTAL: self.form.splitter.setOrientation(Qt.Orientation.Horizontal) self.form.actionLayoutHorizontal.setChecked(True) self.form.actionLayoutVertical.setChecked(False) if not init: tooltip(tr.qt_misc_layout_horizontal_enabled()) def maybe_update_layout(self, aspect_ratio: float, force: bool = False) -> None: if force or math.floor(aspect_ratio) != math.floor(self.aspect_ratio): if aspect_ratio < 1: self.form.splitter.setOrientation(Qt.Orientation.Vertical) else: self.form.splitter.setOrientation(Qt.Orientation.Horizontal) def resizeEvent(self, event: QResizeEvent | None) -> None: assert event is not None if self.height() != 0: aspect_ratio = self.width() / self.height() if self.auto_layout: self.maybe_update_layout(aspect_ratio) self.aspect_ratio = aspect_ratio QMainWindow.resizeEvent(self, event) def get_active_note_type_id(self) -> NotetypeId | None: """ If multiple cards are selected the note type will be derived from the final card selected """ if current_note := self.table.get_current_note(): return current_note.mid return None def add_card(self, deck_id: DeckId): add_cards = cast(AddCards, aqt.dialogs.open("AddCards", self.mw)) add_cards.set_deck(deck_id) if note_type_id := self.get_active_note_type_id(): add_cards.set_note_type(note_type_id) # If in the Browser we open Preview and press Ctrl+W there, # both Preview and Browser windows get closed by Qt out of the box. # We circumvent that behavior by only closing the currently active window def _handle_close(self): active_window = QApplication.activeWindow() if active_window and active_window != self: if isinstance(active_window, QDialog): active_window.reject() else: active_window.close() else: self.close() def setupMenus(self) -> None: # actions f = self.form # edit qconnect(f.actionUndo.triggered, self.undo) qconnect(f.actionRedo.triggered, self.redo) qconnect(f.actionInvertSelection.triggered, self.table.invert_selection) qconnect(f.actionSelectNotes.triggered, self.selectNotes) if not is_mac: f.actionClose.setVisible(False) qconnect(f.actionCreateFilteredDeck.triggered, self.createFilteredDeck) f.actionCreateFilteredDeck.setShortcuts(["Ctrl+G", "Ctrl+Alt+G"]) # view qconnect(f.actionFullScreen.triggered, self.mw.on_toggle_full_screen) qconnect( f.actionZoomIn.triggered, lambda: self._editor_web_view().setZoomFactor( self._editor_web_view().zoomFactor() + 0.1 ), ) qconnect( f.actionZoomOut.triggered, lambda: self._editor_web_view().setZoomFactor( self._editor_web_view().zoomFactor() - 0.1 ), ) qconnect( f.actionResetZoom.triggered, lambda: self._editor_web_view().setZoomFactor(1), ) qconnect( self.form.actionLayoutAuto.triggered, lambda: self.set_layout(BrowserLayout.AUTO), ) qconnect( self.form.actionLayoutVertical.triggered, lambda: self.set_layout(BrowserLayout.VERTICAL), ) qconnect( self.form.actionLayoutHorizontal.triggered, lambda: self.set_layout(BrowserLayout.HORIZONTAL), ) # notes qconnect(f.actionAdd.triggered, self.mw.onAddCard) qconnect(f.actionCopy.triggered, self.on_create_copy) qconnect(f.actionAdd_Tags.triggered, self.add_tags_to_selected_notes) qconnect(f.actionRemove_Tags.triggered, self.remove_tags_from_selected_notes) qconnect(f.actionClear_Unused_Tags.triggered, self.clear_unused_tags) qconnect(f.actionToggle_Mark.triggered, self.toggle_mark_of_selected_notes) qconnect(f.actionChangeModel.triggered, self.onChangeModel) qconnect(f.actionFindDuplicates.triggered, self.onFindDupes) qconnect(f.actionFindReplace.triggered, self.onFindReplace) qconnect(f.actionManage_Note_Types.triggered, self.mw.onNoteTypes) qconnect(f.actionDelete.triggered, self.delete_selected_notes) # cards qconnect(f.actionChange_Deck.triggered, self.set_deck_of_selected_cards) qconnect(f.action_Info.triggered, self.showCardInfo) qconnect(f.actionReposition.triggered, self.reposition) qconnect(f.action_set_due_date.triggered, self.set_due_date) qconnect(f.action_grade_now.triggered, self.grade_now) qconnect(f.action_forget.triggered, self.forget_cards) qconnect(f.actionToggle_Suspend.triggered, self.suspend_selected_cards) qconnect(f.action_toggle_bury.triggered, self.bury_selected_cards) def set_flag_func(desired_flag: int) -> Callable: return lambda: self.set_flag_of_selected_cards(desired_flag) for flag in self.mw.flags.all(): qconnect( getattr(self.form, flag.action).triggered, set_flag_func(flag.index) ) self._update_flag_labels() qconnect(f.actionExport.triggered, self._on_export_notes) # jumps qconnect(f.actionPreviousCard.triggered, self.onPreviousCard) qconnect(f.actionNextCard.triggered, self.onNextCard) qconnect(f.actionFirstCard.triggered, self.onFirstCard) qconnect(f.actionLastCard.triggered, self.onLastCard) qconnect(f.actionFind.triggered, self.onFind) qconnect(f.actionNote.triggered, self.onNote) qconnect(f.actionSidebar.triggered, self.focusSidebar) qconnect(f.actionToggleSidebar.triggered, self.toggle_sidebar) qconnect(f.actionCardList.triggered, self.onCardList) # help qconnect(f.actionGuide.triggered, self.onHelp) # keyboard shortcut for shift+home/end self.pgUpCut = QShortcut(QKeySequence("Shift+Home"), self) qconnect(self.pgUpCut.activated, self.onFirstCard) self.pgDownCut = QShortcut(QKeySequence("Shift+End"), self) qconnect(self.pgDownCut.activated, self.onLastCard) # add-on hook gui_hooks.browser_menus_did_init(self) self.mw.maybeHideAccelerators(self) add_ellipsis_to_action_label(f.actionCopy) add_ellipsis_to_action_label(f.action_forget) add_ellipsis_to_action_label(f.action_grade_now) def _editor_web_view(self) -> EditorWebView: assert self.editor is not None editor_web_view = self.editor.web assert editor_web_view is not None return editor_web_view def closeEvent(self, evt: QCloseEvent | None) -> None: assert evt is not None if self._closeEventHasCleanedUp: evt.accept() return assert self.editor is not None self.editor.call_after_note_saved(self._closeWindow) evt.ignore() def _closeWindow(self) -> None: assert self.editor is not None self._cleanup_preview() self._card_info.close() self.editor.cleanup() self.table.cleanup() self.sidebar.cleanup() saveSplitter(self.form.splitter, "editor3") saveGeom(self, self._editor_state_key) saveState(self, self._editor_state_key) self.teardownHooks() self.mw.maybeReset() aqt.dialogs.markClosed("Browser") self._closeEventHasCleanedUp = True self.mw.deferred_delete_and_garbage_collect(self) self.close() @ensure_editor_saved def closeWithCallback(self, onsuccess: Callable) -> None: self._closeWindow() onsuccess() def keyPressEvent(self, evt: QKeyEvent | None) -> None: assert evt is not None if evt.key() == Qt.Key.Key_Escape: self.close() else: super().keyPressEvent(evt) def reopen( self, _mw: AnkiQt, card: Card | None = None, search: tuple[str | SearchNode] | None = None, ) -> None: if search is not None: self.search_for_terms(*search) self.form.searchEdit.setFocus() if card is not None: if search is None: # implicitly assume 'card' is in the current deck self._default_search(card) self.form.searchEdit.setFocus() self.table.select_single_card(card.id) # Searching ###################################################################### def setupSearch( self, card: Card | None = None, search: tuple[str | SearchNode] | None = None, ) -> None: assert self.mw.pm.profile is not None line_edit = self._line_edit() qconnect(line_edit.returnPressed, self.onSearchActivated) self.form.searchEdit.setCompleter(None) line_edit.setPlaceholderText(tr.browsing_search_bar_hint()) line_edit.setMaxLength(2000000) self.form.searchEdit.addItems( [""] + self.mw.pm.profile.get("searchHistory", []) ) if search is not None: self.search_for_terms(*search) else: self._default_search(card) self.form.searchEdit.setFocus() if card: self.table.select_single_card(card.id) # search triggered by user @ensure_editor_saved def onSearchActivated(self) -> None: text = self.current_search() try: normed = self.col.build_search_string(text) except SearchError as err: showWarning(markdown(str(err))) except Exception as err: showWarning(str(err)) else: self.search_for(normed) self.update_history() def search_for(self, search: str, prompt: str | None = None) -> None: """Keep track of search string so that we reuse identical search when refreshing, rather than whatever is currently in the search field. Optionally set the search bar to a different text than the actual search. """ self._lastSearchTxt = search prompt = search if prompt is None else prompt self.form.searchEdit.setCurrentIndex(-1) self._line_edit().setText(prompt) self.search() def current_search(self) -> str: return self._line_edit().text().replace("\n", " ") def search(self) -> None: """Search triggered programmatically. Caller must have saved note first.""" try: self.table.search(self._lastSearchTxt) except Exception as err: showWarning(str(err)) def update_history(self) -> None: assert self.mw.pm.profile is not None sh = self.mw.pm.profile.get("searchHistory", []) if self._lastSearchTxt in sh: sh.remove(self._lastSearchTxt) sh.insert(0, self._lastSearchTxt) sh = sh[:30] self.form.searchEdit.clear() self.form.searchEdit.addItems(sh) self.mw.pm.profile["searchHistory"] = sh def updateTitle(self) -> None: selected = self.table.len_selection() cur = self.table.len() tr_title = ( tr.browsing_window_title_notes if self.table.is_notes_mode() else tr.browsing_window_title ) self.setWindowTitle( without_unicode_isolation(tr_title(total=cur, selected=selected)) ) def search_for_terms(self, *search_terms: str | SearchNode) -> None: search = self.col.build_search_string(*search_terms) self.form.searchEdit.setEditText(search) self.onSearchActivated() def _default_search(self, card: Card | None = None) -> None: default = self.col.get_config_string(Config.String.DEFAULT_SEARCH_TEXT) if default.strip(): search = default prompt = default else: search = self.col.build_search_string(SearchNode(deck="current")) prompt = "" if card is not None: search = gui_hooks.default_search(search, card) self.search_for(search, prompt) def onReset(self) -> None: self.sidebar.refresh() self.begin_reset() self.end_reset() # caller must have called editor.saveNow() before calling this or .reset() def begin_reset(self) -> None: assert self.editor is not None self.editor.set_note(None, hide=False) self.mw.progress.start() self.table.begin_reset() def end_reset(self) -> None: self.table.end_reset() self.mw.progress.finish() # Table & Editor ###################################################################### def setup_table(self) -> None: self.table = Table(self) self.table.set_view(self.form.tableView) self._switch = switch = Switch(12, tr.browsing_cards(), tr.browsing_notes()) switch.setChecked(self.table.is_notes_mode()) switch.setToolTip(tr.browsing_toggle_showing_cards_notes()) qconnect(self.form.action_toggle_mode.triggered, switch.toggle) qconnect(switch.toggled, self.on_table_state_changed) self.form.gridLayout.addWidget(switch, 0, 0) def setupEditor(self) -> None: QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.onTogglePreview) def add_preview_button(editor: Editor) -> None: editor._links["preview"] = lambda _editor: self.onTogglePreview() gui_hooks.editor_did_init.append(add_preview_button) self.editor = aqt.editor.Editor( self.mw, self.form.fieldsArea, self, editor_mode=aqt.editor.EditorMode.BROWSER, ) gui_hooks.editor_did_init.remove(add_preview_button) @ensure_editor_saved def on_all_or_selected_rows_changed(self) -> None: """Called after the selected or all rows (searching, toggling mode) have changed. Update window title, card preview, context actions, and editor. """ if self._closeEventHasCleanedUp: return self.updateTitle() # if there is only one selected card, use it in the editor # it might differ from the current card self.card = self.table.get_single_selected_card() self.singleCard = bool(self.card) splitter_widget = self.form.splitter.widget(1) assert splitter_widget is not None splitter_widget.setVisible(self.singleCard) assert self.editor is not None if self.singleCard: assert self.card is not None self.editor.set_note(self.card.note(), focusTo=self.focusTo) self.focusTo = None self.editor.card = self.card else: self.editor.set_note(None) self._renderPreview() self._update_row_actions() self._update_selection_actions() gui_hooks.browser_did_change_row(self) @deprecated(info="please use on_all_or_selected_rows_changed() instead.") def onRowChanged(self, *args: Any) -> None: self.on_all_or_selected_rows_changed() def on_current_row_changed(self) -> None: """Called after the row of the current element has changed.""" if self._closeEventHasCleanedUp: return self.current_card = self.table.get_current_card() self._update_current_actions() self._update_card_info() def _update_row_actions(self) -> None: has_rows = bool(self.table.len()) self.form.actionSelectAll.setEnabled(has_rows) self.form.actionInvertSelection.setEnabled(has_rows) self.form.actionFirstCard.setEnabled(has_rows) self.form.actionLastCard.setEnabled(has_rows) def _update_selection_actions(self) -> None: has_selection = bool(self.table.len_selection()) self.form.actionSelectNotes.setEnabled(has_selection) self.form.actionExport.setEnabled(has_selection) self.form.actionAdd_Tags.setEnabled(has_selection) self.form.actionRemove_Tags.setEnabled(has_selection) self.form.actionToggle_Mark.setEnabled(has_selection) self.form.actionChangeModel.setEnabled(has_selection) self.form.actionDelete.setEnabled(has_selection) self.form.actionChange_Deck.setEnabled(has_selection) self.form.action_set_due_date.setEnabled(has_selection) self.form.action_forget.setEnabled(has_selection) self.form.actionReposition.setEnabled(has_selection) self.form.actionToggle_Suspend.setEnabled(has_selection) self.form.action_toggle_bury.setEnabled(has_selection) self.form.menuFlag.setEnabled(has_selection) def _update_current_actions(self) -> None: self._update_flags_menu() self._update_toggle_bury_action() self._update_toggle_mark_action() self._update_toggle_suspend_action() self.form.actionCopy.setEnabled(self.table.has_current()) self.form.action_Info.setEnabled(self.table.has_current()) self.form.actionPreviousCard.setEnabled(self.table.has_previous()) self.form.actionNextCard.setEnabled(self.table.has_next()) @ensure_editor_saved def on_table_state_changed(self, checked: bool) -> None: self.mw.progress.start() try: self.table.toggle_state(checked, self._lastSearchTxt) except Exception as err: self.mw.progress.finish() self._switch.blockSignals(True) self._switch.toggle() self._switch.blockSignals(False) show_exception(parent=self, exception=err) else: self.mw.progress.finish() # Sidebar ###################################################################### def setupSidebar(self) -> None: dw = self.sidebarDockWidget = QDockWidget(tr.browsing_sidebar(), self) dw.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable) dw.setObjectName("Sidebar") dock_area = ( Qt.DockWidgetArea.RightDockWidgetArea if self.layoutDirection() == Qt.LayoutDirection.RightToLeft else Qt.DockWidgetArea.LeftDockWidgetArea ) dw.setAllowedAreas(dock_area) self.sidebar = SidebarTreeView(self) self.sidebarTree = self.sidebar # legacy alias dw.setWidget(self.sidebar) qconnect( self.form.actionSidebarFilter.triggered, self.focusSidebarSearchBar, ) qconnect(dw.visibilityChanged, self.onSidebarVisibilityChange) grid = QGridLayout() grid.addWidget(self.sidebar.searchBar, 0, 0) grid.addWidget(self.sidebar.toolbar, 0, 1) grid.addWidget(self.sidebar, 1, 0, 1, 2) grid.setContentsMargins(8, 4, 0, 0) grid.setSpacing(0) w = QWidget() w.setLayout(grid) dw.setWidget(w) self.sidebarDockWidget.setFloating(False) self.sidebarDockWidget.setTitleBarWidget(QWidget()) self.addDockWidget(dock_area, dw) # schedule sidebar to refresh after browser window has loaded, so the # UI is more responsive self.mw.progress.timer(10, self.sidebar.refresh, False, parent=self.sidebar) def showSidebar(self, show: bool = True) -> None: self.sidebarDockWidget.setVisible(show) def onSidebarVisibilityChange(self, visible): margins = self.form.verticalLayout_3.contentsMargins() skip_left_margin = visible and not ( is_mac and aqt.mw.pm.get_widget_style() == WidgetStyle.NATIVE ) margins.setLeft(0 if skip_left_margin else margins.right()) self.form.verticalLayout_3.setContentsMargins(margins) if visible: self.sidebar.refresh() def focusSidebar(self) -> None: self.showSidebar() self.sidebar.setFocus() def focusSidebarSearchBar(self) -> None: self.showSidebar() self.sidebar.searchBar.setFocus() def toggle_sidebar(self) -> None: self.showSidebar(not self.sidebarDockWidget.isVisible()) # legacy def setFilter(self, *terms: str) -> None: self.sidebar.update_search(*terms) # Info ###################################################################### def showCardInfo(self) -> None: self._card_info.show() def _update_card_info(self) -> None: self._card_info.set_card(self.current_card) # Menu helpers ###################################################################### def selected_cards(self) -> Sequence[CardId]: return self.table.get_selected_card_ids() def selected_notes(self) -> Sequence[NoteId]: return self.table.get_selected_note_ids() def selectedNotesAsCards(self) -> Sequence[CardId]: return self.table.get_card_ids_from_selected_note_ids() def onHelp(self) -> None: openHelp(HelpPage.BROWSING) # legacy selectedCards = selected_cards selectedNotes = selected_notes # Misc menu options ###################################################################### def on_create_copy(self) -> None: if note := self.table.get_current_note(): current_card = self.table.get_current_card() assert current_card is not None deck_id = current_card.current_deck_id() aqt.dialogs.open("AddCards", self.mw).set_note(note, deck_id) @no_arg_trigger @skip_if_selection_is_empty @ensure_editor_saved def onChangeModel(self) -> None: ids = self.selected_notes() change_notetype_dialog(parent=self, note_ids=ids) def createFilteredDeck(self) -> None: search = self.current_search() if KeyboardModifiersPressed().alt: aqt.dialogs.open("FilteredDeckConfigDialog", self.mw, search_2=search) else: aqt.dialogs.open("FilteredDeckConfigDialog", self.mw, search=search) # Preview ###################################################################### def onTogglePreview(self) -> None: assert self.editor is not None if self._previewer: self._previewer.close() elif self.editor.note: self._previewer = PreviewDialog(self, self.mw, self._on_preview_closed) self._previewer.open() self.toggle_preview_button_state(True) def _renderPreview(self) -> None: if self._previewer: if self.singleCard: self._previewer.render_card() else: self.onTogglePreview() def toggle_preview_button_state(self, active: bool) -> None: assert self.editor is not None if self.editor.web: self.editor.web.eval(f"togglePreviewButtonState({json.dumps(active)});") def _cleanup_preview(self) -> None: if self._previewer: self._previewer.cancel_timer() self._previewer.close() def _on_preview_closed(self) -> None: av_player.stop_and_clear_queue() self.toggle_preview_button_state(False) self._previewer = None # Card deletion ###################################################################### @no_arg_trigger @skip_if_selection_is_empty def delete_selected_notes(self) -> None: # ensure deletion is not accidentally triggered when the user is focused # in the editing screen or search bar focus = self.focusWidget() if focus != self.form.tableView: return assert self.editor is not None self.editor.set_note(None) nids = self.table.to_row_of_unselected_note() remove_notes(parent=self, note_ids=nids).run_in_background() # legacy deleteNotes = delete_selected_notes # Deck change ###################################################################### @no_arg_trigger @skip_if_selection_is_empty @ensure_editor_saved def set_deck_of_selected_cards(self) -> None: from aqt.studydeck import StudyDeck assert self.mw.col is not None assert self.mw.col.db is not None cids = self.table.get_selected_card_ids() did = self.mw.col.db.scalar("select did from cards where id = ?", cids[0]) deck_dict = self.mw.col.decks.get(did) assert deck_dict is not None current = deck_dict["name"] def callback(ret: StudyDeck) -> None: if not ret.name: return did = self.col.decks.id(ret.name) assert did is not None set_card_deck(parent=self, card_ids=cids, deck_id=did).run_in_background() StudyDeck( self.mw, current=current, accept=tr.browsing_move_cards(), title=tr.browsing_change_deck(), help=HelpPage.BROWSING, parent=self, callback=callback, ) # legacy setDeck = set_deck_of_selected_cards # Tags ###################################################################### @no_arg_trigger @skip_if_selection_is_empty @ensure_editor_saved def add_tags_to_selected_notes( self, tags: str | None = None, ) -> None: "Shows prompt if tags not provided." if not (tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_add())): return space_separated_tags = re.sub(r"[ \n\t\v]+", " ", tags) add_tags_to_notes( parent=self, note_ids=self.selected_notes(), space_separated_tags=space_separated_tags, ).run_in_background(initiator=self) @no_arg_trigger @skip_if_selection_is_empty @ensure_editor_saved def remove_tags_from_selected_notes(self, tags: str | None = None) -> None: "Shows prompt if tags not provided." if not ( tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_delete()) ): return remove_tags_from_notes( parent=self, note_ids=self.selected_notes(), space_separated_tags=tags ).run_in_background(initiator=self) def _prompt_for_tags(self, prompt: str) -> str | None: (tags, ok) = getTag(self, self.col, prompt) if not ok: return None else: return tags @no_arg_trigger @ensure_editor_saved def clear_unused_tags(self) -> None: clear_unused_tags(parent=self).run_in_background() addTags = add_tags_to_selected_notes deleteTags = remove_tags_from_selected_notes clearUnusedTags = clear_unused_tags # Suspending ###################################################################### def _update_toggle_suspend_action(self) -> None: is_suspended = bool( self.current_card and self.current_card.queue == QUEUE_TYPE_SUSPENDED ) self.form.actionToggle_Suspend.setChecked(is_suspended) @skip_if_selection_is_empty @ensure_editor_saved def suspend_selected_cards(self, checked: bool) -> None: cids = self.selected_cards() if checked: suspend_cards(parent=self, card_ids=cids).run_in_background() else: unsuspend_cards(parent=self.mw, card_ids=cids).run_in_background() # Burying ###################################################################### def _update_toggle_bury_action(self) -> None: is_buried = bool( self.current_card and self.current_card.queue in (QUEUE_TYPE_MANUALLY_BURIED, QUEUE_TYPE_SIBLING_BURIED) ) self.form.action_toggle_bury.setChecked(is_buried) @skip_if_selection_is_empty @ensure_editor_saved def bury_selected_cards(self, checked: bool) -> None: cids = self.selected_cards() if checked: bury_cards(parent=self, card_ids=cids).run_in_background() else: unbury_cards(parent=self.mw, card_ids=cids).run_in_background() # Exporting ###################################################################### @no_arg_trigger @skip_if_selection_is_empty def _on_export_notes(self) -> None: if not self.mw.pm.legacy_import_export(): nids = self.selected_notes() ExportDialog(self.mw, nids=nids, parent=self) else: cids = self.selectedNotesAsCards() LegacyExportDialog(self.mw, cids=list(cids), parent=self) # Flags & Marking ###################################################################### @skip_if_selection_is_empty @ensure_editor_saved def set_flag_of_selected_cards(self, flag: int) -> None: if not self.current_card: return # flag needs toggling off? if flag == self.current_card.user_flag(): flag = 0 set_card_flag( parent=self, card_ids=self.selected_cards(), flag=flag ).run_in_background() def _update_flags_menu(self) -> None: flag = self.current_card and self.current_card.user_flag() flag = flag or 0 for f in self.mw.flags.all(): getattr(self.form, f.action).setChecked(flag == f.index) qtMenuShortcutWorkaround(self.form.menuFlag) def _update_flag_labels(self) -> None: for flag in self.mw.flags.all(): getattr(self.form, flag.action).setText(flag.label) def toggle_mark_of_selected_notes(self, checked: bool) -> None: if checked: self.add_tags_to_selected_notes(tags=MARKED_TAG) else: self.remove_tags_from_selected_notes(tags=MARKED_TAG) def _update_toggle_mark_action(self) -> None: is_marked = bool( self.current_card and self.current_card.note().has_tag(MARKED_TAG) ) self.form.actionToggle_Mark.setChecked(is_marked) # Scheduling ###################################################################### @no_arg_trigger @skip_if_selection_is_empty @ensure_editor_saved def reposition(self) -> None: if op := reposition_new_cards_dialog( parent=self, card_ids=self.selected_cards() ): op.run_in_background() @no_arg_trigger @skip_if_selection_is_empty @ensure_editor_saved def set_due_date(self) -> None: if op := set_due_date_dialog( parent=self, card_ids=self.selected_cards(), config_key=Config.String.SET_DUE_BROWSER, ): op.run_in_background() @no_arg_trigger @skip_if_selection_is_empty @ensure_editor_saved def forget_cards(self) -> None: if op := forget_cards( parent=self, card_ids=self.selected_cards(), context=ScheduleCardsAsNew.Context.BROWSER, ): op.run_in_background() @no_arg_trigger @skip_if_selection_is_empty @ensure_editor_saved def grade_now(self) -> None: """Show dialog to grade selected cards.""" dialog = QDialog(self) dialog.setWindowTitle(tr.actions_grade_now()) layout = QHBoxLayout() dialog.setLayout(layout) # Add grade buttons for ease, label in [ (1, tr.studying_again()), (2, tr.studying_hard()), (3, tr.studying_good()), (4, tr.studying_easy()), ]: btn = QPushButton(label) def cb(ease: int) -> None: grade_now( parent=self, card_ids=self.selected_cards(), ease=ease ).run_in_background() dialog.accept() qconnect( btn.clicked, functools.partial(cb, ease=ease), ) if key := aqt.mw.pm.get_answer_key(ease): QShortcut(key, dialog, activated=btn.click) # type: ignore btn.setToolTip(tr.actions_shortcut_key(key)) layout.addWidget(btn) # Add cancel button cancel_btn = QPushButton(tr.actions_cancel()) qconnect(cancel_btn.clicked, dialog.reject) layout.addWidget(cancel_btn) dialog.exec() # Edit: selection ###################################################################### @no_arg_trigger @skip_if_selection_is_empty @ensure_editor_saved def selectNotes(self) -> None: nids = self.selected_notes() # clear the selection so we don't waste energy preserving it self.table.clear_selection() search = self.col.build_search_string( SearchNode(nids=SearchNode.IdList(ids=nids)) ) self.search_for(search) self.table.select_all() # Hooks ###################################################################### def setupHooks(self) -> None: gui_hooks.undo_state_did_change.append(self.on_undo_state_change) gui_hooks.backend_will_block.append(self.table.on_backend_will_block) gui_hooks.backend_did_block.append(self.table.on_backend_did_block) gui_hooks.operation_did_execute.append(self.on_operation_did_execute) gui_hooks.focus_did_change.append(self.on_focus_change) gui_hooks.flag_label_did_change.append(self._update_flag_labels) gui_hooks.collection_will_temporarily_close.append(self._on_temporary_close) def teardownHooks(self) -> None: gui_hooks.undo_state_did_change.remove(self.on_undo_state_change) gui_hooks.backend_will_block.remove(self.table.on_backend_will_block) gui_hooks.backend_did_block.remove(self.table.on_backend_did_block) gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) gui_hooks.focus_did_change.remove(self.on_focus_change) gui_hooks.flag_label_did_change.remove(self._update_flag_labels) gui_hooks.collection_will_temporarily_close.remove(self._on_temporary_close) def _on_temporary_close(self, col: Collection) -> None: # we could reload browser columns in the future; for now we just close self.close() # Undo ###################################################################### def undo(self) -> None: undo(parent=self) def redo(self) -> None: redo(parent=self) def on_undo_state_change(self, info: UndoActionsInfo) -> None: self.form.actionUndo.setText(info.undo_text) self.form.actionUndo.setEnabled(info.can_undo) self.form.actionRedo.setText(info.redo_text) self.form.actionRedo.setEnabled(info.can_redo) self.form.actionRedo.setVisible(info.show_redo) # Edit: replacing ###################################################################### @no_arg_trigger @ensure_editor_saved def onFindReplace(self) -> None: FindAndReplaceDialog(self, mw=self.mw, note_ids=self.selected_notes()) # Edit: finding dupes ###################################################################### @no_arg_trigger @ensure_editor_saved def onFindDupes(self) -> None: from aqt.browser.find_duplicates import FindDuplicatesDialog FindDuplicatesDialog(browser=self, mw=self.mw) # Jumping ###################################################################### def has_previous_card(self) -> bool: return self.table.has_previous() def has_next_card(self) -> bool: return self.table.has_next() def onPreviousCard(self) -> None: assert self.editor is not None self.focusTo = self.editor.currentField self.editor.call_after_note_saved(self.table.to_previous_row) def onNextCard(self) -> None: assert self.editor is not None self.focusTo = self.editor.currentField self.editor.call_after_note_saved(self.table.to_next_row) def onFirstCard(self) -> None: self.table.to_first_row() def onLastCard(self) -> None: self.table.to_last_row() def onFind(self) -> None: self.form.searchEdit.setFocus() self._line_edit().selectAll() def onNote(self) -> None: def cb(): assert self.editor is not None and self.editor.web is not None self.editor.web.setFocus() self.editor.loadNote(focusTo=0) assert self.editor is not None self.editor.call_after_note_saved(cb) def onCardList(self) -> None: self.form.tableView.setFocus() def _line_edit(self) -> QLineEdit: line_edit = self.form.searchEdit.lineEdit() assert line_edit is not None return line_edit ================================================ FILE: qt/aqt/browser/card_info.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import json from collections.abc import Callable from google.protobuf.json_format import MessageToDict import aqt from anki.cards import Card, CardId from anki.errors import NotFoundError from anki.lang import without_unicode_isolation from aqt.qt import * from aqt.utils import ( disable_help_button, qconnect, restoreGeom, saveGeom, setWindowIcon, tooltip, tr, ) from aqt.webview import AnkiWebView, AnkiWebViewKind class CardInfoDialog(QDialog): TITLE = "browser card info" GEOMETRY_KEY = "revlog" silentlyClose = True def __init__( self, parent: QWidget | None, mw: aqt.AnkiQt, card: Card | None, on_close: Callable | None = None, geometry_key: str | None = None, window_title: str | None = None, ) -> None: super().__init__(parent) self.mw = mw self._on_close = on_close self.GEOMETRY_KEY = geometry_key or self.GEOMETRY_KEY if window_title: self.setWindowTitle(window_title) self._setup_ui(card.id if card else None) self.show() def _setup_ui(self, card_id: CardId | None) -> None: self.mw.garbage_collect_on_dialog_finish(self) self.setMinimumSize(400, 300) disable_help_button(self) restoreGeom(self, self.GEOMETRY_KEY, default_size=(800, 800)) setWindowIcon(self) self.web: AnkiWebView | None = AnkiWebView( kind=AnkiWebViewKind.BROWSER_CARD_INFO ) self.web.setVisible(False) self.web.load_sveltekit_page(f"card-info/{card_id}") layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self.web) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) buttons.setContentsMargins(10, 0, 10, 10) layout.addWidget(buttons) qconnect(buttons.rejected, self.reject) self.copy_debug_info = QShortcut( # type: ignore "ctrl+c", self, activated=lambda: self.copy_card_info(card_id) ) self.setLayout(layout) def copy_card_info(self, card_id: CardId | None) -> None: if self.web and self.web.selectedText(): self.web.onCopy() return if card_id is None: return assert aqt.mw.col.db, tr.errors_inconsistent_db_state() proto_info = aqt.mw.col.card_stats_data(card_id) info = MessageToDict(proto_info) card = aqt.mw.col.get_card(card_id) revlog = aqt.mw.col.db.execute( f"SELECT * FROM revlog WHERE cid == {card_id} ORDER BY id DESC" ) deck = aqt.mw.col.decks.get(card.did) or dict() config = aqt.mw.col.decks.get_config(deck.get("conf", -1)) or dict() info["deck"] = deck info["config"] = config info["config"].pop("name", None) info["deck"].pop("name", None) info["deck"].pop("desc", None) info["deck"].pop("usn", None) info.pop("usn", None) info.pop("cardType", None) info.pop("notetype", None) info.pop("preset", None) info["cardRow"] = aqt.mw.col.db.execute( f"SELECT * FROM cards WHERE id == {card_id} ORDER BY id DESC" )[0] new_revlog = [ {"row": revlog, "info": card_info_review} for revlog, card_info_review in zip(revlog, info.get("revlog", [])) ] info["revlog"] = new_revlog info["rollover"] = aqt.mw.col.get_config("rollover") clipboard = QApplication.clipboard() assert clipboard is not None clipboard.setText(json.dumps(info, indent=2)) tooltip(tr.about_copied_to_clipboard()) def update_card(self, card_id: CardId | None) -> None: try: self.mw.col.get_card(card_id) except NotFoundError: card_id = None assert self.web is not None self.web.eval(f"anki.updateCard('{card_id}');") def reject(self) -> None: if self._on_close: self._on_close() assert self.web is not None self.web.cleanup() self.web = None saveGeom(self, self.GEOMETRY_KEY) return QDialog.reject(self) class CardInfoManager: """Wrapper class to conveniently toggle, update and close a card info dialog.""" def __init__(self, mw: aqt.AnkiQt, geometry_key: str, window_title: str): self.mw = mw self.geometry_key = geometry_key self.window_title = window_title self._card: Card | None = None self._dialog: CardInfoDialog | None = None def show(self) -> None: if self._dialog: self._dialog.activateWindow() self._dialog.raise_() else: self._dialog = CardInfoDialog( None, self.mw, self._card, self._on_close, self.geometry_key, self.window_title, ) def set_card(self, card: Card | None) -> None: self._card = card if self._dialog: self._dialog.update_card(card.id if card else None) def close(self) -> None: if self._dialog: self._dialog.reject() def _on_close(self) -> None: self._dialog = None class BrowserCardInfo(CardInfoManager): def __init__(self, mw: aqt.AnkiQt): super().__init__( mw, "revlog", without_unicode_isolation( tr.card_stats_current_card(context=tr.qt_misc_browse()) ), ) class ReviewerCardInfo(CardInfoManager): def __init__(self, mw: aqt.AnkiQt): super().__init__( mw, "reviewerCardInfo", without_unicode_isolation( tr.card_stats_current_card(context=tr.decks_study()) ), ) class PreviousReviewerCardInfo(CardInfoManager): def __init__(self, mw: aqt.AnkiQt): super().__init__( mw, "previousReviewerCardInfo", without_unicode_isolation( tr.card_stats_previous_card(context=tr.decks_study()) ), ) ================================================ FILE: qt/aqt/browser/find_and_replace.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Sequence import aqt import aqt.forms import aqt.operations from anki.notes import NoteId from aqt import AnkiQt from aqt.operations import QueryOp from aqt.operations.note import find_and_replace from aqt.operations.tag import find_and_replace_tag from aqt.qt import * from aqt.utils import ( HelpPage, disable_help_button, openHelp, qconnect, restore_combo_history, restore_combo_index_for_session, restore_is_checked, restoreGeom, save_combo_history, save_combo_index_for_session, save_is_checked, saveGeom, tooltip, tr, ) class FindAndReplaceDialog(QDialog): COMBO_NAME = "BrowserFindAndReplace" def __init__( self, parent: QWidget, *, mw: AnkiQt, note_ids: Sequence[NoteId], field: str | None = None, ) -> None: """ If 'field' is passed, only this is added to the field selector. Otherwise, the fields belonging to the 'note_ids' are added. """ super().__init__(parent) self.mw = mw self.note_ids = note_ids self.field_names: list[str] = [] self._field = field if field: self._show([field]) elif note_ids: # fetch field names and then show QueryOp( parent=mw, op=lambda col: col.field_names_for_note_ids(note_ids), success=self._show, ).run_in_background() else: self._show([]) def _show(self, field_names: Sequence[str]) -> None: # add "all fields" and "tags" to the top of the list self.field_names = [ tr.browsing_all_fields(), tr.editing_tags(), ] + list(field_names) disable_help_button(self) self.form = aqt.forms.findreplace.Ui_Dialog() self.form.setupUi(self) self.setWindowModality(Qt.WindowModality.WindowModal) self._find_history = restore_combo_history( self.form.find, self.COMBO_NAME + "Find" ) find_completer = self.form.find.completer() assert find_completer is not None find_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseSensitive) self._replace_history = restore_combo_history( self.form.replace, self.COMBO_NAME + "Replace" ) replace_completer = self.form.replace.completer() assert replace_completer is not None replace_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseSensitive) if not self.note_ids: # no selected notes to affect self.form.selected_notes.setChecked(False) self.form.selected_notes.setEnabled(False) elif self._field: self.form.selected_notes.setChecked(False) restore_is_checked(self.form.re, self.COMBO_NAME + "Regex") restore_is_checked(self.form.ignoreCase, self.COMBO_NAME + "ignoreCase") self.form.field.addItems(self.field_names) if self._field: self.form.field.setCurrentIndex(self.field_names.index(self._field)) else: restore_combo_index_for_session( self.form.field, self.field_names, self.COMBO_NAME + "Field" ) qconnect(self.form.buttonBox.helpRequested, self.show_help) restoreGeom(self, "findreplace") self.show() self.form.find.setFocus() def accept(self) -> None: saveGeom(self, "findreplace") save_combo_index_for_session(self.form.field, self.COMBO_NAME + "Field") search = save_combo_history( self.form.find, self._find_history, self.COMBO_NAME + "Find" ) replace = save_combo_history( self.form.replace, self._replace_history, self.COMBO_NAME + "Replace" ) regex = self.form.re.isChecked() match_case = not self.form.ignoreCase.isChecked() save_is_checked(self.form.re, self.COMBO_NAME + "Regex") save_is_checked(self.form.ignoreCase, self.COMBO_NAME + "ignoreCase") if not self.form.selected_notes.isChecked(): # an empty list means *all* notes self.note_ids = [] parent_widget = self.parentWidget() assert parent_widget is not None # tags? if self.form.field.currentIndex() == 1: op = find_and_replace_tag( parent=parent_widget, note_ids=self.note_ids, search=search, replacement=replace, regex=regex, match_case=match_case, ) else: # fields if self.form.field.currentIndex() == 0: field = None else: field = self.field_names[self.form.field.currentIndex()] op = find_and_replace( parent=parent_widget, note_ids=self.note_ids, search=search, replacement=replace, regex=regex, field_name=field, match_case=match_case, ) if not self.note_ids: op.success( lambda out: tooltip( tr.browsing_notes_updated(count=out.count), parent=self.parentWidget(), ) ) op.run_in_background() super().accept() def show_help(self) -> None: openHelp(HelpPage.BROWSING_FIND_AND_REPLACE) ================================================ FILE: qt/aqt/browser/find_duplicates.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import html from typing import Any import anki import anki.find import aqt import aqt.forms from anki.collection import SearchNode from anki.notes import NoteId from aqt.qt import * from aqt.qt import sip from ..operations import QueryOp from ..operations.tag import add_tags_to_notes from ..utils import ( disable_help_button, restore_combo_history, restore_combo_index_for_session, restoreGeom, save_combo_history, save_combo_index_for_session, saveGeom, tr, ) from . import Browser class FindDuplicatesDialog(QDialog): def __init__(self, browser: Browser, mw: aqt.AnkiQt): super().__init__(parent=browser) self.browser = browser self.mw = mw self.mw.garbage_collect_on_dialog_finish(self) self.form = form = aqt.forms.finddupes.Ui_Dialog() form.setupUi(self) restoreGeom(self, "findDupes") disable_help_button(self) searchHistory = restore_combo_history(form.search, "findDupesFind") fields = sorted( anki.find.fieldNames(self.mw.col, downcase=False), key=lambda x: x.lower() ) form.fields.addItems(fields) restore_combo_index_for_session(form.fields, fields, "findDupesFields") self._dupesButton: QPushButton | None = None self._dupes: list[tuple[str, list[NoteId]]] = [] # links form.webView.set_bridge_command(self._on_duplicate_clicked, context=self) form.webView.stdHtml("", context=self) def on_finished(code: Any) -> None: saveGeom(self, "findDupes") qconnect(self.finished, on_finished) def on_click() -> None: search_text = save_combo_history( form.search, searchHistory, "findDupesFind" ) save_combo_index_for_session(form.fields, "findDupesFields") field = fields[form.fields.currentIndex()] QueryOp( parent=self.browser, op=lambda col: col.find_dupes(field, search_text), success=self.show_duplicates_report, ).run_in_background() search = form.buttonBox.addButton( tr.actions_search(), QDialogButtonBox.ButtonRole.ActionRole ) assert search is not None qconnect(search.clicked, on_click) self.show() def show_duplicates_report(self, dupes: list[tuple[str, list[NoteId]]]) -> None: if sip.isdeleted(self): return self._dupes = dupes if not self._dupesButton: self._dupesButton = b = self.form.buttonBox.addButton( tr.browsing_tag_duplicates(), QDialogButtonBox.ButtonRole.ActionRole ) assert b is not None qconnect(b.clicked, self._tag_duplicates) text = "" groups = len(dupes) notes = sum(len(r[1]) for r in dupes) part1 = tr.browsing_group(count=groups) part2 = tr.browsing_note_count(count=notes) text += tr.browsing_found_as_across_bs(part=part1, whole=part2) text += "

    " for val, nids in dupes: text += ( """
  1. %s: %s""" % ( html.escape( self.mw.col.build_search_string( SearchNode(nids=SearchNode.IdList(ids=nids)) ) ), tr.browsing_note_count(count=len(nids)), html.escape(val), ) ) text += "
" self.form.webView.stdHtml(text, context=self) def _tag_duplicates(self) -> None: if not self._dupes: return note_ids = set() for _, nids in self._dupes: note_ids.update(nids) add_tags_to_notes( parent=self, note_ids=list(note_ids), space_separated_tags=tr.browsing_duplicate(), ).run_in_background() def _on_duplicate_clicked(self, link: str) -> None: self.browser.search_for(link) self.browser.onNote() ================================================ FILE: qt/aqt/browser/layout.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from enum import Enum from aqt.qt import QEvent, QObject, QSplitter, Qt class BrowserLayout(Enum): AUTO = "auto" VERTICAL = "vertical" HORIZONTAL = "horizontal" class QSplitterHandleEventFilter(QObject): """Event filter that equalizes QSplitter panes on double-clicking the handle""" def __init__(self, splitter: QSplitter): super().__init__(splitter) self._splitter = splitter def eventFilter(self, object: QObject | None, event: QEvent | None) -> bool: assert event is not None if event.type() == QEvent.Type.MouseButtonDblClick: splitter_parent = self._splitter.parentWidget() assert splitter_parent is not None if self._splitter.orientation() == Qt.Orientation.Horizontal: half_size = splitter_parent.width() // 2 else: half_size = splitter_parent.height() // 2 self._splitter.setSizes([half_size, half_size]) return True return super().eventFilter(object, event) ================================================ FILE: qt/aqt/browser/previewer.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import json import re import time from collections.abc import Callable from typing import Any import aqt.browser from anki.cards import Card from anki.collection import Config from anki.tags import MARKED_TAG from aqt import AnkiQt, gui_hooks, is_mac from aqt.qt import ( QCheckBox, QDialog, QDialogButtonBox, QKeySequence, QShortcut, Qt, QTimer, QVBoxLayout, qconnect, ) from aqt.reviewer import replay_audio from aqt.sound import av_player, play_clicked_audio from aqt.theme import theme_manager from aqt.utils import disable_help_button, restoreGeom, saveGeom, setWindowIcon, tr from aqt.webview import AnkiWebView, AnkiWebViewKind LastStateAndMod = tuple[str, int, int] class Previewer(QDialog): _last_state: LastStateAndMod | None = None _card_changed = False _last_render: int | float = 0 _timer: QTimer | None = None _show_both_sides = False def __init__( self, parent: aqt.browser.Browser | None, mw: AnkiQt, on_close: Callable[[], None], ) -> None: super().__init__(None, Qt.WindowType.Window) mw.garbage_collect_on_dialog_finish(self) self._open = True self._parent = parent self._close_callback = on_close self.mw = mw disable_help_button(self) setWindowIcon(self) gui_hooks.previewer_did_init(self) def card(self) -> Card | None: raise NotImplementedError def card_changed(self) -> bool: raise NotImplementedError def open(self) -> None: self._state = "question" self._last_state = None self._create_gui() self._setup_web_view() self.render_card() restoreGeom(self, "preview") self.show() def _create_gui(self) -> None: self.setWindowTitle(tr.actions_preview()) self.close_shortcut = QShortcut(QKeySequence("Ctrl+Shift+P"), self) qconnect(self.close_shortcut.activated, self.close) qconnect(self.finished, self._on_finished) self.silentlyClose = True self.vbox = QVBoxLayout() spacing = 6 self.vbox.setContentsMargins(0, 0, 0, 0) self.vbox.setSpacing(spacing) self._web: AnkiWebView | None = AnkiWebView(kind=AnkiWebViewKind.PREVIEWER) self.vbox.addWidget(self._web) self.bbox = QDialogButtonBox() self.bbox.setContentsMargins( spacing, spacing if is_mac else 0, spacing, spacing ) self.bbox.setLayoutDirection(Qt.LayoutDirection.LeftToRight) gui_hooks.card_review_webview_did_init(self._web, AnkiWebViewKind.PREVIEWER) self._replay = self.bbox.addButton( tr.actions_replay_audio(), QDialogButtonBox.ButtonRole.ActionRole ) assert self._replay is not None self._replay.setAutoDefault(False) self._replay.setShortcut(QKeySequence("R")) self._replay.setToolTip(tr.actions_shortcut_key(val="R")) qconnect(self._replay.clicked, self._on_replay_audio) both_sides_button = QCheckBox(tr.qt_misc_back_side_only()) both_sides_button.setShortcut(QKeySequence("B")) both_sides_button.setToolTip(tr.actions_shortcut_key(val="B")) self.bbox.addButton(both_sides_button, QDialogButtonBox.ButtonRole.ActionRole) self._show_both_sides = self.mw.col.get_config_bool( Config.Bool.PREVIEW_BOTH_SIDES ) both_sides_button.setChecked(self._show_both_sides) qconnect(both_sides_button.toggled, self._on_show_both_sides) self.vbox.addWidget(self.bbox) self.setLayout(self.vbox) def _on_finished(self, ok: int) -> None: saveGeom(self, "preview") self._on_close() def _on_replay_audio(self) -> None: assert self._web is not None card = self.card() assert card is not None gui_hooks.audio_will_replay(self._web, card, self._state == "question") if self._state == "question": replay_audio(card, True) elif self._state == "answer": replay_audio(card, False) def _on_close(self) -> None: self._open = False self._close_callback() assert self._web is not None self._web.cleanup() self._web = None def _setup_web_view(self) -> None: assert self._web is not None self._web.stdHtml( self.mw.reviewer.revHtml(), css=["css/reviewer.css"], js=[ "js/mathjax.js", "js/vendor/mathjax/tex-chtml-full.js", "js/reviewer.js", ], context=self, ) self._web.allow_drops = True self._web.eval("_blockDefaultDragDropBehavior();") self._web.set_bridge_command(self._on_bridge_cmd, self) def _on_bridge_cmd(self, cmd: str) -> Any: if cmd.startswith("play:"): card = self.card() assert card is not None play_clicked_audio(cmd, card) def _update_flag_and_mark_icons(self, card: Card | None) -> None: if card: flag = card.user_flag() marked = card.note(reload=True).has_tag(MARKED_TAG) else: flag = 0 marked = False assert self._web is not None self._web.eval(f"_drawFlag({flag}); _drawMark({json.dumps(marked)});") def render_card(self) -> None: self.cancel_timer() # Keep track of whether render() has ever been called # with cardChanged=True since the last successful render self._card_changed |= self.card_changed() # avoid rendering in quick succession elap_ms = int((time.time() - self._last_render) * 1000) delay = 300 if elap_ms < delay: self._timer = self.mw.progress.timer( delay - elap_ms, self._render_scheduled, False, parent=self ) else: self._render_scheduled() def cancel_timer(self) -> None: if self._timer: self._timer.stop() self._timer = None def _render_scheduled(self) -> None: self.cancel_timer() self._last_render = time.time() if not self._open: return c = self.card() self._update_flag_and_mark_icons(c) func = "_showQuestion" ans_txt = "" if not c: txt = tr.qt_misc_please_select_1_card() bodyclass = "" self._last_state = None else: if self._show_both_sides: self._state = "answer" elif self._card_changed: self._state = "question" currentState = self._state_and_mod() if currentState == self._last_state: # nothing has changed, avoid refreshing return # need to force reload even if answer txt = c.question(reload=True) ans_txt = c.answer() if self._state == "answer": func = "_showAnswer" txt = ans_txt txt = re.sub(r"\[\[type:[^]]+\]\]", "", txt) bodyclass = theme_manager.body_classes_for_card_ord(c.ord) assert self._web is not None if c.autoplay(): self._web.setPlaybackRequiresGesture(False) if self._show_both_sides: # if we're showing both sides at once, remove any audio # from the answer that's appeared on the question already question_audio = c.question_av_tags() only_on_answer_audio = [ x for x in c.answer_av_tags() if x not in question_audio ] audio = question_audio + only_on_answer_audio elif self._state == "question": audio = c.question_av_tags() else: audio = c.answer_av_tags() else: audio = [] self._web.setPlaybackRequiresGesture(True) gui_hooks.av_player_will_play_tags(audio, self._state, self) av_player.play_tags(audio) txt = self.mw.prepare_card_text_for_display(txt) txt = gui_hooks.card_will_show(txt, c, f"preview{self._state.capitalize()}") self._last_state = self._state_and_mod() js: str if self._state == "question": ans_txt = self.mw.col.media.escape_media_filenames(ans_txt) js = f"{func}({json.dumps(txt)}, {json.dumps(ans_txt)}, '{bodyclass}');" else: js = f"{func}({json.dumps(txt)}, '{bodyclass}');" assert self._web is not None self._web.eval(js) self._card_changed = False def _on_show_both_sides(self, toggle: bool) -> None: assert self._web is not None self._show_both_sides = toggle self.mw.col.set_config_bool(Config.Bool.PREVIEW_BOTH_SIDES, toggle) card = self.card() assert card is not None gui_hooks.previewer_will_redraw_after_show_both_sides_toggled( self._web, card, self._state == "question", toggle ) if self._state == "answer" and not toggle: self._state = "question" self.render_card() def _state_and_mod(self) -> tuple[str, int, int]: c = self.card() assert c is not None n = c.note() n.load() return (self._state, c.id, n.mod) def state(self) -> str: return self._state class MultiCardPreviewer(Previewer): def card(self) -> Card | None: # need to state explicitly it's not implement to avoid W0223 raise NotImplementedError def card_changed(self) -> bool: # need to state explicitly it's not implement to avoid W0223 raise NotImplementedError def _create_gui(self) -> None: super()._create_gui() self._prev = self.bbox.addButton( ">" if self.layoutDirection() == Qt.LayoutDirection.RightToLeft else "<", QDialogButtonBox.ButtonRole.ActionRole, ) assert self._prev is not None self._prev.setAutoDefault(False) self._prev.setShortcut(QKeySequence("Left")) self._prev.setToolTip(tr.qt_misc_shortcut_key_left_arrow()) self._next = self.bbox.addButton( "<" if self.layoutDirection() == Qt.LayoutDirection.RightToLeft else ">", QDialogButtonBox.ButtonRole.ActionRole, ) assert self._next is not None self._next.setAutoDefault(True) self._next.setShortcut(QKeySequence("Right")) self._next.setToolTip(tr.qt_misc_shortcut_key_right_arrow_or_enter()) qconnect(self._prev.clicked, self._on_prev) qconnect(self._next.clicked, self._on_next) def _on_prev(self) -> None: if self._state == "answer" and not self._show_both_sides: self._state = "question" self.render_card() else: self._on_prev_card() def _on_prev_card(self) -> None: pass def _on_next(self) -> None: if self._state == "question": self._state = "answer" self.render_card() else: self._on_next_card() def _on_next_card(self) -> None: pass def _updateButtons(self) -> None: if not self._open: return assert self._prev is not None assert self._next is not None self._prev.setEnabled(self._should_enable_prev()) self._next.setEnabled(self._should_enable_next()) def _should_enable_prev(self) -> bool: return self._state == "answer" and not self._show_both_sides def _should_enable_next(self) -> bool: return self._state == "question" def _on_close(self) -> None: super()._on_close() self._prev = None self._next = None class BrowserPreviewer(MultiCardPreviewer): _last_card_id = 0 _parent: aqt.browser.Browser | None def __init__( self, parent: aqt.browser.Browser, mw: AnkiQt, on_close: Callable[[], None] ) -> None: super().__init__(parent=parent, mw=mw, on_close=on_close) def card(self) -> Card | None: assert self._parent is not None if self._parent.singleCard: return self._parent.card else: return None def card_changed(self) -> bool: c = self.card() if not c: return True else: changed = c.id != self._last_card_id self._last_card_id = c.id return changed def _on_prev_card(self) -> None: assert self._parent is not None self._parent.onPreviousCard() def _on_next_card(self) -> None: assert self._parent is not None self._parent.onNextCard() def _should_enable_prev(self) -> bool: assert self._parent is not None return super()._should_enable_prev() or self._parent.has_previous_card() def _should_enable_next(self) -> bool: assert self._parent is not None return super()._should_enable_next() or self._parent.has_next_card() def _render_scheduled(self) -> None: super()._render_scheduled() self._updateButtons() ================================================ FILE: qt/aqt/browser/sidebar/__init__.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # ruff: noqa: F401 from anki.utils import is_mac from aqt.theme import theme_manager from .item import SidebarItem, SidebarItemType from .model import SidebarModel from .searchbar import SidebarSearchBar from .toolbar import SidebarTool, SidebarToolbar from .tree import SidebarStage, SidebarTreeView ================================================ FILE: qt/aqt/browser/sidebar/item.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Callable, Iterable from enum import Enum, auto from anki.collection import SearchNode from aqt.theme import ColoredIcon class SidebarItemType(Enum): ROOT = auto() SAVED_SEARCH_ROOT = auto() SAVED_SEARCH = auto() TODAY_ROOT = auto() TODAY = auto() FLAG_ROOT = auto() FLAG = auto() FLAG_NONE = auto() CARD_STATE_ROOT = auto() CARD_STATE = auto() DECK_ROOT = auto() DECK_CURRENT = auto() DECK = auto() NOTETYPE_ROOT = auto() NOTETYPE = auto() NOTETYPE_TEMPLATE = auto() NOTETYPE_FIELD = auto() TAG_ROOT = auto() TAG_NONE = auto() TAG = auto() CUSTOM = auto() @staticmethod def section_roots() -> Iterable[SidebarItemType]: return (type for type in SidebarItemType if type.name.endswith("_ROOT")) def is_section_root(self) -> bool: return self in self.section_roots() def is_editable(self) -> bool: return self in ( SidebarItemType.FLAG, SidebarItemType.SAVED_SEARCH, SidebarItemType.DECK, SidebarItemType.TAG, ) def can_be_added_to(self) -> bool: return self == SidebarItemType.DECK def is_deletable(self) -> bool: return self in ( SidebarItemType.SAVED_SEARCH, SidebarItemType.DECK, SidebarItemType.TAG, ) class SidebarItem: def __init__( self, name: str, icon: str | ColoredIcon, search_node: SearchNode | None = None, on_expanded: Callable[[bool], None] | None = None, expanded: bool = False, item_type: SidebarItemType = SidebarItemType.CUSTOM, id: int = 0, name_prefix: str = "", ) -> None: self.name = name self.name_prefix = name_prefix self.full_name = name_prefix + name self.icon = icon self.item_type = item_type self.id = id self.search_node = search_node self.on_expanded = on_expanded self.children: list[SidebarItem] = [] self.tooltip: str = name self._parent_item: SidebarItem | None = None self._expanded = expanded self._row_in_parent: int | None = None self._search_matches_self = False self._search_matches_child = False def add_child(self, cb: SidebarItem) -> None: self.children.append(cb) cb._parent_item = self def add_simple( self, name: str, icon: str | ColoredIcon, type: SidebarItemType, search_node: SearchNode | None, ) -> SidebarItem: "Add child sidebar item, and return it." item = SidebarItem( name=name, icon=icon, search_node=search_node, item_type=type, ) self.add_child(item) return item @property def expanded(self) -> bool: return self._expanded @expanded.setter def expanded(self, expanded: bool) -> None: if self.expanded != expanded: self._expanded = expanded if self.on_expanded: self.on_expanded(expanded) def show_expanded(self, searching: bool) -> bool: if not searching: return self.expanded if self._search_matches_child: return True # if search matches top level, expand children one level return self._search_matches_self and self.item_type.is_section_root() def is_highlighted(self) -> bool: return self._search_matches_self def search(self, lowered_text: str) -> bool: "True if we or child matched." self._search_matches_self = lowered_text in self.name.lower() self._search_matches_child = any( [child.search(lowered_text) for child in self.children] ) return self._search_matches_self or self._search_matches_child def has_same_id(self, other: SidebarItem) -> bool: "True if `other` is same type, with same id/name." if other.item_type == self.item_type: if self.item_type == SidebarItemType.TAG: return self.full_name == other.full_name elif self.item_type in ( SidebarItemType.SAVED_SEARCH, SidebarItemType.TODAY, SidebarItemType.CARD_STATE, ): return self.name == other.name elif self.item_type in [ SidebarItemType.NOTETYPE_TEMPLATE, SidebarItemType.NOTETYPE_FIELD, ]: assert other._parent_item is not None assert self._parent_item is not None return ( other.id == self.id and other._parent_item.id == self._parent_item.id ) else: return other.id == self.id return False ================================================ FILE: qt/aqt/browser/sidebar/model.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import aqt import aqt.browser from aqt.browser.sidebar.item import SidebarItem from aqt.qt import * from aqt.theme import theme_manager class SidebarModel(QAbstractItemModel): def __init__( self, sidebar: aqt.browser.sidebar.SidebarTreeView, root: SidebarItem ) -> None: super().__init__(sidebar) self.sidebar = sidebar self.root = root self._cache_rows(root) def _cache_rows(self, node: SidebarItem) -> None: "Cache index of children in parent." for row, item in enumerate(node.children): item._row_in_parent = row self._cache_rows(item) def item_for_index(self, idx: QModelIndex) -> SidebarItem: return idx.internalPointer() def index_for_item(self, item: SidebarItem) -> QModelIndex: assert item._row_in_parent is not None return self.createIndex(item._row_in_parent, 0, item) def search(self, text: str) -> bool: return self.root.search(text.lower()) # Qt API ###################################################################### def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: if not parent.isValid(): return len(self.root.children) else: item: SidebarItem = parent.internalPointer() return len(item.children) def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: return 1 def index( self, row: int, column: int, parent: QModelIndex = QModelIndex() ) -> QModelIndex: if not self.hasIndex(row, column, parent): return QModelIndex() parentItem: SidebarItem if not parent.isValid(): parentItem = self.root else: parentItem = parent.internalPointer() item = parentItem.children[row] return self.createIndex(row, column, item) def parent(self, child: QModelIndex) -> QModelIndex: # type: ignore if not child.isValid(): return QModelIndex() childItem: SidebarItem = child.internalPointer() parentItem = childItem._parent_item if parentItem is None or parentItem == self.root: return QModelIndex() row = parentItem._row_in_parent assert row is not None return self.createIndex(row, 0, parentItem) def data( self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole ) -> QVariant: if not index.isValid(): return QVariant() if role not in ( Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.DecorationRole, Qt.ItemDataRole.ToolTipRole, Qt.ItemDataRole.EditRole, ): return QVariant() item: SidebarItem = index.internalPointer() if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): return QVariant(item.name) if role == Qt.ItemDataRole.ToolTipRole: return QVariant(item.tooltip) return QVariant(theme_manager.icon_from_resources(item.icon)) def setData( self, index: QModelIndex, text: str, _role: int = Qt.ItemDataRole.EditRole ) -> bool: return self.sidebar._on_rename(index.internalPointer(), text) def supportedDropActions(self) -> Qt.DropAction: return Qt.DropAction.MoveAction def flags(self, index: QModelIndex) -> Qt.ItemFlag: if not index.isValid(): return Qt.ItemFlag.ItemIsEnabled flags = ( Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDragEnabled ) item: SidebarItem = index.internalPointer() if item.item_type in self.sidebar.valid_drop_types: flags |= Qt.ItemFlag.ItemIsDropEnabled if item.item_type.is_editable(): flags |= Qt.ItemFlag.ItemIsEditable return flags ================================================ FILE: qt/aqt/browser/sidebar/searchbar.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import aqt import aqt.browser import aqt.gui_hooks from aqt.qt import * class SidebarSearchBar(QLineEdit): def __init__(self, sidebar: aqt.browser.sidebar.SidebarTreeView) -> None: QLineEdit.__init__(self, sidebar) self.setPlaceholderText(sidebar.col.tr.browsing_sidebar_filter()) self.sidebar = sidebar self.timer = QTimer(self) self.timer.setInterval(600) self.timer.setSingleShot(True) self.setFrame(False) qconnect(self.timer.timeout, self.onSearch) qconnect(self.textChanged, self.onTextChanged) def onTextChanged(self, text: str) -> None: if not self.timer.isActive(): self.timer.start() def onSearch(self) -> None: self.sidebar.search_for(self.text()) def keyPressEvent(self, evt: QKeyEvent | None) -> None: assert evt is not None if evt.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down): self.sidebar.setFocus() elif evt.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return): self.onSearch() else: QLineEdit.keyPressEvent(self, evt) ================================================ FILE: qt/aqt/browser/sidebar/toolbar.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Callable from enum import Enum, auto import aqt import aqt.browser import aqt.gui_hooks from aqt.qt import * from aqt.theme import theme_manager from aqt.utils import tr class SidebarTool(Enum): SELECT = auto() SEARCH = auto() class SidebarToolbar(QToolBar): _tools: tuple[tuple[SidebarTool, str, Callable[[], str]], ...] = ( ( SidebarTool.SEARCH, "mdi:magnify", tr.actions_search, ), ( SidebarTool.SELECT, "mdi:selection-drag", tr.actions_select, ), ) def __init__(self, sidebar: aqt.browser.sidebar.SidebarTreeView) -> None: super().__init__() self.sidebar = sidebar self._action_group = QActionGroup(self) qconnect(self._action_group.triggered, self._on_action_group_triggered) self._setup_tools() self.setIconSize(QSize(18, 18)) self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) self.setStyle(QStyleFactory.create("fusion")) aqt.gui_hooks.theme_did_change.append(self._update_icons) def _setup_tools(self) -> None: for row, tool in enumerate(self._tools): action = self.addAction( theme_manager.icon_from_resources(tool[1]), tool[2]() ) assert action is not None action.setCheckable(True) action.setShortcut(f"Alt+{row + 1}") self._action_group.addAction(action) # always start with first tool active = 0 self._action_group.actions()[active].setChecked(True) self.sidebar.tool = self._tools[active][0] def _on_action_group_triggered(self, action: QAction) -> None: index = self._action_group.actions().index(action) self.sidebar.tool = self._tools[index][0] def cleanup(self) -> None: aqt.gui_hooks.theme_did_change.remove(self._update_icons) def _update_icons(self) -> None: for idx, action in enumerate(self._action_group.actions()): action.setIcon(theme_manager.icon_from_resources(self._tools[idx][1])) ================================================ FILE: qt/aqt/browser/sidebar/tree.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Callable, Iterable from enum import Enum, auto from typing import cast import aqt import aqt.browser import aqt.operations from anki.collection import ( Config, OpChanges, OpChangesWithCount, SearchJoiner, SearchNode, ) from anki.decks import DeckCollapseScope, DeckId, DeckTreeNode from anki.models import NotetypeId from anki.notes import Note from anki.tags import TagTreeNode from anki.types import assert_exhaustive from aqt import colors, gui_hooks from aqt.browser.find_and_replace import FindAndReplaceDialog from aqt.browser.sidebar.item import SidebarItem, SidebarItemType from aqt.browser.sidebar.model import SidebarModel from aqt.browser.sidebar.searchbar import SidebarSearchBar from aqt.browser.sidebar.toolbar import SidebarTool, SidebarToolbar from aqt.clayout import CardLayout from aqt.fields import FieldDialog from aqt.models import Models from aqt.operations import CollectionOp, QueryOp from aqt.operations.deck import ( remove_decks, rename_deck, reparent_decks, set_deck_collapsed, ) from aqt.operations.tag import ( remove_tags_from_all_notes, rename_tag, reparent_tags, set_tag_collapsed, ) from aqt.qt import * from aqt.qt import sip from aqt.theme import ColoredIcon, theme_manager from aqt.utils import ( KeyboardModifiersPressed, askUser, getOnlyText, showInfo, showWarning, tooltip, tr, ) class SidebarStage(Enum): ROOT = auto() SAVED_SEARCHES = auto() TODAY = auto() FLAGS = auto() CARD_STATE = auto() DECKS = auto() NOTETYPES = auto() TAGS = auto() # fixme: we should have a top-level Sidebar class inheriting from QWidget that # handles the treeview, search bar and so on. Currently the treeview embeds the # search bar which is wrong, and the layout code is handled in browser.py instead # of here class SidebarTreeView(QTreeView): def __init__(self, browser: aqt.browser.Browser) -> None: super().__init__() self.browser = browser self.mw = browser.mw self.col = self.mw.col self.current_search: str | None = None self.valid_drop_types: tuple[SidebarItemType, ...] = () self._refresh_needed = False self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.onContextMenu) # type: ignore self.setUniformRowHeights(True) self.setHeaderHidden(True) self.setIndentation(15) self.setAutoExpandDelay(600) self.setDragDropOverwriteMode(False) self.setEditTriggers(QAbstractItemView.EditTrigger.EditKeyPressed) qconnect(self.expanded, self._on_expansion) qconnect(self.collapsed, self._on_collapse) self._setup_style() # these do not really belong here, they should be in a higher-level class self.toolbar = SidebarToolbar(self) self.searchBar = SidebarSearchBar(self) gui_hooks.flag_label_did_change.append(self.refresh) gui_hooks.theme_did_change.append(self._setup_style) def _setup_style(self) -> None: # match window background color and tweak style bgcolor = QPalette().window().color().name() theme_manager.var(colors.BORDER) styles = [ "padding: 3px", "padding-right: 0px", "border: 0", f"background: {bgcolor}", ] self.setStyleSheet("QTreeView { %s }" % ";".join(styles)) def cleanup(self) -> None: self.toolbar.cleanup() gui_hooks.flag_label_did_change.remove(self.refresh) gui_hooks.theme_did_change.remove(self._setup_style) @property def tool(self) -> SidebarTool: return self._tool @tool.setter def tool(self, tool: SidebarTool) -> None: self._tool = tool if tool == SidebarTool.SEARCH: selection_mode = QAbstractItemView.SelectionMode.SingleSelection drag_drop_mode = QAbstractItemView.DragDropMode.NoDragDrop double_click_expands = False else: selection_mode = QAbstractItemView.SelectionMode.ExtendedSelection drag_drop_mode = QAbstractItemView.DragDropMode.InternalMove double_click_expands = True self.setSelectionMode(selection_mode) self.setDragDropMode(drag_drop_mode) self.setExpandsOnDoubleClick(double_click_expands) def model(self) -> SidebarModel: return cast(SidebarModel, super().model()) # Refreshing ########################### def op_executed( self, changes: OpChanges, handler: object | None, focused: bool ) -> None: if changes.browser_sidebar and handler is not self: self._refresh_needed = True if focused: self.refresh_if_needed() def refresh_if_needed(self) -> None: if self._refresh_needed: self.refresh() self._refresh_needed = False def refresh(self, new_current: SidebarItem | None = None) -> None: "Refresh list. No-op if sidebar is not visible." if not self.isVisible(): return if not new_current and self.model() and (idx := self.currentIndex()): new_current = self.model().item_for_index(idx) def on_done(root: SidebarItem) -> None: # user may have closed browser if sip.isdeleted(self): return # block repainting during refreshing to avoid flickering self.setUpdatesEnabled(False) if old_model := self.model(): old_model.deleteLater() model = SidebarModel(self, root) self.setModel(model) if self.current_search: self.search_for(self.current_search) else: self._expand_where_necessary(model) if new_current: self.restore_current(new_current) self.setUpdatesEnabled(True) # needs to be set after changing model qconnect( self._selection_model().selectionChanged, self._on_selection_changed ) QueryOp( parent=self.browser, op=lambda _: self._root_tree(), success=on_done ).run_in_background() def restore_current(self, current: SidebarItem) -> None: if current_item := self.find_item(current.has_same_id): index = self.model().index_for_item(current_item) self._selection_model().setCurrentIndex( index, QItemSelectionModel.SelectionFlag.SelectCurrent ) self.scrollTo(index, QAbstractItemView.ScrollHint.PositionAtCenter) def find_item( self, is_target: Callable[[SidebarItem], bool], parent: SidebarItem | None = None, ) -> SidebarItem | None: def find_item_rec(parent: SidebarItem) -> SidebarItem | None: if is_target(parent): return parent for child in parent.children: if item := find_item_rec(child): return item return None return find_item_rec(parent or self.model().root) def search_for(self, text: str) -> None: self.showColumn(0) if not text.strip(): self.current_search = None self.refresh() return self.current_search = text # start from a collapsed state, as it's faster self.collapseAll() self.setColumnHidden(0, not self.model().search(text)) self._expand_where_necessary(self.model(), searching=True) def _expand_where_necessary( self, model: SidebarModel, parent: QModelIndex | None = None, searching: bool = False, ) -> None: scroll_to_first_match = searching def expand_node(parent: QModelIndex) -> None: nonlocal scroll_to_first_match for row in range(model.rowCount(parent)): idx = model.index(row, 0, parent) if not idx.isValid(): continue # descend into children first expand_node(idx) if item := model.item_for_index(idx): if item.show_expanded(searching): self.setExpanded(idx, True) if item.is_highlighted() and scroll_to_first_match: self._selection_model().setCurrentIndex( idx, QItemSelectionModel.SelectionFlag.SelectCurrent, ) self.scrollTo( idx, QAbstractItemView.ScrollHint.PositionAtCenter ) scroll_to_first_match = False expand_node(parent or QModelIndex()) def update_search( self, *terms: str | SearchNode, joiner: SearchJoiner = "AND", ) -> None: """Modify the current search string based on modifier keys, then refresh.""" mods = KeyboardModifiersPressed() previous = SearchNode(parsable_text=self.browser.current_search()) current = self.mw.col.group_searches(*terms, joiner=joiner) # if Alt pressed, invert if mods.alt: current = SearchNode(negated=current) try: if mods.control and mods.shift: # If Ctrl+Shift, replace searches nodes of the same type. search = self.col.replace_in_search_node(previous, current) elif mods.control: # If Ctrl, AND with previous search = self.col.join_searches(previous, current, "AND") elif mods.shift: # If Shift, OR with previous search = self.col.join_searches(previous, current, "OR") else: search = self.col.build_search_string(current) except Exception as e: showWarning(str(e)) else: self.browser.search_for(search) # Qt API ########### def drawRow( self, painter: QPainter | None, options: QStyleOptionViewItem, idx: QModelIndex ) -> None: if self.current_search and (item := self.model().item_for_index(idx)): if item.is_highlighted(): assert painter is not None brush = QBrush(theme_manager.qcolor(colors.HIGHLIGHT_BG)) painter.save() painter.fillRect(options.rect, brush) painter.restore() return super().drawRow(painter, options, idx) def dropEvent(self, event: QDropEvent | None) -> None: assert event is not None model = self.model() if qtmajor == 5: pos = event.pos() # type: ignore else: pos = event.position().toPoint() target_item = model.item_for_index(self.indexAt(pos)) if self.handle_drag_drop(self._selected_items(), target_item): event.acceptProposedAction() def mouseReleaseEvent(self, event: QMouseEvent | None) -> None: assert event is not None super().mouseReleaseEvent(event) if ( self.tool == SidebarTool.SEARCH and event.button() == Qt.MouseButton.LeftButton ): if qtmajor == 5: pos = event.pos() # type: ignore else: pos = event.position().toPoint() if (index := self.currentIndex()) == self.indexAt(pos): self._on_search(index) def keyPressEvent(self, event: QKeyEvent | None) -> None: assert event is not None index = self.currentIndex() if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): if not self.isPersistentEditorOpen(index): self._on_search(index) elif event.key() == Qt.Key.Key_Delete: self._on_delete_key(index) else: super().keyPressEvent(event) # Slots ########### def _on_selection_changed(self, _new: QItemSelection, _old: QItemSelection) -> None: valid_drop_types = [] selected_items = self._selected_items() selected_types = [item.item_type for item in selected_items] # check if renaming is allowed if all(item_type == SidebarItemType.DECK for item_type in selected_types): valid_drop_types += [SidebarItemType.DECK, SidebarItemType.DECK_ROOT] elif all(item_type == SidebarItemType.TAG for item_type in selected_types): valid_drop_types += [SidebarItemType.TAG, SidebarItemType.TAG_ROOT] # check if creating a saved search is allowed if len(selected_items) == 1: if ( selected_types[0] != SidebarItemType.SAVED_SEARCH and selected_items[0].search_node is not None ): valid_drop_types += [ SidebarItemType.SAVED_SEARCH_ROOT, SidebarItemType.SAVED_SEARCH, ] self.valid_drop_types = tuple(valid_drop_types) def handle_drag_drop(self, sources: list[SidebarItem], target: SidebarItem) -> bool: if target.item_type in (SidebarItemType.DECK, SidebarItemType.DECK_ROOT): return self._handle_drag_drop_decks(sources, target) if target.item_type in (SidebarItemType.TAG, SidebarItemType.TAG_ROOT): return self._handle_drag_drop_tags(sources, target) if target.item_type in ( SidebarItemType.SAVED_SEARCH_ROOT, SidebarItemType.SAVED_SEARCH, ): return self._handle_drag_drop_saved_search(sources, target) return False def _handle_drag_drop_decks( self, sources: list[SidebarItem], target: SidebarItem ) -> bool: deck_ids = [ DeckId(source.id) for source in sources if source.item_type == SidebarItemType.DECK ] if not deck_ids: return False new_parent = DeckId(target.id) reparent_decks( parent=self.browser, deck_ids=deck_ids, new_parent=new_parent ).run_in_background() return True def _handle_drag_drop_tags( self, sources: list[SidebarItem], target: SidebarItem ) -> bool: tags = [ source.full_name for source in sources if source.item_type == SidebarItemType.TAG ] if not tags: return False if target.item_type == SidebarItemType.TAG_ROOT: new_parent = "" else: new_parent = target.full_name reparent_tags( parent=self.browser, tags=tags, new_parent=new_parent ).run_in_background() return True def _handle_drag_drop_saved_search( self, sources: list[SidebarItem], _target: SidebarItem ) -> bool: if len(sources) != 1 or sources[0].search_node is None: return False self._save_search( sources[0].name, self.col.build_search_string(sources[0].search_node) ) return True def _on_search(self, index: QModelIndex) -> None: if model := self.model(): if item := model.item_for_index(index): if search_node := item.search_node: self.update_search(search_node) def _on_rename(self, item: SidebarItem, text: str) -> bool: new_name = text.replace('"', "") if not new_name and item.item_type == SidebarItemType.FLAG: self.restore_default_flag_name(item) elif new_name and new_name != item.name: if item.item_type == SidebarItemType.DECK: self.rename_deck(item, new_name) elif item.item_type == SidebarItemType.SAVED_SEARCH: self.rename_saved_search(item, new_name) elif item.item_type == SidebarItemType.TAG: self.rename_tag(item, new_name) elif item.item_type == SidebarItemType.FLAG: self.rename_flag(item, new_name) # renaming may be asynchronous so always return False return False def _on_delete_key(self, index: QModelIndex) -> None: if item := self.model().item_for_index(index): if self._enable_delete(item): self._on_delete(item) def _enable_delete(self, item: SidebarItem) -> bool: return item.item_type.is_deletable() and all( s.item_type == item.item_type for s in self._selected_items() ) def _on_add(self, item: SidebarItem): self.browser.add_card(DeckId(item.id)) def _on_delete(self, item: SidebarItem) -> None: if item.item_type == SidebarItemType.SAVED_SEARCH: self.remove_saved_searches(item) elif item.item_type == SidebarItemType.DECK: self.delete_decks(item) elif item.item_type == SidebarItemType.TAG: self.remove_tags(item) def _on_expansion(self, idx: QModelIndex) -> None: if self.current_search: return if item := self.model().item_for_index(idx): item.expanded = True def _on_collapse(self, idx: QModelIndex) -> None: if self.current_search: return if item := self.model().item_for_index(idx): item.expanded = False # Tree building ########################### def _root_tree(self) -> SidebarItem: root = SidebarItem("", "", item_type=SidebarItemType.ROOT) for stage in SidebarStage: handled = gui_hooks.browser_will_build_tree( False, root, stage, self.browser ) if not handled: self._build_stage(root, stage) return root def _build_stage(self, root: SidebarItem, stage: SidebarStage) -> None: if stage is SidebarStage.SAVED_SEARCHES: self._saved_searches_tree(root) elif stage is SidebarStage.CARD_STATE: self._card_state_tree(root) elif stage is SidebarStage.TODAY: self._today_tree(root) elif stage is SidebarStage.FLAGS: self._flags_tree(root) elif stage is SidebarStage.DECKS: self._deck_tree(root) elif stage is SidebarStage.NOTETYPES: self._notetype_tree(root) elif stage is SidebarStage.TAGS: self._tag_tree(root) elif stage is SidebarStage.ROOT: pass else: assert_exhaustive(stage) def _section_root( self, *, root: SidebarItem, name: str, icon: str | ColoredIcon, collapse_key: Config.Bool.V, type: SidebarItemType | None = None, ) -> SidebarItem: assert type is not None def update(expanded: bool) -> None: CollectionOp( self.browser, lambda col: col.set_config_bool(collapse_key, not expanded), ).run_in_background(initiator=self) top = SidebarItem( name, icon, on_expanded=update, expanded=not self.col.get_config_bool(collapse_key), item_type=type, ) root.add_child(top) return top # Tree: Saved Searches ########################### def _saved_searches_tree(self, root: SidebarItem) -> None: icon = "icons:heart-outline.svg" saved = self._get_saved_searches() root = self._section_root( root=root, name=tr.browsing_sidebar_saved_searches(), icon=icon, collapse_key=Config.Bool.COLLAPSE_SAVED_SEARCHES, type=SidebarItemType.SAVED_SEARCH_ROOT, ) for name, filt in sorted(saved.items()): item = SidebarItem( name, icon, search_node=SearchNode(parsable_text=filt), item_type=SidebarItemType.SAVED_SEARCH, ) root.add_child(item) # Tree: Today ########################### def _today_tree(self, root: SidebarItem) -> None: icon = "icons:clock-outline.svg" root = self._section_root( root=root, name=tr.browsing_today(), icon=icon, collapse_key=Config.Bool.COLLAPSE_TODAY, type=SidebarItemType.TODAY_ROOT, ) type = SidebarItemType.TODAY root.add_simple( name=tr.browsing_sidebar_due_today(), icon=icon, type=type, search_node=SearchNode(due_on_day=0), ) root.add_simple( name=tr.browsing_added_today(), icon=icon, type=type, search_node=SearchNode(added_in_days=1), ) root.add_simple( name=tr.browsing_edited_today(), icon=icon, type=type, search_node=SearchNode(edited_in_days=1), ) root.add_simple( name=tr.browsing_studied_today(), icon=icon, type=type, search_node=SearchNode(rated=SearchNode.Rated(days=1)), ) root.add_simple( name=tr.browsing_sidebar_first_review(), icon=icon, type=type, search_node=SearchNode(introduced_in_days=1), ) root.add_simple( name=tr.browsing_sidebar_rescheduled(), icon=icon, type=type, search_node=SearchNode( rated=SearchNode.Rated(days=1, rating=SearchNode.RATING_BY_RESCHEDULE) ), ) root.add_simple( name=tr.browsing_again_today(), icon=icon, type=type, search_node=SearchNode( rated=SearchNode.Rated(days=1, rating=SearchNode.RATING_AGAIN) ), ) root.add_simple( name=tr.browsing_sidebar_overdue(), icon=icon, type=type, search_node=self.col.group_searches( SearchNode(card_state=SearchNode.CARD_STATE_DUE), SearchNode(negated=SearchNode(due_on_day=0)), ), ) # Tree: Card State ########################### def _card_state_tree(self, root: SidebarItem) -> None: icon = "icons:circle.svg" icon_outline = "icons:circle-outline.svg" root = self._section_root( root=root, name=tr.browsing_sidebar_card_state(), icon=icon_outline, collapse_key=Config.Bool.COLLAPSE_CARD_STATE, type=SidebarItemType.CARD_STATE_ROOT, ) type = SidebarItemType.CARD_STATE colored_icon = ColoredIcon(path=icon, color=colors.FG_DISABLED) root.add_simple( tr.actions_new(), icon=colored_icon.with_color(colors.STATE_NEW), type=type, search_node=SearchNode(card_state=SearchNode.CARD_STATE_NEW), ) root.add_simple( name=tr.scheduling_learning(), icon=colored_icon.with_color(colors.STATE_LEARN), type=type, search_node=SearchNode(card_state=SearchNode.CARD_STATE_LEARN), ) root.add_simple( name=tr.browsing_sidebar_card_state_review(), icon=colored_icon.with_color(colors.STATE_REVIEW), type=type, search_node=SearchNode(card_state=SearchNode.CARD_STATE_REVIEW), ) root.add_simple( name=tr.browsing_suspended(), icon=colored_icon.with_color(colors.STATE_SUSPENDED), type=type, search_node=SearchNode(card_state=SearchNode.CARD_STATE_SUSPENDED), ) root.add_simple( name=tr.browsing_buried(), icon=colored_icon.with_color(colors.STATE_BURIED), type=type, search_node=SearchNode(card_state=SearchNode.CARD_STATE_BURIED), ) # Tree: Flags ########################### def _flags_tree(self, root: SidebarItem) -> None: icon_off = "icons:flag-variant-off-outline.svg" icon_outline = "icons:flag-variant-outline.svg" root = self._section_root( root=root, name=tr.browsing_sidebar_flags(), icon=icon_outline, collapse_key=Config.Bool.COLLAPSE_FLAGS, type=SidebarItemType.FLAG_ROOT, ) root.search_node = SearchNode(flag=SearchNode.FLAG_ANY) root.add_simple( tr.browsing_no_flag(), icon=icon_off, type=SidebarItemType.FLAG_NONE, search_node=SearchNode(flag=SearchNode.FLAG_NONE), ) for flag in self.mw.flags.all(): root.add_child( SidebarItem( name=flag.label, icon=flag.icon, search_node=flag.search_node, item_type=SidebarItemType.FLAG, id=flag.index, ) ) # Tree: Tags ########################### def _tag_tree(self, root: SidebarItem) -> None: icon = "icons:tag-outline.svg" icon_off = "icons:tag-off-outline.svg" def render( root: SidebarItem, nodes: Iterable[TagTreeNode], head: str = "" ) -> None: def toggle_expand(node: TagTreeNode) -> Callable[[bool], None]: full_name = head + node.name return lambda expanded: set_tag_collapsed( parent=self, tag=full_name, collapsed=not expanded ).run_in_background(initiator=self) for node in nodes: item = SidebarItem( name=node.name, icon=icon, search_node=SearchNode(tag=head + node.name), on_expanded=toggle_expand(node), expanded=not node.collapsed, item_type=SidebarItemType.TAG, name_prefix=head, ) root.add_child(item) newhead = f"{head + node.name}::" render(item, node.children, newhead) tree = self.col.tags.tree() root = self._section_root( root=root, name=tr.browsing_sidebar_tags(), icon=icon, collapse_key=Config.Bool.COLLAPSE_TAGS, type=SidebarItemType.TAG_ROOT, ) root.search_node = SearchNode(tag="_*") root.add_simple( name=tr.browsing_sidebar_untagged(), icon=icon_off, type=SidebarItemType.TAG_NONE, search_node=SearchNode(negated=SearchNode(tag="_*")), ) render(root, tree.children) # Tree: Decks ########################### def _deck_tree(self, root: SidebarItem) -> None: icon = "icons:book-outline.svg" icon_current = "icons:book-clock-outline.svg" icon_filtered = "icons:book-cog-outline.svg" def render( root: SidebarItem, nodes: Iterable[DeckTreeNode], head: str = "" ) -> None: def toggle_expand(node: DeckTreeNode) -> Callable[[bool], None]: return lambda expanded: set_deck_collapsed( parent=self, deck_id=DeckId(node.deck_id), collapsed=not expanded, scope=DeckCollapseScope.BROWSER, ).run_in_background( initiator=self, ) for node in nodes: item = SidebarItem( name=node.name, icon=icon_filtered if node.filtered else icon, search_node=SearchNode(deck=head + node.name), on_expanded=toggle_expand(node), expanded=not node.collapsed, item_type=SidebarItemType.DECK, id=node.deck_id, name_prefix=head, ) root.add_child(item) newhead = f"{head + node.name}::" render(item, node.children, newhead) tree = self.col.decks.deck_tree() root = self._section_root( root=root, name=tr.browsing_sidebar_decks(), icon=icon, collapse_key=Config.Bool.COLLAPSE_DECKS, type=SidebarItemType.DECK_ROOT, ) root.search_node = SearchNode(deck="_*") current = root.add_simple( name=tr.browsing_current_deck(), icon=icon_current, type=SidebarItemType.DECK_CURRENT, search_node=SearchNode(deck="current"), ) current.id = self.mw.col.decks.selected() render(root, tree.children) # Tree: Notetypes ########################### def _notetype_tree(self, root: SidebarItem) -> None: notetype_icon = "icons:newspaper.svg" template_icon = "icons:application-braces-outline.svg" field_icon = "icons:form-textbox.svg" root = self._section_root( root=root, name=tr.browsing_sidebar_notetypes(), icon=notetype_icon, collapse_key=Config.Bool.COLLAPSE_NOTETYPES, type=SidebarItemType.NOTETYPE_ROOT, ) root.search_node = SearchNode(note="_*") for nt in sorted(self.col.models.all(), key=lambda nt: nt["name"].lower()): item = SidebarItem( nt["name"], notetype_icon, search_node=SearchNode(note=nt["name"]), item_type=SidebarItemType.NOTETYPE, id=nt["id"], ) for c, tmpl in enumerate(nt["tmpls"]): child = SidebarItem( tmpl["name"], template_icon, search_node=self.col.group_searches( SearchNode(note=nt["name"]), SearchNode(template=c) ), item_type=SidebarItemType.NOTETYPE_TEMPLATE, id=tmpl["ord"], ) item.add_child(child) for c, fld in enumerate(nt["flds"]): child = SidebarItem( fld["name"], field_icon, search_node=self.col.group_searches( SearchNode(note=nt["name"]), SearchNode(field_name=fld["name"]) ), item_type=SidebarItemType.NOTETYPE_FIELD, id=fld["ord"], ) item.add_child(child) root.add_child(item) # Context menu ########################### def onContextMenu(self, point: QPoint) -> None: index: QModelIndex = self.indexAt(point) item = self.model().item_for_index(index) if item and self._selection_model().isSelected(index): self.show_context_menu(item, index) def show_context_menu(self, item: SidebarItem, index: QModelIndex) -> None: menu = QMenu() self._maybe_add_type_specific_actions(menu, item) menu.addSeparator() self._maybe_add_add_action(menu, item) self._maybe_add_delete_action(menu, item, index) self._maybe_add_rename_actions(menu, item, index) self._maybe_add_find_and_replace_action(menu, item, index) menu.addSeparator() self._maybe_add_search_actions(menu) menu.addSeparator() self._maybe_add_tree_actions(menu) gui_hooks.browser_sidebar_will_show_context_menu(self, menu, item, index) if menu.children(): menu.exec(QCursor.pos()) def _maybe_add_type_specific_actions(self, menu: QMenu, item: SidebarItem) -> None: if item.item_type in (SidebarItemType.NOTETYPE, SidebarItemType.NOTETYPE_ROOT): menu.addAction( tr.browsing_manage_note_types(), lambda: self.manage_notetype(item) ) elif item.item_type == SidebarItemType.NOTETYPE_TEMPLATE: menu.addAction(tr.notetypes_cards(), lambda: self.manage_template(item)) elif item.item_type == SidebarItemType.NOTETYPE_FIELD: menu.addAction(tr.notetypes_fields(), lambda: self.manage_fields(item)) elif item.item_type == SidebarItemType.SAVED_SEARCH_ROOT: menu.addAction( tr.browsing_sidebar_save_current_search(), self.save_current_search ) elif item.item_type == SidebarItemType.SAVED_SEARCH: menu.addAction( tr.browsing_update_saved_search(), lambda: self.update_saved_search(item), ) elif item.item_type == SidebarItemType.TAG: if all(s.item_type == item.item_type for s in self._selected_items()): menu.addAction( tr.browsing_add_to_selected_notes(), self.add_tags_to_selected_notes ) menu.addAction( tr.browsing_remove_from_selected_notes(), self.remove_tags_from_selected_notes, ) def _maybe_add_add_action(self, menu: QMenu, item: SidebarItem) -> None: if item.item_type.can_be_added_to(): menu.addAction(tr.browsing_add_notes(), lambda: self._on_add(item)) def _maybe_add_delete_action( self, menu: QMenu, item: SidebarItem, index: QModelIndex ) -> None: if self._enable_delete(item): menu.addAction(tr.actions_delete(), lambda: self._on_delete(item)) def _maybe_add_rename_actions( self, menu: QMenu, item: SidebarItem, index: QModelIndex ) -> None: if item.item_type.is_editable() and len(self._selected_items()) == 1: menu.addAction(tr.actions_rename(), lambda: self.edit(index)) if ( item.item_type in (SidebarItemType.TAG, SidebarItemType.DECK) and item.name_prefix ): menu.addAction( tr.actions_rename_with_parents(), lambda: self._on_rename_with_parents(item), ) def _maybe_add_find_and_replace_action( self, menu: QMenu, item: SidebarItem, index: QModelIndex ) -> None: if ( len(self._selected_items()) == 1 and item.item_type is SidebarItemType.NOTETYPE_FIELD ): menu.addAction( tr.browsing_find_and_replace(), lambda: self._on_find_and_replace(item) ) def _maybe_add_search_actions(self, menu: QMenu) -> None: nodes = [ item.search_node for item in self._selected_items() if item.search_node ] if not nodes: return if len(nodes) == 1: menu.addAction(tr.actions_search(), lambda: self.update_search(*nodes)) return sub_menu = menu.addMenu(tr.actions_search()) assert sub_menu is not None sub_menu.addAction( tr.actions_all_selected(), lambda: self.update_search(*nodes) ) sub_menu.addAction( tr.actions_any_selected(), lambda: self.update_search(*nodes, joiner="OR"), ) def _maybe_add_tree_actions(self, menu: QMenu) -> None: def set_expanded(expanded: bool) -> None: for index in self.selectedIndexes(): self.setExpanded(index, expanded) def set_children_expanded(expanded: bool) -> None: for index in self.selectedIndexes(): self.setExpanded(index, True) for row in range(self.model().rowCount(index)): self.setExpanded(self.model().index(row, 0, index), expanded) if self.current_search: return selected_items = self._selected_items() if not any(item.children for item in selected_items): return if any(not item.expanded for item in selected_items if item.children): menu.addAction(tr.browsing_sidebar_expand(), lambda: set_expanded(True)) if any(item.expanded for item in selected_items if item.children): menu.addAction(tr.browsing_sidebar_collapse(), lambda: set_expanded(False)) if any( not c.expanded for i in selected_items for c in i.children if c.children ): menu.addAction( tr.browsing_sidebar_expand_children(), lambda: set_children_expanded(True), ) if any(c.expanded for i in selected_items for c in i.children if c.children): menu.addAction( tr.browsing_sidebar_collapse_children(), lambda: set_children_expanded(False), ) def _on_rename_with_parents(self, item: SidebarItem) -> None: title = "Anki" if item.item_type is SidebarItemType.TAG: title = tr.actions_rename_tag() elif item.item_type is SidebarItemType.DECK: title = tr.actions_rename_deck() new_name = getOnlyText( tr.actions_new_name(), title=title, default=item.full_name ).replace('"', "") if not new_name or new_name == item.full_name: return if item.item_type is SidebarItemType.TAG: def success(out: OpChangesWithCount) -> None: if out.count: tooltip(tr.browsing_notes_updated(count=out.count), parent=self) else: showInfo(tr.browsing_tag_rename_warning_empty(), parent=self) rename_tag( parent=self, current_name=item.full_name, new_name=new_name, ).success(success).run_in_background() elif item.item_type is SidebarItemType.DECK: rename_deck( parent=self, deck_id=DeckId(item.id), new_name=new_name, ).run_in_background() def _on_find_and_replace(self, item: SidebarItem) -> None: field = None if item.item_type is SidebarItemType.NOTETYPE_FIELD: field = item.name FindAndReplaceDialog( self, mw=self.mw, note_ids=self.browser.selected_notes(), field=field, ) # Flags ########################### def rename_flag(self, item: SidebarItem, new_name: str) -> None: item.name = new_name self.mw.flags.rename_flag(item.id, new_name) def restore_default_flag_name(self, item: SidebarItem) -> None: self.mw.flags.restore_default_flag_name(item.id) item.name = self.mw.flags.get_flag(item.id).label # Decks ########################### def rename_deck(self, item: SidebarItem, new_name: str) -> None: if not new_name or new_name == item.name: return # update UI immediately, to avoid redraw item.name = new_name rename_deck( parent=self, deck_id=DeckId(item.id), new_name=item.name_prefix + new_name, ).run_in_background() def delete_decks(self, _item: SidebarItem) -> None: remove_decks( parent=self, deck_name=_item.name, deck_ids=self._selected_decks() ).run_in_background() # Tags ########################### def remove_tags(self, item: SidebarItem) -> None: tags = self.mw.col.tags.join(self._selected_tags()) item.name = "..." remove_tags_from_all_notes( parent=self.browser, space_separated_tags=tags ).run_in_background() def rename_tag(self, item: SidebarItem, new_name: str) -> None: if not new_name or new_name == item.name: return old_name = item.name old_full_name = item.full_name new_full_name = item.name_prefix + new_name item.name = new_name item.full_name = new_full_name def success(out: OpChangesWithCount) -> None: if out.count: tooltip(tr.browsing_notes_updated(count=out.count), parent=self) else: # revert renaming of sidebar item item.full_name = old_full_name item.name = old_name showInfo(tr.browsing_tag_rename_warning_empty(), parent=self) rename_tag( parent=self.browser, current_name=old_full_name, new_name=new_full_name, ).success(success).run_in_background() def add_tags_to_selected_notes(self) -> None: tags = " ".join(item.full_name for item in self._selected_items()) self.browser.add_tags_to_selected_notes(tags) def remove_tags_from_selected_notes(self) -> None: tags = " ".join(item.full_name for item in self._selected_items()) self.browser.remove_tags_from_selected_notes(tags) # Saved searches #################################### _saved_searches_key = "savedFilters" def _get_saved_searches(self) -> dict[str, str]: return self.col.get_config(self._saved_searches_key, {}) def _set_saved_searches(self, searches: dict[str, str]) -> None: self.col.set_config(self._saved_searches_key, searches) def _get_current_search(self) -> str | None: try: return self.col.build_search_string(self.browser.current_search()) except Exception as e: showWarning(str(e)) return None def _save_search(self, name: str, search: str, update: bool = False) -> None: conf = self._get_saved_searches() if not update and name in conf: if conf[name] == search: # nothing to do return if not askUser(tr.browsing_confirm_saved_search_overwrite(name=name)): # don't overwrite existing saved search return conf[name] = search self._set_saved_searches(conf) self.refresh(SidebarItem(name, "", item_type=SidebarItemType.SAVED_SEARCH)) def remove_saved_searches(self, _item: SidebarItem) -> None: selected = self._selected_saved_searches() conf = self._get_saved_searches() for name in selected: del conf[name] self._set_saved_searches(conf) self.refresh() def rename_saved_search(self, item: SidebarItem, new_name: str) -> None: old_name = item.name conf = self._get_saved_searches() try: filt = conf[old_name] except KeyError: return if new_name in conf and not askUser( tr.browsing_confirm_saved_search_overwrite(name=new_name) ): return conf[new_name] = filt del conf[old_name] self._set_saved_searches(conf) item.name = new_name self.refresh() def save_current_search(self) -> None: if (search := self._get_current_search()) is None: return name = getOnlyText(tr.browsing_please_give_your_filter_a_name()) if not name: return self._save_search(name, search) def update_saved_search(self, item: SidebarItem) -> None: if (search := self._get_current_search()) is None: return self._save_search(item.name, search, update=True) # Notetypes and templates #################################### def manage_notetype(self, item: SidebarItem) -> None: Models( self.mw, parent=self.browser, fromMain=True, selected_notetype_id=NotetypeId(item.id), ) def manage_template(self, item: SidebarItem) -> None: assert item._parent_item is not None note = Note(self.col, self.col.models.get(NotetypeId(item._parent_item.id))) CardLayout(self.mw, note, ord=item.id, parent=self, fill_empty=True) def manage_fields(self, item: SidebarItem) -> None: assert item._parent_item is not None notetype = self.mw.col.models.get(NotetypeId(item._parent_item.id)) assert notetype is not None FieldDialog(self.mw, notetype, parent=self, open_at=item.id) # Helpers #################################### def _selected_items(self) -> list[SidebarItem]: return [self.model().item_for_index(idx) for idx in self.selectedIndexes()] def _selected_decks(self) -> list[DeckId]: return [ DeckId(item.id) for item in self._selected_items() if item.item_type == SidebarItemType.DECK ] def _selected_saved_searches(self) -> list[str]: return [ item.name for item in self._selected_items() if item.item_type == SidebarItemType.SAVED_SEARCH ] def _selected_tags(self) -> list[str]: return [ item.full_name for item in self._selected_items() if item.item_type == SidebarItemType.TAG ] def _selection_model(self) -> QItemSelectionModel: selection_model = self.selectionModel() assert selection_model is not None return selection_model ================================================ FILE: qt/aqt/browser/table/__init__.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations # ruff: noqa: F401 import copy import time from collections.abc import Generator, Sequence from dataclasses import dataclass from typing import TYPE_CHECKING, Union import aqt import aqt.browser from anki.cards import CardId from anki.collection import BrowserColumns as Columns from anki.collection import BrowserRow from anki.notes import NoteId from aqt import colors from aqt.qt import QColor from aqt.utils import tr Column = Columns.Column ItemId = Union[CardId, NoteId] ItemList = Union[Sequence[CardId], Sequence[NoteId]] @dataclass class SearchContext: search: str browser: aqt.browser.Browser order: bool | str | Column = True reverse: bool = False addon_metadata: dict | None = None # if set, provided ids will be used instead of the regular search ids: Sequence[ItemId] | None = None class Cell: def __init__( self, text: str, is_rtl: bool, elide_mode: BrowserRow.Cell.TextElideMode.V ) -> None: self.text: str = text self.is_rtl: bool = is_rtl self.elide_mode: aqt.Qt.TextElideMode = backend_elide_mode_to_aqt_elide_mode( elide_mode ) class CellRow: is_disabled: bool = False def __init__( self, cells: Generator[tuple[str, bool, BrowserRow.Cell.TextElideMode.V], None, None], color: BrowserRow.Color.V, font_name: str, font_size: int, ) -> None: self.refreshed_at: float = time.time() self.cells: tuple[Cell, ...] = tuple(Cell(*cell) for cell in cells) self.color: dict[str, str] | None = backend_color_to_aqt_color(color) self.font_name: str = font_name or "arial" self.font_size: int = font_size if font_size > 0 else 12 def is_stale(self, threshold: float) -> bool: return self.refreshed_at < threshold @staticmethod def generic(length: int, cell_text: str) -> CellRow: return CellRow( ((cell_text, False, BrowserRow.Cell.ElideRight) for cell in range(length)), BrowserRow.COLOR_DEFAULT, "arial", 12, ) @staticmethod def placeholder(length: int) -> CellRow: return CellRow.generic(length, "...") @staticmethod def disabled(length: int, cell_text: str) -> CellRow: row = CellRow.generic(length, cell_text) row.is_disabled = True return row def backend_elide_mode_to_aqt_elide_mode( elide_mode: BrowserRow.Cell.TextElideMode.V, ) -> aqt.Qt.TextElideMode: if elide_mode == BrowserRow.Cell.ElideLeft: return aqt.Qt.TextElideMode.ElideLeft if elide_mode == BrowserRow.Cell.ElideMiddle: return aqt.Qt.TextElideMode.ElideMiddle if elide_mode == BrowserRow.Cell.ElideNone: return aqt.Qt.TextElideMode.ElideNone return aqt.Qt.TextElideMode.ElideRight def backend_color_to_aqt_color(color: BrowserRow.Color.V) -> dict[str, str] | None: temp_color = None if color == BrowserRow.COLOR_MARKED: temp_color = colors.STATE_MARKED if color == BrowserRow.COLOR_SUSPENDED: temp_color = colors.STATE_SUSPENDED if color == BrowserRow.COLOR_BURIED: temp_color = colors.STATE_BURIED if color == BrowserRow.COLOR_FLAG_RED: temp_color = colors.FLAG_1 if color == BrowserRow.COLOR_FLAG_ORANGE: temp_color = colors.FLAG_2 if color == BrowserRow.COLOR_FLAG_GREEN: temp_color = colors.FLAG_3 if color == BrowserRow.COLOR_FLAG_BLUE: temp_color = colors.FLAG_4 if color == BrowserRow.COLOR_FLAG_PINK: temp_color = colors.FLAG_5 if color == BrowserRow.COLOR_FLAG_TURQUOISE: temp_color = colors.FLAG_6 if color == BrowserRow.COLOR_FLAG_PURPLE: temp_color = colors.FLAG_7 return adjusted_bg_color(temp_color) def adjusted_bg_color(color: dict[str, str] | None) -> dict[str, str] | None: if color: adjusted_color = copy.copy(color) light = QColor(color["light"]).lighter(150) adjusted_color["light"] = light.name() dark = QColor(color["dark"]).darker(150) adjusted_color["dark"] = dark.name() return adjusted_color else: return None from .model import DataModel from .state import CardState, ItemState, NoteState from .table import StatusDelegate, Table ================================================ FILE: qt/aqt/browser/table/model.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import time from collections.abc import Callable, Sequence from typing import Any import aqt import aqt.browser from anki.cards import Card, CardId from anki.collection import BrowserColumns as Columns from anki.collection import Collection from anki.consts import * from anki.errors import BackendError, NotFoundError from anki.notes import Note, NoteId from aqt import gui_hooks from aqt.browser.table import Cell, CellRow, Column, ItemId, SearchContext from aqt.browser.table.state import ItemState from aqt.qt import * from aqt.utils import tr class DataModel(QAbstractTableModel): """Data manager for the browser table. _items -- The card or note ids currently hold and corresponding to the table's rows. _rows -- The cached data objects to render items to rows. columns -- The data objects of all available columns, used to define the display of active columns and list all toggleable columns to the user. _block_updates -- If True, serve stale content to avoid hitting the DB. _stale_cutoff -- A threshold to decide whether a cached row has gone stale. """ def __init__( self, parent: QObject, col: Collection, state: ItemState, row_state_will_change_callback: Callable, row_state_changed_callback: Callable, ) -> None: super().__init__(parent) self.col: Collection = col self.columns: dict[str, Column] = { c.key: c for c in self.col.all_browser_columns() } gui_hooks.browser_did_fetch_columns(self.columns) self._state: ItemState = state self._items: Sequence[ItemId] = [] self._rows: dict[int, CellRow] = {} self._block_updates = False self._stale_cutoff = 0.0 self._on_row_state_will_change = row_state_will_change_callback self._on_row_state_changed = row_state_changed_callback assert aqt.mw is not None self._want_tooltips = aqt.mw.pm.show_browser_table_tooltips() # Row Object Interface ###################################################################### # Get Rows def get_cell(self, index: QModelIndex) -> Cell: return self.get_row(index).cells[index.column()] def get_row(self, index: QModelIndex) -> CellRow: item = self.get_item(index) if row := self._rows.get(item): if not self._block_updates and row.is_stale(self._stale_cutoff): # need to refresh return self._fetch_row_and_update_cache(index, item, row) # return row, even if it's stale return row if self._block_updates: # blank row until we unblock return CellRow.placeholder(self.len_columns()) # missing row, need to build return self._fetch_row_and_update_cache(index, item, None) def _fetch_row_and_update_cache( self, index: QModelIndex, item: ItemId, old_row: CellRow | None ) -> CellRow: """Fetch a row from the backend, add it to the cache and return it. Then fire callbacks if the row is being deleted or restored. """ new_row = self._fetch_row_from_backend(item) # row state has changed if existence of cached and fetched counterparts differ # if the row was previously uncached, it is assumed to have existed state_change = ( new_row.is_disabled if old_row is None else old_row.is_disabled != new_row.is_disabled ) if state_change: self._on_row_state_will_change(index, not new_row.is_disabled) self._rows[item] = new_row if state_change: self._on_row_state_changed(index, not new_row.is_disabled) return self._rows[item] def _fetch_row_from_backend(self, item: ItemId) -> CellRow: try: row = CellRow(*self.col.browser_row_for_id(item)) except BackendError as e: return CellRow.disabled(self.len_columns(), str(e)) except Exception: return CellRow.disabled( self.len_columns(), tr.errors_please_check_database() ) except BaseException: # fatal error like a panic in the backend - dump it to the # console so it gets picked up by the error handler import traceback traceback.print_exc() # and prevent Qt from firing a storm of follow-up errors self._block_updates = True return CellRow.generic(self.len_columns(), "error") gui_hooks.browser_did_fetch_row( item, self._state.is_notes_mode(), row, self._state.active_columns ) return row def get_cached_row(self, index: QModelIndex) -> CellRow | None: """Get row if it is cached, regardless of staleness.""" return self._rows.get(self.get_item(index)) # Reset def mark_cache_stale(self) -> None: self._stale_cutoff = time.time() def reset(self) -> None: self.begin_reset() self.end_reset() def begin_reset(self) -> None: self.beginResetModel() self.mark_cache_stale() def end_reset(self) -> None: self.endResetModel() # Block/Unblock def begin_blocking(self) -> None: self._block_updates = True def end_blocking(self) -> None: self._block_updates = False self.redraw_cells() def redraw_cells(self) -> None: "Update cell contents, without changing search count/columns/sorting." if self.is_empty(): return top_left = self.index(0, 0) bottom_right = self.index(self.len_rows() - 1, self.len_columns() - 1) self.dataChanged.emit(top_left, bottom_right) # type: ignore # Item Interface ###################################################################### # Get metadata def is_empty(self) -> bool: return not self._items def len_rows(self) -> int: return len(self._items) def len_columns(self) -> int: return len(self._state.active_columns) # Get items (card or note ids depending on state) def get_item(self, index: QModelIndex) -> ItemId: return self._items[index.row()] def get_items(self, indices: list[QModelIndex]) -> Sequence[ItemId]: return [self.get_item(index) for index in indices] def get_card_ids(self, indices: list[QModelIndex]) -> Sequence[CardId]: return self._state.get_card_ids(self.get_items(indices)) def get_note_ids(self, indices: list[QModelIndex]) -> Sequence[NoteId]: return self._state.get_note_ids(self.get_items(indices)) def get_note_id(self, index: QModelIndex) -> NoteId | None: if nid_list := self._state.get_note_ids([self.get_item(index)]): return nid_list[0] return None # Get row numbers from items def get_item_row(self, item: ItemId) -> int | None: for row, i in enumerate(self._items): if i == item: return row return None def get_item_rows(self, items: Sequence[ItemId]) -> list[int]: rows = [] for row, i in enumerate(self._items): if i in items: rows.append(row) return rows def get_card_row(self, card_id: CardId) -> int | None: return self.get_item_row(self._state.get_item_from_card_id(card_id)) # Get objects (cards or notes) def get_card(self, index: QModelIndex) -> Card | None: """Try to return the indicated, possibly deleted card.""" if not index.isValid(): return None # The browser code will be calling .note() on the returned card, but # the note might have been be deleted while the card still exists. try: card = self._state.get_card(self.get_item(index)) card.note() except NotFoundError: return None return card def get_note(self, index: QModelIndex) -> Note | None: """Try to return the indicated, possibly deleted note.""" if not index.isValid(): return None try: return self._state.get_note(self.get_item(index)) except NotFoundError: return None # Table Interface ###################################################################### def toggle_state(self, context: SearchContext) -> ItemState: self.begin_reset() self._state = self._state.toggle_state() try: self._search_inner(context) except Exception: # rollback to prevent inconsistent state self._state = self._state.toggle_state() raise finally: self.end_reset() return self._state # Rows def search(self, context: SearchContext) -> None: self.begin_reset() try: self._search_inner(context) finally: self.end_reset() def _search_inner(self, context: SearchContext) -> None: if context.order is True: try: context.order = self.columns[self._state.sort_column] except KeyError: # invalid sort column in config context.order = self.columns["noteCrt"] context.reverse = self._state.sort_backwards context.addon_metadata = {} gui_hooks.browser_will_search(context) if context.ids is None: context.ids = self._state.find_items( context.search, context.order, context.reverse ) gui_hooks.browser_did_search(context) self._items = context.ids self._rows = {} def reverse(self) -> None: self.beginResetModel() self._items = list(reversed(self._items)) self.endResetModel() # Columns def column_at(self, index: QModelIndex) -> Column: return self.column_at_section(index.column()) def column_at_section(self, section: int) -> Column: """Returns the column object corresponding to the active column at index or the default column object if no data is associated with the active column. """ key = self._state.column_key_at(section) try: return self.columns[key] except KeyError: self.columns[key] = addon_column_fillin(key) return self.columns[key] def active_column_index(self, column: str) -> int | None: return ( self._state.active_columns.index(column) if column in self._state.active_columns else None ) def toggle_column(self, column: str) -> None: self.begin_reset() self._state.toggle_active_column(column) self.end_reset() # Model interface ###################################################################### def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: if parent and parent.isValid(): return 0 return self.len_rows() def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: if parent and parent.isValid(): return 0 return self.len_columns() def data(self, index: QModelIndex = QModelIndex(), role: int = 0) -> Any: if not index.isValid(): return QVariant() if role == Qt.ItemDataRole.FontRole: if not self.column_at(index).uses_cell_font: return QVariant() qfont = QFont() row = self.get_row(index) qfont.setFamily(row.font_name) qfont.setPixelSize(row.font_size) return qfont elif role == Qt.ItemDataRole.TextAlignmentRole: align: Qt.AlignmentFlag | int = Qt.AlignmentFlag.AlignVCenter if self.column_at(index).alignment == Columns.ALIGNMENT_CENTER: align |= Qt.AlignmentFlag.AlignHCenter return getattr(align, "value", align) elif role == Qt.ItemDataRole.DisplayRole: return self.get_cell(index).text elif role == Qt.ItemDataRole.ToolTipRole and self._want_tooltips: return self.get_cell(index).text return QVariant() def headerData( self, section: int, orientation: Qt.Orientation, role: int = 0 ) -> str | None: if ( orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole ): return self._state.column_label(self.column_at_section(section)) return None def flags(self, index: QModelIndex) -> Qt.ItemFlag: # shortcut for large selections (Ctrl+A) to avoid fetching large numbers of rows at once if row := self.get_cached_row(index): if row.is_disabled: return Qt.ItemFlag(Qt.ItemFlag.NoItemFlags) return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable def addon_column_fillin(key: str) -> Column: """Return a column with generic fields and a label indicating to the user that this column was added by an add-on. """ return Column( key=key, cards_mode_label=f"{tr.browsing_addon()} ({key})", notes_mode_label=f"{tr.browsing_addon()} ({key})", sorting_cards=Columns.SORTING_NONE, sorting_notes=Columns.SORTING_NONE, uses_cell_font=False, alignment=Columns.ALIGNMENT_CENTER, ) ================================================ FILE: qt/aqt/browser/table/state.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from abc import ABC, abstractmethod, abstractproperty from collections.abc import Sequence from typing import cast from anki.browser import BrowserConfig from anki.cards import Card, CardId from anki.collection import Collection from anki.errors import NotFoundError from anki.notes import Note, NoteId from anki.utils import ids2str from aqt.browser.table import Column, ItemId, ItemList class ItemState(ABC): GEOMETRY_KEY_PREFIX: str SORT_COLUMN_KEY: str SORT_BACKWARDS_KEY: str _active_columns: list[str] def __init__(self, col: Collection) -> None: self.col = col self._sort_column = self.col.get_config(self.SORT_COLUMN_KEY) self._sort_backwards = self.col.get_config(self.SORT_BACKWARDS_KEY, False) def is_notes_mode(self) -> bool: """Return True if the state is a NoteState.""" return isinstance(self, NoteState) # Stateless Helpers def note_ids_from_card_ids(self, items: Sequence[ItemId]) -> Sequence[NoteId]: assert self.col.db is not None return self.col.db.list( f"select distinct nid from cards where id in {ids2str(items)}" ) def card_ids_from_note_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]: assert self.col.db is not None return self.col.db.list(f"select id from cards where nid in {ids2str(items)}") def column_key_at(self, index: int) -> str: return self._active_columns[index] def column_label(self, column: Column) -> str: return ( column.notes_mode_label if self.is_notes_mode() else column.cards_mode_label ) def column_tooltip(self, column: Column) -> str: if self.is_notes_mode(): return column.notes_mode_tooltip return column.cards_mode_tooltip # Columns and sorting # abstractproperty is deprecated but used due to mypy limitations # (https://github.com/python/mypy/issues/1362) @abstractproperty def active_columns(self) -> list[str]: """Return the saved or default columns for the state.""" @abstractmethod def toggle_active_column(self, column: str) -> None: """Add or remove an active column.""" @property def sort_column(self) -> str: return self._sort_column @sort_column.setter def sort_column(self, column: str) -> None: self.col.set_config(self.SORT_COLUMN_KEY, column) self._sort_column = column @property def sort_backwards(self) -> bool: "If true, descending order." return self._sort_backwards @sort_backwards.setter def sort_backwards(self, order: bool) -> None: self.col.set_config(self.SORT_BACKWARDS_KEY, order) self._sort_backwards = order # Get objects @abstractmethod def get_card(self, item: ItemId) -> Card: """Return the item if it's a card or its first card if it's a note.""" @abstractmethod def get_note(self, item: ItemId) -> Note: """Return the item if it's a note or its note if it's a card.""" # Get ids @abstractmethod def find_items( self, search: str, order: bool | str | Column, reverse: bool ) -> Sequence[ItemId]: """Return the item ids fitting the given search and order.""" @abstractmethod def get_item_from_card_id(self, card: CardId) -> ItemId: """Return the appropriate item id for a card id.""" @abstractmethod def get_card_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]: """Return the card ids for the given item ids.""" @abstractmethod def get_note_ids(self, items: Sequence[ItemId]) -> Sequence[NoteId]: """Return the note ids for the given item ids.""" # Toggle @abstractmethod def toggle_state(self) -> ItemState: """Return an instance of the other state.""" @abstractmethod def get_new_items(self, old_items: Sequence[ItemId]) -> ItemList: """Given a list of ids from the other state, return the corresponding ids for this state.""" class CardState(ItemState): GEOMETRY_KEY_PREFIX = "editor" SORT_COLUMN_KEY = BrowserConfig.CARDS_SORT_COLUMN_KEY SORT_BACKWARDS_KEY = BrowserConfig.CARDS_SORT_BACKWARDS_KEY def __init__(self, col: Collection) -> None: super().__init__(col) self._active_columns = self.col.load_browser_card_columns() @property def active_columns(self) -> list[str]: return self._active_columns def toggle_active_column(self, column: str) -> None: if column in self._active_columns: self._active_columns.remove(column) else: self._active_columns.append(column) self.col.set_browser_card_columns(self._active_columns) def get_card(self, item: ItemId) -> Card: return self.col.get_card(CardId(item)) def get_note(self, item: ItemId) -> Note: return self.get_card(item).note() def find_items( self, search: str, order: bool | str | Column, reverse: bool ) -> Sequence[ItemId]: return self.col.find_cards(search, order, reverse) def get_item_from_card_id(self, card: CardId) -> ItemId: return card def get_card_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]: return cast(Sequence[CardId], items) def get_note_ids(self, items: Sequence[ItemId]) -> Sequence[NoteId]: return super().note_ids_from_card_ids(items) def toggle_state(self) -> NoteState: return NoteState(self.col) def get_new_items(self, old_items: Sequence[ItemId]) -> Sequence[CardId]: return super().card_ids_from_note_ids(old_items) class NoteState(ItemState): GEOMETRY_KEY_PREFIX = "editorNotesMode" SORT_COLUMN_KEY = BrowserConfig.NOTES_SORT_COLUMN_KEY SORT_BACKWARDS_KEY = BrowserConfig.NOTES_SORT_BACKWARDS_KEY def __init__(self, col: Collection) -> None: super().__init__(col) self._active_columns = self.col.load_browser_note_columns() @property def active_columns(self) -> list[str]: return self._active_columns def toggle_active_column(self, column: str) -> None: if column in self._active_columns: self._active_columns.remove(column) else: self._active_columns.append(column) self.col.set_browser_note_columns(self._active_columns) def get_card(self, item: ItemId) -> Card: if cards := self.get_note(item).cards(): return cards[0] raise NotFoundError("card not found", None, None, None) def get_note(self, item: ItemId) -> Note: return self.col.get_note(NoteId(item)) def find_items( self, search: str, order: bool | str | Column, reverse: bool ) -> Sequence[ItemId]: return self.col.find_notes(search, order, reverse) def get_item_from_card_id(self, card: CardId) -> ItemId: return self.col.get_card(card).note().id def get_card_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]: return super().card_ids_from_note_ids(items) def get_note_ids(self, items: Sequence[ItemId]) -> Sequence[NoteId]: return cast(Sequence[NoteId], items) def toggle_state(self) -> CardState: return CardState(self.col) def get_new_items(self, old_items: Sequence[ItemId]) -> Sequence[NoteId]: return super().note_ids_from_card_ids(old_items) ================================================ FILE: qt/aqt/browser/table/table.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Callable, Sequence from typing import Any import aqt import aqt.browser import aqt.forms from anki.cards import Card, CardId from anki.collection import Collection, Config, OpChanges from anki.consts import * from anki.notes import Note, NoteId from aqt import gui_hooks from aqt.browser.table import Columns, ItemId, SearchContext from aqt.browser.table.model import DataModel from aqt.browser.table.state import CardState, ItemState, NoteState from aqt.qt import * from aqt.theme import theme_manager from aqt.utils import ( KeyboardModifiersPressed, qtMenuShortcutWorkaround, restoreHeader, saveHeader, showInfo, tr, ) class Table: SELECTION_LIMIT: int = 500 def __init__(self, browser: aqt.browser.Browser) -> None: self.browser = browser self.col: Collection = browser.col self._state: ItemState = ( NoteState(self.col) if self.col.get_config_bool(Config.Bool.BROWSER_TABLE_SHOW_NOTES_MODE) else CardState(self.col) ) self._model = DataModel( self.browser, self.col, self._state, self._on_row_state_will_change, self._on_row_state_changed, ) self._view: QTableView | None = None # cached for performance self._len_selection = 0 self._selected_rows: list[QModelIndex] | None = None # temporarily set for selection preservation self._current_item: ItemId | None = None self._selected_items: Sequence[ItemId] = [] def set_view(self, view: QTableView) -> None: self._view = view self._setup_view() self._setup_headers() def cleanup(self) -> None: self._save_header() # Public Methods ###################################################################### # Get metadata def len(self) -> int: return self._model.len_rows() def len_selection(self, refresh: bool = False) -> int: # `len(self._view.selectionModel().selectedRows())` is slow for large # selections, because Qt queries flags() for every selected cell, so we # calculate the number of selected rows ourselves return self._len_selection def has_current(self) -> bool: return self._selection_model().currentIndex().isValid() def has_previous(self) -> bool: return self.has_current() and self._current().row() > 0 def has_next(self) -> bool: return self.has_current() and self._current().row() < self.len() - 1 def is_notes_mode(self) -> bool: return self._state.is_notes_mode() # Get objects def get_current_card(self) -> Card | None: return self._model.get_card(self._current()) def get_current_note(self) -> Note | None: return self._model.get_note(self._current()) def get_single_selected_card(self) -> Card | None: """If there is only one row selected return its card, else None. This may be a different one than the current card.""" if self.len_selection() != 1: return None return self._model.get_card(self._selected()[0]) # Get ids def get_selected_card_ids(self) -> Sequence[CardId]: return self._model.get_card_ids(self._selected()) def get_selected_note_ids(self) -> Sequence[NoteId]: return self._model.get_note_ids(self._selected()) def get_card_ids_from_selected_note_ids(self) -> Sequence[CardId]: return self._state.card_ids_from_note_ids(self.get_selected_note_ids()) # Selecting def select_all(self) -> None: assert self._view is not None self._view.selectAll() def clear_selection(self) -> None: self._len_selection = 0 self._selected_rows = None self._selection_model().clear() def invert_selection(self) -> None: selection_model = self._selection_model() selection = selection_model.selection() self.select_all() selection_model.select( selection, QItemSelectionModel.SelectionFlag.Deselect | QItemSelectionModel.SelectionFlag.Rows, ) def select_single_card( self, card_id: CardId, scroll_even_if_visible: bool = True ) -> None: """Try to set the selection to the item corresponding to the given card.""" self._reset_selection() if (row := self._model.get_card_row(card_id)) is not None: assert self._view is not None self._view.selectRow(row) self._scroll_to_row(row, scroll_even_if_visible) else: self.browser.on_all_or_selected_rows_changed() self.browser.on_current_row_changed() # Reset def reset(self) -> None: """Reload table data from collection and redraw.""" self.begin_reset() self.end_reset() def begin_reset(self) -> None: self._save_selection() self._model.begin_reset() def end_reset(self) -> None: self._model.end_reset() self._restore_selection(self._intersected_selection) def on_backend_will_block(self) -> None: # make sure the card list doesn't try to refresh itself during the operation, # as that will block the UI self._model.begin_blocking() def on_backend_did_block(self) -> None: self._model.end_blocking() def redraw_cells(self) -> None: self._model.redraw_cells() def op_executed( self, changes: OpChanges, handler: object | None, focused: bool ) -> None: if changes.browser_table: self._model.mark_cache_stale() if focused: self.redraw_cells() # Modify table def search(self, txt: str) -> None: self._save_selection() self._model.search(SearchContext(search=txt, browser=self.browser)) self._restore_selection(self._intersected_selection) def toggle_state(self, is_notes_mode: bool, last_search: str) -> None: if is_notes_mode == self.is_notes_mode(): return self._save_header() self._save_selection() self._state = self._model.toggle_state( SearchContext(search=last_search, browser=self.browser) ) self.col.set_config_bool( Config.Bool.BROWSER_TABLE_SHOW_NOTES_MODE, self.is_notes_mode(), ) self._restore_header() self._restore_selection(self._toggled_selection) # Move cursor def to_previous_row(self) -> None: self._move_current(QAbstractItemView.CursorAction.MoveUp) def to_next_row(self) -> None: self._move_current(QAbstractItemView.CursorAction.MoveDown) def to_first_row(self) -> None: self._move_current_to_row(0) def to_last_row(self) -> None: self._move_current_to_row(self._model.len_rows() - 1) def to_row_of_unselected_note(self) -> Sequence[NoteId]: """Select and set focus to a row whose note is not selected, trying the rows below the bottomost, then above the topmost selected row. If that's not possible, clear selection. Return previously selected note ids. """ nids = self.get_selected_note_ids() bottom = max(r.row() for r in self._selected()) + 1 for row in range(bottom, self.len()): index = self._model.index(row, 0) if self._model.get_row(index).is_disabled: continue if self._model.get_note_id(index) in nids: continue self._move_current_to_row(row) return nids top = min(r.row() for r in self._selected()) - 1 for row in range(top, -1, -1): index = self._model.index(row, 0) if self._model.get_row(index).is_disabled: continue if self._model.get_note_id(index) in nids: continue self._move_current_to_row(row) return nids self._reset_selection() self.browser.on_all_or_selected_rows_changed() self.browser.on_current_row_changed() return nids def clear_current(self) -> None: self._selection_model().setCurrentIndex( QModelIndex(), QItemSelectionModel.SelectionFlag.NoUpdate, ) # Private methods ###################################################################### # Helpers def _current(self) -> QModelIndex: return self._selection_model().currentIndex() def _selected(self) -> list[QModelIndex]: if self._selected_rows is None: self._selected_rows = self._selection_model().selectedRows() return self._selected_rows def _set_current(self, row: int, column: int = 0) -> None: index = self._model.index(row, self._horizontal_header().logicalIndex(column)) self._selection_model().setCurrentIndex( index, QItemSelectionModel.SelectionFlag.NoUpdate, ) def _reset_selection(self) -> None: """Remove selection and focus without emitting signals. If no selection change is triggered afterwards, `browser.on_all_or_selected_rows_changed()` and `browser.on_current_row_changed()` must be called. """ self._selection_model().reset() self._len_selection = 0 self._selected_rows = None def _select_rows(self, rows: list[int]) -> None: selection = QItemSelection() for row in rows: selection.select( self._model.index(row, 0), self._model.index(row, self._model.len_columns() - 1), ) self._selection_model().select( selection, QItemSelectionModel.SelectionFlag.SelectCurrent ) def _set_sort_indicator(self) -> None: hh = self._horizontal_header() index = self._model.active_column_index(self._state.sort_column) if index is None: hh.setSortIndicatorShown(False) return if self._state.sort_backwards: order = Qt.SortOrder.DescendingOrder else: order = Qt.SortOrder.AscendingOrder hh.blockSignals(True) hh.setSortIndicator(index, order) hh.blockSignals(False) hh.setSortIndicatorShown(True) def _set_column_sizes(self) -> None: hh = self._horizontal_header() hh.setSectionResizeMode(QHeaderView.ResizeMode.Interactive) hh.setSectionResizeMode( hh.logicalIndex(self._model.len_columns() - 1), QHeaderView.ResizeMode.Stretch, ) # this must be set post-resize or it doesn't work hh.setCascadingSectionResizes(False) def _save_header(self) -> None: saveHeader(self._horizontal_header(), self._state.GEOMETRY_KEY_PREFIX) def _restore_header(self) -> None: hh = self._horizontal_header() hh.blockSignals(True) restoreHeader(hh, self._state.GEOMETRY_KEY_PREFIX) self._set_column_sizes() self._set_sort_indicator() hh.blockSignals(False) # Setup def _setup_view(self) -> None: assert self._view is not None self._view.setSortingEnabled(True) self._view.setModel(self._model) self._view.selectionModel() self._view.setItemDelegate(StatusDelegate(self.browser, self._model)) selection_model = self._selection_model() qconnect(selection_model.selectionChanged, self._on_selection_changed) qconnect(selection_model.currentChanged, self._on_current_changed) self._view.setWordWrap(False) self._view.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) horizontal_scroll_bar = self._view.horizontalScrollBar() assert horizontal_scroll_bar is not None horizontal_scroll_bar.setSingleStep(10) self._update_font() self._view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) qconnect(self._view.customContextMenuRequested, self._on_context_menu) def _update_font(self) -> None: # we can't choose different line heights efficiently, so we need # to pick a line height big enough for any card template curmax = 16 for m in self.col.models.all(): for t in m["tmpls"]: bsize = t.get("bsize", 0) curmax = max(curmax, bsize) assert self._view is not None vh = self._view.verticalHeader() assert vh is not None vh.setDefaultSectionSize(curmax + 6) def _setup_headers(self) -> None: assert self._view is not None vh = self._view.verticalHeader() assert vh is not None hh = self._horizontal_header() vh.hide() hh.show() hh.setHighlightSections(False) hh.setMinimumSectionSize(50) hh.setSectionsMovable(True) hh.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self._restore_header() qconnect(hh.customContextMenuRequested, self._on_header_context) qconnect(hh.sortIndicatorChanged, self._on_sort_column_changed) qconnect(hh.sectionMoved, self._on_column_moved) # Slots def _on_current_changed(self, current: QModelIndex, previous: QModelIndex) -> None: if current.row() != previous.row(): self.browser.on_current_row_changed() def _on_selection_changed( self, selected: QItemSelection, deselected: QItemSelection ) -> None: # `selection.indexes()` calls `flags()` for all the selection's indexes, # whereas `selectedRows()` calls it for the indexes of the resulting selection. # Both may be slow, so we try to optimise. if KeyboardModifiersPressed().shift or KeyboardModifiersPressed().control: # Current selection is modified. The number of added/removed rows is # usually smaller than the number of rows in the resulting selection. self._len_selection += ( len(selected.indexes()) - len(deselected.indexes()) ) // self._model.len_columns() else: # New selection is created. Usually a single row or none at all. self._len_selection = len(self._selection_model().selectedRows()) self._selected_rows = None self.browser.on_all_or_selected_rows_changed() def _on_row_state_will_change(self, index: QModelIndex, was_restored: bool) -> None: if not was_restored: if self._selection_model().isSelected(index): self._len_selection -= 1 self._selected_rows = None self.browser.on_all_or_selected_rows_changed() if index.row() == self._current().row(): # avoid focus on deleted (disabled) rows self.clear_current() self.browser.on_current_row_changed() def _on_row_state_changed(self, index: QModelIndex, was_restored: bool) -> None: if was_restored: if self._selection_model().isSelected(index): self._len_selection += 1 self._selected_rows = None self.browser.on_all_or_selected_rows_changed() elif not self._current().isValid() and self.len_selection() == 0: # restore focus for convenience self._select_rows([index.row()]) self._set_current(index.row()) self._scroll_to_row(index.row()) # row change and redraw have been triggered return # Workaround for a bug where the flags for the first column don't update # automatically (due to the shortcut in 'model.flags()') top_left = self._model.index(index.row(), 0) bottom_right = self._model.index(index.row(), self._model.len_columns() - 1) self._model.dataChanged.emit(top_left, bottom_right) # type: ignore def _on_context_menu(self, _point: QPoint) -> None: menu = QMenu() if self.is_notes_mode(): main = self.browser.form.menu_Notes other = self.browser.form.menu_Cards other_name = tr.qt_accel_cards() else: main = self.browser.form.menu_Cards other = self.browser.form.menu_Notes other_name = tr.qt_accel_notes() for action in main.actions(): menu.addAction(action) menu.addSeparator() sub_menu = menu.addMenu(other_name) assert sub_menu is not None for action in other.actions(): sub_menu.addAction(action) gui_hooks.browser_will_show_context_menu(self.browser, menu) qtMenuShortcutWorkaround(menu) menu.exec(QCursor.pos()) def _on_header_context(self, pos: QPoint) -> None: assert self._view is not None gpos = self._view.mapToGlobal(pos) m = QMenu() m.setToolTipsVisible(True) for key, column in self._model.columns.items(): a = m.addAction(self._state.column_label(column)) assert a is not None a.setCheckable(True) a.setChecked(self._model.active_column_index(key) is not None) a.setToolTip(self._state.column_tooltip(column)) qconnect( a.toggled, lambda checked, key=key: self._on_column_toggled(checked, key), ) gui_hooks.browser_header_will_show_context_menu(self.browser, m) m.exec(gpos) def _on_column_moved(self, *_args: Any) -> None: self._set_column_sizes() def _on_column_toggled(self, checked: bool, column: str) -> None: if not checked and self._model.len_columns() < 2: showInfo(tr.browsing_you_must_have_at_least_one()) return self._model.toggle_column(column) self._set_column_sizes() # sorted field may have been hidden or revealed self._set_sort_indicator() if checked: self._scroll_to_column(self._model.len_columns() - 1) def _on_sort_column_changed(self, section: int, order: Qt.SortOrder) -> None: column = self._model.column_at_section(section) sorting = column.sorting_notes if self.is_notes_mode() else column.sorting_cards if sorting is Columns.SORTING_NONE: showInfo(tr.browsing_sorting_on_this_column_is_not()) self._set_sort_indicator() return if self._state.sort_column != column.key: self._state.sort_column = column.key # numeric fields default to descending if sorting is Columns.SORTING_DESCENDING: order = Qt.SortOrder.DescendingOrder self._state.sort_backwards = order == Qt.SortOrder.DescendingOrder self.browser.search() else: descending = order == Qt.SortOrder.DescendingOrder if self._state.sort_backwards != descending: self._state.sort_backwards = descending self._reverse() self._set_sort_indicator() def _reverse(self) -> None: self._save_selection() self._model.reverse() self._restore_selection(self._intersected_selection) # Restore selection def _save_selection(self) -> None: """Save the current item and selected items.""" if self.has_current(): self._current_item = self._model.get_item(self._current()) self._selected_items = self._model.get_items(self._selected()) def _restore_selection(self, new_selected_and_current: Callable) -> None: """Restore the saved selection and current element as far as possible and scroll to the new current element. Clear the saved selection. """ self._reset_selection() if not self._model.is_empty(): rows, current = new_selected_and_current() rows = self._qualify_selected_rows(rows, current) current = current or rows[0] self._select_rows(rows) self._set_current(current) self._scroll_to_row(current) if self.len_selection() == 0: # no row change will fire self.browser.on_all_or_selected_rows_changed() self.browser.on_current_row_changed() self._selected_items = [] self._current_item = None def _qualify_selected_rows(self, rows: list[int], current: int | None) -> list[int]: """Return between 1 and SELECTION_LIMIT rows, as far as possible from rows or current.""" if rows: if len(rows) < self.SELECTION_LIMIT: return rows if current and current in rows: return [current] return rows[0:1] return [current if current else 0] def _intersected_selection(self) -> tuple[list[int], int | None]: """Return all rows of items that were in the saved selection and the row of the saved current element if present. """ selected_rows = self._model.get_item_rows(self._selected_items) current_row = self._current_item and self._model.get_item_row( self._current_item ) return selected_rows, current_row def _toggled_selection(self) -> tuple[list[int], int | None]: """Convert the items of the saved selection and current element to the new state and return their rows. """ selected_rows = self._model.get_item_rows( self._state.get_new_items(self._selected_items) ) current_row = None if self._current_item: if new_current := self._state.get_new_items([self._current_item]): current_row = self._model.get_item_row(new_current[0]) return selected_rows, current_row # Move def _scroll_to_row(self, row: int, scroll_even_if_visible: bool = False) -> None: """Scroll vertically to row.""" assert self._view is not None top_border = self._view.rowViewportPosition(row) bottom_border = top_border + self._view.rowHeight(0) viewport = self._view.viewport() assert viewport is not None visible = top_border >= 0 and bottom_border < viewport.height() if not visible or scroll_even_if_visible: horizontal_scroll_bar = self._view.horizontalScrollBar() assert horizontal_scroll_bar is not None horizontal = horizontal_scroll_bar.value() self._view.scrollTo( self._model.index(row, 0), QAbstractItemView.ScrollHint.PositionAtTop ) horizontal_scroll_bar.setValue(horizontal) def _scroll_to_column(self, column: int) -> None: """Scroll horizontally to column.""" assert self._view is not None position = self._view.columnViewportPosition(column) viewport = self._view.viewport() assert viewport is not None visible = 0 <= position < viewport.width() if not visible: vertical_scroll_bar = self._view.verticalScrollBar() assert vertical_scroll_bar is not None vertical = vertical_scroll_bar.value() self._view.scrollTo( self._model.index(0, column), QAbstractItemView.ScrollHint.PositionAtCenter, ) vertical_scroll_bar.setValue(vertical) def _move_current_to_index(self, index: QModelIndex) -> None: if not self.has_current(): return assert self._view is not None # Setting current like this avoids a bug with shift-click selection # https://github.com/ankitects/anki/issues/2469 self._view.setCurrentIndex(index) self._selection_model().select( index, QItemSelectionModel.SelectionFlag.Clear | QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Rows, ) def _move_current( self, direction: QAbstractItemView.CursorAction, ) -> None: assert self._view is not None index = self._view.moveCursor( direction, self.browser.mw.app.keyboardModifiers(), ) self._move_current_to_index(index) def _move_current_to_row(self, row: int) -> None: selection_model = self._selection_model() old = selection_model.currentIndex() self._move_current_to_index(self._model.index(row, 0)) if not KeyboardModifiersPressed().shift: return new = selection_model.currentIndex() selection = QItemSelection(new, old) selection_model.select( selection, QItemSelectionModel.SelectionFlag.SelectCurrent | QItemSelectionModel.SelectionFlag.Rows, ) def _selection_model(self) -> QItemSelectionModel: assert self._view is not None selection_model = self._view.selectionModel() assert selection_model is not None return selection_model def _horizontal_header(self) -> QHeaderView: assert self._view is not None hh = self._view.horizontalHeader() assert hh is not None return hh class StatusDelegate(QItemDelegate): def __init__(self, browser: aqt.browser.Browser, model: DataModel) -> None: QItemDelegate.__init__(self, browser) self._model = model def paint( self, painter: QPainter | None, option: QStyleOptionViewItem, index: QModelIndex ) -> None: option.textElideMode = self._model.get_cell(index).elide_mode if self._model.get_cell(index).is_rtl: option.direction = Qt.LayoutDirection.RightToLeft if row_color := self._model.get_row(index).color: brush = QBrush(theme_manager.qcolor(row_color)) assert painter painter.save() painter.fillRect(option.rect, brush) painter.restore() return QItemDelegate.paint(self, painter, option, index) ================================================ FILE: qt/aqt/changenotetype.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Sequence import aqt import aqt.deckconf import aqt.main import aqt.operations from anki.collection import OpChanges from anki.models import ChangeNotetypeRequest, NotetypeId from anki.notes import NoteId from aqt.operations.notetype import change_notetype_of_notes from aqt.qt import * from aqt.utils import ( disable_help_button, restoreGeom, saveGeom, showWarning, tooltip, tr, ) from aqt.webview import AnkiWebView, AnkiWebViewKind class ChangeNotetypeDialog(QDialog): TITLE = "changeNotetype" silentlyClose = True def __init__( self, parent: QWidget, mw: aqt.main.AnkiQt, note_ids: Sequence[NoteId], notetype_id: NotetypeId, ) -> None: QDialog.__init__(self, parent) self.mw = mw self._note_ids = note_ids self._setup_ui(notetype_id) self.show() def _setup_ui(self, notetype_id: NotetypeId) -> None: self.setWindowModality(Qt.WindowModality.ApplicationModal) self.mw.garbage_collect_on_dialog_finish(self) self.setMinimumSize(400, 300) disable_help_button(self) restoreGeom(self, self.TITLE, default_size=(800, 800)) self.web = AnkiWebView(kind=AnkiWebViewKind.CHANGE_NOTETYPE) self.web.setVisible(False) self.web.load_sveltekit_page(f"change-notetype/{notetype_id}") layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self.web) self.setLayout(layout) self.setWindowTitle(tr.browsing_change_notetype()) def reject(self) -> None: self.web.cleanup() self.web = None # type: ignore saveGeom(self, self.TITLE) QDialog.reject(self) def save(self, data: bytes) -> None: input = ChangeNotetypeRequest() input.ParseFromString(data) if not self.mw.confirm_schema_modification(): return def on_done(op: OpChanges) -> None: tooltip( tr.browsing_notes_updated(count=len(input.note_ids)), parent=self.parentWidget(), ) self.reject() input.note_ids.extend(self._note_ids) change_notetype_of_notes(parent=self, input=input).success( on_done ).run_in_background() def change_notetype_dialog(parent: QWidget, note_ids: Sequence[NoteId]) -> None: try: notetype_id = aqt.mw.col.models.get_single_notetype_of_notes(note_ids) except Exception as e: showWarning(str(e), parent=parent) return ChangeNotetypeDialog( parent=parent, mw=aqt.mw, note_ids=note_ids, notetype_id=notetype_id ) ================================================ FILE: qt/aqt/clayout.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import json import re from collections.abc import Callable from concurrent.futures import Future from typing import Any, Match, cast import aqt import aqt.forms import aqt.operations from anki import stdmodels from anki.collection import OpChanges from anki.consts import * from anki.lang import with_collapsed_whitespace, without_unicode_isolation from anki.notes import Note from anki.notetypes_pb2 import StockNotetype from aqt import AnkiQt, gui_hooks from aqt.forms import browserdisp from aqt.operations.notetype import restore_notetype_to_stock, update_notetype_legacy from aqt.qt import * from aqt.schema_change_tracker import ChangeTracker from aqt.sound import av_player, play_clicked_audio from aqt.theme import theme_manager from aqt.utils import ( HelpPage, ask_user_dialog, askUser, disable_help_button, downArrow, getOnlyText, openHelp, restoreGeom, restoreSplitter, saveGeom, saveSplitter, shortcut, showInfo, tooltip, tr, ) from aqt.webview import AnkiWebView, AnkiWebViewKind class CardLayout(QDialog): def __init__( self, mw: AnkiQt, note: Note, ord: int = 0, parent: QWidget | None = None, fill_empty: bool = False, ) -> None: QDialog.__init__(self, parent or mw, Qt.WindowType.Window) mw.garbage_collect_on_dialog_finish(self) self.mw = aqt.mw self.note = note self.ord = ord self.col = self.mw.col.weakref() self.mm = self.mw.col.models note_type = note.note_type() assert note_type is not None self.model = note_type self.templates = self.model["tmpls"] self.fill_empty_action_toggled = fill_empty self.night_mode_is_enabled = theme_manager.night_mode self.mobile_emulation_enabled = False self.have_autoplayed = False self.mm._remove_from_cache(self.model["id"]) self.change_tracker = ChangeTracker(self.mw) self.setupTopArea() self.setupMainArea() self.setupButtons() self.setupShortcuts() self.setWindowTitle( without_unicode_isolation( tr.card_templates_card_types_for(val=self.model["name"]) ) ) disable_help_button(self) v1 = QVBoxLayout() v1.addWidget(self.topArea) v1.addWidget(self.mainArea) v1.addLayout(self.buttons) v1.setContentsMargins(12, 12, 12, 12) self.setLayout(v1) gui_hooks.card_layout_will_show(self) self.redraw_everything() restoreGeom(self, "CardLayout") restoreSplitter(self.mainArea, "CardLayoutMainArea") self.setWindowModality(Qt.WindowModality.ApplicationModal) self.show() # take the focus away from the first input area when starting up, # as users tend to accidentally type into the template self.setFocus() def redraw_everything(self) -> None: self.ignore_change_signals = True self.updateTopArea() self.ignore_change_signals = False self.update_current_ordinal_and_redraw(self.ord) def update_current_ordinal_and_redraw(self, idx: int) -> None: if self.ignore_change_signals: return self.ord = idx self.have_autoplayed = False self.fill_fields_from_template() self.renderPreview() def _isCloze(self) -> bool: return self.model["type"] == MODEL_CLOZE # Top area ########################################################################## def setupTopArea(self) -> None: self.topArea = QWidget() self.topArea.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum ) self.topAreaForm = aqt.forms.clayout_top.Ui_Form() self.topAreaForm.setupUi(self.topArea) self.topAreaForm.templateOptions.setText( f"{tr.actions_options()} {downArrow()}" ) qconnect(self.topAreaForm.templateOptions.clicked, self.onMore) qconnect( self.topAreaForm.templatesBox.currentIndexChanged, self.update_current_ordinal_and_redraw, ) self.topAreaForm.card_type_label.setText(tr.card_templates_card_type()) def updateTopArea(self) -> None: self.updateCardNames() def updateCardNames(self) -> None: self.ignore_change_signals = True combo = self.topAreaForm.templatesBox combo.clear() combo.addItems( self._summarizedName(idx, tmpl) for (idx, tmpl) in enumerate(self.templates) ) combo.setCurrentIndex(self.ord) combo.setEnabled(not self._isCloze()) self.ignore_change_signals = False def _summarizedName(self, idx: int, tmpl: dict) -> str: return "{}: {}: {} -> {}".format( idx + 1, tmpl["name"], self._fieldsOnTemplate(tmpl["qfmt"]), self._fieldsOnTemplate(tmpl["afmt"]), ) def _fieldsOnTemplate(self, fmt: str) -> str: fmt_without_comments = re.sub("", "", fmt) matches = re.findall("{{[^#/}]+?}}", fmt_without_comments) chars_allowed = 30 field_names: list[str] = [] for m in matches: # strip off mustache m = re.sub(r"[{}]", "", m) # strip off modifiers m = m.split(":")[-1] # don't show 'FrontSide' if m == "FrontSide": continue field_names.append(m) chars_allowed -= len(m) if chars_allowed <= 0: break s = "+".join(field_names) if chars_allowed <= 0: s += "+..." return s def setupShortcuts(self) -> None: self.tform.front_button.setToolTip(shortcut("Ctrl+1")) self.tform.back_button.setToolTip(shortcut("Ctrl+2")) self.tform.style_button.setToolTip(shortcut("Ctrl+3")) QShortcut( # type: ignore QKeySequence("Ctrl+1"), self, activated=self.tform.front_button.click, ) QShortcut( # type: ignore QKeySequence("Ctrl+2"), self, activated=self.tform.back_button.click, ) QShortcut( # type: ignore QKeySequence("Ctrl+3"), self, activated=self.tform.style_button.click, ) QShortcut( # type: ignore QKeySequence("F3"), self, activated=lambda: ( self.update_current_ordinal_and_redraw(self.ord - 1) if self.ord - 1 > -1 else None ), ) QShortcut( # type: ignore QKeySequence("F4"), self, activated=lambda: ( self.update_current_ordinal_and_redraw(self.ord + 1) if self.ord + 1 < len(self.templates) else None ), ) for i in range(min(len(self.cloze_numbers), 9)): QShortcut( # type: ignore QKeySequence(f"Alt+{i + 1}"), self, activated=lambda n=i: self.pform.cloze_number_combo.setCurrentIndex(n), ) # Main area setup ########################################################################## def setupMainArea(self) -> None: split = self.mainArea = QSplitter() split.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) split.setOrientation(Qt.Orientation.Horizontal) left = QWidget() tform = self.tform = aqt.forms.template.Ui_Form() tform.setupUi(left) self.setup_edit_area() split.addWidget(left) split.setCollapsible(0, False) right = QWidget() self.pform = aqt.forms.preview.Ui_Form() pform = self.pform pform.setupUi(right) pform.preview_front.setText(tr.card_templates_front_preview()) pform.preview_back.setText(tr.card_templates_back_preview()) pform.preview_box.setTitle(tr.card_templates_preview_box()) self.setup_preview() split.addWidget(right) split.setCollapsible(1, False) def setup_edit_area(self) -> None: tform = self.tform editor = tform.edit_area tform.front_button.setText(tr.card_templates_front_template()) tform.back_button.setText(tr.card_templates_back_template()) tform.style_button.setText(tr.card_templates_template_styling()) tform.template_box.setTitle(tr.card_templates_template_box()) cnt = self.mw.col.models.use_count(self.model) tform.changes_affect_label.setText( self.col.tr.card_templates_changes_will_affect_notes(count=cnt) ) qconnect(editor.textChanged, self.write_edits_to_template_and_redraw) qconnect(tform.front_button.clicked, self.on_editor_toggled) qconnect(tform.back_button.clicked, self.on_editor_toggled) qconnect(tform.style_button.clicked, self.on_editor_toggled) self.current_editor_index = 0 editor.setAcceptRichText(False) font = QFont("Consolas") if not font.exactMatch(): font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) editor.setFont(font) tab_width = self.fontMetrics().horizontalAdvance(" " * 4) editor.setTabStopDistance(tab_width) palette = editor.palette() palette.setColor( QPalette.ColorGroup.Inactive, QPalette.ColorRole.Highlight, QColor("#4169e1" if theme_manager.night_mode else "#FFFF80"), ) palette.setColor( QPalette.ColorGroup.Inactive, QPalette.ColorRole.HighlightedText, QColor("#ffffff" if theme_manager.night_mode else "#000000"), ) editor.setPalette(palette) widg = tform.search_edit widg.setPlaceholderText("Search") qconnect(widg.textChanged, self.on_search_changed) qconnect(widg.returnPressed, self.on_search_next) def setup_cloze_number_box(self) -> None: names = (tr.card_templates_card(val=n) for n in self.cloze_numbers) self.pform.cloze_number_combo.addItems(names) try: idx = self.cloze_numbers.index(self.ord + 1) self.pform.cloze_number_combo.setCurrentIndex(idx) except ValueError: # invalid cloze pass qconnect( self.pform.cloze_number_combo.currentIndexChanged, self.on_change_cloze ) def on_change_cloze(self, idx: int) -> None: self.ord = self.cloze_numbers[idx] - 1 self.have_autoplayed = False self._renderPreview() def on_editor_toggled(self) -> None: if self.tform.front_button.isChecked(): self.current_editor_index = 0 self.pform.preview_front.setChecked(True) self.on_preview_toggled() self.add_field_button.setHidden(False) elif self.tform.back_button.isChecked(): self.current_editor_index = 1 self.pform.preview_back.setChecked(True) self.on_preview_toggled() self.add_field_button.setHidden(False) else: self.current_editor_index = 2 self.add_field_button.setHidden(True) self.fill_fields_from_template() def on_search_changed(self, text: str) -> None: editor = self.tform.edit_area if not editor.find(text): # try again from top cursor = editor.textCursor() cursor.movePosition(QTextCursor.MoveOperation.Start) editor.setTextCursor(cursor) if not editor.find(text): tooltip("No matches found.") def on_search_next(self) -> None: text = self.tform.search_edit.text() self.on_search_changed(text) def setup_preview(self) -> None: pform = self.pform self.preview_web = AnkiWebView(kind=AnkiWebViewKind.CARD_LAYOUT) pform.verticalLayout.addWidget(self.preview_web) pform.verticalLayout.setStretch(1, 99) pform.preview_front.isChecked() qconnect(pform.preview_front.clicked, self.on_preview_toggled) qconnect(pform.preview_back.clicked, self.on_preview_toggled) pform.preview_settings.setText( f"{tr.card_templates_preview_settings()} {downArrow()}" ) qconnect(pform.preview_settings.clicked, self.on_preview_settings) self.preview_web.stdHtml( self.mw.reviewer.revHtml(), css=["css/reviewer.css"], js=[ "js/mathjax.js", "js/vendor/mathjax/tex-chtml-full.js", "js/reviewer.js", ], context=self, ) self.preview_web.allow_drops = True self.preview_web.eval("_blockDefaultDragDropBehavior();") self.preview_web.set_bridge_command(self._on_bridge_cmd, self) gui_hooks.card_review_webview_did_init( self.preview_web, AnkiWebViewKind.CARD_LAYOUT ) if self._isCloze(): nums = list(self.note.cloze_numbers_in_fields()) if self.ord + 1 not in nums: # current card is empty nums.append(self.ord + 1) self.cloze_numbers = sorted(nums) self.setup_cloze_number_box() else: self.cloze_numbers = [] self.pform.cloze_number_combo.setHidden(True) def on_fill_empty_action_toggled(self) -> None: self.fill_empty_action_toggled = not self.fill_empty_action_toggled self.on_preview_toggled() def on_night_mode_action_toggled(self) -> None: self.night_mode_is_enabled = not self.night_mode_is_enabled force = json.dumps(self.night_mode_is_enabled) self.preview_web.eval( f"document.documentElement.classList.toggle('night-mode', {force});" ) self.on_preview_toggled() def on_mobile_class_action_toggled(self) -> None: self.mobile_emulation_enabled = not self.mobile_emulation_enabled self.on_preview_toggled() def on_preview_settings(self) -> None: m = QMenu(self) a = m.addAction(tr.card_templates_fill_empty()) assert a is not None a.setCheckable(True) a.setChecked(self.fill_empty_action_toggled) qconnect(a.triggered, self.on_fill_empty_action_toggled) if not self.note_has_empty_field(): a.setVisible(False) a = m.addAction(tr.card_templates_night_mode()) assert a is not None a.setCheckable(True) a.setChecked(self.night_mode_is_enabled) qconnect(a.triggered, self.on_night_mode_action_toggled) a = m.addAction(tr.card_templates_add_mobile_class()) assert a is not None a.setCheckable(True) a.setChecked(self.mobile_emulation_enabled) qconnect(a.toggled, self.on_mobile_class_action_toggled) m.popup(self.pform.preview_settings.mapToGlobal(QPoint(0, 0))) def on_preview_toggled(self) -> None: self.have_autoplayed = False self._renderPreview() def _on_bridge_cmd(self, cmd: str) -> Any: if cmd.startswith("play:"): play_clicked_audio(cmd, self.rendered_card) def note_has_empty_field(self) -> bool: for field in self.note.fields: if not field.strip(): # ignores HTML, but this should suffice return True return False # Buttons ########################################################################## def setupButtons(self) -> None: l = self.buttons = QHBoxLayout() help = QPushButton(tr.actions_help()) help.setAutoDefault(False) l.addWidget(help) qconnect(help.clicked, self.onHelp) l.addStretch() self.add_field_button = QPushButton(tr.fields_add_field()) self.add_field_button.setAutoDefault(False) l.addWidget(self.add_field_button) qconnect(self.add_field_button.clicked, self.onAddField) if not self._isCloze(): flip = QPushButton(tr.card_templates_flip()) flip.setAutoDefault(False) l.addWidget(flip) qconnect(flip.clicked, self.onFlip) l.addStretch() save = QPushButton(tr.actions_save()) save.setAutoDefault(False) save.setShortcut(QKeySequence("Ctrl+Return")) l.addWidget(save) qconnect(save.clicked, self.accept) close = QPushButton(tr.actions_cancel()) close.setAutoDefault(False) l.addWidget(close) qconnect(close.clicked, self.reject) # Reading/writing question/answer/css ########################################################################## def current_template(self) -> dict: if self._isCloze(): return self.templates[0] return self.templates[self.ord] def fill_fields_from_template(self) -> None: t = self.current_template() self.ignore_change_signals = True if self.current_editor_index == 0: text = t["qfmt"] elif self.current_editor_index == 1: text = t["afmt"] else: text = self.model["css"] self.tform.edit_area.setPlainText(text) self.ignore_change_signals = False def write_edits_to_template_and_redraw(self) -> None: if self.ignore_change_signals: return self.change_tracker.mark_basic() text = self.tform.edit_area.toPlainText() if self.current_editor_index == 0: self.current_template()["qfmt"] = text elif self.current_editor_index == 1: self.current_template()["afmt"] = text else: self.model["css"] = text self.renderPreview() # Preview ########################################################################## _previewTimer: QTimer | None = None def renderPreview(self) -> None: # schedule a preview when timing stops self.cancelPreviewTimer() self._previewTimer = self.mw.progress.timer( 200, self._renderPreview, False, parent=self ) def cancelPreviewTimer(self) -> None: if self._previewTimer: self._previewTimer.stop() self._previewTimer = None def _renderPreview(self) -> None: self.cancelPreviewTimer() c = self.rendered_card = self.note.ephemeral_card( self.ord, custom_note_type=self.model, custom_template=self.current_template(), fill_empty=self.fill_empty_action_toggled, ) ti = self.maybeTextInput bodyclass = theme_manager.body_classes_for_card_ord( c.ord, self.night_mode_is_enabled ) if self.pform.preview_front.isChecked(): q = ti(self.mw.prepare_card_text_for_display(c.question())) q = gui_hooks.card_will_show(q, c, "clayoutQuestion") text = q else: a = ti(self.mw.prepare_card_text_for_display(c.answer()), type="a") a = gui_hooks.card_will_show(a, c, "clayoutAnswer") text = a # use _showAnswer to avoid the longer delay self.preview_web.eval(f"_showAnswer({json.dumps(text)},'{bodyclass}');") self.preview_web.eval( f"_emulateMobile({json.dumps(self.mobile_emulation_enabled)});" ) if not self.have_autoplayed: self.have_autoplayed = True if c.autoplay(): self.preview_web.setPlaybackRequiresGesture(False) if self.pform.preview_front.isChecked(): audio = c.question_av_tags() else: audio = c.answer_av_tags() else: audio = [] self.preview_web.setPlaybackRequiresGesture(True) side = "question" if self.pform.preview_front.isChecked() else "answer" gui_hooks.av_player_will_play_tags( audio, side, self, ) av_player.play_tags(audio) self.updateCardNames() def maybeTextInput(self, txt: str, type: str = "q") -> str: if "[[type:" not in txt: return txt origLen = len(txt) txt = txt.replace("
", "") hadHR = origLen != len(txt) def answerRepl(match: Match) -> str: res = self.mw.col.compare_answer("example", "sample") if hadHR: res = f"
{res}" return res type_filter = r"\[\[type:.+?\]\]" repl: str | Callable if type == "q": repl = "" repl = f"
{repl}
" else: repl = answerRepl out = re.sub(type_filter, repl, txt, count=1) warning = f"
{tr.card_templates_type_boxes_warning()}
" return re.sub(type_filter, warning, out) # Card operations ###################################################################### def onRemove(self) -> None: if len(self.templates) < 2: showInfo(tr.card_templates_at_least_one_card_type_is()) return def get_count() -> int: ord = self.current_template()["ord"] return self.mm.template_use_count(self.model["id"], ord) def on_done(fut: Future) -> None: card_cnt = fut.result() template = self.current_template() cards = tr.card_templates_card_count(count=card_cnt) msg = tr.card_templates_delete_the_as_card_type_and( template=template["name"], # unlike most cases, 'cards' is a string in this message cards=cards, # type: ignore[arg-type] ) if not askUser(msg): return if not self.change_tracker.mark_schema(): return self.onRemoveInner(template) self.mw.taskman.with_progress(get_count, on_done) def onRemoveInner(self, template: dict) -> None: self.mm.remove_template(self.model, template) # ensure current ordinal is within bounds idx = self.ord if idx >= len(self.templates): self.ord = len(self.templates) - 1 self.redraw_everything() def onRename(self) -> None: template = self.current_template() name = getOnlyText(tr.actions_new_name(), default=template["name"]).replace( '"', "" ) if not name.strip(): return template["name"] = name self.redraw_everything() def onReorder(self) -> None: n = len(self.templates) template = self.current_template() current_pos = self.templates.index(template) + 1 pos_txt = getOnlyText( tr.card_templates_enter_new_card_position_1(val=n), default=str(current_pos), ) if not pos_txt: return try: pos = int(pos_txt) except ValueError: return if pos < 1 or pos > n: return if pos == current_pos: return new_idx = pos - 1 if not self.change_tracker.mark_schema(): return self.mm.reposition_template(self.model, template, new_idx) self.ord = new_idx self.redraw_everything() def _newCardName(self) -> str: n = len(self.templates) + 1 while 1: name = without_unicode_isolation(tr.card_templates_card(val=n)) if name not in [t["name"] for t in self.templates]: break n += 1 return name def onAddCard(self) -> None: cnt = self.mw.col.models.use_count(self.model) txt = tr.card_templates_this_will_create_card_proceed(count=cnt) if cnt and not askUser(txt): return if not self.change_tracker.mark_schema(): return name = self._newCardName() t = self.mm.new_template(name) old = self.current_template() t["qfmt"] = old["qfmt"] t["afmt"] = old["afmt"] self.mm.add_template(self.model, t) self.ord = len(self.templates) - 1 self.redraw_everything() def on_restore_to_default( self, force_kind: StockNotetype.Kind.V | None = None ) -> None: if force_kind is None and not self.model.get("originalStockKind", 0): SelectStockNotetype( mw=self.mw, on_success=lambda kind: self.on_restore_to_default(force_kind=kind), parent=self, ) return if not askUser( with_collapsed_whitespace( tr.card_templates_restore_to_default_confirmation() ), defaultno=True, ): return def on_success(changes: OpChanges) -> None: self.change_tracker.set_unchanged() self.close() showInfo(tr.card_templates_restored_to_default(), parent=self.mw) restore_notetype_to_stock( parent=self, notetype_id=self.model["id"], force_kind=force_kind ).success(on_success).run_in_background() def onFlip(self) -> None: old = self.current_template() self._flipQA(old, old) self.redraw_everything() def _flipQA(self, src: dict, dst: dict) -> None: m = re.match("(?s)(.+)
(.+)", src["afmt"]) if not m: showInfo(tr.card_templates_anki_couldnt_find_the_line_between()) return self.change_tracker.mark_basic() dst["afmt"] = "{{FrontSide}}\n\n
\n\n%s" % src["qfmt"] dst["qfmt"] = m.group(2).strip() def onCopyMarkdown(self) -> None: template = self.current_template() def sanitizeMarkdown(md): return md.replace("```", "\\`\\`\\`") markdown = ( f"## Front Template\n" "```html\n" f"{sanitizeMarkdown(template['qfmt'])}\n" "```\n" "## Back Template\n" "```html\n" f"{sanitizeMarkdown(template['afmt'])}\n" "```\n" "## Styling\n" "```css\n" f"{sanitizeMarkdown(self.model['css'])}\n" "```\n" ) clipboard = QApplication.clipboard() assert clipboard is not None clipboard.setText(markdown) tooltip(tr.about_copied_to_clipboard()) def onMore(self) -> None: m = QMenu(self) a = m.addAction( tr.actions_with_ellipsis(action=tr.card_templates_restore_to_default()) ) assert a is not None qconnect( a.triggered, lambda: self.on_restore_to_default(), ) if not self._isCloze(): a = m.addAction(tr.card_templates_add_card_type()) assert a is not None qconnect(a.triggered, self.onAddCard) a = m.addAction(tr.card_templates_remove_card_type()) assert a is not None qconnect(a.triggered, self.onRemove) a = m.addAction(tr.card_templates_rename_card_type()) assert a is not None qconnect(a.triggered, self.onRename) a = m.addAction(tr.card_templates_reposition_card_type()) assert a is not None qconnect(a.triggered, self.onReorder) m.addSeparator() t = self.current_template() if t["did"]: s = tr.card_templates_on() else: s = tr.card_templates_off() a = m.addAction(tr.card_templates_deck_override() + s) assert a is not None qconnect(a.triggered, self.onTargetDeck) a = m.addAction(tr.card_templates_copy_info()) assert a is not None qconnect(a.triggered, self.onCopyMarkdown) a = m.addAction(tr.card_templates_browser_appearance()) assert a is not None qconnect(a.triggered, self.onBrowserDisplay) m.popup(self.topAreaForm.templateOptions.mapToGlobal(QPoint(0, 0))) def onBrowserDisplay(self) -> None: d = QDialog() disable_help_button(d) f = aqt.forms.browserdisp.Ui_Dialog() f.setupUi(d) t = self.current_template() f.qfmt.setText(t.get("bqfmt", "")) f.afmt.setText(t.get("bafmt", "")) if t.get("bfont"): f.overrideFont.setChecked(True) f.font.setCurrentFont(QFont(t.get("bfont") or "Arial")) f.fontSize.setValue(t.get("bsize") or 12) qconnect(f.buttonBox.accepted, lambda: self.onBrowserDisplayOk(f)) d.exec() def onBrowserDisplayOk(self, f: browserdisp.Ui_Dialog) -> None: t = self.current_template() self.change_tracker.mark_basic() t["bqfmt"] = f.qfmt.text().strip() t["bafmt"] = f.afmt.text().strip() if f.overrideFont.isChecked(): t["bfont"] = f.font.currentFont().family() t["bsize"] = f.fontSize.value() else: for key in ("bfont", "bsize"): if key in t: del t[key] def onTargetDeck(self) -> None: from aqt.tagedit import TagEdit t = self.current_template() d = QDialog(self) d.setWindowTitle("Anki") disable_help_button(d) d.setMinimumWidth(400) l = QVBoxLayout() lab = QLabel( tr.card_templates_enter_deck_to_place_new(val="%s") % self.current_template()["name"] ) lab.setWordWrap(True) l.addWidget(lab) te = TagEdit(d, type=1) te.setCol(self.col) l.addWidget(te) if t["did"]: deck = self.col.decks.get(t["did"]) assert deck is not None te.setText(deck["name"]) te.selectAll() bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) qconnect(bb.rejected, d.close) l.addWidget(bb) d.setLayout(l) d.exec() self.change_tracker.mark_basic() if not te.text().strip(): t["did"] = None else: t["did"] = self.col.decks.id(te.text()) def onAddField(self) -> None: diag = QDialog(self) form = aqt.forms.addfield.Ui_Dialog() form.setupUi(diag) disable_help_button(diag) fields = [f["name"] for f in self.model["flds"]] form.fields.addItems(fields) form.fields.setCurrentRow(0) form.font.setCurrentFont(QFont("Arial")) form.size.setValue(20) if not diag.exec(): return row = form.fields.currentIndex().row() if row >= 0: self._addField( fields[row], form.font.currentFont().family(), form.size.value(), ) def _addField(self, field: str, font: str, size: int) -> None: text = self.tform.edit_area.toPlainText() text += ( "\n
{{%s}}
\n" % ( font, size, field, ) ) self.tform.edit_area.setPlainText(text) self.change_tracker.mark_basic() self.write_edits_to_template_and_redraw() # Closing & Help ###################################################################### def accept(self) -> None: def on_done(changes: OpChanges) -> None: tooltip(tr.card_templates_changes_saved(), parent=self.parentWidget()) self.cleanup() gui_hooks.sidebar_should_refresh_notetypes() QDialog.accept(self) update_notetype_legacy(parent=self, notetype=self.model).success( on_done ).run_in_background() def reject(self) -> None: def _reject() -> None: self.cleanup() QDialog.reject(self) def callback(choice: int) -> None: if choice == 0: self.accept() elif choice == 1: _reject() if self.change_tracker.changed(): ask_user_dialog( text=tr.card_templates_discard_changes(), callback=callback, buttons=[ QMessageBox.StandardButton.Save, QMessageBox.StandardButton.Discard, QMessageBox.StandardButton.Cancel, ], default_button=2, parent=self, ) else: _reject() def cleanup(self) -> None: self.cancelPreviewTimer() av_player.stop_and_clear_queue() saveGeom(self, "CardLayout") saveSplitter(self.mainArea, "CardLayoutMainArea") self.preview_web.cleanup() self.preview_web = None # type: ignore self.model = None # type: ignore self.rendered_card = None # type: ignore self.mw = None # type: ignore def onHelp(self) -> None: openHelp(HelpPage.TEMPLATES) class SelectStockNotetype(QDialog): def __init__( self, mw: AnkiQt, on_success: Callable[[StockNotetype.Kind.V], None], parent: QWidget, ) -> None: self.mw = mw QDialog.__init__(self, parent, Qt.WindowType.Window) self.dialog = aqt.forms.addmodel.Ui_Dialog() self.dialog.setupUi(self) self.setWindowTitle("Anki") self.setWindowModality(Qt.WindowModality.ApplicationModal) disable_help_button(self) stock_types = stdmodels.get_stock_notetypes(mw.col) for name, func in stock_types: item = QListWidgetItem(name) self.dialog.models.addItem(item) self.dialog.models.setCurrentRow(0) # the list widget will swallow the enter key s = QShortcut(QKeySequence("Return"), self) qconnect(s.activated, self.accept) # help # self.dialog.buttonBox.standardButton(QDialogButtonBox.StandardButton.Help). self.on_success = on_success self.show() def reject(self) -> None: QDialog.reject(self) def accept(self) -> None: kind = cast(StockNotetype.Kind.ValueType, self.dialog.models.currentRow()) QDialog.accept(self) # On Mac, we need to allow time for the existing modal to close or # Qt gets confused. self.mw.progress.single_shot(100, lambda: self.on_success(kind), True) ================================================ FILE: qt/aqt/colors.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from _aqt.colors import * ================================================ FILE: qt/aqt/customstudy.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import aqt import aqt.forms import aqt.operations from anki.collection import Collection from anki.consts import * from anki.decks import DeckId from anki.scheduler import CustomStudyRequest from anki.scheduler.base import CustomStudyDefaults from aqt.operations import QueryOp from aqt.operations.scheduling import custom_study from aqt.qt import * from aqt.taglimit import TagLimit from aqt.utils import disable_help_button, tr RADIO_NEW = 1 RADIO_REV = 2 RADIO_FORGOT = 3 RADIO_AHEAD = 4 RADIO_PREVIEW = 5 RADIO_CRAM = 6 TYPE_NEW = 0 TYPE_DUE = 1 TYPE_REVIEW = 2 TYPE_ALL = 3 class CustomStudy(QDialog): @staticmethod def fetch_data_and_show(mw: aqt.AnkiQt) -> None: def fetch_data( col: Collection, ) -> tuple[DeckId, CustomStudyDefaults, Any]: deck_id = mw.col.decks.get_current_id() defaults = col.sched.custom_study_defaults(deck_id) card_count = col.decks.card_count(deck_id, True) return (deck_id, defaults, card_count) def show_dialog(data: tuple[DeckId, CustomStudyDefaults, Any]) -> None: deck_id, defaults, card_count = data CustomStudy( mw=mw, deck_id=deck_id, card_count=card_count, defaults=defaults ) QueryOp( parent=mw, op=fetch_data, success=show_dialog ).with_progress().run_in_background() def __init__( self, mw: aqt.AnkiQt, deck_id: DeckId, card_count: Any, defaults: CustomStudyDefaults, ) -> None: "Don't call this directly; use CustomStudy.fetch_data_and_show()." QDialog.__init__(self, mw) self.mw = mw self.deck_id = deck_id self.card_count = card_count self.defaults = defaults self.form = aqt.forms.customstudy.Ui_Dialog() self.form.setupUi(self) disable_help_button(self) self.setupSignals() self.form.radioNew.click() self.open() def setupSignals(self) -> None: f = self.form qconnect(f.radioNew.clicked, lambda: self.onRadioChange(RADIO_NEW)) qconnect(f.radioRev.clicked, lambda: self.onRadioChange(RADIO_REV)) qconnect(f.radioForgot.clicked, lambda: self.onRadioChange(RADIO_FORGOT)) qconnect(f.radioAhead.clicked, lambda: self.onRadioChange(RADIO_AHEAD)) qconnect(f.radioPreview.clicked, lambda: self.onRadioChange(RADIO_PREVIEW)) qconnect(f.radioCram.clicked, lambda: self.onRadioChange(RADIO_CRAM)) qconnect(f.spin.valueChanged, self.setTextAfterSpinner) def count_with_children(self, parent: int, children: int) -> str: if children: return f"{parent} {tr.custom_study_available_child_count(children)}" else: return str(parent) def onRadioChange(self, idx: int) -> None: self.radioIdx = idx form = self.form min_spinner_value = 1 max_spinner_value = DYN_MAX_SIZE current_spinner_value = 1 title_text = "" show_cram_type = False enable_ok_button = self.card_count is not None and self.card_count > 0 ok = tr.custom_study_ok() if idx == RADIO_NEW: title_text = tr.custom_study_available_new_cards_2( count_string=self.count_with_children( self.defaults.available_new, self.defaults.available_new_in_children, ), ) text_before_spinner = tr.custom_study_increase_todays_new_card_limit_by() current_spinner_value = self.defaults.extend_new min_spinner_value = -DYN_MAX_SIZE enable_ok_button = True elif idx == RADIO_REV: title_text = tr.custom_study_available_review_cards_2( count_string=self.count_with_children( self.defaults.available_review, self.defaults.available_review_in_children, ), ) text_before_spinner = tr.custom_study_increase_todays_review_limit_by() current_spinner_value = self.defaults.extend_review min_spinner_value = -DYN_MAX_SIZE enable_ok_button = True elif idx == RADIO_FORGOT: text_before_spinner = tr.custom_study_review_cards_forgotten_in_last() max_spinner_value = 30 elif idx == RADIO_AHEAD: text_before_spinner = tr.custom_study_review_ahead_by() elif idx == RADIO_PREVIEW: text_before_spinner = tr.custom_study_preview_new_cards_added_in_the() current_spinner_value = 1 elif idx == RADIO_CRAM: text_before_spinner = tr.custom_study_select() ok = tr.custom_study_choose_tags() current_spinner_value = 100 show_cram_type = True else: assert 0 form.spin.setVisible(True) form.cardType.setVisible(show_cram_type) form.title.setText(title_text) form.title.setVisible(bool(title_text)) form.spin.setMinimum(min_spinner_value) form.spin.setMaximum(max_spinner_value) if max_spinner_value > 0: form.spin.setEnabled(True) else: form.spin.setEnabled(False) form.spin.setValue(current_spinner_value) form.preSpin.setText(text_before_spinner) self.setTextAfterSpinner(current_spinner_value) ok_button = form.buttonBox.button(QDialogButtonBox.StandardButton.Ok) assert ok_button is not None ok_button.setText(ok) ok_button.setEnabled(enable_ok_button) def setTextAfterSpinner(self, newSpinValue) -> None: form = self.form text_after_spinner = "" if self.radioIdx == RADIO_NEW: text_after_spinner = tr.custom_study_cards(count=newSpinValue) elif self.radioIdx == RADIO_REV: text_after_spinner = tr.custom_study_cards(count=newSpinValue) elif self.radioIdx == RADIO_FORGOT: text_after_spinner = tr.custom_study_days(count=newSpinValue) elif self.radioIdx == RADIO_AHEAD: text_after_spinner = tr.custom_study_days(count=newSpinValue) elif self.radioIdx == RADIO_PREVIEW: text_after_spinner = tr.custom_study_days(count=newSpinValue) elif self.radioIdx == RADIO_CRAM: text_after_spinner = tr.custom_study_cards_from_the_deck(count=newSpinValue) else: assert 0 form.postSpin.setText(text_after_spinner) def accept(self) -> None: request = CustomStudyRequest(deck_id=self.deck_id) if self.radioIdx == RADIO_NEW: request.new_limit_delta = self.form.spin.value() elif self.radioIdx == RADIO_REV: request.review_limit_delta = self.form.spin.value() elif self.radioIdx == RADIO_FORGOT: request.forgot_days = self.form.spin.value() elif self.radioIdx == RADIO_AHEAD: request.review_ahead_days = self.form.spin.value() elif self.radioIdx == RADIO_PREVIEW: request.preview_days = self.form.spin.value() else: request.cram.card_limit = self.form.spin.value() cram_type = self.form.cardType.currentRow() if cram_type == TYPE_NEW: request.cram.kind = CustomStudyRequest.Cram.CRAM_KIND_NEW elif cram_type == TYPE_DUE: request.cram.kind = CustomStudyRequest.Cram.CRAM_KIND_DUE elif cram_type == TYPE_REVIEW: request.cram.kind = CustomStudyRequest.Cram.CRAM_KIND_REVIEW else: request.cram.kind = CustomStudyRequest.Cram.CRAM_KIND_ALL def on_done(include: list[str], exclude: list[str]) -> None: request.cram.tags_to_include.extend(include) request.cram.tags_to_exclude.extend(exclude) self._create_and_close(request) # continues in background TagLimit(self, self.defaults.tags, on_done) return # other cases are synchronous self._create_and_close(request) def _create_and_close(self, request: CustomStudyRequest) -> None: # keep open on failure, as the cause was most likely an empty search # result, which the user can remedy custom_study(parent=self, request=request).success( lambda _: QDialog.accept(self) ).run_in_background() ================================================ FILE: qt/aqt/data/web/css/addonconf.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ body { margin: 5px; font-size: 13px; } ================================================ FILE: qt/aqt/data/web/css/deckbrowser.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @use "../../../../../ts/lib/sass/root-vars"; @use "../../../../../ts/lib/sass/vars" as *; @use "../../../../../ts/lib/sass/card-counts"; @use "../../../../../ts/lib/sass/elevation" as *; table { padding: 1rem; .fancy & { border: 1px solid var(--border-subtle); border-radius: var(--border-radius-medium); @include elevation(1, $opacity-boost: -0.08); &:hover { @include elevation(2); } background: var(--canvas-glass); } } a.deck { color: color(fg); text-decoration: none; min-width: 5em; display: inline-block; } a.deck:hover { text-decoration: underline; } th { border-bottom: 1px solid color(border-subtle); padding-bottom: 5px; } tr.deck td { padding: 1px 12px; border-bottom: 1px solid var(--border-subtle); .fancy & { border: unset; padding: 4px 12px; } } tr.top-level-drag-row td { border-bottom: 1px solid transparent; } td { white-space: nowrap; } tr.drag-hover td { border-bottom: 1px solid color(border); } body { margin: 2em 1em 1em 1em; -webkit-user-select: none; } .current, tr:hover:not(.top-level-drag-row) { td { background: color(border-subtle); &:first-child { border-top-left-radius: prop(border-radius-medium); border-bottom-left-radius: prop(border-radius-medium); } &:last-child { border-top-right-radius: prop(border-radius-medium); border-bottom-right-radius: prop(border-radius-medium); } .gears { visibility: visible; } } } [dir="rtl"] { .current, tr:hover:not(.top-level-drag-row) { td { background: color(canvas-inset); &:first-child { border-top-left-radius: 0; border-bottom-left-radius: 0; border-top-right-radius: prop(border-radius-medium); border-bottom-right-radius: prop(border-radius-medium); } &:last-child { border-top-right-radius: 0; border-bottom-right-radius: 0; border-top-left-radius: prop(border-radius-medium); border-bottom-left-radius: prop(border-radius-medium); } } } } .decktd { min-width: 15em; max-width: calc(100vw - 300px); overflow: hidden; } .count { min-width: 4em; text-align: right; } .optscol { width: 2em; } .collapse { color: color(fg); text-decoration: none; display: inline-block; width: 1em; } .filtered { color: color(fg-link) !important; } .gears { width: 1em; height: 1em; opacity: 0.5; padding-top: 0.2em; cursor: pointer; visibility: hidden; } .nightMode { .gears { filter: invert(180); } } .callout { background: color(border); padding: 1em; margin: 1em; div { margin: 1em; } } #studiedToday { margin: 2em 0; } ================================================ FILE: qt/aqt/data/web/css/overview.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @use "../../../../../ts/lib/sass/root-vars"; @use "../../../../../ts/lib/sass/vars" as *; @use "../../../../../ts/lib/sass/card-counts"; @use "../../../../../ts/lib/sass/button-mixins" as button; .smallLink { font-size: 10px; } h3 { margin-bottom: 0; } .descfont { padding: 1em; color: color(fg-subtle); } .description { white-space: pre-wrap; } #fulldesc { display: none; } .descmid { width: 70%; margin: 0 auto 0; text-align: left; } .dyn { text-align: center; } #study { @include button.base($primary: true); } ================================================ FILE: qt/aqt/data/web/css/reviewer-bottom.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @use "../../../../../ts/lib/sass/root-vars"; @use "../../../../../ts/lib/sass/vars" as *; @use "../../../../../ts/lib/sass/card-counts"; :root { --focus-color: #{palette-of(border-focus)}; .isMac { --focus-color: rgba(0 103 244 / 0.247); } } body { margin: 0; padding: 0; } #middle td[align="center"] { padding-top: 10px; position: relative; } button { min-width: 60px; white-space: nowrap; margin: 9px; position: relative; } .hitem { margin-top: 2px; } .stat { padding-top: 10px; @media (max-width: 583px) { display: none; } } .stat2 { padding-top: 10px; font-weight: normal; } :focus { border-color: color(border-focus); } .nobold, .stattxt { position: absolute; white-space: nowrap; font-size: small; top: -3px; left: 50%; transform: translate(-50%, -100%); font-weight: normal; display: inline-block; } .spacer { height: 18px; } .spacer2 { height: 16px; } #outer { border-top: 1px solid color(border); /* Better compatibility with graphics pad/touchscreen */ -webkit-user-select: none; } .nightMode { #outer { border-top-color: color(border-subtle); } } ================================================ FILE: qt/aqt/data/web/css/toolbar-bottom.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ body { overflow: hidden; } #header { border-bottom: 0; margin-top: 0; padding: 9px; } ================================================ FILE: qt/aqt/data/web/css/toolbar.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @use "../../../../../ts/lib/sass/root-vars"; @use "../../../../../ts/lib/sass/vars" as *; @use "../../../../../ts/lib/sass/elevation" as *; @use "../../../../../ts/lib/sass/button-mixins" as button; .header { display: grid; grid-template-columns: repeat(3, 1fr); align-items: start; align-content: space-between; body:not(.fancy) & { border-bottom: 1px solid var(--border-subtle); } } .left-tray { justify-self: start; } .right-tray { justify-self: end; } .left-tray, .right-tray { align-self: start; display: flex; flex-direction: row; align-items: start; } .toolbar { justify-self: center; white-space: nowrap; .fancy & { transition: all var(--transition) ease-in-out; } } .hitem { font-weight: bold; padding: 5px 12px; color: color(fg); display: inline-block; &:hover { text-decoration: underline; } } body { margin: 0; padding: 0; -webkit-user-select: none; overflow: hidden; &:not(.fancy).hidden { opacity: 0; } &.fancy { margin-bottom: 5px; &.hidden { transform: translateY(-100vh); } transition: transform var(--transition) ease-in-out; .toolbar { overflow: hidden; border-bottom-left-radius: prop(border-radius-medium); border-bottom-right-radius: prop(border-radius-medium); @include elevation(1, $opacity-boost: -0.1); // glass effect background: var(--canvas-glass); backdrop-filter: blur(var(--blur)); } // elevated state (deck browser, overview) &:not(.flat) .toolbar { background: var(--canvas-elevated); @include elevation(1); &:hover { @include elevation(2); } } &:not(.flat) .hitem { @include button.base($border: false, $with-hover: false); background: var(--canvas-glass); border: 1px solid transparent; } .hitem { text-decoration: none; border: 1px solid transparent; &:hover { border: 1px solid var(--border-subtle); } &:active { background: var(--canvas-inset); } &:first-child { padding-left: 18px; } &:last-child { padding-right: 18px; } } } } * { -webkit-user-drag: none; } .hitem:focus { outline: 0; } @keyframes spin { 0% { -webkit-transform: rotate(0deg); } 100% { -webkit-transform: rotate(360deg); } } .spin { width: 16px !important; animation: spin; animation-duration: 2s; animation-iteration-count: infinite; display: inline-block; visibility: visible !important; animation-timing-function: linear; transition: all var(--transition) ease-in; } #sync-spinner { height: 16px; margin-bottom: -3px; visibility: hidden; width: 0; } .normal-sync { color: color(state-new) !important; } .full-sync { color: color(state-learn) !important; } ================================================ FILE: qt/aqt/data/web/css/webview.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @use "../../../../../ts/lib/sass/root-vars"; @use "../../../../../ts/lib/sass/scrollbar"; @use "../../../../../ts/lib/sass/buttons"; * { // border-box would be better, but we need to // keep the old behaviour for now to avoid breaking // add-ons/card templates box-sizing: content-box; } body { color: var(--fg); background: var(--canvas); &.fancy { transition: opacity var(--transition-medium) ease-out; } margin: 2em; overscroll-behavior: none; &:not(.isMac), &:not(.isMac) * { @include scrollbar.custom; } &.no-blur * { backdrop-filter: none !important; } } a { color: var(--fg-link); text-decoration: none; } h1 { margin-bottom: 0.2em; } ================================================ FILE: qt/aqt/data/web/js/deckbrowser.ts ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ $(init); function init() { $("tr.deck").draggable({ scroll: false, // can't use "helper: 'clone'" because of a bug in jQuery 1.5 helper: function(_event) { return $(this).clone(false); }, delay: 200, opacity: 0.7, }); $("tr.deck").droppable({ drop: handleDropEvent, hoverClass: "drag-hover", }); $("tr.top-level-drag-row").droppable({ drop: handleDropEvent, hoverClass: "drag-hover", }); } function handleDropEvent(event, ui) { const draggedDeckId = ui.draggable.attr("id"); const ontoDeckId = $(this).attr("id") || ""; pycmd("drag:" + draggedDeckId + "," + ontoDeckId); } ================================================ FILE: qt/aqt/data/web/js/pycmd.d.ts ================================================ declare function pycmd(cmd: string, result_callback?: (arg: unknown) => void): unknown; ================================================ FILE: qt/aqt/data/web/js/reviewer-bottom.ts ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ /* eslint @typescript-eslint/no-unused-vars: "off", */ let time: number; // set in python code let timerStopped = false; let maxTime = 0; function updateTime(): void { const timeNode = document.getElementById("time"); if (maxTime === 0) { timeNode.textContent = ""; return; } time = Math.min(maxTime, time); const m = Math.floor(time / 60); const s = time % 60; const sStr = String(s).padStart(2, "0"); const timeString = `${m}:${sStr}`; if (maxTime === time) { timeNode.innerHTML = `${timeString}`; } else { timeNode.textContent = timeString; } } let intervalId: number | undefined; function showQuestion(txt: string, maxTime_: number): void { showAnswer(txt); time = 0; maxTime = maxTime_; updateTime(); if (intervalId !== undefined) { clearInterval(intervalId); } intervalId = setInterval(function() { if (!timerStopped) { time += 1; updateTime(); } }, 1000); } function showAnswer(txt: string, stopTimer = false): void { document.getElementById("middle").innerHTML = txt; timerStopped = stopTimer; } function selectedAnswerButton(): string { const node = document.activeElement as HTMLElement; if (!node) { return; } return node.dataset.ease; } ================================================ FILE: qt/aqt/data/web/js/toolbar.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-unused-vars: "off", */ enum SyncState { NoChanges = 0, Normal, Full, } function updateSyncColor(state: SyncState) { const elem = document.getElementById("sync"); switch (state) { case SyncState.NoChanges: elem.classList.remove("full-sync", "normal-sync"); break; case SyncState.Normal: elem.classList.add("normal-sync"); elem.classList.remove("full-sync"); break; case SyncState.Full: elem.classList.add("full-sync"); elem.classList.remove("normal-sync"); break; } } // Dealing with legacy add-ons that used CSS to absolutely position // themselves at toolbar edges function isAbsolutelyPositioned(node: Node): boolean { if (!(node instanceof HTMLElement)) { return false; } return getComputedStyle(node).position === "absolute"; } function isLegacyAddonElement(node: Node): boolean { if (isAbsolutelyPositioned(node)) { return true; } for (const child of node.childNodes) { if (isAbsolutelyPositioned(child)) { return true; } } return false; } function getElementDimensions(element: HTMLElement): [number, number] { const widths = [element.offsetWidth]; const heights = [element.offsetHeight]; // Some add-ons inject spans or anchors into the toolbar whose dimensions, // as reported by the properties above are zero, but still occupy space due // to their child elements: for (const child of element.childNodes) { if (!(child instanceof HTMLElement)) { continue; } widths.push(child.offsetWidth); heights.push(child.offsetHeight); } return [Math.max(...widths), Math.max(...heights)]; } function moveLegacyAddonsToTray() { const rightTray = document.getElementsByClassName("right-tray")[0]; const toolbarChildren = document.querySelectorAll(".toolbar > *"); const legacyAddonElements: HTMLElement[] = Array.from(toolbarChildren) .reverse() // restore original add-on load order .filter(isLegacyAddonElement); for (const element of legacyAddonElements) { const wrapperElement = document.createElement("div"); const dimensions = getElementDimensions(element); element.style.right = "0px"; // remove manual padding wrapperElement.append(element); wrapperElement.style.cssText = `\ width: ${dimensions[0]}px; height: ${dimensions[1]}}px; margin-left: 5px; margin-right: 5px; position: relative;`; wrapperElement.className = "tray-item tray-item-legacy"; rightTray.append(wrapperElement); } } document.addEventListener("DOMContentLoaded", moveLegacyAddonsToTray); ================================================ FILE: qt/aqt/data/web/js/tsconfig.json ================================================ { "extends": "../../../../../ts/tsconfig_legacy.json", "include": ["*.ts"], "references": [], "compilerOptions": { "target": "es6", "module": "commonjs", "lib": ["es2019", "dom", "dom.iterable"], "types": ["jquery", "jqueryui"], "strict": true, "isolatedModules": false, "noImplicitAny": false, "strictNullChecks": false, "strictPropertyInitialization": false, "noImplicitThis": false, "esModuleInterop": true } } ================================================ FILE: qt/aqt/data/web/js/vendor/plot.js ================================================ /* Javascript plotting library for jQuery, version 0.8.3. Copyright (c) 2007-2014 IOLA and Ole Laursen. Licensed under the MIT license. */ (function($){$.color={};$.color.make=function(r,g,b,a){var o={};o.r=r||0;o.g=g||0;o.b=b||0;o.a=a!=null?a:1;o.add=function(c,d){for(var i=0;i=1){return"rgb("+[o.r,o.g,o.b].join(",")+")"}else{return"rgba("+[o.r,o.g,o.b,o.a].join(",")+")"}};o.normalize=function(){function clamp(min,value,max){return valuemax?max:value}o.r=clamp(0,parseInt(o.r),255);o.g=clamp(0,parseInt(o.g),255);o.b=clamp(0,parseInt(o.b),255);o.a=clamp(0,o.a,1);return o};o.clone=function(){return $.color.make(o.r,o.b,o.g,o.a)};return o.normalize()};$.color.extract=function(elem,css){var c;do{c=elem.css(css).toLowerCase();if(c!=""&&c!="transparent")break;elem=elem.parent()}while(elem.length&&!$.nodeName(elem.get(0),"body"));if(c=="rgba(0, 0, 0, 0)")c="transparent";return $.color.parse(c)};$.color.parse=function(str){var res,m=$.color.make;if(res=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(str))return m(parseInt(res[1],10),parseInt(res[2],10),parseInt(res[3],10));if(res=/rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))return m(parseInt(res[1],10),parseInt(res[2],10),parseInt(res[3],10),parseFloat(res[4]));if(res=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(str))return m(parseFloat(res[1])*2.55,parseFloat(res[2])*2.55,parseFloat(res[3])*2.55);if(res=/rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))return m(parseFloat(res[1])*2.55,parseFloat(res[2])*2.55,parseFloat(res[3])*2.55,parseFloat(res[4]));if(res=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(str))return m(parseInt(res[1],16),parseInt(res[2],16),parseInt(res[3],16));if(res=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(str))return m(parseInt(res[1]+res[1],16),parseInt(res[2]+res[2],16),parseInt(res[3]+res[3],16));var name=$.trim(str).toLowerCase();if(name=="transparent")return m(255,255,255,0);else{res=lookupColors[name]||[0,0,0];return m(res[0],res[1],res[2])}};var lookupColors={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0]}})(jQuery);(function($){var hasOwnProperty=Object.prototype.hasOwnProperty;if(!$.fn.detach){$.fn.detach=function(){return this.each(function(){if(this.parentNode){this.parentNode.removeChild(this)}})}}function Canvas(cls,container){var element=container.children("."+cls)[0];if(element==null){element=document.createElement("canvas");element.className=cls;$(element).css({direction:"ltr",position:"absolute",left:0,top:0}).appendTo(container);if(!element.getContext){if(window.G_vmlCanvasManager){element=window.G_vmlCanvasManager.initElement(element)}else{throw new Error("Canvas is not available. If you're using IE with a fall-back such as Excanvas, then there's either a mistake in your conditional include, or the page has no DOCTYPE and is rendering in Quirks Mode.")}}}this.element=element;var context=this.context=element.getContext("2d");var devicePixelRatio=window.devicePixelRatio||1,backingStoreRatio=context.webkitBackingStorePixelRatio||context.mozBackingStorePixelRatio||context.msBackingStorePixelRatio||context.oBackingStorePixelRatio||context.backingStorePixelRatio||1;this.pixelRatio=devicePixelRatio/backingStoreRatio;this.resize(container.width(),container.height());this.textContainer=null;this.text={};this._textCache={}}Canvas.prototype.resize=function(width,height){if(width<=0||height<=0){throw new Error("Invalid dimensions for plot, width = "+width+", height = "+height)}var element=this.element,context=this.context,pixelRatio=this.pixelRatio;if(this.width!=width){element.width=width*pixelRatio;element.style.width=width+"px";this.width=width}if(this.height!=height){element.height=height*pixelRatio;element.style.height=height+"px";this.height=height}context.restore();context.save();context.scale(pixelRatio,pixelRatio)};Canvas.prototype.clear=function(){this.context.clearRect(0,0,this.width,this.height)};Canvas.prototype.render=function(){var cache=this._textCache;for(var layerKey in cache){if(hasOwnProperty.call(cache,layerKey)){var layer=this.getTextLayer(layerKey),layerCache=cache[layerKey];layer.hide();for(var styleKey in layerCache){if(hasOwnProperty.call(layerCache,styleKey)){var styleCache=layerCache[styleKey];for(var key in styleCache){if(hasOwnProperty.call(styleCache,key)){var positions=styleCache[key].positions;for(var i=0,position;position=positions[i];i++){if(position.active){if(!position.rendered){layer.append(position.element);position.rendered=true}}else{positions.splice(i--,1);if(position.rendered){position.element.detach()}}}if(positions.length==0){delete styleCache[key]}}}}}layer.show()}}};Canvas.prototype.getTextLayer=function(classes){var layer=this.text[classes];if(layer==null){if(this.textContainer==null){this.textContainer=$("
").css({position:"absolute",top:0,left:0,bottom:0,right:0,"font-size":"smaller",color:"#545454"}).insertAfter(this.element)}layer=this.text[classes]=$("
").addClass(classes).css({position:"absolute",top:0,left:0,bottom:0,right:0}).appendTo(this.textContainer)}return layer};Canvas.prototype.getTextInfo=function(layer,text,font,angle,width){var textStyle,layerCache,styleCache,info;text=""+text;if(typeof font==="object"){textStyle=font.style+" "+font.variant+" "+font.weight+" "+font.size+"px/"+font.lineHeight+"px "+font.family}else{textStyle=font}layerCache=this._textCache[layer];if(layerCache==null){layerCache=this._textCache[layer]={}}styleCache=layerCache[textStyle];if(styleCache==null){styleCache=layerCache[textStyle]={}}info=styleCache[text];if(info==null){var element=$("
").html(text).css({position:"absolute","max-width":width,top:-9999}).appendTo(this.getTextLayer(layer));if(typeof font==="object"){element.css({font:textStyle,color:font.color})}else if(typeof font==="string"){element.addClass(font)}info=styleCache[text]={width:element.outerWidth(true),height:element.outerHeight(true),element:element,positions:[]};element.detach()}return info};Canvas.prototype.addText=function(layer,x,y,text,font,angle,width,halign,valign){var info=this.getTextInfo(layer,text,font,angle,width),positions=info.positions;if(halign=="center"){x-=info.width/2}else if(halign=="right"){x-=info.width}if(valign=="middle"){y-=info.height/2}else if(valign=="bottom"){y-=info.height}for(var i=0,position;position=positions[i];i++){if(position.x==x&&position.y==y){position.active=true;return}}position={active:true,rendered:false,element:positions.length?info.element.clone():info.element,x:x,y:y};positions.push(position);position.element.css({top:Math.round(y),left:Math.round(x),"text-align":halign})};Canvas.prototype.removeText=function(layer,x,y,text,font,angle){if(text==null){var layerCache=this._textCache[layer];if(layerCache!=null){for(var styleKey in layerCache){if(hasOwnProperty.call(layerCache,styleKey)){var styleCache=layerCache[styleKey];for(var key in styleCache){if(hasOwnProperty.call(styleCache,key)){var positions=styleCache[key].positions;for(var i=0,position;position=positions[i];i++){position.active=false}}}}}}}else{var positions=this.getTextInfo(layer,text,font,angle).positions;for(var i=0,position;position=positions[i];i++){if(position.x==x&&position.y==y){position.active=false}}}};function Plot(placeholder,data_,options_,plugins){var series=[],options={colors:["#edc240","#afd8f8","#cb4b4b","#4da74d","#9440ed"],legend:{show:true,noColumns:1,labelFormatter:null,labelBoxBorderColor:"#ccc",container:null,position:"ne",margin:5,backgroundColor:null,backgroundOpacity:.85,sorted:null},xaxis:{show:null,position:"bottom",mode:null,font:null,color:null,tickColor:null,transform:null,inverseTransform:null,min:null,max:null,autoscaleMargin:null,ticks:null,tickFormatter:null,labelWidth:null,labelHeight:null,reserveSpace:null,tickLength:null,alignTicksWithAxis:null,tickDecimals:null,tickSize:null,minTickSize:null},yaxis:{autoscaleMargin:.02,position:"left"},xaxes:[],yaxes:[],series:{points:{show:false,radius:3,lineWidth:2,fill:true,fillColor:"#ffffff",symbol:"circle"},lines:{lineWidth:2,fill:false,fillColor:null,steps:false},bars:{show:false,lineWidth:2,barWidth:1,fill:true,fillColor:null,align:"left",horizontal:false,zero:true},shadowSize:3,highlightColor:null},grid:{show:true,aboveData:false,color:"#545454",backgroundColor:null,borderColor:null,tickColor:null,margin:0,labelMargin:5,axisMargin:8,borderWidth:2,minBorderMargin:null,markings:null,markingsColor:"#f4f4f4",markingsLineWidth:2,clickable:false,hoverable:false,autoHighlight:true,mouseActiveRadius:10},interaction:{redrawOverlayInterval:1e3/60},hooks:{}},surface=null,overlay=null,eventHolder=null,ctx=null,octx=null,xaxes=[],yaxes=[],plotOffset={left:0,right:0,top:0,bottom:0},plotWidth=0,plotHeight=0,hooks={processOptions:[],processRawData:[],processDatapoints:[],processOffset:[],drawBackground:[],drawSeries:[],draw:[],bindEvents:[],drawOverlay:[],shutdown:[]},plot=this;plot.setData=setData;plot.setupGrid=setupGrid;plot.draw=draw;plot.getPlaceholder=function(){return placeholder};plot.getCanvas=function(){return surface.element};plot.getPlotOffset=function(){return plotOffset};plot.width=function(){return plotWidth};plot.height=function(){return plotHeight};plot.offset=function(){var o=eventHolder.offset();o.left+=plotOffset.left;o.top+=plotOffset.top;return o};plot.getData=function(){return series};plot.getAxes=function(){var res={},i;$.each(xaxes.concat(yaxes),function(_,axis){if(axis)res[axis.direction+(axis.n!=1?axis.n:"")+"axis"]=axis});return res};plot.getXAxes=function(){return xaxes};plot.getYAxes=function(){return yaxes};plot.c2p=canvasToAxisCoords;plot.p2c=axisToCanvasCoords;plot.getOptions=function(){return options};plot.highlight=highlight;plot.unhighlight=unhighlight;plot.triggerRedrawOverlay=triggerRedrawOverlay;plot.pointOffset=function(point){return{left:parseInt(xaxes[axisNumber(point,"x")-1].p2c(+point.x)+plotOffset.left,10),top:parseInt(yaxes[axisNumber(point,"y")-1].p2c(+point.y)+plotOffset.top,10)}};plot.shutdown=shutdown;plot.destroy=function(){shutdown();placeholder.removeData("plot").empty();series=[];options=null;surface=null;overlay=null;eventHolder=null;ctx=null;octx=null;xaxes=[];yaxes=[];hooks=null;highlights=[];plot=null};plot.resize=function(){var width=placeholder.width(),height=placeholder.height();surface.resize(width,height);overlay.resize(width,height)};plot.hooks=hooks;initPlugins(plot);parseOptions(options_);setupCanvases();setData(data_);setupGrid();draw();bindEvents();function executeHooks(hook,args){args=[plot].concat(args);for(var i=0;imaxIndex){maxIndex=sc}}}if(neededColors<=maxIndex){neededColors=maxIndex+1}var c,colors=[],colorPool=options.colors,colorPoolSize=colorPool.length,variation=0;for(i=0;i=0){if(variation<.5){variation=-variation-.2}else variation=0}else variation=-variation}colors[i]=c.scale("rgb",1+variation)}var colori=0,s;for(i=0;iaxis.datamax&&max!=fakeInfinity)axis.datamax=max}$.each(allAxes(),function(_,axis){axis.datamin=topSentry;axis.datamax=bottomSentry;axis.used=false});for(i=0;i0&&points[k-ps]!=null&&points[k-ps]!=points[k]&&points[k-ps+1]!=points[k+1]){for(m=0;mxmax)xmax=val}if(f.y){if(valymax)ymax=val}}}if(s.bars.show){var delta;switch(s.bars.align){case"left":delta=0;break;case"right":delta=-s.bars.barWidth;break;default:delta=-s.bars.barWidth/2}if(s.bars.horizontal){ymin+=delta;ymax+=delta+s.bars.barWidth}else{xmin+=delta;xmax+=delta+s.bars.barWidth}}updateAxis(s.xaxis,xmin,xmax);updateAxis(s.yaxis,ymin,ymax)}$.each(allAxes(),function(_,axis){if(axis.datamin==topSentry)axis.datamin=null;if(axis.datamax==bottomSentry)axis.datamax=null})}function setupCanvases(){placeholder.css("padding",0).children().filter(function(){return!$(this).hasClass("flot-overlay")&&!$(this).hasClass("flot-base")}).remove();if(placeholder.css("position")=="static")placeholder.css("position","relative");surface=new Canvas("flot-base",placeholder);overlay=new Canvas("flot-overlay",placeholder);ctx=surface.context;octx=overlay.context;eventHolder=$(overlay.element).unbind();var existing=placeholder.data("plot");if(existing){existing.shutdown();overlay.clear()}placeholder.data("plot",plot)}function bindEvents(){if(options.grid.hoverable){eventHolder.mousemove(onMouseMove);eventHolder.bind("mouseleave",onMouseLeave)}if(options.grid.clickable)eventHolder.click(onClick);executeHooks(hooks.bindEvents,[eventHolder])}function shutdown(){if(redrawTimeout)clearTimeout(redrawTimeout);eventHolder.unbind("mousemove",onMouseMove);eventHolder.unbind("mouseleave",onMouseLeave);eventHolder.unbind("click",onClick);executeHooks(hooks.shutdown,[eventHolder])}function setTransformationHelpers(axis){function identity(x){return x}var s,m,t=axis.options.transform||identity,it=axis.options.inverseTransform;if(axis.direction=="x"){s=axis.scale=plotWidth/Math.abs(t(axis.max)-t(axis.min));m=Math.min(t(axis.max),t(axis.min))}else{s=axis.scale=plotHeight/Math.abs(t(axis.max)-t(axis.min));s=-s;m=Math.max(t(axis.max),t(axis.min))}if(t==identity)axis.p2c=function(p){return(p-m)*s};else axis.p2c=function(p){return(t(p)-m)*s};if(!it)axis.c2p=function(c){return m+c/s};else axis.c2p=function(c){return it(m+c/s)}}function measureTickLabels(axis){var opts=axis.options,ticks=axis.ticks||[],labelWidth=opts.labelWidth||0,labelHeight=opts.labelHeight||0,maxWidth=labelWidth||(axis.direction=="x"?Math.floor(surface.width/(ticks.length||1)):null),legacyStyles=axis.direction+"Axis "+axis.direction+axis.n+"Axis",layer="flot-"+axis.direction+"-axis flot-"+axis.direction+axis.n+"-axis "+legacyStyles,font=opts.font||"flot-tick-label tickLabel";for(var i=0;i=0;--i)allocateAxisBoxFirstPhase(allocatedAxes[i]);adjustLayoutForThingsStickingOut();$.each(allocatedAxes,function(_,axis){allocateAxisBoxSecondPhase(axis)})}plotWidth=surface.width-plotOffset.left-plotOffset.right;plotHeight=surface.height-plotOffset.bottom-plotOffset.top;$.each(axes,function(_,axis){setTransformationHelpers(axis)});if(showGrid){drawAxisLabels()}insertLegend()}function setRange(axis){var opts=axis.options,min=+(opts.min!=null?opts.min:axis.datamin),max=+(opts.max!=null?opts.max:axis.datamax),delta=max-min;if(delta==0){var widen=max==0?1:.01;if(opts.min==null)min-=widen;if(opts.max==null||opts.min!=null)max+=widen}else{var margin=opts.autoscaleMargin;if(margin!=null){if(opts.min==null){min-=delta*margin;if(min<0&&axis.datamin!=null&&axis.datamin>=0)min=0}if(opts.max==null){max+=delta*margin;if(max>0&&axis.datamax!=null&&axis.datamax<=0)max=0}}}axis.min=min;axis.max=max}function setupTickGeneration(axis){var opts=axis.options;var noTicks;if(typeof opts.ticks=="number"&&opts.ticks>0)noTicks=opts.ticks;else noTicks=.3*Math.sqrt(axis.direction=="x"?surface.width:surface.height);var delta=(axis.max-axis.min)/noTicks,dec=-Math.floor(Math.log(delta)/Math.LN10),maxDec=opts.tickDecimals;if(maxDec!=null&&dec>maxDec){dec=maxDec}var magn=Math.pow(10,-dec),norm=delta/magn,size;if(norm<1.5){size=1}else if(norm<3){size=2;if(norm>2.25&&(maxDec==null||dec+1<=maxDec)){size=2.5;++dec}}else if(norm<7.5){size=5}else{size=10}size*=magn;if(opts.minTickSize!=null&&size0){if(opts.min==null)axis.min=Math.min(axis.min,niceTicks[0]);if(opts.max==null&&niceTicks.length>1)axis.max=Math.max(axis.max,niceTicks[niceTicks.length-1])}axis.tickGenerator=function(axis){var ticks=[],v,i;for(i=0;i1&&/\..*0$/.test((ts[1]-ts[0]).toFixed(extraDec))))axis.tickDecimals=extraDec}}}}function setTicks(axis){var oticks=axis.options.ticks,ticks=[];if(oticks==null||typeof oticks=="number"&&oticks>0)ticks=axis.tickGenerator(axis);else if(oticks){if($.isFunction(oticks))ticks=oticks(axis);else ticks=oticks}var i,v;axis.ticks=[];for(i=0;i1)label=t[1]}else v=+t;if(label==null)label=axis.tickFormatter(v,axis);if(!isNaN(v))axis.ticks.push({v:v,label:label})}}function snapRangeToTicks(axis,ticks){if(axis.options.autoscaleMargin&&ticks.length>0){if(axis.options.min==null)axis.min=Math.min(axis.min,ticks[0].v);if(axis.options.max==null&&ticks.length>1)axis.max=Math.max(axis.max,ticks[ticks.length-1].v)}}function draw(){surface.clear();executeHooks(hooks.drawBackground,[ctx]);var grid=options.grid;if(grid.show&&grid.backgroundColor)drawBackground();if(grid.show&&!grid.aboveData){drawGrid()}for(var i=0;ito){var tmp=from;from=to;to=tmp}return{from:from,to:to,axis:axis}}function drawBackground(){ctx.save();ctx.translate(plotOffset.left,plotOffset.top);ctx.fillStyle=getColorOrGradient(options.grid.backgroundColor,plotHeight,0,"rgba(255, 255, 255, 0)");ctx.fillRect(0,0,plotWidth,plotHeight);ctx.restore()}function drawGrid(){var i,axes,bw,bc;ctx.save();ctx.translate(plotOffset.left,plotOffset.top);var markings=options.grid.markings;if(markings){if($.isFunction(markings)){axes=plot.getAxes();axes.xmin=axes.xaxis.min;axes.xmax=axes.xaxis.max;axes.ymin=axes.yaxis.min;axes.ymax=axes.yaxis.max;markings=markings(axes)}for(i=0;ixrange.axis.max||yrange.toyrange.axis.max)continue;xrange.from=Math.max(xrange.from,xrange.axis.min);xrange.to=Math.min(xrange.to,xrange.axis.max);yrange.from=Math.max(yrange.from,yrange.axis.min);yrange.to=Math.min(yrange.to,yrange.axis.max);var xequal=xrange.from===xrange.to,yequal=yrange.from===yrange.to;if(xequal&&yequal){continue}xrange.from=Math.floor(xrange.axis.p2c(xrange.from));xrange.to=Math.floor(xrange.axis.p2c(xrange.to));yrange.from=Math.floor(yrange.axis.p2c(yrange.from));yrange.to=Math.floor(yrange.axis.p2c(yrange.to));if(xequal||yequal){var lineWidth=m.lineWidth||options.grid.markingsLineWidth,subPixel=lineWidth%2?.5:0;ctx.beginPath();ctx.strokeStyle=m.color||options.grid.markingsColor;ctx.lineWidth=lineWidth;if(xequal){ctx.moveTo(xrange.to+subPixel,yrange.from);ctx.lineTo(xrange.to+subPixel,yrange.to)}else{ctx.moveTo(xrange.from,yrange.to+subPixel);ctx.lineTo(xrange.to,yrange.to+subPixel)}ctx.stroke()}else{ctx.fillStyle=m.color||options.grid.markingsColor;ctx.fillRect(xrange.from,yrange.to,xrange.to-xrange.from,yrange.from-yrange.to)}}}axes=allAxes();bw=options.grid.borderWidth;for(var j=0;jaxis.max||t=="full"&&(typeof bw=="object"&&bw[axis.position]>0||bw>0)&&(v==axis.min||v==axis.max))continue;if(axis.direction=="x"){x=axis.p2c(v);yoff=t=="full"?-plotHeight:t;if(axis.position=="top")yoff=-yoff}else{y=axis.p2c(v);xoff=t=="full"?-plotWidth:t;if(axis.position=="left")xoff=-xoff}if(ctx.lineWidth==1){if(axis.direction=="x")x=Math.floor(x)+.5;else y=Math.floor(y)+.5}ctx.moveTo(x,y);ctx.lineTo(x+xoff,y+yoff)}ctx.stroke()}if(bw){bc=options.grid.borderColor;if(typeof bw=="object"||typeof bc=="object"){if(typeof bw!=="object"){bw={top:bw,right:bw,bottom:bw,left:bw}}if(typeof bc!=="object"){bc={top:bc,right:bc,bottom:bc,left:bc}}if(bw.top>0){ctx.strokeStyle=bc.top;ctx.lineWidth=bw.top;ctx.beginPath();ctx.moveTo(0-bw.left,0-bw.top/2);ctx.lineTo(plotWidth,0-bw.top/2);ctx.stroke()}if(bw.right>0){ctx.strokeStyle=bc.right;ctx.lineWidth=bw.right;ctx.beginPath();ctx.moveTo(plotWidth+bw.right/2,0-bw.top);ctx.lineTo(plotWidth+bw.right/2,plotHeight);ctx.stroke()}if(bw.bottom>0){ctx.strokeStyle=bc.bottom;ctx.lineWidth=bw.bottom;ctx.beginPath();ctx.moveTo(plotWidth+bw.right,plotHeight+bw.bottom/2);ctx.lineTo(0,plotHeight+bw.bottom/2);ctx.stroke()}if(bw.left>0){ctx.strokeStyle=bc.left;ctx.lineWidth=bw.left;ctx.beginPath();ctx.moveTo(0-bw.left/2,plotHeight+bw.bottom);ctx.lineTo(0-bw.left/2,0);ctx.stroke()}}else{ctx.lineWidth=bw;ctx.strokeStyle=options.grid.borderColor;ctx.strokeRect(-bw/2,-bw/2,plotWidth+bw,plotHeight+bw)}}ctx.restore()}function drawAxisLabels(){$.each(allAxes(),function(_,axis){var box=axis.box,legacyStyles=axis.direction+"Axis "+axis.direction+axis.n+"Axis",layer="flot-"+axis.direction+"-axis flot-"+axis.direction+axis.n+"-axis "+legacyStyles,font=axis.options.font||"flot-tick-label tickLabel",tick,x,y,halign,valign;surface.removeText(layer);if(!axis.show||axis.ticks.length==0)return;for(var i=0;iaxis.max)continue;if(axis.direction=="x"){halign="center";x=plotOffset.left+axis.p2c(tick.v);if(axis.position=="bottom"){y=box.top+box.padding}else{y=box.top+box.height-box.padding;valign="bottom"}}else{valign="middle";y=plotOffset.top+axis.p2c(tick.v);if(axis.position=="left"){x=box.left+box.width-box.padding;halign="right"}else{x=box.left+box.padding}}surface.addText(layer,x,y,tick.label,font,null,null,halign,valign)}})}function drawSeries(series){if(series.lines.show)drawSeriesLines(series);if(series.bars.show)drawSeriesBars(series);if(series.points.show)drawSeriesPoints(series)}function drawSeriesLines(series){function plotLine(datapoints,xoffset,yoffset,axisx,axisy){var points=datapoints.points,ps=datapoints.pointsize,prevx=null,prevy=null;ctx.beginPath();for(var i=ps;i=y2&&y1>axisy.max){if(y2>axisy.max)continue;x1=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y1=axisy.max}else if(y2>=y1&&y2>axisy.max){if(y1>axisy.max)continue;x2=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y2=axisy.max}if(x1<=x2&&x1=x2&&x1>axisx.max){if(x2>axisx.max)continue;y1=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x1=axisx.max}else if(x2>=x1&&x2>axisx.max){if(x1>axisx.max)continue;y2=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x2=axisx.max}if(x1!=prevx||y1!=prevy)ctx.moveTo(axisx.p2c(x1)+xoffset,axisy.p2c(y1)+yoffset);prevx=x2;prevy=y2;ctx.lineTo(axisx.p2c(x2)+xoffset,axisy.p2c(y2)+yoffset)}ctx.stroke()}function plotLineArea(datapoints,axisx,axisy){var points=datapoints.points,ps=datapoints.pointsize,bottom=Math.min(Math.max(0,axisy.min),axisy.max),i=0,top,areaOpen=false,ypos=1,segmentStart=0,segmentEnd=0;while(true){if(ps>0&&i>points.length+ps)break;i+=ps;var x1=points[i-ps],y1=points[i-ps+ypos],x2=points[i],y2=points[i+ypos];if(areaOpen){if(ps>0&&x1!=null&&x2==null){segmentEnd=i;ps=-ps;ypos=2;continue}if(ps<0&&i==segmentStart+ps){ctx.fill();areaOpen=false;ps=-ps;ypos=1;i=segmentStart=segmentEnd+ps;continue}}if(x1==null||x2==null)continue;if(x1<=x2&&x1=x2&&x1>axisx.max){if(x2>axisx.max)continue;y1=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x1=axisx.max}else if(x2>=x1&&x2>axisx.max){if(x1>axisx.max)continue;y2=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x2=axisx.max}if(!areaOpen){ctx.beginPath();ctx.moveTo(axisx.p2c(x1),axisy.p2c(bottom));areaOpen=true}if(y1>=axisy.max&&y2>=axisy.max){ctx.lineTo(axisx.p2c(x1),axisy.p2c(axisy.max));ctx.lineTo(axisx.p2c(x2),axisy.p2c(axisy.max));continue}else if(y1<=axisy.min&&y2<=axisy.min){ctx.lineTo(axisx.p2c(x1),axisy.p2c(axisy.min));ctx.lineTo(axisx.p2c(x2),axisy.p2c(axisy.min));continue}var x1old=x1,x2old=x2;if(y1<=y2&&y1=axisy.min){x1=(axisy.min-y1)/(y2-y1)*(x2-x1)+x1;y1=axisy.min}else if(y2<=y1&&y2=axisy.min){x2=(axisy.min-y1)/(y2-y1)*(x2-x1)+x1;y2=axisy.min}if(y1>=y2&&y1>axisy.max&&y2<=axisy.max){x1=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y1=axisy.max}else if(y2>=y1&&y2>axisy.max&&y1<=axisy.max){x2=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y2=axisy.max}if(x1!=x1old){ctx.lineTo(axisx.p2c(x1old),axisy.p2c(y1))}ctx.lineTo(axisx.p2c(x1),axisy.p2c(y1));ctx.lineTo(axisx.p2c(x2),axisy.p2c(y2));if(x2!=x2old){ctx.lineTo(axisx.p2c(x2),axisy.p2c(y2));ctx.lineTo(axisx.p2c(x2old),axisy.p2c(y2))}}}ctx.save();ctx.translate(plotOffset.left,plotOffset.top);ctx.lineJoin="round";var lw=series.lines.lineWidth,sw=series.shadowSize;if(lw>0&&sw>0){ctx.lineWidth=sw;ctx.strokeStyle="rgba(0,0,0,0.1)";var angle=Math.PI/18;plotLine(series.datapoints,Math.sin(angle)*(lw/2+sw/2),Math.cos(angle)*(lw/2+sw/2),series.xaxis,series.yaxis);ctx.lineWidth=sw/2;plotLine(series.datapoints,Math.sin(angle)*(lw/2+sw/4),Math.cos(angle)*(lw/2+sw/4),series.xaxis,series.yaxis)}ctx.lineWidth=lw;ctx.strokeStyle=series.color;var fillStyle=getFillStyle(series.lines,series.color,0,plotHeight);if(fillStyle){ctx.fillStyle=fillStyle;plotLineArea(series.datapoints,series.xaxis,series.yaxis)}if(lw>0)plotLine(series.datapoints,0,0,series.xaxis,series.yaxis);ctx.restore()}function drawSeriesPoints(series){function plotPoints(datapoints,radius,fillStyle,offset,shadow,axisx,axisy,symbol){var points=datapoints.points,ps=datapoints.pointsize;for(var i=0;iaxisx.max||yaxisy.max)continue;ctx.beginPath();x=axisx.p2c(x);y=axisy.p2c(y)+offset;if(symbol=="circle")ctx.arc(x,y,radius,0,shadow?Math.PI:Math.PI*2,false);else symbol(ctx,x,y,radius,shadow);ctx.closePath();if(fillStyle){ctx.fillStyle=fillStyle;ctx.fill()}ctx.stroke()}}ctx.save();ctx.translate(plotOffset.left,plotOffset.top);var lw=series.points.lineWidth,sw=series.shadowSize,radius=series.points.radius,symbol=series.points.symbol;if(lw==0)lw=1e-4;if(lw>0&&sw>0){var w=sw/2;ctx.lineWidth=w;ctx.strokeStyle="rgba(0,0,0,0.1)";plotPoints(series.datapoints,radius,null,w+w/2,true,series.xaxis,series.yaxis,symbol);ctx.strokeStyle="rgba(0,0,0,0.2)";plotPoints(series.datapoints,radius,null,w/2,true,series.xaxis,series.yaxis,symbol)}ctx.lineWidth=lw;ctx.strokeStyle=series.color;plotPoints(series.datapoints,radius,getFillStyle(series.points,series.color),0,false,series.xaxis,series.yaxis,symbol);ctx.restore()}function drawBar(x,y,b,barLeft,barRight,fillStyleCallback,axisx,axisy,c,horizontal,lineWidth){var left,right,bottom,top,drawLeft,drawRight,drawTop,drawBottom,tmp;if(horizontal){drawBottom=drawRight=drawTop=true;drawLeft=false;left=b;right=x;top=y+barLeft;bottom=y+barRight;if(rightaxisx.max||topaxisy.max)return;if(leftaxisx.max){right=axisx.max;drawRight=false}if(bottomaxisy.max){top=axisy.max;drawTop=false}left=axisx.p2c(left);bottom=axisy.p2c(bottom);right=axisx.p2c(right);top=axisy.p2c(top);if(fillStyleCallback){c.fillStyle=fillStyleCallback(bottom,top);c.fillRect(left,top,right-left,bottom-top)}if(lineWidth>0&&(drawLeft||drawRight||drawTop||drawBottom)){c.beginPath();c.moveTo(left,bottom);if(drawLeft)c.lineTo(left,top);else c.moveTo(left,top);if(drawTop)c.lineTo(right,top);else c.moveTo(right,top);if(drawRight)c.lineTo(right,bottom);else c.moveTo(right,bottom);if(drawBottom)c.lineTo(left,bottom);else c.moveTo(left,bottom);c.stroke()}}function drawSeriesBars(series){function plotBars(datapoints,barLeft,barRight,fillStyleCallback,axisx,axisy){var points=datapoints.points,ps=datapoints.pointsize;for(var i=0;i");fragments.push("");rowStarted=true}fragments.push('
'+''+entry.label+"")}if(rowStarted)fragments.push("");if(fragments.length==0)return;var table=''+fragments.join("")+"
";if(options.legend.container!=null)$(options.legend.container).html(table);else{var pos="",p=options.legend.position,m=options.legend.margin;if(m[0]==null)m=[m,m];if(p.charAt(0)=="n")pos+="top:"+(m[1]+plotOffset.top)+"px;";else if(p.charAt(0)=="s")pos+="bottom:"+(m[1]+plotOffset.bottom)+"px;";if(p.charAt(1)=="e")pos+="right:"+(m[0]+plotOffset.right)+"px;";else if(p.charAt(1)=="w")pos+="left:"+(m[0]+plotOffset.left)+"px;";var legend=$('
'+table.replace('style="','style="position:absolute;'+pos+";")+"
").appendTo(placeholder);if(options.legend.backgroundOpacity!=0){var c=options.legend.backgroundColor;if(c==null){c=options.grid.backgroundColor;if(c&&typeof c=="string")c=$.color.parse(c);else c=$.color.extract(legend,"background-color");c.a=1;c=c.toString()}var div=legend.children();$('
').prependTo(legend).css("opacity",options.legend.backgroundOpacity)}}}var highlights=[],redrawTimeout=null;function findNearbyItem(mouseX,mouseY,seriesFilter){var maxDistance=options.grid.mouseActiveRadius,smallestDistance=maxDistance*maxDistance+1,item=null,foundPoint=false,i,j,ps;for(i=series.length-1;i>=0;--i){if(!seriesFilter(series[i]))continue;var s=series[i],axisx=s.xaxis,axisy=s.yaxis,points=s.datapoints.points,mx=axisx.c2p(mouseX),my=axisy.c2p(mouseY),maxx=maxDistance/axisx.scale,maxy=maxDistance/axisy.scale;ps=s.datapoints.pointsize;if(axisx.options.inverseTransform)maxx=Number.MAX_VALUE;if(axisy.options.inverseTransform)maxy=Number.MAX_VALUE;if(s.lines.show||s.points.show){for(j=0;jmaxx||x-mx<-maxx||y-my>maxy||y-my<-maxy)continue;var dx=Math.abs(axisx.p2c(x)-mouseX),dy=Math.abs(axisy.p2c(y)-mouseY),dist=dx*dx+dy*dy;if(dist=Math.min(b,x)&&my>=y+barLeft&&my<=y+barRight:mx>=x+barLeft&&mx<=x+barRight&&my>=Math.min(b,y)&&my<=Math.max(b,y))item=[i,j/ps]}}}if(item){i=item[0];j=item[1];ps=series[i].datapoints.pointsize;return{datapoint:series[i].datapoints.points.slice(j*ps,(j+1)*ps),dataIndex:j,series:series[i],seriesIndex:i}}return null}function onMouseMove(e){if(options.grid.hoverable)triggerClickHoverEvent("plothover",e,function(s){return s["hoverable"]!=false})}function onMouseLeave(e){if(options.grid.hoverable)triggerClickHoverEvent("plothover",e,function(s){return false})}function onClick(e){triggerClickHoverEvent("plotclick",e,function(s){return s["clickable"]!=false})}function triggerClickHoverEvent(eventname,event,seriesFilter){var offset=eventHolder.offset(),canvasX=event.pageX-offset.left-plotOffset.left,canvasY=event.pageY-offset.top-plotOffset.top,pos=canvasToAxisCoords({left:canvasX,top:canvasY});pos.pageX=event.pageX;pos.pageY=event.pageY;var item=findNearbyItem(canvasX,canvasY,seriesFilter);if(item){item.pageX=parseInt(item.series.xaxis.p2c(item.datapoint[0])+offset.left+plotOffset.left,10);item.pageY=parseInt(item.series.yaxis.p2c(item.datapoint[1])+offset.top+plotOffset.top,10)}if(options.grid.autoHighlight){for(var i=0;iaxisx.max||yaxisy.max)return;var pointRadius=series.points.radius+series.points.lineWidth/2;octx.lineWidth=pointRadius;octx.strokeStyle=highlightColor;var radius=1.5*pointRadius;x=axisx.p2c(x);y=axisy.p2c(y);octx.beginPath();if(series.points.symbol=="circle")octx.arc(x,y,radius,0,2*Math.PI,false);else series.points.symbol(octx,x,y,radius,false);octx.closePath();octx.stroke()}function drawBarHighlight(series,point){var highlightColor=typeof series.highlightColor==="string"?series.highlightColor:$.color.parse(series.color).scale("a",.5).toString(),fillStyle=highlightColor,barLeft;switch(series.bars.align){case"left":barLeft=0;break;case"right":barLeft=-series.bars.barWidth;break;default:barLeft=-series.bars.barWidth/2}octx.lineWidth=series.bars.lineWidth;octx.strokeStyle=highlightColor;drawBar(point[0],point[1],point[2]||0,barLeft,barLeft+series.bars.barWidth,function(){return fillStyle},series.xaxis,series.yaxis,octx,series.bars.horizontal,series.bars.lineWidth)}function getColorOrGradient(spec,bottom,top,defaultColor){if(typeof spec=="string")return spec;else{var gradient=ctx.createLinearGradient(0,top,0,bottom);for(var i=0,l=spec.colors.length;i2&&(horizontal?datapoints.format[2].x:datapoints.format[2].y),withsteps=withlines&&s.lines.steps,fromgap=true,keyOffset=horizontal?1:0,accumulateOffset=horizontal?0:1,i=0,j=0,l,m;while(true){if(i>=points.length)break;l=newpoints.length;if(points[i]==null){for(m=0;m=otherpoints.length){if(!withlines){for(m=0;mqx){if(withlines&&i>0&&points[i-ps]!=null){intery=py+(points[i-ps+accumulateOffset]-py)*(qx-px)/(points[i-ps+keyOffset]-px);newpoints.push(qx);newpoints.push(intery+qy);for(m=2;m0&&otherpoints[j-otherps]!=null)bottom=qy+(otherpoints[j-otherps+accumulateOffset]-qy)*(px-qx)/(otherpoints[j-otherps+keyOffset]-qx);newpoints[l+accumulateOffset]+=bottom;i+=ps}fromgap=false;if(l!=newpoints.length&&withbottom)newpoints[l+2]+=bottom}if(withsteps&&l!=newpoints.length&&l>0&&newpoints[l]!=null&&newpoints[l]!=newpoints[l-ps]&&newpoints[l+1]!=newpoints[l-ps+1]){for(m=0;m1){options.series.pie.tilt=1}else if(options.series.pie.tilt<0){options.series.pie.tilt=0}}});plot.hooks.bindEvents.push(function(plot,eventHolder){var options=plot.getOptions();if(options.series.pie.show){if(options.grid.hoverable){eventHolder.unbind("mousemove").mousemove(onMouseMove)}if(options.grid.clickable){eventHolder.unbind("click").click(onClick)}}});plot.hooks.processDatapoints.push(function(plot,series,data,datapoints){var options=plot.getOptions();if(options.series.pie.show){processDatapoints(plot,series,data,datapoints)}});plot.hooks.drawOverlay.push(function(plot,octx){var options=plot.getOptions();if(options.series.pie.show){drawOverlay(plot,octx)}});plot.hooks.draw.push(function(plot,newCtx){var options=plot.getOptions();if(options.series.pie.show){draw(plot,newCtx)}});function processDatapoints(plot,series,datapoints){if(!processed){processed=true;canvas=plot.getCanvas();target=$(canvas).parent();options=plot.getOptions();plot.setData(combine(plot.getData()))}}function combine(data){var total=0,combined=0,numCombined=0,color=options.series.pie.combine.color,newdata=[];for(var i=0;ioptions.series.pie.combine.threshold){newdata.push($.extend(data[i],{data:[[1,value]],color:data[i].color,label:data[i].label,angle:value*Math.PI*2/total,percent:value/(total/100)}))}}if(numCombined>1){newdata.push({data:[[1,combined]],color:color,label:options.series.pie.combine.label,angle:combined*Math.PI*2/total,percent:combined/(total/100)})}return newdata}function draw(plot,newCtx){if(!target){return}var canvasWidth=plot.getPlaceholder().width(),canvasHeight=plot.getPlaceholder().height(),legendWidth=target.children().filter(".legend").children().width()||0;ctx=newCtx;processed=false;maxRadius=Math.min(canvasWidth,canvasHeight/options.series.pie.tilt)/2;centerTop=canvasHeight/2+options.series.pie.offset.top;centerLeft=canvasWidth/2;if(options.series.pie.offset.left=="auto"){if(options.legend.position.match("w")){centerLeft+=legendWidth/2}else{centerLeft-=legendWidth/2}if(centerLeftcanvasWidth-maxRadius){centerLeft=canvasWidth-maxRadius}}else{centerLeft+=options.series.pie.offset.left}var slices=plot.getData(),attempts=0;do{if(attempts>0){maxRadius*=REDRAW_SHRINK}attempts+=1;clear();if(options.series.pie.tilt<=.8){drawShadow()}}while(!drawPie()&&attempts=REDRAW_ATTEMPTS){clear();target.prepend("
Could not draw pie with labels contained inside canvas
")}if(plot.setSeries&&plot.insertLegend){plot.setSeries(slices);plot.insertLegend()}function clear(){ctx.clearRect(0,0,canvasWidth,canvasHeight);target.children().filter(".pieLabel, .pieLabelBackground").remove()}function drawShadow(){var shadowLeft=options.series.pie.shadow.left;var shadowTop=options.series.pie.shadow.top;var edge=10;var alpha=options.series.pie.shadow.alpha;var radius=options.series.pie.radius>1?options.series.pie.radius:maxRadius*options.series.pie.radius;if(radius>=canvasWidth/2-shadowLeft||radius*options.series.pie.tilt>=canvasHeight/2-shadowTop||radius<=edge){return}ctx.save();ctx.translate(shadowLeft,shadowTop);ctx.globalAlpha=alpha;ctx.fillStyle="#000";ctx.translate(centerLeft,centerTop);ctx.scale(1,options.series.pie.tilt);for(var i=1;i<=edge;i++){ctx.beginPath();ctx.arc(0,0,radius,0,Math.PI*2,false);ctx.fill();radius-=i}ctx.restore()}function drawPie(){var startAngle=Math.PI*options.series.pie.startAngle;var radius=options.series.pie.radius>1?options.series.pie.radius:maxRadius*options.series.pie.radius;ctx.save();ctx.translate(centerLeft,centerTop);ctx.scale(1,options.series.pie.tilt);ctx.save();var currentAngle=startAngle;for(var i=0;i0){ctx.save();ctx.lineWidth=options.series.pie.stroke.width;currentAngle=startAngle;for(var i=0;i1e-9){ctx.moveTo(0,0)}ctx.arc(0,0,radius,currentAngle,currentAngle+angle/2,false);ctx.arc(0,0,radius,currentAngle+angle/2,currentAngle+angle,false);ctx.closePath();currentAngle+=angle;if(fill){ctx.fill()}else{ctx.stroke()}}function drawLabels(){var currentAngle=startAngle;var radius=options.series.pie.label.radius>1?options.series.pie.label.radius:maxRadius*options.series.pie.label.radius;for(var i=0;i=options.series.pie.label.threshold*100){if(!drawLabel(slices[i],currentAngle,i)){return false}}currentAngle+=slices[i].angle}return true;function drawLabel(slice,startAngle,index){if(slice.data[0][1]==0){return true}var lf=options.legend.labelFormatter,text,plf=options.series.pie.label.formatter;if(lf){text=lf(slice.label,slice)}else{text=slice.label}if(plf){text=plf(text,slice)}var halfAngle=(startAngle+slice.angle+startAngle)/2;var x=centerLeft+Math.round(Math.cos(halfAngle)*radius);var y=centerTop+Math.round(Math.sin(halfAngle)*radius)*options.series.pie.tilt;var html=""+text+"";target.append(html);var label=target.children("#pieLabel"+index);var labelTop=y-label.height()/2;var labelLeft=x-label.width()/2;label.css("top",labelTop);label.css("left",labelLeft);if(0-labelTop>0||0-labelLeft>0||canvasHeight-(labelTop+label.height())<0||canvasWidth-(labelLeft+label.width())<0){return false}if(options.series.pie.label.background.opacity!=0){var c=options.series.pie.label.background.color;if(c==null){c=slice.color}var pos="top:"+labelTop+"px;left:"+labelLeft+"px;";$("
").css("opacity",options.series.pie.label.background.opacity).insertBefore(label)}return true}}}}function drawDonutHole(layer){if(options.series.pie.innerRadius>0){layer.save();var innerRadius=options.series.pie.innerRadius>1?options.series.pie.innerRadius:maxRadius*options.series.pie.innerRadius;layer.globalCompositeOperation="destination-out";layer.beginPath();layer.fillStyle=options.series.pie.stroke.color;layer.arc(0,0,innerRadius,0,Math.PI*2,false);layer.fill();layer.closePath();layer.restore();layer.save();layer.beginPath();layer.strokeStyle=options.series.pie.stroke.color;layer.arc(0,0,innerRadius,0,Math.PI*2,false);layer.stroke();layer.closePath();layer.restore()}}function isPointInPoly(poly,pt){for(var c=false,i=-1,l=poly.length,j=l-1;++i1?options.series.pie.radius:maxRadius*options.series.pie.radius,x,y;for(var i=0;i1?options.series.pie.radius:maxRadius*options.series.pie.radius;octx.save();octx.translate(centerLeft,centerTop);octx.scale(1,options.series.pie.tilt);for(var i=0;i1e-9){octx.moveTo(0,0)}octx.arc(0,0,radius,series.startAngle,series.startAngle+series.angle/2,false);octx.arc(0,0,radius,series.startAngle+series.angle/2,series.startAngle+series.angle,false);octx.closePath();octx.fill()}}}var options={series:{pie:{show:false,radius:"auto",innerRadius:0,startAngle:3/2,tilt:1,shadow:{left:5,top:15,alpha:.02},offset:{top:0,left:"auto"},stroke:{color:"#fff",width:1},label:{show:"auto",formatter:function(label,slice){return"
"+label+"
"+Math.round(slice.percent)+"%
"},radius:1,background:{color:null,opacity:0},threshold:0},combine:{threshold:-1,color:null,label:"Other"},highlight:{opacity:.5}}}};$.plot.plugins.push({init:init,options:options,name:"pie",version:"1.1"})})(jQuery); ================================================ FILE: qt/aqt/data/web/js/webview.ts ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ // prevent backspace key from going back a page document.addEventListener("keydown", function(evt: KeyboardEvent) { if (evt.keyCode !== 8) { return; } let isText = 0; const node = evt.target as Element; const nn = node.nodeName; if (nn === "INPUT" || nn === "TEXTAREA") { isText = 1; } else if (nn === "DIV" && (node as HTMLDivElement).contentEditable) { isText = 1; } if (!isText) { evt.preventDefault(); } }); ================================================ FILE: qt/aqt/dbcheck.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from concurrent.futures import Future import aqt import aqt.main from aqt.qt import * from aqt.utils import showText, tooltip def on_progress(mw: aqt.main.AnkiQt) -> None: progress = mw.col.latest_progress() if not progress.HasField("database_check"): return dbprogress = progress.database_check mw.progress.update( process=False, label=dbprogress.stage, value=dbprogress.stage_current, max=dbprogress.stage_total, ) def check_db(mw: aqt.AnkiQt) -> None: def on_timer() -> None: on_progress(mw) timer = QTimer(mw) qconnect(timer.timeout, on_timer) timer.start(100) def do_check() -> tuple[str, bool]: mw.create_backup_now() return mw.col.fix_integrity() def on_future_done(fut: Future) -> None: timer.stop() ret, ok = fut.result() if not ok: showText(ret, parent=mw) else: tooltip(ret, parent=mw) # if an error has directed the user to check the database, # silently clean up any broken reset hooks which distract from # the underlying issue n = 0 while n < 10: try: mw.reset() break except Exception as e: print("swallowed exception in reset hook:", e) n += 1 continue mw.taskman.with_progress(do_check, on_future_done) ================================================ FILE: qt/aqt/debug_console.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import os import sys from collections.abc import Callable from dataclasses import dataclass from functools import partial from pathlib import Path from typing import TextIO, cast import anki.cards import aqt import aqt.forms from aqt import gui_hooks from aqt.qt import * from aqt.utils import ( disable_help_button, restoreGeom, restoreSplitter, saveGeom, saveSplitter, send_to_trash, tr, ) def show_debug_console() -> None: assert aqt.mw console = DebugConsole(aqt.mw) gui_hooks.debug_console_will_show(console) console.show() SCRIPT_FOLDER = "debug_scripts" UNSAVED_SCRIPT = "Unsaved script" @dataclass class Action: name: str shortcut: str action: Callable[[], None] class DebugConsole(QDialog): silentlyClose = True _last_index = 0 def __init__(self, parent: QWidget) -> None: self._buffers: dict[int, str] = {} super().__init__(parent) self._setup_ui() disable_help_button(self) restoreGeom(self, "DebugConsoleWindow") restoreSplitter(self.frm.splitter, "DebugConsoleWindow") def _setup_ui(self): self.frm = aqt.forms.debug.Ui_Dialog() self.frm.setupUi(self) self._text: QPlainTextEdit = self.frm.text self._log: QPlainTextEdit = self.frm.log self._script: QComboBox = self.frm.script self._setup_text_edits() self._setup_scripts() self._setup_actions() self._setup_context_menu() qconnect(self.frm.widgetsButton.clicked, self._on_widgetGallery) qconnect(self._script.currentIndexChanged, self._on_script_change) def _setup_text_edits(self): font = QFont("Consolas") if not font.exactMatch(): font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) font.setPointSize(self._text.font().pointSize() + 1) self._text.setFont(font) self._log.setFont(font) def _setup_scripts(self) -> None: self._dir = Path(aqt.mw.pm.base).joinpath(SCRIPT_FOLDER) self._dir.mkdir(exist_ok=True) self._script.addItem(UNSAVED_SCRIPT) self._script.addItems(os.listdir(self._dir)) def _setup_actions(self) -> None: for action in self._actions(): qconnect( QShortcut(QKeySequence(action.shortcut), self).activated, action.action ) def _actions(self): return [ Action("Execute", "ctrl+return", self.onDebugRet), Action("Execute and print", "ctrl+shift+return", self.onDebugPrint), Action("Clear log", "ctrl+l", self._log.clear), Action("Clear code", "ctrl+shift+l", self._text.clear), Action("Save script", "ctrl+s", self._save_script), Action("Open script", "ctrl+o", self._open_script), Action("Delete script", "ctrl+d", self._delete_script), ] def reject(self) -> None: super().reject() saveSplitter(self.frm.splitter, "DebugConsoleWindow") saveGeom(self, "DebugConsoleWindow") def _on_script_change(self, new_index: int) -> None: self._buffers[self._last_index] = self._text.toPlainText() self._text.setPlainText(self._get_script(new_index) or "") self._last_index = new_index def _get_script(self, idx: int) -> str | None: if script := self._buffers.get(idx, ""): return script if path := self._get_item(idx): return path.read_text(encoding="utf8") return None def _get_item(self, idx: int) -> Path | None: if not idx: return None path = Path(self._script.itemText(idx)) return path if path.is_absolute() else self._dir.joinpath(path) def _get_index(self, path: Path) -> int: return self._script.findText(self._path_to_item(path)) def _path_to_item(self, path: Path) -> str: return path.name if path.is_relative_to(self._dir) else str(path) def _current_script_path(self) -> Path | None: return self._get_item(self._script.currentIndex()) def _save_script(self) -> None: if not (path := self._current_script_path()): new_file = QFileDialog.getSaveFileName( self, directory=str(self._dir), filter="Python file (*.py)" )[0] if not new_file: return path = Path(new_file) path.write_text(self._text.toPlainText(), encoding="utf8") item = self._path_to_item(path) if (idx := self._get_index(path)) == -1: self._script.addItem(item) idx = self._script.count() - 1 # update existing buffer, so text edit doesn't change when index changes self._buffers[idx] = self._text.toPlainText() self._script.setCurrentIndex(idx) def _open_script(self) -> None: file = QFileDialog.getOpenFileName( self, directory=str(self._dir), filter="Python file (*.py)" )[0] if not file: return path = Path(file) item = self._path_to_item(path) if (idx := self._get_index(path)) == -1: self._script.addItem(item) idx = self._script.count() - 1 elif idx in self._buffers: del self._buffers[idx] if idx == self._script.currentIndex(): self._text.setPlainText(path.read_text(encoding="utf8")) else: self._script.setCurrentIndex(idx) def _delete_script(self) -> None: if not (path := self._current_script_path()): return send_to_trash(path) deleted_idx = self._script.currentIndex() self._script.setCurrentIndex(0) self._script.removeItem(deleted_idx) self._drop_buffer_and_shift_keys(deleted_idx) def _drop_buffer_and_shift_keys(self, idx: int) -> None: def shift(old_idx: int) -> int: return old_idx - 1 if old_idx > idx else old_idx self._buffers = {shift(i): val for i, val in self._buffers.items() if i != idx} def _setup_context_menu(self) -> None: for text_edit in (self._log, self._text): text_edit.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) qconnect( text_edit.customContextMenuRequested, partial(self._on_context_menu, text_edit), ) def _on_context_menu(self, text_edit: QPlainTextEdit) -> None: menu = text_edit.createStandardContextMenu() assert menu is not None menu.addSeparator() for action in self._actions(): entry = menu.addAction(action.name) entry.setShortcut(QKeySequence(action.shortcut)) qconnect(entry.triggered, action.action) menu.exec(QCursor.pos()) def _on_widgetGallery(self) -> None: from aqt.widgetgallery import WidgetGallery self.widgetGallery = WidgetGallery(self) self.widgetGallery.show() def _captureOutput(self, on: bool) -> None: mw2 = self class Stream: def write(self, data: str) -> None: mw2._output += data if on: self._output = "" self._oldStderr = sys.stderr self._oldStdout = sys.stdout s = cast(TextIO, Stream()) sys.stderr = s sys.stdout = s else: sys.stderr = self._oldStderr sys.stdout = self._oldStdout def _card_repr(self, card: anki.cards.Card | None) -> None: import copy import pprint if not card: print("no card") return print("Front:", card.question()) print("\n") print("Back:", card.answer()) print("\nNote:") note = copy.copy(card.note()) for k, v in note.items(): print(f"- {k}:", v) print("\n") del note.fields del note._fmap pprint.pprint(note.__dict__) print("\nCard:") c = copy.copy(card) c._render_output = None pprint.pprint(c.__dict__) def _debugCard(self) -> anki.cards.Card | None: assert aqt.mw card = aqt.mw.reviewer.card self._card_repr(card) return card def _debugBrowserCard(self) -> anki.cards.Card | None: card = aqt.dialogs._dialogs["Browser"][1].card self._card_repr(card) return card def onDebugPrint(self) -> None: cursor = self._text.textCursor() position = cursor.position() cursor.select(QTextCursor.SelectionType.LineUnderCursor) line = cursor.selectedText() whitespace, stripped = _split_off_leading_whitespace(line) pfx, sfx = "pp(", ")" if not stripped.startswith(pfx): line = f"{whitespace}{pfx}{stripped}{sfx}" cursor.insertText(line) cursor.setPosition(position + len(pfx)) self._text.setTextCursor(cursor) self.onDebugRet() def onDebugRet(self) -> None: import pprint import traceback text = self._text.toPlainText() vars = { "card": self._debugCard, "bcard": self._debugBrowserCard, "mw": aqt.mw, "pp": pprint.pprint, } self._captureOutput(True) try: exec(text, vars) except Exception: self._output += traceback.format_exc() self._captureOutput(False) buf = "" for c, line in enumerate(text.strip().split("\n")): if c == 0: buf += f">>> {line}\n" else: buf += f"... {line}\n" try: to_append = buf + (self._output or "") to_append = gui_hooks.debug_console_did_evaluate_python( to_append, text, self.frm ) self._log.appendPlainText(to_append) except UnicodeDecodeError: to_append = tr.qt_misc_non_unicode_text() to_append = gui_hooks.debug_console_did_evaluate_python( to_append, text, self.frm ) self._log.appendPlainText(to_append) slider = self._log.verticalScrollBar() assert slider is not None slider.setValue(slider.maximum()) self._log.ensureCursorVisible() def _split_off_leading_whitespace(text: str) -> tuple[str, str]: stripped = text.lstrip() whitespace = text[: len(text) - len(stripped)] return whitespace, stripped ================================================ FILE: qt/aqt/deckbrowser.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import html from copy import deepcopy from dataclasses import dataclass from typing import Any import aqt import aqt.operations from anki.collection import Collection, OpChanges from anki.decks import DeckCollapseScope, DeckId, DeckTreeNode from aqt import AnkiQt, gui_hooks from aqt.deckoptions import display_options_for_deck_id from aqt.operations import QueryOp from aqt.operations.deck import ( add_deck_dialog, remove_decks, rename_deck, reparent_decks, set_current_deck, set_deck_collapsed, ) from aqt.qt import * from aqt.sound import av_player from aqt.toolbar import BottomBar from aqt.utils import getOnlyText, openLink, shortcut, showInfo, tr class DeckBrowserBottomBar: def __init__(self, deck_browser: DeckBrowser) -> None: self.deck_browser = deck_browser @dataclass class RenderData: """Data from collection that is required to show the page.""" tree: DeckTreeNode current_deck_id: DeckId studied_today: str sched_upgrade_required: bool @dataclass class DeckBrowserContent: """Stores sections of HTML content that the deck browser will be populated with. Attributes: tree {str} -- HTML of the deck tree section stats {str} -- HTML of the stats section """ tree: str stats: str @dataclass class RenderDeckNodeContext: current_deck_id: DeckId class DeckBrowser: _render_data: RenderData def __init__(self, mw: AnkiQt) -> None: self.mw = mw self.web = mw.web self.bottom = BottomBar(mw, mw.bottomWeb) self.scrollPos = QPoint(0, 0) self._refresh_needed = False def show(self) -> None: av_player.stop_and_clear_queue() self.web.set_bridge_command(self._linkHandler, self) # redraw top bar for theme change self.mw.toolbar.redraw() self.refresh() def refresh(self) -> None: self._renderPage() self._refresh_needed = False def refresh_if_needed(self) -> None: if self._refresh_needed: self.refresh() def op_executed( self, changes: OpChanges, handler: object | None, focused: bool ) -> bool: if changes.study_queues and handler is not self: self._refresh_needed = True if focused: self.refresh_if_needed() return self._refresh_needed # Event handlers ########################################################################## def _linkHandler(self, url: str) -> Any: if ":" in url: (cmd, arg) = url.split(":", 1) else: cmd = url arg = "" if cmd == "open": self.set_current_deck(DeckId(int(arg))) elif cmd == "opts": self._showOptions(arg) elif cmd == "shared": self._onShared() elif cmd == "import": self.mw.onImport() elif cmd == "create": self._on_create() elif cmd == "drag": source, target = arg.split(",") self._handle_drag_and_drop(DeckId(int(source)), DeckId(int(target or 0))) elif cmd == "collapse": self._collapse(DeckId(int(arg))) elif cmd == "v2upgrade": self._confirm_upgrade() elif cmd == "v2upgradeinfo": if self.mw.col.sched_ver() == 1: openLink("https://faqs.ankiweb.net/the-anki-2.1-scheduler.html") else: openLink("https://faqs.ankiweb.net/the-2021-scheduler.html") elif cmd == "select": set_current_deck( parent=self.mw, deck_id=DeckId(int(arg)) ).run_in_background() return False def set_current_deck(self, deck_id: DeckId) -> None: set_current_deck(parent=self.mw, deck_id=deck_id).success( lambda _: self.mw.onOverview() ).run_in_background(initiator=self) # HTML generation ########################################################################## _body = """
%(tree)s

%(stats)s
""" def _renderPage(self, reuse: bool = False) -> None: if not reuse: def get_data(col: Collection) -> RenderData: return RenderData( tree=col.sched.deck_due_tree(), current_deck_id=col.decks.get_current_id(), studied_today=col.studied_today(), sched_upgrade_required=not col.v3_scheduler(), ) def success(output: RenderData) -> None: self._render_data = output self.__renderPage(None) QueryOp( parent=self.mw, op=get_data, success=success, ).run_in_background() else: self.web.evalWithCallback("window.pageYOffset", self.__renderPage) def __renderPage(self, offset: int | None) -> None: data = self._render_data content = DeckBrowserContent( tree=self._renderDeckTree(data.tree), stats=self._renderStats(), ) gui_hooks.deck_browser_will_render_content(self, content) self.web.stdHtml( self._v1_upgrade_message(data.sched_upgrade_required) + self._body % content.__dict__, css=["css/deckbrowser.css"], js=[ "js/vendor/jquery.min.js", "js/vendor/jquery-ui.min.js", "js/deckbrowser.js", ], context=self, ) self._drawButtons() if offset is not None: self._scrollToOffset(offset) gui_hooks.deck_browser_did_render(self) def _scrollToOffset(self, offset: int) -> None: self.web.eval("window.scrollTo(0, %d, 'instant');" % offset) def _renderStats(self) -> str: return '
{}
'.format( self._render_data.studied_today ) def _renderDeckTree(self, top: DeckTreeNode) -> str: buf = """ {} {} {} {} """.format( tr.decks_deck(), tr.actions_new(), tr.decks_learn_header(), tr.decks_review_header(), ) buf += self._topLevelDragRow() ctx = RenderDeckNodeContext(current_deck_id=self._render_data.current_deck_id) for child in top.children: buf += self._render_deck_node(child, ctx) return buf def _render_deck_node(self, node: DeckTreeNode, ctx: RenderDeckNodeContext) -> str: if node.collapsed: prefix = "+" else: prefix = "−" def indent() -> str: return " " * 6 * (node.level - 1) if node.deck_id == ctx.current_deck_id: klass = "deck current" else: klass = "deck" buf = ( "" % ( klass, node.deck_id, node.deck_id, ) ) # deck link if node.children: collapse = ( "%s" % (node.deck_id, prefix) ) else: collapse = "" if node.filtered: extraclass = "filtered" else: extraclass = "" buf += """ %s%s%s""" % ( indent(), collapse, extraclass, node.deck_id, html.escape(node.name), ) # due counts def nonzeroColour(cnt: int, klass: str) -> str: if not cnt: klass = "zero-count" return f'{cnt}' review = nonzeroColour(node.review_count, "review-count") learn = nonzeroColour(node.learn_count, "learn-count") buf += ("%s" * 3) % ( nonzeroColour(node.new_count, "new-count"), learn, review, ) # options buf += ( "" "" % node.deck_id ) # children if not node.collapsed: for child in node.children: buf += self._render_deck_node(child, ctx) return buf def _topLevelDragRow(self) -> str: return " " # Options ########################################################################## def _showOptions(self, did: str) -> None: m = QMenu(self.mw) a = m.addAction(tr.actions_rename()) assert a is not None qconnect(a.triggered, lambda b, did=did: self._rename(DeckId(int(did)))) a = m.addAction(tr.actions_options()) assert a is not None qconnect(a.triggered, lambda b, did=did: self._options(DeckId(int(did)))) a = m.addAction(tr.actions_export()) assert a is not None qconnect(a.triggered, lambda b, did=did: self._export(DeckId(int(did)))) a = m.addAction(tr.actions_delete()) assert a is not None qconnect(a.triggered, lambda b, did=did: self._delete(DeckId(int(did)))) gui_hooks.deck_browser_will_show_options_menu(m, int(did)) m.popup(QCursor.pos()) def _export(self, did: DeckId) -> None: self.mw.onExport(did=did) def _rename(self, did: DeckId) -> None: def prompt(name: str) -> None: new_name = getOnlyText( tr.decks_new_deck_name(), default=name, title=tr.actions_rename() ) if not new_name or new_name == name: return else: rename_deck( parent=self.mw, deck_id=did, new_name=new_name ).run_in_background() QueryOp( parent=self.mw, op=lambda col: col.decks.name(did), success=prompt ).run_in_background() def _options(self, did: DeckId) -> None: display_options_for_deck_id(did) def _collapse(self, did: DeckId) -> None: node = self.mw.col.decks.find_deck_in_tree(self._render_data.tree, did) if node: node.collapsed = not node.collapsed set_deck_collapsed( parent=self.mw, deck_id=did, collapsed=node.collapsed, scope=DeckCollapseScope.REVIEWER, ).run_in_background() self._renderPage(reuse=True) def _handle_drag_and_drop(self, source: DeckId, target: DeckId) -> None: reparent_decks( parent=self.mw, deck_ids=[source], new_parent=target ).run_in_background() def _delete(self, did: DeckId) -> None: deck = self.mw.col.decks.find_deck_in_tree(self._render_data.tree, did) assert deck is not None deck_name = deck.name remove_decks( parent=self.mw, deck_ids=[did], deck_name=deck_name ).run_in_background() # Top buttons ###################################################################### drawLinks = [ ["", "shared", tr.decks_get_shared()], ["", "create", tr.decks_create_deck()], ["Ctrl+Shift+I", "import", tr.decks_import_file()], ] def _drawButtons(self) -> None: buf = "" drawLinks = deepcopy(self.drawLinks) for b in drawLinks: if b[0]: b[0] = tr.actions_shortcut_key(val=shortcut(b[0])) buf += """ """ % tuple(b) self.bottom.draw( buf=buf, link_handler=self._linkHandler, web_context=DeckBrowserBottomBar(self), ) def _onShared(self) -> None: openLink(f"{aqt.appShared}decks/") def _on_create(self) -> None: if op := add_deck_dialog( parent=self.mw, default_text=self.mw.col.decks.current()["name"] ): op.run_in_background() ###################################################################### def _v1_upgrade_message(self, required: bool) -> str: if not required: return "" update_required = tr.scheduling_update_required().replace("V2", "v3") return f"""
{update_required}
""" def _confirm_upgrade(self) -> None: if self.mw.col.sched_ver() == 1: self.mw.col.mod_schema(check=True) self.mw.col.upgrade_to_v2_scheduler() self.mw.col.set_v3_scheduler(True) showInfo(tr.scheduling_update_done()) self.refresh() ================================================ FILE: qt/aqt/deckchooser.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Callable from anki.collection import OpChanges from anki.decks import DEFAULT_DECK_ID, DeckId from aqt import AnkiQt, gui_hooks from aqt.qt import * from aqt.qt import sip from aqt.utils import HelpPage, shortcut, tr class DeckChooser(QHBoxLayout): def __init__( self, mw: AnkiQt, widget: QWidget, label: bool = True, starting_deck_id: DeckId | None = None, on_deck_changed: Callable[[int], None] | None = None, dyn: bool = False, ) -> None: QHBoxLayout.__init__(self) self._widget = widget # type: ignore self.mw = mw self.dyn = dyn self._setup_ui(show_label=label) self._selected_deck_id = DeckId(0) # default to current deck if starting id not provided if starting_deck_id is None: starting_deck_id = DeckId(self.mw.col.get_config("curDeck", default=1) or 1) self.selected_deck_id = starting_deck_id self.on_deck_changed = on_deck_changed gui_hooks.operation_did_execute.append(self.on_operation_did_execute) def _setup_ui(self, show_label: bool) -> None: self.setContentsMargins(0, 0, 0, 0) self.setSpacing(8) # text label before button? if show_label: self.deckLabel = QLabel(tr.decks_deck()) self.addWidget(self.deckLabel) # decks box self.deck = QPushButton() qconnect(self.deck.clicked, self.choose_deck) self.deck.setAutoDefault(False) self.deck.setToolTip(shortcut(tr.qt_misc_target_deck_ctrlandd())) qconnect( QShortcut(QKeySequence("Ctrl+D"), self._widget).activated, self.choose_deck ) sizePolicy = QSizePolicy(QSizePolicy.Policy(7), QSizePolicy.Policy(0)) self.deck.setSizePolicy(sizePolicy) self.addWidget(self.deck) self._widget.setLayout(self) def selected_deck_name(self) -> str: return ( self.mw.col.decks.name_if_exists(self.selected_deck_id) or "missing default" ) @property def selected_deck_id(self) -> DeckId: self._ensure_selected_deck_valid() return self._selected_deck_id @selected_deck_id.setter def selected_deck_id(self, id: DeckId) -> None: if id != self._selected_deck_id: self._selected_deck_id = id self._ensure_selected_deck_valid() self._update_button_label() def _ensure_selected_deck_valid(self) -> None: deck = self.mw.col.decks.get(self._selected_deck_id, default=False) if not deck or (not self.dyn and deck["dyn"]): self.selected_deck_id = DEFAULT_DECK_ID def _update_button_label(self) -> None: if not sip.isdeleted(self.deck): self.deck.setText(self.selected_deck_name().replace("&", "&&")) def show(self) -> None: self._widget.show() # type: ignore def hide(self) -> None: self._widget.hide() # type: ignore def choose_deck(self) -> None: from aqt.studydeck import StudyDeck current = self.selected_deck_name() def callback(ret: StudyDeck) -> None: if not ret.name: return deck = self.mw.col.decks.by_name(ret.name) assert deck is not None new_selected_deck_id = deck["id"] if self.selected_deck_id != new_selected_deck_id: self.selected_deck_id = new_selected_deck_id if func := self.on_deck_changed: func(new_selected_deck_id) StudyDeck( self.mw, current=current, accept=tr.actions_choose(), title=tr.qt_misc_choose_deck(), help=HelpPage.EDITING, cancel=True, parent=self._widget, geomKey="selectDeck", callback=callback, dyn=self.dyn, ) def on_operation_did_execute( self, changes: OpChanges, handler: object | None ) -> None: if changes.deck: self._update_button_label() def cleanup(self) -> None: gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) # legacy onDeckChange = choose_deck deckName = selected_deck_name def selectedId(self) -> DeckId: return self.selected_deck_id ================================================ FILE: qt/aqt/deckconf.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from operator import itemgetter from typing import Any import aqt import aqt.forms from anki.consts import NEW_CARDS_RANDOM from anki.decks import DeckConfigDict from anki.lang import without_unicode_isolation from aqt import gui_hooks from aqt.qt import * from aqt.utils import ( HelpPage, askUser, disable_help_button, getOnlyText, openHelp, restoreGeom, saveGeom, showInfo, showWarning, tooltip, tr, ) class DeckConf(QDialog): def __init__(self, mw: aqt.AnkiQt, deck: dict) -> None: QDialog.__init__(self, mw) self.mw = mw self.deck = deck self.childDids = [d[1] for d in self.mw.col.decks.children(self.deck["id"])] self._origNewOrder = None self.form = aqt.forms.dconf.Ui_Dialog() self.form.setupUi(self) gui_hooks.deck_conf_did_setup_ui_form(self) self.setupCombos() self.setupConfs() qconnect( self.form.buttonBox.helpRequested, lambda: openHelp(HelpPage.DECK_OPTIONS) ) qconnect(self.form.confOpts.clicked, self.confOpts) qconnect( self.form.buttonBox.button( QDialogButtonBox.StandardButton.RestoreDefaults ).clicked, self.onRestore, ) self.setWindowTitle( without_unicode_isolation(tr.actions_options_for(val=self.deck["name"])) ) disable_help_button(self) # qt doesn't size properly with altered fonts otherwise restoreGeom(self, "deckconf", adjustSize=True) gui_hooks.deck_conf_will_show(self) self.open() saveGeom(self, "deckconf") def setupCombos(self) -> None: import anki.consts as cs f = self.form f.newOrder.addItems(list(cs.new_card_order_labels(self.mw.col).values())) qconnect(f.newOrder.currentIndexChanged, self.onNewOrderChanged) # Conf list ###################################################################### def setupConfs(self) -> None: qconnect(self.form.dconf.currentIndexChanged, self.onConfChange) self.conf: DeckConfigDict | None = None self.loadConfs() def loadConfs(self) -> None: current = self.deck["conf"] self.confList = self.mw.col.decks.all_config() self.confList.sort(key=itemgetter("name")) startOn = 0 self.ignoreConfChange = True self.form.dconf.clear() for idx, conf in enumerate(self.confList): self.form.dconf.addItem(conf["name"]) if str(conf["id"]) == str(current): startOn = idx self.ignoreConfChange = False self.form.dconf.setCurrentIndex(startOn) if self._origNewOrder is None: self._origNewOrder = self.confList[startOn]["new"]["order"] self.onConfChange(startOn) def confOpts(self) -> None: m = QMenu(self.mw) a = m.addAction(tr.actions_add()) qconnect(a.triggered, self.addGroup) a = m.addAction(tr.actions_delete()) qconnect(a.triggered, self.remGroup) a = m.addAction(tr.actions_rename()) qconnect(a.triggered, self.renameGroup) a = m.addAction(tr.scheduling_set_for_all_subdecks()) qconnect(a.triggered, self.setChildren) if not self.childDids: a.setEnabled(False) m.exec(QCursor.pos()) def onConfChange(self, idx: int) -> None: if self.ignoreConfChange: return if self.conf: self.saveConf() conf = self.confList[idx] self.deck["conf"] = conf["id"] self.mw.col.decks.save(self.deck) self.loadConf() cnt = len(self.mw.col.decks.decks_using_config(conf)) if cnt > 1: txt = tr.scheduling_your_changes_will_affect_multiple_decks() else: txt = "" self.form.count.setText(txt) def addGroup(self) -> None: name = getOnlyText(tr.scheduling_new_options_group_name()) if not name: return # first, save currently entered data to current conf self.saveConf() # then clone the conf id = self.mw.col.decks.add_config_returning_id(name, clone_from=self.conf) gui_hooks.deck_conf_did_add_config(self, self.deck, self.conf, name, id) # set the deck to the new conf self.deck["conf"] = id # then reload the conf list self.loadConfs() def remGroup(self) -> None: if int(self.conf["id"]) == 1: showInfo(tr.scheduling_the_default_configuration_cant_be_removed(), self) else: gui_hooks.deck_conf_will_remove_config(self, self.deck, self.conf) self.mw.col.mod_schema(check=True) self.mw.col.decks.remove_config(self.conf["id"]) self.conf = None self.deck["conf"] = 1 self.loadConfs() def renameGroup(self) -> None: old = self.conf["name"] name = getOnlyText(tr.actions_new_name(), default=old) if not name or name == old: return gui_hooks.deck_conf_will_rename_config(self, self.deck, self.conf, name) self.conf["name"] = name self.saveConf() self.loadConfs() def setChildren(self) -> None: if not askUser(tr.scheduling_set_all_decks_below_to(val=self.deck["name"])): return for did in self.childDids: deck = self.mw.col.decks.get(did) if deck["dyn"]: continue deck["conf"] = self.deck["conf"] self.mw.col.decks.save(deck) tooltip(tr.scheduling_deck_updated(count=len(self.childDids))) # Loading ################################################## def listToUser(self, l: list[int | float]) -> str: def num_to_user(n: int | float) -> str: if n == round(n): return str(int(n)) else: return str(n) return " ".join(map(num_to_user, l)) def parentLimText(self, type: str = "new") -> str: # top level? if "::" not in self.deck["name"]: return "" lim = -1 for d in self.mw.col.decks.parents(self.deck["id"]): c = self.mw.col.decks.config_dict_for_deck_id(d["id"]) x = c[type]["perDay"] if lim == -1: lim = x else: lim = min(x, lim) return tr.scheduling_parent_limit(val=lim) def loadConf(self) -> None: self.conf = self.mw.col.decks.config_dict_for_deck_id(self.deck["id"]) # new c = self.conf["new"] f = self.form f.lrnSteps.setText(self.listToUser(c["delays"])) f.lrnGradInt.setValue(c["ints"][0]) f.lrnEasyInt.setValue(c["ints"][1]) f.lrnFactor.setValue(int(c["initialFactor"] / 10.0)) f.newOrder.setCurrentIndex(c["order"]) f.newPerDay.setValue(c["perDay"]) f.bury.setChecked(c.get("bury", True)) f.newplim.setText(self.parentLimText("new")) # rev c = self.conf["rev"] f.revPerDay.setValue(c["perDay"]) f.easyBonus.setValue(int(c["ease4"] * 100)) f.fi1.setValue(c["ivlFct"] * 100) f.maxIvl.setValue(c["maxIvl"]) f.revplim.setText(self.parentLimText("rev")) f.buryRev.setChecked(c.get("bury", True)) f.hardFactor.setValue(int(c.get("hardFactor", 1.2) * 100)) # lapse c = self.conf["lapse"] f.lapSteps.setText(self.listToUser(c["delays"])) f.lapMult.setValue(int(c["mult"] * 100)) f.lapMinInt.setValue(c["minInt"]) f.leechThreshold.setValue(c["leechFails"]) f.leechAction.setCurrentIndex(c["leechAction"]) # general c = self.conf f.maxTaken.setValue(c["maxTaken"]) f.showTimer.setChecked(c.get("timer", 0)) f.autoplaySounds.setChecked(c["autoplay"]) f.replayQuestion.setChecked(c.get("replayq", True)) gui_hooks.deck_conf_did_load_config(self, self.deck, self.conf) def onRestore(self) -> None: self.mw.progress.start() self.mw.col.decks.restore_to_default(self.conf) self.mw.progress.finish() self.loadConf() # New order ################################################## def onNewOrderChanged(self, new: bool) -> None: old = self.conf["new"]["order"] if old == new: return self.conf["new"]["order"] = new self.mw.progress.start() self.mw.col.sched.resort_conf(self.conf) self.mw.progress.finish() # Saving ################################################## def updateList(self, conf: Any, key: str, w: QLineEdit, minSize: int = 1) -> None: items = str(w.text()).split(" ") ret = [] for item in items: if not item: continue try: i = float(item) if i <= 0: raise Exception("0 invalid") if i == int(i): i = int(i) ret.append(i) except Exception: # invalid, don't update showWarning(tr.scheduling_steps_must_be_numbers()) return if len(ret) < minSize: showWarning(tr.scheduling_at_least_one_step_is_required()) return conf[key] = ret def saveConf(self) -> None: # new c = self.conf["new"] f = self.form self.updateList(c, "delays", f.lrnSteps) c["ints"][0] = f.lrnGradInt.value() c["ints"][1] = f.lrnEasyInt.value() c["initialFactor"] = f.lrnFactor.value() * 10 c["order"] = f.newOrder.currentIndex() c["perDay"] = f.newPerDay.value() c["bury"] = f.bury.isChecked() if self._origNewOrder != c["order"]: # order of current deck has changed, so have to resort if c["order"] == NEW_CARDS_RANDOM: self.mw.col.sched.randomize_cards(self.deck["id"]) else: self.mw.col.sched.order_cards(self.deck["id"]) # rev c = self.conf["rev"] c["perDay"] = f.revPerDay.value() c["ease4"] = f.easyBonus.value() / 100.0 c["ivlFct"] = f.fi1.value() / 100.0 c["maxIvl"] = f.maxIvl.value() c["bury"] = f.buryRev.isChecked() c["hardFactor"] = f.hardFactor.value() / 100.0 # lapse c = self.conf["lapse"] self.updateList(c, "delays", f.lapSteps, minSize=0) c["mult"] = f.lapMult.value() / 100.0 c["minInt"] = f.lapMinInt.value() c["leechFails"] = f.leechThreshold.value() c["leechAction"] = f.leechAction.currentIndex() # general c = self.conf c["maxTaken"] = f.maxTaken.value() c["timer"] = f.showTimer.isChecked() and 1 or 0 c["autoplay"] = f.autoplaySounds.isChecked() c["replayq"] = f.replayQuestion.isChecked() gui_hooks.deck_conf_will_save_config(self, self.deck, self.conf) self.mw.col.decks.save(self.deck) self.mw.col.decks.save(self.conf) def reject(self) -> None: self.accept() def accept(self) -> None: self.saveConf() self.mw.reset() QDialog.accept(self) ================================================ FILE: qt/aqt/deckdescription.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import aqt import aqt.main import aqt.operations from anki.decks import DeckDict from aqt.operations import QueryOp from aqt.operations.deck import update_deck_dict from aqt.qt import * from aqt.utils import disable_help_button, restoreGeom, saveGeom, tr class DeckDescriptionDialog(QDialog): TITLE = "deckDescription" silentlyClose = True def __init__(self, mw: aqt.main.AnkiQt) -> None: QDialog.__init__(self, mw, Qt.WindowType.Window) self.mw = mw # set on success self.deck: DeckDict QueryOp( parent=self.mw, op=lambda col: col.decks.current(), success=self._setup_and_show, ).run_in_background() def _setup_and_show(self, deck: DeckDict) -> None: if deck["dyn"]: return self.deck = deck self._setup_ui() self.show() def _setup_ui(self) -> None: self.setWindowTitle(tr.scheduling_description()) self.setWindowModality(Qt.WindowModality.ApplicationModal) self.mw.garbage_collect_on_dialog_finish(self) self.setMinimumWidth(400) disable_help_button(self) restoreGeom(self, self.TITLE) box = QVBoxLayout() self.enable_markdown = QCheckBox(tr.deck_config_description_new_handling()) self.enable_markdown.setToolTip(tr.deck_config_description_new_handling_hint()) self.enable_markdown.setChecked(self.deck.get("md", False)) box.addWidget(self.enable_markdown) self.description = QPlainTextEdit() self.description.setPlainText(self.deck.get("desc", "")) box.addWidget(self.description) button_box = QDialogButtonBox() ok = button_box.addButton(QDialogButtonBox.StandardButton.Ok) assert ok is not None qconnect(ok.clicked, self.save_and_accept) box.addWidget(button_box) self.setLayout(box) self.show() def save_and_accept(self) -> None: self.deck["desc"] = self.description.toPlainText() self.deck["md"] = self.enable_markdown.isChecked() update_deck_dict(parent=self, deck=self.deck).success( lambda _: self.accept() ).run_in_background() def accept(self) -> None: saveGeom(self, self.TITLE) QDialog.accept(self) ================================================ FILE: qt/aqt/deckoptions.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import aqt import aqt.deckconf import aqt.main from anki.cards import Card from anki.decks import DeckDict, DeckId from anki.lang import without_unicode_isolation from aqt import gui_hooks from aqt.qt import * from aqt.utils import ( KeyboardModifiersPressed, disable_help_button, restoreGeom, saveGeom, tr, ) from aqt.webview import AnkiWebView, AnkiWebViewKind class DeckOptionsDialog(QDialog): "The new deck configuration screen." TITLE = "deckOptions" silentlyClose = True def __init__(self, mw: aqt.main.AnkiQt, deck: DeckDict) -> None: QDialog.__init__(self, mw, Qt.WindowType.Window) self.mw = mw self._deck = deck self._close_event_has_cleaned_up = False self._ready = False self._setup_ui() def _setup_ui(self) -> None: self.setWindowModality(Qt.WindowModality.ApplicationModal) self.mw.garbage_collect_on_dialog_finish(self) self.setMinimumWidth(400) disable_help_button(self) restoreGeom(self, self.TITLE, default_size=(800, 800)) self.web = AnkiWebView(kind=AnkiWebViewKind.DECK_OPTIONS) self.web.load_sveltekit_page(f"deck-options/{self._deck['id']}") layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self.web) self.setLayout(layout) self.show() self.web.hide_while_preserving_layout() self.setWindowTitle( without_unicode_isolation(tr.actions_options_for(val=self._deck["name"])) ) def set_ready(self): self._ready = True gui_hooks.deck_options_did_load(self) def closeEvent(self, evt: QCloseEvent | None) -> None: if self._close_event_has_cleaned_up or not self._ready: return super().closeEvent(evt) assert evt is not None evt.ignore() self.web.eval("anki.deckOptionsPendingChanges();") def require_close(self): """Close. Ensure the closeEvent is not ignored.""" self._close_event_has_cleaned_up = True self.close() def reject(self) -> None: self.mw.col.set_wants_abort() self.web.cleanup() self.web = None # type: ignore saveGeom(self, self.TITLE) QDialog.reject(self) def confirm_deck_then_display_options(active_card: Card | None = None) -> None: decks = [aqt.mw.col.decks.current()] if card := active_card: if card.odid and card.odid != decks[0]["id"]: deck = aqt.mw.col.decks.get(card.odid) assert deck is not None decks.append(deck) if not any(d["id"] == card.did for d in decks): deck = aqt.mw.col.decks.get(card.did) assert deck is not None decks.append(deck) if len(decks) == 1: display_options_for_deck(decks[0]) else: decks.sort(key=lambda x: x["dyn"]) _deck_prompt_dialog(decks) def _deck_prompt_dialog(decks: list[DeckDict]) -> None: diag = QDialog(aqt.mw.app.activeWindow()) diag.setWindowTitle("Anki") box = QVBoxLayout() box.addWidget(QLabel(tr.deck_config_which_deck())) for deck in decks: button = QPushButton(deck["name"]) qconnect(button.clicked, diag.close) qconnect(button.clicked, lambda _, deck=deck: display_options_for_deck(deck)) box.addWidget(button) button = QPushButton(tr.actions_cancel()) qconnect(button.clicked, diag.close) box.addWidget(button) diag.setLayout(box) diag.open() def display_options_for_deck_id(deck_id: DeckId) -> None: deck = aqt.mw.col.decks.get(deck_id) assert deck is not None display_options_for_deck(deck) def display_options_for_deck(deck: DeckDict) -> None: if not deck["dyn"]: if KeyboardModifiersPressed().shift or not aqt.mw.col.v3_scheduler(): deck_legacy = aqt.mw.col.decks.get(DeckId(deck["id"])) assert deck_legacy is not None aqt.deckconf.DeckConf(aqt.mw, deck_legacy) else: DeckOptionsDialog(aqt.mw, deck) else: aqt.dialogs.open("FilteredDeckConfigDialog", aqt.mw, deck_id=deck["id"]) ================================================ FILE: qt/aqt/editcurrent.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Callable import aqt.editor from anki.collection import OpChanges from anki.errors import NotFoundError from aqt import gui_hooks from aqt.qt import * from aqt.utils import restoreGeom, saveGeom, tr class EditCurrent(QMainWindow): def __init__(self, mw: aqt.AnkiQt) -> None: super().__init__(None, Qt.WindowType.Window) self.mw = mw self.form = aqt.forms.editcurrent.Ui_Dialog() self.form.setupUi(self) self.setWindowTitle(tr.editing_edit_current()) self.setMinimumHeight(400) self.setMinimumWidth(250) if not is_mac: self.setMenuBar(None) self.editor = aqt.editor.Editor( self.mw, self.form.fieldsArea, self, editor_mode=aqt.editor.EditorMode.EDIT_CURRENT, ) assert self.mw.reviewer.card is not None self.editor.card = self.mw.reviewer.card self.editor.set_note(self.mw.reviewer.card.note(), focusTo=0) restoreGeom(self, "editcurrent") close_button = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Close) assert close_button is not None close_button.setShortcut(QKeySequence("Ctrl+Return")) # qt5.14+ doesn't handle numpad enter on Windows self.compat_add_shorcut = QShortcut(QKeySequence("Ctrl+Enter"), self) qconnect(self.compat_add_shorcut.activated, close_button.click) gui_hooks.operation_did_execute.append(self.on_operation_did_execute) self.show() def on_operation_did_execute( self, changes: OpChanges, handler: object | None ) -> None: if changes.note_text and handler is not self.editor: # reload note note = self.editor.note try: assert note is not None note.load() except NotFoundError: # note's been deleted self.cleanup() self.close() return self.editor.set_note(note) def cleanup(self) -> None: gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) self.editor.cleanup() saveGeom(self, "editcurrent") aqt.dialogs.markClosed("EditCurrent") def reopen(self, mw: aqt.AnkiQt) -> None: if card := self.mw.reviewer.card: self.editor.card = card self.editor.set_note(card.note()) def closeEvent(self, evt: QCloseEvent | None) -> None: self.editor.call_after_note_saved(self.cleanup) def _saveAndClose(self) -> None: self.cleanup() self.mw.deferred_delete_and_garbage_collect(self) self.close() def closeWithCallback(self, onsuccess: Callable[[], None]) -> None: def callback() -> None: self._saveAndClose() onsuccess() self.editor.call_after_note_saved(callback) onReset = on_operation_did_execute ================================================ FILE: qt/aqt/editor.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import base64 import functools import html import itertools import json import mimetypes import os import re import urllib.error import urllib.parse import urllib.request import warnings from collections.abc import Callable from enum import Enum from random import randrange from typing import Any, Iterable, Match, cast import bs4 import requests from bs4 import BeautifulSoup import aqt import aqt.forms import aqt.operations import aqt.sound from anki._legacy import deprecated from anki.cards import Card from anki.collection import Config, SearchNode from anki.consts import MODEL_CLOZE from anki.hooks import runFilter from anki.httpclient import HttpClient from anki.models import NotetypeDict, NotetypeId, StockNotetype from anki.notes import Note, NoteFieldsCheckResult, NoteId from anki.utils import checksum, is_lin, is_win, namedtmp from aqt import AnkiQt, colors, gui_hooks from aqt.operations import QueryOp from aqt.operations.note import update_note from aqt.operations.notetype import update_notetype_legacy from aqt.qt import * from aqt.sound import av_player from aqt.theme import theme_manager from aqt.utils import ( HelpPage, KeyboardModifiersPressed, disable_help_button, getFile, openFolder, openHelp, qtMenuShortcutWorkaround, restoreGeom, saveGeom, shortcut, show_in_folder, showInfo, showWarning, tooltip, tr, ) from aqt.webview import AnkiWebView, AnkiWebViewKind pics = ("jpg", "jpeg", "png", "gif", "svg", "webp", "ico", "avif") audio = ( "3gp", "aac", "avi", "flac", "flv", "m4a", "mkv", "mov", "mp3", "mp4", "mpeg", "mpg", "oga", "ogg", "ogv", "ogx", "opus", "spx", "swf", "wav", "webm", ) class EditorMode(Enum): ADD_CARDS = 0 EDIT_CURRENT = 1 BROWSER = 2 class EditorState(Enum): """ Current input state of the editing UI. """ INITIAL = -1 FIELDS = 0 IO_PICKER = 1 IO_MASKS = 2 IO_FIELDS = 3 class Editor: """The screen that embeds an editing widget should listen for changes via the `operation_did_execute` hook, and call set_note() when the editor needs redrawing. The editor will cause that hook to be fired when it saves changes. To avoid an unwanted refresh, the parent widget should check if handler corresponds to this editor instance, and ignore the change if it does. """ def __init__( self, mw: AnkiQt, widget: QWidget, parentWindow: QWidget, addMode: bool | None = None, *, editor_mode: EditorMode = EditorMode.EDIT_CURRENT, ) -> None: self.mw = mw self.widget = widget self.parentWindow = parentWindow self.note: Note | None = None # legacy argument provided? if addMode is not None: editor_mode = EditorMode.ADD_CARDS if addMode else EditorMode.EDIT_CURRENT self.addMode = editor_mode is EditorMode.ADD_CARDS self.editorMode = editor_mode self.currentField: int | None = None # Similar to currentField, but not set to None on a blur. May be # outside the bounds of the current notetype. self.last_field_index: int | None = None # used when creating a copy of an existing note self.orig_note_id: NoteId | None = None # current card, for card layout self.card: Card | None = None self.state: EditorState = EditorState.INITIAL # used for the io mask editor's context menu self.last_io_image_path: str | None = None self._init_links() self.setupOuter() self.add_webview() self.setupWeb() self.setupShortcuts() self.setupColourPalette() gui_hooks.editor_did_init(self) # Initial setup ############################################################ def setupOuter(self) -> None: l = QVBoxLayout() l.setContentsMargins(0, 0, 0, 0) l.setSpacing(0) self.widget.setLayout(l) self.outerLayout = l def add_webview(self) -> None: self.web = EditorWebView(self.widget, self) self.web.set_bridge_command(self.onBridgeCmd, self) self.outerLayout.addWidget(self.web, 1) def setupWeb(self) -> None: if self.editorMode == EditorMode.ADD_CARDS: mode = "add" elif self.editorMode == EditorMode.BROWSER: mode = "browse" else: mode = "review" # then load page self.web.stdHtml( "", css=["css/editor.css"], js=[ "js/mathjax.js", "js/editor.js", ], context=self, default_css=False, ) self.web.eval(f"setupEditor('{mode}')") self.web.show() lefttopbtns: list[str] = [] gui_hooks.editor_did_init_left_buttons(lefttopbtns, self) lefttopbtns_defs = [ f"uiPromise.then((noteEditor) => noteEditor.toolbar.notetypeButtons.appendButton({{ component: editorToolbar.Raw, props: {{ html: {json.dumps(button)} }} }}, -1));" for button in lefttopbtns ] lefttopbtns_js = "\n".join(lefttopbtns_defs) righttopbtns: list[str] = [] gui_hooks.editor_did_init_buttons(righttopbtns, self) # legacy filter righttopbtns = runFilter("setupEditorButtons", righttopbtns, self) righttopbtns_defs = ", ".join([json.dumps(button) for button in righttopbtns]) righttopbtns_js = ( f""" require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].toolbar.toolbar.append({{ component: editorToolbar.AddonButtons, id: "addons", props: {{ buttons: [ {righttopbtns_defs} ] }}, }})); """ if len(righttopbtns) > 0 else "" ) self.web.eval(f"{lefttopbtns_js} {righttopbtns_js}") # Top buttons ###################################################################### def resourceToData(self, path: str) -> str: """Convert a file (specified by a path) into a data URI.""" if not os.path.exists(path): raise FileNotFoundError mime, _ = mimetypes.guess_type(path) with open(path, "rb") as fp: data = fp.read() data64 = b"".join(base64.encodebytes(data).splitlines()) return f"data:{mime};base64,{data64.decode('ascii')}" def addButton( self, icon: str | None, cmd: str, func: Callable[[Editor], None], tip: str = "", label: str = "", id: str | None = None, toggleable: bool = False, keys: str | None = None, disables: bool = True, rightside: bool = True, ) -> str: """Assign func to bridge cmd, register shortcut, return button""" def wrapped_func(editor: Editor) -> None: self.call_after_note_saved(functools.partial(func, editor), keepFocus=True) self._links[cmd] = wrapped_func if keys: def on_activated() -> None: wrapped_func(self) if toggleable: # generate a random id for triggering toggle id = id or str(randrange(1_000_000)) def on_hotkey() -> None: on_activated() self.web.eval( f'toggleEditorButton(document.getElementById("{id}"));' ) else: on_hotkey = on_activated QShortcut( # type: ignore QKeySequence(keys), self.widget, activated=on_hotkey, ) btn = self._addButton( icon, cmd, tip=tip, label=label, id=id, toggleable=toggleable, disables=disables, rightside=rightside, ) return btn def _addButton( self, icon: str | None, cmd: str, tip: str = "", label: str = "", id: str | None = None, toggleable: bool = False, disables: bool = True, rightside: bool = True, ) -> str: title_attribute = tip if icon: if icon.startswith("qrc:/"): iconstr = icon elif os.path.isabs(icon): iconstr = self.resourceToData(icon) else: iconstr = f"/_anki/imgs/{icon}.png" image_element = f'' else: image_element = "" if not label and icon: label_element = "" elif label: label_element = label else: label_element = cmd title_attribute = shortcut(title_attribute) id_attribute_assignment = f"id={id}" if id else "" class_attribute = "linkb" if rightside else "rounded" if not disables: class_attribute += " perm" return f"""""" def setupShortcuts(self) -> None: # if a third element is provided, enable shortcut even when no field selected cuts: list[tuple] = [] gui_hooks.editor_did_init_shortcuts(cuts, self) for row in cuts: if len(row) == 2: keys, fn = row fn = self._addFocusCheck(fn) else: keys, fn, _ = row QShortcut(QKeySequence(keys), self.widget, activated=fn) # type: ignore def setupColourPalette(self) -> None: if not (colors := self.mw.col.get_config("customColorPickerPalette")): return for i, colour in enumerate(colors[: QColorDialog.customCount()]): if not QColor.isValidColorName(colour): continue QColorDialog.setCustomColor(i, QColor.fromString(colour)) def _addFocusCheck(self, fn: Callable) -> Callable: def checkFocus() -> None: if self.currentField is None: return fn() return checkFocus def onFields(self) -> None: self.call_after_note_saved(self._onFields) def _onFields(self) -> None: from aqt.fields import FieldDialog FieldDialog(self.mw, self.note_type(), parent=self.parentWindow) def onCardLayout(self) -> None: self.call_after_note_saved(self._onCardLayout) def _onCardLayout(self) -> None: from aqt.clayout import CardLayout if self.card: ord = self.card.ord else: ord = 0 assert self.note is not None CardLayout( self.mw, self.note, ord=ord, parent=self.parentWindow, fill_empty=False, ) if is_win: self.parentWindow.activateWindow() # JS->Python bridge ###################################################################### def onBridgeCmd(self, cmd: str) -> Any: if not self.note: # shutdown return # focus lost or key/button pressed? if cmd.startswith("blur") or cmd.startswith("key"): (type, ord_str, nid_str, txt) = cmd.split(":", 3) ord = int(ord_str) try: nid = int(nid_str) except ValueError: nid = 0 if nid != self.note.id: print("ignored late blur") return try: self.note.fields[ord] = self.mungeHTML(txt) except IndexError: print("ignored late blur after notetype change") return if not self.addMode: self._save_current_note() if type == "blur": self.currentField = None # run any filters if gui_hooks.editor_did_unfocus_field(False, self.note, ord): # something updated the note; update it after a subsequent focus # event has had time to fire self.mw.progress.timer( 100, self.loadNoteKeepingFocus, False, parent=self.widget ) else: self._check_and_update_duplicate_display_async() else: gui_hooks.editor_did_fire_typing_timer(self.note) self._check_and_update_duplicate_display_async() # focused into field? elif cmd.startswith("focus"): (type, num) = cmd.split(":", 1) self.last_field_index = self.currentField = int(num) gui_hooks.editor_did_focus_field(self.note, self.currentField) elif cmd.startswith("toggleStickyAll"): model = self.note_type() flds = model["flds"] any_sticky = any([fld["sticky"] for fld in flds]) result = [] for fld in flds: if not any_sticky or fld["sticky"]: fld["sticky"] = not fld["sticky"] result.append(fld["sticky"]) update_notetype_legacy(parent=self.mw, notetype=model).run_in_background( initiator=self ) return result elif cmd.startswith("toggleSticky"): (type, num) = cmd.split(":", 1) ord = int(num) model = self.note_type() fld = model["flds"][ord] new_state = not fld["sticky"] fld["sticky"] = new_state update_notetype_legacy(parent=self.mw, notetype=model).run_in_background( initiator=self ) return new_state elif cmd.startswith("lastTextColor"): (_, textColor) = cmd.split(":", 1) assert self.mw.pm.profile is not None self.mw.pm.profile["lastTextColor"] = textColor elif cmd.startswith("lastHighlightColor"): (_, highlightColor) = cmd.split(":", 1) assert self.mw.pm.profile is not None self.mw.pm.profile["lastHighlightColor"] = highlightColor elif cmd.startswith("saveTags"): (type, tagsJson) = cmd.split(":", 1) self.note.tags = json.loads(tagsJson) gui_hooks.editor_did_update_tags(self.note) if not self.addMode: self._save_current_note() elif cmd.startswith("setTagsCollapsed"): (type, collapsed_string) = cmd.split(":", 1) collapsed = collapsed_string == "true" self.setTagsCollapsed(collapsed) elif cmd.startswith("editorState"): (_, new_state_id, old_state_id) = cmd.split(":", 2) self.signal_state_change( EditorState(int(new_state_id)), EditorState(int(old_state_id)) ) elif cmd.startswith("ioImageLoaded"): (_, path_or_nid_data) = cmd.split(":", 1) path_or_nid = json.loads(path_or_nid_data) if self.addMode: gui_hooks.editor_mask_editor_did_load_image(self, path_or_nid) else: gui_hooks.editor_mask_editor_did_load_image( self, NoteId(int(path_or_nid)) ) elif cmd in self._links: return self._links[cmd](self) else: print("uncaught cmd", cmd) def mungeHTML(self, txt: str) -> str: return gui_hooks.editor_will_munge_html(txt, self) def signal_state_change( self, new_state: EditorState, old_state: EditorState ) -> None: self.state = new_state gui_hooks.editor_state_did_change(self, new_state, old_state) # Setting/unsetting the current note ###################################################################### def set_note( self, note: Note | None, hide: bool = True, focusTo: int | None = None, ) -> None: "Make NOTE the current note." self.note = note self.currentField = None if self.note: self.loadNote(focusTo=focusTo) elif hide: self.widget.hide() def loadNoteKeepingFocus(self) -> None: self.loadNote(self.currentField) def loadNote(self, focusTo: int | None = None) -> None: if not self.note: return data = [ (fld, self.mw.col.media.escape_media_filenames(val)) for fld, val in self.note.items() ] note_type = self.note_type() flds = note_type["flds"] collapsed = [fld["collapsed"] for fld in flds] cloze_fields_ords = self.mw.col.models.cloze_fields(self.note.mid) cloze_fields = [ord in cloze_fields_ords for ord in range(len(flds))] plain_texts = [fld.get("plainText", False) for fld in flds] descriptions = [fld.get("description", "") for fld in flds] notetype_meta = {"id": self.note.mid, "modTime": note_type["mod"]} self.widget.show() note_fields_status = self.note.fields_check() def oncallback(arg: Any) -> None: if not self.note: return self.setupForegroundButton() # we currently do this synchronously to ensure we load before the # sidebar on browser startup self._update_duplicate_display(note_fields_status) if focusTo is not None: self.web.setFocus() gui_hooks.editor_did_load_note(self) assert self.mw.pm.profile is not None text_color = self.mw.pm.profile.get("lastTextColor", "#0000ff") highlight_color = self.mw.pm.profile.get("lastHighlightColor", "#0000ff") js = f""" saveSession(); setFields({json.dumps(data)}); setIsImageOcclusion({json.dumps(self.current_notetype_is_image_occlusion())}); setNotetypeMeta({json.dumps(notetype_meta)}); setCollapsed({json.dumps(collapsed)}); setClozeFields({json.dumps(cloze_fields)}); setPlainTexts({json.dumps(plain_texts)}); setDescriptions({json.dumps(descriptions)}); setFonts({json.dumps(self.fonts())}); focusField({json.dumps(focusTo)}); setNoteId({json.dumps(self.note.id)}); setColorButtons({json.dumps([text_color, highlight_color])}); setTags({json.dumps(self.note.tags)}); setTagsCollapsed({json.dumps(self.mw.pm.tags_collapsed(self.editorMode))}); setMathjaxEnabled({json.dumps(self.mw.col.get_config("renderMathjax", True))}); setShrinkImages({json.dumps(self.mw.col.get_config("shrinkEditorImages", True))}); setCloseHTMLTags({json.dumps(self.mw.col.get_config("closeHTMLTags", True))}); triggerChanges(); """ if self.addMode: sticky = [field["sticky"] for field in self.note_type()["flds"]] js += " setSticky(%s);" % json.dumps(sticky) if self.current_notetype_is_image_occlusion(): io_field_indices = self.mw.backend.get_image_occlusion_fields(self.note.mid) image_field = self.note.fields[io_field_indices.image] self.last_io_image_path = self.extract_img_path_from_html(image_field) if self.editorMode is not EditorMode.ADD_CARDS: io_options = self._create_edit_io_options(note_id=self.note.id) js += " setupMaskEditor(%s);" % json.dumps(io_options) elif orig_note_id := self.orig_note_id: self.orig_note_id = None io_options = self._create_clone_io_options(orig_note_id) js += " setupMaskEditor(%s);" % json.dumps(io_options) js = gui_hooks.editor_will_load_note(js, self.note, self) self.web.evalWithCallback( f'require("anki/ui").loaded.then(() => {{ {js} }})', oncallback ) def _save_current_note(self) -> None: "Call after note is updated with data from webview." if not self.note: return update_note(parent=self.widget, note=self.note).run_in_background( initiator=self ) def fonts(self) -> list[tuple[str, int, bool]]: return [ (gui_hooks.editor_will_use_font_for_field(f["font"]), f["size"], f["rtl"]) for f in self.note_type()["flds"] ] def call_after_note_saved( self, callback: Callable, keepFocus: bool = False ) -> None: "Save unsaved edits then call callback()." if not self.note: # calling code may not expect the callback to fire immediately self.mw.progress.single_shot(10, callback) return self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback()) saveNow = call_after_note_saved def _check_and_update_duplicate_display_async(self) -> None: note = self.note if not note: return def on_done(result: NoteFieldsCheckResult.V) -> None: if self.note != note: return self._update_duplicate_display(result) QueryOp( parent=self.parentWindow, op=lambda _: note.fields_check(), success=on_done, ).run_in_background() checkValid = _check_and_update_duplicate_display_async def _update_duplicate_display(self, result: NoteFieldsCheckResult.V) -> None: assert self.note is not None cols = [""] * len(self.note.fields) cloze_hint = "" if result == NoteFieldsCheckResult.DUPLICATE: cols[0] = "dupe" elif result == NoteFieldsCheckResult.NOTETYPE_NOT_CLOZE: cloze_hint = tr.adding_cloze_outside_cloze_notetype() elif result == NoteFieldsCheckResult.FIELD_NOT_CLOZE: cloze_hint = tr.adding_cloze_outside_cloze_field() self.web.eval( 'require("anki/ui").loaded.then(() => {' f"setBackgrounds({json.dumps(cols)});\n" f"setClozeHint({json.dumps(cloze_hint)});\n" "}); " ) def showDupes(self) -> None: assert self.note is not None aqt.dialogs.open( "Browser", self.mw, search=( SearchNode( dupe=SearchNode.Dupe( notetype_id=self.note_type()["id"], first_field=self.note.fields[0], ) ), ), ) def fieldsAreBlank(self, previousNote: Note | None = None) -> bool: if not self.note: return True m = self.note_type() for c, f in enumerate(self.note.fields): f = f.replace("
", "").strip() notChangedvalues = {"", "
"} if previousNote and m["flds"][c]["sticky"]: notChangedvalues.add(previousNote.fields[c].replace("
", "").strip()) if f not in notChangedvalues: return False return True def cleanup(self) -> None: av_player.stop_and_clear_queue_if_caller(self.editorMode) self.set_note(None) # prevent any remaining evalWithCallback() events from firing after C++ object deleted if self.web: self.web.cleanup() self.web = None # type: ignore # legacy setNote = set_note # Tag handling ###################################################################### def setupTags(self) -> None: import aqt.tagedit g = QGroupBox(self.widget) g.setStyleSheet("border: 0") tb = QGridLayout() tb.setSpacing(12) tb.setContentsMargins(2, 6, 2, 6) # tags l = QLabel(tr.editing_tags()) tb.addWidget(l, 1, 0) self.tags = aqt.tagedit.TagEdit(self.widget) qconnect(self.tags.lostFocus, self.on_tag_focus_lost) self.tags.setToolTip(shortcut(tr.editing_jump_to_tags_with_ctrlandshiftandt())) border = theme_manager.var(colors.BORDER) self.tags.setStyleSheet(f"border: 1px solid {border}") tb.addWidget(self.tags, 1, 1) g.setLayout(tb) self.outerLayout.addWidget(g) def updateTags(self) -> None: if self.tags.col != self.mw.col: self.tags.setCol(self.mw.col) if not self.tags.text() or not self.addMode: assert self.note is not None self.tags.setText(self.note.string_tags().strip()) def on_tag_focus_lost(self) -> None: assert self.note is not None self.note.tags = self.mw.col.tags.split(self.tags.text()) gui_hooks.editor_did_update_tags(self.note) if not self.addMode: self._save_current_note() def blur_tags_if_focused(self) -> None: if not self.note: return if self.tags.hasFocus(): self.widget.setFocus() def hideCompleters(self) -> None: self.tags.hideCompleter() def onFocusTags(self) -> None: self.tags.setFocus() # legacy def saveAddModeVars(self) -> None: pass saveTags = blur_tags_if_focused # Audio/video/images ###################################################################### def onAddMedia(self) -> None: """Show a file selection screen, then add the selected media. This expects initial setup to have been done by TemplateButtons.svelte.""" extension_filter = " ".join( f"*.{extension}" for extension in sorted(itertools.chain(pics, audio)) ) filter = f"{tr.editing_media()} ({extension_filter})" def accept(file: str) -> None: self.resolve_media(file) getFile( parent=self.widget, title=tr.editing_add_media(), cb=cast(Callable[[Any], None], accept), filter=filter, key="media", ) self.parentWindow.activateWindow() def addMedia(self, path: str, canDelete: bool = False) -> None: """Legacy routine used by add-ons to add a media file and update the current field. canDelete is ignored.""" try: html = self._addMedia(path) except Exception as e: showWarning(str(e)) return self.web.eval(f"setFormat('inserthtml', {json.dumps(html)});") def resolve_media(self, path: str) -> None: """Finish inserting media into a field. This expects initial setup to have been done by TemplateButtons.svelte.""" try: html = self._addMedia(path) except Exception as e: showWarning(str(e)) return self.web.eval( f'require("anki/TemplateButtons").resolveMedia({json.dumps(html)})' ) def _addMedia(self, path: str, canDelete: bool = False) -> str: """Add to media folder and return local img or sound tag.""" # copy to media folder fname = self.mw.col.media.add_file(path) # return a local html link return self.fnameToLink(fname) def _addMediaFromData(self, fname: str, data: bytes) -> str: return self.mw.col.media._legacy_write_data(fname, data) def onRecSound(self) -> None: aqt.sound.record_audio( self.parentWindow, self.mw, True, self.resolve_media, ) # Media downloads ###################################################################### def urlToLink(self, url: str, allowed_suffixes: Iterable[str] = ()) -> str: fname = ( self.urlToFile(url, allowed_suffixes) if allowed_suffixes else self.urlToFile(url) ) if not fname: return '{}'.format( url, html.escape(urllib.parse.unquote(url)) ) return self.fnameToLink(fname) def fnameToLink(self, fname: str) -> str: ext = fname.split(".")[-1].lower() if ext in pics: name = urllib.parse.quote(fname.encode("utf8")) return f'' else: av_player.play_file_with_caller(fname, self.editorMode) return f"[sound:{html.escape(fname, quote=False)}]" def urlToFile( self, url: str, allowed_suffixes: Iterable[str] = pics + audio ) -> str | None: l = url.lower() for suffix in allowed_suffixes: if l.endswith(f".{suffix}"): return self._retrieveURL(url) # not a supported type return None def isURL(self, s: str) -> bool: s = s.lower() return ( s.startswith("http://") or s.startswith("https://") or s.startswith("ftp://") or s.startswith("file://") ) def inlinedImageToFilename(self, txt: str) -> str: prefix = "data:image/" suffix = ";base64," for ext in ("jpg", "jpeg", "png", "gif"): fullPrefix = prefix + ext + suffix if txt.startswith(fullPrefix): b64data = txt[len(fullPrefix) :].strip() data = base64.b64decode(b64data, validate=True) if ext == "jpeg": ext = "jpg" return self._addPastedImage(data, ext) return "" def inlinedImageToLink(self, src: str) -> str: fname = self.inlinedImageToFilename(src) if fname: return self.fnameToLink(fname) return "" def _pasted_image_filename(self, data: bytes, ext: str) -> str: csum = checksum(data) return f"paste-{csum}.{ext}" def _read_pasted_image(self, mime: QMimeData) -> str: image = QImage(mime.imageData()) buffer = QBuffer() buffer.open(QBuffer.OpenModeFlag.ReadWrite) if self.mw.col.get_config_bool(Config.Bool.PASTE_IMAGES_AS_PNG): ext = "png" quality = 50 else: ext = "jpg" quality = 80 image.save(buffer, ext, quality) buffer.reset() data = bytes(buffer.readAll()) # type: ignore fname = self._pasted_image_filename(data, ext) path = namedtmp(fname) with open(path, "wb") as file: file.write(data) return path def _addPastedImage(self, data: bytes, ext: str) -> str: # hash and write fname = self._pasted_image_filename(data, ext) return self._addMediaFromData(fname, data) def _retrieveURL(self, url: str) -> str | None: "Download file into media folder and return local filename or None." local = url.lower().startswith("file://") # fetch it into a temporary folder self.mw.progress.start(immediate=not local, parent=self.parentWindow) content_type = None error_msg: str | None = None try: if local: # urllib doesn't understand percent-escaped utf8, but requires things like # '#' to be escaped. url = urllib.parse.unquote(url) url = url.replace("%", "%25") url = url.replace("#", "%23") req = urllib.request.Request( url, None, {"User-Agent": "Mozilla/5.0 (compatible; Anki)"} ) with urllib.request.urlopen(req) as response: filecontents = response.read() else: with HttpClient() as client: client.timeout = 30 with client.get(url) as response: if response.status_code != 200: error_msg = tr.qt_misc_unexpected_response_code( val=response.status_code, ) return None filecontents = response.content content_type = response.headers.get("content-type") except (urllib.error.URLError, requests.exceptions.RequestException) as e: error_msg = tr.editing_an_error_occurred_while_opening(val=str(e)) return None finally: self.mw.progress.finish() if error_msg: showWarning(error_msg) # strip off any query string url = re.sub(r"\?.*?$", "", url) fname = os.path.basename(urllib.parse.unquote(url)) if not fname.strip(): fname = "paste" if content_type: fname = self.mw.col.media.add_extension_based_on_mime(fname, content_type) return self.mw.col.media.write_data(fname, filecontents) # Paste/drag&drop ###################################################################### removeTags = ["script", "iframe", "object", "style"] def _pastePreFilter(self, html: str, internal: bool) -> str: # https://anki.tenderapp.com/discussions/ankidesktop/39543-anki-is-replacing-the-character-by-when-i-exit-the-html-edit-mode-ctrlshiftx if html.find(">") < 0: return html with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) doc = BeautifulSoup(html, "html.parser") if not internal: for tag_name in self.removeTags: for node in doc(tag_name): node.decompose() # convert p tags to divs for node in doc("p"): if hasattr(node, "name"): node.name = "div" for element in doc("img"): if not isinstance(element, bs4.Tag): continue tag = element try: src = tag["src"] except KeyError: # for some bizarre reason, mnemosyne removes src elements # from missing media continue # in internal pastes, rewrite mediasrv references to relative if internal: m = re.match(r"http://127.0.0.1:\d+/(.*)$", str(src)) if m: tag["src"] = m.group(1) # in external pastes, download remote media elif isinstance(src, str) and self.isURL(src): fname = self._retrieveURL(src) if fname: tag["src"] = fname elif isinstance(src, str) and src.startswith("data:image/"): # and convert inlined data tag["src"] = self.inlinedImageToFilename(str(src)) html = str(doc) return html def doPaste(self, html: str, internal: bool, extended: bool = False) -> None: html = self._pastePreFilter(html, internal) if extended: ext = "true" else: ext = "false" self.web.eval(f"pasteHTML({json.dumps(html)}, {json.dumps(internal)}, {ext});") gui_hooks.editor_did_paste(self, html, internal, extended) def doDrop( self, html: str, internal: bool, extended: bool, cursor_pos: QPoint ) -> None: def pasteIfField(ret: bool) -> None: if ret: self.doPaste(html, internal, extended) zoom = self.web.zoomFactor() x, y = int(cursor_pos.x() / zoom), int(cursor_pos.y() / zoom) self.web.evalWithCallback(f"focusIfField({x}, {y});", pasteIfField) def onPaste(self) -> None: self.web.onPaste() def onCutOrCopy(self) -> None: self.web.user_cut_or_copied() # Image occlusion ###################################################################### def current_notetype_is_image_occlusion(self) -> bool: if not self.note: return False return ( self.note_type().get("originalStockKind", None) == StockNotetype.OriginalStockKind.ORIGINAL_STOCK_KIND_IMAGE_OCCLUSION ) def setup_mask_editor(self, image_path: str) -> None: try: if self.editorMode == EditorMode.ADD_CARDS: self.setup_mask_editor_for_new_note( image_path=image_path, notetype_id=0 ) else: assert self.note is not None self.setup_mask_editor_for_existing_note( note_id=self.note.id, image_path=image_path ) except Exception as e: showWarning(str(e)) def select_image_and_occlude(self) -> None: """Show a file selection screen, then get selected image path.""" extension_filter = " ".join( f"*.{extension}" for extension in sorted(itertools.chain(pics)) ) filter = f"{tr.editing_media()} ({extension_filter})" getFile( parent=self.widget, title=tr.editing_add_media(), cb=cast(Callable[[Any], None], self.setup_mask_editor), filter=filter, key="media", ) self.parentWindow.activateWindow() def extract_img_path_from_html(self, html: str) -> str | None: assert self.note is not None # with allowed_suffixes=pics, all non-pics will be rendered as s and won't be included here if not (images := self.mw.col.media.files_in_str(self.note.mid, html)): return None image_path = urllib.parse.unquote(images[0]) return os.path.join(self.mw.col.media.dir(), image_path) def select_image_from_clipboard_and_occlude(self) -> None: """Set up the mask editor for the image in the clipboard.""" clipboard = self.mw.app.clipboard() assert clipboard is not None mime = clipboard.mimeData() assert mime is not None # try checking for urls first, fallback to image data if ( (html := self.web._processUrls(mime, allowed_suffixes=pics)) and (path := self.extract_img_path_from_html(html)) ) or (mime.hasImage() and (path := self._read_pasted_image(mime))): self.setup_mask_editor(path) self.parentWindow.activateWindow() else: showWarning(tr.editing_no_image_found_on_clipboard()) return def setup_mask_editor_for_new_note( self, image_path: str, notetype_id: NotetypeId | int = 0, ): """Set-up IO mask editor for adding new notes Presupposes that active editor notetype is an image occlusion notetype Args: image_path: Absolute path to image. notetype_id: ID of note type to use. Provided ID must belong to an image occlusion notetype. Set this to 0 to auto-select the first found image occlusion notetype in the user's collection. """ image_field_html = self._addMedia(image_path) self.last_io_image_path = self.extract_img_path_from_html(image_field_html) io_options = self._create_add_io_options( image_path=image_path, image_field_html=image_field_html, notetype_id=notetype_id, ) self._setup_mask_editor(io_options) def setup_mask_editor_for_existing_note( self, note_id: NoteId, image_path: str | None = None ): """Set-up IO mask editor for editing existing notes Presupposes that active editor notetype is an image occlusion notetype Args: note_id: ID of note to edit. image_path: (Optional) Absolute path to image that should replace current image """ io_options = self._create_edit_io_options(note_id) if image_path: image_field_html = self._addMedia(image_path) self.last_io_image_path = self.extract_img_path_from_html(image_field_html) self.web.eval(f"resetIOImage({json.dumps(image_path)})") self.web.eval(f"setImageField({json.dumps(image_field_html)})") self._setup_mask_editor(io_options) def reset_image_occlusion(self) -> None: self.web.eval("resetIOImageLoaded()") def update_occlusions_field(self) -> None: self.web.eval("saveOcclusions()") def _setup_mask_editor(self, io_options: dict): self.web.eval( 'require("anki/ui").loaded.then(() =>' f"setupMaskEditor({json.dumps(io_options)})" "); " ) @staticmethod def _create_add_io_options( image_path: str, image_field_html: str, notetype_id: NotetypeId | int = 0 ) -> dict: return { "mode": {"kind": "add", "imagePath": image_path, "notetypeId": notetype_id}, "html": image_field_html, } @staticmethod def _create_clone_io_options(orig_note_id: NoteId) -> dict: return { "mode": {"kind": "add", "clonedNoteId": orig_note_id}, } @staticmethod def _create_edit_io_options(note_id: NoteId) -> dict: return {"mode": {"kind": "edit", "noteId": note_id}} # Legacy editing routines ###################################################################### _js_legacy = "this routine has been moved into JS, and will be removed soon" @deprecated(info=_js_legacy) def onHtmlEdit(self) -> None: field = self.currentField self.call_after_note_saved(lambda: self._onHtmlEdit(field)) @deprecated(info=_js_legacy) def _onHtmlEdit(self, field: int) -> None: assert self.note is not None d = QDialog(self.widget, Qt.WindowType.Window) form = aqt.forms.edithtml.Ui_Dialog() form.setupUi(d) restoreGeom(d, "htmlEditor") disable_help_button(d) qconnect( form.buttonBox.helpRequested, lambda: openHelp(HelpPage.EDITING_FEATURES) ) font = QFont("Courier") font.setStyleHint(QFont.StyleHint.TypeWriter) form.textEdit.setFont(font) form.textEdit.setPlainText(self.note.fields[field]) d.show() form.textEdit.moveCursor(QTextCursor.MoveOperation.End) d.exec() html = form.textEdit.toPlainText() if html.find(">") > -1: # filter html through beautifulsoup so we can strip out things like a # leading html_escaped = self.mw.col.media.escape_media_filenames(html) with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) html_escaped = str(BeautifulSoup(html_escaped, "html.parser")) html = self.mw.col.media.escape_media_filenames( html_escaped, unescape=True ) self.note.fields[field] = html if not self.addMode: self._save_current_note() self.loadNote(focusTo=field) saveGeom(d, "htmlEditor") @deprecated(info=_js_legacy) def toggleBold(self) -> None: self.web.eval("setFormat('bold');") @deprecated(info=_js_legacy) def toggleItalic(self) -> None: self.web.eval("setFormat('italic');") @deprecated(info=_js_legacy) def toggleUnderline(self) -> None: self.web.eval("setFormat('underline');") @deprecated(info=_js_legacy) def toggleSuper(self) -> None: self.web.eval("setFormat('superscript');") @deprecated(info=_js_legacy) def toggleSub(self) -> None: self.web.eval("setFormat('subscript');") @deprecated(info=_js_legacy) def removeFormat(self) -> None: self.web.eval("setFormat('removeFormat');") @deprecated(info=_js_legacy) def onCloze(self) -> None: self.call_after_note_saved(self._onCloze, keepFocus=True) @deprecated(info=_js_legacy) def _onCloze(self) -> None: # check that the model is set up for cloze deletion if self.note_type()["type"] != MODEL_CLOZE: if self.addMode: tooltip(tr.editing_warning_cloze_deletions_will_not_work()) else: showInfo(tr.editing_to_make_a_cloze_deletion_on()) return # find the highest existing cloze highest = 0 assert self.note is not None for _, val in list(self.note.items()): m = re.findall(r"\{\{c(\d+)::", val) if m: highest = max(highest, sorted(int(x) for x in m)[-1]) # reuse last? if not KeyboardModifiersPressed().alt: highest += 1 # must start at 1 highest = max(1, highest) self.web.eval("wrap('{{c%d::', '}}');" % highest) def setupForegroundButton(self) -> None: assert self.mw.pm.profile is not None self.fcolour = self.mw.pm.profile.get("lastColour", "#00f") # use last colour @deprecated(info=_js_legacy) def onForeground(self) -> None: self._wrapWithColour(self.fcolour) # choose new colour @deprecated(info=_js_legacy) def onChangeCol(self) -> None: if is_lin: new = QColorDialog.getColor( QColor(self.fcolour), None, None, QColorDialog.ColorDialogOption.DontUseNativeDialog, ) else: new = QColorDialog.getColor(QColor(self.fcolour), None) # native dialog doesn't refocus us for some reason self.parentWindow.activateWindow() if new.isValid(): self.fcolour = new.name() self.onColourChanged() self._wrapWithColour(self.fcolour) @deprecated(info=_js_legacy) def _updateForegroundButton(self) -> None: pass @deprecated(info=_js_legacy) def onColourChanged(self) -> None: self._updateForegroundButton() assert self.mw.pm.profile is not None self.mw.pm.profile["lastColour"] = self.fcolour @deprecated(info=_js_legacy) def _wrapWithColour(self, colour: str) -> None: self.web.eval(f"setFormat('forecolor', '{colour}')") @deprecated(info=_js_legacy) def onAdvanced(self) -> None: m = QMenu(self.mw) for text, handler, shortcut in ( (tr.editing_mathjax_inline(), self.insertMathjaxInline, "Ctrl+M, M"), (tr.editing_mathjax_block(), self.insertMathjaxBlock, "Ctrl+M, E"), ( tr.editing_mathjax_chemistry(), self.insertMathjaxChemistry, "Ctrl+M, C", ), (tr.editing_latex(), self.insertLatex, "Ctrl+T, T"), (tr.editing_latex_equation(), self.insertLatexEqn, "Ctrl+T, E"), (tr.editing_latex_math_env(), self.insertLatexMathEnv, "Ctrl+T, M"), (tr.editing_edit_html(), self.onHtmlEdit, "Ctrl+Shift+X"), ): a = m.addAction(text) assert a is not None qconnect(a.triggered, handler) a.setShortcut(QKeySequence(shortcut)) qtMenuShortcutWorkaround(m) m.exec(QCursor.pos()) @deprecated(info=_js_legacy) def insertLatex(self) -> None: self.web.eval("wrap('[latex]', '[/latex]');") @deprecated(info=_js_legacy) def insertLatexEqn(self) -> None: self.web.eval("wrap('[$]', '[/$]');") @deprecated(info=_js_legacy) def insertLatexMathEnv(self) -> None: self.web.eval("wrap('[$$]', '[/$$]');") @deprecated(info=_js_legacy) def insertMathjaxInline(self) -> None: self.web.eval("wrap('\\\\(', '\\\\)');") @deprecated(info=_js_legacy) def insertMathjaxBlock(self) -> None: self.web.eval("wrap('\\\\[', '\\\\]');") @deprecated(info=_js_legacy) def insertMathjaxChemistry(self) -> None: self.web.eval("wrap('\\\\(\\\\ce{', '}\\\\)');") def toggleMathjax(self) -> None: self.mw.col.set_config( "renderMathjax", not self.mw.col.get_config("renderMathjax", False) ) # hackily redraw the page self.setupWeb() self.loadNoteKeepingFocus() def toggleShrinkImages(self) -> None: self.mw.col.set_config( "shrinkEditorImages", not self.mw.col.get_config("shrinkEditorImages", True), ) def toggleCloseHTMLTags(self) -> None: self.mw.col.set_config( "closeHTMLTags", not self.mw.col.get_config("closeHTMLTags", True), ) def setTagsCollapsed(self, collapsed: bool) -> None: aqt.mw.pm.set_tags_collapsed(self.editorMode, collapsed) # Links from HTML ###################################################################### def _init_links(self) -> None: self._links: dict[str, Callable] = dict( fields=Editor.onFields, cards=Editor.onCardLayout, bold=Editor.toggleBold, italic=Editor.toggleItalic, underline=Editor.toggleUnderline, super=Editor.toggleSuper, sub=Editor.toggleSub, clear=Editor.removeFormat, colour=Editor.onForeground, changeCol=Editor.onChangeCol, cloze=Editor.onCloze, attach=Editor.onAddMedia, record=Editor.onRecSound, more=Editor.onAdvanced, dupes=Editor.showDupes, paste=Editor.onPaste, cutOrCopy=Editor.onCutOrCopy, htmlEdit=Editor.onHtmlEdit, mathjaxInline=Editor.insertMathjaxInline, mathjaxBlock=Editor.insertMathjaxBlock, mathjaxChemistry=Editor.insertMathjaxChemistry, toggleMathjax=Editor.toggleMathjax, toggleShrinkImages=Editor.toggleShrinkImages, toggleCloseHTMLTags=Editor.toggleCloseHTMLTags, addImageForOcclusion=Editor.select_image_and_occlude, addImageForOcclusionFromClipboard=Editor.select_image_from_clipboard_and_occlude, ) def note_type(self) -> NotetypeDict: assert self.note is not None note_type = self.note.note_type() assert note_type is not None return note_type # Pasting, drag & drop, and keyboard layouts ###################################################################### class EditorWebView(AnkiWebView): def __init__(self, parent: QWidget, editor: Editor) -> None: AnkiWebView.__init__(self, kind=AnkiWebViewKind.EDITOR) self.editor = editor self.setAcceptDrops(True) self._store_field_content_on_next_clipboard_change = False # when we detect the user copying from a field, we store the content # here, and use it when they paste, so we avoid filtering field content self._internal_field_text_for_paste: str | None = None self._last_known_clipboard_mime: QMimeData | None = None clip = self.editor.mw.app.clipboard() assert clip is not None clip.dataChanged.connect(self._on_clipboard_change) gui_hooks.editor_web_view_did_init(self) def user_cut_or_copied(self) -> None: self._store_field_content_on_next_clipboard_change = True self._internal_field_text_for_paste = None def _on_clipboard_change( self, mode: QClipboard.Mode = QClipboard.Mode.Clipboard ) -> None: self._last_known_clipboard_mime = self._clipboard().mimeData(mode) if self._store_field_content_on_next_clipboard_change: # if the flag was set, save the field data self._internal_field_text_for_paste = self._get_clipboard_html_for_field( mode ) self._store_field_content_on_next_clipboard_change = False elif self._internal_field_text_for_paste != self._get_clipboard_html_for_field( mode ): # if we've previously saved the field, blank it out if the clipboard state has changed self._internal_field_text_for_paste = None def _get_clipboard_html_for_field(self, mode: QClipboard.Mode) -> str | None: clip = self._clipboard() if not (mime := clip.mimeData(mode)): return None if not mime.hasHtml(): return None return mime.html() def onCut(self) -> None: self.triggerPageAction(QWebEnginePage.WebAction.Cut) def onCopy(self) -> None: self.triggerPageAction(QWebEnginePage.WebAction.Copy) def on_copy_image(self) -> None: self.triggerPageAction(QWebEnginePage.WebAction.CopyImageToClipboard) def _opened_context_menu_on_image(self) -> bool: if not hasattr(self, "lastContextMenuRequest"): return False context_menu_request = self.lastContextMenuRequest() assert context_menu_request is not None return ( context_menu_request.mediaType() == context_menu_request.MediaType.MediaTypeImage ) def _wantsExtendedPaste(self) -> bool: strip_html = self.editor.mw.col.get_config_bool( Config.Bool.PASTE_STRIPS_FORMATTING ) if KeyboardModifiersPressed().shift: strip_html = not strip_html return not strip_html def _onPaste(self, mode: QClipboard.Mode) -> None: # Since _on_clipboard_change doesn't always trigger properly on macOS, we do a double check if any changes were made before pasting clipboard = self._clipboard() if self._last_known_clipboard_mime != clipboard.mimeData(mode): self._on_clipboard_change(mode) extended = self._wantsExtendedPaste() if html := self._internal_field_text_for_paste: print("reuse internal") self.editor.doPaste(html, True, extended) else: if not (mime := clipboard.mimeData(mode=mode)): return print("use clipboard") html, internal = self._processMime(mime, extended) if html: self.editor.doPaste(html, internal, extended) def onPaste(self) -> None: self._onPaste(QClipboard.Mode.Clipboard) def onMiddleClickPaste(self) -> None: self._onPaste(QClipboard.Mode.Selection) def dragEnterEvent(self, evt: QDragEnterEvent | None) -> None: assert evt is not None evt.accept() def dropEvent(self, evt: QDropEvent | None) -> None: assert evt is not None extended = self._wantsExtendedPaste() mime = evt.mimeData() assert mime is not None if ( self.editor.state is EditorState.IO_PICKER and (html := self._processUrls(mime, allowed_suffixes=pics)) and (path := self.editor.extract_img_path_from_html(html)) ): self.editor.setup_mask_editor(path) return evt_pos = evt.position() cursor_pos = QPoint(int(evt_pos.x()), int(evt_pos.y())) if evt.source() and mime.hasHtml(): # don't filter html from other fields html, internal = mime.html(), True else: html, internal = self._processMime(mime, extended, drop_event=True) if not html: return self.editor.doDrop(html, internal, extended, cursor_pos) # returns (html, isInternal) def _processMime( self, mime: QMimeData, extended: bool = False, drop_event: bool = False ) -> tuple[str, bool]: # print("html=%s image=%s urls=%s txt=%s" % ( # mime.hasHtml(), mime.hasImage(), mime.hasUrls(), mime.hasText())) # print("html", mime.html()) # print("urls", mime.urls()) # print("text", mime.text()) internal = False mime = gui_hooks.editor_will_process_mime( mime, self, internal, extended, drop_event ) # try various content types in turn if mime.hasHtml(): html_content = mime.html()[11:] if internal else mime.html() return html_content, internal # given _processUrls' extra allowed_suffixes kwarg, placate the typechecker def process_url(mime: QMimeData, extended: bool = False) -> str | None: return self._processUrls(mime, extended) # favour url if it's a local link if ( mime.hasUrls() and (urls := mime.urls()) and urls[0].toString().startswith("file://") ): types = (process_url, self._processImage, self._processText) else: types = (self._processImage, process_url, self._processText) for fn in types: html = fn(mime, extended) if html: return html, True return "", False def _processUrls( self, mime: QMimeData, extended: bool = False, allowed_suffixes: Iterable[str] = (), ) -> str | None: if not mime.hasUrls(): return None buf = "" for qurl in mime.urls(): url = qurl.toString() # chrome likes to give us the URL twice with a \n if lines := url.splitlines(): url = lines[0] buf += self.editor.urlToLink(url, allowed_suffixes=allowed_suffixes) return buf def _processText(self, mime: QMimeData, extended: bool = False) -> str | None: if not mime.hasText(): return None txt = mime.text() processed = [] lines = txt.split("\n") for line in lines: for token in re.split(r"(\S+)", line): # inlined data in base64? if extended and token.startswith("data:image/"): processed.append(self.editor.inlinedImageToLink(token)) elif extended and self.editor.isURL(token): # if the user is pasting an image or sound link, convert it to local, otherwise paste as a hyperlink link = self.editor.urlToLink(token) processed.append(link) else: token = html.escape(token).replace("\t", " " * 4) # if there's more than one consecutive space, # use non-breaking spaces for the second one on def repl(match: Match) -> str: return f"{match.group(1).replace(' ', ' ')} " token = re.sub(" ( +)", repl, token) processed.append(token) processed.append("
") # remove last
processed.pop() return "".join(processed) def _processImage(self, mime: QMimeData, extended: bool = False) -> str | None: if not mime.hasImage(): return None path = self.editor._read_pasted_image(mime) fname = self.editor._addMedia(path) return fname def contextMenuEvent(self, evt: QContextMenuEvent | None) -> None: m = QMenu(self) if self.hasSelection(): self._add_cut_action(m) self._add_copy_action(m) a = m.addAction(tr.editing_paste()) assert a is not None qconnect(a.triggered, self.onPaste) if self.editor.state is EditorState.IO_MASKS and ( path := self.editor.last_io_image_path ): self._add_image_menu_with_path(m, path) elif self._opened_context_menu_on_image(): self._add_image_menu(m) gui_hooks.editor_will_show_context_menu(self, m) m.popup(QCursor.pos()) def _add_cut_action(self, menu: QMenu) -> None: a = menu.addAction(tr.editing_cut()) assert a is not None qconnect(a.triggered, self.onCut) def _add_copy_action(self, menu: QMenu) -> None: a = menu.addAction(tr.actions_copy()) assert a is not None qconnect(a.triggered, self.onCopy) def _add_image_menu(self, menu: QMenu) -> None: a = menu.addAction(tr.editing_copy_image()) assert a is not None qconnect(a.triggered, self.on_copy_image) context_menu_request = self.lastContextMenuRequest() assert context_menu_request is not None url = context_menu_request.mediaUrl() file_name = url.fileName() path = os.path.join(self.editor.mw.col.media.dir(), file_name) self._add_image_menu_with_path(menu, path) def _add_image_menu_with_path(self, menu: QMenu, path: str) -> None: a = menu.addAction(tr.editing_open_image()) assert a is not None qconnect(a.triggered, lambda: openFolder(path)) a = menu.addAction(tr.editing_show_in_folder()) assert a is not None qconnect(a.triggered, lambda: show_in_folder(path)) def _clipboard(self) -> QClipboard: clipboard = self.editor.mw.app.clipboard() assert clipboard is not None return clipboard # QFont returns "Kozuka Gothic Pro L" but WebEngine expects "Kozuka Gothic Pro Light" # - there may be other cases like a trailing 'Bold' that need fixing, but will # wait for further reports first. def fontMungeHack(font: str) -> str: return re.sub(" L$", " Light", font) def munge_html(txt: str, editor: Editor) -> str: return "" if txt in ("
", "

") else txt def remove_null_bytes(txt: str, editor: Editor) -> str: # misbehaving apps may include a null byte in the text return txt.replace("\x00", "") def reverse_url_quoting(txt: str, editor: Editor) -> str: # reverse the url quoting we added to get images to display return editor.mw.col.media.escape_media_filenames(txt, unescape=True) gui_hooks.editor_will_use_font_for_field.append(fontMungeHack) gui_hooks.editor_will_munge_html.append(munge_html) gui_hooks.editor_will_munge_html.append(remove_null_bytes) gui_hooks.editor_will_munge_html.append(reverse_url_quoting) def set_cloze_button(editor: Editor) -> None: action = "show" if editor.note_type()["type"] == MODEL_CLOZE else "hide" editor.web.eval( 'require("anki/ui").loaded.then(() =>' f'require("anki/NoteEditor").instances[0].toolbar.toolbar.{action}("cloze")' "); " ) def set_image_occlusion_button(editor: Editor) -> None: action = "show" if editor.current_notetype_is_image_occlusion() else "hide" editor.web.eval( 'require("anki/ui").loaded.then(() =>' f'require("anki/NoteEditor").instances[0].toolbar.toolbar.{action}("image-occlusion-button")' "); " ) gui_hooks.editor_did_load_note.append(set_cloze_button) gui_hooks.editor_did_load_note.append(set_image_occlusion_button) ================================================ FILE: qt/aqt/emptycards.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import re from concurrent.futures import Future from typing import Any import aqt import aqt.forms import aqt.main from anki.cards import CardId from anki.collection import EmptyCardsReport from aqt import gui_hooks from aqt.qt import QDialog, QDialogButtonBox, qconnect from aqt.utils import disable_help_button, restoreGeom, saveGeom, tooltip, tr def show_empty_cards(mw: aqt.main.AnkiQt) -> None: mw.progress.start() def on_done(fut: Future) -> None: mw.progress.finish() report: EmptyCardsReport = fut.result() if not report.notes: tooltip(tr.empty_cards_not_found()) return diag = EmptyCardsDialog(mw, report) diag.show() mw.taskman.run_in_background(mw.col.get_empty_cards, on_done) class EmptyCardsDialog(QDialog): silentlyClose = True def __init__(self, mw: aqt.main.AnkiQt, report: EmptyCardsReport) -> None: super().__init__(mw) self.mw = mw self.mw.garbage_collect_on_dialog_finish(self) self.report = report self.form = aqt.forms.emptycards.Ui_Dialog() self.form.setupUi(self) restoreGeom(self, "emptycards") self.setWindowTitle(tr.empty_cards_window_title()) disable_help_button(self) self.form.keep_notes.setText(tr.empty_cards_preserve_notes_checkbox()) self.form.webview.set_bridge_command(self._on_note_link_clicked, self) gui_hooks.empty_cards_will_show(self) # make the note ids clickable html = re.sub( r"\[anki:nid:(\d+)\]", "
\\1: ", report.report, ) style = "" self.form.webview.stdHtml(style + html, context=self) def on_finished(code: Any) -> None: self.form.webview.cleanup() self.form.webview = None # type: ignore saveGeom(self, "emptycards") qconnect(self.finished, on_finished) self._delete_button = self.form.buttonBox.addButton( tr.empty_cards_delete_button(), QDialogButtonBox.ButtonRole.ActionRole ) assert self._delete_button is not None self._delete_button.setAutoDefault(False) qconnect(self._delete_button.clicked, self._on_delete) def _on_note_link_clicked(self, link: str) -> None: aqt.dialogs.open("Browser", self.mw, search=(link,)) def _on_delete(self) -> None: self.mw.progress.start() def delete() -> int: return self._delete_cards(self.form.keep_notes.isChecked()) def on_done(fut: Future) -> None: self.mw.progress.finish() try: count = fut.result() finally: self.close() tooltip(tr.empty_cards_deleted_count(cards=count)) self.mw.reset() self.mw.taskman.run_in_background(delete, on_done) def _delete_cards(self, keep_notes: bool) -> int: to_delete: list[CardId] = [] note: EmptyCardsReport.NoteWithEmptyCards for note in self.report.notes: if keep_notes and note.will_delete_note: # leave first card to_delete.extend([CardId(id) for id in note.card_ids[1:]]) else: to_delete.extend([CardId(id) for id in note.card_ids]) self.mw.col.remove_cards_and_orphaned_notes(to_delete) return len(to_delete) ================================================ FILE: qt/aqt/errors.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import os import re import sys import time import traceback from typing import TYPE_CHECKING, TextIO, cast from markdown import markdown import aqt from anki.collection import HelpPage from anki.errors import BackendError, CardTypeError, Interrupted from anki.utils import is_win from aqt.addons import AddonManager, AddonMeta from aqt.qt import * from aqt.utils import openHelp, showWarning, supportText, tooltip, tr if TYPE_CHECKING: from aqt.main import AnkiQt # so we can be non-modal/non-blocking, without Python deallocating the message # box ahead of time _mbox: QMessageBox | None = None def show_exception(*, parent: QWidget, exception: Exception) -> None: "Present a caught exception to the user using a pop-up." if isinstance(exception, Interrupted): # nothing to do return global _mbox error_lines = [] help_page = HelpPage.TROUBLESHOOTING # default to PlainText text_format = Qt.TextFormat.PlainText # set CardTypeError messages as rich text to allow HTML formatting if isinstance(exception, CardTypeError): text_format = Qt.TextFormat.RichText if isinstance(exception, BackendError): if exception.context: error_lines.append(exception.context) if exception.backtrace: error_lines.append(exception.backtrace) if exception.help_page is not None: help_page = exception.help_page else: # if the error is not originating from the backend, dump # a traceback to the console to aid in debugging error_lines = traceback.format_exception( None, exception, exception.__traceback__ ) error_text = "\n".join(error_lines) print(error_lines) _mbox = _init_message_box(str(exception), error_text, help_page, text_format) _mbox.show() def is_chromium_cert_error(error: str) -> bool: """QtWebEngine sometimes spits out 'unknown error' messages to stderr on Windows. They appear to be IDS_SETTINGS_CERTIFICATE_MANAGER_UNKNOWN_ERROR in chrome/browser/ui/webui/certificates_handler.cc. At a guess, it's the NetErrorToString() method. The constant appears to get converted to an ID; the resources are found in files like this: chrome/app/resources/generated_resources_fr-CA.xtb 2258:Erreur inconnue List derived with: qtwebengine-chromium% rg --no-heading --no-filename --no-line-number \ 3380365263193509176 | perl -pe 's/.*>(.*)<.*/"$1",/' | sort | uniq This list has been manually updated to add a different Japanese translation, as the translations may change in different Chromium releases. Judging by error reports, we can't assume the error falls on a separate line: https://forums.ankiweb.net/t/topic/22036/ """ if not is_win: return False for msg in ( "알 수 없는 오류가 발생했습니다.", "Bilinmeyen hata", "Eroare necunoscută", "Erreur inconnue", "Erreur inconnue.", "Erro descoñecido", "Erro desconhecido", "Error desconegut", "Error desconocido", "Errore ezezaguna", "Errore sconosciuto", "Gabim i panjohur", "Hindi kilalang error", "Hitilafu isiyojulikana", "Iphutha elingaziwa", "Ismeretlen hiba", "Kesalahan tidak dikenal", "Lỗi không xác định", "Naməlum xəta", "Nepoznata greška", "Nepoznata pogreška", "Nezināma kļūda", "Nežinoma klaida", "Neznáma chyba", "Neznámá chyba", "Neznana napaka", "Nieznany błąd", "Noma’lum xatolik", "Okänt fel", "Onbekende fout", "Óþekkt villa", "Ralat tidak diketahui", "Tundmatu viga", "Tuntematon virhe", "Ukendt fejl", "Ukjent feil", "Unbekannter Fehler", "Unknown error", "Άγνωστο σφάλμα", "Белгисиз ката", "Белгісіз қате", "Невідома помилка", "Невядомая памылка", "Неизвестна грешка", "Неизвестная ошибка", "Непозната грешка", "Үл мэдэгдэх алдаа", "უცნობი შეცდომა", "Անհայտ սխալ", "שגיאה לא ידועה", "خطأ غير معروف", "خطای ناشناس", "نامعلوم خرابی", "ያልታወቀ ስህተት", "अज्ञात एरर", "अज्ञात गड़बड़ी", "अज्ञात त्रुटि", "অজানা ত্রুটি", "অজ্ঞাত আসোঁৱাহ", "ਅਗਿਆਤ ਗੜਬੜ", "અજ્ઞાત ભૂલ", "ଅଜଣା ତୃଟି", "அறியப்படாத பிழை", "తెలియని ఎర్రర్", "ಅಪರಿಚಿತ ದೋಷ", "അജ്ഞാതമായ പിശക്", "නොදන්නා දෝෂය", "ข้อผิดพลาดที่ไม่รู้จัก", "ຄວາມຜິດພາດທີ່ບໍ່ຮູ້ຈັກ", "မသိရ အမှား", "កំហុសឆ្គងមិនស្គាល់", "不明なエラー", "未知のエラー", "未知的錯誤", "未知错误", ): if error.startswith(msg): return True return False if not os.environ.get("DEBUG"): def excepthook(etype, val, tb) -> None: # type: ignore sys.stderr.write("%s\n" % ("".join(traceback.format_exception(etype, val, tb)))) sys.excepthook = excepthook def _init_message_box( user_text: str, debug_text: str, help_page=HelpPage.TROUBLESHOOTING, text_format=Qt.TextFormat.PlainText, ): global _mbox _mbox = QMessageBox() _mbox.setWindowTitle("Anki") _mbox.setText(user_text) _mbox.setIcon(QMessageBox.Icon.Warning) _mbox.setTextFormat(text_format) def show_help(): openHelp(help_page) def copy_debug_info(): QApplication.clipboard().setText(debug_text) tooltip(tr.errors_copied_to_clipboard(), parent=_mbox) help = _mbox.addButton(QMessageBox.StandardButton.Help) if debug_text: debug_info = _mbox.addButton( tr.errors_copy_debug_info_button(), QMessageBox.ButtonRole.ActionRole ) debug_info.disconnect() debug_info.clicked.connect(copy_debug_info) cancel = _mbox.addButton(QMessageBox.StandardButton.Cancel) cancel.setText(tr.actions_close()) help.disconnect() help.clicked.connect(show_help) return _mbox class ErrorHandler(QObject): "Catch stderr and write into buffer." ivl = 100 fatal_error_encountered = False errorTimer = pyqtSignal() def __init__(self, mw: AnkiQt) -> None: QObject.__init__(self, mw) self.mw = mw self.timer: QTimer | None = None qconnect(self.errorTimer, self._setTimer) self.pool = "" self._oldstderr = sys.stderr sys.stderr = cast(TextIO, self) def unload(self) -> None: sys.stderr = self._oldstderr sys.excepthook = None def write(self, data: str) -> None: # dump to stdout sys.stdout.write(data) # save in buffer self.pool += data # and update timer self.setTimer() def setTimer(self) -> None: # we can't create a timer from a different thread, so we post a # message to the object on the main thread self.errorTimer.emit() # type: ignore def _setTimer(self) -> None: if not self.timer: self.timer = QTimer(self.mw) qconnect(self.timer.timeout, self.onTimeout) self.timer.setInterval(self.ivl) self.timer.setSingleShot(True) self.timer.start() def tempFolderMsg(self) -> str: return tr.qt_misc_unable_to_access_anki_media_folder() def onTimeout(self) -> None: if self.fatal_error_encountered: # suppress follow-up errors caused by the poisoned lock return error = self.pool self.pool = "" self.mw.progress.clear() if "AbortSchemaModification" in error: return if "DeprecationWarning" in error: return if "10013" in error: showWarning(tr.qt_misc_your_firewall_or_antivirus_program_is()) return if "invalidTempFolder" in error: showWarning(self.tempFolderMsg()) return if "Beautiful Soup is not an HTTP client" in error: return if "database or disk is full" in error or "Errno 28" in error: showWarning(tr.qt_misc_your_computers_storage_may_be_full()) return if "disk I/O error" in error: showWarning(markdown(tr.errors_accessing_db())) return if "unable to get local issuer certificate" in error and is_win: showWarning(tr.errors_windows_ssl_updates()) return if is_chromium_cert_error(error): return debug_text = supportText() + "\n" + error if "PanicException" in error: self.fatal_error_encountered = True # ensure no collection-related timers like backup fire self.mw.col = None user_text = "A fatal error occurred, and Anki must close. Please report this message on the forums." else: user_text = tr.errors_standard_popup2() if self.mw.addonManager.dirty: user_text += "\n\n" + self._addonText(error) debug_text += addon_debug_info() _mbox = _init_message_box(user_text, debug_text) if self.fatal_error_encountered: _mbox.exec() sys.exit(1) else: _mbox.show() def _addonText(self, error: str) -> str: matches = re.findall(r"addons21(/|\\)(.*?)(/|\\)", error) if not matches: return tr.errors_may_be_addon() # reverse to list most likely suspect first, dict to deduplicate: addons = [ aqt.mw.addonManager.addonName(i[1]) for i in dict.fromkeys(reversed(matches)) ] addons_str = ", ".join(addons) return tr.addons_possibly_involved(addons=addons_str) def addon_fmt(addmgr: AddonManager, addon: AddonMeta) -> str: installed = "0" if addon.installed_at: try: installed = time.strftime( "%Y-%m-%dT%H:%M", time.localtime(addon.installed_at) ) except (OverflowError, OSError): print("invalid timestamp for", addon.provided_name) if addon.provided_name: name = addon.provided_name else: name = "''" user = addmgr.getConfig(addon.dir_name) default = addmgr.addonConfigDefaults(addon.dir_name) if user == default: modified = "''" else: modified = "mod" return ( f"{name} ['{addon.dir_name}', {installed}, '{addon.human_version}', {modified}]" ) def addon_debug_info() -> str: from aqt import mw addmgr = mw.addonManager active = [] activeids = [] inactive = [] for addon in addmgr.all_addon_meta(): if addon.enabled: active.append(addon_fmt(addmgr, addon)) if addon.ankiweb_id(): activeids.append(addon.dir_name) else: inactive.append(addon_fmt(addmgr, addon)) newline = "\n" info = f"""\ ===Add-ons (active)=== (add-on provided name [Add-on folder, installed at, version, is config changed]) {newline.join(sorted(active))} ===IDs of active AnkiWeb add-ons=== {" ".join(activeids)} ===Add-ons (inactive)=== (add-on provided name [Add-on folder, installed at, version, is config changed]) {newline.join(sorted(inactive))} """ return info ================================================ FILE: qt/aqt/exporting.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import os import re import time from concurrent.futures import Future import aqt import aqt.forms import aqt.main from anki import hooks from anki.cards import CardId from anki.decks import DeckId from anki.exporting import Exporter, exporters from aqt import gui_hooks from aqt.errors import show_exception from aqt.qt import * from aqt.utils import ( checkInvalidFilename, disable_help_button, getSaveFile, showWarning, tooltip, tr, ) class ExportDialog(QDialog): def __init__( self, mw: aqt.main.AnkiQt, did: DeckId | None = None, cids: list[CardId] | None = None, parent: QWidget | None = None, ): QDialog.__init__(self, parent or mw, Qt.WindowType.Window) self.mw = mw self.col = mw.col.weakref() self.frm = aqt.forms.exporting.Ui_ExportDialog() self.frm.setupUi(self) self.frm.legacy_support.setVisible(False) self.exporter: Exporter | None = None self.cids = cids disable_help_button(self) self.setup(did) self.exec() def setup(self, did: DeckId | None) -> None: self.exporters = exporters(self.col) # if a deck specified, start with .apkg type selected idx = 0 if did or self.cids: for c, (k, e) in enumerate(self.exporters): if e.ext == ".apkg": idx = c break self.frm.format.insertItems(0, [e[0] for e in self.exporters]) self.frm.format.setCurrentIndex(idx) qconnect(self.frm.format.activated, self.exporterChanged) self.exporterChanged(idx) # deck list if self.cids is None: self.decks = [tr.exporting_all_decks()] self.decks.extend(d.name for d in self.col.decks.all_names_and_ids()) else: self.decks = [tr.exporting_selected_notes()] self.frm.deck.addItems(self.decks) # save button b = QPushButton(tr.exporting_export()) self.frm.buttonBox.addButton(b, QDialogButtonBox.ButtonRole.AcceptRole) # set default option if accessed through deck button if did: name = self.mw.col.decks.get(did)["name"] index = self.frm.deck.findText(name) self.frm.deck.setCurrentIndex(index) def exporterChanged(self, idx: int) -> None: self.exporter = self.exporters[idx][1](self.col) self.isApkg = self.exporter.ext == ".apkg" self.isVerbatim = getattr(self.exporter, "verbatim", False) self.isTextNote = getattr(self.exporter, "includeTags", False) self.frm.includeSched.setVisible( getattr(self.exporter, "includeSched", None) is not None ) self.frm.includeMedia.setVisible( getattr(self.exporter, "includeMedia", None) is not None ) self.frm.includeTags.setVisible( getattr(self.exporter, "includeTags", None) is not None ) html = getattr(self.exporter, "includeHTML", None) if html is not None: self.frm.includeHTML.setVisible(True) self.frm.includeHTML.setChecked(html) else: self.frm.includeHTML.setVisible(False) # show deck list? self.frm.deck.setVisible(not self.isVerbatim) # used by the new export screen self.frm.includeDeck.setVisible(False) self.frm.includeNotetype.setVisible(False) self.frm.includeGuid.setVisible(False) def accept(self) -> None: self.exporter.includeSched = self.frm.includeSched.isChecked() self.exporter.includeMedia = self.frm.includeMedia.isChecked() self.exporter.includeTags = self.frm.includeTags.isChecked() self.exporter.includeHTML = self.frm.includeHTML.isChecked() idx = self.frm.deck.currentIndex() if self.cids is not None: # Browser Selection self.exporter.cids = self.cids self.exporter.did = None elif idx == 0: # All decks self.exporter.did = None self.exporter.cids = None else: # Deck idx-1 in the list of decks self.exporter.cids = None name = self.decks[self.frm.deck.currentIndex()] self.exporter.did = self.col.decks.id(name) if self.isVerbatim: name = time.strftime("-%Y-%m-%d@%H-%M-%S", time.localtime(time.time())) deck_name = tr.exporting_collection() + name else: # Get deck name and remove invalid filename characters deck_name = self.decks[self.frm.deck.currentIndex()] deck_name = re.sub('[\\\\/?<>:*|"^]', "_", deck_name) filename = f"{deck_name}{self.exporter.ext}" if callable(self.exporter.key): key_str = self.exporter.key(self.col) else: key_str = self.exporter.key while 1: file = getSaveFile( self, tr.actions_export(), "export", key_str, self.exporter.ext, fname=filename, ) if not file: return if checkInvalidFilename(os.path.basename(file), dirsep=False): continue file = os.path.normpath(file) if os.path.commonprefix([self.mw.pm.base, file]) == self.mw.pm.base: showWarning("Please choose a different export location.") continue break self.hide() if file: # check we can write to file try: f = open(file, "wb") f.close() except OSError as e: showWarning(tr.exporting_couldnt_save_file(val=str(e))) else: os.unlink(file) # progress handler: old apkg exporter def exported_media_count(cnt: int) -> None: self.mw.taskman.run_on_main( lambda: self.mw.progress.update( label=tr.exporting_exported_media_file(count=cnt) ) ) # progress handler: adaptor for new colpkg importer into old exporting screen. # don't rename this; there's a hack in pylib/exporting.py that assumes this # name def exported_media(progress: str) -> None: self.mw.taskman.run_on_main( lambda: self.mw.progress.update(label=progress) ) def do_export() -> None: self.exporter.exportInto(file) def on_done(future: Future) -> None: self.mw.progress.finish() hooks.media_files_did_export.remove(exported_media_count) hooks.legacy_export_progress.remove(exported_media) try: # raises if exporter failed future.result() except Exception as exc: show_exception(parent=self.mw, exception=exc) self.on_export_failed() else: self.on_export_finished() gui_hooks.legacy_exporter_will_export(self.exporter) if self.isVerbatim: gui_hooks.collection_will_temporarily_close(self.mw.col) self.mw.progress.start() hooks.media_files_did_export.append(exported_media_count) hooks.legacy_export_progress.append(exported_media) self.mw.taskman.run_in_background(do_export, on_done) def on_export_finished(self) -> None: if self.isVerbatim: msg = tr.exporting_collection_exported() self.mw.reopen() elif self.isTextNote: msg = tr.exporting_note_exported(count=self.exporter.count) else: msg = tr.exporting_card_exported(count=self.exporter.count) gui_hooks.legacy_exporter_did_export(self.exporter) tooltip(msg, period=3000) QDialog.reject(self) def on_export_failed(self) -> None: if self.isVerbatim: self.mw.reopen() QDialog.reject(self) ================================================ FILE: qt/aqt/fields.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import aqt import aqt.forms import aqt.operations from anki.collection import OpChanges from anki.lang import without_unicode_isolation from anki.models import NotetypeDict from aqt import AnkiQt, gui_hooks from aqt.operations.notetype import update_notetype_legacy from aqt.qt import * from aqt.schema_change_tracker import ChangeTracker from aqt.utils import ( HelpPage, askUser, disable_help_button, getOnlyText, openHelp, show_warning, tooltip, tr, ) class FieldDialog(QDialog): def __init__( self, mw: AnkiQt, nt: NotetypeDict, parent: QWidget | None = None, open_at: int = 0, ) -> None: QDialog.__init__(self, parent or mw) mw.garbage_collect_on_dialog_finish(self) self.mw = mw self.col = self.mw.col self.mm = self.mw.col.models self.model = nt self.mm._remove_from_cache(self.model["id"]) self.change_tracker = ChangeTracker(self.mw) self.webview = None self.form = aqt.forms.fields.Ui_Dialog() self.form.setupUi(self) self.setWindowTitle( without_unicode_isolation(tr.fields_fields_for(val=self.model["name"])) ) disable_help_button(self) help_button = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Help) assert help_button is not None help_button.setAutoDefault(False) cancel_button = self.form.buttonBox.button( QDialogButtonBox.StandardButton.Cancel ) assert cancel_button is not None cancel_button.setAutoDefault(False) save_button = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Save) assert save_button is not None save_button.setAutoDefault(False) self.currentIdx: int | None = None self.fillFields() self.setupSignals() self.form.fieldList.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.form.fieldList.dropEvent = self.onDrop # type: ignore[assignment] self.form.fieldList.setCurrentRow(open_at) self.exec() def _on_bridge_cmd(self, cmd: str) -> bool: return False ########################################################################## def fillFields(self) -> None: self.currentIdx = None self.form.fieldList.clear() for c, f in enumerate(self.model["flds"]): self.form.fieldList.addItem(f"{c + 1}: {f['name']}") def setupSignals(self) -> None: f = self.form qconnect(f.fieldList.currentRowChanged, self.onRowChange) qconnect(f.fieldAdd.clicked, self.onAdd) qconnect(f.fieldDelete.clicked, self.onDelete) qconnect(f.fieldRename.clicked, self.onRename) qconnect(f.fieldPosition.clicked, self.onPosition) qconnect(f.sortField.clicked, self.onSortField) qconnect(f.buttonBox.helpRequested, self.onHelp) def onDrop(self, ev: QDropEvent) -> None: fieldList = self.form.fieldList indicatorPos = fieldList.dropIndicatorPosition() if qtmajor == 5: pos = ev.pos() # type: ignore else: pos = ev.position().toPoint() dropPos = fieldList.indexAt(pos).row() idx = self.currentIdx if dropPos == idx: return if ( indicatorPos == QAbstractItemView.DropIndicatorPosition.OnViewport ): # to bottom. movePos = fieldList.count() - 1 elif indicatorPos == QAbstractItemView.DropIndicatorPosition.AboveItem: movePos = dropPos elif indicatorPos == QAbstractItemView.DropIndicatorPosition.BelowItem: movePos = dropPos + 1 else: # for pylint return # the item in idx is removed thus subtract 1. assert idx is not None if idx < dropPos: movePos -= 1 self.moveField(movePos + 1) # convert to 1 based. def onRowChange(self, idx: int) -> None: if idx == -1: return self.saveField() self.loadField(idx) def _uniqueName(self, prompt: str, old: str = "") -> str | None: txt = getOnlyText(prompt, default=old).replace('"', "").strip() if not txt: return None if txt[0] in "#^/": show_warning(tr.fields_name_first_letter_not_valid()) return None for letter in """:{"}""": if letter in txt: show_warning(tr.fields_name_invalid_letter()) return None if txt.casefold() == old.casefold(): return None for f in self.model["flds"]: if f["name"].casefold() == txt.casefold(): show_warning(tr.fields_that_field_name_is_already_used()) return None return txt def onRename(self) -> None: if self.currentIdx is None: return idx = self.currentIdx f = self.model["flds"][idx] name = self._uniqueName(tr.actions_new_name(), f["name"]) if not name: return old_name = f["name"] self.change_tracker.mark_basic() self.mm.rename_field(self.model, f, name) gui_hooks.fields_did_rename_field(self, f, old_name) self.saveField() self.fillFields() self.form.fieldList.setCurrentRow(idx) def onAdd(self) -> None: name = self._uniqueName(tr.fields_field_name()) if not name: return if not self.change_tracker.mark_schema(): return self.saveField() f = self.mm.new_field(name) self.mm.add_field(self.model, f) gui_hooks.fields_did_add_field(self, f) self.fillFields() self.form.fieldList.setCurrentRow(len(self.model["flds"]) - 1) def onDelete(self) -> None: if len(self.model["flds"]) < 2: show_warning(tr.fields_notes_require_at_least_one_field()) return field = self.model["flds"][self.form.fieldList.currentRow()] if field["preventDeletion"]: show_warning(tr.fields_field_is_required()) return count = self.mm.use_count(self.model) c = tr.browsing_note_count(count=count) if not askUser(tr.fields_delete_field_from(val=c)): return if not self.change_tracker.mark_schema(): return self.mm.remove_field(self.model, field) gui_hooks.fields_did_delete_field(self, field) self.fillFields() self.form.fieldList.setCurrentRow(0) def onPosition(self, delta: int = -1) -> None: idx = self.currentIdx assert idx is not None l = len(self.model["flds"]) txt = getOnlyText(tr.fields_new_position_1(val=l), default=str(idx + 1)) if not txt: return try: pos = int(txt) except ValueError: return if not 0 < pos <= l: return self.moveField(pos) def onSortField(self) -> None: if not self.change_tracker.mark_schema(): return # don't allow user to disable; it makes no sense self.form.sortField.setChecked(True) self.mm.set_sort_index(self.model, self.form.fieldList.currentRow()) def moveField(self, pos: int) -> None: if not self.change_tracker.mark_schema(): return self.saveField() f = self.model["flds"][self.currentIdx] self.mm.reposition_field(self.model, f, pos - 1) self.fillFields() self.form.fieldList.setCurrentRow(pos - 1) def loadField(self, idx: int) -> None: self.currentIdx = idx fld = self.model["flds"][idx] f = self.form f.fontFamily.setCurrentFont(QFont(fld["font"])) f.fontSize.setValue(fld["size"]) f.sortField.setChecked(self.model["sortf"] == fld["ord"]) f.rtl.setChecked(fld["rtl"]) f.plainTextByDefault.setChecked(fld["plainText"]) f.collapseByDefault.setChecked(fld["collapsed"]) f.excludeFromSearch.setChecked(fld["excludeFromSearch"]) f.fieldDescription.setText(fld.get("description", "")) def saveField(self) -> None: # not initialized yet? if self.currentIdx is None: return idx = self.currentIdx fld = self.model["flds"][idx] f = self.form font = f.fontFamily.currentFont().family() if fld["font"] != font: fld["font"] = font self.change_tracker.mark_basic() size = f.fontSize.value() if fld["size"] != size: fld["size"] = size self.change_tracker.mark_basic() rtl = f.rtl.isChecked() if fld["rtl"] != rtl: fld["rtl"] = rtl self.change_tracker.mark_basic() plain_text = f.plainTextByDefault.isChecked() if fld["plainText"] != plain_text: fld["plainText"] = plain_text self.change_tracker.mark_basic() collapsed = f.collapseByDefault.isChecked() if fld["collapsed"] != collapsed: fld["collapsed"] = collapsed self.change_tracker.mark_basic() exclude_from_search = f.excludeFromSearch.isChecked() if fld["excludeFromSearch"] != exclude_from_search: fld["excludeFromSearch"] = exclude_from_search self.change_tracker.mark_basic() desc = f.fieldDescription.text() if fld.get("description", "") != desc: fld["description"] = desc self.change_tracker.mark_basic() def reject(self) -> None: if self.webview: self.webview.cleanup() self.webview = None if self.change_tracker.changed(): if not askUser(tr.card_templates_discard_changes()): return QDialog.reject(self) def accept(self) -> None: self.saveField() def on_done(changes: OpChanges) -> None: tooltip(tr.card_templates_changes_saved(), parent=self.parentWidget()) QDialog.accept(self) update_notetype_legacy( parent=self.mw, notetype=self.model, skip_checks=True ).success(on_done).run_in_background() def onHelp(self) -> None: openHelp(HelpPage.CUSTOMIZING_FIELDS) ================================================ FILE: qt/aqt/filtered_deck.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import aqt import aqt.forms import aqt.operations from anki.collection import OpChangesWithId, SearchNode from anki.decks import DeckDict, DeckId, FilteredDeckConfig from anki.errors import SearchError from anki.lang import without_unicode_isolation from anki.scheduler import FilteredDeckForUpdate from aqt import AnkiQt, colors, gui_hooks from aqt.operations import QueryOp from aqt.operations.scheduling import add_or_update_filtered_deck from aqt.qt import * from aqt.theme import theme_manager from aqt.utils import ( HelpPage, disable_help_button, openHelp, restoreGeom, saveGeom, showWarning, tr, ) class FilteredDeckConfigDialog(QDialog): """Dialog to modify and (re)build a filtered deck.""" GEOMETRY_KEY = "dyndeckconf" DIALOG_KEY = "FilteredDeckConfigDialog" silentlyClose = True def __init__( self, mw: AnkiQt, deck_id: DeckId = DeckId(0), search: str | None = None, search_2: str | None = None, ) -> None: """If 'deck_id' is non-zero, load and modify its settings. Otherwise, build a new deck and derive settings from the current deck. If search or search_2 are provided, they will be used as the default search text. """ QDialog.__init__(self, mw) self.mw = mw mw.garbage_collect_on_dialog_finish(self) self.col = self.mw.col self._desired_search_1 = search self._desired_search_2 = search_2 self._initial_dialog_setup() # set on successful query self.deck: FilteredDeckForUpdate QueryOp( parent=self.mw, op=lambda col: col.sched.get_or_create_filtered_deck(deck_id=deck_id), success=self.load_deck_and_show, ).failure(self.on_fetch_error).run_in_background() def on_fetch_error(self, exc: Exception) -> None: showWarning(str(exc)) self.close() def _initial_dialog_setup(self) -> None: self.form = aqt.forms.filtered_deck.Ui_Dialog() self.form.setupUi(self) order_labels = self.col.sched.filtered_deck_order_labels() self.form.order.addItems(order_labels) self.form.order_2.addItems(order_labels) qconnect(self.form.allow_empty.stateChanged, self._on_allow_empty_toggled) qconnect(self.form.resched.stateChanged, self._onReschedToggled) qconnect(self.form.search_button.clicked, self.on_search_button) qconnect(self.form.search_button_2.clicked, self.on_search_button_2) qconnect(self.form.hint_button.clicked, self.on_hint_button) blue = theme_manager.var(colors.FG_LINK) grey = theme_manager.var(colors.FG_DISABLED) self.setStyleSheet( f"""QPushButton[label] {{ padding: 0; border: 0 }} QPushButton[label]:hover {{ text-decoration: underline }} QPushButton[label="search"] {{ color: {blue} }} QPushButton[label="hint"] {{ color: {grey} }}""" ) disable_help_button(self) self.setWindowModality(Qt.WindowModality.WindowModal) qconnect( self.form.buttonBox.helpRequested, lambda: openHelp(HelpPage.FILTERED_DECK) ) self.form.again_delay_label.setText( tr.decks_delay_for_button(button=tr.studying_again()) ) self.form.hard_delay_label.setText( tr.decks_delay_for_button(button=tr.studying_hard()) ) self.form.good_delay_label.setText( tr.decks_delay_for_button(button=tr.studying_good()) ) restoreGeom(self, self.GEOMETRY_KEY) def load_deck_and_show(self, deck: FilteredDeckForUpdate) -> None: self.deck = deck self._load_deck() self.show() def _load_deck(self) -> None: form = self.form deck = self.deck config = deck.config self.form.name.setText(deck.name) self.form.name.setPlaceholderText(deck.name) existing = deck.id != 0 if existing: build_label = tr.actions_rebuild() else: build_label = tr.decks_build() ok_button = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Ok) assert ok_button is not None ok_button.setText(build_label) form.resched.setChecked(config.reschedule) self._onReschedToggled(0) term1: FilteredDeckConfig.SearchTerm = config.search_terms[0] form.search.setText(term1.search) form.order.setCurrentIndex(term1.order) form.limit.setValue(term1.limit) form.preview_again.setValue(config.preview_again_secs) form.preview_hard.setValue(config.preview_hard_secs) form.preview_good.setValue(config.preview_good_secs) if len(config.search_terms) > 1: term2: FilteredDeckConfig.SearchTerm = config.search_terms[1] form.search_2.setText(term2.search) form.order_2.setCurrentIndex(term2.order) form.limit_2.setValue(term2.limit) show_second = existing else: show_second = False form.order_2.setCurrentIndex(5) form.limit_2.setValue(20) form.secondFilter.setChecked(show_second) form.filter2group.setVisible(show_second) self.set_custom_searches(self._desired_search_1, self._desired_search_2) self.setWindowTitle( without_unicode_isolation(tr.actions_options_for(val=self.deck.name)) ) gui_hooks.filtered_deck_dialog_did_load_deck(self, deck) def reopen( self, _mw: AnkiQt, search: str | None = None, search_2: str | None = None, _deck: DeckDict | None = None, ) -> None: self.set_custom_searches(search, search_2) def set_custom_searches(self, search: str | None, search_2: str | None) -> None: if search is not None: self.form.search.setText(search) self.form.search.setFocus() self.form.search.selectAll() if search_2 is not None: self.form.secondFilter.setChecked(True) self.form.filter2group.setVisible(True) self.form.search_2.setText(search_2) self.form.search_2.setFocus() self.form.search_2.selectAll() def on_search_button(self) -> None: self._on_search_button(self.form.search) def on_search_button_2(self) -> None: self._on_search_button(self.form.search_2) def _on_search_button(self, line: QLineEdit) -> None: try: search = self.col.build_search_string(line.text()) except SearchError as err: line.setFocus() line.selectAll() showWarning(str(err)) else: aqt.dialogs.open("Browser", self.mw, search=(search,)) def on_hint_button(self) -> None: """Open the browser to show cards that match the typed-in filters but cannot be included due to internal limitations. """ manual_filters = (self.form.search.text(), *self._second_filter()) implicit_filters = ( SearchNode(card_state=SearchNode.CARD_STATE_SUSPENDED), SearchNode(card_state=SearchNode.CARD_STATE_BURIED), *self._filtered_search_node(), ) manual_filter = self.col.group_searches(*manual_filters, joiner="OR") implicit_filter = self.col.group_searches(*implicit_filters, joiner="OR") try: search = self.col.build_search_string(manual_filter, implicit_filter) except Exception as err: showWarning(str(err)) else: aqt.dialogs.open("Browser", self.mw, search=(search,)) def _second_filter(self) -> tuple[str, ...]: if self.form.secondFilter.isChecked(): return (self.form.search_2.text(),) return () def _filtered_search_node(self) -> tuple[SearchNode]: """Return a search node that matches cards in filtered decks, if applicable excluding those in the deck being rebuild.""" if self.deck.id: return ( self.col.group_searches( SearchNode(deck="filtered"), SearchNode(negated=SearchNode(deck=self.deck.name)), ), ) return (SearchNode(deck="filtered"),) def _onReschedToggled(self, _state: int) -> None: self.form.previewDelayWidget.setVisible(not self.form.resched.isChecked()) def _on_allow_empty_toggled(self) -> None: self.deck.allow_empty = self.form.allow_empty.isChecked() def _update_deck(self) -> bool: """Update our stored deck with the details from the GUI. If false, abort adding.""" form = self.form deck = self.deck config = deck.config deck.name = form.name.text() config.reschedule = form.resched.isChecked() del config.delays[:] terms = [ FilteredDeckConfig.SearchTerm( search=form.search.text(), limit=form.limit.value(), order=form.order.currentIndex(), # type: ignore[arg-type] ) ] if form.secondFilter.isChecked(): terms.append( FilteredDeckConfig.SearchTerm( search=form.search_2.text(), limit=form.limit_2.value(), order=form.order_2.currentIndex(), # type: ignore[arg-type] ) ) del config.search_terms[:] config.search_terms.extend(terms) config.preview_again_secs = form.preview_again.value() config.preview_hard_secs = form.preview_hard.value() config.preview_good_secs = form.preview_good.value() return True def reject(self) -> None: aqt.dialogs.markClosed(self.DIALOG_KEY) QDialog.reject(self) def accept(self) -> None: if not self._update_deck(): return def success(out: OpChangesWithId) -> None: gui_hooks.filtered_deck_dialog_did_add_or_update_deck( self, self.deck, out.id ) saveGeom(self, self.GEOMETRY_KEY) aqt.dialogs.markClosed(self.DIALOG_KEY) QDialog.accept(self) gui_hooks.filtered_deck_dialog_will_add_or_update_deck(self, self.deck) add_or_update_filtered_deck(parent=self, deck=self.deck).success( success ).run_in_background() ================================================ FILE: qt/aqt/flags.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from dataclasses import dataclass from typing import cast import aqt import aqt.main from anki.collection import SearchNode from aqt import colors, gui_hooks from aqt.theme import ColoredIcon from aqt.utils import tr @dataclass class Flag: """A container class for flag related data. index -- The integer by which the flag is represented internally (1-7). label -- The text by which the flag is described in the GUI. icon -- The icon by which the flag is represented in the GUI. search_node -- The node to build a search string for finding cards with the flag. action -- The name of the action to assign the flag in the browser form. """ index: int label: str icon: ColoredIcon search_node: SearchNode action: str class FlagManager: def __init__(self, mw: aqt.main.AnkiQt) -> None: self.mw = mw self._flags: list[Flag] = [] def all(self) -> list[Flag]: """Return a list of all flags.""" if not self._flags: self._load_flags() return self._flags def get_flag(self, flag_index: int) -> Flag: if not 1 <= flag_index <= len(self.all()): raise Exception(f"Flag index out of range (1-{len(self.all())}).") return self.all()[flag_index - 1] def rename_flag(self, flag_index: int, new_name: str) -> None: if new_name in ("", self.get_flag(flag_index).label): return labels = self.mw.col.get_config("flagLabels", {}) labels[str(flag_index)] = self.get_flag(flag_index).label = new_name self.mw.col.set_config("flagLabels", labels) gui_hooks.flag_label_did_change() def restore_default_flag_name(self, flag_index: int) -> None: labels = self.mw.col.get_config("flagLabels", {}) if str(flag_index) not in labels: return del labels[str(flag_index)] self.mw.col.set_config("flagLabels", labels) self.require_refresh() gui_hooks.flag_label_did_change() def require_refresh(self) -> None: "Discard cached labels." self._flags = [] def _load_flags(self) -> None: labels = cast(dict[str, str], self.mw.col.get_config("flagLabels", {})) icon = ColoredIcon(path="icons:flag-variant.svg", color=colors.FG_DISABLED) self._flags = [ Flag( 1, labels["1"] if "1" in labels else tr.actions_flag_red(), icon.with_color(colors.FLAG_1), SearchNode(flag=SearchNode.FLAG_RED), "actionRed_Flag", ), Flag( 2, labels["2"] if "2" in labels else tr.actions_flag_orange(), icon.with_color(colors.FLAG_2), SearchNode(flag=SearchNode.FLAG_ORANGE), "actionOrange_Flag", ), Flag( 3, labels["3"] if "3" in labels else tr.actions_flag_green(), icon.with_color(colors.FLAG_3), SearchNode(flag=SearchNode.FLAG_GREEN), "actionGreen_Flag", ), Flag( 4, labels["4"] if "4" in labels else tr.actions_flag_blue(), icon.with_color(colors.FLAG_4), SearchNode(flag=SearchNode.FLAG_BLUE), "actionBlue_Flag", ), Flag( 5, labels["5"] if "5" in labels else tr.actions_flag_pink(), icon.with_color(colors.FLAG_5), SearchNode(flag=SearchNode.FLAG_PINK), "actionPink_Flag", ), Flag( 6, labels["6"] if "6" in labels else tr.actions_flag_turquoise(), icon.with_color(colors.FLAG_6), SearchNode(flag=SearchNode.FLAG_TURQUOISE), "actionTurquoise_Flag", ), Flag( 7, labels["7"] if "7" in labels else tr.actions_flag_purple(), icon.with_color(colors.FLAG_7), SearchNode(flag=SearchNode.FLAG_PURPLE), "actionPurple_Flag", ), ] ================================================ FILE: qt/aqt/forms/__init__.py ================================================ # ruff: noqa: F401 from . import ( about, addcards, addfield, addmodel, addonconf, addons, browser, browserdisp, browseropts, changemap, changemodel, clayout_top, customstudy, dconf, debug, editcurrent, edithtml, emptycards, exporting, fields, filtered_deck, finddupes, findreplace, forget, getaddons, importing, main, modelopts, models, preferences, preview, profiles, progress, reposition, setgroup, setlang, stats, studydeck, synclog, taglimit, template, widgets, ) ================================================ FILE: qt/aqt/forms/about.py ================================================ from _aqt.forms.about_qt6 import * ================================================ FILE: qt/aqt/forms/about.ui ================================================ About 0 0 410 664 0 0 about_about_anki 0 0 0 0 about:blank Qt::Horizontal QDialogButtonBox::Ok AnkiWebView QWidget
aqt/webview
1
buttonBox accepted() About accept() 248 254 157 274 buttonBox rejected() About reject() 316 260 286 274
================================================ FILE: qt/aqt/forms/addcards.py ================================================ from _aqt.forms.addcards_qt6 import * ================================================ FILE: qt/aqt/forms/addcards.ui ================================================ Dialog 0 0 750 493 400 400 :/icons/anki.png:/icons/anki.png 12 12 6 12 12 6 0 0 10 0 10 true QDialogButtonBox::NoButton 0 0 750 22 qt_accel_edit ================================================ FILE: qt/aqt/forms/addfield.py ================================================ from _aqt.forms.addfield_qt6 import * ================================================ FILE: qt/aqt/forms/addfield.ui ================================================ Dialog 0 0 434 186 fields_add_field fields_field fields_size Qt::Vertical 20 40 fields_font 6 200 Qt::Vertical QDialogButtonBox::Cancel|QDialogButtonBox::Ok fields font size buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274 ================================================ FILE: qt/aqt/forms/addmodel.py ================================================ from _aqt.forms.addmodel_qt6 import * ================================================ FILE: qt/aqt/forms/addmodel.ui ================================================ Dialog 0 0 285 269 notetypes_add_note_type QAbstractItemView::NoEditTriggers true Qt::Horizontal QDialogButtonBox::Cancel|QDialogButtonBox::Help|QDialogButtonBox::Ok buttonBox accepted() Dialog accept() 266 353 157 274 buttonBox rejected() Dialog reject() 334 353 286 274 ================================================ FILE: qt/aqt/forms/addonconf.py ================================================ from _aqt.forms.addonconf_qt6 import * ================================================ FILE: qt/aqt/forms/addonconf.ui ================================================ Dialog Qt::ApplicationModal 0 0 631 521 addons_configuration Qt::Horizontal 3 0 QPlainTextEdit::NoWrap about:blank Qt::Horizontal QDialogButtonBox::Cancel|QDialogButtonBox::Ok|QDialogButtonBox::RestoreDefaults AnkiWebView QWidget
aqt/webview
1
buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274
================================================ FILE: qt/aqt/forms/addons.py ================================================ from _aqt.forms.addons_qt6 import * ================================================ FILE: qt/aqt/forms/addons.ui ================================================ Dialog Qt::ApplicationModal 0 0 800 800 ADD-ONS true addons_changes_will_take_effect_when_anki QAbstractItemView::ExtendedSelection addons_get_addons addons_install_from_file addons_check_for_updates Qt::Vertical 20 40 addons_view_addon_page addons_config addons_view_files addons_toggle_enabled actions_delete ================================================ FILE: qt/aqt/forms/browser.py ================================================ from _aqt.forms.browser_qt6 import * ================================================ FILE: qt/aqt/forms/browser.ui ================================================ Dialog 0 0 750 493 400 400 :/icons/anki.png:/icons/anki.png 12 0 6 6 12 4 0 Qt::Horizontal 3 1 0 0 0 0 0 0 0 0 6 12 0 9 0 true QComboBox::NoInsert 9 1 0 150 Qt::ActionsContextMenu Qt::ScrollBarAsNeeded QAbstractItemView::NoEditTriggers false true QAbstractItemView::SelectRows false 20 false true 0 0 1 0 0 0 0 1 50 200 0 0 750 23 qt_accel_edit qt_accel_go qt_accel_help qt_accel_cards browsing_flag qt_accel_notes qt_accel_view qt_accel_layout qt_accel_select_all Ctrl+Alt+A qt_accel_undo Ctrl+Z qt_accel_invert_selection Ctrl+Alt+S qt_accel_find Ctrl+F qt_accel_note Ctrl+Shift+N qt_accel_next_card Ctrl+N qt_accel_previous_card Ctrl+P qt_accel_guide F1 browsing_change_note_type2 Ctrl+Shift+M qt_accel_select_notes qt_accel_find_and_replace Ctrl+Alt+F qt_accel_filter Ctrl+Shift+F browsing_card_list Ctrl+Shift+L qt_accel_find_duplicates browsing_reposition Ctrl+Shift+S browsing_first_card Home browsing_last_card End actions_close Ctrl+W qt_accel_info Ctrl+Shift+I browsing_add_tags2 Ctrl+Shift+A browsing_remove_tags Ctrl+Alt+Shift+A true browsing_toggle_suspend Ctrl+J actions_delete Ctrl+Del browsing_add_notes Ctrl+E browsing_change_deck2 Ctrl+D true actions_flag_red Ctrl+1 true actions_flag_orange Ctrl+2 true actions_flag_green Ctrl+3 true actions_flag_blue Ctrl+4 browsing_sidebar Ctrl+Shift+R browsing_clear_unused_tags browsing_manage_note_types true browsing_toggle_mark Ctrl+K qt_accel_export_notes Ctrl+Shift+E qt_misc_create_filtered_deck Ctrl+G qt_accel_set_due_date Ctrl+Shift+D actions_grade_now Ctrl+Shift+G qt_accel_forget Ctrl+Alt+N browsing_toggle_showing_cards_notes Ctrl+Alt+T qt_accel_redo Ctrl+Shift+Z true actions_flag_pink Ctrl+5 true actions_flag_turquoise Ctrl+6 true actions_flag_purple Ctrl+7 actions_create_copy Ctrl+Alt+E qt_accel_full_screen qt_accel_toggle_sidebar qt_accel_zoom_editor_in qt_accel_zoom_editor_out qt_accel_reset_zoom Ctrl+0 true qt_accel_layout_auto true qt_accel_layout_vertical true qt_accel_layout_horizontal browsing_toggle_showing_cards_notes true browsing_toggle_bury Ctrl+Shift+J actionSelectAll triggered() tableView selectAll() -1 -1 299 279 actionClose triggered() Dialog _handle_close() -1 -1 374 199 ================================================ FILE: qt/aqt/forms/browserdisp.py ================================================ from _aqt.forms.browserdisp_qt6 import * ================================================ FILE: qt/aqt/forms/browserdisp.ui ================================================ Dialog 0 0 412 241 browsing_browser_appearance browsing_override_front_template browsing_override_back_template browsing_override_font 5 0 6 Qt::Vertical 20 40 Qt::Horizontal QDialogButtonBox::Cancel|QDialogButtonBox::Ok qfmt afmt font fontSize buttonBox buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274 ================================================ FILE: qt/aqt/forms/browseropts.py ================================================ from _aqt.forms.browseropts_qt6 import * ================================================ FILE: qt/aqt/forms/browseropts.ui ================================================ Dialog 0 0 288 195 browsing_browser_options browsing_font browsing_font_size 75 0 browsing_line_size Qt::Horizontal 40 20 browsing_search_within_formatting_slow Qt::Vertical 20 40 Qt::Horizontal QDialogButtonBox::Cancel|QDialogButtonBox::Ok fontCombo fontSize lineSize fullSearch buttonBox buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274 ================================================ FILE: qt/aqt/forms/changemap.py ================================================ from _aqt.forms.changemap_qt6 import * ================================================ FILE: qt/aqt/forms/changemap.ui ================================================ ChangeMap 0 0 391 360 actions_import browsing_target_field true Qt::Horizontal QDialogButtonBox::Ok buttonBox accepted() ChangeMap accept() 254 355 157 274 buttonBox rejected() ChangeMap reject() 322 355 286 274 fields doubleClicked(QModelIndex) ChangeMap accept() 99 123 193 5 ================================================ FILE: qt/aqt/forms/changemodel.py ================================================ from _aqt.forms.changemodel_qt6 import * ================================================ FILE: qt/aqt/forms/changemodel.ui ================================================ Dialog 0 0 362 391 browsing_change_note_type 10 4 browsing_current_note_type 0 0 4 browsing_new_note_type 0 0 0 0 editing_cards 0 0 0 true 0 0 330 120 0 0 editing_fields 0 0 0 true 0 0 330 119 Qt::Horizontal QDialogButtonBox::Cancel|QDialogButtonBox::Help|QDialogButtonBox::Ok buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274 ================================================ FILE: qt/aqt/forms/clayout_top.py ================================================ from _aqt.forms.clayout_top_qt6 import * ================================================ FILE: qt/aqt/forms/clayout_top.ui ================================================ Form 0 0 400 300 card_templates_form 3 0 0 0 0 12 CARD TYPE: 30 0 50 30 Qt::Horizontal QSizePolicy::MinimumExpanding 1 20 false false ================================================ FILE: qt/aqt/forms/customstudy.py ================================================ from _aqt.forms.customstudy_qt6 import * ================================================ FILE: qt/aqt/forms/customstudy.ui ================================================ Dialog 0 0 332 380 actions_custom_study custom_study_review_ahead custom_study_review_forgotten_cards custom_study_increase_todays_new_card_limit custom_study_increase_todays_review_card_limit custom_study_study_by_card_state_or_tag custom_study_preview_new_cards ... ... ... Qt::Horizontal 40 20 0 custom_study_new_cards_only custom_study_due_cards_only custom_study_all_review_cards_in_random_order custom_study_all_cards_in_random_order_dont Qt::Vertical 20 40 Qt::Horizontal QDialogButtonBox::Cancel|QDialogButtonBox::Ok radioNew radioRev radioForgot radioAhead radioPreview radioCram spin buttonBox buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274 ================================================ FILE: qt/aqt/forms/dconf.py ================================================ from _aqt.forms.dconf_qt6 import * ================================================ FILE: qt/aqt/forms/dconf.ui ================================================ Dialog 0 0 638 514 scheduling_options_group 3 0 16777215 32 actions_manage Qt::ToolButtonTextBesideIcon Qt::NoArrow * { color: red } Qt::AlignCenter true 0 scheduling_new_cards 12 12 12 12 12 scheduling_starting_ease % 131 999 250 scheduling_order 1 1 scheduling_easy_interval scheduling_graduating_interval 9999 scheduling_new_cardsday scheduling_steps_in_minutes scheduling_bury_related_new_cards_until_the scheduling_days 0 0 scheduling_days Qt::Vertical 20 40 scheduling_reviews 12 12 12 12 12 scheduling_easy_bonus % 100 1000 5 0 9999 scheduling_interval_modifier scheduling_maximum_reviewsday scheduling_maximum_interval 1 99999 scheduling_days % 0 0.000000000000000 999.000000000000000 1.000000000000000 100.000000000000000 scheduling_bury_related_reviews_until_the_next scheduling_hard_interval % 5 120 Qt::Vertical 20 152 scheduling_lapses 12 12 12 12 12 scheduling_steps_in_minutes scheduling_new_interval scheduling_leech_threshold 0 0 scheduling_lapses2 scheduling_leech_action 1 99 scheduling_minimum_interval scheduling_days actions_suspend_card scheduling_tag_only Qt::Horizontal 40 20 % 100 5 Qt::Vertical 20 72 scheduling_general 12 12 12 12 12 scheduling_ignore_answer_times_longer_than 30 3600 10 scheduling_seconds scheduling_show_answer_timer scheduling_automatically_play_audio scheduling_always_include_question_side_when_replaying false Qt::Vertical 20 199 Qt::Horizontal QDialogButtonBox::Help|QDialogButtonBox::Ok|QDialogButtonBox::RestoreDefaults dconf confOpts tabWidget lrnSteps newOrder newPerDay lrnGradInt lrnEasyInt lrnFactor bury revPerDay easyBonus fi1 maxIvl hardFactor buryRev lapSteps lapMult lapMinInt leechThreshold leechAction maxTaken showTimer autoplaySounds replayQuestion buttonBox accepted() Dialog accept() 254 320 157 274 buttonBox rejected() Dialog reject() 322 320 286 274 ================================================ FILE: qt/aqt/forms/debug.py ================================================ from _aqt.forms.debug_qt6 import * ================================================ FILE: qt/aqt/forms/debug.ui ================================================ Dialog 0 0 637 582 qt_misc_debug_console :/icons/anki.png:/icons/anki.png -1 Qt::Vertical false 0 1 0 100 16777215 1677215 QPlainTextEdit::NoWrap Actions: Ctrl+Enter Execute Ctrl+Shift+Enter Execute and print current line Ctrl+L Clear log Ctrl+Shift+L Clear input Ctrl+S Save script Ctrl+O Open script Ctrl+D Delete script Locals: mw: AnkiQt Main window card: Callable[[], Card | None] Reviewer card bcard: Callable[[], Card | None] Browser card pp: Callable[[object], None] Pretty print 0 8 0 150 Qt::ClickFocus true Output Styling Qt Widget Gallery text widgetsButton script ================================================ FILE: qt/aqt/forms/editcurrent.py ================================================ from _aqt.forms.editcurrent_qt6 import * ================================================ FILE: qt/aqt/forms/editcurrent.ui ================================================ Dialog 0 0 400 300 Dialog :/icons/anki.png:/icons/anki.png 3 12 Qt::Horizontal QDialogButtonBox::Close 0 0 750 22 qt_accel_edit buttonBox rejected() Dialog close() 316 260 286 274 ================================================ FILE: qt/aqt/forms/edithtml.py ================================================ from _aqt.forms.edithtml_qt6 import * ================================================ FILE: qt/aqt/forms/edithtml.ui ================================================ Dialog 0 0 400 300 editing_html_editor Qt::Horizontal QDialogButtonBox::Close|QDialogButtonBox::Help buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274 ================================================ FILE: qt/aqt/forms/emptycards.py ================================================ from _aqt.forms.emptycards_qt6 import * ================================================ FILE: qt/aqt/forms/emptycards.ui ================================================ Dialog 0 0 531 345 EMPTY_CARDS 0 0 0 0 0 about:blank 12 12 12 12 12 KEEP_NOTES true Qt::Horizontal QDialogButtonBox::Close EmptyCardsWebView QWidget
aqt/webview
1
buttonBox buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274
================================================ FILE: qt/aqt/forms/exporting.py ================================================ from _aqt.forms.exporting_qt6 import * ================================================ FILE: qt/aqt/forms/exporting.ui ================================================ ExportDialog 0 0 550 200 actions_export 100 0 exporting_export_format 0 0 exporting_include 50 exporting_include_scheduling_information true exporting_include_deck_configs false exporting_include_media true exporting_include_html_and_media_references exporting_include_tags true true exporting_include_deck true exporting_include_notetype exporting_include_guid exporting_support_older_anki_versions false Qt::Vertical 20 40 Qt::Horizontal QDialogButtonBox::Cancel format deck includeSched include_deck_configs includeMedia includeHTML includeTags includeDeck includeNotetype includeGuid legacy_support buttonBox accepted() ExportDialog accept() 248 254 157 274 buttonBox rejected() ExportDialog reject() 316 260 286 274 ================================================ FILE: qt/aqt/forms/fields.py ================================================ from _aqt.forms.fields_qt6 import * ================================================ FILE: qt/aqt/forms/fields.ui ================================================ Dialog 0 0 567 438 editing_fields true 0 0 50 60 actions_add actions_delete actions_rename actions_reposition Qt::Vertical 20 40 0 0 fields_description fields_editing_font 5 300 actions_options 0 25 fields_reverse_text_direction_rtl true fields_html_by_default fields_description_placeholder fields_sort_by_this_field_in_the true fields_collapse_by_default true fields_exclude_from_search Qt::Horizontal QDialogButtonBox::Cancel|QDialogButtonBox::Help|QDialogButtonBox::Save fieldList fieldAdd fieldDelete fieldRename fieldPosition fieldDescription fontFamily fontSize sortField rtl buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274 ================================================ FILE: qt/aqt/forms/filtered_deck.py ================================================ from _aqt.forms.filtered_deck_qt6 import * ================================================ FILE: qt/aqt/forms/filtered_deck.ui ================================================ Dialog 0 0 526 587 Dialog decks_deck 0 0 actions_name actions_filter 0 0 Qt::NoFocus search_view_in_browser actions_search false true search 0 0 1 99999 decks_cards_selected_by decks_limit_to decks_filter_2 0 0 Qt::NoFocus search_view_in_browser actions_search false true search decks_limit_to 0 0 1 99999 decks_cards_selected_by actions_options good delay 99999 99999 again delay 99999 scheduling_seconds scheduling_seconds hard delay scheduling_seconds decks_zero_minutes_hint decks_create_even_if_empty decks_enable_second_filter decks_reschedule_cards_based_on_my_answers true Qt::Horizontal 40 20 Qt::NoFocus search_view_in_browser decks_unmovable_cards false true hint Qt::Vertical 20 40 Qt::Horizontal QDialogButtonBox::Cancel|QDialogButtonBox::Help|QDialogButtonBox::Ok name search limit order search_2 limit_2 order_2 resched preview_again preview_hard preview_good secondFilter allow_empty buttonBox accepted() Dialog accept() 254 295 157 274 buttonBox rejected() Dialog reject() 322 295 286 274 secondFilter toggled(bool) filter2group setVisible(bool) 125 265 222 155 ================================================ FILE: qt/aqt/forms/finddupes.py ================================================ from _aqt.forms.finddupes_qt6 import * ================================================ FILE: qt/aqt/forms/finddupes.ui ================================================ Dialog 0 0 531 345 browsing_find_duplicates browsing_optional_filter browsing_search_in 9 0 true QComboBox::NoInsert QComboBox::SizeAdjustPolicy::AdjustToMinimumContentsLengthWithIcon QFrame::StyledPanel QFrame::Raised 0 0 0 0 about:blank Qt::Horizontal QDialogButtonBox::Close FindDupesWebView QWidget
aqt/webview
1
fields webView buttonBox buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274
================================================ FILE: qt/aqt/forms/findreplace.py ================================================ from _aqt.forms.findreplace_qt6 import * ================================================ FILE: qt/aqt/forms/findreplace.ui ================================================ Dialog 0 0 479 247 browsing_find_and_replace 9 0 true QComboBox::NoInsert QComboBox::AdjustToMinimumContentsLengthWithIcon browsing_replace_with browsing_in browsing_find QComboBox::AdjustToMinimumContentsLengthWithIcon browsing_treat_input_as_regular_expression browsing_ignore_case true 9 0 true QComboBox::NoInsert QComboBox::AdjustToMinimumContentsLengthWithIcon browsing_selected_notes_only true Qt::Vertical 20 40 Qt::Horizontal QDialogButtonBox::Cancel|QDialogButtonBox::Help|QDialogButtonBox::Ok find replace field selected_notes ignoreCase re buttonBox accepted() Dialog accept() 256 154 157 274 buttonBox rejected() Dialog reject() 290 154 286 274 ================================================ FILE: qt/aqt/forms/forget.py ================================================ from _aqt.forms.forget_qt6 import * ================================================ FILE: qt/aqt/forms/forget.ui ================================================ Dialog 0 0 235 118 actions_forget_card true scheduling_restore_position scheduling_reset_counts Qt::Vertical 20 40 Qt::Horizontal QDialogButtonBox::Cancel|QDialogButtonBox::Ok buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274 ================================================ FILE: qt/aqt/forms/getaddons.py ================================================ from _aqt.forms.getaddons_qt6 import * ================================================ FILE: qt/aqt/forms/getaddons.ui ================================================ Dialog 0 0 367 204 addons_install_addon addons_to_browse_addons_please_click_the true Qt::Vertical 20 40 addons_code Qt::Horizontal QDialogButtonBox::Cancel|QDialogButtonBox::Ok buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274 ================================================ FILE: qt/aqt/forms/importing.py ================================================ from _aqt.forms.importing_qt6 import * ================================================ FILE: qt/aqt/forms/importing.ui ================================================ ImportDialog 0 0 553 466 actions_import importing_import_options notetypes_type decks_deck importing_update_existing_notes_when_first_field importing_ignore_lines_where_first_field_matches importing_import_even_if_existing_note_has importing_allow_html_in_fields importing_tag_modified_notes 0 0 importing_field_mapping 0 0 400 150 QFrame::NoFrame true 0 0 529 251 Qt::Horizontal QDialogButtonBox::Close|QDialogButtonBox::Help TagEdit QLineEdit
aqt/tagedit.h
buttonBox buttonBox accepted() ImportDialog accept() 248 254 157 274 buttonBox rejected() ImportDialog reject() 316 260 286 274
================================================ FILE: qt/aqt/forms/main.py ================================================ from _aqt.forms.main_qt6 import * ================================================ FILE: qt/aqt/forms/main.ui ================================================ MainWindow 0 0 667 570 0 0 400 0 Anki :/icons/anki.png:/icons/anki.png 1 1 true 0 0 667 43 qt_accel_help qt_accel_edit qt_accel_file qt_accel_tools qt_accel_view qt_accel_exit Ctrl+Q qt_accel_preferences qt_misc_configure_interface_language_and_options Ctrl+P QAction::MenuRole::PreferencesRole qt_accel_about QAction::MenuRole::ApplicationSpecificRole false qt_accel_undo Ctrl+Z qt_accel_check_media qt_misc_check_the_files_in_the_media qt_accel_support_anki qt_accel_check_database qt_accel_guide F1 qt_accel_switch_profile Ctrl+Shift+P qt_accel_export Ctrl+E qt_accel_import Ctrl+Shift+I qt_misc_study_deck / qt_misc_empty_cards qt_misc_create_filtered_deck F qt_misc_manage_note_types Ctrl+Shift+N qt_misc_addons Ctrl+Shift+A false qt_accel_redo Ctrl+Shift+Z qt_accel_full_screen qt_accel_zoom_in qt_accel_zoom_out qt_accel_reset_zoom Ctrl+0 qt_accel_create_backup qt_accel_load_backup qt_accel_upgrade_downgrade ================================================ FILE: qt/aqt/forms/modelopts.py ================================================ from _aqt.forms.modelopts_qt6 import * ================================================ FILE: qt/aqt/forms/modelopts.ui ================================================ Dialog Qt::ApplicationModal 0 0 374 344 0 editing_latex notetypes_create_scalable_images_with_dvisvgm notetypes_header true notetypes_footer true Qt::Horizontal QDialogButtonBox::Close|QDialogButtonBox::Help qtabwidget buttonBox latexHeader latexFooter buttonBox accepted() Dialog accept() 275 442 157 274 buttonBox rejected() Dialog reject() 343 442 286 274 ================================================ FILE: qt/aqt/forms/models.py ================================================ from _aqt.forms.models_qt6 import * ================================================ FILE: qt/aqt/forms/models.ui ================================================ Dialog Qt::ApplicationModal 0 0 396 255 notetypes_note_types 0 6 0 0 12 Qt::Vertical QDialogButtonBox::Close|QDialogButtonBox::Help Qt::Vertical QSizePolicy::Minimum 20 6 modelsList buttonBox accepted() Dialog accept() 252 513 157 274 buttonBox rejected() Dialog reject() 320 513 286 274 ================================================ FILE: qt/aqt/forms/preferences.py ================================================ from _aqt.forms.preferences_qt6 import * ================================================ FILE: qt/aqt/forms/preferences.ui ================================================ Preferences 0 0 636 638 preferences_preferences Qt::FocusPolicy::StrongFocus 0 preferences_appearance 0 0 preferences_general preferences_language lang 0 0 preferences_video_driver video_driver 0 0 QComboBox::SizeAdjustPolicy::AdjustToMinimumContentsLengthWithIcon preferences_updates preferences_check_for_updates preferences_check_for_addon_updates preferences_user_interface % 100 200 5 preferences_style styleComboBox preferences_theme theme preferences_user_interface_size uiScale preferences_reset_window_sizes preferences_distractions 0 0 preferences_reduce_motion_tooltip preferences_reduce_motion 0 0 preferences_hide_bottom_bar_during_review 0 0 0 0 preferences_hide_top_bar_during_review 0 0 preferences_minimalist_mode_tooltip preferences_minimalist_mode 0 0 Qt::Orientation::Vertical 20 40 preferences_review preferences_scheduler 12 16777215 16777215 preferences_mins 999 preferences_timebox_time_limit timeLimit 16777215 16777215 preferences_hours_past_midnight 23 preferences_learn_ahead_limit lrnCutoff preferences_next_day_starts_at dayOffset preferences_mins 9999 preferences_review 0 0 preferences_show_play_buttons_on_cards_with 0 0 preferences_interrupt_current_audio_when_answering 0 0 preferences_show_remaining_card_count 0 0 preferences_show_next_review_time_above_answer 0 0 preferences_spacebar_rates_card 0 0 preferences_generate_latex_images_automatically preferences_url_schemes preferences_answer_keys Qt::Orientation::Vertical 20 40 preferences_editing preferences_editing 0 0 preferences_paste_clipboard_images_as_png 0 0 preferences_paste_without_shift_key_strips_formatting Qt::Orientation::Vertical QSizePolicy::Policy::Fixed 20 10 preferences_default_deck useCurrent 0 0 preferences_when_adding_default_to_current_deck preferences_change_deck_depending_on_note_type preferences_browsing preferences_default_search_text default_search_text preferences_default_search_text_example 0 0 preferences_ignore_accents_in_search Qt::Orientation::Vertical QSizePolicy::Policy::Expanding 20 20 preferences_network 12 12 12 12 12 preferences_tab_synchronisation 0 0 preferences_synchronize_audio_and_images_too 0 0 preferences_automatically_sync_on_profile_openclose 0 0 preferences_periodically_sync_media 0 0 preferences_on_next_sync_force_changes_in preferences_network_timeout network_timeout scheduling_seconds 30 99999 Qt::Orientation::Horizontal 40 20 0 0 preferences_account 0 0 true 0 0 sync_log_out_button false 0 0 sync_log_in_button false Qt::Orientation::Vertical 20 40 Qt::Orientation::Vertical 20 40 preferences_custom_sync_url_disclaimer preferences_custom_sync_url custom_sync_url preferences_backups 12 12 12 12 12 0 0 preferences_backups preferences_backup_explanation true Qt::Orientation::Vertical QSizePolicy::Policy::Fixed 20 20 5 9999 9999 Qt::Orientation::Horizontal 40 20 preferences_daily_backups daily_backups 9999 preferences_monthly_backups monthly_backups preferences_weekly_backups weekly_backups preferences_minutes_between_backups minutes_between_backups 9999 Qt::Orientation::Horizontal 40 20 Qt::Orientation::Vertical QSizePolicy::Policy::Fixed 20 20 preferences_you_can_restore_backups_via_fileswitch preferences_note preferences_media_is_not_backed_up true Qt::Orientation::Vertical 20 59 preferences_third_party_services 12 12 12 12 12 0 0 preferences_third_party_description Qt::Orientation::Vertical QSizePolicy::Policy::Maximum 20 12 0 0 AnkiHub 0 0 true 0 0 sync_log_out_button false 0 0 sync_log_in_button false Qt::Orientation::Vertical 40 20 preferences_some_settings_will_take_effect_after Qt::AlignmentFlag::AlignCenter Qt::Orientation::Horizontal QDialogButtonBox::StandardButton::Close|QDialogButtonBox::StandardButton::Help lang video_driver check_for_updates check_for_addon_updates theme styleComboBox uiScale resetWindowSizes hide_top_bar topBarComboBox hide_bottom_bar bottomBarComboBox reduce_motion minimalist_mode dayOffset lrnCutoff timeLimit showPlayButtons interrupt_audio showProgress showEstimates spacebar_rates_card render_latex url_schemes pastePNG paste_strips_formatting useCurrent default_search_text ignore_accents_in_search syncMedia syncOnProgramOpen autoSyncMedia fullSync network_timeout media_log syncLogout syncLogin custom_sync_url minutes_between_backups daily_backups weekly_backups monthly_backups syncAnkiHubLogout syncAnkiHubLogin buttonBox tabWidget buttonBox accepted() Preferences accept() 285 439 157 274 buttonBox rejected() Preferences reject() 332 439 286 274 ================================================ FILE: qt/aqt/forms/preview.py ================================================ from _aqt.forms.preview_qt6 import * ================================================ FILE: qt/aqt/forms/preview.ui ================================================ Form 0 0 717 636 Form 0 0 0 0 GroupBox FRONT true BACK Qt::Horizontal 40 20 false false ================================================ FILE: qt/aqt/forms/profiles.py ================================================ from _aqt.forms.profiles_qt6 import * ================================================ FILE: qt/aqt/forms/profiles.ui ================================================ MainWindow 0 0 423 356 profiles_profiles :/icons/anki.png:/icons/anki.png profiles_open true actions_add actions_rename actions_delete profiles_quit Qt::Vertical 20 40 profiles_open_backup DOWNGRADE false 0 0 423 22 false ================================================ FILE: qt/aqt/forms/progress.py ================================================ from _aqt.forms.progress_qt6 import * ================================================ FILE: qt/aqt/forms/progress.ui ================================================ Dialog 0 0 310 69 Dialog 6 Qt::Vertical 0 0 Qt::AlignCenter 24 Qt::Vertical QSizePolicy::MinimumExpanding 0 0 ================================================ FILE: qt/aqt/forms/reposition.py ================================================ from _aqt.forms.reposition_qt6 import * ================================================ FILE: qt/aqt/forms/reposition.ui ================================================ Dialog 0 0 299 229 browsing_reposition_new_cards browsing_start_position 0 1000000 0 browsing_step 1 10000 browsing_randomize_order browsing_shift_position_of_existing_cards false Qt::Vertical 20 40 Qt::Horizontal QDialogButtonBox::Cancel|QDialogButtonBox::Ok start step randomize shift buttonBox buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274 ================================================ FILE: qt/aqt/forms/setgroup.py ================================================ from _aqt.forms.setgroup_qt6 import * ================================================ FILE: qt/aqt/forms/setgroup.ui ================================================ Dialog 0 0 433 143 Anki browsing_move_cards_to_deck Qt::Vertical 20 40 Qt::Horizontal QDialogButtonBox::Cancel|QDialogButtonBox::Ok buttonBox buttonBox accepted() Dialog accept() 224 192 157 213 buttonBox rejected() Dialog reject() 292 198 286 213 ================================================ FILE: qt/aqt/forms/setlang.py ================================================ from _aqt.forms.setlang_qt6 import * ================================================ FILE: qt/aqt/forms/setlang.ui ================================================ Dialog 0 0 400 300 Anki preferences_language Qt::Horizontal QDialogButtonBox::Ok buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274 ================================================ FILE: qt/aqt/forms/stats.py ================================================ from _aqt.forms.stats_qt6 import * ================================================ FILE: qt/aqt/forms/stats.ui ================================================ Dialog 0 0 607 556 statistics_title 0 0 0 0 0 about:blank 8 16 6 16 6 deck true collection 1 month true 1 year deck life 4 0 Qt::Horizontal QDialogButtonBox::Close true StatsWebView QWidget
aqt/webview
1
buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274
================================================ FILE: qt/aqt/forms/studydeck.py ================================================ from _aqt.forms.studydeck_qt6 import * ================================================ FILE: qt/aqt/forms/studydeck.ui ================================================ Dialog 0 0 400 300 decks_study_deck decks_filter Qt::Horizontal QDialogButtonBox::Cancel|QDialogButtonBox::Help buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274 ================================================ FILE: qt/aqt/forms/synclog.py ================================================ from _aqt.forms.synclog_qt6 import * ================================================ FILE: qt/aqt/forms/synclog.ui ================================================ Dialog 0 0 482 90 TextLabel Qt::PlainText Qt::AlignCenter Qt::Horizontal QDialogButtonBox::Close buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274 ================================================ FILE: qt/aqt/forms/taglimit.py ================================================ from _aqt.forms.taglimit_qt6 import * ================================================ FILE: qt/aqt/forms/taglimit.ui ================================================ Dialog 0 0 361 394 custom_study_selective_study custom_study_require_one_or_more_of_these false 0 2 QAbstractItemView::MultiSelection custom_study_select_tags_to_exclude true 0 2 QAbstractItemView::MultiSelection Qt::Horizontal QDialogButtonBox::Cancel|QDialogButtonBox::Ok buttonBox accepted() Dialog accept() 358 264 157 274 buttonBox rejected() Dialog reject() 316 260 286 274 activeCheck toggled(bool) activeList setEnabled(bool) 133 18 133 85 ================================================ FILE: qt/aqt/forms/template.py ================================================ from _aqt.forms.template_qt6 import * ================================================ FILE: qt/aqt/forms/template.ui ================================================ Form 0 0 786 1081 0 0 card_templates_form 0 0 0 0 GroupBox 12 12 12 12 FRONT true BACK STYLE Qt::Horizontal 40 20 CHANGES_WILL_AFFECT true ================================================ FILE: qt/aqt/forms/widgets.py ================================================ from _aqt.forms.widgets_qt6 import * ================================================ FILE: qt/aqt/forms/widgets.ui ================================================ Dialog 0 0 925 822 Qt Widget Gallery Style Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter Qt::Horizontal 40 20 Disable Widgets Check Buttons RadioButton (not checkable) false RadioButton (checked) true RadioButton (unchecked) CheckBox (tristate) true true Buttons PushButton PushButton (checkable) true true PushButton (flat) true true CalendarWidget Text Inputs true ComboBox (editable) LineEdit Qt::Horizontal PlainTextEdit TextEdit Other Inputs 1 KeySequenceEdit DateTimeEdit SpinBox Slider Qt::Horizontal Dial Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 0 ListWidget TreeWidget 1 TableWidget 0 0 ProgressBar 24 ================================================ FILE: qt/aqt/gui_hooks.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """ See pylib/anki/hooks.py """ from __future__ import annotations # You can find the definitions in ../tools/genhooks_gui.py from _aqt.hooks import * ================================================ FILE: qt/aqt/import_export/__init__.py ================================================ ================================================ FILE: qt/aqt/import_export/exporting.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import os import re import time from abc import ABC, abstractmethod from collections.abc import Sequence from dataclasses import dataclass import aqt.forms import aqt.main from anki.collection import ( DeckIdLimit, ExportAnkiPackageOptions, ExportLimit, NoteIdsLimit, Progress, ) from anki.decks import DeckId, DeckNameId from anki.notes import NoteId from aqt import gui_hooks from aqt.errors import show_exception from aqt.operations import QueryOp from aqt.progress import ProgressUpdate from aqt.qt import * from aqt.utils import ( checkInvalidFilename, disable_help_button, getSaveFile, showWarning, tooltip, tr, ) class ExportDialog(QDialog): def __init__( self, mw: aqt.main.AnkiQt, did: DeckId | None = None, nids: Sequence[NoteId] | None = None, parent: QWidget | None = None, ): QDialog.__init__(self, parent or mw, Qt.WindowType.Window) self.mw = mw self.col = mw.col.weakref() self.frm = aqt.forms.exporting.Ui_ExportDialog() self.frm.setupUi(self) self.exporter: Exporter self.nids = nids disable_help_button(self) self.setup(did) self.open() def setup(self, did: DeckId | None) -> None: self.exporter_classes: list[type[Exporter]] = [ ApkgExporter, ColpkgExporter, NoteCsvExporter, CardCsvExporter, ] gui_hooks.exporters_list_did_initialize(self.exporter_classes) self.frm.format.insertItems( 0, [f"{e.name()} (.{e.extension})" for e in self.exporter_classes] ) qconnect(self.frm.format.activated, self.exporter_changed) if self.nids is None and not did: # file>export defaults to colpkg default_exporter_idx = 1 else: default_exporter_idx = 0 self.frm.format.setCurrentIndex(default_exporter_idx) self.exporter_changed(default_exporter_idx) # deck list if self.nids is None: self.all_decks = self.col.decks.all_names_and_ids() decks = [tr.exporting_all_decks()] decks.extend(d.name for d in self.all_decks) else: decks = [tr.exporting_selected_notes()] self.frm.deck.addItems(decks) # save button b = QPushButton(tr.exporting_export()) self.frm.buttonBox.addButton(b, QDialogButtonBox.ButtonRole.AcceptRole) self.frm.includeHTML.setChecked(True) # set default option if accessed through deck button if did: deck = self.mw.col.decks.get(did) assert deck is not None name = deck["name"] index = self.frm.deck.findText(name) self.frm.deck.setCurrentIndex(index) self.frm.includeSched.setChecked(False) def exporter_changed(self, idx: int) -> None: self.exporter = self.exporter_classes[idx]() self.frm.includeSched.setVisible(self.exporter.show_include_scheduling) self.frm.include_deck_configs.setVisible( self.exporter.show_include_deck_configs ) self.frm.includeMedia.setVisible(self.exporter.show_include_media) self.frm.includeTags.setVisible(self.exporter.show_include_tags) self.frm.includeHTML.setVisible(self.exporter.show_include_html) self.frm.includeDeck.setVisible(self.exporter.show_include_deck) self.frm.includeNotetype.setVisible(self.exporter.show_include_notetype) self.frm.includeGuid.setVisible(self.exporter.show_include_guid) self.frm.legacy_support.setVisible(self.exporter.show_legacy_support) self.frm.deck.setVisible(self.exporter.show_deck_list) def accept(self) -> None: if not (out_path := self.get_out_path()): return self.exporter.export(self.mw, self.options(out_path)) QDialog.reject(self) def get_out_path(self) -> str | None: filename = self.filename() while True: path = getSaveFile( parent=self, title=tr.actions_export(), dir_description="export", key=self.exporter.name(), ext="." + self.exporter.extension, fname=filename, ) if not path: return None if checkInvalidFilename(os.path.basename(path), dirsep=False): continue path = os.path.normpath(path) if os.path.commonprefix([self.mw.pm.base, path]) == self.mw.pm.base: showWarning("Please choose a different export location.") continue break return path def options(self, out_path: str) -> ExportOptions: limit: ExportLimit | None = None if self.nids: limit = NoteIdsLimit(self.nids) elif current_deck_id := self.current_deck_id(): limit = DeckIdLimit(current_deck_id) return ExportOptions( out_path=out_path, include_scheduling=self.frm.includeSched.isChecked(), include_deck_configs=self.frm.include_deck_configs.isChecked(), include_media=self.frm.includeMedia.isChecked(), include_tags=self.frm.includeTags.isChecked(), include_html=self.frm.includeHTML.isChecked(), include_deck=self.frm.includeDeck.isChecked(), include_notetype=self.frm.includeNotetype.isChecked(), include_guid=self.frm.includeGuid.isChecked(), legacy_support=self.frm.legacy_support.isChecked(), limit=limit, ) def current_deck_id(self) -> DeckId | None: return (deck := self.current_deck()) and DeckId(deck.id) or None def current_deck(self) -> DeckNameId | None: if self.exporter.show_deck_list: if idx := self.frm.deck.currentIndex(): return self.all_decks[idx - 1] return None def filename(self) -> str: if self.exporter.show_deck_list: deck_name = self.frm.deck.currentText() stem = re.sub('[\\\\/?<>:*|"^]', "_", deck_name) else: time_str = time.strftime("%Y-%m-%d@%H-%M-%S", time.localtime(time.time())) stem = f"{tr.exporting_collection()}-{time_str}" return f"{stem}.{self.exporter.extension}" @dataclass class ExportOptions: out_path: str include_scheduling: bool include_deck_configs: bool include_media: bool include_tags: bool include_html: bool include_deck: bool include_notetype: bool include_guid: bool legacy_support: bool limit: ExportLimit class Exporter(ABC): extension: str show_deck_list = False show_include_scheduling = False show_include_deck_configs = False show_include_media = False show_include_tags = False show_include_html = False show_legacy_support = False show_include_deck = False show_include_notetype = False show_include_guid = False @abstractmethod def export(self, mw: aqt.main.AnkiQt, options: ExportOptions) -> None: pass @staticmethod @abstractmethod def name() -> str: pass class ColpkgExporter(Exporter): extension = "colpkg" show_include_media = True show_legacy_support = True @staticmethod def name() -> str: return tr.exporting_anki_collection_package() def export(self, mw: aqt.main.AnkiQt, options: ExportOptions) -> None: options = gui_hooks.exporter_will_export(options, self) def on_success(_: None) -> None: mw.reopen() gui_hooks.exporter_did_export(options, self) tooltip(tr.exporting_collection_exported(), parent=mw) def on_failure(exception: Exception) -> None: mw.reopen() show_exception(parent=mw, exception=exception) gui_hooks.collection_will_temporarily_close(mw.col) QueryOp( parent=mw, op=lambda col: col.export_collection_package( options.out_path, include_media=options.include_media, legacy=options.legacy_support, ), success=on_success, ).with_backend_progress(export_progress_update).failure( on_failure ).run_in_background() class ApkgExporter(Exporter): extension = "apkg" show_deck_list = True show_include_scheduling = True show_include_deck_configs = True show_include_media = True show_legacy_support = True @staticmethod def name() -> str: return tr.exporting_anki_deck_package() def export(self, mw: aqt.main.AnkiQt, options: ExportOptions) -> None: options = gui_hooks.exporter_will_export(options, self) def on_success(count: int) -> None: gui_hooks.exporter_did_export(options, self) tooltip(tr.exporting_note_exported(count=count), parent=mw) QueryOp( parent=mw, op=lambda col: col.export_anki_package( out_path=options.out_path, limit=options.limit, options=ExportAnkiPackageOptions( with_scheduling=options.include_scheduling, with_deck_configs=options.include_deck_configs, with_media=options.include_media, legacy=options.legacy_support, ), ), success=on_success, ).with_backend_progress(export_progress_update).run_in_background() class NoteCsvExporter(Exporter): extension = "txt" show_deck_list = True show_include_html = True show_include_tags = True show_include_deck = True show_include_notetype = True show_include_guid = True @staticmethod def name() -> str: return tr.exporting_notes_in_plain_text() def export(self, mw: aqt.main.AnkiQt, options: ExportOptions) -> None: options = gui_hooks.exporter_will_export(options, self) def on_success(count: int) -> None: gui_hooks.exporter_did_export(options, self) tooltip(tr.exporting_note_exported(count=count), parent=mw) QueryOp( parent=mw, op=lambda col: col.export_note_csv( out_path=options.out_path, limit=options.limit, with_html=options.include_html, with_tags=options.include_tags, with_deck=options.include_deck, with_notetype=options.include_notetype, with_guid=options.include_guid, ), success=on_success, ).with_backend_progress(export_progress_update).run_in_background() class CardCsvExporter(Exporter): extension = "txt" show_deck_list = True show_include_html = True @staticmethod def name() -> str: return tr.exporting_cards_in_plain_text() def export(self, mw: aqt.main.AnkiQt, options: ExportOptions) -> None: options = gui_hooks.exporter_will_export(options, self) def on_success(count: int) -> None: gui_hooks.exporter_did_export(options, self) tooltip(tr.exporting_card_exported(count=count), parent=mw) QueryOp( parent=mw, op=lambda col: col.export_card_csv( out_path=options.out_path, limit=options.limit, with_html=options.include_html, ), success=on_success, ).with_backend_progress(export_progress_update).run_in_background() def export_progress_update(progress: Progress, update: ProgressUpdate) -> None: if not progress.HasField("exporting"): return update.label = progress.exporting if update.user_wants_abort: update.abort = True ================================================ FILE: qt/aqt/import_export/import_dialog.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import json from dataclasses import dataclass from urllib.parse import quote import aqt import aqt.deckconf import aqt.main import aqt.operations from aqt.qt import * from aqt.utils import disable_help_button, restoreGeom, saveGeom, tr from aqt.webview import AnkiWebView, AnkiWebViewKind @dataclass class ImportArgs: path: str title = "importLog" kind = AnkiWebViewKind.IMPORT_LOG ts_page = "import-page" def args_json(self) -> str: return json.dumps(self.path) class JsonFileArgs(ImportArgs): def args_json(self) -> str: return json.dumps(dict(type="json_file", path=self.path)) class CsvArgs(ImportArgs): title = "csv import" kind = AnkiWebViewKind.IMPORT_CSV ts_page = "import-csv" class AnkiPackageArgs(ImportArgs): title = "anki package import" kind = AnkiWebViewKind.IMPORT_ANKI_PACKAGE ts_page = "import-anki-package" class ImportDialog(QDialog): DEFAULT_SIZE = (800, 600) MIN_SIZE = (400, 300) silentlyClose = True def __init__(self, mw: aqt.main.AnkiQt, args: ImportArgs) -> None: QDialog.__init__(self, mw, Qt.WindowType.Window) self.mw = mw self.args = args self._setup_ui() self.show() def _setup_ui(self) -> None: self.setWindowModality(Qt.WindowModality.ApplicationModal) self.mw.garbage_collect_on_dialog_finish(self) self.setMinimumSize(*self.MIN_SIZE) disable_help_button(self) restoreGeom(self, self.args.title, default_size=self.DEFAULT_SIZE) self.web: AnkiWebView | None = AnkiWebView(kind=self.args.kind) self.web.setVisible(False) self.web.load_sveltekit_page(f"{self.args.ts_page}/{quote(self.args.path)}") layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self.web) self.setLayout(layout) restoreGeom(self, self.args.title, default_size=(800, 800)) self.setWindowTitle(tr.decks_import_file()) def reject(self) -> None: if self.mw.col and self.windowModality() == Qt.WindowModality.ApplicationModal: self.mw.col.set_wants_abort() assert self.web is not None self.web.cleanup() self.web = None saveGeom(self, self.args.title) QDialog.reject(self) ================================================ FILE: qt/aqt/import_export/importing.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import os import re from abc import ABC, abstractmethod from collections.abc import Callable from itertools import chain import aqt.main from anki.collection import Collection, Progress from anki.errors import Interrupted from anki.foreign_data import mnemosyne from anki.lang import without_unicode_isolation from anki.utils import tmpdir from aqt.import_export.import_dialog import ( AnkiPackageArgs, CsvArgs, ImportDialog, JsonFileArgs, ) from aqt.operations import QueryOp from aqt.progress import ProgressUpdate from aqt.qt import * from aqt.utils import askUser, getFile, showWarning, tooltip, tr class Importer(ABC): accepted_file_endings: list[str] @classmethod def can_import(cls, lowercase_filename: str) -> bool: return any( lowercase_filename.endswith(ending) for ending in cls.accepted_file_endings ) @classmethod @abstractmethod def do_import(cls, mw: aqt.main.AnkiQt, path: str) -> None: ... class ColpkgImporter(Importer): accepted_file_endings = [".apkg", ".colpkg"] @staticmethod def can_import(filename: str) -> bool: return ( filename == "collection.apkg" or (filename.startswith("backup-") and filename.endswith(".apkg")) or filename.endswith(".colpkg") ) @staticmethod def do_import(mw: aqt.main.AnkiQt, path: str) -> None: if askUser( tr.importing_this_will_delete_your_existing_collection(), msgfunc=QMessageBox.warning, defaultno=True, ): ColpkgImporter._import(mw, path) @staticmethod def _import(mw: aqt.main.AnkiQt, file: str) -> None: def on_success() -> None: mw.loadCollection() tooltip(tr.importing_importing_complete()) def on_failure(err: Exception) -> None: mw.loadCollection() if not isinstance(err, Interrupted): showWarning(str(err)) QueryOp( parent=mw, op=lambda _: mw.create_backup_now(), success=lambda _: mw.unloadCollection( lambda: import_collection_package_op(mw, file, on_success) .failure(on_failure) .run_in_background() ), ).with_progress().run_in_background() class ApkgImporter(Importer): accepted_file_endings = [".apkg", ".zip"] @staticmethod def do_import(mw: aqt.main.AnkiQt, path: str) -> None: ImportDialog(mw, AnkiPackageArgs(path)) class MnemosyneImporter(Importer): accepted_file_endings = [".db"] @staticmethod def do_import(mw: aqt.main.AnkiQt, path: str) -> None: def on_success(json: str) -> None: json_path = os.path.join(tmpdir(), os.path.basename(path)) with open(json_path, "wb") as file: file.write(json.encode("utf8")) ImportDialog(mw, JsonFileArgs(path=json_path)) QueryOp( parent=mw, op=lambda col: mnemosyne.serialize(path, col.decks.current()["id"]), success=on_success, ).with_progress().run_in_background() class CsvImporter(Importer): accepted_file_endings = [".csv", ".tsv", ".txt"] @staticmethod def do_import(mw: aqt.main.AnkiQt, path: str) -> None: ImportDialog(mw, CsvArgs(path)) class JsonImporter(Importer): accepted_file_endings = [".anki-json"] @staticmethod def do_import(mw: aqt.main.AnkiQt, path: str) -> None: ImportDialog(mw, JsonFileArgs(path=path)) IMPORTERS: list[type[Importer]] = [ ColpkgImporter, ApkgImporter, MnemosyneImporter, CsvImporter, ] def legacy_file_endings(col: Collection) -> list[str]: from anki.importing import AnkiPackageImporter, TextImporter, importers from anki.importing import MnemosyneImporter as LegacyMnemosyneImporter return [ ext for (text, importer) in importers(col) if importer not in (TextImporter, AnkiPackageImporter, LegacyMnemosyneImporter) for ext in re.findall(r"[( ]?\*(\..+?)[) ]", text) ] def import_file(mw: aqt.main.AnkiQt, path: str) -> None: filename = os.path.basename(path).lower() if any(filename.endswith(ext) for ext in legacy_file_endings(mw.col)): import aqt.importing aqt.importing.importFile(mw, path) return for importer in IMPORTERS: if importer.can_import(filename): importer.do_import(mw, path) return showWarning("Unsupported file type.") def prompt_for_file_then_import(mw: aqt.main.AnkiQt) -> None: if path := get_file_path(mw): import_file(mw, path) def get_file_path(mw: aqt.main.AnkiQt) -> str | None: filter = without_unicode_isolation( tr.importing_all_supported_formats( val="({})".format( " ".join(f"*{ending}" for ending in all_accepted_file_endings(mw)) ) ) ) if file := getFile(mw, tr.actions_import(), None, key="import", filter=filter): return str(file) return None def all_accepted_file_endings(mw: aqt.main.AnkiQt) -> set[str]: return set( chain( *(importer.accepted_file_endings for importer in IMPORTERS), legacy_file_endings(mw.col), ) ) def import_collection_package_op( mw: aqt.main.AnkiQt, path: str, success: Callable[[], None] ) -> QueryOp[None]: def op(_: Collection) -> None: col_path = mw.pm.collectionPath() media_folder = os.path.join(mw.pm.profileFolder(), "collection.media") media_db = os.path.join(mw.pm.profileFolder(), "collection.media.db2") mw.backend.import_collection_package( col_path=col_path, backup_path=path, media_folder=media_folder, media_db=media_db, ) return QueryOp(parent=mw, op=op, success=lambda _: success()).with_backend_progress( import_progress_update ) def import_progress_update(progress: Progress, update: ProgressUpdate) -> None: if not progress.HasField("importing"): return update.label = progress.importing if update.user_wants_abort: update.abort = True ================================================ FILE: qt/aqt/importing.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import os import re import sys import traceback import zipfile from collections.abc import Callable from concurrent.futures import Future from typing import Any import aqt.deckchooser import aqt.forms import aqt.modelchooser from anki import importing from anki.importing.anki2 import MediaMapInvalid, V2ImportIntoV1 from anki.importing.apkg import AnkiPackageImporter from aqt.import_export.importing import ColpkgImporter from aqt.main import AnkiQt, gui_hooks from aqt.qt import * from aqt.utils import ( HelpPage, disable_help_button, getFile, getText, openHelp, showInfo, showText, showWarning, tooltip, tr, ) class ChangeMap(QDialog): def __init__(self, mw: AnkiQt, model: dict, current: str) -> None: QDialog.__init__(self, mw, Qt.WindowType.Window) self.mw = mw self.model = model self.frm = aqt.forms.changemap.Ui_ChangeMap() self.frm.setupUi(self) disable_help_button(self) n = 0 setCurrent = False for field in self.model["flds"]: item = QListWidgetItem(tr.importing_map_to(val=field["name"])) self.frm.fields.addItem(item) if current == field["name"]: setCurrent = True self.frm.fields.setCurrentRow(n) n += 1 self.frm.fields.addItem(QListWidgetItem(tr.importing_map_to_tags())) self.frm.fields.addItem(QListWidgetItem(tr.importing_ignore_field())) if not setCurrent: if current == "_tags": self.frm.fields.setCurrentRow(n) else: self.frm.fields.setCurrentRow(n + 1) self.field: str | None = None def getField(self) -> str | None: self.exec() return self.field def accept(self) -> None: row = self.frm.fields.currentRow() if row < len(self.model["flds"]): self.field = self.model["flds"][row]["name"] elif row == self.frm.fields.count() - 2: self.field = "_tags" else: self.field = None QDialog.accept(self) def reject(self) -> None: self.accept() # called by importFile() when importing a mappable file like .csv # ImportType = Union[Importer,AnkiPackageImporter, TextImporter] class ImportDialog(QDialog): _DEFAULT_FILE_DELIMITER = "\t" def __init__(self, mw: AnkiQt, importer: Any) -> None: QDialog.__init__(self, mw, Qt.WindowType.Window) self.mw = mw self.importer = importer self.frm = aqt.forms.importing.Ui_ImportDialog() self.frm.setupUi(self) help_button = self.frm.buttonBox.button(QDialogButtonBox.StandardButton.Help) assert help_button is not None qconnect( help_button.clicked, self.helpRequested, ) disable_help_button(self) self.setupMappingFrame() self.setupOptions() self.modelChanged() self.frm.autoDetect.setVisible(self.importer.needDelimiter) gui_hooks.current_note_type_did_change.append(self.modelChanged) qconnect(self.frm.autoDetect.clicked, self.onDelimiter) self.updateDelimiterButtonText() assert self.mw.pm.profile is not None self.frm.allowHTML.setChecked(self.mw.pm.profile.get("allowHTML", True)) qconnect(self.frm.importMode.currentIndexChanged, self.importModeChanged) self.frm.importMode.setCurrentIndex(self.mw.pm.profile.get("importMode", 1)) self.frm.tagModified.setText(self.mw.pm.profile.get("tagModified", "")) self.frm.tagModified.setCol(self.mw.col) # import button b = QPushButton(tr.actions_import()) self.frm.buttonBox.addButton(b, QDialogButtonBox.ButtonRole.AcceptRole) self.exec() def setupOptions(self) -> None: self.model = self.mw.col.models.current() self.modelChooser = aqt.modelchooser.ModelChooser( self.mw, self.frm.modelArea, label=False ) self.deck = aqt.deckchooser.DeckChooser(self.mw, self.frm.deckArea, label=False) def modelChanged(self, unused: Any | None = None) -> None: self.importer.model = self.mw.col.models.current() self.importer.initMapping() self.showMapping() def onDelimiter(self) -> None: # Open a modal dialog to enter an delimiter # Todo/Idea Constrain the maximum width, so it doesn't take up that much screen space delim, ok = getText( tr.importing_by_default_anki_will_detect_the(), self, help=HelpPage.IMPORTING, ) # If the modal dialog has been confirmed, update the delimiter if ok: # Check if the entered value is valid and if not fallback to default # at the moment every single character entry as well as '\t' is valid delim = delim if len(delim) > 0 else self._DEFAULT_FILE_DELIMITER delim = delim.replace("\\t", "\t") # un-escape it if len(delim) > 1: showWarning( tr.importing_multicharacter_separators_are_not_supported_please() ) return self.hideMapping() def updateDelim() -> None: self.importer.delimiter = delim self.importer.updateDelimiter() self.updateDelimiterButtonText() self.showMapping(hook=updateDelim) else: # If the operation has been canceled, do not do anything pass def updateDelimiterButtonText(self) -> None: if not self.importer.needDelimiter: return if self.importer.delimiter: d = self.importer.delimiter else: d = self.importer.dialect.delimiter if d == "\t": d = tr.importing_tab() elif d == ",": d = tr.importing_comma() elif d == " ": d = tr.studying_space() elif d == ";": d = tr.importing_semicolon() elif d == ":": d = tr.importing_colon() else: d = repr(d) txt = tr.importing_fields_separated_by(val=d) self.frm.autoDetect.setText(txt) def accept(self) -> None: self.importer.mapping = self.mapping if not self.importer.mappingOk(): showWarning(tr.importing_the_first_field_of_the_note()) return self.importer.importMode = self.frm.importMode.currentIndex() assert self.mw.pm.profile is not None self.mw.pm.profile["importMode"] = self.importer.importMode self.importer.allowHTML = self.frm.allowHTML.isChecked() self.mw.pm.profile["allowHTML"] = self.importer.allowHTML self.importer.tagModified = self.frm.tagModified.text() self.mw.pm.profile["tagModified"] = self.importer.tagModified self.mw.col.set_aux_notetype_config( self.importer.model["id"], "lastDeck", self.deck.selected_deck_id ) self.mw.col.models.save(self.importer.model, updateReqs=False) self.mw.progress.start() def on_done(future: Future) -> None: self.mw.progress.finish() try: future.result() except UnicodeDecodeError: showUnicodeWarning() return except Exception as e: msg = f"{tr.importing_failed_debug_info()}\n" err = repr(str(e)) if "1-character string" in err: msg += err elif "invalidTempFolder" in err: msg += self.mw.errorHandler.tempFolderMsg() else: msg += traceback.format_exc() showText(msg) return else: txt = f"{tr.importing_importing_complete()}\n" if self.importer.log: txt += "\n".join(self.importer.log) self.close() showText(txt, plain_text_edit=True) self.mw.reset() self.mw.taskman.run_in_background(self.importer.run, on_done) def setupMappingFrame(self) -> None: # qt seems to have a bug with adding/removing from a grid, so we add # to a separate object and add/remove that instead self.frame = QFrame(self.frm.mappingArea) self.frm.mappingArea.setWidget(self.frame) self.mapbox = QVBoxLayout(self.frame) self.mapbox.setContentsMargins(0, 0, 0, 0) self.mapwidget: QWidget | None = None def hideMapping(self) -> None: self.frm.mappingGroup.hide() def showMapping( self, keepMapping: bool = False, hook: Callable | None = None ) -> None: if hook: hook() if not keepMapping: self.mapping = self.importer.mapping self.frm.mappingGroup.show() assert self.importer.fields() # set up the mapping grid if self.mapwidget: self.mapbox.removeWidget(self.mapwidget) self.mapwidget.deleteLater() self.mapwidget = QWidget() self.mapbox.addWidget(self.mapwidget) self.grid = QGridLayout(self.mapwidget) self.mapwidget.setLayout(self.grid) self.grid.setContentsMargins(3, 3, 3, 3) self.grid.setSpacing(6) for num in range(len(self.mapping)): text = tr.importing_field_of_file_is(val=num + 1) self.grid.addWidget(QLabel(text), num, 0) if self.mapping[num] == "_tags": text = tr.importing_mapped_to_tags() elif self.mapping[num]: text = tr.importing_mapped_to(val=self.mapping[num]) else: text = tr.importing_ignored() self.grid.addWidget(QLabel(text), num, 1) button = QPushButton(tr.importing_change()) self.grid.addWidget(button, num, 2) qconnect(button.clicked, lambda _, s=self, n=num: s.changeMappingNum(n)) def changeMappingNum(self, n: int) -> None: f = ChangeMap(self.mw, self.importer.model, self.mapping[n]).getField() try: # make sure we don't have it twice index = self.mapping.index(f) self.mapping[index] = None except ValueError: pass self.mapping[n] = f if getattr(self.importer, "delimiter", False): self.savedDelimiter = self.importer.delimiter def updateDelim() -> None: self.importer.delimiter = self.savedDelimiter self.showMapping(hook=updateDelim, keepMapping=True) else: self.showMapping(keepMapping=True) def reject(self) -> None: self.modelChooser.cleanup() self.deck.cleanup() gui_hooks.current_note_type_did_change.remove(self.modelChanged) QDialog.reject(self) def helpRequested(self) -> None: openHelp(HelpPage.IMPORTING) def importModeChanged(self, newImportMode: int) -> None: if newImportMode == 0: self.frm.tagModified.setEnabled(True) else: self.frm.tagModified.setEnabled(False) def showUnicodeWarning() -> None: """Shorthand to show a standard warning.""" showWarning(tr.importing_selected_file_was_not_in_utf8()) def onImport(mw: AnkiQt) -> None: filt = ";;".join([x[0] for x in importing.importers(mw.col)]) file = getFile(mw, tr.actions_import(), None, key="import", filter=filt) if not file: return file = str(file) head, ext = os.path.splitext(file) ext = ext.lower() if ext == ".anki": showInfo(tr.importing_anki_files_are_from_a_very()) return elif ext == ".anki2": showInfo(tr.importing_anki2_files_are_not_directly_importable()) return importFile(mw, file) def importFile(mw: AnkiQt, file: str) -> None: importerClass = None done = False for i in importing.importers(mw.col): if done: break for mext in re.findall(r"[( ]?\*\.(.+?)[) ]", i[0]): if file.endswith(f".{mext}"): importerClass = i[1] done = True break if not importerClass: # if no matches, assume TSV importerClass = importing.importers(mw.col)[0][1] importer = importerClass(mw.col, file) # need to show import dialog? if importer.needMapper: # make sure we can load the file first mw.progress.start(immediate=True) try: importer.open() mw.progress.finish() ImportDialog(mw, importer) except UnicodeDecodeError: mw.progress.finish() showUnicodeWarning() return except Exception as e: mw.progress.finish() msg = repr(str(e)) if msg == "'unknownFormat'": showWarning(tr.importing_unknown_file_format()) else: msg = f"{tr.importing_failed_debug_info()}\n" msg += str(traceback.format_exc()) showText(msg) return finally: importer.close() else: # if it's an apkg/zip, first test it's a valid file if isinstance(importer, AnkiPackageImporter): # we need to ask whether to import/replace; if it's # a colpkg file then the rest of the import process # will happen in setupApkgImport() if not setupApkgImport(mw, importer): return # importing non-colpkg files mw.progress.start(immediate=True) def on_done(future: Future) -> None: mw.progress.finish() try: future.result() except zipfile.BadZipfile: showWarning(invalidZipMsg()) except MediaMapInvalid: showWarning( "Unable to read file. It probably requires a newer version of Anki to import." ) except V2ImportIntoV1: showWarning( """\ To import this deck, please click the Update button at the top of the deck list, then try again.""" ) except Exception as e: err = repr(str(e)) if "invalidFile" in err: msg = tr.importing_invalid_file_please_restore_from_backup() showWarning(msg) elif "invalidTempFolder" in err: showWarning(mw.errorHandler.tempFolderMsg()) elif "readonly" in err: showWarning(tr.importing_unable_to_import_from_a_readonly()) else: msg = f"{tr.importing_failed_debug_info()}\n" traceback.print_exc(file=sys.stdout) msg += str(e) showText(msg) else: log = "\n".join(importer.log) if "\n" not in log: tooltip(log) else: showText(log, plain_text_edit=True) mw.reset() mw.taskman.run_in_background(importer.run, on_done) def invalidZipMsg() -> str: return tr.importing_this_file_does_not_appear_to() def setupApkgImport(mw: AnkiQt, importer: AnkiPackageImporter) -> bool: base = os.path.basename(importer.file).lower() full = ( (base == "collection.apkg") or re.match("backup-.*\\.apkg", base) or base.endswith(".colpkg") ) if not full: # adding return True ColpkgImporter.do_import(mw, importer.file) return False return False ================================================ FILE: qt/aqt/legacy.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """ Legacy support """ from __future__ import annotations from typing import Any import anki import anki.sound import anki.utils import aqt from aqt.theme import theme_manager # Routines removed from pylib/ ########################################################################## def bodyClass(col, card) -> str: # type: ignore print("bodyClass() deprecated") return theme_manager.body_classes_for_card_ord(card.ord) def allSounds(text) -> list: # type: ignore print("allSounds() deprecated") return aqt.mw.col.media._extract_filenames(text) def stripSounds(text) -> str: # type: ignore print("stripSounds() deprecated") return aqt.mw.col.media.strip_av_tags(text) def fmtTimeSpan( time: Any, pad: Any = 0, point: Any = 0, short: Any = False, inTime: Any = False, unit: Any = 99, ) -> Any: print("fmtTimeSpan() has become col.format_timespan()") return aqt.mw.col.format_timespan(time) def install_pylib_legacy() -> None: anki.utils.bodyClass = bodyClass # type: ignore anki.utils.fmtTimeSpan = fmtTimeSpan # type: ignore anki.sound._soundReg = r"\[sound:(.+?)\]" # type: ignore anki.sound.allSounds = allSounds # type: ignore anki.sound.stripSounds = stripSounds # type: ignore ================================================ FILE: qt/aqt/log.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import logging import sys from logging.handlers import TimedRotatingFileHandler from pathlib import Path from typing import Optional, cast # All loggers with the following prefix will be treated as add-on loggers # # To instatiate a logger with this prefix, use aqt.AddonManager.get_logger() # # NOTE: Add-ons might also directly instantiate a logger with this prefix, e.g. in # order to avoid depending on the Anki codebase, so this prefix should not # be changed. ADDON_LOGGER_PREFIX = "addon." # Formatter used for all loggers FORMATTER = logging.Formatter("%(asctime)s:%(levelname)s:%(name)s: %(message)s") class AnkiLoggerManager(logging.Manager): # inspired by: https://github.com/abdnh/ankiutils/blob/master/src/ankiutils/log.py def __init__( self, logs_path: Path | str, existing_loggers: dict[str, logging.Logger | logging.PlaceHolder], rootnode: logging.RootLogger, ): super().__init__(rootnode) self.loggerDict = existing_loggers self.logs_path = Path(logs_path) def getLogger(self, name: str) -> logging.Logger: if not name.startswith(ADDON_LOGGER_PREFIX) or name in self.loggerDict: return super().getLogger(name) # Create a new add-on logger logger = super().getLogger(name) module = name.split(ADDON_LOGGER_PREFIX)[1].partition(".")[0] path = get_addon_logs_folder(self.logs_path, module=module) / f"{module}.log" path.parent.mkdir(parents=True, exist_ok=True) # Keep the last 10 days of logs handler = TimedRotatingFileHandler( filename=path, when="D", interval=1, backupCount=10, encoding="utf-8" ) handler.setFormatter(FORMATTER) logger.addHandler(handler) return logger def get_addon_logs_folder(logs_path: Path | str, module: str) -> Path: return Path(logs_path) / "addons" / module def find_addon_logger(module: str) -> logging.Logger | None: return cast( Optional[logging.Logger], logging.Logger.manager.loggerDict.get(f"{ADDON_LOGGER_PREFIX}{module}"), ) def setup_logging(path: Path | str, **kwargs) -> None: """ Set up logging for the application. Configures the root logger to output logs to stdout by default, with custom handling for add-on logs. The add-on logs are saved to a separate folder and file for each add-on, under the path provided. Args: path (Path): The path where the log files should be stored. **kwargs: Arbitrary keyword arguments for logging.basicConfig """ # Patch root logger manager to handle add-on loggers logger_manager = AnkiLoggerManager( path, existing_loggers=logging.Logger.manager.loggerDict, rootnode=logging.root ) logging.Logger.manager = logger_manager stdout_handler = logging.StreamHandler(stream=sys.stdout) stdout_handler.setFormatter(FORMATTER) logging.basicConfig(handlers=[stdout_handler], force=True, **kwargs) logging.captureWarnings(True) # Silence some loggers of external libraries: silenced_loggers = [ "waitress.queue", ] for logger in silenced_loggers: logging.getLogger(logger).setLevel(logging.CRITICAL) logging.getLogger(logger).propagate = False ================================================ FILE: qt/aqt/main.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import enum import gc import os import re import signal import sys import traceback import weakref from argparse import Namespace from collections.abc import Callable, Sequence from concurrent.futures import Future from typing import Any, Literal, TypeVar, cast import anki import anki.cards import anki.sound import aqt import aqt.forms import aqt.mediasrv import aqt.mpv import aqt.operations import aqt.progress import aqt.sound import aqt.stats import aqt.toolbar import aqt.webview from anki import hooks from anki._backend import RustBackend as _RustBackend from anki._legacy import deprecated from anki.collection import Collection, Config, OpChanges, UndoStatus from anki.decks import DeckDict, DeckId from anki.hooks import runHook from anki.notes import NoteId from anki.sound import AVTag, SoundOrVideoTag from anki.utils import ( dev_mode, ids2str, int_time, int_version, is_lin, is_mac, is_win, split_fields, ) from aqt import gui_hooks from aqt.addons import DownloadLogEntry, check_and_prompt_for_updates, show_log_to_user from aqt.dbcheck import check_db from aqt.debug_console import show_debug_console from aqt.emptycards import show_empty_cards from aqt.flags import FlagManager from aqt.import_export.exporting import ExportDialog from aqt.import_export.importing import ( import_collection_package_op, import_file, prompt_for_file_then_import, ) from aqt.legacy import install_pylib_legacy from aqt.mediacheck import check_media_db from aqt.mediasync import MediaSyncer from aqt.operations import QueryOp from aqt.operations.collection import redo, undo from aqt.operations.deck import set_current_deck from aqt.profiles import ProfileManager as ProfileManagerType from aqt.qt import * from aqt.qt import sip from aqt.sync import sync_collection, sync_login from aqt.taskman import TaskManager from aqt.theme import Theme, theme_manager from aqt.toolbar import BottomWebView, Toolbar, TopWebView from aqt.undo import UndoActionsInfo from aqt.utils import ( HelpPage, KeyboardModifiersPressed, askUser, checkInvalidFilename, current_window, disallow_full_screen, getFile, getOnlyText, openHelp, openLink, restoreGeom, restoreState, saveGeom, saveState, showInfo, showWarning, tooltip, tr, ) from aqt.webview import AnkiWebView, AnkiWebViewKind install_pylib_legacy() MainWindowState = Literal[ "startup", "deckBrowser", "overview", "review", "resetRequired", "profileManager" ] T = TypeVar("T") class MainWebView(AnkiWebView): def __init__(self, mw: AnkiQt) -> None: AnkiWebView.__init__(self, kind=AnkiWebViewKind.MAIN) self.mw = mw self.setFocusPolicy(Qt.FocusPolicy.WheelFocus) self.setMinimumWidth(400) self.setAcceptDrops(True) # Importing files via drag & drop ########################################################################## def dragEnterEvent(self, event: QDragEnterEvent) -> None: if self.mw.state != "deckBrowser": return super().dragEnterEvent(event) mime = event.mimeData() if not mime.hasUrls(): return for url in mime.urls(): path = url.toLocalFile() if not os.path.exists(path) or os.path.isdir(path): return event.accept() def dropEvent(self, event: QDropEvent) -> None: import aqt.importing if self.mw.state != "deckBrowser": return super().dropEvent(event) mime = event.mimeData() paths = [url.toLocalFile() for url in mime.urls()] deck_paths = filter(lambda p: not p.endswith(".colpkg"), paths) for path in deck_paths: if not self.mw.pm.legacy_import_export(): import_file(self.mw, path) else: aqt.importing.importFile(self.mw, path) # importing continues after the above call returns, so it is not # currently safe for us to import more than one file at once return # Main webview specific event handling def eventFilter(self, obj: QObject | None, evt: QEvent | None) -> bool: if handled := super().eventFilter(obj, evt): return handled if evt.type() == QEvent.Type.Leave: handled_leave = False # Show menubar when mouse moves outside main webview in fullscreen if self.mw.fullscreen: self.mw.show_menubar() handled_leave = True # Show toolbar when mouse moves outside main webview # and automatically hide it with delay after mouse has entered again # The toolbar's hide timer will also trigger menubar hiding when in fullscreen mode if self.mw.pm.hide_top_bar() or self.mw.pm.hide_bottom_bar(): self.mw.toolbarWeb.show() self.mw.bottomWeb.show() handled_leave = True return handled_leave if evt.type() == QEvent.Type.Enter: self.mw.toolbarWeb.hide_timer.start() self.mw.bottomWeb.hide_timer.start() return True return False class AnkiQt(QMainWindow): col: Collection pm: ProfileManagerType web: MainWebView bottomWeb: BottomWebView def __init__( self, app: aqt.AnkiApp, profileManager: ProfileManagerType, backend: _RustBackend, opts: Namespace, args: list[Any], ) -> None: QMainWindow.__init__(self) self.backend = backend self.state: MainWindowState = "startup" self.opts = opts self.col: Collection | None = None self.taskman = TaskManager(self) self.media_syncer = MediaSyncer(self) aqt.mw = self self.app = app self.pm = profileManager self.fullscreen = False # init rest of app self.safeMode = ( bool(self.app.queryKeyboardModifiers() & Qt.KeyboardModifier.ShiftModifier) or self.opts.safemode ) try: self.setupUI() self.setupAddons(args) self.finish_ui_setup() except Exception: showInfo(tr.qt_misc_error_during_startup(val=traceback.format_exc())) sys.exit(1) # must call this after ui set up if self.safeMode: tooltip(tr.qt_misc_shift_key_was_held_down_skipping()) # were we given a file to import? if args and args[0] and not self._isAddon(args[0]): self.onAppMsg(args[0]) # Load profile in a timer so we can let the window finish init and not # close on profile load error. if is_win: fn = self.setupProfileAfterWebviewsLoaded else: fn = self.setupProfile def on_window_init() -> None: fn() gui_hooks.main_window_did_init() self.progress.single_shot(10, on_window_init, False) def setupUI(self) -> None: self.col = None self.disable_automatic_garbage_collection() self.setupAppMsg() self.setupKeys() self.setupThreads() self.setupMediaServer() self.setupSpellCheck() self.setupProgress() self.setupStyle() self.setupMainWindow() self.setupSystemSpecific() self.setupMenus() self.setupErrorHandler() self.setupSignals() self.setupHooks() self.setup_timers() self.updateTitleBar() self.setup_focus() # screens self.setupDeckBrowser() self.setupOverview() self.setupReviewer() def finish_ui_setup(self) -> None: "Actions that are deferred until after add-on loading." self.toolbar.draw() # add-ons are only available here after setupAddons gui_hooks.reviewer_did_init(self.reviewer) def setupProfileAfterWebviewsLoaded(self) -> None: for w in (self.web, self.bottomWeb): if not w._domDone: self.progress.single_shot( 10, self.setupProfileAfterWebviewsLoaded, False, ) return else: w.requiresCol = True self.setupProfile() def weakref(self) -> AnkiQt: "Shortcut to create a weak reference that doesn't break code completion." return weakref.proxy(self) # type: ignore def setup_focus(self) -> None: qconnect(self.app.focusChanged, self.on_focus_changed) def on_focus_changed(self, old: QWidget, new: QWidget) -> None: gui_hooks.focus_did_change(new, old) # Profiles ########################################################################## class ProfileManager(QMainWindow): onClose = pyqtSignal() closeFires = True def closeEvent(self, evt: QCloseEvent) -> None: if self.closeFires: self.onClose.emit() # type: ignore evt.accept() def closeWithoutQuitting(self) -> None: self.closeFires = False self.close() self.closeFires = True def setupProfile(self) -> None: if self.pm.meta["firstRun"]: # load the new deck user profile self.pm.load(self.pm.profiles()[0]) self.pm.meta["firstRun"] = False self.pm.save() self.pendingImport: str | None = None self.restoring_backup = False # - if a valid profile was provided on commandline, we load it # - if an invalid profile was provided, we skip this step and show the picker # - if no profile was provided, we use this step if not self.pm.name and not self.pm.invalid_profile_provided_on_commandline: profs = self.pm.profiles() name = self.pm.last_loaded_profile_name() if len(profs) == 1: self.pm.load(profs[0]) elif name in profs: self.pm.load(name) if not self.pm.name: self.showProfileManager() else: self.loadProfile() def showProfileManager(self) -> None: self.pm.profile = None self.moveToState("profileManager") d = self.profileDiag = self.ProfileManager() f = self.profileForm = aqt.forms.profiles.Ui_MainWindow() f.setupUi(d) qconnect(f.login.clicked, self.onOpenProfile) qconnect(f.profiles.itemDoubleClicked, self.onOpenProfile) qconnect(f.openBackup.clicked, self.onOpenBackup) qconnect(f.quit.clicked, d.close) qconnect(d.onClose, self.cleanupAndExit) qconnect(f.add.clicked, self.onAddProfile) qconnect(f.rename.clicked, self.onRenameProfile) qconnect(f.delete_2.clicked, self.onRemProfile) qconnect(f.profiles.currentRowChanged, self.onProfileRowChange) f.statusbar.setVisible(False) qconnect(f.downgrade_button.clicked, self._on_downgrade) f.downgrade_button.setText(tr.profiles_downgrade_and_quit()) # enter key opens profile QShortcut(QKeySequence("Return"), d, activated=self.onOpenProfile) # type: ignore self.refreshProfilesList() # raise first, for osx testing d.show() d.activateWindow() d.raise_() def refreshProfilesList(self) -> None: f = self.profileForm f.profiles.clear() profs = self.pm.profiles() f.profiles.addItems(profs) try: idx = profs.index(self.pm.name) except Exception: idx = 0 f.profiles.setCurrentRow(idx) def onProfileRowChange(self, n: int) -> None: if n < 0: # called on .clear() return name = self.pm.profiles()[n] self.pm.load(name) def openProfile(self) -> None: name = self.pm.profiles()[self.profileForm.profiles.currentRow()] self.pm.load(name) def onOpenProfile(self, *, callback: Callable[[], None] | None = None) -> None: def on_done() -> None: self.profileDiag.closeWithoutQuitting() if callback: callback() self.profileDiag.hide() # code flow is confusing here - if load fails, profile dialog # will be shown again self.loadProfile(on_done) def profileNameOk(self, name: str) -> bool: return not checkInvalidFilename(name) and name != "addons21" def onAddProfile(self) -> None: name = getOnlyText(tr.actions_name()).strip() if name: if name in self.pm.profiles(): showWarning(tr.qt_misc_name_exists()) return if not self.profileNameOk(name): return self.pm.create(name) self.pm.name = name self.refreshProfilesList() def onRenameProfile(self) -> None: name = getOnlyText(tr.actions_new_name(), default=self.pm.name).strip() if not name: return if name == self.pm.name: return if name in self.pm.profiles(): showWarning(tr.qt_misc_name_exists()) return if not self.profileNameOk(name): return self.pm.rename(name) self.refreshProfilesList() def onRemProfile(self) -> None: profs = self.pm.profiles() if len(profs) < 2: showWarning(tr.qt_misc_there_must_be_at_least_one()) return # sure? if not askUser( tr.qt_misc_all_cards_notes_and_media_for2(name=self.pm.name), msgfunc=QMessageBox.warning, defaultno=True, ): return self.pm.remove(self.pm.name) self.refreshProfilesList() def _handle_load_backup_success(self) -> None: """ Actions that occur when profile backup has been loaded successfully """ if self.state == "profileManager": self.profileDiag.closeWithoutQuitting() self.loadProfile() def _handle_load_backup_failure(self, error: Exception) -> None: """ Actions that occur when a profile has loaded unsuccessfully """ showWarning(str(error)) if self.state != "profileManager": self.loadProfile() def onOpenBackup(self) -> None: def do_open(path: str) -> None: if not askUser( tr.qt_misc_replace_your_collection_with_an_earlier2( os.path.basename(path) ), msgfunc=QMessageBox.warning, defaultno=True, ): return showInfo(tr.qt_misc_automatic_syncing_and_backups_have_been()) # Collection is still loaded if called from main window, so we unload. This is already # unloaded if called from the ProfileManager window. if self.col: self.unloadProfile(lambda: self._start_restore_backup(path)) return self._start_restore_backup(path) getFile( self.profileDiag if self.state == "profileManager" else self, tr.qt_misc_revert_to_backup(), cb=do_open, # type: ignore filter="*.colpkg", dir=self.pm.backupFolder(), ) def _start_restore_backup(self, path: str): self.restoring_backup = True import_collection_package_op( self, path, success=self._handle_load_backup_success ).failure(self._handle_load_backup_failure).run_in_background() def _on_downgrade(self) -> None: self.progress.start() profiles = self.pm.profiles() def downgrade() -> list[str]: return self.pm.downgrade(profiles) def on_done(future: Future) -> None: self.progress.finish() problems = future.result() if not problems: showInfo("Profiles can now be opened with an older version of Anki.") else: showWarning( "The following profiles could not be downgraded: {}".format( ", ".join(problems) ) ) return self.profileDiag.close() self.taskman.run_in_background(downgrade, on_done) def loadProfile(self, onsuccess: Callable | None = None) -> None: if not self.loadCollection(): return self.setup_sound() self.flags = FlagManager(self) # show main window restoreGeom(self, "mainWindow") restoreState(self, "mainWindow") # titlebar self.setWindowTitle(f"{self.pm.name} - Anki") # show and raise window for osx self.show() self.activateWindow() self.raise_() # import pending? if self.pendingImport: if self._isAddon(self.pendingImport): self.installAddon(self.pendingImport) else: self.handleImport(self.pendingImport) self.pendingImport = None def _onsuccess(synced: bool) -> None: if synced: self._refresh_after_sync() if onsuccess: onsuccess() if not self.safeMode: self.maybe_check_for_addon_updates(self.setup_auto_update) last_day_cutoff = self.col.sched.day_cutoff def refresh_reviewer_on_day_rollover_change(): from aqt.reviewer import RefreshNeeded # need to refresh? nonlocal last_day_cutoff current_cutoff = self.col.sched.day_cutoff if self.state == "review" and last_day_cutoff != current_cutoff: last_day_cutoff = self.col.sched.day_cutoff self.reviewer._refresh_needed = RefreshNeeded.QUEUES self.reviewer.refresh_if_needed() if last_day_cutoff != current_cutoff: gui_hooks.day_did_change() # schedule another check secs_until_cutoff = current_cutoff - int_time() self._reviewer_refresh_timer = self.progress.timer( secs_until_cutoff * 1000, refresh_reviewer_on_day_rollover_change, repeat=False, parent=self, ) refresh_reviewer_on_day_rollover_change() gui_hooks.profile_did_open() self.maybe_auto_sync_on_open_close(_onsuccess) def unloadProfile(self, onsuccess: Callable) -> None: def callback() -> None: self._unloadProfile() onsuccess() gui_hooks.profile_will_close() self.unloadCollection(callback) def _unloadProfile(self) -> None: self.cleanup_sound() saveGeom(self, "mainWindow") saveState(self, "mainWindow") self.pm.save() self.hide() self.restoring_backup = False # at this point there should be no windows left self._checkForUnclosedWidgets() self._reviewer_refresh_timer.deleteLater() def _checkForUnclosedWidgets(self) -> None: for w in self.app.topLevelWidgets(): if w.isVisible(): # windows with this property are safe to close immediately if getattr(w, "silentlyClose", None): w.close() else: print(f"Window should have been closed: {w}") def unloadProfileAndExit(self) -> None: self.unloadProfile(self.cleanupAndExit) def unloadProfileAndShowProfileManager(self) -> None: self.unloadProfile(self.showProfileManager) def cleanupAndExit(self) -> None: self.errorHandler.unload() self.mediaServer.shutdown() # Rust background jobs are not awaited implicitly self.backend.await_backup_completion() self.deleteLater() app = self.app app._unset_windows_shutdown_block_reason() def exit(): # try to ensure Qt objects are deleted in a logical order, # to prevent crashes on shutdown gc.collect() app.exit(0) self.progress.single_shot(100, exit, False) # Sound/video ########################################################################## def setup_sound(self) -> None: aqt.sound.setup_audio(self.taskman, self.pm.base, self.col.media.dir()) def cleanup_sound(self) -> None: aqt.sound.cleanup_audio() def _add_play_buttons(self, text: str) -> str: "Return card text with play buttons added, or stripped." if self.col.get_config_bool(Config.Bool.HIDE_AUDIO_PLAY_BUTTONS): return anki.sound.strip_av_refs(text) else: return aqt.sound.av_refs_to_play_icons(text) def prepare_card_text_for_display(self, text: str) -> str: text = self.col.media.escape_media_filenames(text) text = self._add_play_buttons(text) return text # Collection load/unload ########################################################################## def loadCollection(self) -> bool: try: self._loadCollection() except Exception as e: if "FileTooNew" in str(e): showWarning( "This profile requires a newer version of Anki to open. Did you forget to use the Downgrade button prior to switching Anki versions?" ) else: showWarning( f"{tr.errors_unable_open_collection()}\n{traceback.format_exc()}" ) # clean up open collection if possible try: self.backend.close_collection(downgrade_to_schema11=False) except Exception as e: print("unable to close collection:", e) self.col = None # return to profile manager self.hide() self.showProfileManager() return False # make sure we don't get into an inconsistent state if an add-on # has broken the deck browser or the did_load hook try: self.update_undo_actions() gui_hooks.collection_did_load(self.col) self.apply_collection_options() self.moveToState("deckBrowser") except Exception: # dump error to stderr so it gets picked up by errors.py traceback.print_exc() return True def _loadCollection(self) -> None: cpath = self.pm.collectionPath() self.col = Collection(cpath, backend=self.backend) self.setEnabled(True) def reopen(self, after_full_sync: bool = False) -> None: self.col.reopen(after_full_sync=after_full_sync) gui_hooks.collection_did_temporarily_close(self.col) def unloadCollection(self, onsuccess: Callable) -> None: def after_media_sync() -> None: self._unloadCollection() onsuccess() def after_sync(synced: bool) -> None: self.media_syncer.show_diag_until_finished(after_media_sync) def before_sync() -> None: self.setEnabled(False) self.maybe_auto_sync_on_open_close(after_sync) self.closeAllWindows(before_sync) def _unloadCollection(self) -> None: if not self.col: return label = ( tr.qt_misc_closing() if self.restoring_backup else tr.qt_misc_backing_up() ) self.progress.start(label=label) corrupt = False try: self.maybeOptimize() if not dev_mode: corrupt = self.col.db.scalar("pragma quick_check") != "ok" except Exception: corrupt = True try: if not corrupt and not dev_mode and not self.restoring_backup: try: # default 5 minute throttle self.col.create_backup( backup_folder=self.pm.backupFolder(), force=False, wait_for_completion=False, ) except Exception: print("backup on close failed") self.col.close(downgrade=False) except Exception as e: print(e) corrupt = True finally: self.col = None self.progress.finish() if corrupt: showWarning(tr.qt_misc_your_collection_file_appears_to_be()) def apply_collection_options(self) -> None: "Setup audio after collection loaded." aqt.sound.av_player.interrupt_current_audio = self.col.get_config_bool( Config.Bool.INTERRUPT_AUDIO_WHEN_ANSWERING ) # Auto-optimize ########################################################################## def maybeOptimize(self) -> None: # have two weeks passed? if (last_optimize := self.pm.profile.get("lastOptimize")) is not None: if (int_time() - last_optimize) < 86400 * 14: return self.progress.start(label=tr.qt_misc_optimizing()) self.col.optimize() self.pm.profile["lastOptimize"] = int_time() self.pm.save() self.progress.finish() # Tracking main window state (deck browser, reviewer, etc) ########################################################################## def moveToState(self, state: MainWindowState, *args: Any) -> None: # print("-> move from", self.state, "to", state) oldState = self.state cleanup = getattr(self, f"_{oldState}Cleanup", None) if cleanup: cleanup(state) self.clearStateShortcuts() self.state = state gui_hooks.state_will_change(state, oldState) getattr(self, f"_{state}State", lambda *_: None)(oldState, *args) if state != "resetRequired": self.bottomWeb.adjustHeightToFit() gui_hooks.state_did_change(state, oldState) def _deckBrowserState(self, oldState: MainWindowState) -> None: self.deckBrowser.show() def _selectedDeck(self) -> DeckDict | None: did = self.col.decks.selected() if not self.col.decks.name_if_exists(did): showInfo(tr.qt_misc_please_select_a_deck()) return None return self.col.decks.get(did) def _overviewState(self, oldState: MainWindowState) -> None: if not self._selectedDeck(): return self.moveToState("deckBrowser") self.overview.show() def _reviewState(self, oldState: MainWindowState) -> None: self.reviewer.show() fullscreen_was_checked = False if self.pm.hide_top_bar(): self.toolbarWeb.hide_timer.setInterval(500) self.toolbarWeb.hide_timer.start() # check the `hide_if_allowed` method in `qt/aqt/toolbar.py` fullscreen_was_checked = True else: self.toolbarWeb.flatten() if not fullscreen_was_checked and self.fullscreen: self.hide_menubar() if self.pm.hide_bottom_bar(): self.bottomWeb.hide_timer.setInterval(500) self.bottomWeb.hide_timer.start() def _reviewCleanup(self, newState: MainWindowState) -> None: if newState not in {"resetRequired", "review"}: self.reviewer.auto_advance_enabled = False self.reviewer.cleanup() self.toolbarWeb.elevate() self.toolbarWeb.show() self.bottomWeb.show() # Resetting state ########################################################################## def _increase_background_ops(self) -> None: if not self._background_op_count: gui_hooks.backend_will_block() self._background_op_count += 1 def _decrease_background_ops(self) -> None: self._background_op_count -= 1 if not self._background_op_count: gui_hooks.backend_did_block() if self._background_op_count < 0: raise Exception("no background ops active") def _synthesize_op_did_execute_from_reset(self) -> None: """Fire the `operation_did_execute` hook with everything marked as changed, after legacy code has called .reset()""" op = OpChanges() for field in op.DESCRIPTOR.fields: if field.name != "kind": setattr(op, field.name, True) gui_hooks.operation_did_execute(op, None) def on_operation_did_execute( self, changes: OpChanges, handler: object | None ) -> None: "Notify current screen of changes." focused = current_window() == self if self.state == "review": dirty = self.reviewer.op_executed(changes, handler, focused) elif self.state == "overview": dirty = self.overview.op_executed(changes, handler, focused) elif self.state == "deckBrowser": dirty = self.deckBrowser.op_executed(changes, handler, focused) else: dirty = False if not focused and dirty: self.fade_out_webview() if changes.mtime: self.toolbar.update_sync_status() if changes.notetype: self.col.models._clear_cache() def on_focus_did_change( self, new_focus: QWidget | None, _old: QWidget | None ) -> None: "If main window has received focus, ensure current UI state is updated." if new_focus and new_focus.window() == self: if self.state == "review": self.reviewer.refresh_if_needed() elif self.state == "overview": self.overview.refresh_if_needed() elif self.state == "deckBrowser": self.deckBrowser.refresh_if_needed() def fade_out_webview(self) -> None: self.web.eval("document.body.style.opacity = 0.3") def fade_in_webview(self) -> None: self.web.eval("document.body.style.opacity = 1") def reset(self, unused_arg: bool = False) -> None: """Legacy method of telling UI to refresh after changes made to DB. New code should use CollectionOp() instead.""" if self.col: # fire new `operation_did_execute` hook first. If the overview # or review screen are currently open, they will rebuild the study # queues (via mw.col.reset()) self._synthesize_op_did_execute_from_reset() # fire the old reset hook gui_hooks.state_did_reset() self.update_undo_actions() # legacy def requireReset( self, modal: bool = False, reason: Any | None = None, context: Any | None = None, ) -> None: traceback.print_stack(file=sys.stdout) print("requireReset() is obsolete; please use CollectionOp()") self.reset() def maybeReset(self) -> None: pass def delayedMaybeReset(self) -> None: pass def _resetRequiredState(self, oldState: MainWindowState) -> None: pass # HTML helpers ########################################################################## def button( self, link: str, name: str, key: str | None = None, class_: str = "", id: str = "", extra: str = "", ) -> str: class_ = f"but {class_}" if key: key = tr.actions_shortcut_key(val=key) else: key = "" return """ """.format( id, class_, link, key, extra, name, ) # Main window setup ########################################################################## def setupMainWindow(self) -> None: # main window self.form = aqt.forms.main.Ui_MainWindow() self.form.setupUi(self) # toolbar tweb = self.toolbarWeb = TopWebView(self) self.toolbar = Toolbar(self, tweb) # main area self.web = MainWebView(self) # bottom area sweb = self.bottomWeb = BottomWebView(self) sweb.setFocusPolicy(Qt.FocusPolicy.WheelFocus) sweb.disable_zoom() # add in a layout self.mainLayout = QVBoxLayout() self.mainLayout.setContentsMargins(0, 0, 0, 0) self.mainLayout.setSpacing(0) self.mainLayout.addWidget(tweb) self.mainLayout.addWidget(self.web) self.mainLayout.addWidget(sweb) self.form.centralwidget.setLayout(self.mainLayout) # force webengine processes to load before cwd is changed if is_win: for webview in self.web, self.bottomWeb: webview.force_load_hack() gui_hooks.card_review_webview_did_init(self.web, AnkiWebViewKind.MAIN) def closeAllWindows(self, onsuccess: Callable) -> None: aqt.dialogs.closeAll(onsuccess) # Components ########################################################################## def setupSignals(self) -> None: signal.signal(signal.SIGINT, self.onUnixSignal) signal.signal(signal.SIGTERM, self.onUnixSignal) def onUnixSignal(self, signum: Any, frame: Any) -> None: def quit() -> None: self.close() self.progress.single_shot(100, quit) def setupProgress(self) -> None: self.progress = aqt.progress.ProgressManager(self) def setupErrorHandler(self) -> None: import aqt.errors self.errorHandler = aqt.errors.ErrorHandler(self) def setupAddons(self, args: list | None) -> None: import aqt.addons self.addonManager = aqt.addons.AddonManager(self) if args and args[0] and self._isAddon(args[0]): self.installAddon(args[0], startup=True) if not self.safeMode: self.addonManager.loadAddons() def maybe_check_for_addon_updates( self, on_done: Callable[[list[DownloadLogEntry]], None] | None = None ) -> None: if not self.pm.check_for_addon_updates(): if on_done: on_done([]) return last_check = self.pm.last_addon_update_check() elap = int_time() - last_check if elap > 86_400 or self.pm.last_run_version != int_version(): self.check_for_addon_updates(by_user=False, on_done=on_done) elif on_done: on_done([]) def check_for_addon_updates( self, by_user: bool, on_done: Callable[[list[DownloadLogEntry]], None] | None = None, ) -> None: def wrap_on_updates_installed(log: list[DownloadLogEntry]) -> None: self.on_updates_installed(log) self.pm.set_last_addon_update_check(int_time()) if on_done: on_done(log) check_and_prompt_for_updates( self, self.addonManager, wrap_on_updates_installed, requested_by_user=by_user, ) def on_updates_installed(self, log: list[DownloadLogEntry]) -> None: if log: show_log_to_user(self, log) def setupSpellCheck(self) -> None: os.environ["QTWEBENGINE_DICTIONARIES_PATH"] = os.path.join( self.pm.base, "dictionaries" ) def setupThreads(self) -> None: self._mainThread = QThread.currentThread() self._background_op_count = 0 def inMainThread(self) -> bool: return self._mainThread == QThread.currentThread() def setupDeckBrowser(self) -> None: from aqt.deckbrowser import DeckBrowser self.deckBrowser = DeckBrowser(self) def setupOverview(self) -> None: from aqt.overview import Overview self.overview = Overview(self) def setupReviewer(self) -> None: from aqt.reviewer import Reviewer self.reviewer = Reviewer(self) # Syncing ########################################################################## def on_sync_button_clicked(self) -> None: if self.media_syncer.is_syncing(): self.media_syncer.show_sync_log() else: auth = self.pm.sync_auth() if not auth: sync_login( self, lambda: self._sync_collection_and_media(self._refresh_after_sync), ) else: self._sync_collection_and_media(self._refresh_after_sync) def _refresh_after_sync(self) -> None: self.toolbar.redraw() self.flags.require_refresh() def _sync_collection_and_media(self, after_sync: Callable[[], None]) -> None: "Caller should ensure auth available." def on_collection_sync_finished() -> None: self.col.models._clear_cache() gui_hooks.sync_did_finish() self.reset() after_sync() gui_hooks.sync_will_start() sync_collection(self, on_done=on_collection_sync_finished) def maybe_auto_sync_on_open_close(self, after_sync: Callable[[bool], None]) -> None: "If disabled, after_sync() is called immediately." if self.can_auto_sync(): self._sync_collection_and_media(lambda: after_sync(True)) else: after_sync(False) def can_auto_sync(self) -> bool: "True if syncing on startup/shutdown enabled." return self._can_sync_unattended() and self.pm.auto_syncing_enabled() def _can_sync_unattended(self) -> bool: return ( bool(self.pm.sync_auth()) and not self.safeMode and not self.restoring_backup ) # legacy def _sync(self) -> None: pass onSync = on_sync_button_clicked # Tools ########################################################################## def raiseMain(self) -> bool: if not self.app.activeWindow(): # make sure window is shown self.setWindowState(self.windowState() & ~Qt.WindowState.WindowMinimized) # type: ignore return True def setupStyle(self) -> None: theme_manager.apply_style() if is_lin: # On Linux, the check requires invoking an external binary, # and can potentially produce verbose logs on systems where # the preferred theme cannot be determined, # which we don't want to be doing frequently interval_secs = 300 else: interval_secs = 2 self.progress.timer( interval_secs * 1000, theme_manager.apply_style, True, False, parent=self, ) def set_theme(self, theme: Theme) -> None: self.pm.set_theme(theme) self.setupStyle() # Key handling ########################################################################## def setupKeys(self) -> None: globalShortcuts = [ ("Ctrl+:", show_debug_console), ("d", lambda: self.moveToState("deckBrowser")), ("s", self.onStudyKey), ("a", self.onAddCard), ("b", self.onBrowse), ("t", self.onStats), ("Shift+t", self.onStats), ("y", self.on_sync_button_clicked), ] self.applyShortcuts(globalShortcuts) self.stateShortcuts: list[QShortcut] = [] def _close_active_window(self) -> None: window = ( QApplication.activeModalWidget() or current_window() or self.app.activeWindow() ) if not window or window is self: return if window is getattr(self, "profileDiag", None): # Do not allow closing of ProfileManager return if isinstance(window, QDialog): window.reject() else: window.close() def _normalize_shortcuts( self, shortcuts: Sequence[tuple[str, Callable]] ) -> Sequence[tuple[QKeySequence, Callable]]: """ Remove duplicate shortcuts (possibly added by add-ons) by normalizing them and filtering through a dictionary. The last duplicate shortcut wins, so add-ons will override standard shortcuts if they append to the shortcut list. """ return tuple({QKeySequence(key): fn for key, fn in shortcuts}.items()) def applyShortcuts( self, shortcuts: Sequence[tuple[str, Callable]] ) -> list[QShortcut]: qshortcuts = [] for key, fn in self._normalize_shortcuts(shortcuts): scut = QShortcut(key, self, activated=fn) # type: ignore scut.setAutoRepeat(False) qshortcuts.append(scut) return qshortcuts def setStateShortcuts(self, shortcuts: list[tuple[str, Callable]]) -> None: gui_hooks.state_shortcuts_will_change(self.state, shortcuts) # legacy hook runHook(f"{self.state}StateShortcuts", shortcuts) self.stateShortcuts = self.applyShortcuts(shortcuts) def clearStateShortcuts(self) -> None: for qs in self.stateShortcuts: sip.delete(qs) # type: ignore self.stateShortcuts = [] def onStudyKey(self) -> None: if self.state == "overview": self.col.startTimebox() self.moveToState("review") else: self.moveToState("overview") # App exit ########################################################################## def closeEvent(self, event: QCloseEvent) -> None: if self.state == "profileManager": # if profile manager active, this event may fire via OS X menu bar's # quit option self.profileDiag.close() event.accept() else: # ignore the event for now, as we need time to clean up event.ignore() self.unloadProfileAndExit() # Undo & autosave ########################################################################## def undo(self) -> None: "Call operations/collection.py:undo() directly instead." undo(parent=self) def redo(self) -> None: "Call operations/collection.py:redo() directly instead." redo(parent=self) def undo_actions_info(self) -> UndoActionsInfo: "Info about the current undo/redo state for updating menus." status = self.col.undo_status() if self.col else UndoStatus() return UndoActionsInfo.from_undo_status(status) def update_undo_actions(self) -> None: """Tell the UI to redraw the undo/redo menu actions based on the current state. Usually you do not need to call this directly; it is called when a CollectionOp is run, and will be called when the legacy .reset() or .checkpoint() methods are used.""" info = self.undo_actions_info() self.form.actionUndo.setText(info.undo_text) self.form.actionUndo.setEnabled(info.can_undo) self.form.actionRedo.setText(info.redo_text) self.form.actionRedo.setEnabled(info.can_redo) self.form.actionRedo.setVisible(info.show_redo) gui_hooks.undo_state_did_change(info) @deprecated(info="checkpoints are no longer supported") def checkpoint(self, name: str) -> None: pass @deprecated(info="saving is automatic") def autosave(self) -> None: pass onUndo = undo # Other menu operations ########################################################################## def onAddCard(self) -> None: aqt.dialogs.open("AddCards", self) def onBrowse(self) -> None: aqt.dialogs.open("Browser", self, card=self.reviewer.card) def onEditCurrent(self) -> None: aqt.dialogs.open("EditCurrent", self) def onOverview(self) -> None: self.moveToState("overview") def onStats(self) -> None: deck = self._selectedDeck() if not deck: return want_old = KeyboardModifiersPressed().shift if want_old: aqt.dialogs.open("DeckStats", self) else: aqt.dialogs.open("NewDeckStats", self) def onPrefs(self) -> None: aqt.dialogs.open("Preferences", self) def on_upgrade_downgrade(self) -> None: if not askUser(tr.qt_misc_open_anki_launcher()): return from aqt.package import update_and_restart update_and_restart() def onNoteTypes(self) -> None: import aqt.models aqt.models.Models(self, self, fromMain=True) def onAbout(self) -> None: aqt.dialogs.open("About", self) def onDonate(self) -> None: openLink(aqt.appDonate) def onDocumentation(self) -> None: openHelp(HelpPage.INDEX) # legacy def onDeckConf(self, deck: DeckDict | None = None) -> None: pass # Importing & exporting ########################################################################## def handleImport(self, path: str) -> None: "Importing triggered via file double-click, or dragging file onto Anki icon." import aqt.importing if not os.path.exists(path): # there were instances in the distant past where the received filename was not # valid (encoding issues?), so this was added to direct users to try # file>import instead. showInfo(f"{tr.qt_misc_please_use_fileimport_to_import_this()} ({path})") return None if not self.pm.legacy_import_export(): import_file(self, path) else: aqt.importing.importFile(self, path) def onImport(self) -> None: "Importing triggered via File>Import." import aqt.importing if not self.pm.legacy_import_export(): prompt_for_file_then_import(self) else: aqt.importing.onImport(self) def onExport(self, did: DeckId | None = None) -> None: import aqt.exporting if not self.pm.legacy_import_export(): ExportDialog(self, did=did) else: aqt.exporting.ExportDialog(self, did=did) # Installing add-ons from CLI / mimetype handler ########################################################################## def installAddon(self, path: str, startup: bool = False) -> None: from aqt.addons import installAddonPackages installAddonPackages( self.addonManager, [path], warn=True, advise_restart=not startup, strictly_modal=startup, parent=None if startup else self, force_enable=True, ) # Cramming ########################################################################## def onCram(self) -> None: aqt.dialogs.open("FilteredDeckConfigDialog", self) # Menu, title bar & status ########################################################################## def setupMenus(self) -> None: from aqt.package import launcher_executable m = self.form # File qconnect( m.actionSwitchProfile.triggered, self.unloadProfileAndShowProfileManager ) qconnect(m.actionImport.triggered, self.onImport) qconnect(m.actionExport.triggered, self.onExport) qconnect(m.action_create_backup.triggered, self.on_create_backup_now) qconnect(m.action_open_backup.triggered, self.onOpenBackup) qconnect(m.actionExit.triggered, self.close) # Help qconnect(m.actionDocumentation.triggered, self.onDocumentation) qconnect(m.actionDonate.triggered, self.onDonate) qconnect(m.actionAbout.triggered, self.onAbout) m.actionAbout.setText(tr.qt_accel_about_mac()) # Edit qconnect(m.actionUndo.triggered, self.undo) qconnect(m.actionRedo.triggered, self.redo) # Tools qconnect(m.actionFullDatabaseCheck.triggered, self.onCheckDB) qconnect(m.actionCheckMediaDatabase.triggered, self.on_check_media_db) qconnect(m.actionStudyDeck.triggered, self.onStudyDeck) qconnect(m.actionCreateFiltered.triggered, self.onCram) qconnect(m.actionEmptyCards.triggered, self.onEmptyCards) qconnect(m.actionNoteTypes.triggered, self.onNoteTypes) qconnect(m.action_upgrade_downgrade.triggered, self.on_upgrade_downgrade) if not launcher_executable(): m.action_upgrade_downgrade.setVisible(False) qconnect(m.actionPreferences.triggered, self.onPrefs) # View qconnect( m.actionZoomIn.triggered, lambda: self.web.setZoomFactor(self.web.zoomFactor() + 0.1), ) qconnect( m.actionZoomOut.triggered, lambda: self.web.setZoomFactor(self.web.zoomFactor() - 0.1), ) qconnect(m.actionResetZoom.triggered, lambda: self.web.setZoomFactor(1)) # app-wide shortcut qconnect(m.actionFullScreen.triggered, self.on_toggle_full_screen) m.actionFullScreen.setShortcut( QKeySequence("F11") if is_lin else QKeySequence.StandardKey.FullScreen ) m.actionFullScreen.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut) def updateTitleBar(self) -> None: self.setWindowTitle("Anki") # View ########################################################################## def on_toggle_full_screen(self) -> None: if disallow_full_screen(): showWarning( tr.actions_fullscreen_unsupported(), parent=self, help=HelpPage.FULL_SCREEN_ISSUE, ) return else: window = self.app.activeWindow() window.setWindowState( window.windowState() ^ Qt.WindowState.WindowFullScreen ) # Hide Menubar on Windows and Linux if window.windowState() & Qt.WindowState.WindowFullScreen and not is_mac: self.fullscreen = True self.hide_menubar() else: self.fullscreen = False self.show_menubar() # Update Toolbar states self.toolbarWeb.hide_if_allowed() self.bottomWeb.hide_if_allowed() def hide_menubar(self) -> None: self.form.menubar.setFixedHeight(0) def show_menubar(self) -> None: self.form.menubar.setMaximumSize(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX) self.form.menubar.setMinimumSize(0, 0) # Auto update ########################################################################## def setup_auto_update(self, _log: list[DownloadLogEntry]) -> None: from aqt.update import check_for_update if aqt.mw.pm.check_for_updates(): check_for_update() # Timers ########################################################################## def setup_timers(self) -> None: # refresh decks every 10 minutes self.progress.timer(10 * 60 * 1000, self.onRefreshTimer, True, parent=self) # check media sync every 5 minutes self.progress.timer( 5 * 60 * 1000, self.on_periodic_sync_timer, True, parent=self ) # periodic garbage collection self.progress.timer( 15 * 60 * 1000, self.garbage_collect_now, True, False, parent=self ) # ensure Python interpreter runs at least once per second, so that # SIGINT/SIGTERM is processed without a long delay self.progress.timer(1000, lambda: None, True, False, parent=self) # periodic backups are checked every 5 minutes self.progress.timer( 5 * 60 * 1000, self.on_periodic_backup_timer, True, parent=self, ) def onRefreshTimer(self) -> None: if self.state == "deckBrowser": self.deckBrowser.refresh() elif self.state == "overview": self.overview.refresh() def on_periodic_sync_timer(self) -> None: elap = self.media_syncer.seconds_since_last_sync() minutes = self.pm.periodic_sync_media_minutes() if not minutes: return if elap > minutes * 60: if not self._can_sync_unattended(): return # media_syncer takes care of media syncing preference check self.media_syncer.start(True) # Backups ########################################################################## def on_periodic_backup_timer(self) -> None: """Create a backup if enough time has elapsed and collection changed.""" self._create_backup_with_progress(user_initiated=False) def on_create_backup_now(self) -> None: self._create_backup_with_progress(user_initiated=True) def create_backup_now(self) -> None: """Create a backup immediately, regardless of when the last one was created. Waits until the backup completes. Intended to be used as part of a longer-running CollectionOp/QueryOp.""" self.col.create_backup( backup_folder=self.pm.backupFolder(), force=True, wait_for_completion=True, ) def _create_backup_with_progress(self, user_initiated: bool) -> None: # The initial copy will display a progress window if it takes too long def backup(col: Collection) -> bool: return col.create_backup( backup_folder=self.pm.backupFolder(), force=user_initiated, wait_for_completion=False, ) def on_success(val: None) -> None: if user_initiated: tooltip(tr.profiles_backup_created(), parent=self) def on_failure(exc: Exception) -> None: showWarning( tr.profiles_backup_creation_failed(reason=str(exc)), parent=self ) def after_backup_started(created: bool) -> None: self.update_undo_actions() if user_initiated and not created: tooltip(tr.profiles_backup_unchanged(), parent=self) return # We await backup completion to confirm it was successful, but this step # does not block collection access, so we don't need to show the progress # window anymore. QueryOp( parent=self, op=lambda col: col.await_backup_completion(), success=on_success, ).failure(on_failure).without_collection().run_in_background() QueryOp(parent=self, op=backup, success=after_backup_started).failure( on_failure ).with_progress(tr.profiles_creating_backup()).run_in_background() # Permanent hooks ########################################################################## def setupHooks(self) -> None: hooks.schema_will_change.append(self.onSchemaMod) hooks.notes_will_be_deleted.append(self.onRemNotes) hooks.card_odue_was_invalid.append(self.onOdueInvalid) gui_hooks.av_player_will_play.append(self.on_av_player_will_play) gui_hooks.av_player_did_end_playing.append(self.on_av_player_did_end_playing) gui_hooks.operation_did_execute.append(self.on_operation_did_execute) gui_hooks.focus_did_change.append(self.on_focus_did_change) self._activeWindowOnPlay: QWidget | None = None def onOdueInvalid(self) -> None: showWarning(tr.qt_misc_invalid_property_found_on_card_please()) def _isVideo(self, tag: AVTag) -> bool: if isinstance(tag, SoundOrVideoTag): head, ext = os.path.splitext(tag.filename.lower()) return ext in (".mp4", ".mov", ".mpg", ".mpeg", ".mkv", ".avi") return False def on_av_player_will_play(self, tag: AVTag) -> None: "Record active window to restore after video playing." if not self._isVideo(tag): return self._activeWindowOnPlay = self.app.activeWindow() or self._activeWindowOnPlay def on_av_player_did_end_playing(self, player: Any) -> None: "Restore window focus after a video was played." w = self._activeWindowOnPlay if not self.app.activeWindow() and w and not sip.isdeleted(w) and w.isVisible(): w.activateWindow() w.raise_() self._activeWindowOnPlay = None # Log note deletion ########################################################################## def onRemNotes(self, col: Collection, nids: Sequence[NoteId]) -> None: path = os.path.join(self.pm.profileFolder(), "deleted.txt") existed = os.path.exists(path) with open(path, "ab") as f: if not existed: f.write(b"#guid column:1\n") f.write(b"#notetype column:2\n") f.write(b"#nid\tmid\tfields\n") for id, mid, flds in col.db.execute( f"select id, mid, flds from notes where id in {ids2str(nids)}" ): fields = split_fields(flds) f.write(("\t".join([str(id), str(mid)] + fields)).encode("utf8")) f.write(b"\n") # Schema modifications ########################################################################## # this will gradually be phased out def onSchemaMod(self, arg: bool) -> bool: if not self.inMainThread(): raise Exception("not in main thread") progress_shown = self.progress.busy() if progress_shown: self.progress.finish() ret = askUser(tr.qt_misc_the_requested_change_will_require_a()) if progress_shown: self.progress.start() return ret # in favour of this def confirm_schema_modification(self) -> bool: """If schema unmodified, ask user to confirm change. True if confirmed or already modified.""" if self.col.schema_changed(): return True return askUser(tr.qt_misc_the_requested_change_will_require_a()) # Advanced features ########################################################################## def onCheckDB(self) -> None: check_db(self) def on_check_media_db(self) -> None: gui_hooks.media_check_will_start() check_media_db(self) def onStudyDeck(self) -> None: from aqt.studydeck import StudyDeck def callback(ret: StudyDeck) -> None: if not ret.name: return deck_id = self.col.decks.id(ret.name) set_current_deck(parent=self, deck_id=deck_id).success( lambda out: self.moveToState("overview") ).run_in_background() StudyDeck( self, parent=self, dyn=True, current=self.col.decks.current()["name"], callback=callback, ) def onEmptyCards(self) -> None: show_empty_cards(self) # System specific code ########################################################################## def setupSystemSpecific(self) -> None: self.hideMenuAccels = False if is_mac: # mac users expect a minimize option self.minimizeShortcut = QShortcut("Ctrl+M", self) qconnect(self.minimizeShortcut.activated, self.onMacMinimize) self.hideMenuAccels = True self.maybeHideAccelerators() self.hideStatusTips() elif is_win: self._setupWin32() def _setupWin32(self): """Fix taskbar display/pinning""" if sys.platform != "win32": return launcher_path = os.environ.get("ANKI_LAUNCHER") if not launcher_path: return from win32com.propsys import propsys, pscon from win32com.propsys.propsys import PROPVARIANTType hwnd = int(self.winId()) prop_store = propsys.SHGetPropertyStoreForWindow(hwnd) # type: ignore[call-arg] prop_store.SetValue( pscon.PKEY_AppUserModel_ID, PROPVARIANTType("Ankitects.Anki") ) prop_store.SetValue( pscon.PKEY_AppUserModel_RelaunchCommand, PROPVARIANTType(f'"{launcher_path}"'), ) prop_store.SetValue( pscon.PKEY_AppUserModel_RelaunchDisplayNameResource, PROPVARIANTType("Anki") ) prop_store.SetValue( pscon.PKEY_AppUserModel_RelaunchIconResource, PROPVARIANTType(f"{launcher_path},0"), ) prop_store.Commit() def maybeHideAccelerators(self, tgt: Any | None = None) -> None: if not self.hideMenuAccels: return tgt = tgt or self for action_ in tgt.findChildren(QAction): action = cast(QAction, action_) txt = str(action.text()) m = re.match(r"^(.+)\(&.+\)(.+)?", txt) if m: action.setText(m.group(1) + (m.group(2) or "")) def hideStatusTips(self) -> None: for action in self.findChildren(QAction): # On Windows, this next line gives a 'redundant cast' error after moving to # PyQt6.5.2. cast(QAction, action).setStatusTip("") # type: ignore def onMacMinimize(self) -> None: self.setWindowState(self.windowState() | Qt.WindowState.WindowMinimized) # type: ignore # Single instance support ########################################################################## def setupAppMsg(self) -> None: qconnect(self.app.appMsg, self.onAppMsg) def onAppMsg(self, buf: str) -> None: is_addon = self._isAddon(buf) if self.state == "startup": # try again in a second self.progress.single_shot( 1000, lambda: self.onAppMsg(buf), False, ) return elif self.state == "profileManager": # can't raise window while in profile manager if buf == "raise": return None self.pendingImport = buf if is_addon: msg = tr.qt_misc_addon_will_be_installed_when_a() else: msg = tr.qt_misc_deck_will_be_imported_when_a() tooltip(msg) return if not self.interactiveState() or self.progress.busy(): # we can't raise the main window while in profile dialog, syncing, etc if buf != "raise": showInfo( tr.qt_misc_please_ensure_a_profile_is_open(), parent=None, ) return None # raise window if is_win: # on windows we can raise the window by minimizing and restoring self.showMinimized() self.setWindowState(Qt.WindowState.WindowActive) self.showNormal() else: # on osx we can raise the window. on unity the icon in the tray will just flash. self.activateWindow() self.raise_() if buf == "raise": return None # import / add-on installation if is_addon: self.installAddon(buf) else: self.handleImport(buf) return None def _isAddon(self, buf: str) -> bool: # only accept primary extension here to avoid conflicts with deck packages return buf.endswith(self.addonManager.exts[0]) def interactiveState(self) -> bool: "True if not in profile manager, syncing, etc." return self.state in ("overview", "review", "deckBrowser") # GC ########################################################################## # The default Python garbage collection can trigger on any thread. This can # cause crashes if Qt objects are garbage-collected, as Qt expects access # only on the main thread. So Anki disables the default GC on startup, and # instead runs it on a timer, and after dialog close. # The gc after dialog close is necessary to free up the memory and extra # processes that webviews spawn, as a lot of the GUI code creates ref cycles. def garbage_collect_on_dialog_finish(self, dialog: QDialog) -> None: qconnect( dialog.finished, lambda: self.deferred_delete_and_garbage_collect(dialog) ) def deferred_delete_and_garbage_collect(self, obj: QObject) -> None: obj.deleteLater() self.progress.single_shot(1000, self.garbage_collect_now, False) def disable_automatic_garbage_collection(self) -> None: gc.collect() gc.disable() def garbage_collect_now(self) -> None: # gc.collect() has optional arguments that will cause problems if # it's passed directly to a QTimer, and pylint complains if we # wrap it in a lambda, so we use this trivial wrapper gc.collect() # legacy aliases setupDialogGC = garbage_collect_on_dialog_finish gcWindow = deferred_delete_and_garbage_collect # Media server ########################################################################## def setupMediaServer(self) -> None: self.mediaServer = aqt.mediasrv.MediaServer(self) self.mediaServer.start() def baseHTML(self) -> str: return f'' def serverURL(self) -> str: return "http://127.0.0.1:%d/" % self.mediaServer.getPort() # legacy class ResetReason(enum.Enum): Unknown = "unknown" AddCardsAddNote = "addCardsAddNote" EditCurrentInit = "editCurrentInit" EditorBridgeCmd = "editorBridgeCmd" BrowserSetDeck = "browserSetDeck" BrowserAddTags = "browserAddTags" BrowserRemoveTags = "browserRemoveTags" BrowserSuspend = "browserSuspend" BrowserReposition = "browserReposition" BrowserReschedule = "browserReschedule" BrowserFindReplace = "browserFindReplace" BrowserTagDupes = "browserTagDupes" BrowserDeleteDeck = "browserDeleteDeck" ================================================ FILE: qt/aqt/mediacheck.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import itertools import time from collections.abc import Iterable, Sequence from concurrent.futures import Future from typing import TypeVar import aqt import aqt.progress from anki.collection import Collection, SearchNode from anki.errors import Interrupted from anki.media import CheckMediaResponse from anki.notes import NoteId from aqt import gui_hooks from aqt.operations import QueryOp from aqt.operations.tag import add_tags_to_notes from aqt.qt import * from aqt.utils import ( askUser, disable_help_button, openFolder, restoreGeom, saveGeom, showText, tooltip, tr, ) T = TypeVar("T") def chunked_list(l: Iterable[T], n: int) -> Iterable[list[T]]: l = iter(l) while True: res = list(itertools.islice(l, n)) if not res: return yield res def check_media_db(mw: aqt.AnkiQt) -> None: c = MediaChecker(mw) c.check() class MediaChecker: progress_dialog: aqt.progress.ProgressDialog | None def __init__(self, mw: aqt.AnkiQt) -> None: self.mw = mw self._progress_timer: QTimer | None = None def check(self) -> None: self.progress_dialog = self.mw.progress.start() self._set_progress_enabled(True) self.mw.taskman.run_in_background(self._check, self._on_finished) def _set_progress_enabled(self, enabled: bool) -> None: if self._progress_timer: self._progress_timer.stop() self._progress_timer.deleteLater() self._progress_timer = None if enabled: self._progress_timer = timer = QTimer() timer.setSingleShot(False) timer.setInterval(100) qconnect(timer.timeout, self._on_progress) timer.start() def _on_progress(self) -> None: if not self.mw.col: return progress = self.mw.col.latest_progress() if not progress.HasField("media_check"): return label = progress.media_check try: assert self.progress_dialog is not None if self.progress_dialog.wantCancel: self.mw.col.set_wants_abort() except AttributeError: # dialog may not be active pass self.mw.taskman.run_on_main(lambda: self.mw.progress.update(label=label)) def _check(self) -> CheckMediaResponse: "Run the check on a background thread." return self.mw.col.media.check() def _on_finished(self, future: Future) -> None: self._set_progress_enabled(False) self.mw.progress.finish() self.progress_dialog = None exc = future.exception() if isinstance(exc, Interrupted): return output: CheckMediaResponse = future.result() gui_hooks.media_check_did_finish(output) report = output.report # show report and offer to delete diag = QDialog(self.mw) diag.setWindowTitle(tr.media_check_window_title()) disable_help_button(diag) layout = QVBoxLayout(diag) diag.setLayout(layout) text = QPlainTextEdit() text.setReadOnly(True) text.setPlainText(report) text.setWordWrapMode(QTextOption.WrapMode.NoWrap) layout.addWidget(text) box = QDialogButtonBox() layout.addWidget(box) if output.unused: b = QPushButton(tr.media_check_delete_unused()) b.setAutoDefault(False) box.addButton(b, QDialogButtonBox.ButtonRole.RejectRole) qconnect(b.clicked, lambda c: self._on_trash_files(output.unused)) if output.missing: b = QPushButton(tr.media_check_add_tag()) b.setAutoDefault(False) box.addButton(b, QDialogButtonBox.ButtonRole.RejectRole) qconnect( b.clicked, lambda: add_missing_media_tag(self.mw, output.missing_media_notes), ) if any(map(lambda x: x.startswith("latex-"), output.missing)): b = QPushButton(tr.media_check_render_latex()) b.setAutoDefault(False) box.addButton(b, QDialogButtonBox.ButtonRole.RejectRole) qconnect(b.clicked, self._on_render_latex) if output.have_trash: b = QPushButton(tr.media_check_empty_trash()) b.setAutoDefault(False) box.addButton(b, QDialogButtonBox.ButtonRole.RejectRole) qconnect(b.clicked, lambda c: self._on_empty_trash()) b = QPushButton(tr.media_check_restore_trash()) b.setAutoDefault(False) box.addButton(b, QDialogButtonBox.ButtonRole.RejectRole) qconnect(b.clicked, lambda c: self._on_restore_trash()) b = QPushButton(tr.addons_view_files()) b.setAutoDefault(False) box.addButton(b, QDialogButtonBox.ButtonRole.ActionRole) qconnect(b.clicked, lambda c: self._on_view_files()) qconnect(box.rejected, diag.reject) diag.setMinimumHeight(400) diag.setMinimumWidth(500) restoreGeom(diag, "checkmediadb", default_size=(800, 800)) diag.exec() saveGeom(diag, "checkmediadb") def _on_render_latex(self) -> None: self.progress_dialog = self.mw.progress.start() assert self.progress_dialog is not None try: out = self.mw.col.media.render_all_latex(self._on_render_latex_progress) if self.progress_dialog.wantCancel: return finally: self.mw.progress.finish() self.progress_dialog = None if out is not None: nid, err = out aqt.dialogs.open("Browser", self.mw, search=(SearchNode(nid=nid),)) showText(err, type="html") else: tooltip(tr.media_check_all_latex_rendered()) def _on_render_latex_progress(self, count: int) -> bool: assert self.progress_dialog is not None if self.progress_dialog.wantCancel: return False self.mw.progress.update(tr.media_check_checked(count=count)) return True def _on_trash_files(self, fnames: Sequence[str]) -> None: if not askUser(tr.media_check_delete_unused_confirm()): return total = len(fnames) def trash(col: Collection) -> None: last_progress = 0.0 remaining = total for chunk in chunked_list(fnames, 25): col.media.trash_files(chunk) remaining -= len(chunk) if time.time() - last_progress >= 0.1: self.mw.taskman.run_on_main( lambda: self.mw.progress.update( label=tr.media_check_files_remaining(count=remaining), value=total - remaining, max=total, ) ) last_progress = time.time() QueryOp( parent=aqt.mw, op=trash, success=lambda _: tooltip( tr.media_check_delete_unused_complete(count=total) ), ).with_progress().run_in_background() def _on_empty_trash(self) -> None: self.progress_dialog = self.mw.progress.start() self._set_progress_enabled(True) def empty_trash() -> None: self.mw.col.media.empty_trash() def on_done(fut: Future) -> None: self.mw.progress.finish() self._set_progress_enabled(False) # check for errors fut.result() tooltip(tr.media_check_trash_emptied()) self.mw.taskman.run_in_background(empty_trash, on_done) def _on_restore_trash(self) -> None: self.progress_dialog = self.mw.progress.start() self._set_progress_enabled(True) def restore_trash() -> None: self.mw.col.media.restore_trash() def on_done(fut: Future) -> None: self.mw.progress.finish() self._set_progress_enabled(False) # check for errors fut.result() tooltip(tr.media_check_trash_restored()) self.mw.taskman.run_in_background(restore_trash, on_done) def _on_view_files(self) -> None: openFolder(self.mw.col.media.dir()) def add_missing_media_tag(parent: QWidget, missing_media_notes: Sequence[int]) -> None: add_tags_to_notes( parent=parent, note_ids=list(map(NoteId, missing_media_notes)), space_separated_tags=tr.media_check_missing_media_tag(), ).run_in_background() ================================================ FILE: qt/aqt/mediasrv.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import enum import logging import mimetypes import os import re import secrets import sys import threading import traceback from collections.abc import Callable from dataclasses import dataclass from errno import EPROTOTYPE from http import HTTPStatus import flask import flask_cors import stringcase import waitress.wasyncore from flask import Response, abort, request from waitress.server import create_server import aqt import aqt.main import aqt.operations from anki import hooks from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode from anki.decks import UpdateDeckConfigs from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest from anki.utils import dev_mode from aqt.changenotetype import ChangeNotetypeDialog from aqt.deckoptions import DeckOptionsDialog from aqt.operations import on_op_finished from aqt.operations.deck import update_deck_configs as update_deck_configs_op from aqt.progress import ProgressUpdate from aqt.qt import * from aqt.utils import aqt_data_path, show_warning, tr # https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266 waitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROTOTYPE}) # type: ignore logger = logging.getLogger(__name__) app = flask.Flask(__name__, root_path="/fake") flask_cors.CORS(app, resources={r"/*": {"origins": "127.0.0.1"}}) @dataclass class LocalFileRequest: # base folder, eg media folder root: str # path to file relative to root folder path: str @dataclass class BundledFileRequest: # path relative to aqt data folder path: str @dataclass class NotFound: message: str DynamicRequest = Callable[[], Response] class PageContext(enum.IntEnum): UNKNOWN = enum.auto() EDITOR = enum.auto() REVIEWER = enum.auto() PREVIEWER = enum.auto() CARD_LAYOUT = enum.auto() DECK_OPTIONS = enum.auto() # something in /_anki/pages/ NON_LEGACY_PAGE = enum.auto() # Do not use this if you present user content (e.g. content from cards), as it's a # security issue. ADDON_PAGE = enum.auto() @dataclass class LegacyPage: html: str context: PageContext class MediaServer(threading.Thread): _ready = threading.Event() daemon = True def __init__(self, mw: aqt.main.AnkiQt) -> None: super().__init__() self.is_shutdown = False # map of webview ids to pages self._legacy_pages: dict[int, LegacyPage] = {} def run(self) -> None: try: desired_host = os.getenv("ANKI_API_HOST", "127.0.0.1") desired_port = int(os.getenv("ANKI_API_PORT") or 0) self.server = create_server( app, host=desired_host, port=desired_port, clear_untrusted_proxy_headers=True, ) logger.info( "Serving on http://%s:%s", self.server.effective_host, # type: ignore[union-attr] self.server.effective_port, # type: ignore[union-attr] ) self._ready.set() self.server.run() except Exception: if not self.is_shutdown: raise def shutdown(self) -> None: self.is_shutdown = True sockets = list(self.server._map.values()) # type: ignore for socket in sockets: socket.handle_close() # https://github.com/Pylons/webtest/blob/4b8a3ebf984185ff4fefb31b4d0cf82682e1fcf7/webtest/http.py#L93-L104 self.server.task_dispatcher.shutdown() def getPort(self) -> int: self._ready.wait() return int(self.server.effective_port) # type: ignore def set_page_html( self, id: int, html: str, context: PageContext = PageContext.UNKNOWN ) -> None: self._legacy_pages[id] = LegacyPage(html, context) def get_page(self, id: int) -> LegacyPage | None: return self._legacy_pages.get(id) def get_page_html(self, id: int) -> str | None: if page := self.get_page(id): return page.html else: return None def get_page_context(self, id: int) -> PageContext | None: if page := self.get_page(id): return page.context else: return None def clear_page_html(self, id: int) -> None: try: del self._legacy_pages[id] except KeyError: pass @app.route("/favicon.ico") def favicon() -> Response: request = BundledFileRequest(os.path.join("imgs", "favicon.ico")) return _handle_builtin_file_request(request) def _mime_for_path(path: str) -> str: "Mime type for provided path/filename." _, ext = os.path.splitext(path) ext = ext.lower() # Badly-behaved apps on Windows can alter the standard mime types in the registry, which can completely # break Anki's UI. So we hard-code the most common extensions. mime_types = { ".css": "text/css", ".js": "application/javascript", ".mjs": "application/javascript", ".html": "text/html", ".htm": "text/html", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf", ".mp3": "audio/mpeg", ".mp4": "video/mp4", ".webm": "video/webm", ".ogg": "audio/ogg", ".pdf": "application/pdf", ".txt": "text/plain", } if mime := mime_types.get(ext): return mime else: # fallback to mimetypes, which may consult the registry mime, _encoding = mimetypes.guess_type(path) return mime or "application/octet-stream" def _text_response(code: HTTPStatus, text: str) -> Response: """Return an error message. Response is returned as text/plain, so no escaping of untrusted input is required.""" resp = flask.make_response(text, code) resp.headers["Content-type"] = "text/plain" return resp def _handle_local_file_request(request: LocalFileRequest) -> Response: directory = request.root path = request.path try: isdir = os.path.isdir(os.path.join(directory, path)) except ValueError: return _text_response( HTTPStatus.BAD_REQUEST, f"Path for '{directory} - {path}' is too long!" ) directory = os.path.realpath(directory) path = os.path.normpath(path) fullpath = os.path.abspath(os.path.join(directory, path)) # protect against directory transversal: https://security.openstack.org/guidelines/dg_using-file-paths.html if not fullpath.startswith(directory): return _text_response( HTTPStatus.FORBIDDEN, f"Path for '{directory} - {path}' is a security leak!" ) if isdir: return _text_response( HTTPStatus.FORBIDDEN, f"Path for '{directory} - {path}' is a directory (not supported)!", ) try: mimetype = _mime_for_path(fullpath) if os.path.exists(fullpath): if fullpath.endswith(".css"): # caching css files prevents flicker in the webview, but we want # a short cache max_age = 10 elif fullpath.endswith(".js"): # don't cache js files max_age = 0 else: max_age = 60 * 60 return flask.send_file( fullpath, mimetype=mimetype, conditional=True, max_age=max_age, download_name="foo", # type: ignore[call-arg] ) else: print(f"Not found: {path}") return _text_response(HTTPStatus.NOT_FOUND, f"Invalid path: {path}") except Exception as error: if dev_mode: print( "Caught HTTP server exception,\n%s" % "".join(traceback.format_exception(*sys.exc_info())), ) # swallow it - user likely surfed away from # review screen before an image had finished # downloading return _text_response(HTTPStatus.INTERNAL_SERVER_ERROR, str(error)) def _builtin_data(path: str) -> bytes: """Return data from file in aqt/data folder. Path must use forward slash separators.""" full_path = aqt_data_path() / ".." / path return full_path.read_bytes() def _handle_builtin_file_request(request: BundledFileRequest) -> Response: path = request.path # do we need to serve the fallback page? immutable = "immutable" in path if path.startswith("sveltekit/") and not immutable: path = "sveltekit/index.html" mimetype = _mime_for_path(path) data_path = f"data/web/{path}" try: data = _builtin_data(data_path) response = Response(data, mimetype=mimetype) if immutable: response.headers["Cache-Control"] = "max-age=31536000" return response except FileNotFoundError: if dev_mode: print(f"404: {data_path}") resp = _text_response(HTTPStatus.NOT_FOUND, f"Invalid path: {path}") # we're including the path verbatim in our response, so we need to either use # plain text, or escape HTML characters to avoid reflecting untrusted input resp.headers["Content-type"] = "text/plain" return resp except Exception as error: if dev_mode: print( "Caught HTTP server exception,\n%s" % "".join(traceback.format_exception(*sys.exc_info())), ) # swallow it - user likely surfed away from # review screen before an image had finished # downloading return _text_response(HTTPStatus.INTERNAL_SERVER_ERROR, str(error)) @app.route("/", methods=["GET", "POST"]) def handle_request(pathin: str) -> Response: host = request.headers.get("Host", "").lower() allowed_prefixes = ("127.0.0.1:", "localhost:", "[::1]:") if not any(host.startswith(prefix) for prefix in allowed_prefixes): # while we only bind to localhost, this request may have come from a local browser # via a DNS rebinding attack; deny it unless we're doing non-local testing if os.environ.get("ANKI_API_HOST") != "0.0.0.0": print("deny non-local host", host) abort(403) req = _extract_request(pathin) logger.debug("%s /%s", flask.request.method, pathin) if isinstance(req, NotFound): print(req.message) return _text_response(HTTPStatus.NOT_FOUND, f"Invalid path: {pathin}") elif callable(req): return _handle_dynamic_request(req) elif isinstance(req, BundledFileRequest): return _handle_builtin_file_request(req) elif isinstance(req, LocalFileRequest): return _handle_local_file_request(req) else: return _text_response(HTTPStatus.FORBIDDEN, f"unexpected request: {pathin}") def is_sveltekit_page(path: str) -> bool: page_name = path.split("/")[0] return page_name in [ "graphs", "congrats", "card-info", "change-notetype", "deck-options", "import-anki-package", "import-csv", "import-page", "image-occlusion", ] def _extract_internal_request( path: str, ) -> BundledFileRequest | DynamicRequest | NotFound | None: "Catch /_anki references and rewrite them to web export folder." if is_sveltekit_page(path): path = f"_anki/sveltekit/_app/{path}" if path.startswith("_app/"): path = path.replace("_app", "_anki/sveltekit/_app") prefix = "_anki/" if not path.startswith(prefix): return None dirname = os.path.dirname(path) filename = os.path.basename(path) additional_prefix = None if dirname == "_anki": if flask.request.method == "POST": return _extract_collection_post_request(filename) elif get_handler := _extract_dynamic_get_request(filename): return get_handler # remap legacy top-level references base, ext = os.path.splitext(filename) if ext == ".css": additional_prefix = "css/" elif ext == ".js": if base in ("jquery-ui", "jquery", "plot"): additional_prefix = "js/vendor/" else: additional_prefix = "js/" # handle requests for vendored libraries elif dirname == "_anki/js/vendor": base, ext = os.path.splitext(filename) if base == "jquery": base = "jquery.min" additional_prefix = "js/vendor/" elif base == "jquery-ui": base = "jquery-ui.min" additional_prefix = "js/vendor/" if additional_prefix: oldpath = path path = f"{prefix}{additional_prefix}{base}{ext}" print(f"legacy {oldpath} remapped to {path}") return BundledFileRequest(path=path[len(prefix) :]) def _extract_addon_request(path: str) -> LocalFileRequest | NotFound | None: "Catch /_addons references and rewrite them to addons folder." prefix = "_addons/" if not path.startswith(prefix): return None addon_path = path[len(prefix) :] try: manager = aqt.mw.addonManager except AttributeError as error: if dev_mode: print(f"_redirectWebExports: {error}") return None try: addon, sub_path = addon_path.split("/", 1) except ValueError: return None if not addon: return None pattern = manager.getWebExports(addon) if not pattern: return None if re.fullmatch(pattern, sub_path): return LocalFileRequest(root=manager.addonsFolder(), path=addon_path) return NotFound(message=f"couldn't locate item in add-on folder {path}") def _extract_request( path: str, ) -> LocalFileRequest | BundledFileRequest | DynamicRequest | NotFound: if internal := _extract_internal_request(path): return internal elif addon := _extract_addon_request(path): return addon if not aqt.mw.col: return NotFound(message=f"collection not open, ignore request for {path}") path = hooks.media_file_filter(path) return LocalFileRequest(root=aqt.mw.col.media.dir(), path=path) def congrats_info() -> bytes: if not aqt.mw.col.sched._is_finished(): aqt.mw.taskman.run_on_main(lambda: aqt.mw.moveToState("overview")) return raw_backend_request("congrats_info")() def get_deck_configs_for_update() -> bytes: return aqt.mw.col._backend.get_deck_configs_for_update_raw(request.data) def update_deck_configs() -> bytes: # the regular change tracking machinery expects to be started on the main # thread and uses a callback on success, so we need to run this op on # main, and return immediately from the web request input = UpdateDeckConfigs() input.ParseFromString(request.data) def on_progress(progress: Progress, update: ProgressUpdate) -> None: if progress.HasField("compute_memory"): val = progress.compute_memory update.max = val.total_cards update.value = val.current_cards update.label = val.label elif progress.HasField("compute_params"): val2 = progress.compute_params # prevent an indeterminate progress bar from appearing at the start of each preset update.max = max(val2.total, 1) update.value = val2.current pct = str(int(val2.current / val2.total * 100) if val2.total > 0 else 0) label = tr.deck_config_optimizing_preset( current_count=val2.current_preset, total_count=val2.total_presets ) if val2.reviews: reviews = tr.deck_config_percent_of_reviews( pct=pct, reviews=val2.reviews ) else: reviews = tr.qt_misc_processing() update.label = label + "\n" + reviews else: return if update.user_wants_abort: update.abort = True def on_success(changes: OpChanges) -> None: if isinstance(window := aqt.mw.app.activeModalWidget(), DeckOptionsDialog): window.reject() def handle_on_main() -> None: update_deck_configs_op(parent=aqt.mw, input=input).success( on_success ).with_backend_progress(on_progress).run_in_background() aqt.mw.taskman.run_on_main(handle_on_main) return b"" def get_scheduling_states_with_context() -> bytes: return SchedulingStatesWithContext( states=aqt.mw.reviewer.get_scheduling_states(), context=aqt.mw.reviewer.get_scheduling_context(), ).SerializeToString() def set_scheduling_states() -> bytes: states = SetSchedulingStatesRequest() states.ParseFromString(request.data) aqt.mw.reviewer.set_scheduling_states(states) return b"" def import_done() -> bytes: def update_window_modality() -> None: if window := aqt.mw.app.activeModalWidget(): from aqt.import_export.import_dialog import ImportDialog if isinstance(window, ImportDialog): window.hide() window.setWindowModality(Qt.WindowModality.NonModal) window.show() aqt.mw.taskman.run_on_main(update_window_modality) return b"" def import_request(endpoint: str) -> bytes: output = raw_backend_request(endpoint)() response = OpChangesOnly() response.ParseFromString(output) def handle_on_main() -> None: window = aqt.mw.app.activeModalWidget() on_op_finished(aqt.mw, response, window) aqt.mw.taskman.run_on_main(handle_on_main) return output def import_csv() -> bytes: return import_request("import_csv") def import_anki_package() -> bytes: return import_request("import_anki_package") def import_json_file() -> bytes: return import_request("import_json_file") def import_json_string() -> bytes: return import_request("import_json_string") def search_in_browser() -> bytes: node = SearchNode() node.ParseFromString(request.data) def handle_on_main() -> None: aqt.dialogs.open("Browser", aqt.mw, search=(node,)) aqt.mw.taskman.run_on_main(handle_on_main) return b"" def change_notetype() -> bytes: data = request.data def handle_on_main() -> None: window = aqt.mw.app.activeModalWidget() if isinstance(window, ChangeNotetypeDialog): window.save(data) aqt.mw.taskman.run_on_main(handle_on_main) return b"" def deck_options_require_close() -> bytes: def handle_on_main() -> None: window = aqt.mw.app.activeModalWidget() if isinstance(window, DeckOptionsDialog): window.require_close() # on certain linux systems, askUser's QMessageBox.question unsets the active window # so we wait for the next event loop before querying the next current active window aqt.mw.taskman.run_on_main(lambda: QTimer.singleShot(0, handle_on_main)) return b"" def deck_options_ready() -> bytes: def handle_on_main() -> None: window = aqt.mw.app.activeModalWidget() if isinstance(window, DeckOptionsDialog): window.set_ready() aqt.mw.taskman.run_on_main(handle_on_main) return b"" def save_custom_colours() -> bytes: colors = [ QColorDialog.customColor(i).name(QColor.NameFormat.HexRgb) for i in range(QColorDialog.customCount()) ] aqt.mw.col.set_config("customColorPickerPalette", colors) return b"" post_handler_list = [ congrats_info, get_deck_configs_for_update, update_deck_configs, get_scheduling_states_with_context, set_scheduling_states, change_notetype, import_done, import_csv, import_anki_package, import_json_file, import_json_string, search_in_browser, deck_options_require_close, deck_options_ready, save_custom_colours, ] exposed_backend_list = [ # CollectionService "latest_progress", "get_custom_colours", # DeckService "get_deck_names", # I18nService "i18n_resources", # ImportExportService "get_csv_metadata", "get_import_anki_package_presets", # NotesService "get_field_names", "get_note", # NotetypesService "get_notetype_names", "get_change_notetype_info", # StatsService "card_stats", "get_review_logs", "graphs", "get_graph_preferences", "set_graph_preferences", # TagsService "complete_tag", # ImageOcclusionService "get_image_for_occlusion", "add_image_occlusion_note", "get_image_occlusion_note", "update_image_occlusion_note", "get_image_occlusion_fields", # SchedulerService "compute_fsrs_params", "compute_optimal_retention", "set_wants_abort", "evaluate_params_legacy", "get_optimal_retention_parameters", "simulate_fsrs_review", "simulate_fsrs_workload", # DeckConfigService "get_ignored_before_count", "get_retention_workload", ] def raw_backend_request(endpoint: str) -> Callable[[], bytes]: # check for key at startup from anki._backend import RustBackend assert hasattr(RustBackend, f"{endpoint}_raw") return lambda: getattr(aqt.mw.col._backend, f"{endpoint}_raw")(request.data) # all methods in here require a collection post_handlers = { stringcase.camelcase(handler.__name__): handler for handler in post_handler_list } | { stringcase.camelcase(handler): raw_backend_request(handler) for handler in exposed_backend_list } def _extract_collection_post_request(path: str) -> DynamicRequest | NotFound: if not aqt.mw.col: return NotFound(message=f"collection not open, ignore request for {path}") if handler := post_handlers.get(path): # convert bytes/None into response def wrapped() -> Response: try: if data := handler(): response = flask.make_response(data) response.headers["Content-Type"] = "application/binary" else: response = _text_response(HTTPStatus.NO_CONTENT, "") except Exception as exc: print(traceback.format_exc()) response = _text_response(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) return response return wrapped else: return NotFound(message=f"{path} not found") def _check_dynamic_request_permissions(): if request.method == "GET": return def warn() -> None: show_warning( "Unexpected API access. Please report this message on the Anki forums." ) # check content type header to ensure this isn't an opaque request from another origin if request.headers["Content-type"] != "application/binary": aqt.mw.taskman.run_on_main(warn) abort(403) # does page have access to entire API? if _have_api_access(): return # whitelisted API endpoints for reviewer/previewer if request.path in ( "/_anki/getSchedulingStatesWithContext", "/_anki/setSchedulingStates", "/_anki/i18nResources", "/_anki/congratsInfo", ): pass else: # other legacy pages may contain third-party JS, so we do not # allow them to access our API aqt.mw.taskman.run_on_main(warn) abort(403) def _handle_dynamic_request(req: DynamicRequest) -> Response: _check_dynamic_request_permissions() try: return req() except Exception as e: return _text_response(HTTPStatus.INTERNAL_SERVER_ERROR, str(e)) def legacy_page_data() -> Response: id = int(request.args["id"]) page = aqt.mw.mediaServer.get_page(id) if page: response = Response(page.html, mimetype="text/html") # Prevent JS in field content from being executed in the editor, as it would # have access to our internal API, and is a security risk. if page.context == PageContext.EDITOR: port = aqt.mw.mediaServer.getPort() csp_paths = ( f"http://127.0.0.1:{port}/_anki/", f"http://127.0.0.1:{port}/_addons/", ) response.headers["Content-Security-Policy"] = ( f"script-src {' '.join(csp_paths)}" ) return response else: return _text_response(HTTPStatus.NOT_FOUND, "page not found") _APIKEY = secrets.token_urlsafe(32) def _have_api_access() -> bool: return ( request.headers.get("Authorization") == f"Bearer {_APIKEY}" or os.environ.get("ANKI_API_HOST") == "0.0.0.0" ) # this currently only handles a single method; in the future, idempotent # requests like i18nResources should probably be moved here def _extract_dynamic_get_request(path: str) -> DynamicRequest | None: if path == "legacyPageData": return legacy_page_data else: return None ================================================ FILE: qt/aqt/mediasync.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import time from collections.abc import Callable from concurrent.futures import Future from datetime import datetime from typing import Any import aqt import aqt.forms import aqt.main from anki.collection import Collection from anki.errors import Interrupted from anki.utils import int_time from aqt import gui_hooks from aqt.operations import QueryOp from aqt.qt import QDialog, QDialogButtonBox, QPushButton, Qt, QTimer, qconnect from aqt.utils import disable_help_button, show_info, tr class MediaSyncer: def __init__(self, mw: aqt.main.AnkiQt) -> None: self.mw = mw self._syncing: bool = False self.last_progress = "" self._last_progress_at = 0 gui_hooks.media_sync_did_start_or_stop.append(self._on_start_stop) def start(self, is_periodic_sync: bool = False) -> None: "Start media syncing in the background, if it's not already running." if not self.mw.pm.media_syncing_enabled() or not ( auth := self.mw.pm.sync_auth() ): return def run(col: Collection) -> None: col.sync_media(auth) # this will exit after the thread is spawned, but may block if there's an existing # backend lock QueryOp(parent=aqt.mw, op=run, success=lambda _: 1).failure( lambda e: self._handle_sync_error(e, is_periodic_sync) ).run_in_background() self.start_monitoring(is_periodic_sync) def start_monitoring(self, is_periodic_sync: bool = False) -> None: if self._syncing: return self._syncing = True gui_hooks.media_sync_did_start_or_stop(True) self._update_progress(tr.sync_media_starting()) def monitor() -> None: while True: resp = self.mw.col.media_sync_status() if not resp.active: return if p := resp.progress: self._update_progress(f"{p.added}, {p.removed}, {p.checked}") time.sleep(0.25) self.mw.taskman.run_in_background( monitor, lambda fut: self._on_finished(fut, is_periodic_sync), uses_collection=False, ) def _update_progress(self, progress: str) -> None: self.last_progress = progress self.mw.taskman.run_on_main(lambda: gui_hooks.media_sync_did_progress(progress)) def _on_finished(self, future: Future, is_periodic_sync: bool = False) -> None: self._syncing = False self._last_progress_at = int_time() gui_hooks.media_sync_did_start_or_stop(False) exc = future.exception() if exc is not None: self._handle_sync_error(exc, is_periodic_sync) else: self._update_progress(tr.sync_media_complete()) def _handle_sync_error( self, exc: BaseException, is_periodic_sync: bool = False ) -> None: if isinstance(exc, Interrupted): self._update_progress(tr.sync_media_aborted()) elif is_periodic_sync: print(str(exc)) else: self._update_progress(tr.sync_media_failed()) show_info(str(exc), modality=Qt.WindowModality.NonModal) def abort(self) -> None: if not self.is_syncing(): return self.mw.col.set_wants_abort() self.mw.col.abort_media_sync() self._update_progress(tr.sync_media_aborting()) def is_syncing(self) -> bool: return self._syncing def _on_start_stop(self, running: bool) -> None: self.mw.toolbar.set_sync_active(running) def show_sync_log(self) -> None: aqt.dialogs.open("sync_log", self.mw, self) def show_diag_until_finished(self, on_finished: Callable[[], None]) -> None: # nothing to do if not syncing if not self.is_syncing(): return on_finished() diag: MediaSyncDialog = aqt.dialogs.open("sync_log", self.mw, self, True) diag.show() timer: QTimer def check_finished() -> None: if not self.is_syncing(): timer.deleteLater() on_finished() timer = self.mw.progress.timer(150, check_finished, True, False, parent=self.mw) def seconds_since_last_sync(self) -> int: if self.is_syncing(): return 0 return int_time() - self._last_progress_at class MediaSyncDialog(QDialog): silentlyClose = True def __init__( self, mw: aqt.main.AnkiQt, syncer: MediaSyncer, close_when_done: bool = False ) -> None: super().__init__(mw) self.mw = mw self._syncer = syncer self._close_when_done = close_when_done self.form = aqt.forms.synclog.Ui_Dialog() self.form.setupUi(self) self.setWindowTitle(tr.sync_media_log_title()) disable_help_button(self) self.abort_button = QPushButton(tr.sync_abort_button()) qconnect(self.abort_button.clicked, self._on_abort) self.abort_button.setAutoDefault(False) self.form.buttonBox.addButton( self.abort_button, QDialogButtonBox.ButtonRole.ActionRole ) self.abort_button.setHidden(not self._syncer.is_syncing()) gui_hooks.media_sync_did_progress.append(self._on_log_entry) gui_hooks.media_sync_did_start_or_stop.append(self._on_start_stop) self._on_log_entry(syncer.last_progress) self.show() def reject(self) -> None: if self._close_when_done and self._syncer.is_syncing(): # closing while syncing on close starts an abort self._on_abort() return aqt.dialogs.markClosed("sync_log") QDialog.reject(self) def reopen( self, mw: aqt.AnkiQt, syncer: Any, close_when_done: bool = False ) -> None: self._close_when_done = close_when_done self.show() def _on_abort(self, *_args: Any) -> None: self._syncer.abort() self.abort_button.setHidden(True) def _on_log_entry(self, entry: str) -> None: dt = datetime.fromtimestamp(int_time()) time = dt.strftime("%H:%M:%S") text = f"{time}: {entry}" self.form.log_label.setText(text) if not self._syncer.is_syncing(): self.abort_button.setHidden(True) def _on_start_stop(self, running: bool) -> None: if not running and self._close_when_done: aqt.dialogs.markClosed("sync_log") self._close_when_done = False self.close() ================================================ FILE: qt/aqt/modelchooser.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Callable from aqt import AnkiQt, gui_hooks from aqt.qt import * from aqt.utils import HelpPage, shortcut, tr class ModelChooser(QHBoxLayout): "New code should prefer NotetypeChooser." def __init__( self, mw: AnkiQt, widget: QWidget, label: bool = True, on_activated: Callable[[], None] | None = None, ) -> None: """If provided, on_activated() will be called when the button is clicked, and the caller can call .onModelChange() to pull up the dialog when they are ready.""" QHBoxLayout.__init__(self) self._widget = widget # type: ignore self.mw = mw self.deck = mw.col self.label = label if on_activated: self.on_activated = on_activated else: self.on_activated = self.onModelChange self.setContentsMargins(0, 0, 0, 0) self.setSpacing(8) self.setupModels() gui_hooks.state_did_reset.append(self.onReset) self._widget.setLayout(self) def setupModels(self) -> None: if self.label: self.modelLabel = QLabel(tr.notetypes_type()) self.addWidget(self.modelLabel) # models box self.models = QPushButton() self.models.setToolTip(shortcut(tr.qt_misc_change_note_type_ctrlandn())) QShortcut(QKeySequence("Ctrl+N"), self._widget, activated=self.on_activated) # type: ignore self.models.setAutoDefault(False) self.addWidget(self.models) qconnect(self.models.clicked, self.onModelChange) # layout sizePolicy = QSizePolicy(QSizePolicy.Policy(7), QSizePolicy.Policy(0)) self.models.setSizePolicy(sizePolicy) self.updateModels() def cleanup(self) -> None: gui_hooks.state_did_reset.remove(self.onReset) def onReset(self) -> None: self.updateModels() def show(self) -> None: self._widget.show() # type: ignore def hide(self) -> None: self._widget.hide() # type: ignore def onEdit(self) -> None: import aqt.models aqt.models.Models(self.mw, self._widget) def onModelChange(self) -> None: from aqt.studydeck import StudyDeck current = self.deck.models.current()["name"] # edit button edit = QPushButton(tr.qt_misc_manage(), clicked=self.onEdit) # type: ignore def nameFunc() -> list[str]: return [nt.name for nt in self.deck.models.all_names_and_ids()] def callback(ret: StudyDeck) -> None: if not ret.name: return m = self.deck.models.by_name(ret.name) assert m is not None self.deck.conf["curModel"] = m["id"] cdeck = self.deck.decks.current() cdeck["mid"] = m["id"] self.deck.decks.save(cdeck) gui_hooks.current_note_type_did_change(current) self.mw.reset() StudyDeck( self.mw, names=nameFunc, accept=tr.actions_choose(), title=tr.qt_misc_choose_note_type(), help=HelpPage.NOTE_TYPE, current=current, parent=self._widget, buttons=[edit], cancel=True, geomKey="selectModel", callback=callback, ) def updateModels(self) -> None: self.models.setText(self.deck.models.current()["name"].replace("&", "&&")) ================================================ FILE: qt/aqt/models.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Callable, Sequence from concurrent.futures import Future from operator import itemgetter import aqt.clayout from anki import stdmodels from anki.collection import Collection, OpChangesWithId from anki.lang import without_unicode_isolation from anki.models import NotetypeDict, NotetypeId, NotetypeNameIdUseCount from anki.notes import Note from aqt import AnkiQt, gui_hooks from aqt.operations import QueryOp from aqt.operations.notetype import ( add_notetype_legacy, remove_notetype, update_notetype_legacy, ) from aqt.qt import * from aqt.schema_change_tracker import ChangeTracker from aqt.utils import ( HelpPage, askUser, disable_help_button, getText, maybeHideClose, openHelp, restoreGeom, saveGeom, showInfo, tr, ) class Models(QDialog): def __init__( self, mw: AnkiQt, parent: QWidget | None = None, fromMain: bool = False, selected_notetype_id: NotetypeId | None = None, ): self.mw = mw parent = parent or mw self.fromMain = fromMain self.selected_notetype_id = selected_notetype_id QDialog.__init__(self, parent or mw) self.col = mw.col.weakref() assert self.col self.mm = self.col.models self.form = aqt.forms.models.Ui_Dialog() self.form.setupUi(self) qconnect( self.form.buttonBox.helpRequested, lambda: openHelp(HelpPage.ADDING_A_NOTE_TYPE), ) self.models: Sequence[NotetypeNameIdUseCount] = [] self.setupModels() self.setWindowFlags( self.windowFlags() | Qt.WindowType.WindowMaximizeButtonHint | Qt.WindowType.WindowMinimizeButtonHint ) restoreGeom(self, "models") self.show() # Models ########################################################################## def maybe_select_provided_notetype( self, selected_notetype_id: NotetypeId | None = None, row: int = 0 ) -> None: """Select the provided notetype ID, if any. Otherwise the one at `self.selected_notetype_id`, otherwise the `row`-th element.""" selected_notetype_id = selected_notetype_id or self.selected_notetype_id if not selected_notetype_id: self.form.modelsList.setCurrentRow(row) return for i, m in enumerate(self.models): if m.id == selected_notetype_id: self.form.modelsList.setCurrentRow(i) break def setupModels(self) -> None: self.model = None f = self.form box = f.buttonBox default_buttons = [ (tr.actions_add(), self.onAdd), (tr.actions_rename(), self.onRename), (tr.actions_delete(), self.onDelete), ] if self.fromMain: default_buttons.extend( [ (tr.notetypes_fields(), self.onFields), (tr.notetypes_cards(), self.onCards), ] ) default_buttons.append((tr.notetypes_options(), self.onAdvanced)) for label, func in gui_hooks.models_did_init_buttons(default_buttons, self): button = box.addButton(label, QDialogButtonBox.ButtonRole.ActionRole) qconnect(button.clicked, func) qconnect(f.modelsList.itemDoubleClicked, self.onRename) def on_done(fut: Future) -> None: self.updateModelsList(fut.result()) self.maybe_select_provided_notetype() self.mw.taskman.with_progress(self.col.models.all_use_counts, on_done, self) maybeHideClose(box) def refresh_list(self, selected_notetype_id: NotetypeId | None = None) -> None: QueryOp( parent=self, op=lambda col: col.models.all_use_counts(), success=lambda notetypes: self.updateModelsList( notetypes, selected_notetype_id ), ).run_in_background() def onRename(self) -> None: nt = self.current_notetype() text, ok = getText(tr.actions_new_name(), default=nt["name"]) if ok and text.strip(): selected_notetype_id = nt["id"] nt["name"] = text update_notetype_legacy(parent=self, notetype=nt).success( lambda _: self.refresh_list(selected_notetype_id) ).run_in_background() def updateModelsList( self, notetypes: Sequence[NotetypeNameIdUseCount], selected_notetype_id: NotetypeId | None = None, ) -> None: row = self.form.modelsList.currentRow() if row == -1: row = 0 self.form.modelsList.clear() self.models = notetypes for m in self.models: mUse = tr.browsing_note_count(count=m.use_count) item = QListWidgetItem(f"{m.name} [{mUse}]") self.form.modelsList.addItem(item) self.maybe_select_provided_notetype(selected_notetype_id, row) def current_notetype(self) -> NotetypeDict: row = self.form.modelsList.currentRow() return self.mm.get(NotetypeId(self.models[row].id)) def onAdd(self) -> None: def on_success(notetype: NotetypeDict) -> None: # if legacy add-ons already added the notetype, skip adding nid = notetype["id"] if nid: self.refresh_list(nid) return # prompt for name text, ok = getText(tr.actions_name(), default=notetype["name"], parent=self) if not ok or not text.strip(): return notetype["name"] = text def refresh_list(op: OpChangesWithId) -> None: self.refresh_list(NotetypeId(op.id)) add_notetype_legacy(parent=self, notetype=notetype).success( refresh_list ).run_in_background() AddModel(self.mw, on_success, self) def onDelete(self) -> None: if len(self.models) < 2: showInfo(tr.notetypes_please_add_another_note_type_first(), parent=self) return idx = self.form.modelsList.currentRow() if self.models[idx].use_count: msg = tr.notetypes_delete_this_note_type_and_all() else: msg = tr.notetypes_delete_this_unused_note_type() if not askUser(msg, parent=self): return tracker = ChangeTracker(self.mw) if not tracker.mark_schema(): return nt = self.current_notetype() remove_notetype(parent=self, notetype_id=nt["id"]).success( lambda _: self.refresh_list(None) ).run_in_background() def onAdvanced(self) -> None: nt = self.current_notetype() d = QDialog(self) disable_help_button(d) frm = aqt.forms.modelopts.Ui_Dialog() frm.setupUi(d) frm.latexsvg.setChecked(nt.get("latexsvg", False)) frm.latexHeader.setText(nt["latexPre"]) frm.latexFooter.setText(nt["latexPost"]) d.setWindowTitle( without_unicode_isolation(tr.actions_options_for(val=nt["name"])) ) qconnect(frm.buttonBox.helpRequested, lambda: openHelp(HelpPage.LATEX)) restoreGeom(d, "modelopts") gui_hooks.models_advanced_will_show(d) d.exec() saveGeom(d, "modelopts") nt["latexsvg"] = frm.latexsvg.isChecked() nt["latexPre"] = str(frm.latexHeader.toPlainText()) nt["latexPost"] = str(frm.latexFooter.toPlainText()) update_notetype_legacy(parent=self, notetype=nt).success( lambda _: self.refresh_list(nt["id"]) ).run_in_background() def _tmpNote(self) -> Note: nt = self.current_notetype() return Note(self.col, nt) def onFields(self) -> None: from aqt.fields import FieldDialog FieldDialog(self.mw, self.current_notetype(), parent=self) def onCards(self) -> None: from aqt.clayout import CardLayout n = self._tmpNote() CardLayout(self.mw, n, ord=0, parent=self, fill_empty=True) # Cleanup ########################################################################## def reject(self) -> None: saveGeom(self, "models") QDialog.reject(self) class AddModel(QDialog): model: NotetypeDict | None def __init__( self, mw: AnkiQt, on_success: Callable[[NotetypeDict], None], parent: QWidget | None = None, ) -> None: self.parent_ = parent or mw self.mw = mw self.col = mw.col QDialog.__init__(self, self.parent_, Qt.WindowType.Window) self.model = None self.dialog = aqt.forms.addmodel.Ui_Dialog() self.dialog.setupUi(self) self.setWindowModality(Qt.WindowModality.ApplicationModal) disable_help_button(self) # standard models self.notetypes: list[NotetypeDict | Callable[[Collection], NotetypeDict]] = [] for name, func in stdmodels.get_stock_notetypes(self.col): item = QListWidgetItem(tr.notetypes_add(val=name)) self.dialog.models.addItem(item) self.notetypes.append(func) # add copies for m in sorted(self.col.models.all(), key=itemgetter("name")): item = QListWidgetItem(tr.notetypes_clone(val=m["name"])) self.dialog.models.addItem(item) self.notetypes.append(m) self.dialog.models.setCurrentRow(0) # the list widget will swallow the enter key s = QShortcut(QKeySequence("Return"), self) qconnect(s.activated, self.accept) # help qconnect(self.dialog.buttonBox.helpRequested, self.onHelp) self.on_success = on_success self.show() def reject(self) -> None: QDialog.reject(self) def accept(self) -> None: model = self.notetypes[self.dialog.models.currentRow()] if isinstance(model, dict): # clone existing self.model = self.mw.col.models.copy(model, add=False) else: self.model = model(self.col) QDialog.accept(self) # On mac, we need to allow time for the existing modal to close or # Qt gets confused. self.mw.progress.single_shot(100, lambda: self.on_success(self.model), True) def onHelp(self) -> None: openHelp(HelpPage.ADDING_A_NOTE_TYPE) ================================================ FILE: qt/aqt/mpv.py ================================================ # ------------------------------------------------------------------------------ # # mpv.py - Control mpv from Python using JSON IPC # # Copyright (c) 2015 Lars Gustäbel # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # # ------------------------------------------------------------------------------ from __future__ import annotations import inspect import json import os import platform import select import socket import subprocess import sys import tempfile import threading import time from queue import Empty, Full, Queue from shutil import which import aqt from anki.utils import is_mac, is_win class MPVError(Exception): pass class MPVProcessError(MPVError): pass class MPVCommunicationError(MPVError): pass class MPVCommandError(MPVError): pass class MPVTimeoutError(MPVError): pass if is_win: import pywintypes import win32file # pytype: disable=import-error import win32job import win32pipe import winerror class MPVBase: """Base class for communication with the mpv media player via unix socket based JSON IPC. """ executable = which("mpv") popenEnv: dict[str, str] | None = None default_argv = [ "--idle", "--no-terminal", "--force-window=no", "--ontop", "--audio-display=no", "--keep-open=no", "--autoload-files=no", "--gapless-audio=no", ] if is_win: default_argv += ["--af-add=lavfi=[apad=pad_dur=0.150]"] if not is_mac or platform.machine() != "arm64": # our arm64 mpv build doesn't support this option (compiled out) default_argv += ["--no-ytdl"] def __init__(self, window_id=None, debug=False): self.window_id = window_id self.debug = debug self._prepare_socket() self._prepare_process() self._start_process() self._start_socket() self._prepare_thread() self._start_thread() def __del__(self): self._stop_thread() self._stop_process() self._stop_socket() def _thread_id(self): return threading.get_ident() # # Process # def _prepare_process(self): """Prepare the argument list for the mpv process.""" self.argv = [self.executable] self.argv += self.default_argv self.argv += [f"--input-ipc-server={self._sock_filename}"] if self.window_id is not None: self.argv += [f"--wid={str(self.window_id)}"] def _start_process(self): """Start the mpv process.""" self._proc = subprocess.Popen(self.argv, env=self.popenEnv) if is_win: # Ensure mpv gets terminated if Anki closes abruptly. self._job = win32job.CreateJobObject(None, f"AnkiJob_{os.getpid()}") extended_info = win32job.QueryInformationJobObject( self._job, win32job.JobObjectExtendedLimitInformation ) extended_info["BasicLimitInformation"]["LimitFlags"] = ( win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE ) win32job.SetInformationJobObject( self._job, win32job.JobObjectExtendedLimitInformation, extended_info, ) handle = self._proc._handle win32job.AssignProcessToJobObject(self._job, handle) def _stop_process(self): """Stop the mpv process.""" if hasattr(self, "_proc"): try: self._proc.terminate() self._proc.wait() except ProcessLookupError: pass # # Socket communication # def _prepare_socket(self): """Create a random socket filename which we pass to mpv with the --input-unix-socket option. """ if is_win: self._sock_filename = "ankimpv{}".format(os.getpid()) return fd, self._sock_filename = tempfile.mkstemp(prefix="mpv.") os.close(fd) os.remove(self._sock_filename) def _start_socket(self): """Wait for the mpv process to create the unix socket and finish startup. """ start = time.time() timeout = 60 if is_mac else 10 while self.is_running() and time.time() < start + timeout: time.sleep(0.1) if is_win: # named pipe try: self._sock = win32file.CreateFile( r"\\.\pipe\{}".format(self._sock_filename), win32file.GENERIC_READ | win32file.GENERIC_WRITE, 0, None, win32file.OPEN_EXISTING, 0, None, ) win32pipe.SetNamedPipeHandleState( self._sock, 1, None, None, # PIPE_NOWAIT ) except pywintypes.error as err: if err.args[0] == winerror.ERROR_FILE_NOT_FOUND: pass else: break else: break else: # unix socket try: self._sock = socket.socket(socket.AF_UNIX) self._sock.connect(self._sock_filename) except (FileNotFoundError, ConnectionRefusedError): self._sock.close() continue else: break else: raise MPVProcessError("unable to start process") def _stop_socket(self): """Clean up the socket.""" if hasattr(self, "_sock"): self._sock.close() if hasattr(self, "_sock_filename"): try: os.remove(self._sock_filename) except OSError: pass def _prepare_thread(self): """Set up the queues for the communication threads.""" self._request_queue = Queue(1) self._response_queues = {} self._event_queue = Queue() self._stop_event = threading.Event() def _start_thread(self): """Start up the communication threads.""" self._thread = threading.Thread(target=self._reader) self._thread.daemon = True self._thread.start() def _stop_thread(self): """Stop the communication threads.""" if hasattr(self, "_stop_event"): self._stop_event.set() if hasattr(self, "_thread"): self._thread.join() def _reader(self): """Read the incoming json messages from the unix socket that is connected to the mpv process. Pass them on to the message handler. """ buf = b"" while not self._stop_event.is_set(): if is_win: try: (n, b) = win32file.ReadFile(self._sock, 4096) buf += b except pywintypes.error as err: if err.args[0] == winerror.ERROR_NO_DATA: time.sleep(0.1) continue elif err.args[0] == winerror.ERROR_BROKEN_PIPE: return else: raise else: r, w, e = select.select([self._sock], [], [], 1) if r: try: b = self._sock.recv(1024) if not b: break buf += b except ConnectionResetError: return newline = buf.find(b"\n") while newline >= 0: data = buf[: newline + 1] buf = buf[newline + 1 :] if self.debug: sys.stdout.write(f"<<< {data.decode('utf8', 'replace')}") message = self._parse_message(data) self._handle_message(message) newline = buf.find(b"\n") # # Message handling # def _compose_message(self, message): """Return a json representation from a message dictionary.""" # XXX may be strict is too strict ;-) data = json.dumps(message) return data.encode("utf8", "strict") + b"\n" def _parse_message(self, data): """Return a message dictionary from a json representation.""" # XXX may be strict is too strict ;-) data = data.decode("utf8", "strict") return json.loads(data) def _handle_message(self, message): """Handle different types of incoming messages, i.e. responses to commands or asynchronous events. """ if "error" in message: # This message is a reply to a request. try: thread_id = self._request_queue.get(timeout=1) except Empty: raise MPVCommunicationError("got a response without a pending request") self._response_queues[thread_id].put(message) elif "event" in message: # This message is an asynchronous event. self._event_queue.put(message) else: raise MPVCommunicationError(f"invalid message {message!r}") def _send_message(self, message, timeout=None): """Send a message/command to the mpv process, message must be a dictionary of the form {"command": ["arg1", "arg2", ...]}. Responses from the mpv process must be collected using _get_response(). """ data = self._compose_message(message) if self.debug: sys.stdout.write(f">>> {data.decode('utf8', 'replace')}") # Request/response cycles are coordinated across different threads, so # that they don't get mixed up. This makes it possible to use commands # (e.g. fetch properties) from event callbacks that run in a different # thread context. thread_id = self._thread_id() if thread_id not in self._response_queues: # Prepare a response queue for the thread to wait on. self._response_queues[thread_id] = Queue() # Put the id of the current thread on the request queue. This id is # later used to associate responses from the mpv process with this # request. try: self._request_queue.put(thread_id, block=True, timeout=timeout) except Full: raise MPVTimeoutError("unable to put request") # Write the message data to the socket. if is_win: win32file.WriteFile(self._sock, data) else: while data: size = self._sock.send(data) if size == 0: raise MPVCommunicationError("broken sender socket") data = data[size:] def _get_response(self, timeout=None): """Collect the response message to a previous request. If there was an error a MPVCommandError exception is raised, otherwise the command specific data is returned. """ try: message = self._response_queues[self._thread_id()].get( block=True, timeout=timeout ) except Empty: raise MPVTimeoutError("unable to get response") if message["error"] != "success": raise MPVCommandError(message["error"]) else: return message.get("data") def _get_event(self, timeout=None): """Collect a single event message that has been received out-of-band from the mpv process. If a timeout is specified and there have not been any events during that period, None is returned. """ try: return self._event_queue.get(block=timeout is not None, timeout=timeout) except Empty: return None def _send_request(self, message, timeout=None, _retry=1): """Send a command to the mpv process and collect the result.""" self.ensure_running() try: self._send_message(message, timeout) return self._get_response(timeout) except MPVCommandError as e: raise MPVCommandError(f"{message['command']!r}: {e}") except Exception: if _retry: print("mpv timed out, restarting") self._stop_process() return self._send_request(message, timeout, _retry - 1) else: raise def _register_callbacks(self): """Will be called after mpv restart to reinitialize callbacks defined in MPV subclass """ # # Public API # def is_running(self): """Return True if the mpv process is still active.""" return self._proc.poll() is None def ensure_running(self): if not self.is_running(): self._stop_thread() self._stop_process() self._stop_socket() self._prepare_socket() self._prepare_process() self._start_process() self._start_socket() self._prepare_thread() self._start_thread() self._register_callbacks() def close(self): """Shutdown the mpv process and our communication setup.""" if self.is_running(): self._send_request({"command": ["quit"]}, timeout=1) self._stop_process() self._stop_thread() self._stop_socket() self._stop_process() class MPV(MPVBase): """Class for communication with the mpv media player via unix socket based JSON IPC. It adds a few usable methods and a callback API. To automatically register methods as event callbacks, subclass this class and define specially named methods as follows: def on_file_loaded(self): # This is called for every 'file-loaded' event. ... def on_property_time_pos(self, position): # This is called whenever the 'time-pos' property is updated. ... Please note that callbacks are executed inside a separate thread. The MPV class itself is completely thread-safe. Requests from different threads to the same MPV instance are synchronized. """ def __init__(self, *args, **kwargs): self._callbacks_queue = Queue() self._callbacks_initialized = False super().__init__(*args, **kwargs) aqt.mw.taskman.run_in_background(self._register_callbacks, None) def _register_callbacks(self): self._callbacks = {} self._property_serials = {} self._new_serial = iter(range(sys.maxsize)) # Enumerate all methods and auto-register callbacks for # events and property-changes. for method_name, method in inspect.getmembers(self): if not inspect.ismethod(method): continue # Bypass MPVError: no such event 'init' if method_name == "on_init": continue if method_name.startswith("on_property_"): name = method_name[12:] name = name.replace("_", "-") self.register_property_callback(name, method) elif method_name.startswith("on_"): name = method_name[3:] name = name.replace("_", "-") self.register_callback(name, method) self._callbacks_initialized = True while True: try: message = self._callbacks_queue.get_nowait() except Empty: break self._handle_event(message) # Simulate an init event when the process and all callbacks have been # completely set up. if hasattr(self, "on_init"): self.on_init() # # Socket communication # def _start_thread(self): """Start up the communication threads.""" super()._start_thread() if not hasattr(self, "_event_thread"): self._event_thread = threading.Thread(target=self._event_reader) self._event_thread.daemon = True self._event_thread.start() # # Event/callback API # def _event_reader(self): """Collect incoming event messages and call the event handler.""" while True: message = self._get_event(timeout=1) if message is None: continue self._handle_event(message) def _handle_event(self, message): """Lookup and call the callbacks for a particular event message.""" if not self._callbacks_initialized: self._callbacks_queue.put(message) return if message["event"] == "property-change": name = f"property-{message['name']}" else: name = message["event"] for callback in self._callbacks.get(name, []): if "data" in message: callback(message["data"]) else: callback() def register_callback(self, name, callback): """Register a function `callback` for the event `name`.""" try: self.command("enable_event", name) except MPVCommandError: raise MPVError(f"no such event {name!r}") self._callbacks.setdefault(name, []).append(callback) def unregister_callback(self, name, callback): """Unregister a previously registered function `callback` for the event `name`. """ try: callbacks = self._callbacks[name] except KeyError: raise MPVError(f"no callbacks registered for event {name!r}") try: callbacks.remove(callback) except ValueError: raise MPVError(f"callback {callback!r} not registered for event {name!r}") def register_property_callback(self, name, callback): """Register a function `callback` for the property-change event on property `name`. """ # Property changes are normally not sent over the connection unless they # are requested using the 'observe_property' command. # XXX We manually have to check for the existence of the property name. # Apparently observe_property does not check it :-( proplist = self.command("get_property", "property-list", timeout=5) if name not in proplist: raise MPVError(f"no such property {name!r}") self._callbacks.setdefault(f"property-{name}", []).append(callback) # 'observe_property' expects some kind of id which can be used later # for unregistering with 'unobserve_property'. serial = next(self._new_serial) self.command("observe_property", serial, name) self._property_serials[(name, callback)] = serial return serial def unregister_property_callback(self, name, callback): """Unregister a previously registered function `callback` for the property-change event on property `name`. """ try: callbacks = self._callbacks[f"property-{name}"] except KeyError: raise MPVError(f"no callbacks registered for property {name!r}") try: callbacks.remove(callback) except ValueError: raise MPVError( f"callback {callback!r} not registered for property {name!r}" ) serial = self._property_serials.pop((name, callback)) self.command("unobserve_property", serial) # # Public API # def command(self, *args, timeout=1): """Execute a single command on the mpv process and return the result.""" return self._send_request({"command": list(args)}, timeout=timeout) def get_property(self, name): """Return the value of property `name`.""" return self.command("get_property", name) def set_property(self, name, value): """Set the value of property `name`.""" return self.command("set_property", name, value) # alias this module for backwards compat sys.modules["anki.mpv"] = sys.modules["aqt.mpv"] ================================================ FILE: qt/aqt/notetypechooser.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Callable from anki.collection import OpChanges from anki.models import NotetypeId from aqt import AnkiQt, gui_hooks from aqt.qt import * from aqt.utils import HelpPage, shortcut, tr class NotetypeChooser(QHBoxLayout): """ Unlike the older modelchooser, this does not modify the "current model", so changes made here do not affect other parts of the UI. To read the currently selected notetype id, use .selected_notetype_id. By default, a chooser will pop up when the button is pressed. You can override this by providing `on_button_activated`. Call .choose_notetype() to run the normal behaviour. `on_notetype_changed` will be called with the new notetype ID if the user selects a different notetype, or if the currently-selected notetype is deleted. """ _selected_notetype_id: NotetypeId def __init__( self, *, mw: AnkiQt, widget: QWidget, starting_notetype_id: NotetypeId, on_button_activated: Callable[[], None] | None = None, on_notetype_changed: Callable[[NotetypeId], None] | None = None, show_prefix_label: bool = True, ) -> None: QHBoxLayout.__init__(self) self._widget = widget # type: ignore self.mw = mw if on_button_activated: self.on_button_activated = on_button_activated else: self.on_button_activated = self.choose_notetype self._setup_ui(show_label=show_prefix_label) gui_hooks.state_did_reset.append(self.reset_state) gui_hooks.operation_did_execute.append(self.on_operation_did_execute) self._selected_notetype_id = NotetypeId(0) # triggers UI update; avoid firing changed hook on startup self.on_notetype_changed = None self.selected_notetype_id = starting_notetype_id self.on_notetype_changed = on_notetype_changed def _setup_ui(self, show_label: bool) -> None: self.setContentsMargins(0, 0, 0, 0) self.setSpacing(8) if show_label: self.label = QLabel(tr.notetypes_type()) self.addWidget(self.label) # button self.button = QPushButton() self.button.setToolTip(shortcut(tr.qt_misc_change_note_type_ctrlandn())) qconnect( QShortcut(QKeySequence("Ctrl+N"), self._widget).activated, self.on_button_activated, ) self.button.setAutoDefault(False) self.addWidget(self.button) qconnect(self.button.clicked, self.on_button_activated) sizePolicy = QSizePolicy(QSizePolicy.Policy(7), QSizePolicy.Policy(0)) self.button.setSizePolicy(sizePolicy) self._widget.setLayout(self) def cleanup(self) -> None: gui_hooks.state_did_reset.remove(self.reset_state) gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) def reset_state(self) -> None: self._ensure_selected_notetype_valid() def show(self) -> None: self._widget.show() # type: ignore def hide(self) -> None: self._widget.hide() # type: ignore def onEdit(self) -> None: import aqt.models aqt.models.Models(self.mw, self._widget) def choose_notetype(self) -> None: from aqt.studydeck import StudyDeck current = self.selected_notetype_name() # edit button edit = QPushButton(tr.qt_misc_manage()) qconnect(edit.clicked, self.onEdit) def nameFunc() -> list[str]: return sorted(n.name for n in self.mw.col.models.all_names_and_ids()) def callback(ret: StudyDeck) -> None: if not ret.name: return notetype = self.mw.col.models.by_name(ret.name) assert notetype is not None if (id := notetype["id"]) != self._selected_notetype_id: self.selected_notetype_id = id StudyDeck( self.mw, names=nameFunc, accept=tr.actions_choose(), title=tr.qt_misc_choose_note_type(), help=HelpPage.NOTE_TYPE, current=current, parent=self._widget, buttons=[edit], cancel=True, geomKey="selectModel", callback=callback, ) @property def selected_notetype_id(self) -> NotetypeId: # theoretically this should not be necessary, as we're listening to # resets self._ensure_selected_notetype_valid() return self._selected_notetype_id @selected_notetype_id.setter def selected_notetype_id(self, id: NotetypeId) -> None: if id != self._selected_notetype_id: self._selected_notetype_id = id self._ensure_selected_notetype_valid() self._update_button_label() if func := self.on_notetype_changed: func(self._selected_notetype_id) def selected_notetype_name(self) -> str: selected_notetype = self.mw.col.models.get(self.selected_notetype_id) assert selected_notetype is not None return selected_notetype["name"] def _ensure_selected_notetype_valid(self) -> None: if not self.mw.col.models.get(self._selected_notetype_id): self.selected_notetype_id = NotetypeId( self.mw.col.models.all_names_and_ids()[0].id ) def _update_button_label(self) -> None: self.button.setText(self.selected_notetype_name().replace("&", "&&")) def on_operation_did_execute( self, changes: OpChanges, handler: object | None ) -> None: if changes.notetype: self._update_button_label() ================================================ FILE: qt/aqt/operations/__init__.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Callable from concurrent.futures._base import Future from typing import Any, Generic, Protocol, TypeVar, Union import aqt import aqt.gui_hooks import aqt.main from anki.collection import ( Collection, ImportLogWithChanges, OpChanges, OpChangesAfterUndo, OpChangesOnly, OpChangesWithCount, OpChangesWithId, Progress, ) from aqt.errors import show_exception from aqt.progress import ProgressUpdate from aqt.qt import QWidget class HasChangesProperty(Protocol): changes: OpChanges # either an OpChanges object, or an object with .changes on it. This bound # doesn't actually work for protobuf objects, so new protobuf objects will # either need to be added here, or cast at call time ResultWithChanges = TypeVar( "ResultWithChanges", bound=Union[ OpChanges, OpChangesOnly, OpChangesWithCount, OpChangesWithId, OpChangesAfterUndo, ImportLogWithChanges, HasChangesProperty, ], ) class CollectionOp(Generic[ResultWithChanges]): """Helper to perform a mutating DB operation on a background thread, and update UI. `op` should either return OpChanges, or an object with a 'changes' property. The changes will be passed to `operation_did_execute` so that the UI can decide whether it needs to update itself. - Shows progress popup for the duration of the op. - Ensures the browser doesn't try to redraw during the operation, which can lead to a frozen UI - Updates undo state at the end of the operation - Commits changes - Fires the `operation_(will|did)_reset` hooks - Fires the legacy `state_did_reset` hook Be careful not to call any UI routines in `op`, as that may crash Qt. This includes things select .selectedCards() in the browse screen. `success` will be called with the return value of op(). If op() throws an exception, it will be shown in a popup, or passed to `failure` if it is provided. """ _success: Callable[[ResultWithChanges], Any] | None = None _failure: Callable[[Exception], Any] | None = None _progress_update: Callable[[Progress, ProgressUpdate], None] | None = None def __init__(self, parent: QWidget, op: Callable[[Collection], ResultWithChanges]): self._parent = parent self._op = op def success( self, success: Callable[[ResultWithChanges], Any] | None ) -> CollectionOp[ResultWithChanges]: self._success = success return self def failure( self, failure: Callable[[Exception], Any] | None ) -> CollectionOp[ResultWithChanges]: self._failure = failure return self def with_backend_progress( self, progress_update: Callable[[Progress, ProgressUpdate], None] | None ) -> CollectionOp[ResultWithChanges]: self._progress_update = progress_update return self def run_in_background(self, *, initiator: object | None = None) -> None: from aqt import mw assert mw mw._increase_background_ops() def wrapped_op() -> ResultWithChanges: assert mw return self._op(mw.col) def wrapped_done(future: Future) -> None: assert mw mw._decrease_background_ops() # did something go wrong? if exception := future.exception(): if isinstance(exception, Exception): if self._failure: self._failure(exception) else: show_exception(parent=self._parent, exception=exception) return else: # BaseException like SystemExit; rethrow it future.result() result = future.result() try: if self._success: self._success(result) finally: on_op_finished(mw, result, initiator) self._run(mw, wrapped_op, wrapped_done) def _run( self, mw: aqt.main.AnkiQt, op: Callable[[], ResultWithChanges], on_done: Callable[[Future], None], ) -> None: if self._progress_update: mw.taskman.with_backend_progress( op, self._progress_update, on_done=on_done, parent=self._parent ) else: mw.taskman.with_progress(op, on_done, parent=self._parent) def on_op_finished( mw: aqt.main.AnkiQt, result: ResultWithChanges, initiator: object | None ) -> None: mw.update_undo_actions() if isinstance(result, OpChanges): changes = result else: changes = result.changes # type: ignore[union-attr] # fire new hook aqt.gui_hooks.operation_did_execute(changes, initiator) # fire legacy hook so old code notices changes if mw.col.op_made_changes(changes): aqt.gui_hooks.state_did_reset() T = TypeVar("T") class QueryOp(Generic[T]): """Helper to perform an operation on a background thread. QueryOp is primarily used for read-only requests (reading information from the database, fetching data from the network, etc), but can also be used for mutable requests outside of the collection undo system (eg adding/deleting files, calling a collection method that doesn't support undo, etc). For operations that support undo, use CollectionOp instead. - Optionally shows progress popup for the duration of the op. - Ensures the browser doesn't try to redraw during the operation, which can lead to a frozen UI Be careful not to call any UI routines in `op`, as that may crash Qt. This includes things like .selectedCards() in the browse screen. `success` will be called with the return value of op(). If op() throws an exception, it will be shown in a popup, or passed to `failure` if it is provided. """ _failure: Callable[[Exception], Any] | None = None _progress: bool | str = False _progress_update: Callable[[Progress, ProgressUpdate], None] | None = None def __init__( self, *, parent: QWidget, op: Callable[[Collection], T], success: Callable[[T], Any], ): self._parent = parent self._op = op self._success = success self._uses_collection = True def failure(self, failure: Callable[[Exception], Any] | None) -> QueryOp[T]: self._failure = failure return self def without_collection(self) -> QueryOp[T]: """Flag this QueryOp as not needing the collection. Operations that access the collection are serialized. If you're doing something like a series of network queries, and your operation does not access the collection, then you can call this to allow the requests to run in parallel.""" self._uses_collection = False return self def with_progress( self, label: str | None = None, ) -> QueryOp[T]: "If label not provided, will default to 'Processing...'" self._progress = label or True return self def with_backend_progress( self, progress_update: Callable[[Progress, ProgressUpdate], None] | None ) -> QueryOp[T]: self._progress_update = progress_update return self def run_in_background(self) -> None: from aqt import mw assert mw mw._increase_background_ops() def wrapped_op() -> T: assert mw return self._op(mw.col) def wrapped_done(future: Future) -> None: assert mw mw._decrease_background_ops() # did something go wrong? if exception := future.exception(): if isinstance(exception, Exception): if self._failure: self._failure(exception) else: show_exception(parent=self._parent, exception=exception) return else: # BaseException like SystemExit; rethrow it future.result() self._success(future.result()) self._run(mw, wrapped_op, wrapped_done) def _run( self, mw: aqt.main.AnkiQt, op: Callable[[], T], on_done: Callable[[Future], None], ) -> None: label = self._progress if isinstance(self._progress, str) else None if self._progress_update: mw.taskman.with_backend_progress( op, self._progress_update, on_done=on_done, start_label=label, parent=self._parent, ) elif self._progress: mw.taskman.with_progress(op, on_done, label=label, parent=self._parent) else: mw.taskman.run_in_background( op, on_done, uses_collection=self._uses_collection ) ================================================ FILE: qt/aqt/operations/card.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Sequence from anki.cards import CardId from anki.collection import OpChangesWithCount from anki.decks import DeckId from aqt.operations import CollectionOp from aqt.qt import QWidget from aqt.utils import tooltip, tr def set_card_deck( *, parent: QWidget, card_ids: Sequence[CardId], deck_id: DeckId ) -> CollectionOp[OpChangesWithCount]: return CollectionOp(parent, lambda col: col.set_deck(card_ids, deck_id)).success( lambda out: tooltip(tr.browsing_cards_updated(count=out.count), parent=parent) ) def set_card_flag( *, parent: QWidget, card_ids: Sequence[CardId], flag: int, ) -> CollectionOp[OpChangesWithCount]: return CollectionOp( parent, lambda col: col.set_user_flag_for_cards(flag, card_ids) ).success( lambda out: tooltip(tr.browsing_cards_updated(count=out.count), parent=parent) ) ================================================ FILE: qt/aqt/operations/collection.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from anki.collection import OpChanges, OpChangesAfterUndo, Preferences from anki.errors import UndoEmpty from aqt import gui_hooks from aqt.operations import CollectionOp from aqt.qt import QWidget from aqt.utils import showWarning, tooltip, tr def undo(*, parent: QWidget) -> None: "Undo the last operation, and refresh the UI." def on_success(out: OpChangesAfterUndo) -> None: gui_hooks.state_did_undo(out) tooltip(tr.undo_action_undone(action=out.operation), parent=parent) def on_failure(exc: Exception) -> None: if not isinstance(exc, UndoEmpty): showWarning(str(exc), parent=parent) CollectionOp(parent, lambda col: col.undo()).success(on_success).failure( on_failure ).run_in_background() def redo(*, parent: QWidget) -> None: "Redo the last operation, and refresh the UI." def on_success(out: OpChangesAfterUndo) -> None: tooltip(tr.undo_action_redone(action=out.operation), parent=parent) CollectionOp(parent, lambda col: col.redo()).success(on_success).run_in_background() def set_preferences( *, parent: QWidget, preferences: Preferences ) -> CollectionOp[OpChanges]: return CollectionOp(parent, lambda col: col.set_preferences(preferences)) ================================================ FILE: qt/aqt/operations/deck.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import html from collections.abc import Sequence from anki.collection import OpChanges, OpChangesWithCount, OpChangesWithId from anki.decks import DeckCollapseScope, DeckDict, DeckId, UpdateDeckConfigs from aqt.operations import CollectionOp from aqt.qt import QWidget from aqt.utils import getOnlyText, tooltip, tr def remove_decks( *, parent: QWidget, deck_ids: Sequence[DeckId], deck_name: str, ) -> CollectionOp[OpChangesWithCount]: return CollectionOp(parent, lambda col: col.decks.remove(deck_ids)).success( lambda out: tooltip( tr.browsing_cards_deleted_with_deckname( count=out.count, deck_name=html.escape(deck_name), ), parent=parent, ) ) def reparent_decks( *, parent: QWidget, deck_ids: Sequence[DeckId], new_parent: DeckId ) -> CollectionOp[OpChangesWithCount]: return CollectionOp( parent, lambda col: col.decks.reparent(deck_ids=deck_ids, new_parent=new_parent) ).success( lambda out: tooltip( tr.browsing_reparented_decks(count=out.count), parent=parent ) ) def rename_deck( *, parent: QWidget, deck_id: DeckId, new_name: str, ) -> CollectionOp[OpChanges]: return CollectionOp( parent, lambda col: col.decks.rename(deck_id, new_name), ) def add_deck_dialog( *, parent: QWidget, default_text: str = "", ) -> CollectionOp[OpChangesWithId] | None: if name := getOnlyText( tr.decks_new_deck_name(), default=default_text, parent=parent, title=tr.decks_create_deck(), ).strip(): return add_deck(parent=parent, name=name) else: return None def add_deck(*, parent: QWidget, name: str) -> CollectionOp[OpChangesWithId]: return CollectionOp(parent, lambda col: col.decks.add_normal_deck_with_name(name)) def set_deck_collapsed( *, parent: QWidget, deck_id: DeckId, collapsed: bool, scope: DeckCollapseScope.V, ) -> CollectionOp[OpChanges]: return CollectionOp( parent, lambda col: col.decks.set_collapsed( deck_id=deck_id, collapsed=collapsed, scope=scope ), ) def set_current_deck(*, parent: QWidget, deck_id: DeckId) -> CollectionOp[OpChanges]: return CollectionOp(parent, lambda col: col.decks.set_current(deck_id)) def update_deck_configs( *, parent: QWidget, input: UpdateDeckConfigs ) -> CollectionOp[OpChanges]: return CollectionOp(parent, lambda col: col.decks.update_deck_configs(input)) def update_deck_dict(*, parent: QWidget, deck: DeckDict) -> CollectionOp[OpChanges]: return CollectionOp(parent, lambda col: col.decks.update_dict(deck)) ================================================ FILE: qt/aqt/operations/note.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Sequence from anki.collection import OpChanges, OpChangesWithCount from anki.decks import DeckId from anki.notes import Note, NoteId from aqt.operations import CollectionOp from aqt.qt import QWidget from aqt.utils import tooltip, tr def add_note( *, parent: QWidget, note: Note, target_deck_id: DeckId, ) -> CollectionOp[OpChangesWithCount]: return CollectionOp(parent, lambda col: col.add_note(note, target_deck_id)) def update_note(*, parent: QWidget, note: Note) -> CollectionOp[OpChanges]: return CollectionOp(parent, lambda col: col.update_note(note)) def update_notes(*, parent: QWidget, notes: Sequence[Note]) -> CollectionOp[OpChanges]: return CollectionOp(parent, lambda col: col.update_notes(notes)).success( lambda _: tooltip(tr.browsing_cards_updated(count=len(notes))) ) def remove_notes( *, parent: QWidget, note_ids: Sequence[NoteId], ) -> CollectionOp[OpChangesWithCount]: return CollectionOp(parent, lambda col: col.remove_notes(note_ids)).success( lambda out: tooltip(tr.browsing_cards_deleted(count=out.count)), ) def find_and_replace( *, parent: QWidget, note_ids: Sequence[NoteId], search: str, replacement: str, regex: bool, field_name: str | None, match_case: bool, ) -> CollectionOp[OpChangesWithCount]: return CollectionOp( parent, lambda col: col.find_and_replace( note_ids=note_ids, search=search, replacement=replacement, regex=regex, field_name=field_name, match_case=match_case, ), ).success( lambda out: tooltip( tr.findreplace_notes_updated(changed=out.count, total=len(note_ids)), parent=parent, ) ) ================================================ FILE: qt/aqt/operations/notetype.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from anki.collection import OpChanges, OpChangesWithId from anki.models import ChangeNotetypeRequest, NotetypeDict, NotetypeId from anki.stdmodels import StockNotetypeKind from aqt.operations import CollectionOp from aqt.qt import QWidget def add_notetype_legacy( *, parent: QWidget, notetype: NotetypeDict, ) -> CollectionOp[OpChangesWithId]: return CollectionOp(parent, lambda col: col.models.add_dict(notetype)) def update_notetype_legacy( *, parent: QWidget, notetype: NotetypeDict, skip_checks: bool = False, ) -> CollectionOp[OpChanges]: return CollectionOp( parent, lambda col: col.models.update_dict(notetype, skip_checks) ) def remove_notetype( *, parent: QWidget, notetype_id: NotetypeId, ) -> CollectionOp[OpChanges]: return CollectionOp(parent, lambda col: col.models.remove(notetype_id)) def change_notetype_of_notes( *, parent: QWidget, input: ChangeNotetypeRequest ) -> CollectionOp[OpChanges]: return CollectionOp(parent, lambda col: col.models.change_notetype_of_notes(input)) def restore_notetype_to_stock( *, parent: QWidget, notetype_id: NotetypeId, force_kind: StockNotetypeKind.V | None ) -> CollectionOp[OpChanges]: return CollectionOp( parent, lambda col: col.models.restore_notetype_to_stock(notetype_id, force_kind), ) ================================================ FILE: qt/aqt/operations/scheduling.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Sequence import aqt import aqt.forms from anki.cards import CardId from anki.collection import ( CARD_TYPE_NEW, Collection, Config, OpChanges, OpChangesWithCount, OpChangesWithId, ) from anki.decks import DeckId from anki.notes import NoteId from anki.scheduler import CustomStudyRequest, FilteredDeckForUpdate, UnburyDeck from anki.scheduler.base import ScheduleCardsAsNew from anki.scheduler.v3 import CardAnswer from anki.scheduler.v3 import Scheduler as V3Scheduler from aqt.operations import CollectionOp from aqt.qt import * from aqt.utils import disable_help_button, getText, tooltip, tr def set_due_date_dialog( *, parent: QWidget, card_ids: Sequence[CardId], config_key: Config.String.V | None, ) -> CollectionOp[OpChanges] | None: assert aqt.mw if not card_ids: return None default_text = ( aqt.mw.col.get_config_string(config_key) if config_key is not None else "" ) prompt = "\n".join( [ tr.scheduling_set_due_date_prompt(cards=len(card_ids)), tr.scheduling_set_due_date_prompt_hint(), ] ) (days, success) = getText( prompt=prompt, parent=parent, default=default_text, title=tr.actions_set_due_date(), ) if not success or not days.strip(): return None else: return CollectionOp( parent, lambda col: col.sched.set_due_date(card_ids, days, config_key) ).success( lambda _: tooltip( tr.scheduling_set_due_date_done(cards=len(card_ids)), parent=parent, ) ) def grade_now( *, parent: QWidget, card_ids: Sequence[CardId], ease: int, ) -> CollectionOp[OpChanges]: if ease == 1: rating = CardAnswer.AGAIN elif ease == 2: rating = CardAnswer.HARD elif ease == 3: rating = CardAnswer.GOOD else: rating = CardAnswer.EASY return CollectionOp( parent, lambda col: col._backend.grade_now( card_ids=card_ids, rating=rating, ), ).success( lambda _: tooltip( tr.scheduling_graded_cards_done(cards=len(card_ids)), parent=parent ) ) def forget_cards( *, parent: QWidget, card_ids: Sequence[CardId], context: ScheduleCardsAsNew.Context.V | None = None, ) -> CollectionOp[OpChanges] | None: assert aqt.mw dialog = QDialog(parent) disable_help_button(dialog) form = aqt.forms.forget.Ui_Dialog() form.setupUi(dialog) if context is not None: defaults = aqt.mw.col.sched.schedule_cards_as_new_defaults(context) form.restore_position.setChecked(defaults.restore_position) form.reset_counts.setChecked(defaults.reset_counts) if not dialog.exec(): return None restore_position = form.restore_position.isChecked() reset_counts = form.reset_counts.isChecked() return CollectionOp( parent, lambda col: col.sched.schedule_cards_as_new( card_ids, restore_position=restore_position, reset_counts=reset_counts, context=context, ), ).success( lambda _: tooltip( tr.scheduling_forgot_cards(cards=len(card_ids)), parent=parent ) ) def reposition_new_cards_dialog( *, parent: QWidget, card_ids: Sequence[CardId], ) -> CollectionOp[OpChangesWithCount] | None: from aqt import mw assert mw assert mw.col.db row = mw.col.db.first( f"select min(due), max(due) from cards where type={CARD_TYPE_NEW} and odid=0" ) assert row (min_position, max_position) = row min_position = max(min_position or 0, 0) max_position = max_position or 0 dialog = QDialog(parent) disable_help_button(dialog) dialog.setWindowModality(Qt.WindowModality.WindowModal) form = aqt.forms.reposition.Ui_Dialog() form.setupUi(dialog) txt = tr.browsing_queue_top(val=min_position) txt += "\n" + tr.browsing_queue_bottom(val=max_position) form.label.setText(txt) form.start.selectAll() defaults = mw.col.sched.reposition_defaults() form.randomize.setChecked(defaults.random) form.shift.setChecked(defaults.shift) if not dialog.exec(): return None start = form.start.value() step = form.step.value() randomize = form.randomize.isChecked() shift = form.shift.isChecked() return reposition_new_cards( parent=parent, card_ids=card_ids, starting_from=start, step_size=step, randomize=randomize, shift_existing=shift, ) def reposition_new_cards( *, parent: QWidget, card_ids: Sequence[CardId], starting_from: int, step_size: int, randomize: bool, shift_existing: bool, ) -> CollectionOp[OpChangesWithCount]: return CollectionOp( parent, lambda col: col.sched.reposition_new_cards( card_ids=card_ids, starting_from=starting_from, step_size=step_size, randomize=randomize, shift_existing=shift_existing, ), ).success( lambda out: tooltip( tr.browsing_changed_new_position(count=out.count), parent=parent ) ) def suspend_cards( *, parent: QWidget, card_ids: Sequence[CardId], ) -> CollectionOp[OpChangesWithCount]: return CollectionOp(parent, lambda col: col.sched.suspend_cards(card_ids)) def suspend_note( *, parent: QWidget, note_ids: Sequence[NoteId], ) -> CollectionOp[OpChangesWithCount]: return CollectionOp(parent, lambda col: col.sched.suspend_notes(note_ids)) def unsuspend_cards( *, parent: QWidget, card_ids: Sequence[CardId] ) -> CollectionOp[OpChanges]: return CollectionOp(parent, lambda col: col.sched.unsuspend_cards(card_ids)) def bury_cards( *, parent: QWidget, card_ids: Sequence[CardId], ) -> CollectionOp[OpChangesWithCount]: return CollectionOp(parent, lambda col: col.sched.bury_cards(card_ids)) def bury_notes( *, parent: QWidget, note_ids: Sequence[NoteId], ) -> CollectionOp[OpChangesWithCount]: return CollectionOp(parent, lambda col: col.sched.bury_notes(note_ids)) def unbury_cards( *, parent: QWidget, card_ids: Sequence[CardId] ) -> CollectionOp[OpChanges]: return CollectionOp(parent, lambda col: col.sched.unbury_cards(card_ids)) def rebuild_filtered_deck( *, parent: QWidget, deck_id: DeckId ) -> CollectionOp[OpChangesWithCount]: return CollectionOp(parent, lambda col: col.sched.rebuild_filtered_deck(deck_id)) def empty_filtered_deck(*, parent: QWidget, deck_id: DeckId) -> CollectionOp[OpChanges]: return CollectionOp(parent, lambda col: col.sched.empty_filtered_deck(deck_id)) def add_or_update_filtered_deck( *, parent: QWidget, deck: FilteredDeckForUpdate, ) -> CollectionOp[OpChangesWithId]: return CollectionOp(parent, lambda col: col.sched.add_or_update_filtered_deck(deck)) def unbury_deck( *, parent: QWidget, deck_id: DeckId, mode: UnburyDeck.Mode.V = UnburyDeck.ALL, ) -> CollectionOp[OpChanges]: return CollectionOp( parent, lambda col: col.sched.unbury_deck(deck_id=deck_id, mode=mode) ) def answer_card( *, parent: QWidget, answer: CardAnswer, ) -> CollectionOp[OpChanges]: def answer_v3(col: Collection) -> OpChanges: assert isinstance(col.sched, V3Scheduler) return col.sched.answer_card(answer) return CollectionOp(parent, answer_v3) def custom_study( *, parent: QWidget, request: CustomStudyRequest, ) -> CollectionOp[OpChanges]: return CollectionOp(parent, lambda col: col.sched.custom_study(request)) ================================================ FILE: qt/aqt/operations/tag.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Sequence from anki.collection import OpChanges, OpChangesWithCount from anki.notes import NoteId from aqt.operations import CollectionOp from aqt.qt import QWidget from aqt.utils import showInfo, tooltip, tr def add_tags_to_notes( *, parent: QWidget, note_ids: Sequence[NoteId], space_separated_tags: str, ) -> CollectionOp[OpChangesWithCount]: return CollectionOp( parent, lambda col: col.tags.bulk_add(note_ids, space_separated_tags) ).success( lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent) ) def remove_tags_from_notes( *, parent: QWidget, note_ids: Sequence[NoteId], space_separated_tags: str, ) -> CollectionOp[OpChangesWithCount]: return CollectionOp( parent, lambda col: col.tags.bulk_remove(note_ids, space_separated_tags) ).success( lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent) ) def clear_unused_tags(*, parent: QWidget) -> CollectionOp[OpChangesWithCount]: return CollectionOp(parent, lambda col: col.tags.clear_unused_tags()).success( lambda out: tooltip( tr.browsing_removed_unused_tags_count(count=out.count), parent=parent ) ) def rename_tag( *, parent: QWidget, current_name: str, new_name: str, ) -> CollectionOp[OpChangesWithCount]: def success(out: OpChangesWithCount) -> None: if out.count: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent) else: showInfo(tr.browsing_tag_rename_warning_empty(), parent=parent) return CollectionOp( parent, lambda col: col.tags.rename(old=current_name, new=new_name), ).success(success) def remove_tags_from_all_notes( *, parent: QWidget, space_separated_tags: str ) -> CollectionOp[OpChangesWithCount]: return CollectionOp( parent, lambda col: col.tags.remove(space_separated_tags=space_separated_tags) ).success( lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent) ) def reparent_tags( *, parent: QWidget, tags: Sequence[str], new_parent: str ) -> CollectionOp[OpChangesWithCount]: return CollectionOp( parent, lambda col: col.tags.reparent(tags=tags, new_parent=new_parent) ).success( lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent) ) def set_tag_collapsed( *, parent: QWidget, tag: str, collapsed: bool ) -> CollectionOp[OpChanges]: return CollectionOp( parent, lambda col: col.tags.set_collapsed(tag=tag, collapsed=collapsed) ) def find_and_replace_tag( *, parent: QWidget, note_ids: Sequence[int], search: str, replacement: str, regex: bool, match_case: bool, ) -> CollectionOp[OpChangesWithCount]: return CollectionOp( parent, lambda col: col.tags.find_and_replace( note_ids=note_ids, search=search, replacement=replacement, regex=regex, match_case=match_case, ), ).success( lambda out: tooltip( tr.findreplace_notes_updated(changed=out.count, total=len(note_ids)), parent=parent, ), ) ================================================ FILE: qt/aqt/overview.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import html from collections.abc import Callable from dataclasses import dataclass from typing import Any import aqt import aqt.operations from anki.collection import OpChanges from anki.scheduler import UnburyDeck from aqt import gui_hooks from aqt.deckdescription import DeckDescriptionDialog from aqt.deckoptions import display_options_for_deck from aqt.operations import QueryOp from aqt.operations.scheduling import ( empty_filtered_deck, rebuild_filtered_deck, unbury_deck, ) from aqt.sound import av_player from aqt.toolbar import BottomBar from aqt.utils import askUserDialog, openLink, shortcut, tooltip, tr class OverviewBottomBar: def __init__(self, overview: Overview) -> None: self.overview = overview @dataclass class OverviewContent: """Stores sections of HTML content that the overview will be populated with. Attributes: deck {str} -- Plain text deck name shareLink {str} -- HTML of the share link section desc {str} -- HTML of the deck description section table {str} -- HTML of the deck stats table section """ deck: str shareLink: str desc: str table: str class Overview: "Deck overview." def __init__(self, mw: aqt.AnkiQt) -> None: self.mw = mw self.web = mw.web self.bottom = BottomBar(mw, mw.bottomWeb) self._refresh_needed = False def show(self) -> None: av_player.stop_and_clear_queue() self.web.set_bridge_command(self._linkHandler, self) self.mw.setStateShortcuts(self._shortcutKeys()) self.refresh() def refresh(self) -> None: def success(_counts: tuple) -> None: self._refresh_needed = False self._renderPage() self._renderBottom() self.mw.web.setFocus() gui_hooks.overview_did_refresh(self) QueryOp( parent=self.mw, op=lambda col: col.sched.counts(), success=success ).run_in_background() def refresh_if_needed(self) -> None: if self._refresh_needed: self.refresh() def op_executed( self, changes: OpChanges, handler: object | None, focused: bool ) -> bool: if changes.study_queues: self._refresh_needed = True if focused: self.refresh_if_needed() return self._refresh_needed # Handlers ############################################################ def _linkHandler(self, url: str) -> bool: if url == "study": self.mw.col.startTimebox() self.mw.moveToState("review") if self.mw.state == "overview": tooltip(tr.studying_no_cards_are_due_yet()) elif url == "anki": print("anki menu") elif url == "opts": display_options_for_deck(self.mw.col.decks.current()) elif url == "cram": aqt.dialogs.open("FilteredDeckConfigDialog", self.mw) elif url == "refresh": self.rebuild_current_filtered_deck() elif url == "empty": self.empty_current_filtered_deck() elif url == "decks": self.mw.moveToState("deckBrowser") elif url == "review": openLink(f"{aqt.appShared}info/{self.sid}?v={self.sidVer}") elif url in {"studymore", "customStudy"}: self.onStudyMore() elif url == "unbury": self.on_unbury() elif url == "description": self.edit_description() elif url.lower().startswith("http"): openLink(url) return False def _shortcutKeys(self) -> list[tuple[str, Callable]]: return [ ("o", lambda: display_options_for_deck(self.mw.col.decks.current())), ("r", self.rebuild_current_filtered_deck), ("e", self.empty_current_filtered_deck), ("c", self.onCustomStudyKey), ("u", self.on_unbury), ] def _current_deck_is_filtered(self) -> int: return self.mw.col.decks.current()["dyn"] def rebuild_current_filtered_deck(self) -> None: rebuild_filtered_deck( parent=self.mw, deck_id=self.mw.col.decks.selected() ).run_in_background() def empty_current_filtered_deck(self) -> None: empty_filtered_deck( parent=self.mw, deck_id=self.mw.col.decks.selected() ).run_in_background() def onCustomStudyKey(self) -> None: if not self._current_deck_is_filtered(): self.onStudyMore() def on_unbury(self) -> None: mode = UnburyDeck.Mode.ALL info = self.mw.col.sched.congratulations_info() if info.have_sched_buried and info.have_user_buried: opts = [ tr.studying_manually_buried_cards(), tr.studying_buried_siblings(), tr.studying_all_buried_cards(), tr.actions_cancel(), ] diag = askUserDialog(tr.studying_what_would_you_like_to_unbury(), opts) diag.setDefault(0) ret = diag.run() if ret == opts[0]: mode = UnburyDeck.Mode.USER_ONLY elif ret == opts[1]: mode = UnburyDeck.Mode.SCHED_ONLY elif ret == opts[3]: return unbury_deck( parent=self.mw, deck_id=self.mw.col.decks.get_current_id(), mode=mode ).run_in_background() onUnbury = on_unbury # HTML ############################################################ def _renderPage(self) -> None: deck = self.mw.col.decks.current() self.sid = deck.get("sharedFrom") if self.sid: self.sidVer = deck.get("ver", None) shareLink = 'Reviews and Updates' else: shareLink = "" if self.mw.col.sched._is_finished(): self._show_finished_screen() return content = OverviewContent( deck=deck["name"], shareLink=shareLink, desc=self._desc(deck), table=self._table(), ) gui_hooks.overview_will_render_content(self, content) content.deck = html.escape(content.deck) self.web.stdHtml( self._body % content.__dict__, css=["css/overview.css"], js=["js/vendor/jquery.min.js"], context=self, ) def _show_finished_screen(self) -> None: self.web.load_sveltekit_page("congrats") def _desc(self, deck: dict[str, Any]) -> str: if deck["dyn"]: desc = tr.studying_this_is_a_special_deck_for() desc += f" {tr.studying_cards_will_be_automatically_returned_to()}" desc += f" {tr.studying_deleting_this_deck_from_the_deck()}" else: desc = deck.get("desc", "") if deck.get("md", False): desc = self.mw.col.render_markdown(desc) if not desc: return "

" if deck["dyn"]: dyn = "dyn" else: dyn = "" return f'

{desc}
' def _table(self) -> str: counts = list(self.mw.col.sched.counts()) current_did = self.mw.col.decks.get_current_id() deck_node = self.mw.col.sched.deck_due_tree(current_did) but = self.mw.button if self.mw.col.v3_scheduler(): assert deck_node is not None buried_new = deck_node.new_count - counts[0] buried_learning = deck_node.learn_count - counts[1] buried_review = deck_node.review_count - counts[2] else: buried_new = buried_learning = buried_review = 0 buried_label = tr.studying_counts_differ() def number_row(title: str, klass: str, count: int, buried_count: int) -> str: buried = f"{buried_count:+}" if buried_count else "" return f""" {title}: {count} {buried} """ return f"""
{number_row(tr.actions_new(), "new-count", counts[0], buried_new)} {number_row(tr.scheduling_learning(), "learn-count", counts[1], buried_learning)} {number_row(tr.studying_to_review(), "review-count", counts[2], buried_review)}
{but("study", tr.studying_study_now(), id="study", extra=" autofocus")}
""" _body = """

%(deck)s

%(shareLink)s %(desc)s %(table)s
""" def edit_description(self) -> None: DeckDescriptionDialog(self.mw) # Bottom area ###################################################################### def _renderBottom(self) -> None: links = [ ["O", "opts", tr.actions_options()], ] is_dyn = self.mw.col.decks.current()["dyn"] if is_dyn: links.append(["R", "refresh", tr.actions_rebuild()]) links.append(["E", "empty", tr.studying_empty()]) else: links.append(["C", "studymore", tr.actions_custom_study()]) # links.append(["F", "cram", _("Filter/Cram")]) if self.mw.col.sched.have_buried(): links.append(["U", "unbury", tr.studying_unbury()]) if not is_dyn: links.append(["", "description", tr.scheduling_description()]) link_handler = gui_hooks.overview_will_render_bottom( self._linkHandler, links, ) if not callable(link_handler): link_handler = self._linkHandler buf = "" for b in links: if b[0]: b[0] = tr.actions_shortcut_key(val=shortcut(b[0])) buf += """ """ % tuple(b) self.bottom.draw( buf=buf, link_handler=link_handler, web_context=OverviewBottomBar(self), ) # Studying more ###################################################################### def onStudyMore(self) -> None: import aqt.customstudy aqt.customstudy.CustomStudy.fetch_data_and_show(self.mw) ================================================ FILE: qt/aqt/package.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """Helpers for the packaged version of Anki.""" from __future__ import annotations import contextlib import os import subprocess import sys from pathlib import Path from anki.utils import is_mac, is_win # ruff: noqa: F401 def first_run_setup() -> None: """Code run the first time after install/upgrade. Currently, we just import our main libraries and invoke mpv/lame on macOS, which is slow on the first run, and doing it this way shows progress being made. """ if not is_mac: return # Import anki_audio first and spawn commands import anki_audio audio_pkg_path = Path(anki_audio.__file__).parent # Start mpv and lame commands concurrently processes = [] for cmd_name in ["mpv", "lame"]: cmd_path = audio_pkg_path / cmd_name proc = subprocess.Popen( [str(cmd_path), "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) processes.append(proc) # Continue with other imports while commands run import concurrent.futures import bs4 import flask import flask_cors import markdown import PyQt6.QtCore import PyQt6.QtGui import PyQt6.QtNetwork import PyQt6.QtQuick import PyQt6.QtWebChannel import PyQt6.QtWebEngineCore import PyQt6.QtWebEngineWidgets import PyQt6.QtWidgets import PyQt6.sip import requests import waitress import anki.collection from . import _macos_helper # Wait for both commands to complete for proc in processes: proc.wait() def uv_binary() -> str | None: """Return the path to the uv binary.""" return os.environ.get("ANKI_LAUNCHER_UV") def launcher_root() -> str | None: """Return the path to the launcher root directory (AnkiProgramFiles).""" return os.environ.get("UV_PROJECT") def venv_binary(cmd: str) -> str | None: """Return the path to a binary in the launcher's venv.""" root = launcher_root() if not root: return None root_path = Path(root) if is_win: binary_path = root_path / ".venv" / "Scripts" / cmd else: binary_path = root_path / ".venv" / "bin" / cmd return str(binary_path) def add_python_requirements(reqs: list[str]) -> tuple[bool, str]: """Add Python requirements to the launcher venv using uv add. Returns (success, output)""" binary = uv_binary() if not binary: return (False, "Not in packaged build.") uv_cmd = [binary, "add"] + reqs result = subprocess.run(uv_cmd, capture_output=True, text=True, check=False) if result.returncode == 0: root = launcher_root() if root: sync_marker = Path(root) / ".sync_complete" sync_marker.touch() return (True, result.stdout) else: return (False, result.stderr) def launcher_executable() -> str | None: """Return the path to the Anki launcher executable.""" return os.getenv("ANKI_LAUNCHER") def trigger_launcher_run() -> None: """Create a trigger file to request launcher UI on next run.""" try: root = launcher_root() if not root: return trigger_path = Path(root) / ".want-launcher" trigger_path.touch() except Exception as e: print(e) def update_and_restart() -> None: """Update and restart Anki using the launcher.""" from aqt import mw launcher = launcher_executable() assert launcher trigger_launcher_run() with contextlib.suppress(ResourceWarning): env = os.environ.copy() env["ANKI_LAUNCHER_WANT_TERMINAL"] = "1" # fixes a bug where launcher fails to appear if opening it # straight after updating if "GNOME_TERMINAL_SCREEN" in env: del env["GNOME_TERMINAL_SCREEN"] creationflags = 0 if sys.platform == "win32": creationflags = ( subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS ) # On Windows 10, changing the handles breaks ANSI display io = None if sys.platform == "win32" else subprocess.DEVNULL subprocess.Popen( [launcher], start_new_session=True, stdin=io, stdout=io, stderr=io, env=env, creationflags=creationflags, ) mw.app.quit() ================================================ FILE: qt/aqt/preferences.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import functools import re from collections.abc import Callable import anki.lang import aqt import aqt.forms import aqt.operations from anki.collection import OpChanges from anki.utils import is_mac from aqt import AnkiQt from aqt.ankihub import ankihub_login, ankihub_logout from aqt.operations.collection import set_preferences from aqt.profiles import VideoDriver from aqt.qt import * from aqt.sync import sync_login from aqt.theme import Theme from aqt.url_schemes import show_url_schemes_dialog from aqt.utils import ( HelpPage, add_ellipsis_to_action_label, askUser, disable_help_button, is_win, openHelp, showInfo, showWarning, tr, ) class Preferences(QDialog): def __init__(self, mw: AnkiQt) -> None: QDialog.__init__(self, mw, Qt.WindowType.Window) self.mw = mw self.prof = self.mw.pm.profile self.form = aqt.forms.preferences.Ui_Preferences() self.form.setupUi(self) for spinbox in ( self.form.lrnCutoff, self.form.dayOffset, self.form.timeLimit, self.form.network_timeout, ): spinbox.setSuffix(f" {spinbox.suffix()}") disable_help_button(self) help_button = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Help) assert help_button is not None help_button.setAutoDefault(False) close_button = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Close) assert close_button is not None close_button.setAutoDefault(False) qconnect( self.form.buttonBox.helpRequested, lambda: openHelp(HelpPage.PREFERENCES) ) self.silentlyClose = True self.setup_collection() self.setup_profile() self.setup_global() self.setup_configurable_answer_keys() self.show() def setup_configurable_answer_keys(self): """ Create a group box in Preferences with widgets that let the user edit answer keys. """ ease_labels = ( (1, tr.studying_again()), (2, tr.studying_hard()), (3, tr.studying_good()), (4, tr.studying_easy()), ) group = self.form.preferences_answer_keys group.setLayout(layout := QFormLayout()) tab_widget: QWidget = self.form.url_schemes for ease, label in ease_labels: layout.addRow( label, line_edit := QLineEdit(self.mw.pm.get_answer_key(ease) or ""), ) QWidget.setTabOrder(tab_widget, line_edit) tab_widget = line_edit qconnect( line_edit.textChanged, functools.partial(self.mw.pm.set_answer_key, ease), ) line_edit.setPlaceholderText(tr.preferences_shortcut_placeholder()) def accept(self) -> None: self.accept_with_callback() def accept_with_callback(self, callback: Callable[[], None] | None = None) -> None: # avoid exception if main window is already closed if not self.mw.col: return def after_collection_update() -> None: self.update_profile() self.update_global() self.mw.pm.save() self.done(0) aqt.dialogs.markClosed("Preferences") if callback: callback() self.update_collection(after_collection_update) def reject(self) -> None: self.accept() # Preferences stored in the collection ###################################################################### def setup_collection(self) -> None: self.prefs = self.mw.col.get_preferences() form = self.form scheduling = self.prefs.scheduling form.lrnCutoff.setValue(int(scheduling.learn_ahead_secs / 60.0)) form.dayOffset.setValue(scheduling.rollover) reviewing = self.prefs.reviewing form.timeLimit.setValue(int(reviewing.time_limit_secs / 60.0)) form.showEstimates.setChecked(reviewing.show_intervals_on_buttons) form.showProgress.setChecked(reviewing.show_remaining_due_counts) form.showPlayButtons.setChecked(not reviewing.hide_audio_play_buttons) form.interrupt_audio.setChecked(reviewing.interrupt_audio_when_answering) editing = self.prefs.editing form.useCurrent.setCurrentIndex( 0 if editing.adding_defaults_to_current_deck else 1 ) form.paste_strips_formatting.setChecked(editing.paste_strips_formatting) form.ignore_accents_in_search.setChecked(editing.ignore_accents_in_search) form.pastePNG.setChecked(editing.paste_images_as_png) form.render_latex.setChecked(editing.render_latex) form.default_search_text.setText(editing.default_search_text) form.backup_explanation.setText( anki.lang.with_collapsed_whitespace(tr.preferences_backup_explanation()) ) form.daily_backups.setValue(self.prefs.backups.daily) form.weekly_backups.setValue(self.prefs.backups.weekly) form.monthly_backups.setValue(self.prefs.backups.monthly) form.minutes_between_backups.setValue(self.prefs.backups.minimum_interval_mins) add_ellipsis_to_action_label(self.form.url_schemes) qconnect(self.form.url_schemes.clicked, show_url_schemes_dialog) def update_collection(self, on_done: Callable[[], None]) -> None: form = self.form scheduling = self.prefs.scheduling scheduling.learn_ahead_secs = form.lrnCutoff.value() * 60 scheduling.rollover = form.dayOffset.value() reviewing = self.prefs.reviewing reviewing.show_remaining_due_counts = form.showProgress.isChecked() reviewing.show_intervals_on_buttons = form.showEstimates.isChecked() reviewing.time_limit_secs = form.timeLimit.value() * 60 reviewing.hide_audio_play_buttons = not self.form.showPlayButtons.isChecked() reviewing.interrupt_audio_when_answering = self.form.interrupt_audio.isChecked() editing = self.prefs.editing editing.adding_defaults_to_current_deck = not form.useCurrent.currentIndex() editing.paste_images_as_png = self.form.pastePNG.isChecked() editing.paste_strips_formatting = self.form.paste_strips_formatting.isChecked() editing.render_latex = self.form.render_latex.isChecked() editing.default_search_text = self.form.default_search_text.text() editing.ignore_accents_in_search = ( self.form.ignore_accents_in_search.isChecked() ) self.prefs.backups.daily = form.daily_backups.value() self.prefs.backups.weekly = form.weekly_backups.value() self.prefs.backups.monthly = form.monthly_backups.value() self.prefs.backups.minimum_interval_mins = form.minutes_between_backups.value() def after_prefs_update(changes: OpChanges) -> None: self.mw.apply_collection_options() on_done() set_preferences(parent=self, preferences=self.prefs).success( after_prefs_update ).run_in_background() # Preferences stored in the profile ###################################################################### def setup_profile(self) -> None: "Setup options stored in the user profile." self.setup_network() def update_profile(self) -> None: self.update_network() # Profile: network ###################################################################### def setup_network(self) -> None: self.form.media_log.setText(tr.sync_media_log_button()) qconnect(self.form.media_log.clicked, self.on_media_log) self.form.syncOnProgramOpen.setChecked(self.mw.pm.auto_syncing_enabled()) self.form.syncMedia.setChecked(self.mw.pm.media_syncing_enabled()) self.form.autoSyncMedia.setChecked( self.mw.pm.periodic_sync_media_minutes() != 0 ) self.form.custom_sync_url.setText(self.mw.pm.custom_sync_url()) self.form.network_timeout.setValue(self.mw.pm.network_timeout()) self.form.check_for_updates.setChecked(self.mw.pm.check_for_updates()) qconnect(self.form.check_for_updates.stateChanged, self.mw.pm.set_update_check) self.form.check_for_addon_updates.setChecked( self.mw.pm.check_for_addon_updates() ) qconnect( self.form.check_for_addon_updates.stateChanged, self.mw.pm.set_check_for_addon_updates, ) self.update_login_status() qconnect(self.form.syncLogout.clicked, self.sync_logout) qconnect(self.form.syncLogin.clicked, self.sync_login) qconnect(self.form.syncAnkiHubLogout.clicked, self.ankihub_sync_logout) qconnect(self.form.syncAnkiHubLogin.clicked, self.ankihub_sync_login) def update_login_status(self) -> None: assert self.prof is not None if not self.prof.get("syncKey"): self.form.syncUser.setText(tr.preferences_ankiweb_intro()) self.form.syncLogin.setVisible(True) self.form.syncLogout.setVisible(False) else: self.form.syncUser.setText(self.prof.get("syncUser", "")) self.form.syncLogin.setVisible(False) self.form.syncLogout.setVisible(True) if not self.mw.pm.ankihub_token(): self.form.syncAnkiHubUser.setText(tr.preferences_ankihub_intro()) self.form.syncAnkiHubLogin.setVisible(True) self.form.syncAnkiHubLogout.setVisible(False) else: self.form.syncAnkiHubUser.setText(self.mw.pm.ankihub_username()) self.form.syncAnkiHubLogin.setVisible(False) self.form.syncAnkiHubLogout.setVisible(True) def on_media_log(self) -> None: self.mw.media_syncer.show_sync_log() def sync_login(self) -> None: def on_success(): assert self.prof is not None if self.prof.get("syncKey"): self.update_login_status() self.confirm_sync_after_login() self.update_network() sync_login(self.mw, on_success) def sync_logout(self) -> None: if self.mw.media_syncer.is_syncing(): showWarning("Can't log out while sync in progress.") return assert self.prof is not None self.prof["syncKey"] = None self.mw.col.media.force_resync() self.update_login_status() def ankihub_sync_login(self) -> None: def on_success(): if self.mw.pm.ankihub_token(): self.update_login_status() ankihub_login(self.mw, on_success) def ankihub_sync_logout(self) -> None: ankihub_token = self.mw.pm.ankihub_token() if ankihub_token is None: return ankihub_logout(self.mw, self.update_login_status, ankihub_token) def confirm_sync_after_login(self) -> None: from aqt import mw if askUser(tr.preferences_login_successful_sync_now(), parent=mw): self.accept_with_callback(self.mw.on_sync_button_clicked) def update_network(self) -> None: assert self.prof is not None self.prof["autoSync"] = self.form.syncOnProgramOpen.isChecked() self.prof["syncMedia"] = self.form.syncMedia.isChecked() self.mw.pm.set_periodic_sync_media_minutes( self.form.autoSyncMedia.isChecked() and 15 or 0 ) if self.form.fullSync.isChecked(): self.mw.col.mod_schema(check=False) self.mw.pm.set_custom_sync_url(self.form.custom_sync_url.text()) self.mw.pm.set_network_timeout(self.form.network_timeout.value()) # Global preferences ###################################################################### def setup_global(self) -> None: "Setup options global to all profiles." self.form.reduce_motion.setChecked(self.mw.pm.reduce_motion()) qconnect(self.form.reduce_motion.stateChanged, self.mw.pm.set_reduce_motion) self.form.minimalist_mode.setChecked(self.mw.pm.minimalist_mode()) qconnect(self.form.minimalist_mode.stateChanged, self.mw.pm.set_minimalist_mode) self.form.spacebar_rates_card.setChecked(self.mw.pm.spacebar_rates_card()) qconnect( self.form.spacebar_rates_card.stateChanged, self.mw.pm.set_spacebar_rates_card, ) hide_choices = [tr.preferences_full_screen_only(), tr.preferences_always()] self.form.hide_top_bar.setChecked(self.mw.pm.hide_top_bar()) qconnect(self.form.hide_top_bar.stateChanged, self.mw.pm.set_hide_top_bar) qconnect( self.form.hide_top_bar.stateChanged, self.form.topBarComboBox.setVisible, ) self.form.topBarComboBox.addItems(hide_choices) self.form.topBarComboBox.setCurrentIndex(self.mw.pm.top_bar_hide_mode()) self.form.topBarComboBox.setVisible(self.form.hide_top_bar.isChecked()) qconnect( self.form.topBarComboBox.currentIndexChanged, self.mw.pm.set_top_bar_hide_mode, ) self.form.hide_bottom_bar.setChecked(self.mw.pm.hide_bottom_bar()) qconnect(self.form.hide_bottom_bar.stateChanged, self.mw.pm.set_hide_bottom_bar) qconnect( self.form.hide_bottom_bar.stateChanged, self.form.bottomBarComboBox.setVisible, ) self.form.bottomBarComboBox.addItems(hide_choices) self.form.bottomBarComboBox.setCurrentIndex(self.mw.pm.bottom_bar_hide_mode()) self.form.bottomBarComboBox.setVisible(self.form.hide_bottom_bar.isChecked()) qconnect( self.form.bottomBarComboBox.currentIndexChanged, self.mw.pm.set_bottom_bar_hide_mode, ) self.form.uiScale.setValue(int(self.mw.pm.uiScale() * 100)) themes = [ tr.preferences_theme_follow_system(), tr.preferences_theme_light(), tr.preferences_theme_dark(), ] self.form.theme.addItems(themes) self.form.theme.setCurrentIndex(self.mw.pm.theme().value) qconnect(self.form.theme.currentIndexChanged, self.on_theme_changed) self.form.styleComboBox.addItems(["Anki"] + (["Native"] if not is_win else [])) self.form.styleComboBox.setCurrentIndex(self.mw.pm.get_widget_style()) qconnect( self.form.styleComboBox.currentIndexChanged, self.mw.pm.set_widget_style, ) self.form.styleLabel.setVisible(not is_win) self.form.styleComboBox.setVisible(not is_win) qconnect(self.form.resetWindowSizes.clicked, self.on_reset_window_sizes) self.setup_language() self.setup_video_driver() self.setupOptions() def update_global(self) -> None: restart_required = False self.update_video_driver() newScale = self.form.uiScale.value() / 100 if newScale != self.mw.pm.uiScale(): self.mw.pm.setUiScale(newScale) restart_required = True if restart_required: showInfo(tr.preferences_changes_will_take_effect_when_you()) self.updateOptions() def on_theme_changed(self, index: int) -> None: self.mw.set_theme(Theme(index)) def on_reset_window_sizes(self) -> None: assert self.prof is not None regexp = re.compile(r"(Geom(etry)?|State|Splitter|Header)(\d+.\d+)?$") for key in list(self.prof.keys()): if regexp.search(key): del self.prof[key] showInfo(tr.preferences_reset_window_sizes_complete()) # legacy - one of Henrik's add-ons is currently wrapping them def setupOptions(self) -> None: pass def updateOptions(self) -> None: pass # Global: language ###################################################################### def setup_language(self) -> None: f = self.form f.lang.addItems([x[0] for x in anki.lang.langs]) f.lang.setCurrentIndex(self.current_lang_index()) qconnect(f.lang.currentIndexChanged, self.on_language_index_changed) def current_lang_index(self) -> int: codes = [x[1] for x in anki.lang.langs] lang = anki.lang.current_lang if lang in anki.lang.compatMap: lang = anki.lang.compatMap[lang] else: lang = lang.replace("-", "_") try: return codes.index(lang) except Exception: return codes.index("en_US") def on_language_index_changed(self, idx: int) -> None: code = anki.lang.langs[idx][1] self.mw.pm.setLang(code) showInfo(tr.preferences_please_restart_anki_to_complete_language(), parent=self) # Global: video driver ###################################################################### def setup_video_driver(self) -> None: self.video_drivers = VideoDriver.all_for_platform() names = [video_driver_name_for_platform(d) for d in self.video_drivers] self.form.video_driver.addItems(names) self.form.video_driver.setCurrentIndex( self.video_drivers.index(self.mw.pm.video_driver()) ) def update_video_driver(self) -> None: new_driver = self.video_drivers[self.form.video_driver.currentIndex()] if new_driver != self.mw.pm.video_driver(): self.mw.pm.set_video_driver(new_driver) showInfo(tr.preferences_changes_will_take_effect_when_you()) def video_driver_name_for_platform(driver: VideoDriver) -> str: if qtmajor < 6: if driver == VideoDriver.ANGLE: return tr.preferences_video_driver_angle() elif driver == VideoDriver.Software: if is_mac: return tr.preferences_video_driver_software_mac() else: return tr.preferences_video_driver_software_other() elif driver == VideoDriver.OpenGL: if is_mac: return tr.preferences_video_driver_opengl_mac() else: return tr.preferences_video_driver_opengl_other() label = driver.name if driver == VideoDriver.default_for_platform(): label += f" ({tr.preferences_video_driver_default()})" return label ================================================ FILE: qt/aqt/profiles.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import errno import io import os import pickle import random import shutil import traceback from enum import Enum from pathlib import Path from typing import TYPE_CHECKING, Any import anki.lang import aqt.forms import aqt.sound from anki._legacy import deprecated from anki.collection import Collection from anki.db import DB from anki.lang import without_unicode_isolation from anki.sync import SyncAuth from anki.utils import int_time, int_version, is_mac, is_win from aqt import appHelpSite, gui_hooks from aqt.qt import * from aqt.qt import sip from aqt.theme import Theme, WidgetStyle, theme_manager from aqt.toolbar import HideMode from aqt.utils import disable_help_button, send_to_trash, showWarning, tr if TYPE_CHECKING: from aqt.browser.layout import BrowserLayout from aqt.editor import EditorMode # Profile handling ########################################################################## # - Saves in pickles rather than json to easily store Qt window state. # - Saves in sqlite rather than a flat file so the config can't be corrupted class VideoDriver(Enum): OpenGL = "auto" ANGLE = "angle" Software = "software" Metal = "metal" Vulkan = "vulkan" Direct3D = "d3d11" @staticmethod def default_for_platform() -> VideoDriver: return VideoDriver.all_for_platform()[0] def constrained_to_platform(self) -> VideoDriver: if self not in VideoDriver.all_for_platform(): return VideoDriver.default_for_platform() return self def next(self) -> VideoDriver: all = VideoDriver.all_for_platform() try: idx = (all.index(self) + 1) % len(all) except ValueError: idx = 0 return all[idx] @staticmethod def all_for_platform() -> list[VideoDriver]: all = [] if qtmajor > 5: if is_win: all.append(VideoDriver.Direct3D) if is_mac: all.append(VideoDriver.Metal) all.append(VideoDriver.OpenGL) if qtmajor > 5 and not is_mac: all.append(VideoDriver.Vulkan) if is_win and qtmajor < 6: all.append(VideoDriver.ANGLE) all.append(VideoDriver.Software) return all metaConf = dict( ver=0, updates=True, created=int_time(), id=random.randrange(0, 2**63), lastMsg=0, suppressUpdate=False, firstRun=True, defaultLang=None, ) # Old Anki versions expected these keys to exist. Don't add new ones here - it's better practice # to always use profile.get(..., defaultValue) instead, as keys may be missing. profileConf: dict[str, Any] = dict( # profile mainWindowGeom=None, mainWindowState=None, numBackups=50, lastOptimize=int_time(), # editing searchHistory=[], # syncing syncKey=None, syncMedia=True, autoSync=True, # importing allowHTML=False, importMode=1, # these are not used, but Anki 2.1.42 and below # expect these keys to exist lastColour="#00f", stripHTML=True, deleteMedia=False, ) class LoadMetaResult: firstTime: bool loadError: bool class ProfileManager: default_answer_keys = {ease_num: str(ease_num) for ease_num in range(1, 5)} last_run_version: int = 0 def __init__(self, base: Path) -> None: "base should be retrieved via ProfileMangager.get_created_base_folder" ## Settings which should be forgotten each Anki restart self.session: dict[str, Any] = {} self.name: str | None = None self.db: DB | None = None self.profile: dict | None = None self.invalid_profile_provided_on_commandline = False self.base = str(base) def setupMeta(self) -> LoadMetaResult: # load metadata res = self._loadMeta() self.firstRun = res.firstTime self.last_run_version = self.meta.get("last_run_version", self.last_run_version) self.meta["last_run_version"] = int_version() return res # -p profile provided on command line. def openProfile(self, profile: str) -> None: if profile not in self.profiles(): self.invalid_profile_provided_on_commandline = True else: try: self.load(profile) except Exception: self.invalid_profile_provided_on_commandline = True # Profile load/save ###################################################################### def profiles(self) -> list[str]: def names() -> list[str]: return self.db.list("select name from profiles where name != '_global'") n = names() if not n: self._ensureProfile() n = names() return n def _unpickle(self, data: bytes) -> Any: class Unpickler(pickle.Unpickler): def find_class(self, class_module: str, name: str) -> Any: # handle sip lookup ourselves, mapping to current Qt version if class_module == "sip" or class_module.endswith(".sip"): def unpickle_type(module: str, klass: str, args: Any) -> Any: if qtmajor > 5: module = module.replace("Qt5", "Qt6") else: module = module.replace("Qt6", "Qt5") if klass == "QByteArray": if module.startswith("PyQt4"): # can't trust str objects from python 2 return QByteArray() else: # return the bytes directly return args[0] elif name == "_unpickle_enum": # old style enums can't be unpickled return None else: return sip._unpickle_type(module, klass, args) # type: ignore return unpickle_type else: return super().find_class(class_module, name) up = Unpickler(io.BytesIO(data), errors="ignore") return up.load() def _pickle(self, obj: Any) -> bytes: for key, val in obj.items(): if isinstance(val, QByteArray): obj[key] = bytes(val) # type: ignore return pickle.dumps(obj, protocol=4) def load(self, name: str) -> bool: if name == "_global": raise Exception("_global is not a valid name") data = self.db.scalar( "select cast(data as blob) from profiles where name = ? collate nocase", name, ) self.name = name try: self.profile = self._unpickle(data) except Exception: print(traceback.format_exc()) QMessageBox.warning( None, tr.profiles_profile_corrupt(), tr.profiles_anki_could_not_read_your_profile(), ) print("resetting corrupt profile") self.profile = profileConf.copy() self.save() self.set_last_loaded_profile_name(name) return True def save(self) -> None: sql = "update profiles set data = ? where name = ? collate nocase" self.db.execute(sql, self._pickle(self.profile), self.name) self.db.execute(sql, self._pickle(self.meta), "_global") self.db.commit() def create(self, name: str) -> None: prof = profileConf.copy() if self.db.scalar("select 1 from profiles where name = ? collate nocase", name): return self.db.execute( "insert or ignore into profiles values (?, ?)", name, self._pickle(prof), ) self.db.commit() def remove(self, name: str) -> None: path = self.profileFolder(create=False) send_to_trash(Path(path)) self.db.execute("delete from profiles where name = ? collate nocase", name) self.db.commit() def trashCollection(self) -> None: path = self.collectionPath() send_to_trash(Path(path)) def rename(self, name: str) -> None: oldName = self.name oldFolder = self.profileFolder() self.name = name newFolder = self.profileFolder(create=False) if os.path.exists(newFolder): if (oldFolder != newFolder) and (oldFolder.lower() == newFolder.lower()): # OS is telling us the folder exists because it does not take # case into account; use a temporary folder location midFolder = "".join([oldFolder, "-temp"]) if not os.path.exists(midFolder): os.rename(oldFolder, midFolder) oldFolder = midFolder else: showWarning(tr.profiles_please_remove_the_folder_and(val=midFolder)) self.name = oldName return else: showWarning(tr.profiles_folder_already_exists()) self.name = oldName return # update name self.db.execute( "update profiles set name = ? where name = ? collate nocase", name, oldName ) # rename folder try: os.rename(oldFolder, newFolder) except Exception as e: self.db.rollback() if "WinError 5" in str(e): showWarning(tr.profiles_anki_could_not_rename_your_profile()) elif isinstance(e, OSError) and e.errno == errno.ENAMETOOLONG: showWarning(tr.profiles_anki_could_not_rename_your_profile()) else: raise except BaseException: self.db.rollback() raise else: self.db.commit() # Folder handling ###################################################################### def profileFolder(self, create: bool = True) -> str: path = os.path.join(self.base, self.name) if create: self._ensureExists(path) return path def addonFolder(self) -> str: return self._ensureExists(os.path.join(self.base, "addons21")) def backupFolder(self) -> str: return self._ensureExists(os.path.join(self.profileFolder(), "backups")) def collectionPath(self) -> str: return os.path.join(self.profileFolder(), "collection.anki2") def addon_logs(self) -> str: return self._ensureExists(os.path.join(self.base, "logs")) # Downgrade ###################################################################### def downgrade(self, profiles: list[str]) -> list[str]: "Downgrade all profiles. Return a list of profiles that couldn't be opened." problem_profiles = [] for name in profiles: path = os.path.join(self.base, name, "collection.anki2") if not os.path.exists(path): continue with DB(path) as db: if db.scalar("select ver from col") == 11: # nothing to do continue try: c = Collection(path) c.close(downgrade=True) except Exception as e: print(e) problem_profiles.append(name) return problem_profiles # Helpers ###################################################################### def _ensureExists(self, path: str) -> str: if not os.path.exists(path): os.makedirs(path) return path @staticmethod def get_created_base_folder(path_override: str | None) -> Path: "Create the base folder and return it, using provided path or default." path = Path( path_override or os.environ.get("ANKI_BASE") or ProfileManager._default_base() ) path.mkdir(parents=True, exist_ok=True) return path.resolve() @staticmethod def _default_base() -> str: if is_win: from aqt.winpaths import get_appdata return os.path.join(get_appdata(), "Anki2") elif is_mac: return os.path.expanduser("~/Library/Application Support/Anki2") else: dataDir = os.environ.get( "XDG_DATA_HOME", os.path.expanduser("~/.local/share") ) if not os.path.exists(dataDir): os.makedirs(dataDir) return os.path.join(dataDir, "Anki2") def _loadMeta(self, retrying: bool = False) -> LoadMetaResult: result = LoadMetaResult() result.firstTime = False result.loadError = retrying opath = os.path.join(self.base, "prefs.db") path = os.path.join(self.base, "prefs21.db") if not retrying and os.path.exists(opath) and not os.path.exists(path): shutil.copy(opath, path) result.firstTime = not os.path.exists(path) def recover() -> None: # if we can't load profile, start with a new one if self.db: try: self.db.close() except Exception: pass for suffix in ("", "-journal"): fpath = path + suffix if os.path.exists(fpath): os.unlink(fpath) # open DB file and read data try: self.db = DB(path) if not self.db.scalar("pragma integrity_check") == "ok": raise Exception("corrupt db") self.db.execute( """ create table if not exists profiles (name text primary key collate nocase, data blob not null);""" ) data = self.db.scalar( "select cast(data as blob) from profiles where name = '_global'" ) except Exception: traceback.print_stack() if result.loadError: # already failed, prevent infinite loop raise # delete files and try again recover() return self._loadMeta(retrying=True) # try to read data if not result.firstTime: try: self.meta = self._unpickle(data) return result except Exception: traceback.print_stack() print("resetting corrupt _global") result.loadError = True result.firstTime = True # if new or read failed, create a default global profile self.meta = metaConf.copy() self.db.execute( "insert or replace into profiles values ('_global', ?)", self._pickle(metaConf), ) return result def _ensureProfile(self) -> None: "Create a new profile if none exists." self.create(tr.profiles_user_1()) p = os.path.join(self.base, "README.txt") with open(p, "w", encoding="utf8") as file: file.write( without_unicode_isolation( tr.profiles_folder_readme( link=f"{appHelpSite}files#startup-options", ) ) + "\n" ) # Default language ###################################################################### # On first run, allow the user to choose the default language def setDefaultLang(self, idx: int) -> None: # create dialog class NoCloseDiag(QDialog): def reject(self) -> None: pass d = self.langDiag = NoCloseDiag() f = self.langForm = aqt.forms.setlang.Ui_Dialog() f.setupUi(d) disable_help_button(d) qconnect(d.accepted, self._onLangSelected) qconnect(d.rejected, lambda: True) # update list f.lang.addItems([x[0] for x in anki.lang.langs]) f.lang.setCurrentRow(idx) d.exec() def _onLangSelected(self) -> None: f = self.langForm obj = anki.lang.langs[f.lang.currentRow()] code = obj[1] name = obj[0] r = QMessageBox.question( None, "Anki", tr.profiles_confirm_lang_choice(lang=name), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, # type: ignore ) if r != QMessageBox.StandardButton.Yes: return self.setDefaultLang(f.lang.currentRow()) self.setLang(code) def setLang(self, code: str) -> None: self.meta["defaultLang"] = code sql = "update profiles set data = ? where name = ? collate nocase" self.db.execute(sql, self._pickle(self.meta), "_global") self.db.commit() anki.lang.set_lang(code) # OpenGL ###################################################################### def _gldriver_path(self) -> str: if qtmajor < 6: fname = "gldriver" else: fname = "gldriver6" return os.path.join(self.base, fname) def video_driver(self) -> VideoDriver: path = self._gldriver_path() try: with open(path, encoding="utf8") as file: text = file.read().strip() return VideoDriver(text).constrained_to_platform() except (ValueError, OSError): return VideoDriver.default_for_platform() def set_video_driver(self, driver: VideoDriver) -> None: with open(self._gldriver_path(), "w", encoding="utf8") as file: file.write(driver.value) def set_next_video_driver(self) -> None: self.set_video_driver(self.video_driver().next()) # Shared options ###################################################################### def uiScale(self) -> float: scale = self.meta.get("uiScale", 1.0) return max(scale, 1) def setUiScale(self, scale: float) -> None: self.meta["uiScale"] = scale def reduce_motion(self) -> bool: return self.meta.get("reduce_motion", True) def set_reduce_motion(self, on: bool) -> None: self.meta["reduce_motion"] = on gui_hooks.body_classes_need_update() def minimalist_mode(self) -> bool: return self.meta.get("minimalist_mode", False) def set_minimalist_mode(self, on: bool) -> None: self.meta["minimalist_mode"] = on gui_hooks.body_classes_need_update() def spacebar_rates_card(self) -> bool: return self.meta.get("spacebar_rates_card", True) def set_spacebar_rates_card(self, on: bool) -> None: self.meta["spacebar_rates_card"] = on def get_answer_key(self, ease: int) -> str | None: return self.meta.setdefault("answer_keys", self.default_answer_keys).get(ease) def set_answer_key(self, ease: int, key: str): self.meta.setdefault("answer_keys", self.default_answer_keys)[ease] = key def hide_top_bar(self) -> bool: return self.meta.get("hide_top_bar", False) def set_hide_top_bar(self, on: bool) -> None: self.meta["hide_top_bar"] = on gui_hooks.body_classes_need_update() def top_bar_hide_mode(self) -> HideMode: return self.meta.get("top_bar_hide_mode", HideMode.FULLSCREEN) def set_top_bar_hide_mode(self, mode: HideMode) -> None: self.meta["top_bar_hide_mode"] = mode gui_hooks.body_classes_need_update() def hide_bottom_bar(self) -> bool: return self.meta.get("hide_bottom_bar", False) def set_hide_bottom_bar(self, on: bool) -> None: self.meta["hide_bottom_bar"] = on gui_hooks.body_classes_need_update() def bottom_bar_hide_mode(self) -> HideMode: return self.meta.get("bottom_bar_hide_mode", HideMode.FULLSCREEN) def set_bottom_bar_hide_mode(self, mode: HideMode) -> None: self.meta["bottom_bar_hide_mode"] = mode gui_hooks.body_classes_need_update() def last_addon_update_check(self) -> int: return self.meta.get("last_addon_update_check", 0) def set_last_addon_update_check(self, secs: int) -> None: self.meta["last_addon_update_check"] = secs def check_for_addon_updates(self) -> bool: return self.meta.get("check_for_addon_updates", True) def set_check_for_addon_updates(self, on: bool) -> None: self.meta["check_for_addon_updates"] = on @deprecated(info="use theme_manager.night_mode") def night_mode(self) -> bool: return theme_manager.night_mode def theme(self) -> Theme: return Theme(self.meta.get("theme", 0)) def set_theme(self, theme: Theme) -> None: self.meta["theme"] = theme.value def set_widget_style(self, style: WidgetStyle) -> None: self.meta["widget_style"] = style theme_manager.apply_style() def get_widget_style(self) -> WidgetStyle: return self.meta.get( "widget_style", WidgetStyle.NATIVE if is_mac else WidgetStyle.ANKI ) def browser_layout(self) -> BrowserLayout: from aqt.browser.layout import BrowserLayout return BrowserLayout(self.meta.get("browser_layout", "auto")) def set_browser_layout(self, layout: BrowserLayout) -> None: self.meta["browser_layout"] = layout.value def editor_key(self, mode: EditorMode) -> str: from aqt.editor import EditorMode return { EditorMode.ADD_CARDS: "add", EditorMode.BROWSER: "browser", EditorMode.EDIT_CURRENT: "current", }[mode] def tags_collapsed(self, mode: EditorMode) -> bool: return self.meta.get(f"{self.editor_key(mode)}TagsCollapsed", False) def set_tags_collapsed(self, mode: EditorMode, collapsed: bool) -> None: self.meta[f"{self.editor_key(mode)}TagsCollapsed"] = collapsed def legacy_import_export(self) -> bool: "Always returns False so users with this option enabled are not stuck on the legacy importer after the UI option is removed." return False def set_legacy_import_export(self, enabled: bool) -> None: self.meta["legacy_import"] = enabled def last_loaded_profile_name(self) -> str | None: return self.meta.get("last_loaded_profile_name") def set_last_loaded_profile_name(self, name: str) -> None: self.meta["last_loaded_profile_name"] = name # Profile-specific ###################################################################### def set_sync_key(self, val: str | None) -> None: self.profile["syncKey"] = val def set_sync_username(self, val: str | None) -> None: self.profile["syncUser"] = val def set_host_number(self, val: int | None) -> None: self.profile["hostNum"] = val or 0 def check_for_updates(self) -> bool: return self.meta.get("check_for_updates", True) def set_update_check(self, on: bool) -> None: self.meta["check_for_updates"] = on def media_syncing_enabled(self) -> bool: return self.profile.get("syncMedia", True) def auto_syncing_enabled(self) -> bool: "True if syncing on startup/shutdown enabled." return self.profile.get("autoSync", True) def sync_auth(self) -> SyncAuth | None: if not (hkey := self.profile.get("syncKey")): return None return SyncAuth( hkey=hkey, endpoint=self.sync_endpoint(), io_timeout_secs=self.network_timeout(), ) def clear_sync_auth(self) -> None: self.set_sync_key(None) self.set_sync_username(None) self.set_host_number(None) self.set_current_sync_url(None) def sync_endpoint(self) -> str | None: return self._current_sync_url() or self.custom_sync_url() or None def _current_sync_url(self) -> str | None: """The last endpoint the server redirected us to.""" return self.profile.get("currentSyncUrl") def set_current_sync_url(self, url: str | None) -> None: self.profile["currentSyncUrl"] = url def middle_click_paste_enabled(self) -> bool: return self.profile.get("middleClickPasteEnabled", True) def set_middle_click_paste_enabled(self, val: bool) -> None: self.profile["middleClickPasteEnabled"] = val def custom_sync_url(self) -> str | None: """A custom server provided by the user.""" return self.profile.get("customSyncUrl") def set_custom_sync_url(self, url: str | None) -> None: if url != self.custom_sync_url(): self.set_current_sync_url(None) self.profile["customSyncUrl"] = url def periodic_sync_media_minutes(self) -> int: return self.profile.get("autoSyncMediaMinutes", 15) def set_periodic_sync_media_minutes(self, val: int) -> None: self.profile["autoSyncMediaMinutes"] = val def show_browser_table_tooltips(self) -> bool: return self.profile.get("browserTableTooltips", True) def set_show_browser_table_tooltips(self, val: bool) -> None: self.profile["browserTableTooltips"] = val def set_network_timeout(self, timeout_secs: int) -> None: self.profile["networkTimeout"] = timeout_secs def network_timeout(self) -> int: return self.profile.get("networkTimeout") or 60 def set_ankihub_token(self, val: str | None) -> None: self.profile["thirdPartyAnkiHubToken"] = val def ankihub_token(self) -> str | None: return self.profile.get("thirdPartyAnkiHubToken") def set_ankihub_username(self, val: str | None) -> None: self.profile["thirdPartyAnkiHubUsername"] = val def ankihub_username(self) -> str | None: return self.profile.get("thirdPartyAnkiHubUsername") def allowed_url_schemes(self) -> list[str]: return self.profile.get("allowedUrlSchemes", []) def set_allowed_url_schemes(self, schemes: list[str]) -> None: self.profile["allowedUrlSchemes"] = schemes def always_allow_scheme(self, scheme: str) -> None: schemes = self.allowed_url_schemes() if scheme not in schemes: schemes.append(scheme) self.set_allowed_url_schemes(schemes) ================================================ FILE: qt/aqt/progress.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import time from collections.abc import Callable from concurrent.futures import Future from dataclasses import dataclass import aqt.forms from anki._legacy import print_deprecation_warning from anki.collection import Progress from aqt.qt import * from aqt.qt import sip from aqt.utils import disable_help_button, tr # Progress info ########################################################################## class ProgressManager: def __init__(self, mw: aqt.AnkiQt) -> None: self.mw = mw self.app = mw.app self.inDB = False self.blockUpdates = False self._show_timer: QTimer | None = None self._busy_cursor_timer: QTimer | None = None self._win: ProgressDialog | None = None self._levels = 0 self._backend_timer: QTimer | None = None # Safer timers ########################################################################## # Custom timers which avoid firing while a progress dialog is active # (likely due to some long-running DB operation) def timer( self, ms: int, func: Callable, repeat: bool, requiresCollection: bool = True, *, parent: QObject | None = None, ) -> QTimer: """Create and start a standard Anki timer. For an alternative see `single_shot()`. If the timer fires while a progress window is shown: - if it is a repeating timer, it will wait the same delay again - if it is non-repeating, it will try again in 100ms If requiresCollection is True, the timer will not fire if the collection has been unloaded. Setting it to False will allow the timer to fire even when there is no collection, but will still only fire when there is no current progress dialog. Issues and alternative --- The created timer will only be destroyed when `parent` is destroyed. This can cause memory leaks, because anything captured by `func` isn't freed either. If there is no QObject that will get destroyed reasonably soon, and you have to pass `mw`, you should call `deleteLater()` on the returned QTimer as soon as it's served its purpose, or use `single_shot()`. Also note that you may not be able to pass an adequate parent, if you want to make a callback after a widget closes. If you passed that widget, the timer would get destroyed before it could fire. """ if parent is None: print_deprecation_warning( "to avoid memory leaks, pass an appropriate parent to progress.timer()" " or use progress.single_shot()" ) parent = self.mw qtimer = QTimer(parent) if not repeat: qtimer.setSingleShot(True) qconnect(qtimer.timeout, self._get_handler(func, repeat, requiresCollection)) qtimer.start(ms) return qtimer def single_shot( self, ms: int, func: Callable[[], None], requires_collection: bool = True, ) -> None: """Create and start a one-off Anki timer. For an alternative and more documentation, see `timer()`. Issues and alternative --- `single_shot()` cleans itself up, so a passed closure won't leak any memory. However, if `func` references a QObject other than `mw`, which gets deleted before the timer fires, an Exception is raised. To avoid this, either use `timer()` passing that object as the parent, or check in `func` with `sip.isdeleted(object)` if it still exists. On the other hand, if a widget is supposed to make an external callback after it closes, you likely want to use `single_shot()`, which will fire even if the calling widget is already destroyed. """ QTimer.singleShot(ms, self._get_handler(func, False, requires_collection)) def _get_handler( self, func: Callable[[], None], repeat: bool, requires_collection: bool ) -> Callable[[], None]: def handler() -> None: if requires_collection and not self.mw.col: # no current collection; timer is no longer valid print(f"Ignored progress func as collection unloaded: {repr(func)}") return if not self._levels: # no current progress; safe to fire func() elif repeat: # skip this time; we'll fire again pass else: # retry in 100ms self.single_shot(100, func, requires_collection) return handler # Creating progress dialogs ########################################################################## def start( self, max: int = 0, min: int = 0, label: str | None = None, parent: QWidget | None = None, immediate: bool = False, title: str = "Anki", ) -> ProgressDialog | None: self._levels += 1 if self._levels > 1: return None # setup window parent = parent or self.app.activeWindow() if not parent and self.mw.isVisible(): parent = self.mw label = label or tr.qt_misc_processing() self._win = ProgressDialog(parent) self._win.form.progressBar.setMinimum(min) self._win.form.progressBar.setMaximum(max) self._win.form.progressBar.setTextVisible(False) self._win.form.label.setText(label) self._win.setWindowTitle(title) self._win.setWindowModality(Qt.WindowModality.ApplicationModal) self._win.setMinimumWidth(300) self._busy_cursor_timer = QTimer(self.mw) self._busy_cursor_timer.setSingleShot(True) self._busy_cursor_timer.start(300) qconnect(self._busy_cursor_timer.timeout, self._set_busy_cursor) self._shown: float = 0 self._counter = min self._min = min self._max = max self._firstTime = time.monotonic() self._show_timer = QTimer(self.mw) self._show_timer.setSingleShot(True) self._show_timer.start(immediate and 100 or 600) qconnect(self._show_timer.timeout, self._on_show_timer) return self._win def start_with_backend_updates( self, progress_update: Callable[[Progress, ProgressUpdate], None], start_label: str | None = None, parent: QWidget | None = None, ) -> None: self._backend_timer = QTimer() self._backend_timer.setSingleShot(False) self._backend_timer.setInterval(100) if not (dialog := self.start(immediate=True, label=start_label, parent=parent)): print("Progress dialog already running; aborting will not work") def on_progress() -> None: assert self.mw user_wants_abort = dialog and dialog.wantCancel or False update = ProgressUpdate(user_wants_abort=user_wants_abort) progress = self.mw.backend.latest_progress() progress_update(progress, update) if update.abort: self.mw.backend.set_wants_abort() if update.has_update(): self.update(label=update.label, value=update.value, max=update.max) qconnect(self._backend_timer.timeout, on_progress) self._backend_timer.start() def update( self, label: str | None = None, value: int | None = None, process: bool = True, maybeShow: bool = True, max: int | None = None, ) -> None: # print("update", label, self._levels, self._min, self._counter, self._max, label, time.monotonic() - self._shown) if not self.mw.inMainThread(): print("progress.update() called on wrong thread") return if maybeShow: self._maybeShow() if not self._shown: return assert self._win is not None if label: self._win.form.label.setText(label) self._max = max or 0 self._win.form.progressBar.setMaximum(self._max) if self._max: self._counter = value if value is not None else (self._counter + 1) self._win.form.progressBar.setValue(self._counter) def finish(self) -> None: def do_window_cleanup(future: Future | None = None): # this method can be called in an async fashion from taskman where a future # is passed or in synchronous manner from the main thread if future is not None: future.result() next_levels = self._levels - 1 next_levels = max(0, next_levels) try: if next_levels == 0: if self._win: self._closeWin() if self._busy_cursor_timer: self._busy_cursor_timer.stop() self._busy_cursor_timer = None self._restore_cursor() if self._show_timer: self._show_timer.stop() self._show_timer = None if self._backend_timer: self._backend_timer.stop() self._backend_timer.deleteLater() self._backend_timer = None except RuntimeError as exc: # during shutdown, the timers may have already been deleted by Qt print(f"do_window_cleanup error ignored: {exc}") self._levels = next_levels # if the window is not currently shown, we can do cleanup immediately, if it is # currently shown then we need to give the window system a half-second to # present the window before we close it again - fixes progress window getting # stuck, especially on ubuntu 16.10+ elapsed_time = time.monotonic() - self._shown time_to_wait = 0.5 - elapsed_time # NOTE: we must not yield control if the window is not shown since we don't want # to expose ourselves to the possibility of something showing the window in the # meantime if (not self._shown) or (time_to_wait <= 0): do_window_cleanup() else: # NOTE: we can't use self.single_shot here since it won't call the callback # until _levels = 0, but if we are in this branch then _levels > 0 self.mw.taskman.run_in_background( lambda time_to_wait=time_to_wait: time.sleep(time_to_wait), do_window_cleanup, uses_collection=False, ) def clear(self) -> None: "Restore the interface after an error." if self._levels: self._levels = 1 self.finish() def _maybeShow(self) -> None: if not self._levels: return if self._shown: return delta = time.monotonic() - self._firstTime if delta > 0.5: self._showWin() def _showWin(self) -> None: assert self._win is not None self._shown = time.monotonic() self._win.show() def _closeWin(self) -> None: # if the parent window has been deleted, the progress dialog may have # already been dropped; delete it if it hasn't been if self._win and not sip.isdeleted(self._win): self._win.cancel() self._win = None self._shown = 0 def _set_busy_cursor(self) -> None: self.mw.app.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor)) def _restore_cursor(self) -> None: self.app.restoreOverrideCursor() def busy(self) -> int: "True if processing." return self._levels def _on_show_timer(self) -> None: if self.mw.app.focusWindow() is None: # if no window is focused (eg app is minimized), defer display assert self._show_timer is not None self._show_timer.start(10) return self._show_timer = None self._showWin() def want_cancel(self) -> bool: win = self._win if win: return win.wantCancel else: return False def set_title(self, title: str) -> None: win = self._win if win: win.setWindowTitle(title) class ProgressDialog(QDialog): def __init__(self, parent: QWidget | None) -> None: QDialog.__init__(self, parent) disable_help_button(self) self.form = aqt.forms.progress.Ui_Dialog() self.form.setupUi(self) self._closingDown = False self.wantCancel = False # required for smooth progress bars self.form.progressBar.setStyleSheet("QProgressBar::chunk { width: 1px; }") def cancel(self) -> None: self._closingDown = True self.hide() self.deleteLater() def closeEvent(self, evt: QCloseEvent | None) -> None: assert evt is not None if self._closingDown: evt.accept() else: self.wantCancel = True evt.ignore() def keyPressEvent(self, evt: QKeyEvent | None) -> None: assert evt is not None if evt.key() == Qt.Key.Key_Escape: evt.ignore() self.wantCancel = True @dataclass class ProgressUpdate: label: str | None = None value: int | None = None max: int | None = None user_wants_abort: bool = False abort: bool = False def has_update(self) -> bool: return self.label is not None or self.value is not None or self.max is not None ================================================ FILE: qt/aqt/props.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """ See pylib/anki/hooks.py """ from __future__ import annotations # You can find the definitions in ../tools/genhooks_gui.py from _aqt.props import * ================================================ FILE: qt/aqt/py.typed ================================================ ================================================ FILE: qt/aqt/qt/__init__.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # make sure not to optimize imports on this file # ruff: noqa: F401 from __future__ import annotations import os import sys import traceback from collections.abc import Callable from typing import TypeVar, Union from anki._legacy import deprecated # legacy code depends on these re-exports from anki.utils import is_mac, is_win from .qt6 import * def debug() -> None: from pdb import set_trace pyqtRemoveInputHook() set_trace() if os.environ.get("DEBUG"): def info(type, value, tb) -> None: # type: ignore for line in traceback.format_exception(type, value, tb): sys.stdout.write(line) pyqtRemoveInputHook() from pdb import pm pm() sys.excepthook = info _version = QLibraryInfo.version() qtmajor = _version.majorVersion() qtminor = _version.minorVersion() qtpoint = _version.microVersion() qtfullversion = _version.segments() if qtmajor == 6 and qtminor < 2: raise Exception("Anki does not support your Qt version.") def qconnect(signal: Callable | pyqtSignal | pyqtBoundSignal, func: Callable) -> None: """Helper to work around type checking not working with signal.connect(func).""" signal.connect(func) # type: ignore _T = TypeVar("_T") @deprecated(info="no longer required, and now a no-op") def without_qt5_compat_wrapper(cls: _T) -> _T: return cls ================================================ FILE: qt/aqt/qt/qt6.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # make sure not to optimize imports on this file # ruff: noqa: F401 """ PyQt6 imports """ from PyQt6 import sip from PyQt6.QtCore import * # conflicting Qt and qFuzzyCompare definitions require an ignore from PyQt6.QtGui import * # type: ignore[no-redef,assignment] from PyQt6.QtNetwork import QLocalServer, QLocalSocket, QNetworkProxy from PyQt6.QtQuick import * from PyQt6.QtWebChannel import QWebChannel from PyQt6.QtWebEngineCore import * from PyQt6.QtWebEngineWidgets import * from PyQt6.QtWidgets import * ================================================ FILE: qt/aqt/reviewer.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import json import random import re from collections.abc import Callable, Generator, Sequence from dataclasses import dataclass from enum import Enum, auto from functools import partial from typing import Any, Literal, Match, Union, cast import aqt import aqt.browser import aqt.operations from anki.cards import Card, CardId from anki.collection import Config, OpChanges, OpChangesWithCount from anki.lang import with_collapsed_whitespace from anki.scheduler.base import ScheduleCardsAsNew from anki.scheduler.v3 import ( CardAnswer, QueuedCards, SchedulingContext, SchedulingStates, SetSchedulingStatesRequest, ) from anki.scheduler.v3 import Scheduler as V3Scheduler from anki.tags import MARKED_TAG from anki.types import assert_exhaustive from anki.utils import is_mac from aqt import AnkiQt, gui_hooks from aqt.browser.card_info import PreviousReviewerCardInfo, ReviewerCardInfo from aqt.deckoptions import confirm_deck_then_display_options from aqt.operations.card import set_card_flag from aqt.operations.note import remove_notes from aqt.operations.scheduling import ( answer_card, bury_cards, bury_notes, forget_cards, set_due_date_dialog, suspend_cards, suspend_note, ) from aqt.operations.tag import add_tags_to_notes, remove_tags_from_notes from aqt.profiles import VideoDriver from aqt.qt import * from aqt.sound import av_player, play_clicked_audio, record_audio from aqt.theme import theme_manager from aqt.toolbar import BottomBar from aqt.utils import ( askUserDialog, downArrow, qtMenuShortcutWorkaround, show_warning, tooltip, tr, ) class RefreshNeeded(Enum): NOTE_TEXT = auto() QUEUES = auto() FLAG = auto() class ReviewerBottomBar: def __init__(self, reviewer: Reviewer) -> None: self.reviewer = reviewer def replay_audio(card: Card, question_side: bool) -> None: if question_side: av_player.play_tags(card.question_av_tags()) else: tags = card.answer_av_tags() if card.replay_question_audio_on_answer_side(): tags = card.question_av_tags() + tags av_player.play_tags(tags) @dataclass class V3CardInfo: """Stores the top of the card queue for the v3 scheduler. This includes current and potential next states of the displayed card, which may be mutated by a user's custom scheduling. """ queued_cards: QueuedCards states: SchedulingStates context: SchedulingContext @staticmethod def from_queue(queued_cards: QueuedCards) -> V3CardInfo: top_card = queued_cards.cards[0] states = top_card.states states.current.custom_data = top_card.card.custom_data return V3CardInfo( queued_cards=queued_cards, states=states, context=top_card.context ) def top_card(self) -> QueuedCards.QueuedCard: return self.queued_cards.cards[0] def counts(self) -> tuple[int, list[int]]: "Returns (idx, counts)." counts = [ self.queued_cards.new_count, self.queued_cards.learning_count, self.queued_cards.review_count, ] card = self.top_card() if card.queue == QueuedCards.NEW: idx = 0 elif card.queue == QueuedCards.LEARNING: idx = 1 else: idx = 2 return idx, counts @staticmethod def rating_from_ease(ease: int) -> CardAnswer.Rating.V: if ease == 1: return CardAnswer.AGAIN elif ease == 2: return CardAnswer.HARD elif ease == 3: return CardAnswer.GOOD else: return CardAnswer.EASY class AnswerAction(Enum): BURY_CARD = 0 ANSWER_AGAIN = 1 ANSWER_GOOD = 2 ANSWER_HARD = 3 SHOW_REMINDER = 4 class QuestionAction(Enum): SHOW_ANSWER = 0 SHOW_REMINDER = 1 class Reviewer: def __init__(self, mw: AnkiQt) -> None: self.mw = mw self.web = mw.web self.card: Card | None = None self.previous_card: Card | None = None self._answeredIds: list[CardId] = [] self._recordedAudio: str | None = None self._combining: bool = True self.typeCorrect: str | None = None # web init happens before this is set self.state: Literal["question", "answer", "transition"] | None = None self._refresh_needed: RefreshNeeded | None = None self._v3: V3CardInfo | None = None self._state_mutation_key = str(random.randint(0, 2**64 - 1)) self.bottom = BottomBar(mw, mw.bottomWeb) self._card_info = ReviewerCardInfo(self.mw) self._previous_card_info = PreviousReviewerCardInfo(self.mw) self._states_mutated = True self._state_mutation_js = None self._reps: int | None = None self._show_question_timer: QTimer | None = None self._show_answer_timer: QTimer | None = None self.auto_advance_enabled = False gui_hooks.av_player_did_end_playing.append(self._on_av_player_did_end_playing) def show(self) -> None: if self.mw.col.sched_ver() == 1 or not self.mw.col.v3_scheduler(): self.mw.moveToState("deckBrowser") show_warning(tr.scheduling_update_required().replace("V2", "v3")) return self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore self.web.set_bridge_command(self._linkHandler, self) self.bottom.web.set_bridge_command(self._linkHandler, ReviewerBottomBar(self)) self._state_mutation_js = self.mw.col.get_config("cardStateCustomizer") self._reps = None self._refresh_needed = RefreshNeeded.QUEUES self.refresh_if_needed() # this is only used by add-ons def lastCard(self) -> Card | None: if self._answeredIds: if not self.card or self._answeredIds[-1] != self.card.id: try: return self.mw.col.get_card(self._answeredIds[-1]) except TypeError: # id was deleted return None return None def cleanup(self) -> None: gui_hooks.reviewer_will_end() self.card = None self.auto_advance_enabled = False def refresh_if_needed(self) -> None: if self._refresh_needed is RefreshNeeded.QUEUES: self.nextCard() self.mw.fade_in_webview() self._refresh_needed = None elif self._refresh_needed is RefreshNeeded.NOTE_TEXT: self._redraw_current_card() self.mw.fade_in_webview() self._refresh_needed = None elif self._refresh_needed is RefreshNeeded.FLAG: self.card.load() self._update_flag_icon() # for when modified in browser self.mw.fade_in_webview() self._refresh_needed = None elif self._refresh_needed: assert_exhaustive(self._refresh_needed) def op_executed( self, changes: OpChanges, handler: object | None, focused: bool ) -> bool: if handler is not self: if changes.study_queues: self._refresh_needed = RefreshNeeded.QUEUES elif changes.note_text: self._refresh_needed = RefreshNeeded.NOTE_TEXT elif changes.card: self._refresh_needed = RefreshNeeded.FLAG if focused and self._refresh_needed: self.refresh_if_needed() return bool(self._refresh_needed) def _redraw_current_card(self) -> None: self.card.load() if self.state == "answer": self._showAnswer() else: self._showQuestion() # Fetching a card ########################################################################## def nextCard(self) -> None: self.previous_card = self.card self.card = None self._v3 = None self._get_next_v3_card() self._previous_card_info.set_card(self.previous_card) self._card_info.set_card(self.card) if not self.card: self.mw.moveToState("overview") return if self._reps is None: self._initWeb() self._showQuestion() def _get_next_v3_card(self) -> None: assert isinstance(self.mw.col.sched, V3Scheduler) output = self.mw.col.sched.get_queued_cards() if not output.cards: return self._v3 = V3CardInfo.from_queue(output) self.card = Card(self.mw.col, backend_card=self._v3.top_card().card) self.card.start_timer() def get_scheduling_states(self) -> SchedulingStates: return self._v3.states def get_scheduling_context(self) -> SchedulingContext: return self._v3.context def set_scheduling_states(self, request: SetSchedulingStatesRequest) -> None: if request.key != self._state_mutation_key: return self._v3.states = request.states def _run_state_mutation_hook(self) -> None: def on_eval(result: Any) -> None: if result is None: # eval failed, usually a syntax error self._states_mutated = True if js := self._state_mutation_js: self._states_mutated = False self.web.evalWithCallback( RUN_STATE_MUTATION.format(key=self._state_mutation_key, js=js), on_eval, ) # Audio ########################################################################## def replayAudio(self) -> None: if self.state == "question": replay_audio(self.card, True) elif self.state == "answer": replay_audio(self.card, False) gui_hooks.audio_will_replay(self.web, self.card, self.state == "question") def _on_av_player_did_end_playing(self, *args) -> None: def task() -> None: if av_player.queue_is_empty(): if ( self._show_question_timer and self._show_question_timer.remainingTime() <= 0 ): self._on_show_question_timeout() elif ( self._show_answer_timer and self._show_answer_timer.remainingTime() <= 0 ): self._on_show_answer_timeout() # Allow time for audio queue to update self.mw.taskman.run_on_main(lambda: self.mw.progress.single_shot(100, task)) # Initializing the webview ########################################################################## def revHtml(self) -> str: extra = self.mw.col.conf.get("reviewExtra", "") fade = "" if self.mw.pm.video_driver() == VideoDriver.Software: fade = "" return f""" {fade}
{extra} """ def _initWeb(self) -> None: self._reps = 0 # main window self.web.stdHtml( self.revHtml(), css=["css/reviewer.css"], js=[ "js/mathjax.js", "js/vendor/mathjax/tex-chtml-full.js", "js/reviewer.js", ], context=self, ) # block default drag & drop behavior while allowing drop events to be received by JS handlers self.web.allow_drops = True self.web.eval("_blockDefaultDragDropBehavior();") # show answer / ease buttons self.bottom.web.stdHtml( self._bottomHTML(), css=["css/toolbar-bottom.css", "css/reviewer-bottom.css"], js=["js/vendor/jquery.min.js", "js/reviewer-bottom.js"], context=ReviewerBottomBar(self), ) # Showing the question ########################################################################## def _mungeQA(self, buf: str) -> str: return self.typeAnsFilter(self.mw.prepare_card_text_for_display(buf)) def _showQuestion(self) -> None: self._reps += 1 self.state = "question" self.typedAnswer: str | None = None c = self.card # grab the question and play audio q = c.question() # play audio? if c.autoplay(): self.web.setPlaybackRequiresGesture(False) sounds = c.question_av_tags() gui_hooks.reviewer_will_play_question_sounds(c, sounds) else: self.web.setPlaybackRequiresGesture(True) sounds = [] gui_hooks.reviewer_will_play_question_sounds(c, sounds) gui_hooks.av_player_will_play_tags(sounds, self.state, self) av_player.play_tags(sounds) # render & update bottom q = self._mungeQA(q) q = gui_hooks.card_will_show(q, c, "reviewQuestion") self._run_state_mutation_hook() bodyclass = theme_manager.body_classes_for_card_ord(c.ord) a = self.mw.col.media.escape_media_filenames(c.answer()) self.web.eval( f"_showQuestion({json.dumps(q)}, {json.dumps(a)}, '{bodyclass}');" ) self._update_flag_icon() self._update_mark_icon() self._showAnswerButton() self.mw.web.setFocus() # user hook gui_hooks.reviewer_did_show_question(c) self._auto_advance_to_answer_if_enabled() def _auto_advance_to_answer_if_enabled(self) -> None: self._clear_auto_advance_timers() if self.auto_advance_enabled: conf = self.mw.col.decks.config_dict_for_deck_id( self.card.current_deck_id() ) if conf["secondsToShowQuestion"]: self._show_answer_timer = self.mw.progress.timer( int(conf["secondsToShowQuestion"] * 1000), self._on_show_answer_timeout, repeat=False, parent=self.mw, ) def _on_show_answer_timeout(self) -> None: if self.card is None: return conf = self.mw.col.decks.config_dict_for_deck_id(self.card.current_deck_id()) if conf["waitForAudio"] and av_player.current_player: return if ( not self.auto_advance_enabled or not self.mw.app.focusWidget() or self.mw.app.focusWidget().window() != self.mw ): self.auto_advance_enabled = False return try: question_action = list(QuestionAction)[conf["questionAction"]] except IndexError: question_action = QuestionAction.SHOW_ANSWER if question_action == QuestionAction.SHOW_ANSWER: self._showAnswer() else: tooltip(tr.studying_question_time_elapsed()) def autoplay(self, card: Card) -> bool: print("use card.autoplay() instead of reviewer.autoplay(card)") return card.autoplay() def _update_flag_icon(self) -> None: self.web.eval(f"_drawFlag({self.card.user_flag()});") def _update_mark_icon(self) -> None: self.web.eval(f"_drawMark({json.dumps(self.card.note().has_tag(MARKED_TAG))});") _drawMark = _update_mark_icon _drawFlag = _update_flag_icon # Showing the answer ########################################################################## def _showAnswer(self) -> None: if self.mw.state != "review": # showing resetRequired screen; ignore space return self.state = "answer" c = self.card a = c.answer() # play audio? if c.autoplay(): sounds = c.answer_av_tags() gui_hooks.reviewer_will_play_answer_sounds(c, sounds) else: sounds = [] gui_hooks.reviewer_will_play_answer_sounds(c, sounds) gui_hooks.av_player_will_play_tags(sounds, self.state, self) av_player.play_tags(sounds) a = self._mungeQA(a) a = gui_hooks.card_will_show(a, c, "reviewAnswer") # render and update bottom self.web.eval(f"_showAnswer({json.dumps(a)});") self._showEaseButtons() self.mw.web.setFocus() # user hook gui_hooks.reviewer_did_show_answer(c) self._auto_advance_to_question_if_enabled() def _auto_advance_to_question_if_enabled(self) -> None: self._clear_auto_advance_timers() if self.auto_advance_enabled: conf = self.mw.col.decks.config_dict_for_deck_id( self.card.current_deck_id() ) if conf["secondsToShowAnswer"]: self._show_question_timer = self.mw.progress.timer( int(conf["secondsToShowAnswer"] * 1000), self._on_show_question_timeout, repeat=False, parent=self.mw, ) def _on_show_question_timeout(self) -> None: if self.card is None: return conf = self.mw.col.decks.config_dict_for_deck_id(self.card.current_deck_id()) if conf["waitForAudio"] and av_player.current_player: return if ( not self.auto_advance_enabled or not self.mw.app.focusWidget() or self.mw.app.focusWidget().window() != self.mw ): self.auto_advance_enabled = False return try: answer_action = list(AnswerAction)[conf["answerAction"]] except IndexError: answer_action = AnswerAction.BURY_CARD if answer_action == AnswerAction.ANSWER_AGAIN: self._answerCard(1) elif answer_action == AnswerAction.ANSWER_HARD: self._answerCard(2) elif answer_action == AnswerAction.ANSWER_GOOD: self._answerCard(3) elif answer_action == AnswerAction.SHOW_REMINDER: tooltip(tr.studying_answer_time_elapsed()) else: self.bury_current_card() # Answering a card ############################################################ def _answerCard(self, ease: Literal[1, 2, 3, 4]) -> None: "Reschedule card and show next." if self.mw.state != "review": # showing resetRequired screen; ignore key return if self.state != "answer": return proceed, ease = gui_hooks.reviewer_will_answer_card( (True, ease), self, self.card ) if not proceed: return sched = cast(V3Scheduler, self.mw.col.sched) answer = sched.build_answer( card=self.card, states=self._v3.states, rating=self._v3.rating_from_ease(ease), ) def after_answer(changes: OpChanges) -> None: if gui_hooks.reviewer_did_answer_card.count() > 0: self.card.load() # v3 scheduler doesn't report this suspended = self.card is not None and self.card.queue < 0 self._after_answering(ease) if sched.state_is_leech(answer.new_state): self.onLeech(suspended) self.state = "transition" answer_card(parent=self.mw, answer=answer).success( after_answer ).run_in_background(initiator=self) def _after_answering(self, ease: Literal[1, 2, 3, 4]) -> None: gui_hooks.reviewer_did_answer_card(self, self.card, ease) self._answeredIds.append(self.card.id) if not self.check_timebox(): self.nextCard() # Handlers ############################################################ def korean_shortcuts( self, ) -> Sequence[tuple[str, Callable] | tuple[Qt.Key, Callable]]: return [ ("ㄷ", self.mw.onEditCurrent), ("ㅡ", self.showContextMenu), ("ㄱ", self.replayAudio), ("Ctrl+Alt+ㅜ", self.forget_current_card), # does not work # ("Ctrl+Alt+ㄷ", self.on_create_copy), # does not work # ("Ctrl+Shift+ㅇ", self.on_set_due), ("ㅍ", self.onReplayRecorded), ("Shift+ㅍ", self.onRecordVoice), ("ㅐ", self.onOptions), ("ㅑ", self.on_card_info), ("Ctrl+Alt+ㅑ", self.on_previous_card_info), ("ㅕ", self.mw.undo), ] def _shortcutKeys( self, ) -> Sequence[tuple[str, Callable] | tuple[Qt.Key, Callable]]: def generate_default_answer_keys() -> Generator[ tuple[str, partial], None, None ]: for ease in aqt.mw.pm.default_answer_keys: key = aqt.mw.pm.get_answer_key(ease) if not key: continue ease = cast(Literal[1, 2, 3, 4], ease) answer_card_according_to_pressed_key = partial(self._answerCard, ease) yield (key, answer_card_according_to_pressed_key) return [ ("e", self.mw.onEditCurrent), (" ", self.onEnterKey), (Qt.Key.Key_Return, self.onEnterKey), (Qt.Key.Key_Enter, self.onEnterKey), ("m", self.showContextMenu), ("r", self.replayAudio), (Qt.Key.Key_F5, self.replayAudio), *( (f"Ctrl+{flag.index}", self.set_flag_func(flag.index)) for flag in self.mw.flags.all() ), ("*", self.toggle_mark_on_current_note), ("=", self.bury_current_note), ("-", self.bury_current_card), ("!", self.suspend_current_note), ("@", self.suspend_current_card), ("Ctrl+Alt+N", self.forget_current_card), ("Ctrl+Alt+E", self.on_create_copy), ("Ctrl+Backspace" if is_mac else "Ctrl+Delete", self.delete_current_note), ("Ctrl+Shift+D", self.on_set_due), ("v", self.onReplayRecorded), ("Shift+v", self.onRecordVoice), ("o", self.onOptions), ("i", self.on_card_info), ("Ctrl+Alt+i", self.on_previous_card_info), *generate_default_answer_keys(), ("u", self.mw.undo), ("5", self.on_pause_audio), ("6", self.on_seek_backward), ("7", self.on_seek_forward), ("Shift+A", self.toggle_auto_advance), *self.korean_shortcuts(), ] def on_pause_audio(self) -> None: av_player.toggle_pause() gui_hooks.audio_did_pause_or_unpause(self.web) seek_secs = 5 def on_seek_backward(self) -> None: av_player.seek_relative(-self.seek_secs) gui_hooks.audio_did_seek_relative(self.web, -self.seek_secs) def on_seek_forward(self) -> None: av_player.seek_relative(self.seek_secs) gui_hooks.audio_did_seek_relative(self.web, self.seek_secs) def onEnterKey(self) -> None: if self.state == "question": self._getTypedAnswer() elif self.state == "answer" and aqt.mw.pm.spacebar_rates_card(): self.bottom.web.evalWithCallback( "selectedAnswerButton()", self._onAnswerButton ) def _onAnswerButton(self, val: str) -> None: # button selected? if val and val in "1234": val2: Literal[1, 2, 3, 4] = int(val) # type: ignore self._answerCard(val2) else: self._answerCard(self._defaultEase()) def _linkHandler(self, url: str) -> None: if url == "ans": self._getTypedAnswer() elif url.startswith("ease"): val: Literal[1, 2, 3, 4] = int(url[4:]) # type: ignore self._answerCard(val) elif url == "edit": self.mw.onEditCurrent() elif url == "more": self.showContextMenu() elif url.startswith("play:"): play_clicked_audio(url, self.card) elif url.startswith("updateToolbar"): self.mw.toolbarWeb.update_background_image() elif url == "statesMutated": self._states_mutated = True else: print("unrecognized anki link:", url) # Type in the answer ########################################################################## typeAnsPat = r"\[\[type:(.+?)\]\]" def typeAnsFilter(self, buf: str) -> str: if self.state == "question": return self.typeAnsQuestionFilter(buf) else: return self.typeAnsAnswerFilter(buf) def typeAnsQuestionFilter(self, buf: str) -> str: self._combining = True self.typeCorrect = None clozeIdx = None m = re.search(self.typeAnsPat, buf) if not m: return buf fld = m.group(1) # if it's a cloze, extract data if fld.startswith("cloze:"): # get field and cloze position clozeIdx = self.card.ord + 1 fld = fld.split(":")[1] if fld.startswith("nc:"): self._combining = False fld = fld.split(":")[1] # loop through fields for a match for f in self.card.note_type()["flds"]: if f["name"] == fld: self.typeCorrect = self.card.note()[f["name"]] if clozeIdx: # narrow to cloze self.typeCorrect = self._contentForCloze(self.typeCorrect, clozeIdx) self.typeFont = f["font"] self.typeSize = f["size"] break if not self.typeCorrect: if self.typeCorrect is None: if clozeIdx: warn = tr.studying_please_run_toolsempty_cards() else: warn = tr.studying_type_answer_unknown_field(val=fld) return re.sub(self.typeAnsPat, warn, buf) else: # empty field, remove type answer pattern return re.sub(self.typeAnsPat, "", buf) return re.sub( self.typeAnsPat, f"""
""", buf, ) def typeAnsAnswerFilter(self, buf: str) -> str: if not self.typeCorrect: return re.sub(self.typeAnsPat, "", buf) m = re.search(self.typeAnsPat, buf) type_pattern = m.group(1) if m else "" orig = buf origSize = len(buf) buf = buf.replace("
", "") hadHR = len(buf) != origSize initial_expected = self.typeCorrect initial_provided = self.typedAnswer expected, provided = gui_hooks.reviewer_will_compare_answer( (initial_expected, initial_provided), type_pattern ) output = self.mw.col.compare_answer(expected, provided, self._combining) output = gui_hooks.reviewer_will_render_compared_answer( output, initial_expected, initial_provided, type_pattern, ) # and update the type answer area def repl(match: Match) -> str: # can't pass a string in directly, and can't use re.escape as it # escapes too much s = """
{}
""".format( self.typeFont, self.typeSize, output, ) if hadHR: # a hack to ensure the q/a separator falls before the answer # comparison when user is using {{FrontSide}} s = f"
{s}" return s if hadHR and not re.search(self.typeAnsPat, buf): return orig return re.sub(self.typeAnsPat, repl, buf) def _contentForCloze(self, txt: str, idx: int) -> str | None: return self.mw.col.extract_cloze_for_typing(txt, idx) or None def _getTypedAnswer(self) -> None: self.web.evalWithCallback("getTypedAnswer();", self._onTypedAnswer) def _onTypedAnswer(self, val: None) -> None: self.typedAnswer = val or "" self._showAnswer() # Bottom bar ########################################################################## def _bottomHTML(self) -> str: return """
""" % dict( edit=tr.studying_edit(), editkey=tr.actions_shortcut_key(val="E"), more=tr.studying_more(), morekey=tr.actions_shortcut_key(val="M"), downArrow=downArrow(), time=self.card.time_taken() // 1000, ) def _showAnswerButton(self) -> None: middle = """ """.format( tr.actions_shortcut_key(val=tr.studying_space()), tr.studying_show_answer(), self._remaining(), ) # wrap it in a table so it has the same top margin as the ease buttons middle = ( "
%s
" % middle ) if self.card.should_show_timer(): maxTime = self.card.time_limit() / 1000 else: maxTime = 0 self.bottom.web.eval("showQuestion(%s,%d);" % (json.dumps(middle), maxTime)) def _showEaseButtons(self) -> None: if not self._states_mutated: self.mw.progress.single_shot(50, self._showEaseButtons) return middle = self._answerButtons() conf = self.mw.col.decks.config_dict_for_deck_id(self.card.current_deck_id()) self.bottom.web.eval( f"showAnswer({json.dumps(middle)}, {json.dumps(conf['stopTimerOnAnswer'])});" ) def _remaining(self) -> str: if not self.mw.col.conf["dueCounts"]: return "" counts: list[int | str] idx, counts_ = self._v3.counts() counts = cast(list[Union[int, str]], counts_) counts[idx] = f"{counts[idx]}" return f""" {counts[0]} + {counts[1]} + {counts[2]} """ def _defaultEase(self) -> Literal[2, 3]: return 3 def _answerButtonList(self) -> tuple[tuple[int, str], ...]: button_count = self.mw.col.sched.answerButtons(self.card) if button_count == 2: buttons_tuple: tuple[tuple[int, str], ...] = ( (1, tr.studying_again()), (2, tr.studying_good()), ) elif button_count == 3: buttons_tuple = ( (1, tr.studying_again()), (2, tr.studying_good()), (3, tr.studying_easy()), ) else: buttons_tuple = ( (1, tr.studying_again()), (2, tr.studying_hard()), (3, tr.studying_good()), (4, tr.studying_easy()), ) buttons_tuple = gui_hooks.reviewer_will_init_answer_buttons( buttons_tuple, self, self.card ) return buttons_tuple def _answerButtons(self) -> str: default = self._defaultEase() assert isinstance(self.mw.col.sched, V3Scheduler) labels = self.mw.col.sched.describe_next_states(self._v3.states) def but(i: int, label: str) -> str: if i == default: extra = """id="defease" """ else: extra = "" due = self._buttonTime(i, v3_labels=labels) key = ( tr.actions_shortcut_key(val=aqt.mw.pm.get_answer_key(i)) if aqt.mw.pm.get_answer_key(i) else "" ) return """ """ % ( extra, key, i, i, label, due, ) buf = "
" for ease, label in self._answerButtonList(): buf += but(ease, label) buf += "
" return buf def _buttonTime(self, i: int, v3_labels: Sequence[str]) -> str: if self.mw.col.conf["estTimes"]: txt = v3_labels[i - 1] return f"""{txt}""" else: return "" # Leeches ########################################################################## def onLeech(self, suspended: bool = False) -> None: # for now s = tr.studying_card_was_a_leech() if suspended: s += f" {tr.studying_it_has_been_suspended()}" tooltip(s) # Timebox ########################################################################## def check_timebox(self) -> bool: "True if answering should be aborted." elapsed = self.mw.col.timeboxReached() if elapsed: assert not isinstance(elapsed, bool) cards_val = elapsed[1] minutes_val = int(round(elapsed[0] / 60)) message = with_collapsed_whitespace( tr.studying_card_studied_in_minute( cards=cards_val, minutes=str(minutes_val) ) ) fin = tr.studying_finish() diag = askUserDialog(message, [tr.studying_continue(), fin]) diag.setIcon(QMessageBox.Icon.Information) if diag.run() == fin: self.mw.moveToState("deckBrowser") return True self.mw.col.startTimebox() return False # Context menu ########################################################################## # note the shortcuts listed here also need to be defined above def _contextMenu(self) -> list[Any]: currentFlag = self.card and self.card.user_flag() opts = [ [ tr.studying_flag_card(), [ [ flag.label, f"Ctrl+{flag.index}", self.set_flag_func(flag.index), dict(checked=currentFlag == flag.index), ] for flag in self.mw.flags.all() ], ], [tr.studying_bury_card(), "-", self.bury_current_card], [ tr.actions_with_ellipsis(action=tr.actions_forget_card()), "Ctrl+Alt+N", self.forget_current_card, ], [ tr.actions_with_ellipsis(action=tr.actions_set_due_date()), "Ctrl+Shift+D", self.on_set_due, ], [tr.actions_suspend_card(), "@", self.suspend_current_card], [tr.actions_options(), "O", self.onOptions], [tr.actions_card_info(), "I", self.on_card_info], [tr.actions_previous_card_info(), "Ctrl+Alt+I", self.on_previous_card_info], None, [tr.studying_mark_note(), "*", self.toggle_mark_on_current_note], [tr.studying_bury_note(), "=", self.bury_current_note], [tr.studying_suspend_note(), "!", self.suspend_current_note], [ tr.actions_with_ellipsis(action=tr.actions_create_copy()), "Ctrl+Alt+E", self.on_create_copy, ], [ tr.studying_delete_note(), "Ctrl+Backspace" if is_mac else "Ctrl+Delete", self.delete_current_note, ], None, [tr.actions_replay_audio(), "R", self.replayAudio], [tr.studying_pause_audio(), "5", self.on_pause_audio], [tr.studying_audio_5s(), "6", self.on_seek_backward], [tr.studying_audio_and5s(), "7", self.on_seek_forward], [tr.studying_record_own_voice(), "Shift+V", self.onRecordVoice], [tr.studying_replay_own_voice(), "V", self.onReplayRecorded], [ tr.actions_auto_advance(), "Shift+A", self.toggle_auto_advance, dict(checked=self.auto_advance_enabled), ], ] return opts def showContextMenu(self) -> None: opts = self._contextMenu() m = QMenu(self.mw) self._addMenuItems(m, opts) gui_hooks.reviewer_will_show_context_menu(self, m) qtMenuShortcutWorkaround(m) m.popup(QCursor.pos()) def _addMenuItems(self, m: QMenu, rows: Sequence) -> None: for row in rows: if not row: m.addSeparator() continue if len(row) == 2: subm = m.addMenu(row[0]) self._addMenuItems(subm, row[1]) qtMenuShortcutWorkaround(subm) continue if len(row) == 4: label, scut, func, opts = row else: label, scut, func = row opts = {} a = m.addAction(label) if scut: a.setShortcut(QKeySequence(scut)) if opts.get("checked"): a.setCheckable(True) a.setChecked(True) qconnect(a.triggered, func) def onOptions(self) -> None: confirm_deck_then_display_options(self.card) def on_previous_card_info(self) -> None: self._previous_card_info.show() def on_card_info(self) -> None: self._card_info.show() def set_flag_on_current_card(self, desired_flag: int) -> None: # need to toggle off? if self.card.user_flag() == desired_flag: flag = 0 else: flag = desired_flag set_card_flag(parent=self.mw, card_ids=[self.card.id], flag=flag).success( lambda _: None ).run_in_background() def set_flag_func(self, desired_flag: int) -> Callable: return lambda: self.set_flag_on_current_card(desired_flag) def toggle_mark_on_current_note(self) -> None: def redraw_mark(out: OpChangesWithCount) -> None: self.card.load() self._update_mark_icon() note = self.card.note() if note.has_tag(MARKED_TAG): remove_tags_from_notes( parent=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG ).success(redraw_mark).run_in_background(initiator=self) else: add_tags_to_notes( parent=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG, ).success(redraw_mark).run_in_background(initiator=self) def on_set_due(self) -> None: if self.mw.state != "review" or not self.card: return if op := set_due_date_dialog( parent=self.mw, card_ids=[self.card.id], config_key=Config.String.SET_DUE_REVIEWER, ): op.run_in_background() def suspend_current_note(self) -> None: gui_hooks.reviewer_will_suspend_note(self.card.nid) suspend_note( parent=self.mw, note_ids=[self.card.nid], ).success(lambda _: tooltip(tr.studying_note_suspended())).run_in_background() def suspend_current_card(self) -> None: gui_hooks.reviewer_will_suspend_card(self.card.id) suspend_cards( parent=self.mw, card_ids=[self.card.id], ).success(lambda _: tooltip(tr.studying_card_suspended())).run_in_background() def bury_current_note(self) -> None: gui_hooks.reviewer_will_bury_note(self.card.nid) bury_notes( parent=self.mw, note_ids=[self.card.nid], ).success( lambda res: tooltip(tr.studying_cards_buried(count=res.count)) ).run_in_background() def bury_current_card(self) -> None: gui_hooks.reviewer_will_bury_card(self.card.id) bury_cards( parent=self.mw, card_ids=[self.card.id], ).success( lambda res: tooltip(tr.studying_cards_buried(count=res.count)) ).run_in_background() def forget_current_card(self) -> None: if op := forget_cards( parent=self.mw, card_ids=[self.card.id], context=ScheduleCardsAsNew.Context.REVIEWER, ): op.run_in_background() def on_create_copy(self) -> None: if self.card: aqt.dialogs.open("AddCards", self.mw).set_note( self.card.note(), self.card.current_deck_id() ) def delete_current_note(self) -> None: # need to check state because the shortcut is global to the main # window if self.mw.state != "review" or not self.card: return remove_notes(parent=self.mw, note_ids=[self.card.nid]).run_in_background() def onRecordVoice(self) -> None: def after_record(path: str) -> None: self._recordedAudio = path self.onReplayRecorded() record_audio(self.mw, self.mw, False, after_record) def onReplayRecorded(self) -> None: self._recordedAudio = gui_hooks.reviewer_will_replay_recording( self._recordedAudio ) if not self._recordedAudio: tooltip(tr.studying_you_havent_recorded_your_voice_yet()) return av_player.play_file(self._recordedAudio) def _clear_auto_advance_timers(self) -> None: if self._show_answer_timer: self._show_answer_timer.deleteLater() self._show_answer_timer = None if self._show_question_timer: self._show_question_timer.deleteLater() self._show_question_timer = None def toggle_auto_advance(self) -> None: self.auto_advance_enabled = not self.auto_advance_enabled if self.auto_advance_enabled: tooltip(tr.actions_auto_advance_activated()) else: tooltip(tr.actions_auto_advance_deactivated()) self.auto_advance_if_enabled() def auto_advance_if_enabled(self) -> None: if self.state == "question": self._auto_advance_to_answer_if_enabled() elif self.state == "answer": self._auto_advance_to_question_if_enabled() # legacy onBuryCard = bury_current_card onBuryNote = bury_current_note onSuspend = suspend_current_note onSuspendCard = suspend_current_card onDelete = delete_current_note onMark = toggle_mark_on_current_note setFlag = set_flag_on_current_card # if the last element is a comment, then the RUN_STATE_MUTATION code # breaks due to the comment wrongly commenting out python code. # To prevent this we put the js code on a separate line RUN_STATE_MUTATION = """ anki.mutateNextCardStates('{key}', async (states, customData, ctx) => {{ {js} }}).finally(() => bridgeCommand('statesMutated')); """ ================================================ FILE: qt/aqt/schema_change_tracker.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import enum from aqt import AnkiQt class Change(enum.Enum): NO_CHANGE = 0 BASIC_CHANGE = 1 SCHEMA_CHANGE = 2 class ChangeTracker: _changed = Change.NO_CHANGE def __init__(self, mw: AnkiQt) -> None: self.mw = mw def mark_basic(self) -> None: if self._changed == Change.NO_CHANGE: self._changed = Change.BASIC_CHANGE def mark_schema(self) -> bool: "If false, processing should be aborted." if self._changed != Change.SCHEMA_CHANGE: if not self.mw.confirm_schema_modification(): return False self._changed = Change.SCHEMA_CHANGE return True def changed(self) -> bool: return self._changed != Change.NO_CHANGE def set_unchanged(self) -> None: self._changed = Change.NO_CHANGE ================================================ FILE: qt/aqt/sound.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import os import os.path import platform import re import subprocess import sys import time import traceback import wave from abc import ABC, abstractmethod from collections.abc import Callable from concurrent.futures import Future from operator import itemgetter from pathlib import Path from typing import Any, cast from markdown import markdown import aqt import aqt.mpv import aqt.qt from anki.cards import Card from anki.sound import AV_REF_RE, AVTag, SoundOrVideoTag from anki.utils import is_lin, is_mac, is_win, namedtmp from aqt import gui_hooks from aqt._macos_helper import macos_helper from aqt.mpv import MPV, MPVBase, MPVCommandError from aqt.qt import * from aqt.taskman import TaskManager from aqt.theme import theme_manager from aqt.utils import ( disable_help_button, restoreGeom, saveGeom, showWarning, startup_info, tooltip, tr, ) # AV player protocol ########################################################################## OnDoneCallback = Callable[[], None] class Player(ABC): @abstractmethod def play(self, tag: AVTag, on_done: OnDoneCallback) -> None: """Play a file. When reimplementing, make sure to call gui_hooks.av_player_did_begin_playing(self, tag) on the main thread after playback begins. """ @abstractmethod def rank_for_tag(self, tag: AVTag) -> int | None: """How suited this player is to playing tag. AVPlayer will choose the player that returns the highest rank for a given tag. If None, this player can not play the tag. """ def stop(self) -> None: """Optional. If implemented, the player must not call on_done() when the audio is stopped.""" def seek_relative(self, secs: int) -> None: "Jump forward or back by secs. Optional." def toggle_pause(self) -> None: "Optional." def shutdown(self) -> None: "Do any cleanup required at program termination. Optional." AUDIO_EXTENSIONS = { "3gp", "flac", "m4a", "mp3", "oga", "ogg", "opus", "spx", "wav", } def is_audio_file(fname: str) -> bool: ext = fname.split(".")[-1].lower() return ext in AUDIO_EXTENSIONS class SoundOrVideoPlayer(Player): default_rank = 0 def rank_for_tag(self, tag: AVTag) -> int | None: if isinstance(tag, SoundOrVideoTag): return self.default_rank else: return None class SoundPlayer(Player): default_rank = 0 def rank_for_tag(self, tag: AVTag) -> int | None: if isinstance(tag, SoundOrVideoTag) and is_audio_file(tag.filename): return self.default_rank else: return None class VideoPlayer(Player): default_rank = 0 def rank_for_tag(self, tag: AVTag) -> int | None: if isinstance(tag, SoundOrVideoTag) and not is_audio_file(tag.filename): return self.default_rank else: return None # Main playing interface ########################################################################## class AVPlayer: players: list[Player] = [] # when a new batch of audio is played, should the currently playing # audio be stopped? interrupt_current_audio = True # caller key for the current playback (optional) current_caller: Any = None # whether the last call to play_file_with_caller interrupted another current_caller_interrupted = False def __init__(self) -> None: self._enqueued: list[AVTag] = [] self.current_player: Player | None = None def play_tags(self, tags: list[AVTag]) -> None: """Clear the existing queue, then start playing provided tags.""" self.clear_queue_and_maybe_interrupt() self._enqueued = tags[:] self._play_next_if_idle() def append_tags(self, tags: list[AVTag]) -> None: """Append provided tags to the queue, then start playing them if the current player is idle.""" self._enqueued.extend(tags) self._play_next_if_idle() def queue_is_empty(self) -> bool: return not bool(self._enqueued) def stop_and_clear_queue(self) -> None: self._enqueued = [] self._stop_if_playing() def stop_and_clear_queue_if_caller(self, caller: Any) -> None: if caller == self.current_caller: self.stop_and_clear_queue() def clear_queue_and_maybe_interrupt(self) -> None: self._enqueued = [] if self.interrupt_current_audio: self._stop_if_playing() def play_file(self, filename: str) -> None: """Play the provided path. SECURITY: Filename may be an arbitrary path. For filenames coming from a collection, you should only ever use the os.path.basename(filename) as the filename.""" self.play_tags([SoundOrVideoTag(filename=filename)]) def play_file_with_caller(self, filename: str, caller: Any) -> None: """Play the provided path, noting down the caller. SECURITY: Filename may be an arbitrary path. For filenames coming from a collection, you should only ever use the os.path.basename(filename) as the filename.""" if self.current_caller: self.current_caller_interrupted = True self.current_caller = caller self.play_file(filename) def insert_file(self, filename: str) -> None: """Place the provided path at the top of the playlist. SECURITY: Filename may be an arbitrary path. For filenames coming from a collection, you should only ever use the os.path.basename(filename) as the filename.""" self._enqueued.insert(0, SoundOrVideoTag(filename=filename)) self._play_next_if_idle() def toggle_pause(self) -> None: if self.current_player: self.current_player.toggle_pause() def seek_relative(self, secs: int) -> None: if self.current_player: self.current_player.seek_relative(secs) def shutdown(self) -> None: self.stop_and_clear_queue() for player in self.players: player.shutdown() self.players.clear() def _stop_if_playing(self) -> None: if self.current_player: self.current_player.stop() def _pop_next(self) -> AVTag | None: if not self._enqueued: return None return self._enqueued.pop(0) def _on_play_finished(self) -> None: if not self.current_caller_interrupted: self.current_caller = None self.current_caller_interrupted = False gui_hooks.av_player_did_end_playing(self.current_player) self.current_player = None self._play_next_if_idle() def _play_next_if_idle(self) -> None: if self.current_player: return next = self._pop_next() if next is not None: self._play(next) def _play(self, tag: AVTag) -> None: best_player = self._best_player_for_tag(tag) if best_player: self.current_player = best_player gui_hooks.av_player_will_play(tag) self.current_player.play(tag, self._on_play_finished) else: tooltip(f"no players found for {tag}") def _best_player_for_tag(self, tag: AVTag) -> Player | None: ranked = [] for p in self.players: rank = p.rank_for_tag(tag) if rank is not None: ranked.append((rank, p)) ranked.sort(key=itemgetter(0)) if ranked: return ranked[-1][1] else: return None av_player = AVPlayer() # Packaged commands ########################################################################## # return modified command array that points to bundled command, and return # required environment def _packagedCmd(cmd: list[str]) -> tuple[Any, dict[str, str]]: cmd = cmd[:] env = os.environ.copy() # keep LD_LIBRARY_PATH when in snap environment if "LD_LIBRARY_PATH" in env and "SNAP" not in env: del env["LD_LIBRARY_PATH"] # Try to find binary in anki-audio package for Windows/Mac if is_win or is_mac: try: import anki_audio audio_pkg_path = Path(anki_audio.__file__).parent if is_win: packaged_path = audio_pkg_path / (cmd[0] + ".exe") else: # is_mac packaged_path = audio_pkg_path / cmd[0] if packaged_path.exists(): cmd[0] = str(packaged_path) return cmd, env except ImportError: # anki-audio not available, fall back to old behavior pass packaged_path = Path(sys.prefix) / cmd[0] if packaged_path.exists(): cmd[0] = str(packaged_path) return cmd, env # Platform hacks ########################################################################## # legacy global for add-ons si = startup_info() # osx throws interrupted system call errors frequently def retryWait(proc: subprocess.Popen) -> int: while 1: try: return proc.wait() except OSError: continue # Simple player implementations ########################################################################## class SimpleProcessPlayer(Player): "A player that invokes a new process for each tag to play." args: list[str] = [] env: dict[str, str] | None = None def __init__(self, taskman: TaskManager, media_folder: str | None = None) -> None: self._taskman = taskman self._media_folder = media_folder self._terminate_flag = False self._process: subprocess.Popen | None = None self._warned_about_missing_player = False def play(self, tag: AVTag, on_done: OnDoneCallback) -> None: self._terminate_flag = False self._taskman.run_in_background( lambda: self._play(tag), lambda res: self._on_done(res, on_done), uses_collection=False, ) def stop(self) -> None: self._terminate_flag = True # note: mplayer implementation overrides this def _play(self, tag: AVTag) -> None: assert isinstance(tag, SoundOrVideoTag) self._process = subprocess.Popen( self.args + ["--", tag.path(self._media_folder)], env=self.env, cwd=self._media_folder, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) self._wait_for_termination(tag) def _wait_for_termination(self, tag: AVTag) -> None: self._taskman.run_on_main( lambda: gui_hooks.av_player_did_begin_playing(self, tag) ) while True: # should we abort playing? if self._terminate_flag: self._process.terminate() self._process.wait(1) try: if self._process.stdin: self._process.stdin.close() except Exception as e: print("unable to close stdin:", e) self._process = None return # wait for completion try: self._process.wait(0.1) if self._process.returncode != 0: print(f"player got return code: {self._process.returncode}") try: if self._process.stdin: self._process.stdin.close() except Exception as e: print("unable to close stdin:", e) self._process = None return except subprocess.TimeoutExpired: # process still running, repeat loop pass def _on_done(self, ret: Future, cb: OnDoneCallback) -> None: try: ret.result() except FileNotFoundError: if not self._warned_about_missing_player: showWarning(tr.media_sound_and_video_on_cards_will()) self._warned_about_missing_player = True # must call cb() here, as we don't currently have another way # to flag to av_player that we've stopped cb() class SimpleMpvPlayer(SimpleProcessPlayer, VideoPlayer): default_rank = 1 args, env = _packagedCmd( [ "mpv", "--no-terminal", "--force-window=no", "--ontop", "--audio-display=no", "--keep-open=no", "--input-media-keys=no", "--autoload-files=no", "--no-ytdl", ] ) def __init__( self, taskman: TaskManager, base_folder: str, media_folder: str ) -> None: super().__init__(taskman, media_folder) self.args += [f"--config-dir={base_folder}"] class SimpleMplayerPlayer(SimpleProcessPlayer, SoundOrVideoPlayer): args, env = _packagedCmd(["mplayer", "-really-quiet", "-noautosub"]) if is_win: args += ["-ao", "win32"] # MPV ########################################################################## class MpvManager(MPV, SoundOrVideoPlayer): if not is_lin: default_argv = MPVBase.default_argv + [ "--input-media-keys=no", ] def __init__(self, base_path: str, media_folder: str) -> None: self.media_folder = media_folder mpvPath, self.popenEnv = _packagedCmd(["mpv"]) self.executable = mpvPath[0] self._on_done: OnDoneCallback | None = None self.default_argv += [f"--config-dir={base_path}"] super().__init__(window_id=None, debug=False) def on_init(self) -> None: # if mpv dies and is restarted, tell Anki the # current file is done if self._on_done: self._on_done() m = re.search(r"(\d+)\.(\d+)\.(\d+)", self.get_property("mpv-version")) if m: self.mpv_version = (int(m[1]), int(m[2]), int(m[3])) else: self.mpv_version = None try: self.command("keybind", "q", "stop") self.command("keybind", "Q", "stop") self.command("keybind", "CLOSE_WIN", "stop") self.command("keybind", "ctrl+w", "stop") self.command("keybind", "ctrl+c", "stop") except MPVCommandError: print("mpv too old for key rebinding") def play(self, tag: AVTag, on_done: OnDoneCallback) -> None: assert isinstance(tag, SoundOrVideoTag) self._on_done = on_done path = tag.path(self.media_folder) if self.mpv_version is None or self.mpv_version >= (0, 38, 0): self.command("loadfile", path, "replace", -1, "pause=no") else: self.command("loadfile", path, "replace", "pause=no") gui_hooks.av_player_did_begin_playing(self, tag) def stop(self) -> None: self.command("stop") def toggle_pause(self) -> None: self.command("cycle", "pause") def seek_relative(self, secs: int) -> None: self.command("seek", secs, "relative") def on_property_idle_active(self, value: bool) -> None: if value and self._on_done: from aqt import mw mw.taskman.run_on_main(self._on_done) def shutdown(self) -> None: self.close() # Legacy, not used ################################################## togglePause = toggle_pause seekRelative = seek_relative def queueFile(self, file: str) -> None: return def clearQueue(self) -> None: return # Mplayer in slave mode ########################################################################## class SimpleMplayerSlaveModePlayer(SimpleMplayerPlayer): def __init__(self, taskman: TaskManager, media_folder: str) -> None: self.media_folder = media_folder super().__init__(taskman, media_folder) self.args.append("-slave") def _play(self, tag: AVTag) -> None: assert isinstance(tag, SoundOrVideoTag) self._process = subprocess.Popen( self.args + ["--", tag.path(self.media_folder)], env=self.env, cwd=self.media_folder, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, startupinfo=startup_info(), ) self._wait_for_termination(tag) def command(self, *args: Any) -> None: """Send a command over the slave interface. The trailing newline is automatically added.""" str_args = [str(x) for x in args] if self._process: self._process.stdin.write(" ".join(str_args).encode("utf8") + b"\n") self._process.stdin.flush() def seek_relative(self, secs: int) -> None: self.command("seek", secs, 0) def toggle_pause(self) -> None: self.command("pause") # MP3 transcoding ########################################################################## def _encode_mp3(src_wav: str, dst_mp3: str) -> None: cmd = ["lame", src_wav, dst_mp3, "--noreplaygain", "--quiet"] cmd, env = _packagedCmd(cmd) try: retcode = retryWait(subprocess.Popen(cmd, startupinfo=startup_info(), env=env)) except Exception as e: raise Exception(tr.media_error_running(val=" ".join(cmd))) from e if retcode != 0: raise Exception(tr.media_error_running(val=" ".join(cmd))) os.unlink(src_wav) def encode_mp3(mw: aqt.AnkiQt, src_wav: str, on_done: Callable[[str], None]) -> None: "Encode the provided wav file to .mp3, and call on_done() with the path." dst_mp3 = src_wav.replace(".wav", "%d.mp3" % time.time()) def _on_done(fut: Future) -> None: if exc := fut.exception(): print(exc) showWarning(tr.editing_couldnt_record_audio_have_you_installed()) return on_done(dst_mp3) mw.taskman.run_in_background( lambda: _encode_mp3(src_wav, dst_mp3), _on_done, uses_collection=False ) # Recording interface ########################################################################## class Recorder(ABC): # seconds to wait before recording STARTUP_DELAY = 0.3 def __init__(self, output_path: str) -> None: self.output_path = output_path def start(self, on_done: Callable[[], None]) -> None: "Start recording, then call on_done() when started." self._started_at = time.time() on_done() def stop(self, on_done: Callable[[str], None]) -> None: "Stop recording, then call on_done() when finished." on_done(self.output_path) def duration(self) -> float: "Seconds since recording started." return time.time() - self._started_at def on_timer(self) -> None: "Will be called periodically." # QAudioInput recording ########################################################################## class QtAudioInputRecorder(Recorder): def __init__(self, output_path: str, mw: aqt.AnkiQt, parent: QWidget) -> None: super().__init__(output_path) self.mw = mw self._parent = parent from PyQt6.QtMultimedia import QAudioSource, QMediaDevices # type: ignore # Get the default audio input device device = QMediaDevices.defaultAudioInput() # Try to use Int16 format first (avoids conversion) preferred_format = device.preferredFormat() int16_format = preferred_format int16_format.setSampleFormat(preferred_format.SampleFormat.Int16) if device.isFormatSupported(int16_format): # Use Int16 if supported format = int16_format else: # Fall back to device's preferred format format = preferred_format # Create the audio source with the chosen format source = QAudioSource(device, format, parent) # Store the actual format being used self._format = source.format() self._audio_input = source def _convert_float_to_int16(self, float_buffer: bytearray) -> bytes: """Convert float32 audio samples to int16 format for WAV output.""" import struct float_count = len(float_buffer) // 4 # 4 bytes per float32 floats = struct.unpack(f"{float_count}f", float_buffer) # Convert to int16 range, clipping and scaling in one step int16_samples = [ max(-32768, min(32767, int(max(-1.0, min(1.0, f)) * 32767))) for f in floats ] return struct.pack(f"{len(int16_samples)}h", *int16_samples) def start(self, on_done: Callable[[], None]) -> None: self._iodevice = self._audio_input.start() self._buffer = bytearray() qconnect(self._iodevice.readyRead, self._on_read_ready) super().start(on_done) def _on_read_ready(self) -> None: self._buffer.extend(cast(bytes, self._iodevice.readAll())) def stop(self, on_done: Callable[[str], None]) -> None: from PyQt6.QtMultimedia import QAudio def on_stop_timer() -> None: # read anything remaining in buffer & stop self._on_read_ready() self._audio_input.stop() if (err := self._audio_input.error()) != QAudio.Error.NoError: showWarning(f"recording failed: {err}") return def write_file() -> None: from PyQt6.QtMultimedia import QAudioFormat # swallow the first 300ms to allow audio device to quiesce bytes_per_frame = self._format.bytesPerFrame() frames_to_skip = int(self._format.sampleRate() * self.STARTUP_DELAY) bytes_to_skip = frames_to_skip * bytes_per_frame if len(self._buffer) <= bytes_to_skip: return self._buffer = self._buffer[bytes_to_skip:] # Check if we need to convert float samples to int16 if self._format.sampleFormat() == QAudioFormat.SampleFormat.Float: audio_data = self._convert_float_to_int16(self._buffer) sample_width = 2 # int16 is 2 bytes else: # For integer formats, use the data as-is audio_data = bytes(self._buffer) sample_width = self._format.bytesPerSample() # write out the wave file with the correct format parameters wf = wave.open(self.output_path, "wb") wf.setnchannels(self._format.channelCount()) wf.setsampwidth(sample_width) wf.setframerate(self._format.sampleRate()) wf.writeframes(audio_data) wf.close() def and_then(fut: Future) -> None: fut.result() Recorder.stop(self, on_done) self.mw.taskman.run_in_background( write_file, and_then, uses_collection=False ) # schedule the stop for half a second in the future, # to avoid truncating the end of the recording self._stop_timer = t = QTimer(self._parent) t.timeout.connect(on_stop_timer) # type: ignore t.setSingleShot(True) t.start(500) # Native macOS recording ########################################################################## class NativeMacRecorder(Recorder): def __init__(self, output_path: str) -> None: super().__init__(output_path) self._error: str | None = None def _on_error(self, msg: str) -> None: self._error = msg def start(self, on_done: Callable[[], None]) -> None: self._error = None assert macos_helper macos_helper.start_wav_record(self.output_path, self._on_error) super().start(on_done) def stop(self, on_done: Callable[[str], None]) -> None: assert macos_helper macos_helper.end_wav_record() Recorder.stop(self, on_done) # Recording dialog ########################################################################## class RecordDialog(QDialog): _recorder: Recorder def __init__( self, parent: QWidget, mw: aqt.AnkiQt, on_success: Callable[[str], None], ): QDialog.__init__(self, parent) self._parent = parent self.mw = mw self._on_success = on_success disable_help_button(self) self._start_recording() self._setup_dialog() def _setup_dialog(self) -> None: self.setWindowTitle("Anki") icon = QLabel() qicon = theme_manager.icon_from_resources("icons:media-record.svg") icon.setPixmap(qicon.pixmap(60, 60)) self.label = QLabel("...") hbox = QHBoxLayout() hbox.addWidget(icon) hbox.addWidget(self.label) v = QVBoxLayout() v.addLayout(hbox) buts = ( QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel ) b = QDialogButtonBox(buts) # type: ignore v.addWidget(b) self.setLayout(v) save_button = b.button(QDialogButtonBox.StandardButton.Save) save_button.setDefault(True) save_button.setAutoDefault(True) qconnect(save_button.clicked, self.accept) cancel_button = b.button(QDialogButtonBox.StandardButton.Cancel) cancel_button.setDefault(False) cancel_button.setAutoDefault(False) qconnect(cancel_button.clicked, self.reject) restoreGeom(self, "audioRecorder2") self.show() def _save_diag(self) -> None: saveGeom(self, "audioRecorder2") def _start_recording(self) -> None: if macos_helper and platform.machine() == "arm64": self._recorder = NativeMacRecorder( namedtmp("rec.wav"), ) else: self._recorder = QtAudioInputRecorder( namedtmp("rec.wav"), self.mw, self._parent ) self._recorder.start(self._start_timer) def _start_timer(self) -> None: self._timer = t = QTimer(self._parent) t.timeout.connect(self._on_timer) # type: ignore t.setSingleShot(False) t.start(100) def _on_timer(self) -> None: self._recorder.on_timer() duration = self._recorder.duration() self.label.setText(tr.media_recordingtime(secs=f"{duration:0.1f}")) def accept(self) -> None: self._timer.stop() try: self._save_diag() self._recorder.stop(self._on_success) finally: QDialog.accept(self) def reject(self) -> None: self._timer.stop() def cleanup(out: str) -> None: os.unlink(out) try: self._recorder.stop(cleanup) finally: QDialog.reject(self) def record_audio( parent: QWidget, mw: aqt.AnkiQt, encode: bool, on_done: Callable[[str], None] ) -> None: def after_record(path: str) -> None: if not encode: on_done(path) else: encode_mp3(mw, path, on_done) try: _diag = RecordDialog(parent, mw, after_record) except Exception as e: err_str = str(e) showWarning(markdown(tr.qt_misc_unable_to_record(error=err_str))) # Legacy audio interface ########################################################################## # these will be removed in the future def clearAudioQueue() -> None: av_player.stop_and_clear_queue() def play(filename: str) -> None: av_player.play_file(filename) def playFromText(text: Any) -> None: print("playFromText() deprecated") # legacy globals _player = play _queueEraser = clearAudioQueue mpvManager: MpvManager | None = None # add everything from this module into anki.sound for backwards compat _exports = [i for i in locals().items() if not i[0].startswith("__")] for k, v in _exports: sys.modules["anki.sound"].__dict__[k] = v # Tag handling ########################################################################## def av_refs_to_play_icons(text: str) -> str: """Add play icons into the HTML. When clicked, the icon will call eg pycmd('play:q:1'). """ def repl(match: re.Match) -> str: return f""" """ return AV_REF_RE.sub(repl, text) def play_clicked_audio(pycmd: str, card: Card) -> None: """eg. if pycmd is 'play:q:0', play the first audio on the question side.""" play, context, str_idx = pycmd.split(":") idx = int(str_idx) if context == "q": tags = card.question_av_tags() else: tags = card.answer_av_tags() av_player.play_tags([tags[idx]]) # Init defaults ########################################################################## def setup_audio(taskman: TaskManager, base_folder: str, media_folder: str) -> None: # legacy global var global mpvManager try: mpvManager = MpvManager(base_folder, media_folder) except FileNotFoundError: print("mpv not found, reverting to mplayer") except aqt.mpv.MPVProcessError: print(traceback.format_exc()) print("mpv too old or failed to open, reverting to mplayer") if mpvManager is not None: av_player.players.append(mpvManager) if is_win: mpvPlayer = SimpleMpvPlayer(taskman, base_folder, media_folder) av_player.players.append(mpvPlayer) else: mplayer = SimpleMplayerSlaveModePlayer(taskman, media_folder) av_player.players.append(mplayer) # tts support if is_mac: from aqt.tts import MacTTSPlayer av_player.players.append(MacTTSPlayer(taskman)) elif is_win: from aqt.tts import WindowsTTSPlayer av_player.players.append(WindowsTTSPlayer(taskman)) if platform.release() == "10": from aqt.tts import WindowsRTTTSFilePlayer # If Windows 10, ensure it's October 2018 update or later if int(platform.version().split(".")[-1]) >= 17763: av_player.players.append(WindowsRTTTSFilePlayer(taskman)) def cleanup_audio() -> None: av_player.shutdown() ================================================ FILE: qt/aqt/stats.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import time from collections.abc import Callable from typing import Any import aqt import aqt.forms import aqt.main from anki.decks import DeckId from anki.utils import is_mac from aqt import gui_hooks from aqt.operations.deck import set_current_deck from aqt.qt import * from aqt.theme import theme_manager from aqt.utils import ( disable_help_button, getSaveFile, maybeHideClose, restoreGeom, saveGeom, tooltip, tr, ) from aqt.webview import LegacyStatsWebView class NewDeckStats(QDialog): """New deck stats.""" def __init__(self, mw: aqt.main.AnkiQt) -> None: QDialog.__init__(self, mw, Qt.WindowType.Window) mw.garbage_collect_on_dialog_finish(self) self.mw = mw self.name = "deckStats" self.period = 0 self.form = aqt.forms.stats.Ui_Dialog() self.oldPos = None self.wholeCollection = False self.setMinimumWidth(700) disable_help_button(self) f = self.form f.setupUi(self) f.groupBox.setVisible(False) f.groupBox_2.setVisible(False) if not is_mac: f.horizontalLayout_4.setContentsMargins(0, 0, 0, 0) restoreGeom(self, self.name, default_size=(800, 800)) from aqt.deckchooser import DeckChooser self.deck_chooser = DeckChooser( self.mw, f.deckArea, on_deck_changed=self.on_deck_changed, dyn=True, # include filtered decks ) b = f.buttonBox.addButton( tr.statistics_save_pdf(), QDialogButtonBox.ButtonRole.ActionRole ) assert b is not None qconnect(b.clicked, self.saveImage) b.setAutoDefault(False) b = f.buttonBox.button(QDialogButtonBox.StandardButton.Close) assert b is not None b.setAutoDefault(False) maybeHideClose(self.form.buttonBox) gui_hooks.stats_dialog_will_show(self) self.form.web.hide_while_preserving_layout() self.show() self.refresh() self.form.web.set_bridge_command(self._on_bridge_cmd, self) self.activateWindow() def reject(self) -> None: self.deck_chooser.cleanup() self.form.web.cleanup() self.form.web = None # type: ignore saveGeom(self, self.name) aqt.dialogs.markClosed("NewDeckStats") QDialog.reject(self) def closeWithCallback(self, callback: Callable[[], None]) -> None: self.reject() callback() def on_deck_changed(self, deck_id: int) -> None: set_current_deck(parent=self, deck_id=DeckId(deck_id)).success( lambda _: self.refresh() ).run_in_background() def _imagePath(self) -> str | None: name = time.strftime("-%Y-%m-%d@%H-%M-%S.pdf", time.localtime(time.time())) name = f"anki-{tr.statistics_stats()}{name}" file = getSaveFile( self, title=tr.statistics_save_pdf(), dir_description="stats", key="stats", ext=".pdf", fname=name, ) return file def saveImage(self) -> None: path = self._imagePath() if not path: return # When scrolled down in dark mode, the top of the page in the # final PDF will have a white background, making the text and graphs # unreadable. A simple fix for now is to scroll to the top of the # page first. def after_scroll(arg: Any) -> None: form_web_page = self.form.web.page() assert form_web_page is not None form_web_page.printToPdf(path) tooltip(tr.statistics_saved()) self.form.web.evalWithCallback("window.scrollTo(0, 0);", after_scroll) # legacy add-ons def changePeriod(self, n: Any) -> None: pass def changeScope(self, type: Any) -> None: pass def _on_bridge_cmd(self, cmd: str) -> bool: if cmd.startswith("browserSearch"): _, query = cmd.split(":", 1) browser = aqt.dialogs.open("Browser", self.mw) browser.search_for(query) return False def refresh(self) -> None: self.form.web.load_sveltekit_page("graphs") class DeckStats(QDialog): """Legacy deck stats, used by some add-ons.""" def __init__(self, mw: aqt.main.AnkiQt) -> None: QDialog.__init__(self, mw, Qt.WindowType.Window) mw.garbage_collect_on_dialog_finish(self) self.mw = mw self.name = "deckStats" self.period = 0 self.form = aqt.forms.stats.Ui_Dialog() # Hack: Switch out web views dynamically to avoid maintaining multiple # Qt forms for different versions of the stats dialog. self.form.web = LegacyStatsWebView(self.mw) self.oldPos = None self.wholeCollection = False self.setMinimumWidth(700) disable_help_button(self) f = self.form if theme_manager.night_mode and not theme_manager.macos_dark_mode(): # the grouping box renders incorrectly in the fusion theme. 5.9+ # 5.13 behave differently to 5.14, but it looks bad in either case, # and adjusting the top margin makes the 'save PDF' button show in # the wrong place, so for now we just disable the border instead self.setStyleSheet("QGroupBox { border: 0; }") f.setupUi(self) restoreGeom(self, self.name) b = f.buttonBox.addButton( tr.statistics_save_pdf(), QDialogButtonBox.ButtonRole.ActionRole ) assert b is not None qconnect(b.clicked, self.saveImage) b.setAutoDefault(False) qconnect(f.groups.clicked, lambda: self.changeScope("deck")) f.groups.setShortcut("g") qconnect(f.all.clicked, lambda: self.changeScope("collection")) qconnect(f.month.clicked, lambda: self.changePeriod(0)) qconnect(f.year.clicked, lambda: self.changePeriod(1)) qconnect(f.life.clicked, lambda: self.changePeriod(2)) maybeHideClose(self.form.buttonBox) gui_hooks.stats_dialog_old_will_show(self) self.show() self.refresh() self.activateWindow() def reject(self) -> None: self.form.web.cleanup() self.form.web = None # type: ignore saveGeom(self, self.name) aqt.dialogs.markClosed("DeckStats") QDialog.reject(self) def closeWithCallback(self, callback: Callable[[], None]) -> None: self.reject() callback() def _imagePath(self) -> str | None: name = time.strftime("-%Y-%m-%d@%H-%M-%S.pdf", time.localtime(time.time())) name = f"anki-{tr.statistics_stats()}{name}" file = getSaveFile( self, title=tr.statistics_save_pdf(), dir_description="stats", key="stats", ext=".pdf", fname=name, ) return file def saveImage(self) -> None: path = self._imagePath() if not path: return form_web_page = self.form.web.page() assert form_web_page is not None form_web_page.printToPdf(path) tooltip(tr.statistics_saved()) def changePeriod(self, n: int) -> None: self.period = n self.refresh() def changeScope(self, type: str) -> None: self.wholeCollection = type == "collection" self.refresh() def refresh(self) -> None: self.mw.progress.start(parent=self) stats = self.mw.col.stats() stats.wholeCollection = self.wholeCollection self.report = stats.report(type=self.period) self.form.web.stdHtml( f"{self.report}", js=["js/vendor/jquery.min.js", "js/vendor/plot.js"], context=self, ) self.mw.progress.finish() ================================================ FILE: qt/aqt/studydeck.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from collections.abc import Callable import aqt import aqt.forms import aqt.operations from anki.collection import OpChangesWithId from anki.decks import DeckId from aqt import gui_hooks from aqt.operations.deck import add_deck_dialog from aqt.qt import * from aqt.utils import ( HelpPage, HelpPageArgument, disable_help_button, openHelp, restoreGeom, saveGeom, shortcut, showInfo, tr, ) class StudyDeck(QDialog): def __init__( self, mw: aqt.AnkiQt, names: Callable[[], list[str]] | None = None, accept: str | None = None, title: str | None = None, help: HelpPageArgument = HelpPage.KEYBOARD_SHORTCUTS, current: str | None = None, cancel: bool = True, parent: QWidget | None = None, dyn: bool = False, buttons: list[str | QPushButton] | None = None, geomKey: str = "default", callback: Callable[[StudyDeck], None] | None = None, ) -> None: super().__init__(parent) if not parent: mw.garbage_collect_on_dialog_finish(self) self.mw = mw self.form = aqt.forms.studydeck.Ui_Dialog() self.form.setupUi(self) self.form.filter.installEventFilter(self) gui_hooks.state_did_reset.append(self.onReset) self.geomKey = f"studyDeck-{geomKey}" restoreGeom(self, self.geomKey) disable_help_button(self) if not cancel: self.form.buttonBox.removeButton( self.form.buttonBox.button(QDialogButtonBox.StandardButton.Cancel) ) if buttons is not None: for button_or_label in buttons: self.form.buttonBox.addButton( button_or_label, QDialogButtonBox.ButtonRole.ActionRole ) else: b = QPushButton(tr.actions_add()) b.setShortcut(QKeySequence("Ctrl+N")) b.setToolTip(shortcut(tr.decks_add_new_deck_ctrlandn())) self.form.buttonBox.addButton(b, QDialogButtonBox.ButtonRole.ActionRole) qconnect(b.clicked, self.onAddDeck) if title: self.setWindowTitle(title) if not names: names_ = [ d.name for d in self.mw.col.decks.all_names_and_ids( include_filtered=dyn, skip_empty_default=True ) ] self.nameFunc = None self.origNames = names_ else: self.nameFunc = names self.origNames = names() self.name: str | None = None self.form.buttonBox.addButton( accept or tr.decks_study(), QDialogButtonBox.ButtonRole.AcceptRole ) self.setModal(True) qconnect(self.form.buttonBox.helpRequested, lambda: openHelp(help)) qconnect(self.form.filter.textEdited, self.redraw) qconnect(self.form.list.itemDoubleClicked, self.accept) qconnect(self.finished, self.on_finished) self.form.filter.setFocus() self.show() # redraw after show so position at center correct self.redraw("", current) self.callback = callback if callback: self.show() else: self.exec() def eventFilter(self, obj: QObject | None, evt: QEvent | None) -> bool: if isinstance(evt, QKeyEvent) and evt.type() == QEvent.Type.KeyPress: new_row = current_row = self.form.list.currentRow() rows_count = self.form.list.count() key = evt.key() if key == Qt.Key.Key_Up: new_row = current_row - 1 elif key == Qt.Key.Key_Down: new_row = current_row + 1 elif ( evt.modifiers() & Qt.KeyboardModifier.ControlModifier and Qt.Key.Key_1 <= key <= Qt.Key.Key_9 ): row_index = key - Qt.Key.Key_1 if row_index < rows_count: new_row = row_index if rows_count: new_row %= rows_count # don't let row index overflow/underflow if new_row != current_row: self.form.list.setCurrentRow(new_row) return True return False def redraw(self, filt: str, focus: str | None = None) -> None: self.filt = filt self.focus = focus self.names = [n for n in self.origNames if self._matches(n, filt)] l = self.form.list l.clear() l.addItems(self.names) if focus in self.names: idx = self.names.index(focus) else: idx = 0 l.setCurrentRow(idx) l.scrollToItem(l.item(idx), QAbstractItemView.ScrollHint.PositionAtCenter) def _matches(self, name: str, filt: str) -> bool: name = name.lower() filt = filt.lower() if not filt: return True for word in filt.split(" "): if word not in name: return False return True def onReset(self) -> None: # model updated? if self.nameFunc: self.origNames = self.nameFunc() self.redraw(self.filt, self.focus) def accept(self) -> None: row = self.form.list.currentRow() if row < 0: showInfo(tr.decks_please_select_something()) return self.name = self.names[self.form.list.currentRow()] self.accept_with_callback() def accept_with_callback(self) -> None: if self.callback: self.callback(self) super().accept() def onAddDeck(self) -> None: row = self.form.list.currentRow() if row < 0: default = self.form.filter.text() else: default = self.names[self.form.list.currentRow()] def success(out: OpChangesWithId) -> None: deck = self.mw.col.decks.get(DeckId(out.id)) assert deck is not None self.name = deck["name"] self.accept_with_callback() if diag := add_deck_dialog(parent=self, default_text=default): diag.success(success).run_in_background() def on_finished(self) -> None: saveGeom(self, self.geomKey) gui_hooks.state_did_reset.remove(self.onReset) ================================================ FILE: qt/aqt/stylesheets.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from anki.utils import is_mac, is_win from aqt import colors, props from aqt.theme import ThemeManager def button_gradient(start: str, end: str) -> str: return f""" qlineargradient( spread:pad, x1:0.5, y1:0, x2:0.5, y2:1, stop:0 {start}, stop:1 {end} ); """ def button_pressed_gradient(start: str, end: str, shadow: str) -> str: return f""" qlineargradient( spread:pad, x1:0.5, y1:0, x2:0.5, y2:1, stop:0 {shadow}, stop:0.1 {start}, stop:0.9 {end}, stop:1 {shadow} ); """ def button_layout(tm: ThemeManager): # https://doc.qt.io/qt-6/stylesheet-reference.html#button-layout if is_win: return 0 elif is_mac: return 1 # on linux, use non-default layout if available if tm._default_button_layout: return tm._default_button_layout # fallback to GnomeLayout return 3 class CustomStyles: def general(self, tm: ThemeManager) -> str: return f""" QFrame, QWidget {{ background: none; }} QPushButton, QComboBox, QSpinBox, QDateTimeEdit, QLineEdit, QListWidget, QTreeWidget, QListView, QTextEdit, QPlainTextEdit {{ border: 1px solid {tm.var(colors.BORDER_SUBTLE)}; border-radius: {tm.var(props.BORDER_RADIUS)}; }} QLineEdit, QTextEdit, QPlainTextEdit, QDateTimeEdit, QListWidget, QTreeWidget, QListView {{ background: {tm.var(colors.CANVAS_CODE)}; }} QLineEdit, QTextEdit, QPlainTextEdit, QDateTimeEdit {{ padding: 2px; }} QSpinBox:focus, QDateTimeEdit:focus, QLineEdit:focus, QTextEdit:editable:focus, QPlainTextEdit:editable:focus, QWidget:editable:focus {{ border-color: {tm.var(colors.BORDER_FOCUS)}; }} QPushButton {{ margin-top: 1px; }} QPushButton, QComboBox, QSpinBox {{ padding: 2px 6px; }} QGroupBox {{ text-align: center; font-weight: bold; border: 1px solid {tm.var(colors.BORDER_SUBTLE)}; padding: 0.75em 0 0.75em 0; background: {tm.var(colors.CANVAS_ELEVATED)}; border-radius: {tm.var(props.BORDER_RADIUS)}; margin-top: 10px; }} QGroupBox#preview_box, QGroupBox#template_box {{ background: none; border: none; }} QGroupBox::title {{ subcontrol-origin: margin; subcontrol-position: top left; margin: 0 2px; left: 15px; }} QGroupBox#preview_box::title, QGroupBox#template_box::title {{ margin-top: 5px; left: 5px; }} QLabel:disabled {{ color: {tm.var(colors.FG_DISABLED)}; }} QToolTip {{ color: {tm.var(colors.FG)}; background-color: {tm.var(colors.CANVAS)}; }} """ def menu(self, tm: ThemeManager) -> str: return f""" QMenuBar {{ border-bottom: 1px solid {tm.var(colors.BORDER_SUBTLE)}; }} QMenuBar::item {{ background-color: transparent; padding: 2px 4px; border-radius: {tm.var(props.BORDER_RADIUS)}; }} QMenuBar::item:selected {{ background-color: {tm.var(colors.CANVAS_ELEVATED)}; }} QMenu {{ background-color: {tm.var(colors.CANVAS_OVERLAY)}; border: 1px solid {tm.var(colors.BORDER_SUBTLE)}; padding: 4px; }} QMenu::item {{ background-color: transparent; padding: 3px 14px; margin-bottom: 4px; }} QMenu::item:selected {{ background-color: {tm.var(colors.HIGHLIGHT_BG)}; border-radius: {tm.var(props.BORDER_RADIUS)}; }} QMenu::separator {{ height: 1px; background: {tm.var(colors.BORDER_SUBTLE)}; margin: 0 8px 4px 8px; }} QMenu::indicator {{ border: 1px solid {tm.var(colors.BORDER)}; margin-{tm.left()}: 6px; margin-{tm.right()}: -6px; }} """ def button(self, tm: ThemeManager) -> str: # For some reason, Windows needs a larger padding to look the same button_pad = 25 if is_win else 15 return f""" QPushButton {{ padding-left: {button_pad}px; padding-right: {button_pad}px; }} QPushButton, QTabBar::tab:!selected, QComboBox:!editable, QComboBox::drop-down:editable {{ background: {tm.var(colors.BUTTON_BG)}; border-bottom: 1px solid {tm.var(colors.SHADOW)}; }} QPushButton:default {{ border: 1px solid {tm.var(colors.BORDER_FOCUS)}; }} QPushButton {{ margin: 1px; }} QPushButton:focus, QPushButton:default:hover {{ border: 2px solid {tm.var(colors.BORDER_FOCUS)}; outline: none; margin: 0px; }} QPushButton:hover, QTabBar::tab:hover, QComboBox:!editable:hover, QSpinBox::up-button:hover, QSpinBox::down-button:hover, QDateTimeEdit::up-button:hover, QDateTimeEdit::down-button:hover {{ background: { button_gradient( tm.var(colors.BUTTON_GRADIENT_START), tm.var(colors.BUTTON_GRADIENT_END), ) }; }} QPushButton:pressed, QPushButton:checked, QSpinBox::up-button:pressed, QSpinBox::down-button:pressed, QDateTimeEdit::up-button:pressed, QDateTimeEdit::down-button:pressed {{ background: { button_pressed_gradient( tm.var(colors.BUTTON_GRADIENT_START), tm.var(colors.BUTTON_GRADIENT_END), tm.var(colors.SHADOW), ) }; }} QPushButton:flat {{ border: none; }} QDialogButtonBox {{ button-layout: {button_layout(tm)}; }} """ def splitter(self, tm: ThemeManager) -> str: return f""" QSplitter::handle, QMainWindow::separator {{ height: 16px; }} QSplitter::handle:vertical, QMainWindow::separator:horizontal {{ image: url({tm.themed_icon("mdi:drag-horizontal-FG_SUBTLE")}); }} QSplitter::handle:horizontal, QMainWindow::separator:vertical {{ image: url({tm.themed_icon("mdi:drag-vertical-FG_SUBTLE")}); }} """ def combobox(self, tm: ThemeManager) -> str: return f""" QComboBox {{ padding: {"1px 6px 2px 4px" if tm.rtl() else "1px 4px 2px 6px"}; }} QComboBox:focus {{ border-color: {tm.var(colors.BORDER_FOCUS)}; }} QComboBox:editable:on, QComboBox:editable:focus, QComboBox::drop-down:focus:editable, QComboBox::drop-down:pressed {{ border-color: {tm.var(colors.BORDER_FOCUS)}; }} QComboBox:on {{ border-bottom: none; border-bottom-right-radius: 0; border-bottom-left-radius: 0; }} QComboBox::item {{ color: {tm.var(colors.FG)}; background: {tm.var(colors.CANVAS_ELEVATED)}; }} QComboBox::item:selected {{ background: {tm.var(colors.HIGHLIGHT_BG)}; color: {tm.var(colors.HIGHLIGHT_FG)}; }} QComboBox::item::icon:selected {{ position: absolute; }} QComboBox::drop-down {{ subcontrol-origin: border; padding: 2px; padding-left: 4px; padding-right: 4px; width: 16px; subcontrol-position: top right; border: 1px solid {tm.var(colors.BORDER_SUBTLE)}; border-top-{tm.right()}-radius: {tm.var(props.BORDER_RADIUS)}; border-bottom-{tm.right()}-radius: {tm.var(props.BORDER_RADIUS)}; }} QComboBox::drop-down:!editable {{ background: none; border-color: transparent; }} QComboBox::down-arrow {{ image: url({tm.themed_icon("mdi:chevron-down")}); }} QComboBox::down-arrow:disabled {{ image: url({tm.themed_icon("mdi:chevron-down-FG_DISABLED")}); }} QComboBox::drop-down:hover:editable {{ background: { button_gradient( tm.var(colors.BUTTON_GRADIENT_START), tm.var(colors.BUTTON_GRADIENT_END), ) }; }} """ def tabwidget(self, tm: ThemeManager) -> str: return f""" QTabWidget {{ border-radius: {tm.var(props.BORDER_RADIUS)}; background: none; }} QTabWidget::pane {{ top: -15px; padding-top: 1em; background: {tm.var(colors.CANVAS_ELEVATED)}; border: 1px solid {tm.var(colors.BORDER_SUBTLE)}; border-radius: {tm.var(props.BORDER_RADIUS)}; }} QTabWidget::tab-bar {{ alignment: center; }} QTabBar::tab {{ background: none; padding: 4px 8px; min-width: 8ex; }} QTabBar::tab {{ border: 1px solid {tm.var(colors.BORDER_SUBTLE)}; border-bottom-color: {tm.var(colors.SHADOW)}; }} QTabBar::tab:first {{ border-top-{tm.left()}-radius: {tm.var(props.BORDER_RADIUS)}; border-bottom-{tm.left()}-radius: {tm.var(props.BORDER_RADIUS)}; }} QTabBar::tab:!first {{ margin-{tm.left()}: -1px; }} QTabBar::tab:last {{ border-top-{tm.right()}-radius: {tm.var(props.BORDER_RADIUS)}; border-bottom-{tm.right()}-radius: {tm.var(props.BORDER_RADIUS)}; }} QTabBar::tab:selected {{ color: white; background: {tm.var(colors.BUTTON_PRIMARY_BG)}; }} QTabBar::tab:selected:hover {{ background: { button_gradient( tm.var(colors.BUTTON_PRIMARY_GRADIENT_START), tm.var(colors.BUTTON_PRIMARY_GRADIENT_END), ) }; }} QTabBar::tab:focus {{ outline: none; }} QTabBar::tab:disabled, QTabBar::tab:disabled:hover {{ background: {tm.var(colors.BUTTON_DISABLED)}; color: {tm.var(colors.FG_DISABLED)}; }} QTabBar::tab:selected:disabled, QTabBar::tab:selected:hover:disabled {{ background: {tm.var(colors.BUTTON_PRIMARY_DISABLED)}; }} """ def table(self, tm: ThemeManager) -> str: return f""" QTableView {{ border-radius: {tm.var(props.BORDER_RADIUS)}; border-{tm.left()}: 1px solid {tm.var(colors.BORDER_SUBTLE)}; border-bottom: 1px solid {tm.var(colors.BORDER_SUBTLE)}; border-bottom-left-radius: 0; border-bottom-right-radius: 0; gridline-color: {tm.var(colors.BORDER_SUBTLE)}; selection-background-color: {tm.var(colors.SELECTED_BG)}; selection-color: {tm.var(colors.SELECTED_FG)}; background: {tm.var(colors.CANVAS_CODE)}; }} QHeaderView {{ background: {tm.var(colors.CANVAS)}; }} QHeaderView::section {{ padding-{tm.left()}: 0px; padding-{tm.right()}: 15px; border: 1px solid {tm.var(colors.BORDER_SUBTLE)}; background: {tm.var(colors.BUTTON_BG)}; }} QHeaderView::section:first {{ margin-left: -1px; }} QHeaderView::section:pressed, QHeaderView::section:pressed:!first {{ background: { button_pressed_gradient( tm.var(colors.BUTTON_GRADIENT_START), tm.var(colors.BUTTON_GRADIENT_END), tm.var(colors.SHADOW), ) } }} QHeaderView::section:hover {{ background: { button_gradient( tm.var(colors.BUTTON_GRADIENT_START), tm.var(colors.BUTTON_GRADIENT_END), ) }; }} QHeaderView::section:first {{ border-left: 1px solid {tm.var(colors.BORDER_SUBTLE)}; border-top-left-radius: {tm.var(props.BORDER_RADIUS)}; }} QHeaderView::section:!first {{ border-left: none; }} QHeaderView::section:last {{ border-right: 1px solid {tm.var(colors.BORDER_SUBTLE)}; border-top-right-radius: {tm.var(props.BORDER_RADIUS)}; }} QHeaderView::section:only-one {{ border-left: 1px solid {tm.var(colors.BORDER_SUBTLE)}; border-right: 1px solid {tm.var(colors.BORDER_SUBTLE)}; border-top-left-radius: {tm.var(props.BORDER_RADIUS)}; border-top-right-radius: {tm.var(props.BORDER_RADIUS)}; }} QHeaderView::up-arrow, QHeaderView::down-arrow {{ width: 20px; height: 20px; margin-{tm.left()}: -20px; }} QHeaderView::up-arrow {{ image: url({tm.themed_icon("mdi:menu-up")}); }} QHeaderView::down-arrow {{ image: url({tm.themed_icon("mdi:menu-down")}); }} """ def spinbox(self, tm: ThemeManager) -> str: return f""" QSpinBox::up-button, QSpinBox::down-button, QDateTimeEdit::up-button, QDateTimeEdit::down-button {{ subcontrol-origin: border; width: 16px; margin: 1px; }} QSpinBox::up-button, QDateTimeEdit::up-button {{ margin-bottom: -1px; subcontrol-position: top right; border-top-{tm.right()}-radius: {tm.var(props.BORDER_RADIUS)}; }} QSpinBox::down-button, QDateTimeEdit::down-button {{ margin-top: -1px; subcontrol-position: bottom right; border-bottom-{tm.right()}-radius: {tm.var(props.BORDER_RADIUS)}; }} QSpinBox::up-arrow, QDateTimeEdit::up-arrow {{ image: url({tm.themed_icon("mdi:chevron-up")}); }} QSpinBox::down-arrow, QDateTimeEdit::down-arrow {{ image: url({tm.themed_icon("mdi:chevron-down")}); }} QSpinBox::up-arrow, QSpinBox::down-arrow, QSpinBox::up-arrow:pressed, QSpinBox::down-arrow:pressed, QSpinBox::up-arrow:disabled:hover, QSpinBox::up-arrow:off:hover, QSpinBox::down-arrow:disabled:hover, QSpinBox::down-arrow:off:hover, QDateTimeEdit::up-arrow, QDateTimeEdit::down-arrow, QDateTimeEdit::up-arrow:pressed, QDateTimeEdit::down-arrow:pressed, QDateTimeEdit::up-arrow:disabled:hover, QDateTimeEdit::up-arrow:off:hover, QDateTimeEdit::down-arrow:disabled:hover, QDateTimeEdit::down-arrow:off:hover {{ width: 16px; height: 16px; }} QSpinBox::up-arrow:hover, QSpinBox::down-arrow:hover, QDateTimeEdit::up-arrow:hover, QDateTimeEdit::down-arrow:hover {{ width: 20px; height: 20px; }} QSpinBox::up-arrow:disabled, QSpinBox::up-arrow:off, QDateTimeEdit::up-arrow:disabled, QDateTimeEdit::up-arrow:off {{ image: url({tm.themed_icon("mdi:chevron-up-FG_DISABLED")}); }} QSpinBox::down-arrow:disabled, QSpinBox::down-arrow:off, QDateTimeEdit::down-arrow:disabled, QDateTimeEdit::down-arrow:off {{ image: url({tm.themed_icon("mdi:chevron-down-FG_DISABLED")}); }} """ def checkbox(self, tm: ThemeManager) -> str: return f""" QCheckBox, QRadioButton {{ spacing: 8px; margin: 2px 0; }} QCheckBox::indicator, QRadioButton::indicator, QMenu::indicator {{ border: 1px solid {tm.var(colors.BORDER)}; border-radius: {tm.var(props.BORDER_RADIUS)}; background: {tm.var(colors.CANVAS_ELEVATED)}; width: 16px; height: 16px; }} QRadioButton::indicator, QMenu::indicator:exclusive {{ border-radius: 8px; }} QCheckBox::indicator:focus:!disabled, QCheckBox::indicator:hover:!disabled, QCheckBox::indicator:checked:hover:!disabled, QRadioButton::indicator:focus:!disabled, QRadioButton::indicator:hover:!disabled, QRadioButton::indicator:checked::!disabled {{ border: 2px solid {tm.var(colors.BORDER_STRONG)}; width: 14px; height: 14px; }} QCheckBox::indicator:checked, QRadioButton::indicator:checked, QMenu::indicator:checked {{ image: url({tm.themed_icon("mdi:check")}); }} QRadioButton::indicator:checked {{ image: url({tm.themed_icon("mdi:circle-medium")}); }} QCheckBox::indicator:indeterminate {{ image: url({tm.themed_icon("mdi:minus-thick")}); }} QCheckBox:disabled, QRadioButton:disabled {{ color: {tm.var(colors.FG_DISABLED)}; }} QCheckBox::indicator:disabled, QRadioButton::indicator:disabled, QMenu:indicator:disabled {{ color: {tm.var(colors.FG_DISABLED)}; border-color: {tm.var(colors.FG_DISABLED)}; }} QCheckBox::indicator:checked:disabled, QRadioButton::indicator:checked:disabled, QMenu::indicator:checked:disabled {{ image: url({tm.themed_icon("mdi:check-FG_DISABLED")}); }} QRadioButton::indicator:checked:disabled {{ image: url({tm.themed_icon("mdi:circle-medium-FG_DISABLED")}); }} QCheckBox::indicator:indeterminate:disabled {{ image: url({tm.themed_icon("mdi:minus-thick-FG_DISABLED")}); }} """ def scrollbar(self, tm: ThemeManager) -> str: return f""" QAbstractScrollArea::corner {{ background: none; border: none; }} QScrollBar {{ subcontrol-origin: content; background-color: transparent; }} QScrollBar::handle {{ border-radius: {tm.var(props.BORDER_RADIUS)}; background-color: {tm.var(colors.SCROLLBAR_BG)}; }} QScrollBar::handle:hover {{ background-color: {tm.var(colors.SCROLLBAR_BG_HOVER)}; }} QScrollBar::handle:pressed {{ background-color: {tm.var(colors.SCROLLBAR_BG_ACTIVE)}; }} QScrollBar:horizontal {{ height: 12px; }} QScrollBar::handle:horizontal {{ min-width: 60px; }} QScrollBar:vertical {{ width: 12px; }} QScrollBar::handle:vertical {{ min-height: 60px; }} QScrollBar::add-line {{ border: none; background: none; }} QScrollBar::sub-line {{ border: none; background: none; }} """ def slider(self, tm: ThemeManager) -> str: return f""" QSlider::horizontal {{ height: 20px; }} QSlider::vertical {{ width: 20px; }} QSlider::groove {{ border: 1px solid {tm.var(colors.BORDER_SUBTLE)}; border-radius: 3px; background: {tm.var(colors.CANVAS_ELEVATED)}; }} QSlider::sub-page {{ background: {tm.var(colors.BUTTON_PRIMARY_GRADIENT_START)}; border-radius: 3px; margin: 1px; }} QSlider::sub-page:disabled {{ background: {tm.var(colors.BUTTON_DISABLED)}; }} QSlider::add-page {{ margin-{tm.right()}: 2px; }} QSlider::groove:vertical {{ width: 6px; }} QSlider::groove:horizontal {{ height: 6px; }} QSlider::handle {{ background: {tm.var(colors.BUTTON_BG)}; border: 1px solid {tm.var(colors.BORDER)}; border-radius: 9px; width: 18px; height: 18px; border-bottom-color: {tm.var(colors.SHADOW)}; }} QSlider::handle:vertical {{ margin: 0 -7px; }} QSlider::handle:horizontal {{ margin: -7px 0; }} QSlider::handle:hover {{ background: { button_gradient( tm.var(colors.BUTTON_GRADIENT_START), tm.var(colors.BUTTON_GRADIENT_END), ) } }} """ custom_styles = CustomStyles() ================================================ FILE: qt/aqt/switch.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from typing import cast from aqt import colors, props from aqt.qt import * from aqt.theme import theme_manager class Switch(QAbstractButton): """A horizontal slider to toggle between two states which can be denoted by strings and/or QIcons. The left state is the default and corresponds to isChecked()=False. The suppoorted slots are toggle(), for an animated transition, and setChecked(). """ def __init__( self, radius: int = 10, left_label: str = "", right_label: str = "", left_color: dict[str, str] | None = None, right_color: dict[str, str] | None = None, parent: QWidget | None = None, ) -> None: super().__init__(parent=parent) self.setCheckable(True) super().setChecked(False) self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) self._left_label = left_label self._right_label = right_label self._left_color = left_color if left_color else colors.ACCENT_CARD self._right_color = right_color if right_color else colors.ACCENT_NOTE self._path_radius = radius self._knob_radius = radius - 2 self._label_padding = 4 self._left_knob_position = self._position = radius self._right_knob_position = self.width() - self._path_radius self._left_label_position = self._label_padding / 2 self._right_label_position = 2 * self._knob_radius self._hide_label: bool = False @pyqtProperty(int) # type: ignore def position(self) -> int: return self._position @position.setter # type: ignore def position(self, position: int) -> None: self._position = position self.update() @property def start_position(self) -> int: return ( self._left_knob_position if self.isChecked() else self._right_knob_position ) @property def end_position(self) -> int: return ( self._right_knob_position if self.isChecked() else self._left_knob_position ) @property def label(self) -> str: return self._right_label if self.isChecked() else self._left_label @property def path_color(self) -> QColor: color = self._right_color if self.isChecked() else self._left_color return theme_manager.qcolor(color) @property def label_width(self) -> int: font = QFont() font.setPixelSize(int(self._knob_radius)) font.setWeight(QFont.Weight.Bold) fm = QFontMetrics(font) return ( max( fm.horizontalAdvance(self._left_label), fm.horizontalAdvance(self._right_label), ) + 2 * self._label_padding ) def width(self) -> int: return self.label_width + 2 * self._path_radius def height(self) -> int: return 2 * self._path_radius def sizeHint(self) -> QSize: return QSize( self.width(), self.height(), ) def setChecked(self, checked: bool) -> None: super().setChecked(checked) self._position = self.end_position self.update() def paintEvent(self, _event: QPaintEvent | None) -> None: painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) painter.setPen(Qt.PenStyle.NoPen) self._paint_path(painter) if not self._hide_label: self._paint_label(painter) self._paint_knob(painter) def _current_path_rectangle(self) -> QRectF: return QRectF( 0, 0, self.width(), self.height(), ) def _current_label_rectangle(self) -> QRectF: return QRectF( ( self._left_label_position if self.isChecked() else self._right_label_position ), 0, self.label_width, self.height(), ) def _current_knob_rectangle(self) -> QRectF: return QRectF( self.position - self._knob_radius, # type: ignore 2, 2 * self._knob_radius, 2 * self._knob_radius, ) def _paint_path(self, painter: QPainter) -> None: painter.setBrush(QBrush(self.path_color)) painter.drawRoundedRect( self._current_path_rectangle(), self._path_radius, self._path_radius ) def _paint_knob(self, painter: QPainter) -> None: color = theme_manager.qcolor(colors.BUTTON_GRADIENT_START) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(color)) painter.drawEllipse(self._current_knob_rectangle()) def _paint_label(self, painter: QPainter) -> None: painter.setPen(theme_manager.qcolor(colors.CANVAS)) font = painter.font() font.setPixelSize(int(self._knob_radius)) font.setWeight(QFont.Weight.Bold) painter.setFont(font) painter.drawText( self._current_label_rectangle(), Qt.AlignmentFlag.AlignCenter, self.label ) def mouseReleaseEvent(self, event: QMouseEvent | None) -> None: super().mouseReleaseEvent(event) assert event is not None if event.button() == Qt.MouseButton.LeftButton: self._animate_toggle() def enterEvent(self, event: QEnterEvent | None) -> None: self.setCursor(Qt.CursorShape.PointingHandCursor) super().enterEvent(event) def toggle(self) -> None: super().toggle() self._animate_toggle() def _animate_toggle(self) -> None: animation = QPropertyAnimation(self, cast(QByteArray, b"position"), self) animation.setDuration(int(theme_manager.var(props.TRANSITION))) animation.setStartValue(self.start_position) animation.setEndValue(self.end_position) # hide label during animation self._hide_label = True self.update() def on_animation_finished() -> None: self._hide_label = False self.update() qconnect(animation.finished, on_animation_finished) # make triggered events execute first so the animation runs smoothly afterwards QTimer.singleShot(50, animation.start) ================================================ FILE: qt/aqt/sync.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import functools import os from collections.abc import Callable from concurrent.futures import Future import aqt import aqt.main from anki.errors import Interrupted, SyncError, SyncErrorKind from anki.lang import without_unicode_isolation from anki.sync import SyncOutput, SyncStatus from anki.sync_pb2 import SyncAuth from anki.utils import plat_desc from aqt import gui_hooks from aqt.qt import ( QDialog, QDialogButtonBox, QGridLayout, QLabel, QLineEdit, Qt, QTimer, QVBoxLayout, qconnect, ) from aqt.utils import ( ask_user_dialog, disable_help_button, show_warning, showText, showWarning, tooltip, tr, ) def get_sync_status( mw: aqt.main.AnkiQt, callback: Callable[[SyncStatus], None] ) -> None: auth = mw.pm.sync_auth() if not auth: callback(SyncStatus(required=SyncStatus.NO_CHANGES)) return def on_future_done(fut: Future[SyncStatus]) -> None: try: out = fut.result() except Exception as e: # swallow errors print("sync status check failed:", str(e)) return if out.new_endpoint: mw.pm.set_current_sync_url(out.new_endpoint) callback(out) mw.taskman.run_in_background( lambda: mw.col.sync_status(auth), on_future_done, # The check quickly releases the collection, and we don't need to block other callers uses_collection=False, ) def handle_sync_error(mw: aqt.main.AnkiQt, err: Exception) -> None: if isinstance(err, SyncError): if err.kind is SyncErrorKind.AUTH: mw.pm.clear_sync_auth() elif isinstance(err, Interrupted): # no message to show return show_warning(str(err), parent=mw) def on_normal_sync_timer(mw: aqt.main.AnkiQt) -> None: progress = mw.col.latest_progress() if not progress.HasField("normal_sync"): return sync_progress = progress.normal_sync mw.progress.update( label=f"{sync_progress.added}\n{sync_progress.removed}", process=False, ) mw.progress.set_title(sync_progress.stage) if mw.progress.want_cancel(): mw.col.abort_sync() def sync_collection(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None: auth = mw.pm.sync_auth() if not auth: raise Exception("expected auth") def on_timer() -> None: on_normal_sync_timer(mw) timer = QTimer(mw) qconnect(timer.timeout, on_timer) timer.start(150) def on_future_done(fut: Future[SyncOutput]) -> None: # scheduler version may have changed mw.col._load_scheduler() timer.stop() try: out = fut.result() except Exception as err: handle_sync_error(mw, err) return on_done() mw.pm.set_host_number(out.host_number) if out.new_endpoint: mw.pm.set_current_sync_url(out.new_endpoint) if out.server_message: showText(out.server_message, parent=mw) if out.required == out.NO_CHANGES: tooltip(parent=mw, msg=tr.sync_collection_complete()) # all done; track media progress mw.media_syncer.start_monitoring() return on_done() else: full_sync(mw, out, on_done) mw.taskman.with_progress( lambda: mw.col.sync_collection(auth, mw.pm.media_syncing_enabled()), on_future_done, label=tr.sync_checking(), immediate=True, title=tr.sync_checking(), ) def full_sync( mw: aqt.main.AnkiQt, out: SyncOutput, on_done: Callable[[], None] ) -> None: server_usn = out.server_media_usn if mw.pm.media_syncing_enabled() else None if out.required == out.FULL_DOWNLOAD: confirm_full_download(mw, server_usn, on_done) elif out.required == out.FULL_UPLOAD: confirm_full_upload(mw, server_usn, on_done) else: button_labels: list[str] = [ tr.sync_upload_to_ankiweb(), tr.sync_download_from_ankiweb(), tr.sync_cancel_button(), ] def callback(choice: int) -> None: if choice == 0: full_upload(mw, server_usn, on_done) elif choice == 1: full_download(mw, server_usn, on_done) else: on_done() ask_user_dialog( tr.sync_conflict_explanation2(), callback=callback, buttons=button_labels, default_button=2, parent=mw, textFormat=Qt.TextFormat.MarkdownText, title=tr.qt_misc_sync(), ) def confirm_full_download( mw: aqt.main.AnkiQt, server_usn: int | None, on_done: Callable[[], None] ) -> None: # confirmation step required, as some users customize their notetypes # in an empty collection, then want to upload them def callback(choice: int) -> None: if choice: on_done() else: mw.closeAllWindows(lambda: full_download(mw, server_usn, on_done)) ask_user_dialog( tr.sync_confirm_empty_download(), callback=callback, default_button=0, parent=mw ) def confirm_full_upload( mw: aqt.main.AnkiQt, server_usn: int | None, on_done: Callable[[], None] ) -> None: # confirmation step required, as some users have reported an upload # happening despite having their AnkiWeb collection not being empty # (not reproducible - maybe a compiler bug?) def callback(choice: int) -> None: if choice: on_done() else: mw.closeAllWindows(lambda: full_upload(mw, server_usn, on_done)) ask_user_dialog( tr.sync_confirm_empty_upload(), callback=callback, default_button=0, parent=mw ) def on_full_sync_timer(mw: aqt.main.AnkiQt, label: str) -> None: progress = mw.col.latest_progress() if not progress.HasField("full_sync"): return sync_progress = progress.full_sync # If we've reached total, show the "checking" label if sync_progress.transferred == sync_progress.total: label = tr.sync_checking() total = sync_progress.total transferred = sync_progress.transferred # Scale both to kilobytes with floor division max_for_bar = total // 1024 value_for_bar = transferred // 1024 mw.progress.update( value=value_for_bar, max=max_for_bar, process=False, label=label, ) if mw.progress.want_cancel(): mw.col.abort_sync() def full_download( mw: aqt.main.AnkiQt, server_usn: int | None, on_done: Callable[[], None] ) -> None: label = tr.sync_downloading_from_ankiweb() def on_timer() -> None: on_full_sync_timer(mw, label) timer = QTimer(mw) qconnect(timer.timeout, on_timer) timer.start(150) # hook needs to be called early, on the main thread gui_hooks.collection_will_temporarily_close(mw.col) def download() -> None: mw.create_backup_now() mw.col.close_for_full_sync() mw.col.full_upload_or_download( auth=mw.pm.sync_auth(), server_usn=server_usn, upload=False ) def on_future_done(fut: Future) -> None: timer.stop() mw.reopen(after_full_sync=True) mw.reset() try: fut.result() except Exception as err: handle_sync_error(mw, err) mw.media_syncer.start_monitoring() return on_done() mw.taskman.with_progress( download, on_future_done, ) def full_upload( mw: aqt.main.AnkiQt, server_usn: int | None, on_done: Callable[[], None] ) -> None: gui_hooks.collection_will_temporarily_close(mw.col) mw.col.close_for_full_sync() label = tr.sync_uploading_to_ankiweb() def on_timer() -> None: on_full_sync_timer(mw, label) timer = QTimer(mw) qconnect(timer.timeout, on_timer) timer.start(150) def on_future_done(fut: Future) -> None: timer.stop() mw.reopen(after_full_sync=True) mw.reset() try: fut.result() except Exception as err: handle_sync_error(mw, err) return on_done() mw.media_syncer.start_monitoring() return on_done() mw.taskman.with_progress( lambda: mw.col.full_upload_or_download( auth=mw.pm.sync_auth(), server_usn=server_usn, upload=True ), on_future_done, ) def sync_login( mw: aqt.main.AnkiQt, on_success: Callable[[], None], username: str = "", password: str = "", ) -> None: def on_future_done(fut: Future[SyncAuth], username: str, password: str) -> None: try: auth = fut.result() except SyncError as e: if e.kind is SyncErrorKind.AUTH: showWarning(str(e)) sync_login(mw, on_success, username, password) else: handle_sync_error(mw, e) return except Exception as err: handle_sync_error(mw, err) return mw.pm.set_sync_key(auth.hkey) mw.pm.set_sync_username(username) on_success() def callback(username: str, password: str) -> None: if not username and not password: return if username and password: mw.taskman.with_progress( lambda: mw.col.sync_login( username=username, password=password, endpoint=mw.pm.sync_endpoint() ), functools.partial(on_future_done, username=username, password=password), parent=mw, ) else: sync_login(mw, on_success, username, password) get_id_and_pass_from_user(mw, callback, username, password) def get_id_and_pass_from_user( mw: aqt.main.AnkiQt, callback: Callable[[str, str], None], username: str = "", password: str = "", ) -> None: diag = QDialog(mw) diag.setWindowTitle(tr.qt_misc_sync()) disable_help_button(diag) diag.setWindowModality(Qt.WindowModality.WindowModal) vbox = QVBoxLayout() info_label = QLabel( without_unicode_isolation( tr.sync_account_required(link="https://ankiweb.net/account/register") ) ) info_label.setOpenExternalLinks(True) info_label.setWordWrap(True) vbox.addWidget(info_label) vbox.addSpacing(20) g = QGridLayout() l1 = QLabel(tr.sync_ankiweb_id_label()) g.addWidget(l1, 0, 0) user = QLineEdit() user.setText(username) g.addWidget(user, 0, 1) l1.setBuddy(user) l2 = QLabel(tr.sync_password_label()) g.addWidget(l2, 1, 0) passwd = QLineEdit() passwd.setText(password) passwd.setEchoMode(QLineEdit.EchoMode.Password) g.addWidget(passwd, 1, 1) l2.setBuddy(passwd) vbox.addLayout(g) bb = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) # type: ignore ok_button = bb.button(QDialogButtonBox.StandardButton.Ok) assert ok_button is not None ok_button.setAutoDefault(True) qconnect(bb.accepted, diag.accept) qconnect(bb.rejected, diag.reject) vbox.addWidget(bb) diag.setLayout(vbox) diag.adjustSize() diag.show() user.setFocus() def on_finished(result: int) -> None: if result == QDialog.DialogCode.Rejected: callback("", "") else: callback(user.text().strip(), passwd.text()) qconnect(diag.finished, on_finished) diag.open() # export platform version to syncing code os.environ["PLATFORM"] = plat_desc() ================================================ FILE: qt/aqt/tagedit.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import re from collections.abc import Iterable from anki.collection import Collection from aqt import gui_hooks from aqt.qt import * from aqt.qt import sip class TagEdit(QLineEdit): _completer: QCompleter | TagCompleter lostFocus = pyqtSignal() # 0 = tags, 1 = decks def __init__(self, parent: QWidget, type: int = 0) -> None: QLineEdit.__init__(self, parent) self.col: Collection | None = None self.model = QStringListModel() self.type = type if type == 0: self._completer = TagCompleter(self.model, parent, self) else: self._completer = QCompleter(self.model, parent) self._completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) self._completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) self._completer.setFilterMode(Qt.MatchFlag.MatchContains) self.setCompleter(self._completer) def setCol(self, col: Collection) -> None: "Set the current col, updating list of available tags." self.col = col l: Iterable[str] if self.type == 0: l = self.col.tags.all() else: l = (d.name for d in self.col.decks.all_names_and_ids()) self.model.setStringList(l) def focusInEvent(self, evt: QFocusEvent | None) -> None: QLineEdit.focusInEvent(self, evt) def keyPressEvent(self, evt: QKeyEvent | None) -> None: assert evt is not None popup = self._completer.popup() assert popup is not None if evt.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down): # show completer on arrow key up/down if not popup.isVisible(): self.showCompleter() return if ( evt.key() == Qt.Key.Key_Tab and evt.modifiers() & Qt.KeyboardModifier.ControlModifier ): # select next completion if not popup.isVisible(): self.showCompleter() index = self._completer.currentIndex() popup.setCurrentIndex(index) cur_row = index.row() if not self._completer.setCurrentRow(cur_row + 1): self._completer.setCurrentRow(0) return if evt.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return) and popup.isVisible(): # apply first completion if no suggestion selected selected_row = popup.currentIndex().row() if selected_row == -1: self._completer.setCurrentRow(0) index = self._completer.currentIndex() popup.setCurrentIndex(index) self.hideCompleter() QWidget.keyPressEvent(self, evt) return QLineEdit.keyPressEvent(self, evt) if not evt.text(): # if it's a modifier, don't show return if evt.key() not in ( Qt.Key.Key_Enter, Qt.Key.Key_Return, Qt.Key.Key_Escape, Qt.Key.Key_Space, Qt.Key.Key_Tab, Qt.Key.Key_Backspace, Qt.Key.Key_Delete, ): self.showCompleter() gui_hooks.tag_editor_did_process_key(self, evt) def showCompleter(self) -> None: self._completer.setCompletionPrefix(self.text()) self._completer.complete() def focusOutEvent(self, evt: QFocusEvent | None) -> None: QLineEdit.focusOutEvent(self, evt) self.lostFocus.emit() # type: ignore popup = self._completer.popup() assert popup is not None popup.hide() def hideCompleter(self) -> None: if sip.isdeleted(self._completer): # type: ignore return popup = self._completer.popup() assert popup is not None popup.hide() class TagCompleter(QCompleter): def __init__( self, model: QStringListModel, parent: QWidget, edit: TagEdit, ) -> None: QCompleter.__init__(self, model, parent) self.tags: list[str] = [] self.edit = edit self.cursor: int | None = None def splitPath(self, tags: str | None) -> list[str]: assert tags is not None assert self.edit.col is not None stripped_tags = tags.strip() stripped_tags = re.sub(" +", " ", stripped_tags) self.tags = self.edit.col.tags.split(stripped_tags) self.tags.append("") p = self.edit.cursorPosition() if tags.endswith(" "): self.cursor = len(self.tags) - 1 else: self.cursor = stripped_tags.count(" ", 0, p) return [self.tags[self.cursor]] def pathFromIndex(self, idx: QModelIndex) -> str: if self.cursor is None: return self.edit.text() ret = QCompleter.pathFromIndex(self, idx) self.tags[self.cursor] = ret try: self.tags.remove("") except ValueError: pass return f"{' '.join(self.tags)} " ================================================ FILE: qt/aqt/taglimit.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/copyleft/agpl.html from __future__ import annotations from collections.abc import Callable, Sequence import aqt import aqt.customstudy import aqt.forms from anki.lang import with_collapsed_whitespace from anki.scheduler.base import CustomStudyDefaults from aqt.qt import * from aqt.utils import disable_help_button, restoreGeom, saveGeom, showWarning, tr class TagLimit(QDialog): def __init__( self, parent: QWidget, tags: Sequence[CustomStudyDefaults.Tag], on_success: Callable[[list[str], list[str]], None], ) -> None: "Ask user to select tags. on_success() will be called with selected included and excluded tags." QDialog.__init__(self, parent, Qt.WindowType.Window) self.tags = tags self.form = aqt.forms.taglimit.Ui_Dialog() self.form.setupUi(self) self.on_success = on_success disable_help_button(self) s = QShortcut( QKeySequence("ctrl+d"), self.form.activeList, context=Qt.ShortcutContext.WidgetShortcut, ) qconnect(s.activated, self.form.activeList.clearSelection) s = QShortcut( QKeySequence("ctrl+d"), self.form.inactiveList, context=Qt.ShortcutContext.WidgetShortcut, ) qconnect(s.activated, self.form.inactiveList.clearSelection) self.build_tag_lists() restoreGeom(self, "tagLimit") self.open() def build_tag_lists(self) -> None: def add_tag(tag: str, select: bool, list: QListWidget) -> None: item = QListWidgetItem(tag.replace("_", " ")) list.addItem(item) if select: idx = list.indexFromItem(item) list_selection_model = list.selectionModel() assert list_selection_model is not None list_selection_model.select( idx, QItemSelectionModel.SelectionFlag.Select ) had_included_tag = False for tag in self.tags: if tag.include: had_included_tag = True add_tag(tag.name, tag.include, self.form.activeList) add_tag(tag.name, tag.exclude, self.form.inactiveList) if had_included_tag: self.form.activeCheck.setChecked(True) def reject(self) -> None: QDialog.reject(self) def accept(self) -> None: include_tags = [] exclude_tags = [] want_active = self.form.activeCheck.isChecked() for c, tag in enumerate(self.tags): # active if want_active: item = self.form.activeList.item(c) idx = self.form.activeList.indexFromItem(item) active_list_selection_model = self.form.activeList.selectionModel() assert active_list_selection_model is not None if active_list_selection_model.isSelected(idx): include_tags.append(tag.name) # inactive item = self.form.inactiveList.item(c) idx = self.form.inactiveList.indexFromItem(item) inactive_list_selection_model = self.form.inactiveList.selectionModel() assert inactive_list_selection_model is not None if inactive_list_selection_model.isSelected(idx): exclude_tags.append(tag.name) if (len(include_tags) + len(exclude_tags)) > 100: showWarning(with_collapsed_whitespace(tr.errors_100_tags_max())) return saveGeom(self, "tagLimit") QDialog.accept(self) self.on_success(include_tags, exclude_tags) ================================================ FILE: qt/aqt/taskman.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """ Helper for running tasks on background threads. See QueryOp() and CollectionOp() for higher-level routines. """ from __future__ import annotations import traceback from collections.abc import Callable from concurrent.futures import Future from concurrent.futures.thread import ThreadPoolExecutor from threading import Lock, current_thread, main_thread from typing import Any import aqt from anki.collection import Progress from aqt.progress import ProgressUpdate from aqt.qt import * Closure = Callable[[], None] class TaskManager(QObject): _closures_pending = pyqtSignal() def __init__(self, mw: aqt.AnkiQt) -> None: QObject.__init__(self) self.mw = mw.weakref() self._no_collection_executor = ThreadPoolExecutor() self._collection_executor = ThreadPoolExecutor(max_workers=1) self._closures: list[Closure] = [] self._closures_lock = Lock() qconnect(self._closures_pending, self._on_closures_pending) def run_on_main(self, closure: Closure) -> None: "Run the provided closure on the main thread." with self._closures_lock: self._closures.append(closure) self._closures_pending.emit() # type: ignore def run_in_background( self, task: Callable, on_done: Callable[[Future], None] | None = None, args: dict[str, Any] | None = None, uses_collection=True, ) -> Future: """Use QueryOp()/CollectionOp() in new code. Run task on a background thread. If on_done is provided, it will be called on the main thread with the completed future. Args if provided will be passed on as keyword arguments to the task callable. Tasks that access the collection are serialized. If you're doing things that don't require the collection (e.g. network requests), you can pass uses_collection =False to allow multiple tasks to run in parallel.""" # Before we launch a background task, ensure any pending on_done closure are run on # main. Qt's signal/slot system will have posted a notification, but it may # not have been processed yet. The on_done() closures may make small queries # to the database that we want to run first - if we delay them until after the # background task starts, and it takes out a long-running lock on the database, # the UI thread will hang until the end of the op. if current_thread() is main_thread(): self._on_closures_pending() else: print("bug: run_in_background not called from main thread") traceback.print_stack() if args is None: args = {} executor = ( self._collection_executor if uses_collection else self._no_collection_executor ) fut = executor.submit(task, **args) if on_done is not None: fut.add_done_callback( lambda future: self.run_on_main(lambda: on_done(future)) ) return fut def with_progress( self, task: Callable, on_done: Callable[[Future], None] | None = None, parent: QWidget | None = None, label: str | None = None, immediate: bool = False, uses_collection=True, title: str = "Anki", ) -> None: "Use QueryOp()/CollectionOp() in new code." self.mw.progress.start( parent=parent, label=label, immediate=immediate, title=title ) def wrapped_done(fut: Future) -> None: self.mw.progress.finish() if on_done: on_done(fut) self.run_in_background(task, wrapped_done, uses_collection=uses_collection) def with_backend_progress( self, task: Callable, progress_update: Callable[[Progress, ProgressUpdate], None], on_done: Callable[[Future], None] | None = None, parent: QWidget | None = None, start_label: str | None = None, uses_collection=True, ) -> None: self.mw.progress.start_with_backend_updates( progress_update, parent=parent, start_label=start_label, ) def wrapped_done(fut: Future) -> None: self.mw.progress.finish() # allow the event loop to close the window before we proceed if on_done: self.mw.progress.single_shot( 100, lambda: on_done(fut), requires_collection=False ) self.run_in_background(task, wrapped_done, uses_collection=uses_collection) def _on_closures_pending(self) -> None: """Run any pending closures. This runs in the main thread.""" with self._closures_lock: closures = self._closures self._closures = [] for closure in closures: try: closure() except Exception as e: def raise_exception(exception=e) -> None: raise exception QTimer.singleShot(0, raise_exception) ================================================ FILE: qt/aqt/theme.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import enum import os import re import subprocess from collections.abc import Callable from dataclasses import dataclass import anki.lang import aqt from anki.lang import is_rtl from anki.utils import is_lin, is_mac, is_win from aqt import QApplication, colors, gui_hooks from aqt.qt import ( QColor, QIcon, QPainter, QPalette, QPixmap, QStyle, QStyleFactory, Qt, qtmajor, qtminor, ) @dataclass class ColoredIcon: path: str color: dict[str, str] def current_color(self, night_mode: bool) -> str: if night_mode: return self.color.get("dark", "") else: return self.color.get("light", "") def with_color(self, color: dict[str, str]) -> ColoredIcon: return ColoredIcon(path=self.path, color=color) class WidgetStyle(enum.IntEnum): ANKI = 0 NATIVE = 1 class Theme(enum.IntEnum): FOLLOW_SYSTEM = 0 LIGHT = 1 DARK = 2 class ThemeManager: _night_mode_preference = False _icon_cache_light: dict[str, QIcon] = {} _icon_cache_dark: dict[str, QIcon] = {} _icon_size = 128 _dark_mode_available: bool | None = None _default_style: str | None = None _current_widget_style: WidgetStyle | None = None _default_button_layout: int | None = None def rtl(self) -> bool: return is_rtl(anki.lang.current_lang) def left(self) -> str: return "right" if self.rtl() else "left" def right(self) -> str: return "left" if self.rtl() else "right" # Qt applies a gradient to the buttons in dark mode # from about #505050 to #606060. DARK_MODE_BUTTON_BG_MIDPOINT = "#555555" def macos_dark_mode(self) -> bool: "True if the user has night mode on." if not is_mac: return False if not self._night_mode_preference: return False if self._dark_mode_available is None: self._dark_mode_available = set_macos_dark_mode(True) return self._dark_mode_available def get_night_mode(self) -> bool: return self._night_mode_preference def set_night_mode(self, val: bool) -> None: self._night_mode_preference = val self._update_stat_colors() night_mode = property(get_night_mode, set_night_mode) def themed_icon(self, path: str) -> str: "Fetch themed version of svg." from aqt.utils import aqt_data_folder if m := re.match(r"(?:mdi:)(.+)$", path): name = m.group(1) else: return path filename = f"{name}-{'dark' if self.night_mode else 'light'}.svg" path = os.path.join(aqt_data_folder(), "qt", "icons", filename) path = path.replace("\\\\?\\", "").replace("\\", "/") # Workaround for Qt bug. First attempt was percent-escaping the chars, # but Qt can't handle that. # https://forum.qt.io/topic/55274/solved-qss-with-special-characters/11 path = re.sub(r"(['\u00A1-\u00FF])", r"\\\1", path) return path def icon_from_resources(self, path: str | ColoredIcon) -> QIcon: "Fetch icon from Qt resources." if self.night_mode: cache = self._icon_cache_light else: cache = self._icon_cache_dark if isinstance(path, str): key = path else: key = f"{path.path}-{path.color}" icon = cache.get(key) if icon: return icon if isinstance(path, str): # default black/white if "mdi:" in path: icon = QIcon(self.themed_icon(path)) else: icon = QIcon(path) if self.night_mode: img = icon.pixmap(self._icon_size, self._icon_size).toImage() img.invertPixels() icon = QIcon(QPixmap(img)) else: # specified colours icon = QIcon(path.path) pixmap = icon.pixmap(16) painter = QPainter(pixmap) painter.setCompositionMode( QPainter.CompositionMode.CompositionMode_SourceIn ) painter.fillRect(pixmap.rect(), QColor(path.current_color(self.night_mode))) painter.end() icon = QIcon(pixmap) return icon return cache.setdefault(path, icon) def body_class(self, night_mode: bool | None = None, reviewer: bool = False) -> str: "Returns space-separated class list for platform/theme/global settings." classes = [] if is_win: classes.append("isWin") elif is_mac: classes.append("isMac") else: classes.append("isLin") if night_mode is None: night_mode = self.night_mode if night_mode: classes.extend(["nightMode", "night_mode"]) if self.macos_dark_mode(): classes.append("macos-dark-mode") if aqt.mw.pm.reduce_motion() and not reviewer: classes.append("reduce-motion") if not aqt.mw.pm.minimalist_mode(): classes.append("fancy") if qtmajor == 5 and qtminor < 15: classes.append("no-blur") return " ".join(classes) def body_classes_for_card_ord( self, card_ord: int, night_mode: bool | None = None ) -> str: "Returns body classes used when showing a card." return f"card card{card_ord + 1} {self.body_class(night_mode, reviewer=True)}" def var(self, vars: dict[str, str]) -> str: """Given day/night colors/props, return the correct one for the current theme.""" return vars["dark" if self.night_mode else "light"] def qcolor(self, colors: dict[str, str]) -> QColor: """Create QColor instance from CSS string for the current theme.""" if m := re.match( r"rgba\((\d+),\s*(\d+),\s*(\d+),\s*(\d+\.*\d+?)\)", self.var(colors) ): return QColor( int(m.group(1)), int(m.group(2)), int(m.group(3)), int(255 * float(m.group(4))), ) return QColor(self.var(colors)) def _determine_night_mode(self) -> bool: theme = aqt.mw.pm.theme() if theme == Theme.LIGHT: return False elif theme == Theme.DARK: return True elif is_win: return get_windows_dark_mode() elif is_mac: return get_macos_dark_mode() else: return get_linux_dark_mode() def apply_style(self) -> None: "Apply currently configured style." new_theme = self._determine_night_mode() theme_changed = self.night_mode != new_theme new_widget_style = aqt.mw.pm.get_widget_style() style_changed = self._current_widget_style != new_widget_style if not theme_changed and not style_changed: return self.night_mode = new_theme self._current_widget_style = new_widget_style app = aqt.mw.app if not self._default_style: style = app.style() assert style is not None self._default_style = style.objectName() self._default_button_layout = style.styleHint( QStyle.StyleHint.SH_DialogButtonLayout ) self._apply_palette(app) self._apply_style(app) gui_hooks.theme_did_change() def _apply_style(self, app: QApplication) -> None: buf = "" if aqt.mw.pm.get_widget_style() == WidgetStyle.ANKI: from aqt.stylesheets import custom_styles app.setStyle(QStyleFactory.create("fusion")) # type: ignore buf += "".join( [ custom_styles.general(self), custom_styles.button(self), custom_styles.checkbox(self), custom_styles.menu(self), custom_styles.combobox(self), custom_styles.tabwidget(self), custom_styles.table(self), custom_styles.spinbox(self), custom_styles.scrollbar(self), custom_styles.slider(self), custom_styles.splitter(self), ] ) else: app.setStyle(QStyleFactory.create(self._default_style)) # type: ignore # allow addons to modify the styling buf = gui_hooks.style_did_init(buf) app.setStyleSheet(buf) def _apply_palette(self, app: QApplication) -> None: set_macos_dark_mode(self.night_mode) palette = QPalette() text = self.qcolor(colors.FG) palette.setColor(QPalette.ColorRole.WindowText, text) palette.setColor(QPalette.ColorRole.ToolTipText, text) palette.setColor(QPalette.ColorRole.Text, text) palette.setColor(QPalette.ColorRole.ButtonText, text) hlbg = self.qcolor(colors.HIGHLIGHT_BG) palette.setColor( QPalette.ColorRole.HighlightedText, self.qcolor(colors.HIGHLIGHT_FG) ) palette.setColor(QPalette.ColorRole.Highlight, hlbg) canvas = self.qcolor(colors.CANVAS) palette.setColor(QPalette.ColorRole.Window, canvas) palette.setColor(QPalette.ColorRole.AlternateBase, canvas) palette.setColor(QPalette.ColorRole.Button, canvas) input_base = self.qcolor(colors.CANVAS_CODE) palette.setColor(QPalette.ColorRole.Base, input_base) palette.setColor(QPalette.ColorRole.ToolTipBase, input_base) palette.setColor( QPalette.ColorRole.PlaceholderText, self.qcolor(colors.FG_SUBTLE) ) disabled_color = self.qcolor(colors.FG_DISABLED) palette.setColor( QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_color ) palette.setColor( QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_color ) palette.setColor( QPalette.ColorGroup.Disabled, QPalette.ColorRole.HighlightedText, disabled_color, ) palette.setColor(QPalette.ColorRole.Link, self.qcolor(colors.FG_LINK)) palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red) app.setPalette(palette) def _update_stat_colors(self) -> None: import anki.stats as s s.colLearn = self.var(colors.STATE_NEW) s.colRelearn = self.var(colors.STATE_LEARN) s.colCram = self.var(colors.STATE_SUSPENDED) s.colSusp = self.var(colors.STATE_SUSPENDED) s.colMature = self.var(colors.STATE_REVIEW) s._legacy_nightmode = self._night_mode_preference def get_windows_dark_mode() -> bool: "True if Windows system is currently in dark mode." if not is_win: return False from winreg import ( # type: ignore[attr-defined] HKEY_CURRENT_USER, OpenKey, QueryValueEx, ) try: key = OpenKey( HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", ) return not QueryValueEx(key, "AppsUseLightTheme")[0] except Exception: # key reportedly missing or set to wrong type on some systems return False def set_macos_dark_mode(enabled: bool) -> bool: "True if setting successful." from aqt._macos_helper import macos_helper if not macos_helper: return False return macos_helper.set_darkmode_enabled(enabled) def get_macos_dark_mode() -> bool: "True if macOS system is currently in dark mode." from aqt._macos_helper import macos_helper if not macos_helper: return False return macos_helper.system_is_dark() def get_linux_dark_mode() -> bool: """True if Linux system is in dark mode. Only works if D-Bus is installed and system uses org.freedesktop.appearance color-scheme to indicate dark mode preference OR if GNOME theme has '-dark' in the name.""" if not is_lin: return False def parse_stdout_dbus_send(stdout: str) -> bool: dbus_response = stdout.split() if len(dbus_response) != 4: return False # https://github.com/flatpak/xdg-desktop-portal/blob/main/data/org.freedesktop.impl.portal.Settings.xml#L40 PREFER_DARK = "1" return dbus_response[-1] == PREFER_DARK dark_mode_detection_strategies: list[tuple[str, Callable[[str], bool]]] = [ ( "dbus-send --session --print-reply=literal --reply-timeout=1000 " "--dest=org.freedesktop.portal.Desktop /org/freedesktop/portal/desktop " "org.freedesktop.portal.Settings.Read string:'org.freedesktop.appearance' " "string:'color-scheme'", parse_stdout_dbus_send, ), ( "gsettings get org.gnome.desktop.interface gtk-theme", lambda stdout: "-dark" in stdout.lower(), ), ] for cmd, parse_stdout in dark_mode_detection_strategies: try: process = subprocess.run( cmd, shell=True, check=True, capture_output=True, encoding="utf8", ) except FileNotFoundError: # detection strategy failed, missing program # print(e) continue except subprocess.CalledProcessError: # detection strategy failed, command returned error # print(e) continue return parse_stdout(process.stdout) return False # all dark mode detection strategies failed theme_manager = ThemeManager() ================================================ FILE: qt/aqt/toolbar.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import enum import re from collections.abc import Callable from typing import Any, cast import aqt from anki.sync import SyncStatus from aqt import gui_hooks, props from aqt.qt import * from aqt.sync import get_sync_status from aqt.theme import theme_manager from aqt.utils import tr from aqt.webview import AnkiWebView, AnkiWebViewKind class HideMode(enum.IntEnum): FULLSCREEN = 0 ALWAYS = 1 # wrapper class for set_bridge_command() class TopToolbar: def __init__(self, toolbar: Toolbar) -> None: self.toolbar = toolbar # wrapper class for set_bridge_command() class BottomToolbar: def __init__(self, toolbar: Toolbar) -> None: self.toolbar = toolbar class ToolbarWebView(AnkiWebView): hide_condition: Callable[..., bool] def __init__( self, mw: aqt.AnkiQt, kind: AnkiWebViewKind = AnkiWebViewKind.DEFAULT ) -> None: AnkiWebView.__init__(self, mw, kind=kind) self.mw = mw self.setFocusPolicy(Qt.FocusPolicy.WheelFocus) self.disable_zoom() self.hidden = False self.hide_timer = QTimer() self.hide_timer.setSingleShot(True) self.reset_timer() def reset_timer(self) -> None: self.hide_timer.stop() self.hide_timer.setInterval(2000) def hide(self) -> None: self.hidden = True def show(self) -> None: self.hidden = False class TopWebView(ToolbarWebView): def __init__(self, mw: aqt.AnkiQt) -> None: super().__init__(mw, kind=AnkiWebViewKind.TOP_TOOLBAR) self.web_height = 0 qconnect(self.hide_timer.timeout, self.hide_if_allowed) def eventFilter(self, obj, evt): if handled := super().eventFilter(obj, evt): return handled # prevent collapse of both toolbars if pointer is inside one of them if evt.type() == QEvent.Type.Enter: self.reset_timer() self.mw.bottomWeb.reset_timer() return True return False def on_body_classes_need_update(self) -> None: super().on_body_classes_need_update() if self.mw.state == "review": if self.mw.pm.hide_top_bar(): self.eval("""document.body.classList.remove("flat"); """) else: self.flatten() self.adjustHeightToFit() self.show() def _onHeight(self, qvar: int | None) -> None: super()._onHeight(qvar) if qvar: self.web_height = int(qvar) def hide_if_allowed(self) -> None: if self.mw.state != "review": return # Invariant: The `hide_if_allowed` method ensures that the fullscreen state is checked # and the menubar will be hidden if necessary # Note: The `eventFilter` and `_reviewState` methods in `qt/aqt/main.py` rely on this invariant if self.mw.fullscreen: self.mw.hide_menubar() if self.mw.pm.hide_top_bar(): if ( self.mw.pm.top_bar_hide_mode() == HideMode.FULLSCREEN and not self.mw.windowState() & Qt.WindowState.WindowFullScreen ): self.show() return self.hide() def hide(self) -> None: super().hide() self.hidden = True self.eval( """document.body.classList.add("hidden"); """, ) def show(self) -> None: super().show() self.eval("""document.body.classList.remove("hidden"); """) def flatten(self) -> None: self.eval("""document.body.classList.add("flat"); """) def elevate(self) -> None: self.eval( """ document.body.classList.remove("flat"); document.body.style.removeProperty("background"); """ ) def update_background_image(self) -> None: if self.mw.pm.minimalist_mode(): return def set_background(computed: str) -> None: # remove offset from copy background = re.sub(r"-\d+px ", "0%", computed) # ensure alignment with main webview background = re.sub(r"\sfixed", "", background) # change computedStyle px value back to 100vw background = re.sub(r"\d+px", "100vw", background) self.eval( f""" document.body.style.setProperty("background", '{background}'); """ ) self.set_body_height(self.mw.web.height()) # offset reviewer background by toolbar height if self.web_height: self.mw.web.eval( f"""document.body.style.setProperty("background-position-y", "-{self.web_height}px"); """ ) self.mw.web.evalWithCallback( """window.getComputedStyle(document.body).background; """, set_background, ) def set_body_height(self, height: int) -> None: self.eval( f"""document.body.style.setProperty("min-height", "{self.mw.web.height()}px"); """ ) def adjustHeightToFit(self) -> None: self.eval("""document.body.style.setProperty("min-height", "0px"); """) self.evalWithCallback("document.documentElement.offsetHeight", self._onHeight) def resizeEvent(self, event: QResizeEvent | None) -> None: super().resizeEvent(event) self.mw.web.evalWithCallback( """window.innerHeight; """, self.set_body_height, ) class BottomWebView(ToolbarWebView): def __init__(self, mw: aqt.AnkiQt) -> None: super().__init__(mw, kind=AnkiWebViewKind.BOTTOM_TOOLBAR) qconnect(self.hide_timer.timeout, self.hide_if_allowed) def eventFilter(self, obj, evt): if handled := super().eventFilter(obj, evt): return handled if evt.type() == QEvent.Type.Enter: self.reset_timer() self.mw.toolbarWeb.reset_timer() return True return False def on_body_classes_need_update(self) -> None: super().on_body_classes_need_update() if self.mw.state == "review": self.show() def animate_height(self, height: int) -> None: self.web_height = height if self.mw.pm.reduce_motion() or height == self.height(): self.setFixedHeight(height) else: # Collapse/Expand animation self.setMinimumHeight(0) self.animation = QPropertyAnimation( self, cast(QByteArray, b"maximumHeight") ) self.animation.setDuration(int(theme_manager.var(props.TRANSITION))) self.animation.setStartValue(self.height()) self.animation.setEndValue(height) qconnect(self.animation.finished, lambda: self.setFixedHeight(height)) self.animation.start() def hide_if_allowed(self) -> None: if self.mw.state != "review": return if self.mw.pm.hide_bottom_bar(): if ( self.mw.pm.bottom_bar_hide_mode() == HideMode.FULLSCREEN and not self.mw.windowState() & Qt.WindowState.WindowFullScreen ): self.show() return self.hide() def hide(self) -> None: super().hide() self.hidden = True self.animate_height(1) def show(self) -> None: super().show() self.hidden = False if self.mw.state == "review": # delay to account for reflow def cb(height: int | None): # "When QWebEnginePage is deleted, the callback is triggered with an invalid value" if height is not None: self.animate_height(height) self.mw.progress.single_shot( 50, lambda: self.evalWithCallback( "document.documentElement.offsetHeight", cb ), False, ) else: self.adjustHeightToFit() class Toolbar: def __init__(self, mw: aqt.AnkiQt, web: AnkiWebView) -> None: self.mw = mw self.web = web self.link_handlers: dict[str, Callable] = { "study": self._studyLinkHandler, } self.web.requiresCol = False def draw( self, buf: str = "", web_context: Any | None = None, link_handler: Callable[[str], Any] | None = None, ) -> None: web_context = web_context or TopToolbar(self) link_handler = link_handler or self._linkHandler self.web.set_bridge_command(link_handler, web_context) body = self._body.format( toolbar_content=self._centerLinks(), left_tray_content=self._left_tray_content(), right_tray_content=self._right_tray_content(), ) self.web.stdHtml( body, css=["css/toolbar.css"], js=["js/vendor/jquery.min.js", "js/toolbar.js"], context=web_context, ) self.web.adjustHeightToFit() def redraw(self) -> None: self.set_sync_active(self.mw.media_syncer.is_syncing()) self.update_sync_status() gui_hooks.top_toolbar_did_redraw(self) # Available links ###################################################################### def create_link( self, cmd: str, label: str, func: Callable, tip: str | None = None, id: str | None = None, ) -> str: """Generates HTML link element and registers link handler Arguments: cmd {str} -- Command name used for the JS → Python bridge label {str} -- Display label of the link func {Callable} -- Callable to be called on clicking the link Keyword Arguments: tip {Optional[str]} -- Optional tooltip text to show on hovering over the link (default: {None}) id: {Optional[str]} -- Optional id attribute to supply the link with (default: {None}) Returns: str -- HTML link element """ self.link_handlers[cmd] = func title_attr = f'title="{tip}"' if tip else "" id_attr = f'id="{id}"' if id else "" return ( f"""""" f"""{label}""" ) def _centerLinks(self) -> str: links = [ self.create_link( "decks", tr.actions_decks(), self._deckLinkHandler, tip=tr.actions_shortcut_key(val="D"), id="decks", ), self.create_link( "add", tr.actions_add(), self._addLinkHandler, tip=tr.actions_shortcut_key(val="A"), id="add", ), self.create_link( "browse", tr.qt_misc_browse(), self._browseLinkHandler, tip=tr.actions_shortcut_key(val="B"), id="browse", ), self.create_link( "stats", tr.qt_misc_stats(), self._statsLinkHandler, tip=tr.actions_shortcut_key(val="T"), id="stats", ), ] links.append(self._create_sync_link()) gui_hooks.top_toolbar_did_init_links(links, self) return "\n".join(links) # Add-ons ###################################################################### def _left_tray_content(self) -> str: left_tray_content: list[str] = [] gui_hooks.top_toolbar_will_set_left_tray_content(left_tray_content, self) return self._process_tray_content(left_tray_content) def _right_tray_content(self) -> str: right_tray_content: list[str] = [] gui_hooks.top_toolbar_will_set_right_tray_content(right_tray_content, self) return self._process_tray_content(right_tray_content) def _process_tray_content(self, content: list[str]) -> str: return "\n".join(f"""
{item}
""" for item in content) # Sync ###################################################################### def _create_sync_link(self) -> str: name = tr.qt_misc_sync() title = tr.actions_shortcut_key(val="Y") label = "sync" self.link_handlers[label] = self._syncLinkHandler return f""" {name} """ def set_sync_active(self, active: bool) -> None: method = "add" if active else "remove" self.web.eval( f"document.getElementById('sync-spinner').classList.{method}('spin')" ) def set_sync_status(self, status: SyncStatus) -> None: self.web.eval(f"updateSyncColor({status.required})") def update_sync_status(self) -> None: get_sync_status(self.mw, self.mw.toolbar.set_sync_status) # Link handling ###################################################################### def _linkHandler(self, link: str) -> bool: if link in self.link_handlers: self.link_handlers[link]() return False def _deckLinkHandler(self) -> None: self.mw.moveToState("deckBrowser") def _studyLinkHandler(self) -> None: # if overview already shown, switch to review if self.mw.state == "overview": self.mw.col.startTimebox() self.mw.moveToState("review") else: self.mw.onOverview() def _addLinkHandler(self) -> None: self.mw.onAddCard() def _browseLinkHandler(self) -> None: self.mw.onBrowse() def _statsLinkHandler(self) -> None: self.mw.onStats() def _syncLinkHandler(self) -> None: self.mw.on_sync_button_clicked() # HTML & CSS ###################################################################### _body = """
{left_tray_content}
{toolbar_content}
{right_tray_content}
""" # Bottom bar ###################################################################### class BottomBar(Toolbar): _centerBody = """
""" def draw( self, buf: str = "", web_context: Any | None = None, link_handler: Callable[[str], Any] | None = None, ) -> None: # note: some screens may override this web_context = web_context or BottomToolbar(self) link_handler = link_handler or self._linkHandler self.web.set_bridge_command(link_handler, web_context) self.web.stdHtml( self._centerBody % buf, css=["css/toolbar.css", "css/toolbar-bottom.css"], context=web_context, ) self.web.adjustHeightToFit() ================================================ FILE: qt/aqt/tts.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """ Basic text to speech support. Users can use the following in their card template: {{tts en_US:Field}} or {{tts ja_JP voices=Kyoko,Otoya,Another_name:Field}} The first argument must be an underscored language code, eg en_US. If provided, voices is a comma-separated list of one or more voices that the user would prefer. Spaces must not be included. Underscores will be converted to spaces. AVPlayer decides which TTSPlayer to use based on the returned rank. In the default implementation, the TTS player is chosen based on the order of voices the user has specified. When adding new TTS players, your code can either expose the underlying names the TTS engine provides, or simply expose the name of the engine, which would mean the user could write {{tts en_AU voices=MyEngine}} to prioritize your engine. """ from __future__ import annotations import os import re import subprocess from concurrent.futures import Future from dataclasses import dataclass from operator import attrgetter from typing import Any, cast import anki import anki.template import aqt from anki import hooks from anki.collection import TtsVoice as BackendVoice from anki.sound import AVTag, TTSTag from anki.utils import checksum, is_win, tmpdir from aqt import gui_hooks from aqt.sound import OnDoneCallback, SimpleProcessPlayer from aqt.utils import tooltip, tr @dataclass class TTSVoice: name: str lang: str def __str__(self) -> str: out = f"{{{{tts {self.lang} voices={self.name}}}}}" if self.unavailable(): out += " (unavailable)" return out def unavailable(self) -> bool: return False @dataclass class TTSVoiceMatch: voice: TTSVoice rank: int class TTSPlayer: default_rank = 0 _available_voices: list[TTSVoice] | None = None def get_available_voices(self) -> list[TTSVoice]: return [] def voices(self) -> list[TTSVoice]: if self._available_voices is None: self._available_voices = self.get_available_voices() return self._available_voices def voice_for_tag(self, tag: TTSTag) -> TTSVoiceMatch | None: avail_voices = self.voices() rank = self.default_rank # any requested voices match? for requested_voice in tag.voices: for avail in avail_voices: if avail.name == requested_voice and avail.lang == tag.lang: return TTSVoiceMatch(voice=avail, rank=rank) rank -= 1 # if no requested voices match, use a preferred fallback voice # (for example, Apple Samantha) with rank of -50 for avail in avail_voices: if avail.lang == tag.lang: if avail.lang == "en_US" and avail.name.startswith("Apple_Samantha"): return TTSVoiceMatch(voice=avail, rank=-50) # if no requested or preferred voices match, we fall back on # the first available voice for the language, with a rank of -100 for avail in avail_voices: if avail.lang == tag.lang: return TTSVoiceMatch(voice=avail, rank=-100) return None def temp_file_for_tag_and_voice(self, tag: AVTag, voice: TTSVoice) -> str: """Return a hashed filename, to allow for caching generated files. No file extension is included.""" assert isinstance(tag, TTSTag) buf = f"{voice.name}-{voice.lang}-{tag.field_text}" return os.path.join(tmpdir(), f"tts-{checksum(buf)}") class TTSProcessPlayer(SimpleProcessPlayer, TTSPlayer): # mypy gets confused if rank_for_tag is defined in TTSPlayer def rank_for_tag(self, tag: AVTag) -> int | None: if not isinstance(tag, TTSTag): return None match = self.voice_for_tag(tag) if match: return match.rank else: return None # tts-voices filter ########################################################################## def all_tts_voices() -> list[TTSVoice]: from aqt.sound import av_player all_voices: list[TTSVoice] = [] for p in av_player.players: getter = getattr(p, "validated_voices", getattr(p, "voices", None)) if getter: all_voices.extend(getter()) return all_voices def on_tts_voices( text: str, field: str, filter: str, ctx: anki.template.TemplateRenderContext ) -> str: if filter != "tts-voices": return text voices = all_tts_voices() voices.sort(key=attrgetter("lang", "name")) buf = "
TTS voices available:
" buf += "
".join(map(str, voices)) if any(v.unavailable() for v in voices): buf += "
One or more voices are unavailable." buf += " Installing a Windows language pack may help.
" return f"{buf}
" hooks.field_filter.append(on_tts_voices) # Mac support ########################################################################## @dataclass class MacVoice(TTSVoice): original_name: str class MacTTSPlayer(TTSProcessPlayer): "Invokes a process to play the audio in the background." VOICE_HELP_LINE_RE = re.compile(r"^(.+)\s+(\S+)\s+#.*$") def _play(self, tag: AVTag) -> None: assert isinstance(tag, TTSTag) match = self.voice_for_tag(tag) assert match voice = match.voice assert isinstance(voice, MacVoice) default_wpm = 170 words_per_min = str(int(default_wpm * tag.speed)) self._process = subprocess.Popen( ["say", "-v", voice.original_name, "-r", words_per_min, "-f", "-"], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) # write the input text to stdin assert self._process.stdin is not None self._process.stdin.write(tag.field_text.encode("utf8")) self._process.stdin.close() self._wait_for_termination(tag) def get_available_voices(self) -> list[TTSVoice]: cmd = subprocess.run( ["say", "-v", "?"], capture_output=True, check=True, encoding="utf8" ) voices = [] for line in cmd.stdout.splitlines(): voice = self._parse_voice_line(line) if voice: voices.append(voice) return voices def _parse_voice_line(self, line: str) -> TTSVoice | None: m = self.VOICE_HELP_LINE_RE.match(line) if not m: return None original_name = m.group(1).strip() tidy_name = f"Apple_{original_name.replace(' ', '_')}" return MacVoice(name=tidy_name, original_name=original_name, lang=m.group(2)) class MacTTSFilePlayer(MacTTSPlayer): "Generates an .aiff file, which is played using av_player." tmppath = os.path.join(tmpdir(), "tts.aiff") def _play(self, tag: AVTag) -> None: assert isinstance(tag, TTSTag) match = self.voice_for_tag(tag) assert match voice = match.voice assert isinstance(voice, MacVoice) default_wpm = 170 words_per_min = str(int(default_wpm * tag.speed)) self._process = subprocess.Popen( [ "say", "-v", voice.original_name, "-r", words_per_min, "-f", "-", "-o", self.tmppath, ], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) # write the input text to stdin assert self._process.stdin is not None self._process.stdin.write(tag.field_text.encode("utf8")) self._process.stdin.close() self._wait_for_termination(tag) def _on_done(self, ret: Future, cb: OnDoneCallback) -> None: ret.result() # inject file into the top of the audio queue from aqt.sound import av_player av_player.current_player = None av_player.insert_file(self.tmppath) # Windows support ########################################################################## @dataclass class WindowsVoice(TTSVoice): handle: Any if is_win: # language ID map from https://github.com/sindresorhus/lcid/blob/master/lcid.json LCIDS = { "4": "zh_CHS", "1025": "ar_SA", "1026": "bg_BG", "1027": "ca_ES", "1028": "zh_TW", "1029": "cs_CZ", "1030": "da_DK", "1031": "de_DE", "1032": "el_GR", "1033": "en_US", "1034": "es_ES", "1035": "fi_FI", "1036": "fr_FR", "1037": "he_IL", "1038": "hu_HU", "1039": "is_IS", "1040": "it_IT", "1041": "ja_JP", "1042": "ko_KR", "1043": "nl_NL", "1044": "nb_NO", "1045": "pl_PL", "1046": "pt_BR", "1047": "rm_CH", "1048": "ro_RO", "1049": "ru_RU", "1050": "hr_HR", "1051": "sk_SK", "1052": "sq_AL", "1053": "sv_SE", "1054": "th_TH", "1055": "tr_TR", "1056": "ur_PK", "1057": "id_ID", "1058": "uk_UA", "1059": "be_BY", "1060": "sl_SI", "1061": "et_EE", "1062": "lv_LV", "1063": "lt_LT", "1064": "tg_TJ", "1065": "fa_IR", "1066": "vi_VN", "1067": "hy_AM", "1069": "eu_ES", "1070": "wen_DE", "1071": "mk_MK", "1074": "tn_ZA", "1076": "xh_ZA", "1077": "zu_ZA", "1078": "af_ZA", "1079": "ka_GE", "1080": "fo_FO", "1081": "hi_IN", "1082": "mt_MT", "1083": "se_NO", "1086": "ms_MY", "1087": "kk_KZ", "1088": "ky_KG", "1089": "sw_KE", "1090": "tk_TM", "1092": "tt_RU", "1093": "bn_IN", "1094": "pa_IN", "1095": "gu_IN", "1096": "or_IN", "1097": "ta_IN", "1098": "te_IN", "1099": "kn_IN", "1100": "ml_IN", "1101": "as_IN", "1102": "mr_IN", "1103": "sa_IN", "1104": "mn_MN", "1105": "bo_CN", "1106": "cy_GB", "1107": "kh_KH", "1108": "lo_LA", "1109": "my_MM", "1110": "gl_ES", "1111": "kok_IN", "1114": "syr_SY", "1115": "si_LK", "1118": "am_ET", "1121": "ne_NP", "1122": "fy_NL", "1123": "ps_AF", "1124": "fil_PH", "1125": "div_MV", "1128": "ha_NG", "1130": "yo_NG", "1131": "quz_BO", "1132": "ns_ZA", "1133": "ba_RU", "1134": "lb_LU", "1135": "kl_GL", "1144": "ii_CN", "1146": "arn_CL", "1148": "moh_CA", "1150": "br_FR", "1152": "ug_CN", "1153": "mi_NZ", "1154": "oc_FR", "1155": "co_FR", "1156": "gsw_FR", "1157": "sah_RU", "1158": "qut_GT", "1159": "rw_RW", "1160": "wo_SN", "1164": "gbz_AF", "2049": "ar_IQ", "2052": "zh_CN", "2055": "de_CH", "2057": "en_GB", "2058": "es_MX", "2060": "fr_BE", "2064": "it_CH", "2067": "nl_BE", "2068": "nn_NO", "2070": "pt_PT", "2077": "sv_FI", "2080": "ur_IN", "2092": "az_AZ", "2094": "dsb_DE", "2107": "se_SE", "2108": "ga_IE", "2110": "ms_BN", "2115": "uz_UZ", "2128": "mn_CN", "2129": "bo_BT", "2141": "iu_CA", "2143": "tmz_DZ", "2155": "quz_EC", "3073": "ar_EG", "3076": "zh_HK", "3079": "de_AT", "3081": "en_AU", "3082": "es_ES", "3084": "fr_CA", "3098": "sr_SP", "3131": "se_FI", "3179": "quz_PE", "4097": "ar_LY", "4100": "zh_SG", "4103": "de_LU", "4105": "en_CA", "4106": "es_GT", "4108": "fr_CH", "4122": "hr_BA", "4155": "smj_NO", "5121": "ar_DZ", "5124": "zh_MO", "5127": "de_LI", "5129": "en_NZ", "5130": "es_CR", "5132": "fr_LU", "5179": "smj_SE", "6145": "ar_MA", "6153": "en_IE", "6154": "es_PA", "6156": "fr_MC", "6203": "sma_NO", "7169": "ar_TN", "7177": "en_ZA", "7178": "es_DO", "7194": "sr_BA", "7227": "sma_SE", "8193": "ar_OM", "8201": "en_JA", "8202": "es_VE", "8218": "bs_BA", "8251": "sms_FI", "9217": "ar_YE", "9225": "en_CB", "9226": "es_CO", "9275": "smn_FI", "10241": "ar_SY", "10249": "en_BZ", "10250": "es_PE", "11265": "ar_JO", "11273": "en_TT", "11274": "es_AR", "12289": "ar_LB", "12297": "en_ZW", "12298": "es_EC", "13313": "ar_KW", "13321": "en_PH", "13322": "es_CL", "14337": "ar_AE", "14346": "es_UR", "15361": "ar_BH", "15370": "es_PY", "16385": "ar_QA", "16394": "es_BO", "17417": "en_MY", "17418": "es_SV", "18441": "en_IN", "18442": "es_HN", "19466": "es_NI", "20490": "es_PR", "21514": "es_US", "31748": "zh_CHT", } def lcid_hex_str_to_lang_codes(hex_codes: str) -> list[str]: return [ LCIDS.get(str(int(code, 16)), "unknown") for code in hex_codes.split(";") ] class WindowsTTSPlayer(TTSProcessPlayer): default_rank = -1 try: import win32com.client speaker = win32com.client.Dispatch("SAPI.SpVoice") except Exception as exc: print("unable to activate sapi:", exc) speaker = None def get_available_voices(self) -> list[TTSVoice]: if self.speaker is None: return [] return [ obj for voice in self.speaker.GetVoices() for obj in self._voice_to_objects(voice) ] def _voice_to_objects(self, voice: Any) -> list[WindowsVoice]: try: langs = voice.GetAttribute("language") except Exception: # no associated language; ignore return [] langs = lcid_hex_str_to_lang_codes(langs) try: name = voice.GetAttribute("name") except Exception: # some voices may not have a name name = "unknown" name = self._tidy_name(name) return [WindowsVoice(name=name, lang=lang, handle=voice) for lang in langs] def _play(self, tag: AVTag) -> None: assert isinstance(tag, TTSTag) match = self.voice_for_tag(tag) assert match voice = cast(WindowsVoice, match.voice) try: native_voice = voice.handle self.speaker.Voice = native_voice self.speaker.Rate = self._rate_for_speed(tag.speed) # SAPI SpeechVoiceSpeakFlags: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ee125223(v=vs.85) ASYNC = 1 IS_NOT_XML = 16 self.speaker.Speak(tag.field_text, ASYNC + IS_NOT_XML) gui_hooks.av_player_did_begin_playing(self, tag) # wait 100ms while not self.speaker.WaitUntilDone(100): if self._terminate_flag: # stop playing self.speaker.Skip("Sentence", 2**15) return finally: self._terminate_flag = False def _tidy_name(self, name: str) -> str: "eg. Microsoft Haruka Desktop -> Microsoft_Haruka." return re.sub(r"^Microsoft (.+) Desktop$", "Microsoft_\\1", name).replace( " ", "_" ) def _rate_for_speed(self, speed: float) -> int: "eg. 1.5 -> 15, 0.5 -> -5" speed = (speed * 10) - 10 return int(max(-10, min(10, speed))) @dataclass class WindowsRTVoice(TTSVoice): id: str available: bool | None = None def unavailable(self) -> bool: return self.available is False @classmethod def from_backend_voice(cls, voice: BackendVoice) -> WindowsRTVoice: return cls( id=voice.id, name=voice.name.replace(" ", "_"), lang=voice.language.replace("-", "_"), available=voice.available, ) class WindowsRTTTSFilePlayer(TTSProcessPlayer): tmppath = os.path.join(tmpdir(), "tts.wav") def validated_voices(self) -> list[TTSVoice]: self._available_voices = self._get_available_voices(validate=True) return self._available_voices @classmethod def get_available_voices(cls) -> list[TTSVoice]: return cls._get_available_voices(validate=False) @staticmethod def _get_available_voices(validate: bool) -> list[TTSVoice]: assert aqt.mw voices = aqt.mw.backend.all_tts_voices(validate=validate) return list(map(WindowsRTVoice.from_backend_voice, voices)) def _play(self, tag: AVTag) -> None: assert aqt.mw assert isinstance(tag, TTSTag) match = self.voice_for_tag(tag) assert match voice = cast(WindowsRTVoice, match.voice) self._taskman.run_on_main( lambda: gui_hooks.av_player_did_begin_playing(self, tag) ) aqt.mw.backend.write_tts_stream( path=self.tmppath, voice_id=voice.id, speed=tag.speed, text=tag.field_text, ) def _on_done(self, ret: Future, cb: OnDoneCallback) -> None: if exception := ret.exception(): print(str(exception)) tooltip(tr.errors_windows_tts_runtime_error()) cb() return # inject file into the top of the audio queue from aqt.sound import av_player av_player.current_player = None av_player.insert_file(self.tmppath) ================================================ FILE: qt/aqt/undo.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from dataclasses import dataclass from anki.collection import UndoStatus @dataclass class UndoActionsInfo: can_undo: bool can_redo: bool undo_text: str redo_text: str # menu item is hidden when legacy undo is active, since it can't be undone show_redo: bool @staticmethod def from_undo_status(status: UndoStatus) -> UndoActionsInfo: from aqt import tr return UndoActionsInfo( can_undo=bool(status.undo), can_redo=bool(status.redo), undo_text=( tr.undo_undo_action(val=status.undo) if status.undo else tr.undo_undo() ), redo_text=( tr.undo_redo_action(action=status.redo) if status.redo else tr.undo_redo() ), show_redo=status.last_step > 0, ) ================================================ FILE: qt/aqt/update.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import aqt from anki.buildinfo import buildhash from anki.collection import CheckForUpdateResponse, Collection from anki.utils import dev_mode, int_time, int_version, plat_desc from aqt.operations import QueryOp from aqt.package import ( launcher_executable as _launcher_executable, ) from aqt.package import ( update_and_restart as _update_and_restart, ) from aqt.qt import * from aqt.utils import openLink, show_warning, showText, tr def check_for_update() -> None: from aqt import mw def do_check(_col: Collection) -> CheckForUpdateResponse: return mw.backend.check_for_update( version=int_version(), buildhash=buildhash, os=plat_desc(), install_id=mw.pm.meta["id"], last_message_id=max(0, mw.pm.meta["lastMsg"]), ) def on_done(resp: CheckForUpdateResponse) -> None: # is clock off? if not dev_mode: diff = abs(resp.current_time - int_time()) if diff > 300: diff_text = tr.qt_misc_second(count=diff) warn = ( tr.qt_misc_in_order_to_ensure_your_collection(val="%s") % diff_text ) show_warning( warn, parent=mw, textFormat=Qt.TextFormat.RichText, callback=mw.app.closeAllWindows, ) return # should we show a message? if msg := resp.message: showText(msg, parent=mw, type="html") mw.pm.meta["lastMsg"] = resp.last_message_id # has Anki been updated? if ver := resp.new_version: if mw.pm.meta.get("suppressUpdate", None) != ver: prompt_to_update(mw, ver) def on_fail(exc: Exception) -> None: print(f"update check failed: {exc}") QueryOp(parent=mw, op=do_check, success=on_done).failure( on_fail ).without_collection().run_in_background() def prompt_to_update(mw: aqt.AnkiQt, ver: str) -> None: msg = ( tr.qt_misc_anki_updatedanki_has_been_released(val=ver) + tr.qt_misc_would_you_like_to_download_it() ) msgbox = QMessageBox(mw) msgbox.setStandardButtons( QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) msgbox.setIcon(QMessageBox.Icon.Information) msgbox.setText(msg) button = QPushButton(tr.qt_misc_ignore_this_update()) msgbox.addButton(button, QMessageBox.ButtonRole.RejectRole) msgbox.setDefaultButton(QMessageBox.StandardButton.Yes) ret = msgbox.exec() if msgbox.clickedButton() == button: # ignore this update mw.pm.meta["suppressUpdate"] = ver elif ret == QMessageBox.StandardButton.Yes: if _launcher_executable(): _update_and_restart() else: openLink(aqt.appWebsiteDownloadSection) ================================================ FILE: qt/aqt/url_schemes.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations from markdown import markdown from aqt.qt import QMessageBox, Qt, QUrl from aqt.utils import MessageBox, getText, openLink, tr def show_url_schemes_dialog() -> None: from aqt import mw default = " ".join(mw.pm.allowed_url_schemes()) schemes, ok = getText( prompt=tr.preferences_url_scheme_prompt(), title=tr.preferences_url_schemes(), default=default, ) if ok: mw.pm.set_allowed_url_schemes(schemes.split(" ")) mw.pm.save() def is_supported_scheme(url: QUrl) -> bool: from aqt import mw scheme = url.scheme().lower() allowed_schemes = mw.pm.allowed_url_schemes() return scheme in allowed_schemes or scheme in ["http", "https"] def always_allow_scheme(url: QUrl) -> None: from aqt import mw scheme = url.scheme().lower() mw.pm.always_allow_scheme(scheme) def open_url_if_supported_scheme(url: QUrl) -> None: from aqt import mw if is_supported_scheme(url): openLink(url) else: def on_button(idx: int) -> None: if idx == 0: openLink(url) elif idx == 1: always_allow_scheme(url) openLink(url) msg = markdown( tr.preferences_url_scheme_warning(link=url.toString(), scheme=url.scheme()) ) MessageBox( msg, buttons=[ tr.preferences_url_scheme_allow_once(), tr.preferences_url_scheme_always_allow(), (tr.actions_cancel(), QMessageBox.ButtonRole.RejectRole), ], parent=mw, callback=on_button, textFormat=Qt.TextFormat.RichText, default_button=2, icon=QMessageBox.Icon.Warning, ) ================================================ FILE: qt/aqt/utils.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import enum import inspect import os import re import shutil import subprocess import sys from collections.abc import Callable, Sequence from functools import partial, wraps from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, Union from send2trash import send2trash import aqt from anki._legacy import DeprecatedNamesMixinForModule from anki.collection import Collection, HelpPage from anki.lang import TR, tr_legacyglobal # noqa: F401 from anki.utils import ( call, invalid_filename, is_mac, is_win, no_bundled_libs, version_with_build, ) from aqt.qt import * from aqt.qt import ( PYQT_VERSION_STR, QT_VERSION_STR, # noqa: F401 QAction, QApplication, QCheckBox, QColor, QComboBox, QDesktopServices, QDialog, QDialogButtonBox, QEvent, QFileDialog, QFrame, QHeaderView, QIcon, QLabel, QLineEdit, QListWidget, QMainWindow, QMenu, QMessageBox, QMouseEvent, QNativeGestureEvent, QOffscreenSurface, QOpenGLContext, QPalette, QPixmap, QPlainTextEdit, QPoint, QPushButton, QSize, QSplitter, QStandardPaths, Qt, QTextBrowser, QTextOption, QTimer, QUrl, QVBoxLayout, QWheelEvent, QWidget, pyqtSlot, qconnect, qtmajor, qtminor, qVersion, traceback, ) from aqt.theme import theme_manager if TYPE_CHECKING: TextFormat = Literal["plain", "rich", "markdown"] def aqt_data_path() -> Path: import _aqt.colors data_folder = Path(inspect.getfile(_aqt.colors)).with_name("data") if data_folder.exists(): return data_folder.absolute() else: # should only happen when running unit tests print("warning, data folder not found") return Path(".") def aqt_data_folder() -> str: return str(aqt_data_path()) # shortcut to access Fluent translations; set as tr = tr_legacyglobal HelpPageArgument = Union["HelpPage.V", str] def openHelp(section: HelpPageArgument) -> None: assert tr.backend is not None backend = tr.backend() assert backend is not None if isinstance(section, str): link = backend.help_page_link(page=HelpPage.INDEX) + section else: link = backend.help_page_link(page=section) openLink(link) def openLink(link: str | QUrl) -> None: tooltip(tr.qt_misc_loading(), period=1000) with no_bundled_libs(): QDesktopServices.openUrl(QUrl(link)) class MessageBox(QMessageBox): def __init__( self, text: str, callback: Callable[[int], None] | None = None, parent: QWidget | None = None, icon: QMessageBox.Icon = QMessageBox.Icon.NoIcon, help: HelpPageArgument | None = None, title: str = "Anki", buttons: ( Sequence[ str | QMessageBox.StandardButton | tuple[str, QMessageBox.ButtonRole] ] | None ) = None, default_button: int = 0, textFormat: Qt.TextFormat = Qt.TextFormat.PlainText, modality: Qt.WindowModality = Qt.WindowModality.WindowModal, ) -> None: parent = parent or aqt.mw.app.activeWindow() or aqt.mw super().__init__(parent) self.setText(text) self.setWindowTitle(title) self.setWindowModality(modality) self.setIcon(icon) if icon == QMessageBox.Icon.Question and theme_manager.night_mode: img = self.iconPixmap().toImage() img.invertPixels() self.setIconPixmap(QPixmap(img)) self.setTextFormat(textFormat) if buttons is None: buttons = [QMessageBox.StandardButton.Ok] for i, button in enumerate(buttons): if isinstance(button, str): b = self.addButton(button, QMessageBox.ButtonRole.ActionRole) elif isinstance(button, QMessageBox.StandardButton): b = self.addButton(button) # a translator has complained the default Qt translation is inappropriate, so we override it if button == QMessageBox.StandardButton.Discard: assert b is not None b.setText(tr.actions_discard()) elif isinstance(button, tuple): b = self.addButton(button[0], button[1]) else: continue if callback is not None: assert b is not None qconnect(b.clicked, partial(callback, i)) if i == default_button: self.setDefaultButton(b) if help is not None: b = self.addButton(QMessageBox.StandardButton.Help) assert b is not None qconnect(b.clicked, lambda: openHelp(help)) self.open() def ask_user( text: str, callback: Callable[[bool], None], defaults_yes: bool = True, **kwargs: Any, ) -> MessageBox: "Shows a yes/no question, passes the answer to the callback function as a bool." return MessageBox( text, callback=lambda response: callback(not response), icon=QMessageBox.Icon.Question, buttons=[QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No], default_button=not defaults_yes, **kwargs, ) def ask_user_dialog( text: str, callback: Callable[[int], None], buttons: ( Sequence[str | QMessageBox.StandardButton | tuple[str, QMessageBox.ButtonRole]] | None ) = None, default_button: int = 1, parent: QWidget | None = None, title: str = "Anki", **kwargs: Any, ) -> MessageBox: "Shows a question to the user, passes the index of the button clicked to the callback." if buttons is None: buttons = [QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No] return MessageBox( text, callback=callback, icon=QMessageBox.Icon.Question, buttons=buttons, default_button=default_button, parent=parent, title=title, **kwargs, ) def show_info( text: str, callback: Callable | None = None, parent: QWidget | None = None, **kwargs: Any, ) -> MessageBox: "Show a small info window with an OK button." if "icon" not in kwargs: kwargs["icon"] = QMessageBox.Icon.Information return MessageBox( text, callback=(lambda _: callback()) if callback is not None else None, parent=parent, **kwargs, ) def show_warning( text: str, callback: Callable | None = None, parent: QWidget | None = None, **kwargs: Any, ) -> MessageBox: "Show a small warning window with an OK button." return show_info( text, icon=QMessageBox.Icon.Warning, callback=callback, parent=parent, **kwargs ) def show_critical( text: str, callback: Callable | None = None, parent: QWidget | None = None, **kwargs: Any, ) -> MessageBox: "Show a small critical error window with an OK button." return show_info( text, icon=QMessageBox.Icon.Critical, callback=callback, parent=parent, **kwargs ) def showWarning( text: str, parent: QWidget | None = None, help: HelpPageArgument | None = None, title: str = "Anki", textFormat: TextFormat | None = None, ) -> int: "Show a small warning with an OK button." return showInfo(text, parent, help, "warning", title=title, textFormat=textFormat) def showCritical( text: str, parent: QDialog | None = None, help: str = "", title: str = "Anki", textFormat: TextFormat | None = None, ) -> int: "Show a small critical error with an OK button." return showInfo(text, parent, help, "critical", title=title, textFormat=textFormat) def showInfo( text: str, parent: QWidget | None = None, help: HelpPageArgument | None = None, type: str = "info", title: str = "Anki", textFormat: TextFormat | None = None, customBtns: list[QMessageBox.StandardButton] | None = None, ) -> int: "Show a small info window with an OK button." parent_widget: QWidget if parent is None: parent_widget = aqt.mw.app.activeWindow() or aqt.mw else: parent_widget = parent if type == "warning": icon = QMessageBox.Icon.Warning elif type == "critical": icon = QMessageBox.Icon.Critical else: icon = QMessageBox.Icon.Information mb = QMessageBox(parent_widget) if textFormat == "plain": mb.setTextFormat(Qt.TextFormat.PlainText) elif textFormat == "rich": mb.setTextFormat(Qt.TextFormat.RichText) elif textFormat == "markdown": mb.setTextFormat(Qt.TextFormat.MarkdownText) elif textFormat is not None: raise Exception("unexpected textFormat type") mb.setText(text) mb.setIcon(icon) mb.setWindowTitle(title) if customBtns: default = None for btn in customBtns: b = mb.addButton(btn) if not default: default = b mb.setDefaultButton(default) else: b = mb.addButton(QMessageBox.StandardButton.Ok) assert b is not None b.setDefault(True) if help is not None: b = mb.addButton(QMessageBox.StandardButton.Help) assert b is not None qconnect(b.clicked, lambda: openHelp(help)) b.setAutoDefault(False) return mb.exec() def showText( txt: str, parent: QWidget | None = None, type: str = "text", run: bool = True, geomKey: str | None = None, minWidth: int = 500, minHeight: int = 400, title: str = "Anki", copyBtn: bool = False, plain_text_edit: bool = False, ) -> tuple[QDialog, QDialogButtonBox] | None: if not parent: parent = aqt.mw.app.activeWindow() or aqt.mw diag = QDialog(parent) diag.setWindowTitle(title) disable_help_button(diag) layout = QVBoxLayout(diag) diag.setLayout(layout) text: QPlainTextEdit | QTextBrowser if plain_text_edit: # used by the importer text = QPlainTextEdit() text.setReadOnly(True) text.setWordWrapMode(QTextOption.WrapMode.NoWrap) text.setPlainText(txt) else: text = QTextBrowser() text.setOpenExternalLinks(True) if type == "text": text.setPlainText(txt) else: text.setHtml(txt) layout.addWidget(text) box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) layout.addWidget(box) if copyBtn: def onCopy() -> None: clipboard = QApplication.clipboard() assert clipboard is not None clipboard.setText(text.toPlainText()) btn = QPushButton(tr.qt_misc_copy_to_clipboard()) qconnect(btn.clicked, onCopy) box.addButton(btn, QDialogButtonBox.ButtonRole.ActionRole) def onReject() -> None: if geomKey: saveGeom(diag, geomKey) QDialog.reject(diag) qconnect(box.rejected, onReject) def onFinish() -> None: if geomKey: saveGeom(diag, geomKey) qconnect(box.accepted, onFinish) diag.setMinimumHeight(minHeight) diag.setMinimumWidth(minWidth) if geomKey: restoreGeom(diag, geomKey) if run: diag.exec() return None else: return diag, box def askUser( text: str, parent: QWidget | None = None, help: HelpPageArgument | None = None, defaultno: bool = False, msgfunc: Callable | None = None, title: str = "Anki", ) -> bool: "Show a yes/no question. Return true if yes." if not parent: parent = aqt.mw.app.activeWindow() if not msgfunc: msgfunc = QMessageBox.question sb = QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No if help: sb |= QMessageBox.StandardButton.Help while 1: if defaultno: default = QMessageBox.StandardButton.No else: default = QMessageBox.StandardButton.Yes r = msgfunc(parent, title, text, sb, default) if r == QMessageBox.StandardButton.Help: assert help is not None openHelp(help) else: break return r == QMessageBox.StandardButton.Yes class ButtonedDialog(QMessageBox): def __init__( self, text: str, buttons: list[str], parent: QWidget | None = None, help: HelpPageArgument | None = None, title: str = "Anki", ): QMessageBox.__init__(self, parent) self._buttons: list[QPushButton | None] = [] self.setWindowTitle(title) self.help = help self.setIcon(QMessageBox.Icon.Warning) self.setText(text) for b in buttons: self._buttons.append(self.addButton(b, QMessageBox.ButtonRole.AcceptRole)) if help: self.addButton(tr.actions_help(), QMessageBox.ButtonRole.HelpRole) buttons.append(tr.actions_help()) def run(self) -> str: self.exec() clicked_button = self.clickedButton() assert clicked_button is not None txt = clicked_button.text() if txt == "Help": # FIXME stop dialog closing? assert self.help is not None openHelp(self.help) # work around KDE 'helpfully' adding accelerators to button text of Qt apps return txt.replace("&", "") def setDefault(self, idx: int) -> None: self.setDefaultButton(self._buttons[idx]) def askUserDialog( text: str, buttons: list[str], parent: QWidget | None = None, help: HelpPageArgument | None = None, title: str = "Anki", ) -> ButtonedDialog: if not parent: parent = aqt.mw diag = ButtonedDialog(text, buttons, parent, help, title=title) return diag class GetTextDialog(QDialog): def __init__( self, parent: QWidget | None, question: str, help: HelpPageArgument | None = None, edit: QLineEdit | None = None, default: str = "", title: str = "Anki", minWidth: int = 400, ) -> None: QDialog.__init__(self, parent) self.setWindowTitle(title) disable_help_button(self) self.question = question self.help = help self.qlabel = QLabel(question) self.setMinimumWidth(minWidth) v = QVBoxLayout() v.addWidget(self.qlabel) if not edit: edit = QLineEdit() self.l = edit if default: self.l.setText(default) self.l.selectAll() v.addWidget(self.l) buts = ( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) if help: buts |= QDialogButtonBox.StandardButton.Help b = QDialogButtonBox(buts) # type: ignore v.addWidget(b) self.setLayout(v) ok_button = b.button(QDialogButtonBox.StandardButton.Ok) assert ok_button is not None qconnect(ok_button.clicked, self.accept) cancel_button = b.button(QDialogButtonBox.StandardButton.Cancel) assert cancel_button is not None qconnect(cancel_button.clicked, self.reject) if help: help_button = b.button(QDialogButtonBox.StandardButton.Help) assert help_button is not None qconnect(help_button.clicked, self.helpRequested) self.l.setFocus() def accept(self) -> None: return QDialog.accept(self) def reject(self) -> None: return QDialog.reject(self) def helpRequested(self) -> None: if self.help is not None: openHelp(self.help) def getText( prompt: str, parent: QWidget | None = None, help: HelpPageArgument | None = None, edit: QLineEdit | None = None, default: str = "", title: str = "Anki", geomKey: str | None = None, **kwargs: Any, ) -> tuple[str, int]: "Returns (string, succeeded)." if not parent: parent = aqt.mw.app.activeWindow() or aqt.mw d = GetTextDialog( parent, prompt, help=help, edit=edit, default=default, title=title, **kwargs ) d.setWindowModality(Qt.WindowModality.WindowModal) if geomKey: restoreGeom(d, geomKey) ret = d.exec() if geomKey and ret: saveGeom(d, geomKey) return (str(d.l.text()), ret) def getOnlyText(*args: Any, **kwargs: Any) -> str: (s, r) = getText(*args, **kwargs) if r: return s else: return "" # fixme: these utilities could be combined into a single base class # unused by Anki, but used by add-ons def chooseList( prompt: str, choices: list[str], startrow: int = 0, parent: Any | None = None ) -> int: if not parent: parent = aqt.mw.app.activeWindow() d = QDialog(parent) disable_help_button(d) d.setWindowModality(Qt.WindowModality.WindowModal) l = QVBoxLayout() d.setLayout(l) t = QLabel(prompt) l.addWidget(t) c = QListWidget() c.addItems(choices) c.setCurrentRow(startrow) l.addWidget(c) bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) qconnect(bb.accepted, d.accept) l.addWidget(bb) d.exec() return c.currentRow() def getTag( parent: QWidget, deck: Collection, question: str, **kwargs: Any ) -> tuple[str, int]: from aqt.tagedit import TagEdit te = TagEdit(parent) te.setCol(deck) ret = getText(question, parent, edit=te, geomKey="getTag", **kwargs) te.hideCompleter() return ret def disable_help_button(widget: QWidget) -> None: "Disable the help button in the window titlebar." widget.setWindowFlags( widget.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint ) def setWindowIcon(widget: QWidget) -> None: icon = QIcon() icon.addPixmap(QPixmap("icons:anki.png"), QIcon.Mode.Normal, QIcon.State.Off) widget.setWindowIcon(icon) # File handling ###################################################################### def getFile( parent: QWidget, title: str, # single file returned unless multi=True cb: Callable[[str | Sequence[str]], None] | None, filter: str = "*", dir: str | None = None, key: str | None = None, multi: bool = False, # controls whether a single or multiple files is returned ) -> Sequence[str] | str | None: "Ask the user for a file." if dir and key: raise Exception("expected dir or key") if not dir: assert aqt.mw.pm.profile is not None dirkey = f"{key}Directory" dir = aqt.mw.pm.profile.get(dirkey, "") else: dirkey = None d = QFileDialog(parent) mode = ( QFileDialog.FileMode.ExistingFiles if multi else QFileDialog.FileMode.ExistingFile ) d.setFileMode(mode) assert dir is not None if os.path.exists(dir): d.setDirectory(dir) d.setWindowTitle(title) d.setNameFilter(filter) ret = [] def accept() -> None: files = list(d.selectedFiles()) if dirkey: assert aqt.mw.pm.profile is not None dir = os.path.dirname(files[0]) aqt.mw.pm.profile[dirkey] = dir result = files if multi else files[0] if cb: cb(result) ret.append(result) qconnect(d.accepted, accept) if key: restoreState(d, key) d.exec() if key: saveState(d, key) return ret[0] if ret else None def running_in_sandbox(): """Check whether running in Flatpak or Snap. When in such a sandbox, Qt will not report the true location of user-chosen files, but instead a temporary location from which the sandboxing software will copy the file to the user-chosen destination. Thus file renames are impossible and caching the reported file location is unhelpful.""" in_flatpak = ( QStandardPaths.locate( QStandardPaths.StandardLocation.RuntimeLocation, "flatpak-info", ) != "" ) in_snap = bool(os.environ.get("SNAP")) return in_flatpak or in_snap def getSaveFile( parent: QDialog, title: str, dir_description: str, key: str, ext: str, fname: str = "", ) -> str | None: """Ask the user for a file to save. Use DIR_DESCRIPTION as config variable. The file dialog will default to open with FNAME.""" assert aqt.mw.pm.profile is not None config_key = f"{dir_description}Directory" defaultPath = QStandardPaths.writableLocation( QStandardPaths.StandardLocation.DocumentsLocation ) base = aqt.mw.pm.profile.get(config_key, defaultPath) path = os.path.join(base, fname) file = QFileDialog.getSaveFileName( parent, title, path, f"{key} (*{ext})", options=QFileDialog.Option.DontConfirmOverwrite, )[0] if file and not running_in_sandbox(): # add extension if not file.lower().endswith(ext): file += ext # save new default dir = os.path.dirname(file) aqt.mw.pm.profile[config_key] = dir # check if it exists if os.path.exists(file) and not askUser( tr.qt_misc_this_file_exists_are_you_sure(), parent ): return None return file class _QtStateKeyKind(enum.Enum): HEADER = enum.auto() SPLITTER = enum.auto() STATE = enum.auto() GEOMETRY = enum.auto() def _qt_state_key(kind: _QtStateKeyKind, key: str) -> str: """Construct a key used to save/restore geometry, state, etc. Adds Qt version number to key so that different data is saved per Qt version, preventing crashes and bugs when restoring data saved with a different Qt version. """ qt_suffix = f"{qtmajor}.{qtminor}" if qtmajor > 5 else "" return f"{key}{kind.name.capitalize()}{qt_suffix}" def saveGeom(widget: QWidget, key: str) -> None: # restoring a fullscreen window breaks the tab functionality of 5.15 if not widget.isFullScreen() or qtmajor == 6: assert aqt.mw.pm.profile is not None key = _qt_state_key(_QtStateKeyKind.GEOMETRY, key) aqt.mw.pm.profile[key] = widget.saveGeometry() def restoreGeom( widget: QWidget, key: str, adjustSize: bool = False, default_size: tuple[int, int] | None = None, ) -> None: assert aqt.mw.pm.profile is not None key = _qt_state_key(_QtStateKeyKind.GEOMETRY, key) if existing_geom := aqt.mw.pm.profile.get(key): widget.restoreGeometry(existing_geom) ensureWidgetInScreenBoundaries(widget) elif adjustSize: widget.adjustSize() elif default_size: widget.resize(*default_size) def ensureWidgetInScreenBoundaries(widget: QWidget) -> None: window = widget.window() assert window is not None handle = window.windowHandle() if not handle: # window has not yet been shown, retry later aqt.mw.progress.timer( 50, lambda: ensureWidgetInScreenBoundaries(widget), False, parent=widget ) return # ensure widget is smaller than screen bounds screen = handle.screen() assert screen is not None geom = screen.availableGeometry() wsize = widget.size() cappedWidth = min(geom.width(), wsize.width()) cappedHeight = min(geom.height(), wsize.height()) if cappedWidth < wsize.width() or cappedHeight < wsize.height(): widget.resize(QSize(cappedWidth, cappedHeight)) # ensure widget is inside top left wpos = widget.pos() x = max(geom.x(), wpos.x()) y = max(geom.y(), wpos.y()) # and bottom right x = min(x, geom.width() + geom.x() - cappedWidth) y = min(y, geom.height() + geom.y() - cappedHeight) if x != wpos.x() or y != wpos.y(): widget.move(x, y) def saveState(widget: QFileDialog | QMainWindow, key: str) -> None: assert aqt.mw.pm.profile is not None key = _qt_state_key(_QtStateKeyKind.STATE, key) aqt.mw.pm.profile[key] = widget.saveState() def restoreState(widget: QFileDialog | QMainWindow, key: str) -> None: assert aqt.mw.pm.profile is not None key = _qt_state_key(_QtStateKeyKind.STATE, key) if data := aqt.mw.pm.profile.get(key): widget.restoreState(data) def saveSplitter(widget: QSplitter, key: str) -> None: assert aqt.mw.pm.profile is not None key = _qt_state_key(_QtStateKeyKind.SPLITTER, key) aqt.mw.pm.profile[key] = widget.saveState() def restoreSplitter(widget: QSplitter, key: str) -> None: assert aqt.mw.pm.profile is not None key = _qt_state_key(_QtStateKeyKind.SPLITTER, key) if data := aqt.mw.pm.profile.get(key): widget.restoreState(data) def saveHeader(widget: QHeaderView, key: str) -> None: assert aqt.mw.pm.profile is not None key = _qt_state_key(_QtStateKeyKind.HEADER, key) aqt.mw.pm.profile[key] = widget.saveState() def restoreHeader(widget: QHeaderView, key: str) -> None: assert aqt.mw.pm.profile is not None key = _qt_state_key(_QtStateKeyKind.HEADER, key) if state := aqt.mw.pm.profile.get(key): widget.restoreState(state) def save_is_checked(widget: QCheckBox, key: str) -> None: assert aqt.mw.pm.profile is not None key += "IsChecked" aqt.mw.pm.profile[key] = widget.isChecked() def restore_is_checked(widget: QCheckBox, key: str) -> None: assert aqt.mw.pm.profile is not None key += "IsChecked" if aqt.mw.pm.profile.get(key) is not None: widget.setChecked(aqt.mw.pm.profile[key]) def save_combo_index_for_session(widget: QComboBox, key: str) -> None: textKey = f"{key}ComboActiveText" indexKey = f"{key}ComboActiveIndex" aqt.mw.pm.session[textKey] = widget.currentText() aqt.mw.pm.session[indexKey] = widget.currentIndex() def restore_combo_index_for_session( widget: QComboBox, history: list[str], key: str ) -> None: textKey = f"{key}ComboActiveText" indexKey = f"{key}ComboActiveIndex" text = aqt.mw.pm.session.get(textKey) index = aqt.mw.pm.session.get(indexKey) if text is not None and index is not None: if index < len(history) and history[index] == text: widget.setCurrentIndex(index) def save_combo_history(comboBox: QComboBox, history: list[str], name: str) -> str: assert aqt.mw.pm.profile is not None name += "BoxHistory" line_edit = comboBox.lineEdit() assert line_edit is not None text_input = line_edit.text() if text_input in history: history.remove(text_input) history.insert(0, text_input) history = history[:50] comboBox.clear() comboBox.addItems(history) aqt.mw.pm.session[name] = text_input aqt.mw.pm.profile[name] = history return text_input def restore_combo_history(comboBox: QComboBox, name: str) -> list[str]: assert aqt.mw.pm.profile is not None name += "BoxHistory" history = aqt.mw.pm.profile.get(name, []) comboBox.addItems([""] + history) if history: session_input = aqt.mw.pm.session.get(name) if session_input and session_input == history[0]: line_edit = comboBox.lineEdit() assert line_edit is not None line_edit.setText(session_input) line_edit.selectAll() return history def mungeQA(col: Collection, txt: str) -> str: print("mungeQA() deprecated; use mw.prepare_card_text_for_display()") txt = col.media.escape_media_filenames(txt) return txt def openFolder(path: str) -> None: if is_win: subprocess.run(["explorer", f"file://{path}"], check=False) else: with no_bundled_libs(): QDesktopServices.openUrl(QUrl.fromLocalFile(path)) def show_in_folder(path: str) -> None: if is_win: _show_in_folder_win32(path) elif is_mac: script = f""" tell application "Finder" activate select POSIX file "{path}" end tell """ call(osascript_to_args(script)) else: # For linux, there are multiple file managers. Let's test if one of the # most common file managers is found and use it in case it is installed. # If none of this list are installed, use a fallback. The fallback # might open the image in a web browser, image viewer or others, # depending on the users defaults. file_managers = [ "nautilus", # GNOME "dolphin", # KDE "pcmanfm", # LXDE "thunar", # XFCE "nemo", # Cinnamon "caja", # MATE ] available_file_manager = None # Test if a file manager is installed and use it, fallback otherwise for file_manager in file_managers: if shutil.which(file_manager): available_file_manager = file_manager break if available_file_manager: subprocess.run([available_file_manager, path], check=False) else: # Just open the file in any other platform with no_bundled_libs(): QDesktopServices.openUrl(QUrl.fromLocalFile(path)) def _show_in_folder_win32(path: str) -> None: import win32con import win32gui from aqt import mw def focus_explorer(): hwnd = win32gui.FindWindow("CabinetWClass", None) if hwnd: win32gui.ShowWindow(hwnd, win32con.SW_RESTORE) win32gui.SetForegroundWindow(hwnd) subprocess.run(["explorer", "/select,", path], check=False) mw.progress.single_shot(500, focus_explorer) def osascript_to_args(script: str): args = [ item for line in script.splitlines() for item in ("-e", line.strip()) if line.strip() ] return ["osascript"] + args def shortcut(key: str) -> str: if is_mac: return re.sub("(?i)ctrl", "Command", key) return key def maybeHideClose(bbox: QDialogButtonBox) -> None: if is_mac: b = bbox.button(QDialogButtonBox.StandardButton.Close) if b: bbox.removeButton(b) def downArrow() -> str: if is_win: return "▼" # windows 10 is lacking the smaller arrow on English installs return "▾" def current_window() -> QWidget | None: if widget := QApplication.focusWidget(): return widget.window() else: return None def send_to_trash(path: Path) -> None: "Place file/folder in recycling bin, or delete permanently on failure." if not path.exists(): return try: send2trash(path) except Exception as exc: # Linux users may not have a trash folder set up print("trash failure:", path, exc) if path.is_dir(): shutil.rmtree(path) else: path.unlink() # Tooltips ###################################################################### _tooltipTimer: QTimer | None = None _tooltipLabel: QLabel | None = None def tooltip( msg: str, period: int = 3000, parent: QWidget | None = None, x_offset: int = 0, y_offset: int = 100, ) -> None: global _tooltipTimer, _tooltipLabel class CustomLabel(QLabel): silentlyClose = True def mousePressEvent(self, evt: QMouseEvent | None) -> None: assert evt is not None evt.accept() self.hide() closeTooltip() aw = parent or aqt.mw.app.activeWindow() or aqt.mw lab = CustomLabel( f"""
{msg}
""", aw, ) lab.setFrameStyle(QFrame.Shape.Panel) lab.setLineWidth(2) lab.setWindowFlags(Qt.WindowType.ToolTip) if not theme_manager.night_mode: p = QPalette() p.setColor(QPalette.ColorRole.Window, QColor("#feffc4")) p.setColor(QPalette.ColorRole.WindowText, QColor("#000000")) lab.setPalette(p) lab.move(aw.mapToGlobal(QPoint(0 + x_offset, aw.height() - y_offset))) lab.show() _tooltipTimer = aqt.mw.progress.timer( period, closeTooltip, False, requiresCollection=False, parent=aw ) _tooltipLabel = lab def closeTooltip() -> None: global _tooltipLabel, _tooltipTimer if _tooltipLabel: try: _tooltipLabel.deleteLater() except RuntimeError: # already deleted as parent window closed pass _tooltipLabel = None if _tooltipTimer: try: _tooltipTimer.deleteLater() except RuntimeError: pass _tooltipTimer = None # true if invalid; print warning def checkInvalidFilename(str: str, dirsep: bool = True) -> bool: bad = invalid_filename(str, dirsep) if bad: showWarning(tr.qt_misc_the_following_character_can_not_be(val=bad)) return True return False # Menus ###################################################################### # This code will be removed in the future, please don't rely on it. MenuListChild = Union["SubMenu", QAction, "MenuItem", "MenuList"] class MenuList: def __init__(self) -> None: traceback.print_stack(file=sys.stdout) print( "MenuList will be removed; please copy it into your add-on's code if you need it." ) self.children: list[MenuListChild | None] = [] def addItem(self, title: str, func: Callable) -> MenuItem: item = MenuItem(title, func) self.children.append(item) return item def addSeparator(self) -> None: self.children.append(None) def addMenu(self, title: str) -> SubMenu: submenu = SubMenu(title) self.children.append(submenu) return submenu def addChild(self, child: SubMenu | QAction | MenuList) -> None: self.children.append(child) def renderTo(self, qmenu: QMenu) -> None: for child in self.children: if child is None: qmenu.addSeparator() elif isinstance(child, QAction): qmenu.addAction(child) else: child.renderTo(qmenu) def popupOver(self, widget: QPushButton) -> None: qmenu = QMenu() self.renderTo(qmenu) qmenu.exec(widget.mapToGlobal(QPoint(0, 0))) class SubMenu(MenuList): def __init__(self, title: str) -> None: super().__init__() self.title = title def renderTo(self, menu: QMenu) -> None: submenu = menu.addMenu(self.title) assert submenu is not None super().renderTo(submenu) class MenuItem: def __init__(self, title: str, func: Callable) -> None: self.title = title self.func = func def renderTo(self, qmenu: QMenu) -> None: a = qmenu.addAction(self.title) assert a is not None qconnect(a.triggered, self.func) def qtMenuShortcutWorkaround(qmenu: QMenu) -> None: for act in qmenu.actions(): act.setShortcutVisibleInContextMenu(True) ###################################################################### def disallow_full_screen() -> bool: """Test for OpenGl on Windows, which is known to cause issues with full screen mode.""" from aqt import mw from aqt.profiles import VideoDriver return is_win and ( mw.pm.video_driver() == VideoDriver.OpenGL and not os.environ.get("ANKI_SOFTWAREOPENGL") ) def add_ellipsis_to_action_label(*actions: QAction | QPushButton) -> None: """Pass actions to add '...' to their labels, indicating that more input is required before they can be performed. This approach is used so that the same fluent translations can be used on mobile, where the '...' convention does not exist. """ for action in actions: action.setText(tr.actions_with_ellipsis(action=action.text())) def supportText() -> str: import platform from aqt import mw platname = platform.platform() return """\ Anki {} {} Python {} Qt {} PyQt {} Platform: {} """.format( version_with_build(), "(ao)" if mw.addonManager.dirty else "", platform.python_version(), qVersion(), PYQT_VERSION_STR, platname, ) ###################################################################### # adapted from version detection in qutebrowser def opengl_vendor() -> str | None: if qtmajor != 5: return "unknown" old_context = QOpenGLContext.currentContext() old_surface = None if old_context is None else old_context.surface() surface = QOffscreenSurface() surface.create() ctx = QOpenGLContext() ok = ctx.create() if not ok: return None ok = ctx.makeCurrent(surface) if not ok: return None try: if ctx.isOpenGLES(): # Can't use versionFunctions there return None vp = QOpenGLVersionProfile() # type: ignore vp.setVersion(2, 0) try: vf = ctx.versionFunctions(vp) # type: ignore except ImportError: return None if vf is None: return None return vf.glGetString(vf.GL_VENDOR) finally: ctx.doneCurrent() if old_context and old_surface: old_context.makeCurrent(old_surface) def gfxDriverIsBroken() -> bool: driver = opengl_vendor() return driver == "nouveau" ###################################################################### def startup_info() -> Any: "Use subprocess.Popen(startupinfo=...) to avoid opening a console window." if sys.platform != "win32": return None si = subprocess.STARTUPINFO() # pytype: disable=module-attr si.dwFlags |= subprocess.STARTF_USESHOWWINDOW # pytype: disable=module-attr return si def ensure_editor_saved(func: Callable) -> Callable: """Ensure the current editor's note is saved before running the wrapped function. Must be used on functions that may be invoked from a shortcut key while the editor has focus. For functions that can't be activated while the editor has focus, you don't need this. Will look for the editor as self.editor. """ @wraps(func) def decorated(self: Any, *args: Any, **kwargs: Any) -> None: self.editor.call_after_note_saved(lambda: func(self, *args, **kwargs)) return decorated def skip_if_selection_is_empty(func: Callable) -> Callable: """Make the wrapped method a no-op and show a hint if the table selection is empty.""" @wraps(func) def decorated(self: Any, *args: Any, **kwargs: Any) -> None: if self.table.len_selection() > 0: func(self, *args, **kwargs) else: tooltip(tr.browsing_no_selection()) return decorated def no_arg_trigger(func: Callable) -> Callable: """Tells Qt this function takes no args. This ensures PyQt doesn't attempt to pass a `toggled` arg into functions connected to a `triggered` signal. """ return pyqtSlot()(func) # type: ignore def is_gesture_or_zoom_event(evt: QEvent) -> bool: """If the event is a gesture and/or will trigger zoom. Includes zoom by pinching, and Ctrl-scrolling on Win and Linux. """ return isinstance(evt, QNativeGestureEvent) or ( isinstance(evt, QWheelEvent) and not is_mac and KeyboardModifiersPressed().control ) class KeyboardModifiersPressed: "Util for type-safe checks of currently-pressed modifier keys." def __init__(self) -> None: from aqt import mw self._modifiers = mw.app.keyboardModifiers() @property def shift(self) -> bool: return bool(self._modifiers & Qt.KeyboardModifier.ShiftModifier) @property def control(self) -> bool: return bool(self._modifiers & Qt.KeyboardModifier.ControlModifier) @property def alt(self) -> bool: return bool(self._modifiers & Qt.KeyboardModifier.AltModifier) @property def meta(self) -> bool: return bool(self._modifiers & Qt.KeyboardModifier.MetaModifier) # add-ons attempting to import isMac from this module :-( _deprecated_names = DeprecatedNamesMixinForModule(globals()) if not TYPE_CHECKING: def __getattr__(name: str) -> Any: return _deprecated_names.__getattr__(name) ================================================ FILE: qt/aqt/webview.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import dataclasses import json import os import re import sys from collections.abc import Callable, Sequence from enum import Enum from typing import TYPE_CHECKING, Any, Type, cast from typing_extensions import TypedDict, Unpack import anki import anki.lang from anki._legacy import deprecated from anki.lang import is_rtl from anki.utils import hmr_mode, is_lin, is_mac, is_win from aqt import colors, gui_hooks from aqt.qt import * from aqt.qt import sip from aqt.theme import theme_manager from aqt.utils import askUser, is_gesture_or_zoom_event, openLink, showInfo, tr serverbaseurl = re.compile(r"^.+:\/\/[^\/]+") if TYPE_CHECKING: from aqt.mediasrv import PageContext BridgeCommandHandler = Callable[[str], Any] class AnkiWebViewKind(Enum): """Enum registry of all web views managed by Anki The value of each entry corresponds to the web view's title. When introducing a new web view, please add it to the registry below. """ DEFAULT = "default" MAIN = "main webview" TOP_TOOLBAR = "top toolbar" BOTTOM_TOOLBAR = "bottom toolbar" DECK_OPTIONS = "deck options" EDITOR = "editor" LEGACY_DECK_STATS = "legacy deck stats" DECK_STATS = "deck stats" PREVIEWER = "previewer" CHANGE_NOTETYPE = "change notetype" CARD_LAYOUT = "card layout" BROWSER_CARD_INFO = "browser card info" IMPORT_CSV = "csv import" EMPTY_CARDS = "empty cards" FIND_DUPLICATES = "find duplicates" FIELDS = "fields" IMPORT_LOG = "import log" IMPORT_ANKI_PACKAGE = "anki package import" class AuthInterceptor(QWebEngineUrlRequestInterceptor): _api_enabled = False def __init__(self, parent: QObject | None = None, api_enabled: bool = False): super().__init__(parent) self._api_enabled = api_enabled def interceptRequest(self, info): from aqt.mediasrv import _APIKEY if self._api_enabled and info.requestUrl().host() == "127.0.0.1": info.setHttpHeader(b"Authorization", f"Bearer {_APIKEY}".encode("utf-8")) def _create_bridge_script() -> QWebEngineScript: qwebchannel = ":/qtwebchannel/qwebchannel.js" jsfile = QFile(qwebchannel) if not jsfile.open(QIODevice.OpenModeFlag.ReadOnly): print(f"Error opening '{qwebchannel}': {jsfile.error()}", file=sys.stderr) jstext = bytes(cast(bytes, jsfile.readAll())).decode("utf-8") jsfile.close() script = QWebEngineScript() script.setSourceCode( jstext + """ var pycmd, bridgeCommand; new QWebChannel(qt.webChannelTransport, function(channel) { bridgeCommand = pycmd = function (arg, cb) { var resultCB = function (res) { // pass result back to user-provided callback if (cb) { cb(JSON.parse(res)); } } channel.objects.py.cmd(arg, resultCB); return false; } pycmd("domDone"); }); """ ) script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld) script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady) script.setRunsOnSubFrames(False) return script _bridge_script = _create_bridge_script() _profile_with_api_access: QWebEngineProfile | None = None _profile_without_api_access: QWebEngineProfile | None = None class AnkiWebPage(QWebEnginePage): def __init__( self, onBridgeCmd: BridgeCommandHandler, kind: AnkiWebViewKind = AnkiWebViewKind.DEFAULT, parent: QObject | None = None, ) -> None: profile = self._profileForPage(kind) self._inject_user_script(profile, _bridge_script) QWebEnginePage.__init__(self, profile, parent) self._onBridgeCmd = onBridgeCmd self._kind = kind self._setupBridge() self.open_links_externally = True def _profileForPage(self, kind: AnkiWebViewKind) -> QWebEngineProfile: have_api_access = kind in ( AnkiWebViewKind.DECK_OPTIONS, AnkiWebViewKind.EDITOR, AnkiWebViewKind.DECK_STATS, AnkiWebViewKind.CHANGE_NOTETYPE, AnkiWebViewKind.BROWSER_CARD_INFO, AnkiWebViewKind.IMPORT_ANKI_PACKAGE, AnkiWebViewKind.IMPORT_CSV, AnkiWebViewKind.IMPORT_LOG, ) global _profile_with_api_access, _profile_without_api_access # Use cached profile if available if have_api_access and _profile_with_api_access is not None: return _profile_with_api_access elif not have_api_access and _profile_without_api_access is not None: return _profile_without_api_access # Create a new profile if not cached profile = QWebEngineProfile() interceptor = AuthInterceptor(profile, api_enabled=have_api_access) profile.setUrlRequestInterceptor(interceptor) if have_api_access: _profile_with_api_access = profile else: _profile_without_api_access = profile return profile def _setupBridge(self) -> None: # Add-on compatibility: For existing add-on callers that override the init # and invoke _setupBridge directly (e.g. in order to use a custom web profile), # we need to ensure that the bridge script is injected into the profile scripts, # if it has yet to be injected. profile = self.profile() assert profile is not None scripts = profile.scripts() assert scripts is not None if not scripts.contains(_bridge_script): print("add-on callers should not call _setupBridge directly") self._inject_user_script(profile, _bridge_script) class Bridge(QObject): def __init__(self, bridge_handler: Callable[[str], Any]) -> None: super().__init__() self.onCmd = bridge_handler @pyqtSlot(str, result=str) # type: ignore def cmd(self, str: str) -> Any: return json.dumps(self.onCmd(str)) self._bridge = Bridge(self._onCmd) self._channel = QWebChannel(self) self._channel.registerObject("py", self._bridge) self.setWebChannel(self._channel) def _inject_user_script( self, profile: QWebEngineProfile, script: QWebEngineScript ) -> None: scripts = profile.scripts() assert scripts is not None scripts.insert(script) def javaScriptConsoleMessage( self, level: QWebEnginePage.JavaScriptConsoleMessageLevel, msg: str | None, line: int, srcID: str | None, ) -> None: # not translated because console usually not visible, # and may only accept ascii text assert srcID is not None if srcID.startswith("data"): srcID = "" else: srcID = serverbaseurl.sub("", srcID[:80], 1) if level == QWebEnginePage.JavaScriptConsoleMessageLevel.InfoMessageLevel: level_str = "info" elif level == QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: level_str = "warning" elif level == QWebEnginePage.JavaScriptConsoleMessageLevel.ErrorMessageLevel: level_str = "error" else: level_str = str(level) buf = "JS %(t)s %(f)s:%(a)d %(b)s" % dict( t=level_str, a=line, f=srcID, b=f"{msg}\n" ) if "MathJax localStorage" in buf: # silence localStorage noise return elif "link preload" in buf: # silence 'link preload' warning on the first card return # ensure we don't try to write characters the terminal can't handle buf = buf.encode(sys.stdout.encoding, "backslashreplace").decode( sys.stdout.encoding ) # output to stdout because it may raise error messages on the anki GUI # https://github.com/ankitects/anki/pull/560 sys.stdout.write(buf) def acceptNavigationRequest( self, url: QUrl, navType: Any, isMainFrame: bool ) -> bool: from aqt.mediasrv import is_sveltekit_page if ( not self.open_links_externally or "_anki/pages" in url.path() or url.path() == "/_anki/legacyPageData" or is_sveltekit_page(url.path()[1:]) ): return super().acceptNavigationRequest(url, navType, isMainFrame) if not isMainFrame: return True # data: links generated by setHtml() if url.scheme() == "data": return True # catch buggy links from aqt import mw if url.matches( QUrl(mw.serverURL()), cast(Any, QUrl.UrlFormattingOption.RemoveFragment) ): print("onclick handler needs to return false") return False # load all other links in browser from aqt.url_schemes import open_url_if_supported_scheme open_url_if_supported_scheme(url) return False def _onCmd(self, str: str) -> Any: return self._onBridgeCmd(str) def javaScriptAlert(self, frame: Any, text: str | None) -> None: if text is None: return showInfo(text) def javaScriptConfirm(self, frame: Any, text: str | None) -> bool: if text is None: return False return askUser(text) # Add-ons ########################################################################## @dataclasses.dataclass class WebContent: """Stores all dynamically modified content that a particular web view will be populated with. Attributes: body {str} -- HTML body head {str} -- HTML head css {List[str]} -- List of media server subpaths, each pointing to a CSS file js {List[str]} -- List of media server subpaths, each pointing to a JS file Important Notes: - When modifying the attributes specified above, please make sure your changes only perform the minimum required edits to make your add-on work. You should avoid overwriting or interfering with existing data as much as possible, instead opting to append your own changes, e.g.: def on_webview_will_set_content(web_content: WebContent, context) -> None: web_content.body += "" web_content.head += "" - The paths specified in `css` and `js` need to be accessible by Anki's media server. All list members without a specified subpath are assumed to be located under `/_anki`, which is the media server subpath used for all web assets shipped with Anki. Add-ons may expose their own web assets by utilizing aqt.addons.AddonManager.setWebExports(). Web exports registered in this manner may then be accessed under the `/_addons` subpath. E.g., to allow access to a `my-addon.js` and `my-addon.css` residing in a "web" subfolder in your add-on package, first register the corresponding web export: > from aqt import mw > mw.addonManager.setWebExports(__name__, r"web/.*(css|js)") Then append the subpaths to the corresponding web_content fields within a function subscribing to gui_hooks.webview_will_set_content: def on_webview_will_set_content(web_content: WebContent, context) -> None: addon_package = mw.addonManager.addonFromModule(__name__) web_content.css.append( f"/_addons/{addon_package}/web/my-addon.css") web_content.js.append( f"/_addons/{addon_package}/web/my-addon.js") Note that '/' will also match the os specific path separator. """ body: str = "" head: str = "" css: list[str] = dataclasses.field(default_factory=lambda: []) js: list[str] = dataclasses.field(default_factory=lambda: []) # Main web view ########################################################################## class AnkiWebView(QWebEngineView): allow_drops = False _kind: AnkiWebViewKind def __init__( self, parent: QWidget | None = None, title: str = "", # used by add-ons; in Anki code use kind instead to set title kind: AnkiWebViewKind = AnkiWebViewKind.DEFAULT, ) -> None: QWebEngineView.__init__(self, parent=parent) self._kind = kind self.set_title(kind.value) self.setPage(AnkiWebPage(self._onBridgeCmd, kind, self)) # reduce flicker self.page().setBackgroundColor(theme_manager.qcolor(colors.CANVAS)) # in new code, use .set_bridge_command() instead of setting this directly self.onBridgeCmd: Callable[[str], Any] = self.defaultOnBridgeCmd self._domDone = True self._pendingActions: list[tuple[str, Sequence[Any]]] = [] self.requiresCol = True self._disable_zoom = False self.resetHandlers() self._filterSet = False gui_hooks.theme_did_change.append(self.on_theme_did_change) gui_hooks.body_classes_need_update.append(self.on_body_classes_need_update) qconnect(self.loadFinished, self._on_load_finished) def _on_load_finished(self) -> None: self.eval( """ document.addEventListener("keydown", function(evt) { if (evt.key === "Escape") { pycmd("close"); } }); """ ) def page(self) -> AnkiWebPage: return cast(AnkiWebPage, super().page()) @property def kind(self) -> AnkiWebViewKind: """Used by add-ons to identify the webview kind""" return self._kind def set_title(self, title: str) -> None: self.title = title # type: ignore[assignment] def disable_zoom(self) -> None: self._disable_zoom = True def createWindow(self, windowType: QWebEnginePage.WebWindowType) -> QWebEngineView: # intercept opening a new window (hrefs # with target="_blank") and return view return AnkiWebView() def eventFilter(self, obj: QObject | None, evt: QEvent | None) -> bool: if evt is None: return False if self._disable_zoom and is_gesture_or_zoom_event(evt): return True if ( isinstance(evt, QMouseEvent) and evt.type() == QEvent.Type.MouseButtonRelease ): from aqt import mw if evt.button() == Qt.MouseButton.MiddleButton and is_lin: if mw.pm.middle_click_paste_enabled(): self.onMiddleClickPaste() return True return False def set_open_links_externally(self, enable: bool) -> None: self.page().open_links_externally = enable def onEsc(self) -> None: w = self.parent() while w: if isinstance(w, QDialog) or isinstance(w, QMainWindow): from aqt import mw # esc in a child window closes the window if w != mw: w.close() else: # in the main window, removes focus from type in area parent = self.parent() assert isinstance(parent, QWidget) parent.setFocus() break w = w.parent() def onCopy(self) -> None: self.triggerPageAction(QWebEnginePage.WebAction.Copy) def onCut(self) -> None: self.triggerPageAction(QWebEnginePage.WebAction.Cut) def onPaste(self) -> None: self.triggerPageAction(QWebEnginePage.WebAction.Paste) def onMiddleClickPaste(self) -> None: self.triggerPageAction(QWebEnginePage.WebAction.Paste) def onSelectAll(self) -> None: self.triggerPageAction(QWebEnginePage.WebAction.SelectAll) def contextMenuEvent(self, evt: QContextMenuEvent | None) -> None: m = QMenu(self) self._maybe_add_copy_action(m) gui_hooks.webview_will_show_context_menu(self, m) if m.actions(): m.popup(QCursor.pos()) def _maybe_add_copy_action(self, menu: QMenu) -> None: if self.hasSelection(): a = menu.addAction(tr.actions_copy()) assert a is not None qconnect(a.triggered, self.onCopy) def dropEvent(self, evt: QDropEvent | None) -> None: if self.allow_drops: super().dropEvent(evt) def setHtml( # type: ignore[override] self, html: str, context: PageContext | None = None ) -> None: from aqt.mediasrv import PageContext # discard any previous pending actions self._pendingActions = [] self._domDone = True if context is None: context = PageContext.UNKNOWN self._queueAction("setHtml", html, context) self.set_open_links_externally(True) self.allow_drops = False self.show() def _setHtml(self, html: str, context: PageContext) -> None: """Send page data to media server, then surf to it. This function used to be implemented by QWebEngine's .setHtml() call. It is no longer used, as it has a maximum size limit, and due to security changes, it will stop working in the future.""" from aqt import mw oldFocus = mw.app.focusWidget() self._domDone = False webview_id = id(self) mw.mediaServer.set_page_html(webview_id, html, context) self.load_url(QUrl(f"{mw.serverURL()}_anki/legacyPageData?id={webview_id}")) # work around webengine stealing focus on setHtml() # fixme: check which if any qt versions this is still required on if oldFocus: oldFocus.setFocus() def load_url(self, url: QUrl) -> None: # allow queuing actions when loading url directly self._domDone = False self.allow_drops = False super().load(url) def app_zoom_factor(self) -> float: # overridden scale factor? webscale = os.environ.get("ANKI_WEBSCALE") if webscale: return float(webscale) if qtmajor > 5 or is_mac: return 1 screen = QApplication.desktop().screen() # type: ignore if screen is None: return 1 dpi = screen.logicalDpiX() factor = dpi / 96.0 if is_lin: factor = max(1, factor) return factor return 1 def setPlaybackRequiresGesture(self, value: bool) -> None: settings = self.settings() assert settings is not None settings.setAttribute( QWebEngineSettings.WebAttribute.PlaybackRequiresUserGesture, value ) def _getQtIntScale(self, screen: QWidget) -> int: # try to detect if Qt has scaled the screen # - qt will round the scale factor to a whole number, so a dpi of 125% = 1x, # and a dpi of 150% = 2x # - a screen with a normal physical dpi of 72 will have a dpi of 32 # if the scale factor has been rounded to 2x # - different screens have different physical DPIs (eg 72, 93, 102) # - until a better solution presents itself, assume a physical DPI at # or above 70 is unscaled if screen.physicalDpiX() > 70: return 1 elif screen.physicalDpiX() > 35: return 2 else: return 3 def standard_css(self) -> str: color_hl = theme_manager.var(colors.BORDER_FOCUS) if is_win: # T: include a font for your language on Windows, eg: "Segoe UI", "MS Mincho" family = tr.qt_misc_segoe_ui() button_style = f""" button {{ font-family: {family}; }} """ font = f"font-family:{family};" elif is_mac: family = "Helvetica" font = f'font-family:"{family}";' button_style = """ button { --canvas: #fff; -webkit-appearance: none; background: var(--canvas); border-radius: var(--border-radius); padding: 3px 12px; border: 1px solid var(--border); box-shadow: 0px 1px 3px var(--border-subtle); font-family: Helvetica } .night-mode button { --canvas: #606060; --fg: #eee; } """ else: family = self.font().family() font = f'font-family:"{family}", sans-serif;' button_style = """ /* Buttons */ button{{ font-family: "{family}", sans-serif; }} /* Input field focus outline */ textarea:focus, input:focus, input[type]:focus, .uneditable-input:focus, div[contenteditable="true"]:focus {{ outline: 0 none; border-color: {color_hl}; }}""".format( family=family, color_hl=color_hl, ) zoom = self.app_zoom_factor() return f""" body {{ zoom: {zoom}; background-color: var(--canvas); }} html {{ {font} }} {button_style} :root {{ --canvas: {colors.CANVAS["light"]} }} :root[class*=night-mode] {{ --canvas: {colors.CANVAS["dark"]} }} """ def stdHtml( self, body: str, css: list[str] | None = None, js: list[str] | None = None, head: str = "", context: Any | None = None, default_css: bool = True, ) -> None: css = (["css/webview.css"] if default_css else []) + ( [] if css is None else css ) web_content = WebContent( body=body, head=head, js=["js/webview.js"] + (["js/vendor/jquery.min.js"] if js is None else js), css=css, ) gui_hooks.webview_will_set_content(web_content, context) csstxt = "" if "css/webview.css" in css: # we want our dynamic styling to override the defaults in # css/webview.css, but come before user-provided stylesheets so that # they can override us if necessary web_content.css.remove("css/webview.css") csstxt = self.bundledCSS("css/webview.css") csstxt += f"" csstxt += "\n".join(self.bundledCSS(fname) for fname in web_content.css) jstxt = "\n".join(self.bundledScript(fname) for fname in web_content.js) from aqt import mw head = mw.baseHTML() + csstxt + web_content.head body_class = theme_manager.body_class() if theme_manager.night_mode: doc_class = "night-mode" bs_theme = "dark" else: doc_class = "" bs_theme = "light" if is_rtl(anki.lang.current_lang): lang_dir = "rtl" else: lang_dir = "ltr" html = f""" {self.title} {head} {jstxt} {web_content.body} """ # print(html) import aqt.browser.previewer import aqt.clayout import aqt.deckoptions import aqt.editor import aqt.reviewer from aqt.mediasrv import PageContext if isinstance(context, aqt.editor.Editor): page_context = PageContext.EDITOR elif isinstance(context, aqt.reviewer.Reviewer): page_context = PageContext.REVIEWER elif isinstance(context, aqt.browser.previewer.Previewer): page_context = PageContext.PREVIEWER elif isinstance(context, aqt.clayout.CardLayout): page_context = PageContext.CARD_LAYOUT elif isinstance(context, aqt.deckoptions.DeckOptionsDialog): page_context = PageContext.DECK_OPTIONS else: page_context = PageContext.UNKNOWN self.setHtml(html, page_context) @classmethod def webBundlePath(cls, path: str) -> str: from aqt import mw if path.startswith("/"): subpath = "" else: subpath = "/_anki/" return f"http://127.0.0.1:{mw.mediaServer.getPort()}{subpath}{path}" def bundledScript(self, fname: str) -> str: return f'' def bundledCSS(self, fname: str) -> str: return '' % self.webBundlePath( fname ) def eval(self, js: str) -> None: self.evalWithCallback(js, None) def evalWithCallback(self, js: str, cb: Callable | None) -> None: self._queueAction("eval", js, cb) def _evalWithCallback(self, js: str, cb: Callable[[Any], Any] | None) -> None: page = self.page() assert page is not None def handler(val: Any) -> None: if self._shouldIgnoreWebEvent(): print("ignored late js callback", cb) return if cb: cb(val) # Without the following, stale frames showing previous or corrupt content get occasionally displayed. (see #3668 for more details) self.update() page.runJavaScript(js, handler) def _queueAction(self, name: str, *args: Any) -> None: self._pendingActions.append((name, args)) self._maybeRunActions() def _maybeRunActions(self) -> None: if sip.isdeleted(self): return while self._pendingActions and self._domDone: name, args = self._pendingActions.pop(0) if name == "eval": self._evalWithCallback(*args) elif name == "setHtml": self._setHtml(*args) else: raise Exception(f"unknown action: {name}") def _openLinksExternally(self, url: str) -> None: openLink(url) def _shouldIgnoreWebEvent(self) -> bool: # async web events may be received after the profile has been closed # or the underlying webview has been deleted from aqt import mw if sip.isdeleted(self): return True if not mw.col and self.requiresCol: return True return False def _onBridgeCmd(self, cmd: str) -> Any: if self._shouldIgnoreWebEvent(): print("ignored late bridge cmd", cmd) return if not self._filterSet: focus_proxy = self.focusProxy() assert focus_proxy is not None focus_proxy.installEventFilter(self) self._filterSet = True if cmd == "domDone": self._domDone = True self._maybeRunActions() elif cmd == "close": self.onEsc() else: handled, result = gui_hooks.webview_did_receive_js_message( (False, None), cmd, self._bridge_context ) if handled: return result else: return self.onBridgeCmd(cmd) def defaultOnBridgeCmd(self, cmd: str) -> None: print("unhandled bridge cmd:", cmd) # legacy def resetHandlers(self) -> None: self.onBridgeCmd = self.defaultOnBridgeCmd self._bridge_context = None def adjustHeightToFit(self) -> None: self.evalWithCallback("document.documentElement.offsetHeight", self._onHeight) def _onHeight(self, qvar: int | None) -> None: from aqt import mw if qvar is None: mw.progress.single_shot(1000, mw.reset) return self.setFixedHeight(int(qvar)) def set_bridge_command(self, func: Callable[[str], Any], context: Any) -> None: """Set a handler for pycmd() messages received from Javascript. Context is the object calling this routine, eg an instance of aqt.reviewer.Reviewer or aqt.deckbrowser.DeckBrowser.""" self.onBridgeCmd = func self._bridge_context = context def hide_while_preserving_layout(self) -> None: "Hide but keep existing size." sp = self.sizePolicy() sp.setRetainSizeWhenHidden(True) self.setSizePolicy(sp) self.hide() def add_dynamic_styling_and_props_then_show(self) -> None: "Add dynamic styling, title, set platform-specific body classes and reveal." css = self.standard_css() body_classes = theme_manager.body_class().split(" ") def after_injection(arg: Any) -> None: gui_hooks.webview_did_inject_style_into_page(self) self.show() if theme_manager.night_mode: night_mode = 'document.documentElement.classList.add("night-mode");' else: night_mode = "" self.evalWithCallback( f""" (function(){{ document.title = `{self.title}`; const style = document.createElement('style'); style.innerHTML = `{css}`; document.head.appendChild(style); document.body.classList.add({", ".join([f'"{c}"' for c in body_classes])}); {night_mode} }})(); """, after_injection, ) def load_ts_page(self, name: str) -> None: from aqt import mw self.set_open_links_externally(True) if theme_manager.night_mode: extra = "#night" else: extra = "" self.load_url(QUrl(f"{mw.serverURL()}_anki/pages/{name}.html{extra}")) self.add_dynamic_styling_and_props_then_show() def load_sveltekit_page(self, path: str) -> None: from aqt import mw self.set_open_links_externally(True) if theme_manager.night_mode: extra = "#night" else: extra = "" if hmr_mode: server = "http://127.0.0.1:5173/" else: server = mw.serverURL() self.load_url(QUrl(f"{server}{path}{extra}")) self.add_dynamic_styling_and_props_then_show() def force_load_hack(self) -> None: """Force process to initialize. Must be done on Windows prior to changing current working directory.""" self.requiresCol = False self._domReady = False self.page().setContent(cast(QByteArray, bytes("", "ascii"))) def cleanup(self) -> None: try: from aqt import mw except ImportError: # this will fail when __del__ is called during app shutdown return gui_hooks.theme_did_change.remove(self.on_theme_did_change) gui_hooks.body_classes_need_update.remove(self.on_body_classes_need_update) # defer page cleanup so that in-flight requests have a chance to complete first # https://forums.ankiweb.net/t/error-when-exiting-browsing-when-the-software-is-installed-in-the-path-c-program-files-anki/38363 mw.progress.single_shot(5000, lambda: mw.mediaServer.clear_page_html(id(self))) self.page().deleteLater() def on_theme_did_change(self) -> None: # avoid flashes if page reloaded self.page().setBackgroundColor(theme_manager.qcolor(colors.CANVAS)) # update night-mode class, and legacy nightMode/night-mode body classes self.eval( f""" (function() {{ const doc = document.documentElement; const body = document.body.classList; if ({1 if theme_manager.night_mode else 0}) {{ doc.dataset.bsTheme = "dark"; doc.classList.add("night-mode"); body.add("night_mode"); body.add("nightMode"); {"body.add('macos-dark-mode');" if theme_manager.macos_dark_mode() else ""} }} else {{ doc.dataset.bsTheme = "light"; doc.classList.remove("night-mode"); body.remove("night_mode"); body.remove("nightMode"); body.remove("macos-dark-mode"); }} }})(); """ ) def on_body_classes_need_update(self) -> None: from aqt import mw self.eval( f"""document.body.classList.toggle("fancy", {json.dumps(not mw.pm.minimalist_mode())}); """ ) self.eval( f"""document.body.classList.toggle("reduce-motion", {json.dumps(mw.pm.reduce_motion())}); """ ) @deprecated(info="use theme_manager.qcolor() instead") def get_window_bg_color(self, night_mode: bool | None = None) -> QColor: return theme_manager.qcolor(colors.CANVAS) # Pre-configured classes for use in Qt Designer ########################################################################## class _AnkiWebViewKwargs(TypedDict, total=False): parent: QWidget | None title: str kind: AnkiWebViewKind def _create_ankiwebview_subclass( name: str, /, **fixed_kwargs: Unpack[_AnkiWebViewKwargs], ) -> Type[AnkiWebView]: def __init__(self, *args: Any, **kwargs: _AnkiWebViewKwargs) -> None: # user‑supplied kwargs override fixed kwargs merged = cast(_AnkiWebViewKwargs, {**fixed_kwargs, **kwargs}) AnkiWebView.__init__(self, *args, **merged) __init__.__qualname__ = f"{name}.__init__" if fixed_kwargs: __init__.__doc__ = ( f"Auto‑generated wrapper that pre‑sets " f"{', '.join(f'{k}={v!r}' for k, v in fixed_kwargs.items())}." ) cls: Type[AnkiWebView] = type(name, (AnkiWebView,), {"__init__": __init__}) return cls # These subclasses are used in Qt Designer UI files to allow for configuring # web views at initialization time (custom widgets can otherwise only be # initialized with the default constructor) StatsWebView = _create_ankiwebview_subclass( "StatsWebView", kind=AnkiWebViewKind.DECK_STATS ) LegacyStatsWebView = _create_ankiwebview_subclass( "LegacyStatsWebView", kind=AnkiWebViewKind.LEGACY_DECK_STATS ) EmptyCardsWebView = _create_ankiwebview_subclass( "EmptyCardsWebView", kind=AnkiWebViewKind.EMPTY_CARDS ) FindDupesWebView = _create_ankiwebview_subclass( "FindDupesWebView", kind=AnkiWebViewKind.FIND_DUPLICATES ) ================================================ FILE: qt/aqt/widgetgallery.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import aqt import aqt.main from aqt.qt import QDialog, QWidget, qconnect from aqt.theme import WidgetStyle from aqt.utils import restoreGeom, saveGeom class WidgetGallery(QDialog): silentlyClose = True def __init__(self, parent: QWidget) -> None: assert aqt.mw super().__init__(parent) self.form = aqt.forms.widgets.Ui_Dialog() self.form.setupUi(self) restoreGeom(self, "WidgetGallery") qconnect( self.form.disableCheckBox.stateChanged, lambda: self.form.testGrid.setEnabled( not self.form.disableCheckBox.isChecked() ), ) self.form.styleComboBox.addItems( [member.name.lower().capitalize() for member in WidgetStyle] ) self.form.styleComboBox.setCurrentIndex(aqt.mw.pm.get_widget_style()) qconnect( self.form.styleComboBox.currentIndexChanged, aqt.mw.pm.set_widget_style, ) def reject(self) -> None: super().reject() saveGeom(self, "WidgetGallery") ================================================ FILE: qt/aqt/winpaths.py ================================================ """ System File Locations Retrieves common system path names on Windows XP/Vista Depends only on ctypes, and retrieves path locations in Unicode """ import ctypes from ctypes import windll, wintypes # type: ignore __license__ = "MIT" __version__ = "0.2" __author__ = "Ryan Ginstrom" __description__ = "Retrieves common Windows system paths as Unicode strings" class PathConstants: """ Define constants here to avoid dependency on shellcon. Put it in a class to avoid polluting namespace """ CSIDL_DESKTOP = 0 CSIDL_PROGRAMS = 2 CSIDL_PERSONAL = 5 CSIDL_FAVORITES = 6 CSIDL_STARTUP = 7 CSIDL_RECENT = 8 CSIDL_SENDTO = 9 CSIDL_BITBUCKET = 10 CSIDL_STARTMENU = 11 CSIDL_MYDOCUMENTS = 12 CSIDL_MYMUSIC = 13 CSIDL_MYVIDEO = 14 CSIDL_DESKTOPDIRECTORY = 16 CSIDL_DRIVES = 17 CSIDL_NETWORK = 18 CSIDL_NETHOOD = 19 CSIDL_FONTS = 20 CSIDL_TEMPLATES = 21 CSIDL_COMMON_STARTMENU = 22 CSIDL_COMMON_PROGRAMS = 23 CSIDL_COMMON_STARTUP = 24 CSIDL_COMMON_DESKTOPDIRECTORY = 25 CSIDL_APPDATA = 26 CSIDL_PRINTHOOD = 27 CSIDL_LOCAL_APPDATA = 28 CSIDL_ALTSTARTUP = 29 CSIDL_COMMON_ALTSTARTUP = 30 CSIDL_COMMON_FAVORITES = 31 CSIDL_INTERNET_CACHE = 32 CSIDL_COOKIES = 33 CSIDL_HISTORY = 34 CSIDL_COMMON_APPDATA = 35 CSIDL_WINDOWS = 36 CSIDL_SYSTEM = 37 CSIDL_PROGRAM_FILES = 38 CSIDL_MYPICTURES = 39 CSIDL_PROFILE = 40 CSIDL_SYSTEMX86 = 41 CSIDL_PROGRAM_FILESX86 = 42 CSIDL_PROGRAM_FILES_COMMON = 43 CSIDL_PROGRAM_FILES_COMMONX86 = 44 CSIDL_COMMON_TEMPLATES = 45 CSIDL_COMMON_DOCUMENTS = 46 CSIDL_COMMON_ADMINTOOLS = 47 CSIDL_ADMINTOOLS = 48 CSIDL_CONNECTIONS = 49 CSIDL_COMMON_MUSIC = 53 CSIDL_COMMON_PICTURES = 54 CSIDL_COMMON_VIDEO = 55 CSIDL_RESOURCES = 56 CSIDL_RESOURCES_LOCALIZED = 57 CSIDL_COMMON_OEM_LINKS = 58 CSIDL_CDBURN_AREA = 59 # 60 unused CSIDL_COMPUTERSNEARME = 61 class WinPathsException(Exception): pass def _err_unless_zero(result): if result == 0: return result else: raise WinPathsException(f"Failed to retrieve windows path: {result}") _SHGetFolderPath = windll.shell32.SHGetFolderPathW _SHGetFolderPath.argtypes = [ wintypes.HWND, ctypes.c_int, wintypes.HANDLE, wintypes.DWORD, wintypes.LPCWSTR, ] _SHGetFolderPath.restype = _err_unless_zero def _get_path_buf(csidl): path_buf = ctypes.create_unicode_buffer(wintypes.MAX_PATH) _SHGetFolderPath(0, csidl, 0, 0, path_buf) return path_buf.value def get_local_appdata(): return _get_path_buf(PathConstants.CSIDL_LOCAL_APPDATA) def get_appdata(): return _get_path_buf(PathConstants.CSIDL_APPDATA) def get_desktop(): return _get_path_buf(PathConstants.CSIDL_DESKTOP) def get_programs(): """current user -> Start menu -> Programs""" return _get_path_buf(PathConstants.CSIDL_PROGRAMS) def get_admin_tools(): """current user -> Start menu -> Programs -> Admin tools""" return _get_path_buf(PathConstants.CSIDL_ADMINTOOLS) def get_common_admin_tools(): """all users -> Start menu -> Programs -> Admin tools""" return _get_path_buf(PathConstants.CSIDL_COMMON_ADMINTOOLS) def get_common_appdata(): return _get_path_buf(PathConstants.CSIDL_COMMON_APPDATA) def get_common_documents(): return _get_path_buf(PathConstants.CSIDL_COMMON_DOCUMENTS) def get_cookies(): return _get_path_buf(PathConstants.CSIDL_COOKIES) def get_history(): return _get_path_buf(PathConstants.CSIDL_HISTORY) def get_internet_cache(): return _get_path_buf(PathConstants.CSIDL_INTERNET_CACHE) def get_my_pictures(): """Get the user's My Pictures folder""" return _get_path_buf(PathConstants.CSIDL_MYPICTURES) def get_personal(): """AKA 'My Documents'""" return _get_path_buf(PathConstants.CSIDL_PERSONAL) get_my_documents = get_personal def get_program_files(): return _get_path_buf(PathConstants.CSIDL_PROGRAM_FILES) def get_program_files_common(): return _get_path_buf(PathConstants.CSIDL_PROGRAM_FILES_COMMON) def get_system(): """Use with care and discretion""" return _get_path_buf(PathConstants.CSIDL_SYSTEM) def get_windows(): """Use with care and discretion""" return _get_path_buf(PathConstants.CSIDL_WINDOWS) def get_favorites(): return _get_path_buf(PathConstants.CSIDL_FAVORITES) def get_startup(): """current user -> start menu -> programs -> startup""" return _get_path_buf(PathConstants.CSIDL_STARTUP) def get_recent(): return _get_path_buf(PathConstants.CSIDL_RECENT) ================================================ FILE: qt/hatch_build.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import os import sys from pathlib import Path from typing import Any, Dict from hatchling.builders.hooks.plugin.interface import BuildHookInterface class CustomBuildHook(BuildHookInterface): """Build hook to copy generated files into both sdist and wheel.""" PLUGIN_NAME = "custom" def initialize(self, version: str, build_data: Dict[str, Any]) -> None: """Initialize the build hook.""" force_include = build_data.setdefault("force_include", {}) # Pin anki== self._set_anki_dependency(version, build_data) # Look for generated files in out/qt/_aqt project_root = Path(self.root).parent generated_root = project_root / "out" / "qt" / "_aqt" if not os.environ.get("ANKI_WHEEL_TAG"): # On Windows, uv invokes this build hook during the initial uv sync, # when the tag has not been declared by our build script. return assert generated_root.exists(), "you should build with --wheel" self._add_aqt_files(force_include, generated_root) def _set_anki_dependency(self, version: str, build_data: Dict[str, Any]) -> None: # Get current dependencies and replace 'anki' with exact version dependencies = build_data.setdefault("dependencies", []) # Remove any existing anki dependency dependencies[:] = [dep for dep in dependencies if not dep.startswith("anki")] # Handle version detection actual_version = version if version == "standard": # Read actual version from .version file project_root = Path(self.root).parent version_file = project_root / ".version" if version_file.exists(): actual_version = version_file.read_text().strip() # Only add exact version for real releases, not editable installs if actual_version != "editable": dependencies.append(f"anki=={actual_version}") else: # For editable installs, just add anki without version constraint dependencies.append("anki") def _add_aqt_files(self, force_include: Dict[str, str], aqt_root: Path) -> None: """Add _aqt files to the build.""" for path in aqt_root.rglob("*"): if path.is_file() and not self._should_exclude(path): relative_path = path.relative_to(aqt_root) # Place files under _aqt/ in the distribution dist_path = "_aqt" / relative_path force_include[str(path)] = str(dist_path) def _should_exclude(self, path: Path) -> bool: """Check if a file should be excluded from the wheel.""" # Exclude __pycache__ if "/__pycache__/" in str(path): return True if path.suffix in [".ui", ".scss", ".map", ".ts"]: return True if path.name.startswith("tsconfig"): return True return False ================================================ FILE: qt/icons/README.md ================================================ Source files used to produce some of the svg/png files. ================================================ FILE: qt/launcher/Cargo.toml ================================================ [package] name = "launcher" version = "1.0.0" authors.workspace = true edition.workspace = true license.workspace = true publish = false rust-version.workspace = true [dependencies] anki_i18n.workspace = true anki_io.workspace = true anki_process.workspace = true anyhow.workspace = true camino.workspace = true dirs.workspace = true locale_config.workspace = true serde_json.workspace = true [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] libc.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true widestring.workspace = true libc.workspace = true libc-stdhandle.workspace = true [[bin]] name = "build_win" path = "src/bin/build_win.rs" [[bin]] name = "anki-console" path = "src/bin/anki_console.rs" [target.'cfg(windows)'.build-dependencies] embed-resource.workspace = true ================================================ FILE: qt/launcher/addon/__init__.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import contextlib import os import subprocess import sys from pathlib import Path from typing import Any from anki.utils import pointVersion from aqt import mw from aqt.qt import QAction from aqt.utils import askUser, is_mac, is_win, showInfo def launcher_executable() -> str | None: """Return the path to the Anki launcher executable.""" return os.getenv("ANKI_LAUNCHER") def uv_binary() -> str | None: """Return the path to the uv binary.""" return os.environ.get("ANKI_LAUNCHER_UV") def launcher_root() -> str | None: """Return the path to the launcher root directory (AnkiProgramFiles).""" return os.environ.get("UV_PROJECT") def venv_binary(cmd: str) -> str | None: """Return the path to a binary in the launcher's venv.""" root = launcher_root() if not root: return None root_path = Path(root) if is_win: binary_path = root_path / ".venv" / "Scripts" / cmd else: binary_path = root_path / ".venv" / "bin" / cmd return str(binary_path) def add_python_requirements(reqs: list[str]) -> tuple[bool, str]: """Add Python requirements to the launcher venv using uv add. Returns (success, output)""" binary = uv_binary() if not binary: return (False, "Not in packaged build.") uv_cmd = [binary, "add"] + reqs result = subprocess.run(uv_cmd, capture_output=True, text=True, check=False) if result.returncode == 0: root = launcher_root() if root: sync_marker = Path(root) / ".sync_complete" sync_marker.touch() return (True, result.stdout) else: return (False, result.stderr) def trigger_launcher_run() -> None: """Create a trigger file to request launcher UI on next run.""" try: root = launcher_root() if not root: return trigger_path = Path(root) / ".want-launcher" trigger_path.touch() except Exception as e: print(e) def update_and_restart() -> None: """Update and restart Anki using the launcher.""" launcher = launcher_executable() assert launcher trigger_launcher_run() with contextlib.suppress(ResourceWarning): env = os.environ.copy() env["ANKI_LAUNCHER_WANT_TERMINAL"] = "1" creationflags = 0 if sys.platform == "win32": creationflags = ( subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS ) # On Windows, changing the handles breaks ANSI display io = None if sys.platform == "win32" else subprocess.DEVNULL subprocess.Popen( [launcher], start_new_session=True, stdin=io, stdout=io, stderr=io, env=env, creationflags=creationflags, ) mw.app.quit() def confirm_then_upgrade(): if not askUser("Change to a different Anki version?"): return update_and_restart() # return modified command array that points to bundled command, and return # required environment def _packagedCmd(cmd: list[str]) -> tuple[Any, dict[str, str]]: cmd = cmd[:] env = os.environ.copy() # keep LD_LIBRARY_PATH when in snap environment if "LD_LIBRARY_PATH" in env and "SNAP" not in env: del env["LD_LIBRARY_PATH"] # Try to find binary in anki-audio package for Windows/Mac if is_win or is_mac: try: import anki_audio audio_pkg_path = Path(anki_audio.__file__).parent if is_win: packaged_path = audio_pkg_path / (cmd[0] + ".exe") else: # is_mac packaged_path = audio_pkg_path / cmd[0] if packaged_path.exists(): cmd[0] = str(packaged_path) return cmd, env except ImportError: # anki-audio not available, fall back to old behavior pass packaged_path = Path(sys.prefix) / cmd[0] if packaged_path.exists(): cmd[0] = str(packaged_path) return cmd, env def on_addon_config(): showInfo( "This add-on is automatically added when installing older Anki versions, so that they work with the launcher. You can remove it if you wish." ) def setup(): mw.addonManager.setConfigAction(__name__, on_addon_config) if pointVersion() >= 250600: return if not launcher_executable(): return # Add action to tools menu action = QAction("Upgrade/Downgrade", mw) action.triggered.connect(confirm_then_upgrade) mw.form.menuTools.addAction(action) # Monkey-patch audio tools to use anki-audio if is_win or is_mac: import aqt import aqt.sound aqt.sound._packagedCmd = _packagedCmd # Inject launcher functions into launcher module import aqt.package aqt.package.launcher_executable = launcher_executable aqt.package.update_and_restart = update_and_restart aqt.package.trigger_launcher_run = trigger_launcher_run aqt.package.uv_binary = uv_binary aqt.package.launcher_root = launcher_root aqt.package.venv_binary = venv_binary aqt.package.add_python_requirements = add_python_requirements setup() ================================================ FILE: qt/launcher/addon/manifest.json ================================================ { "name": "Anki Launcher", "package": "anki-launcher", "min_point_version": 50, "max_point_version": 250600 } ================================================ FILE: qt/launcher/build.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html fn main() { #[cfg(windows)] { embed_resource::compile("win/anki-manifest.rc", embed_resource::NONE) .manifest_required() .unwrap(); } println!("cargo:rerun-if-changed=../../out/buildhash"); let buildhash = std::fs::read_to_string("../../out/buildhash").unwrap_or_default(); println!("cargo:rustc-env=BUILDHASH={buildhash}"); } ================================================ FILE: qt/launcher/lin/README.md ================================================ # Installing Anki ## Running directly To run without installing, change to this folder in a terminal, and run the following command: ./anki ## Installing To install system wide, run 'sudo ./install.sh' To remove in the future, run 'sudo /usr/local/share/anki/uninstall.sh'. You should do this before installing a newer version. ## Audio To play and record audio, mpv and lame must be installed. If mpv is not installed or too old, Anki will try to fall back on using mplayer. ## Problems If Anki fails to start, please run it from a terminal to see what errors it outputs, and then post on our support site. ================================================ FILE: qt/launcher/lin/anki ================================================ #!/bin/bash # Universal Anki launcher script # Get the directory where this script is located (resolve symlinks) SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" # Determine architecture ARCH=$(uname -m) case "$ARCH" in x86_64|amd64) LAUNCHER="$SCRIPT_DIR/launcher.amd64" ;; aarch64|arm64) LAUNCHER="$SCRIPT_DIR/launcher.arm64" ;; *) echo "Error: Unsupported architecture: $ARCH" echo "Supported architectures: x86_64, aarch64" exit 1 ;; esac # Check if launcher exists if [ ! -f "$LAUNCHER" ]; then echo "Error: Launcher not found: $LAUNCHER" exit 1 fi # Execute the appropriate launcher with all arguments exec "$LAUNCHER" "$@" ================================================ FILE: qt/launcher/lin/anki.1 ================================================ .\" Hey, EMACS: -*- nroff -*- .\" First parameter, NAME, should be all caps .\" Second parameter, SECTION, should be 1-8, maybe w/ subsection .\" other parameters are allowed: see man(7), man(1) .TH ANKI 1 "August 11, 2007" .\" Please adjust this date whenever revising the manpage. .\" .\" Some roff macros, for reference: .\" .nh disable hyphenation .\" .hy enable hyphenation .\" .ad l left justify .\" .ad b justify to both left and right margins .\" .nf disable filling .\" .fi enable filling .\" .br insert line break .\" .sp insert n+1 empty lines .\" for manpage-specific macros, see man(7) .SH NAME anki \- flexible, intelligent flashcard program .SH DESCRIPTION \fBAnki\fP is a program designed to help you remember facts (such as words and phrases in a foreign language) as easily, quickly and efficiently as possible. To do this, it tracks how well you remember each fact, and uses that information to optimally schedule review times. With a minimal amount of effort, you can greatly increase the amount of material you remember, making study more productive, and more fun. Anki is based on a theory called \fIspaced repetition\fP. In simple terms, it means that each time you review some material, you should wait longer than last time before reviewing it again. This maximizes the time spent studying difficult material and minimizes the time spent reviewing things you already know. The concept is simple, but the vast majority of memory trainers and flashcard programs out there either avoid the concept all together, or implement inflexible and suboptimal methods that were originally designed for pen and paper. .SH OPTIONS .B \-b ~/.anki Use ~/.anki instead of ~/Anki as Anki's base folder .B \-p ProfileName Load a specific profile .B \-l Start the program in a specific language (de=German, en=English, etc) .SH SEE ALSO Anki home page: .SH AUTHOR Anki was written by Damien Elmes . .PP This manual page was written by Nicholas Breen , for the Debian project (but may be used by others), and has been updated for Anki 2 by Damien Elmes. ================================================ FILE: qt/launcher/lin/anki.desktop ================================================ [Desktop Entry] Name=Anki Comment=An intelligent spaced-repetition memory training program GenericName=Flashcards Exec=anki %f TryExec=anki Icon=anki Categories=Education;Languages;KDE;Qt; Terminal=false Type=Application Version=1.0 MimeType=application/x-apkg;application/x-anki;application/x-ankiaddon; #should be removed eventually as it was upstreamed as to be an XDG specification called SingleMainWindow X-GNOME-SingleWindow=true SingleMainWindow=true StartupWMClass=anki ================================================ FILE: qt/launcher/lin/anki.xml ================================================ Anki 2.1 collection package Anki 2.0 deck package Anki 2.1 add-on package ================================================ FILE: qt/launcher/lin/anki.xpm ================================================ /* XPM */ static char * anki_xpm[] = { "32 32 256 2", " c None", ". c #525252", "+ c #515151", "@ c #505050", "# c #4F4F4F", "$ c #4D4D4D", "% c #4B4B4B", "& c #4A4A4A", "* c #494949", "= c #484848", "- c #474747", "; c #464646", "> c #454545", ", c #444444", "' c #424242", ") c #404040", "! c #595959", "~ c #5E5E5E", "{ c #707070", "] c #787878", "^ c #7C7C7C", "/ c #7B7B7B", "( c #7A7A7A", "_ c #797979", ": c #777777", "< c #767676", "[ c #757575", "} c #747474", "| c #737373", "1 c #727272", "2 c #6D6D6D", "3 c #606060", "4 c #636363", "5 c #828282", "6 c #808080", "7 c #7F7F7F", "8 c #7E7E7E", "9 c #7D7D7D", "0 c #6C6C6C", "a c #616161", "b c #898989", "c c #888888", "d c #868686", "e c #848484", "f c #818181", "g c #989898", "h c #656565", "i c #646464", "j c #8A8A8A", "k c #8E8E8E", "l c #8C8C8C", "m c #858585", "n c #838383", "o c #929292", "p c #A7A7A7", "q c #949494", "r c #C7C7C7", "s c #E8E9E9", "t c #6E6E6E", "u c #696969", "v c #959595", "w c #939393", "x c #919191", "y c #8F8F8F", "z c #999999", "A c #F6FBFE", "B c #DFEFFB", "C c #E6F1F9", "D c #BADEF5", "E c #D4E9F7", "F c #A5A5A5", "G c #575757", "H c #979797", "I c #969696", "J c #8D8D8D", "K c #8B8B8B", "L c #878787", "M c #E5EFF5", "N c #97CDF1", "O c #8DC8EF", "P c #7ABFED", "Q c #D4EAF9", "R c #C6C6C6", "S c #5B5B5B", "T c #9E9E9E", "U c #9C9C9C", "V c #9B9B9B", "W c #E5E7E8", "X c #B4DAF5", "Y c #90C9F0", "Z c #94CBF1", "` c #ABD6F3", " . c #E4F2FB", ".. c #D6D7D7", "+. c #5F5F5F", "@. c #A2A2A2", "#. c #A0A0A0", "$. c #9F9F9F", "%. c #9D9D9D", "&. c #9A9A9A", "*. c #B5B5B5", "=. c #E8F3FA", "-. c #AED8F4", ";. c #A9D5F3", ">. c #ADD7F4", ",. c #CDE7F8", "'. c #EAF5FC", "). c #E7E7E7", "!. c #626262", "~. c #909090", "{. c #A1A1A1", "]. c #D8D8D8", "^. c #EFF2F3", "/. c #ECF1F4", "(. c #E8F3FC", "_. c #F0F0F0", ":. c #B6B6B6", "<. c #666666", "[. c #010101", "}. c #686868", "|. c #A9A9A9", "1. c #B0B0B0", "2. c #E9EAEA", "3. c #F7FBFD", "4. c #D7D7D7", "5. c #6A6A6A", "6. c #000000", "7. c #5D5D5D", "8. c #585858", "9. c #A8A8A8", "0. c #E1E1E1", "a. c #ACACAC", "b. c #5A5A5A", "c. c #717171", "d. c #EEF0F1", "e. c #CCCCCC", "f. c #565656", "g. c #676767", "h. c #C9C9C9", "i. c #AAD6F4", "j. c #DBEBF6", "k. c #ADADAD", "l. c #6F6F6F", "m. c #ECF3F7", "n. c #4CA9E7", "o. c #4EAAE7", "p. c #D2E9F9", "q. c #319CE3", "r. c #118CDF", "s. c #E4E4E4", "t. c #C2C2C2", "u. c #C0C0C0", "v. c #C8C8C8", "w. c #EEEFF0", "x. c #9DD0F2", "y. c #2998E2", "z. c #1C91E0", "A. c #92CBF0", "B. c #96CDF1", "C. c #98CEF1", "D. c #99CEF1", "E. c #F0F8FD", "F. c #5C5C5C", "G. c #ECECEC", "H. c #EEF5F9", "I. c #C1E1F7", "J. c #93CBF0", "K. c #58AEE9", "L. c #3BA0E5", "M. c #2F9AE3", "N. c #2596E2", "O. c #1990E0", "P. c #108BDF", "Q. c #0686DD", "R. c #47A6E7", "S. c #E9EFF3", "T. c #171717", "U. c #DBEDFA", "V. c #70BAEB", "W. c #67B6EA", "X. c #5BB0E8", "Y. c #52ABE7", "Z. c #45A5E6", "`. c #3CA1E5", " + c #309BE3", ".+ c #2796E2", "++ c #50ABE8", "@+ c #DCEDF9", "#+ c #A5A6A6", "$+ c #4C4C4C", "%+ c #0F0F0F", "&+ c #ECEDEE", "*+ c #E1F1FB", "=+ c #94CBF0", "-+ c #7ABEED", ";+ c #6EB9EB", ">+ c #64B4EA", ",+ c #58AEE8", "'+ c #4FAAE7", ")+ c #43A4E5", "!+ c #3FA2E5", "~+ c #CBE6F8", "{+ c #D0D0D0", "]+ c #101010", "^+ c #F1F6FA", "/+ c #B7DCF5", "(+ c #84C4EE", "_+ c #7BBFED", ":+ c #6FB9EB", "<+ c #66B5EA", "[+ c #5AAFE8", "}+ c #5BAFE8", "|+ c #F1F5F7", "1+ c #6B6B6B", "2+ c #D1D1D1", "3+ c #E2F1FB", "4+ c #8EC8F0", "5+ c #82C2EE", "6+ c #78BEED", "7+ c #6CB8EB", "8+ c #63B3EA", "9+ c #D5EBF9", "0+ c #B9B9B9", "a+ c #545454", "b+ c #111111", "c+ c #C5C5C5", "d+ c #E7F4FC", "e+ c #A5D3F3", "f+ c #AAD5F4", "g+ c #ACD7F4", "h+ c #8FC9F0", "i+ c #CACACA", "j+ c #ECF6FC", "k+ c #C2E1F6", "l+ c #CBE5F7", "m+ c #F0F7FD", "n+ c #F9FCFE", "o+ c #C7E4F7", "p+ c #B1D9F4", "q+ c #F1F8FC", "r+ c #121212", "s+ c #CFCFCF", "t+ c #F5FAFD", "u+ c #EFF7FC", "v+ c #F3F3F4", "w+ c #F1F1F1", "x+ c #0D0D0D", "y+ c #BFBFBF", "z+ c #FDFEFE", "A+ c #EBEBEB", "B+ c #AEAEAE", "C+ c #040404", "D+ c #1B1B1B", "E+ c #A3A3A3", "F+ c #0E0E0E", "G+ c #020202", " ", " . + @ # $ $ % & * = - ; > , ' ' ) ", " ! ~ { ] ^ / ( _ _ ] : < [ } | | 1 2 3 $ ' ", " 4 / 5 6 7 8 9 ^ / ( ( _ ] : < [ } } | 0 % ", " a ^ b c d e 5 f 6 7 8 9 ^ / ( _ 9 g f < [ h & ", " i j k l j c d m n 5 f 6 o p q j r s g _ ] t + ", " u v w x y k l j b d m n z A B C D E F ^ / } G ", " 0 z H I q o x y J K b L j M N O P Q R 6 8 < S ", " { T U V z H I q o x y J y W X Y Z ` ...o ( +. ", " } @.#.$.%.U &.g H v w x *.=.-.;.>.,.'.).T 9 !. ", " @ ~.o g T {.$.%.U &.g %.].^./.(.Q _.:.K L 6 <. ", " [.+.!.}.2 ] c T #.T U %.|.1.1.2.3.4.o J K e 5. ", " 6.3 ~ 7.S ! 8.S t L w T T %.V 9.0.a.q w x b t ", " 6.4 !.3 +.7.S b.c.! a { e U $.%.9.V g H v J 1 ", " 6.<.h 4 !.3 +.~.d.e.0 G f.! } w T $.%.U &.o < ", " 6.5.}.g.h 4 !.h.i.j.k.b.! G f.3 [ &.@.#.$.I ( ", " 6.2 0 5.u g.l.m.n.o.=.m 7.b.! G f.! 1 w {.V 8 ", " 6.{ l.2 0 5.z p.q.r.Z s.t.u.u.a.l.G f.~ : V 5 ", " 6.} [ J T v.w.x.y.z.z.A.B.C.D.E.*.S b.8.G G F. ", " 6./ 1.G.H.I.J.K.L.M.N.O.P.Q.R.S.~.~ 7.S b.* T. ", " 6.d ].U.O V.W.X.Y.Z.`. +.+++@+#+h !.3 ~ 7.$+%+ ", " 6.8 &.&+*+=+-+;+>+,+'+)+!+~+{+2 g.h i !.3 # ]+ ", " 6.f 6 K v.^+/+(+_+:+<+[+}+|+z 1+5.}.g.h i . ]+ ", " 6.e n f m 2+3+N 4+5+6+7+8+9+0+l.2 1+5.}.g.a+b+ ", " 6.c L m e c+d+-.e+f+g+h+_+g+2.} c.l.t 0 1+8.b+ ", " 6.K j c L i+j+k+l+m+n+ .o+p+q+b } 1 c.l.t b.r+ ", " 6.7 J l j s+t+u+v+0+~.*.4._.w+L ] < } | c.G x+ ", " 6.a x y J y+z+A+B+d e 5 L V V 8 / _ ] < [ & C+ ", " D+[ o x H E+y K b c d e n f 6 8 ^ / _ g.F+ ", " G+D+4 n o x y k l K b c d m n 5 7 | $ D+6. ", " 6.6.6.6.6.6.6.6.6.6.6.6.6.6.6.6.6. ", " "}; ================================================ FILE: qt/launcher/lin/build.sh ================================================ #!/bin/bash # # This script currently only supports universal builds on x86_64. # set -e # Add Linux cross-compilation target rustup target add aarch64-unknown-linux-gnu # Detect host architecture HOST_ARCH=$(uname -m) # Define output paths OUTPUT_DIR="../../../out/launcher" ANKI_VERSION=$(cat ../../../.version | tr -d '\n') LAUNCHER_DIR="$OUTPUT_DIR/anki-launcher-$ANKI_VERSION-linux" # Clean existing output directory rm -rf "$LAUNCHER_DIR" # Build binaries based on host architecture if [ "$HOST_ARCH" = "aarch64" ]; then # On aarch64 host, only build for aarch64 cargo build -p launcher --release --target aarch64-unknown-linux-gnu else # On other hosts, build for both architectures cargo build -p launcher --release --target x86_64-unknown-linux-gnu CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ cargo build -p launcher --release --target aarch64-unknown-linux-gnu # Extract uv_lin_arm for cross-compilation (cd ../../.. && ./ninja extract:uv_lin_arm) fi # Create output directory mkdir -p "$LAUNCHER_DIR" # Copy binaries and support files TARGET_DIR=${CARGO_TARGET_DIR:-../../../target} # Copy binaries with architecture suffixes if [ "$HOST_ARCH" = "aarch64" ]; then # On aarch64 host, copy arm64 binary to both locations cp "$TARGET_DIR/aarch64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.amd64" cp "$TARGET_DIR/aarch64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.arm64" # Copy uv binary to both locations cp "../../../out/extracted/uv/uv" "$LAUNCHER_DIR/uv.amd64" cp "../../../out/extracted/uv/uv" "$LAUNCHER_DIR/uv.arm64" else # On other hosts, copy architecture-specific binaries cp "$TARGET_DIR/x86_64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.amd64" cp "$TARGET_DIR/aarch64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.arm64" cp "../../../out/extracted/uv/uv" "$LAUNCHER_DIR/uv.amd64" cp "../../../out/extracted/uv_lin_arm/uv" "$LAUNCHER_DIR/uv.arm64" fi # Copy support files from lin directory for file in README.md anki.1 anki.desktop anki.png anki.xml anki.xpm install.sh uninstall.sh anki; do cp "$file" "$LAUNCHER_DIR/" done # Copy additional files from parent directory cp ../pyproject.toml "$LAUNCHER_DIR/" cp ../../../.python-version "$LAUNCHER_DIR/" cp ../versions.py "$LAUNCHER_DIR/" # Set executable permissions chmod +x \ "$LAUNCHER_DIR/anki" \ "$LAUNCHER_DIR/launcher.amd64" \ "$LAUNCHER_DIR/launcher.arm64" \ "$LAUNCHER_DIR/uv.amd64" \ "$LAUNCHER_DIR/uv.arm64" \ "$LAUNCHER_DIR/install.sh" \ "$LAUNCHER_DIR/uninstall.sh" # Set proper permissions and create tarball chmod -R a+r "$LAUNCHER_DIR" ZSTD="zstd -c --long -T0 -18" TRANSFORM="s%^.%anki-launcher-$ANKI_VERSION-linux%S" TARBALL="$OUTPUT_DIR/anki-launcher-$ANKI_VERSION-linux.tar.zst" tar -I "$ZSTD" --transform "$TRANSFORM" -cf "$TARBALL" -C "$LAUNCHER_DIR" . echo "Build complete:" echo "Universal launcher: $LAUNCHER_DIR" echo "Tarball: $TARBALL" ================================================ FILE: qt/launcher/lin/install.sh ================================================ #!/bin/bash set -e if [ "$(dirname "$(realpath "$0")")" != "$(realpath "$PWD")" ]; then echo "Please run from the folder install.sh is in." exit 1 fi if [ "$PREFIX" = "" ]; then PREFIX=/usr/local fi rm -rf "$PREFIX"/share/anki "$PREFIX"/bin/anki mkdir -p "$PREFIX"/share/anki cp -av --no-preserve=owner,context -- * .python-version "$PREFIX"/share/anki/ mkdir -p "$PREFIX"/bin ln -sf "$PREFIX"/share/anki/anki "$PREFIX"/bin/anki # fix a previous packaging issue where we created this as a file (test -f "$PREFIX"/share/applications && rm "$PREFIX"/share/applications)||true mkdir -p "$PREFIX"/share/pixmaps mkdir -p "$PREFIX"/share/applications mkdir -p "$PREFIX"/share/man/man1 cd "$PREFIX"/share/anki && (\ mv -Z anki.xpm anki.png "$PREFIX"/share/pixmaps/;\ mv -Z anki.desktop "$PREFIX"/share/applications/;\ mv -Z anki.1 "$PREFIX"/share/man/man1/) xdg-mime install anki.xml --novendor xdg-mime default anki.desktop application/x-colpkg xdg-mime default anki.desktop application/x-apkg xdg-mime default anki.desktop application/x-ankiaddon rm install.sh echo "Install complete. Type 'anki' to run." ================================================ FILE: qt/launcher/lin/uninstall.sh ================================================ #!/bin/bash set -e if [ "$PREFIX" = "" ]; then PREFIX=/usr/local fi echo "Uninstalling Anki..." xdg-mime uninstall "$PREFIX"/share/anki/anki.xml || true rm -rf "$PREFIX"/share/anki rm -rf "$PREFIX"/bin/anki rm -rf "$PREFIX"/share/pixmaps/anki.xpm rm -rf "$PREFIX"/share/pixmaps/anki.png rm -rf "$PREFIX"/share/applications/anki.desktop rm -rf "$PREFIX"/share/man/man1/anki.1 echo "Uninstall complete." ================================================ FILE: qt/launcher/mac/Info.plist ================================================ CFBundleDisplayName Anki CFBundleShortVersionString ANKI_VERSION LSMinimumSystemVersion 12 LSApplicationCategoryType public.app-category.education CFBundleDocumentTypes CFBundleTypeExtensions colpkg apkg ankiaddon CFBundleTypeIconName AppIcon CFBundleTypeName Anki File CFBundleTypeRole Editor CFBundleExecutable launcher CFBundleIconName AppIcon CFBundleIdentifier net.ankiweb.launcher CFBundleInfoDictionaryVersion 6.0 CFBundleName Anki CFBundlePackageType APPL NSHighResolutionCapable NSMicrophoneUsageDescription The microphone will only be used when you tap the record button. NSCameraUsageDescription Add-ons may access your camera. NSRequiresAquaSystemAppearance NSSupportsAutomaticGraphicsSwitching ================================================ FILE: qt/launcher/mac/build.sh ================================================ #!/bin/bash set -e # Define output path OUTPUT_DIR="../../../out/launcher" APP_LAUNCHER="$OUTPUT_DIR/Anki.app" rm -rf "$APP_LAUNCHER" # Build binaries for both architectures rustup target add aarch64-apple-darwin x86_64-apple-darwin cargo build -p launcher --release --target aarch64-apple-darwin cargo build -p launcher --release --target x86_64-apple-darwin (cd ../../.. && ./ninja launcher:uv_universal) # Ensure output directory exists mkdir -p "$OUTPUT_DIR" # Remove existing app launcher rm -rf "$APP_LAUNCHER" # Create app launcher structure mkdir -p "$APP_LAUNCHER/Contents/MacOS" "$APP_LAUNCHER/Contents/Resources" # Copy binaries in TARGET_DIR=${CARGO_TARGET_DIR:-target} lipo -create \ "$TARGET_DIR/aarch64-apple-darwin/release/launcher" \ "$TARGET_DIR/x86_64-apple-darwin/release/launcher" \ -output "$APP_LAUNCHER/Contents/MacOS/launcher" cp "$OUTPUT_DIR/uv" "$APP_LAUNCHER/Contents/MacOS/" # Build install_name_tool stub clang -arch arm64 -o "$OUTPUT_DIR/stub_arm64" stub.c clang -arch x86_64 -o "$OUTPUT_DIR/stub_x86_64" stub.c lipo -create "$OUTPUT_DIR/stub_arm64" "$OUTPUT_DIR/stub_x86_64" -output "$APP_LAUNCHER/Contents/MacOS/install_name_tool" rm "$OUTPUT_DIR/stub_arm64" "$OUTPUT_DIR/stub_x86_64" # Copy support files ANKI_VERSION=$(cat ../../../.version | tr -d '\n') sed "s/ANKI_VERSION/$ANKI_VERSION/g" Info.plist > "$APP_LAUNCHER/Contents/Info.plist" cp icon/Assets.car "$APP_LAUNCHER/Contents/Resources/" cp ../pyproject.toml "$APP_LAUNCHER/Contents/Resources/" cp ../../../.python-version "$APP_LAUNCHER/Contents/Resources/" cp ../versions.py "$APP_LAUNCHER/Contents/Resources/" # Codesign/bundle if [ -z "$NODMG" ]; then for i in "$APP_LAUNCHER/Contents/MacOS/uv" "$APP_LAUNCHER/Contents/MacOS/install_name_tool" "$APP_LAUNCHER/Contents/MacOS/launcher" "$APP_LAUNCHER"; do codesign --force -vvvv -o runtime -s "Developer ID Application:" \ --entitlements entitlements.python.xml \ "$i" done # Check codesign -vvv "$APP_LAUNCHER" spctl -a "$APP_LAUNCHER" # Notarize and build dmg ./notarize.sh "$OUTPUT_DIR" ./dmg/build.sh "$OUTPUT_DIR" fi ================================================ FILE: qt/launcher/mac/dmg/build.sh ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html set -e # base folder with Anki.app in it output="$1" dist="$1/tmp" ANKI_VERSION=$(cat ../../../.version | tr -d '\n') dmg_path="$output/anki-launcher-$ANKI_VERSION-mac.dmg" if [ -d "/Volumes/Anki" ] then echo "You already have one Anki mounted, unmount it first!" exit 1 fi rm -rf $dist $dmg_path mkdir -p $dist rsync -av $output/Anki.app $dist/ script_folder=$(dirname $0) echo "bundling..." ln -s /Applications $dist/Applications mkdir -p $dist/.background cp ${script_folder}/anki-logo-bg.png $dist/.background cp ${script_folder}/dmg_ds_store $dist/.DS_Store # create a writable dmg first, and modify its layout with AppleScript hdiutil create -attach -ov -format UDRW -fs HFS+ -volname Anki -srcfolder $dist -o /tmp/Anki-rw.dmg # announce before making the window appear say "applescript" open /tmp/Anki-rw.dmg sleep 2 open ${script_folder}/set-dmg-settings.app sleep 2 hdiutil detach "/Volumes/Anki" || (sleep 3; hdiutil detach /Volumes/Anki) sleep 1 if [ -d "/Volumes/Anki" ] then echo "drive did not detach" exit 1 fi # convert it to a read-only image rm -rf $dmg_path hdiutil convert /tmp/Anki-rw.dmg -ov -format ULFO -o $dmg_path rm -rf /tmp/Anki-rw.dmg ================================================ FILE: qt/launcher/mac/dmg/set-dmg-settings.app/Contents/Info.plist ================================================ CFBundleAllowMixedLocalizations CFBundleDevelopmentRegion English CFBundleExecutable applet CFBundleIconFile applet CFBundleIdentifier com.apple.ScriptEditor.id.set-dmg-settings CFBundleInfoDictionaryVersion 6.0 CFBundleName set-dmg-settings CFBundlePackageType APPL CFBundleShortVersionString 1.0 CFBundleSignature aplt LSMinimumSystemVersionByArchitecture x86_64 10.6 LSRequiresCarbon NSAppleEventsUsageDescription This script needs to control other applications to run. NSAppleMusicUsageDescription This script needs access to your music to run. NSCalendarsUsageDescription This script needs access to your calendars to run. NSCameraUsageDescription This script needs access to your camera to run. NSContactsUsageDescription This script needs access to your contacts to run. NSHomeKitUsageDescription This script needs access to your HomeKit Home to run. NSMicrophoneUsageDescription This script needs access to your microphone to run. NSPhotoLibraryUsageDescription This script needs access to your photos to run. NSRemindersUsageDescription This script needs access to your reminders to run. NSSiriUsageDescription This script needs access to Siri to run. NSSystemAdministrationUsageDescription This script needs access to administer this system to run. WindowState bundleDividerCollapsed bundlePositionOfDivider 0.0 dividerCollapsed eventLogLevel 2 name ScriptWindowState positionOfDivider 388 savedFrame 1308 314 700 672 0 0 2880 1597 selectedTab result ================================================ FILE: qt/launcher/mac/dmg/set-dmg-settings.app/Contents/PkgInfo ================================================ APPLaplt ================================================ FILE: qt/launcher/mac/dmg/set-dmg-settings.app/Contents/Resources/description.rtfd/TXT.rtf ================================================ {\rtf1\ansi\ansicpg1252\cocoartf1671 {\fonttbl} {\colortbl;\red255\green255\blue255;} {\*\expandedcolortbl;;} } ================================================ FILE: qt/launcher/mac/dmg/set-dmg-settings.app/Contents/_CodeSignature/CodeResources ================================================ files Resources/Scripts/main.scpt BbcHsL7M8GleNWeDVHOZVEfpSUQ= Resources/applet.icns sINd6lbiqHD5dL8c6u79cFvVXhw= Resources/applet.rsrc 7JOq2AjTwoRdSRoaun87Me8EbB4= Resources/description.rtfd/TXT.rtf HZLGvORC/avx2snxaACit3D0IJY= files2 Resources/Scripts/main.scpt hash BbcHsL7M8GleNWeDVHOZVEfpSUQ= hash2 T6pvOxUGXyc+qwn+hdv1xPzvnYM+qo9uxLLWUkIFq3Q= Resources/applet.icns hash sINd6lbiqHD5dL8c6u79cFvVXhw= hash2 J7weZ6vlnv9r32tS5HFcyuPXl2StdDnfepLxAixlryk= Resources/applet.rsrc hash 7JOq2AjTwoRdSRoaun87Me8EbB4= hash2 WvL2TvNeKuY64Sp86Cyvcmiood5xzbJmcAH3R0+gIc8= Resources/description.rtfd/TXT.rtf hash HZLGvORC/avx2snxaACit3D0IJY= hash2 XuDTd2OPOPGq65NBuXy6WuqU+bODdg+oDmBFhsZTaVU= rules ^Resources/ ^Resources/.*\.lproj/ optional weight 1000 ^Resources/.*\.lproj/locversion.plist$ omit weight 1100 ^Resources/Base\.lproj/ weight 1010 ^version.plist$ rules2 .*\.dSYM($|/) weight 11 ^(.*/)?\.DS_Store$ omit weight 2000 ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ nested weight 10 ^.* ^Info\.plist$ omit weight 20 ^PkgInfo$ omit weight 20 ^Resources/ weight 20 ^Resources/.*\.lproj/ optional weight 1000 ^Resources/.*\.lproj/locversion.plist$ omit weight 1100 ^Resources/Base\.lproj/ weight 1010 ^[^/]+$ nested weight 10 ^embedded\.provisionprofile$ weight 20 ^version\.plist$ weight 20 ================================================ FILE: qt/launcher/mac/entitlements.python.xml ================================================ com.apple.security.cs.disable-executable-page-protection com.apple.security.device.audio-input com.apple.security.device.camera com.apple.security.cs.allow-dyld-environment-variables com.apple.security.cs.disable-library-validation ================================================ FILE: qt/launcher/mac/icon/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images": [ { "idiom": "mac", "scale": "1x", "size": "16x16" }, { "idiom": "mac", "scale": "2x", "size": "16x16" }, { "idiom": "mac", "scale": "1x", "size": "32x32" }, { "idiom": "mac", "scale": "2x", "size": "32x32" }, { "idiom": "mac", "scale": "1x", "size": "128x128" }, { "idiom": "mac", "scale": "2x", "size": "128x128" }, { "idiom": "mac", "scale": "1x", "size": "256x256" }, { "idiom": "mac", "scale": "2x", "size": "256x256" }, { "filename": "round-1024-512.png", "idiom": "mac", "scale": "1x", "size": "512x512" }, { "idiom": "mac", "scale": "2x", "size": "512x512" } ], "info": { "author": "xcode", "version": 1 } } ================================================ FILE: qt/launcher/mac/icon/Assets.xcassets/Contents.json ================================================ { "info": { "author": "xcode", "version": 1 } } ================================================ FILE: qt/launcher/mac/icon/build.sh ================================================ #!/bin/bash set -e xcrun actool --app-icon AppIcon $(pwd)/Assets.xcassets --compile . --platform macosx --minimum-deployment-target 13.0 --target-device mac --output-partial-info-plist /dev/null ================================================ FILE: qt/launcher/mac/notarize.sh ================================================ #!/bin/bash set -e # Define output path OUTPUT_DIR="$1" APP_LAUNCHER="$OUTPUT_DIR/Anki.app" ZIP_FILE="$OUTPUT_DIR/Anki.zip" # Create zip for notarization (cd "$OUTPUT_DIR" && rm -rf Anki.zip && zip -r Anki.zip Anki.app) # Upload for notarization xcrun notarytool submit "$ZIP_FILE" -p default --wait # Staple the app xcrun stapler staple "$APP_LAUNCHER" ================================================ FILE: qt/launcher/mac/stub.c ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html int main(void) { return 0; } ================================================ FILE: qt/launcher/pyproject.toml ================================================ [project] name = "anki-launcher" version = "1.0.0" description = "UV-based launcher for Anki." requires-python = ">=3.9" dependencies = [ "anki-release", ] ================================================ FILE: qt/launcher/src/bin/anki_console.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html #![windows_subsystem = "console"] use std::env; use std::io::stdin; use std::process::Command; use anyhow::Context; use anyhow::Result; fn main() { if let Err(e) = run() { eprintln!("Error: {e:#}"); std::process::exit(1); } } fn run() -> Result<()> { let current_exe = env::current_exe().context("Failed to get current executable path")?; let exe_dir = current_exe .parent() .context("Failed to get executable directory")?; let anki_exe = exe_dir.join("anki.exe"); if !anki_exe.exists() { anyhow::bail!("anki.exe not found in the same directory"); } // Forward all command line arguments to anki.exe let args: Vec = env::args().skip(1).collect(); let mut cmd = Command::new(&anki_exe); cmd.args(&args); if std::env::var("ANKI_IMPLICIT_CONSOLE").is_err() { // if directly invoked by the user, signal the launcher that the // user wants a Python console std::env::set_var("ANKI_CONSOLE", "1"); } // Wait for the process to complete and forward its exit code let status = cmd.status().context("Failed to execute anki.exe")?; if !status.success() { println!("\nPress enter to close."); let mut input = String::new(); let _ = stdin().read_line(&mut input); } if let Some(code) = status.code() { std::process::exit(code); } else { // Process was terminated by a signal std::process::exit(1); } } ================================================ FILE: qt/launcher/src/bin/build_win.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::env; use std::path::Path; use std::path::PathBuf; use std::process::Command; use anki_io::copy_file; use anki_io::create_dir_all; use anki_io::remove_dir_all; use anki_io::write_file; use anki_process::CommandExt; use anyhow::Result; const OUTPUT_DIR: &str = "../../../out/launcher"; const LAUNCHER_EXE_DIR: &str = "../../../out/launcher_exe"; const NSIS_DIR: &str = "../../../out/nsis"; const CARGO_TARGET_DIR: &str = "../../../out/rust"; const NSIS_PATH: &str = "C:\\Program Files (x86)\\NSIS\\makensis.exe"; fn main() -> Result<()> { println!("Building Windows launcher..."); // Read version early so it can be used throughout the build process let version = std::fs::read_to_string("../../../.version")? .trim() .to_string(); let output_dir = PathBuf::from(OUTPUT_DIR); let launcher_exe_dir = PathBuf::from(LAUNCHER_EXE_DIR); let nsis_dir = PathBuf::from(NSIS_DIR); setup_directories(&output_dir, &launcher_exe_dir, &nsis_dir)?; build_launcher_binary()?; extract_nsis_plugins()?; copy_files(&output_dir)?; sign_binaries(&output_dir)?; copy_nsis_files(&nsis_dir, &version)?; build_uninstaller(&output_dir, &nsis_dir)?; sign_file(&output_dir.join("uninstall.exe"))?; generate_install_manifest(&output_dir)?; build_installer(&output_dir, &nsis_dir)?; let installer_filename = format!("anki-launcher-{version}-windows.exe"); let installer_path = PathBuf::from("../../../out/launcher_exe").join(&installer_filename); sign_file(&installer_path)?; println!("Build completed successfully!"); println!("Output directory: {}", output_dir.display()); println!("Installer: ../../../out/launcher_exe/{installer_filename}"); Ok(()) } fn setup_directories(output_dir: &Path, launcher_exe_dir: &Path, nsis_dir: &Path) -> Result<()> { println!("Setting up directories..."); // Remove existing output directories if output_dir.exists() { remove_dir_all(output_dir)?; } if launcher_exe_dir.exists() { remove_dir_all(launcher_exe_dir)?; } if nsis_dir.exists() { remove_dir_all(nsis_dir)?; } // Create output directories create_dir_all(output_dir)?; create_dir_all(launcher_exe_dir)?; create_dir_all(nsis_dir)?; Ok(()) } fn build_launcher_binary() -> Result<()> { println!("Building launcher binary..."); env::set_var("CARGO_TARGET_DIR", CARGO_TARGET_DIR); Command::new("cargo") .args([ "build", "-p", "launcher", "--release", "--target", "x86_64-pc-windows-msvc", ]) .ensure_success()?; Ok(()) } fn extract_nsis_plugins() -> Result<()> { println!("Extracting NSIS plugins..."); // Change to the anki root directory and run tools/ninja.bat Command::new("cmd") .args([ "/c", "cd", "/d", "..\\..\\..\\", "&&", "tools\\ninja.bat", "extract:nsis_plugins", ]) .ensure_success()?; Ok(()) } fn copy_files(output_dir: &Path) -> Result<()> { println!("Copying binaries..."); // Copy launcher binary as anki.exe let launcher_src = PathBuf::from(CARGO_TARGET_DIR).join("x86_64-pc-windows-msvc/release/launcher.exe"); let launcher_dst = output_dir.join("anki.exe"); copy_file(&launcher_src, &launcher_dst)?; // Copy anki-console binary let console_src = PathBuf::from(CARGO_TARGET_DIR).join("x86_64-pc-windows-msvc/release/anki-console.exe"); let console_dst = output_dir.join("anki-console.exe"); copy_file(&console_src, &console_dst)?; // Copy uv.exe and uvw.exe let uv_src = PathBuf::from("../../../out/extracted/uv/uv.exe"); let uv_dst = output_dir.join("uv.exe"); copy_file(&uv_src, &uv_dst)?; let uv_src = PathBuf::from("../../../out/extracted/uv/uvw.exe"); let uv_dst = output_dir.join("uvw.exe"); copy_file(&uv_src, &uv_dst)?; println!("Copying support files..."); // Copy pyproject.toml copy_file("../pyproject.toml", output_dir.join("pyproject.toml"))?; // Copy .python-version copy_file( "../../../.python-version", output_dir.join(".python-version"), )?; // Copy versions.py copy_file("../versions.py", output_dir.join("versions.py"))?; Ok(()) } fn sign_binaries(output_dir: &Path) -> Result<()> { sign_file(&output_dir.join("anki.exe"))?; sign_file(&output_dir.join("anki-console.exe"))?; sign_file(&output_dir.join("uv.exe"))?; Ok(()) } fn sign_file(file_path: &Path) -> Result<()> { let codesign = env::var("CODESIGN").unwrap_or_default(); if codesign != "1" { println!( "Skipping code signing for {} (CODESIGN not set to 1)", file_path.display() ); return Ok(()); } let signtool_path = find_signtool()?; println!("Signing {}...", file_path.display()); Command::new(&signtool_path) .args([ "sign", "/sha1", "dccfc6d312fc0432197bb7be951478e5866eebf8", "/fd", "sha256", "/tr", "http://time.certum.pl", "/td", "sha256", "/v", ]) .arg(file_path) .ensure_success()?; Ok(()) } fn find_signtool() -> Result { println!("Locating signtool.exe..."); let output = Command::new("where") .args([ "/r", "C:\\Program Files (x86)\\Windows Kits", "signtool.exe", ]) .utf8_output()?; // Find signtool.exe with "arm64" in the path (as per original batch logic) for line in output.stdout.lines() { if line.contains("\\arm64\\") { let signtool_path = PathBuf::from(line.trim()); println!("Using signtool: {}", signtool_path.display()); return Ok(signtool_path); } } anyhow::bail!("Could not find signtool.exe with arm64 architecture"); } fn generate_install_manifest(output_dir: &Path) -> Result<()> { println!("Generating install manifest..."); let mut manifest_content = String::new(); let entries = anki_io::read_dir_files(output_dir)?; for entry in entries { let entry = entry?; let path = entry.path(); if let Some(file_name) = path.file_name() { let file_name_str = file_name.to_string_lossy(); // Skip manifest file and uninstaller (can't delete itself) if file_name_str != "anki.install-manifest" && file_name_str != "uninstall.exe" { if let Ok(relative_path) = path.strip_prefix(output_dir) { // Convert to Windows-style backslashes for NSIS let windows_path = relative_path.display().to_string().replace('/', "\\"); // Use Windows line endings (\r\n) as expected by NSIS manifest_content.push_str(&format!("{windows_path}\r\n")); } } } } write_file(output_dir.join("anki.install-manifest"), manifest_content)?; Ok(()) } fn copy_nsis_files(nsis_dir: &Path, version: &str) -> Result<()> { println!("Copying NSIS support files..."); // Copy anki.template.nsi as anki.nsi and substitute version placeholders let template_content = std::fs::read_to_string("anki.template.nsi")?; let substituted_content = template_content.replace("ANKI_VERSION", version); write_file(nsis_dir.join("anki.nsi"), substituted_content)?; // Copy fileassoc.nsh copy_file("fileassoc.nsh", nsis_dir.join("fileassoc.nsh"))?; // Copy nsProcess.dll copy_file( "../../../out/extracted/nsis_plugins/nsProcess.dll", nsis_dir.join("nsProcess.dll"), )?; Ok(()) } fn build_uninstaller(output_dir: &Path, nsis_dir: &Path) -> Result<()> { println!("Building uninstaller..."); let mut flags = vec!["-V3", "-DWRITE_UNINSTALLER"]; if env::var("NO_COMPRESS").unwrap_or_default() == "1" { println!("NO_COMPRESS=1 detected, disabling compression"); flags.push("-DNO_COMPRESS"); } run_nsis( &PathBuf::from("anki.nsi"), &flags, nsis_dir, // Run from nsis directory )?; // Copy uninstaller from nsis directory to output directory copy_file( nsis_dir.join("uninstall.exe"), output_dir.join("uninstall.exe"), )?; Ok(()) } fn build_installer(_output_dir: &Path, nsis_dir: &Path) -> Result<()> { println!("Building installer..."); let mut flags = vec!["-V3"]; if env::var("NO_COMPRESS").unwrap_or_default() == "1" { println!("NO_COMPRESS=1 detected, disabling compression"); flags.push("-DNO_COMPRESS"); } run_nsis( &PathBuf::from("anki.nsi"), &flags, nsis_dir, // Run from nsis directory )?; Ok(()) } fn run_nsis(script_path: &Path, flags: &[&str], working_dir: &Path) -> Result<()> { let mut cmd = Command::new(NSIS_PATH); cmd.args(flags).arg(script_path).current_dir(working_dir); cmd.ensure_success()?; Ok(()) } ================================================ FILE: qt/launcher/src/main.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html #![windows_subsystem = "windows"] use std::io::stdin; use std::io::stdout; use std::io::Write; use std::process::Command; use std::time::SystemTime; use std::time::UNIX_EPOCH; use anki_i18n::I18n; use anki_io::copy_file; use anki_io::create_dir_all; use anki_io::modified_time; use anki_io::read_file; use anki_io::remove_file; use anki_io::write_file; use anki_io::ToUtf8Path; use anki_process::CommandExt as AnkiCommandExt; use anyhow::Context; use anyhow::Result; use crate::platform::ensure_os_supported; use crate::platform::ensure_terminal_shown; use crate::platform::get_exe_and_resources_dirs; use crate::platform::get_uv_binary_name; use crate::platform::launch_anki_normally; use crate::platform::respawn_launcher; mod platform; struct State { tr: I18n, current_version: Option, prerelease_marker: std::path::PathBuf, uv_install_root: std::path::PathBuf, uv_cache_dir: std::path::PathBuf, no_cache_marker: std::path::PathBuf, anki_base_folder: std::path::PathBuf, uv_path: std::path::PathBuf, uv_python_install_dir: std::path::PathBuf, user_pyproject_path: std::path::PathBuf, user_python_version_path: std::path::PathBuf, dist_pyproject_path: std::path::PathBuf, dist_python_version_path: std::path::PathBuf, uv_lock_path: std::path::PathBuf, sync_complete_marker: std::path::PathBuf, launcher_trigger_file: std::path::PathBuf, mirror_path: std::path::PathBuf, pyproject_modified_by_user: bool, previous_version: Option, resources_dir: std::path::PathBuf, venv_folder: std::path::PathBuf, /// system Python + PyQt6 library mode system_qt: bool, } #[derive(Debug, Clone)] pub enum VersionKind { PyOxidizer(String), Uv(String), } #[derive(Debug)] pub struct Releases { pub latest: Vec, pub all: Vec, } #[derive(Debug, Clone)] pub enum MainMenuChoice { Latest, KeepExisting, Version(VersionKind), ToggleBetas, ToggleCache, DownloadMirror, Uninstall, } fn main() { if let Err(e) = run() { eprintln!("Error: {e:#}"); eprintln!("Press enter to close..."); let mut input = String::new(); let _ = stdin().read_line(&mut input); std::process::exit(1); } } fn run() -> Result<()> { let uv_install_root = if let Ok(custom_root) = std::env::var("ANKI_LAUNCHER_VENV_ROOT") { std::path::PathBuf::from(custom_root) } else { dirs::data_local_dir() .context("Unable to determine data_dir")? .join("AnkiProgramFiles") }; let (exe_dir, resources_dir) = get_exe_and_resources_dirs()?; let locale = locale_config::Locale::user_default().to_string(); let mut state = State { tr: I18n::new(&[if !locale.is_empty() { locale } else { "en".to_owned() }]), current_version: None, prerelease_marker: uv_install_root.join("prerelease"), uv_install_root: uv_install_root.clone(), uv_cache_dir: uv_install_root.join("cache"), no_cache_marker: uv_install_root.join("nocache"), anki_base_folder: get_anki_base_path()?, uv_path: exe_dir.join(get_uv_binary_name()), uv_python_install_dir: uv_install_root.join("python"), user_pyproject_path: uv_install_root.join("pyproject.toml"), user_python_version_path: uv_install_root.join(".python-version"), dist_pyproject_path: resources_dir.join("pyproject.toml"), dist_python_version_path: resources_dir.join(".python-version"), uv_lock_path: uv_install_root.join("uv.lock"), sync_complete_marker: uv_install_root.join(".sync_complete"), launcher_trigger_file: uv_install_root.join(".want-launcher"), mirror_path: uv_install_root.join("mirror"), pyproject_modified_by_user: false, // calculated later previous_version: None, system_qt: (cfg!(unix) && !cfg!(target_os = "macos")) && resources_dir.join("system_qt").exists(), resources_dir, venv_folder: uv_install_root.join(".venv"), }; // Check for uninstall request from Windows uninstaller if std::env::var("ANKI_LAUNCHER_UNINSTALL").is_ok() { ensure_terminal_shown()?; handle_uninstall(&state)?; return Ok(()); } // Create install directory create_dir_all(&state.uv_install_root)?; let launcher_requested = state.launcher_trigger_file.exists() || !state.user_pyproject_path.exists(); // Calculate whether user has custom edits that need syncing let pyproject_time = file_timestamp_secs(&state.user_pyproject_path); let sync_time = file_timestamp_secs(&state.sync_complete_marker); state.pyproject_modified_by_user = pyproject_time > sync_time; let pyproject_has_changed = state.pyproject_modified_by_user; let different_launcher = diff_launcher_was_installed(&state)?; if !launcher_requested && !pyproject_has_changed && !different_launcher { // If no launcher request and venv is already up to date, launch Anki normally let args: Vec = std::env::args().skip(1).collect(); let cmd = build_python_command(&state, &args)?; launch_anki_normally(cmd)?; return Ok(()); } // If we weren't in a terminal, respawn ourselves in one ensure_terminal_shown()?; if launcher_requested { // Remove the trigger file to make request ephemeral let _ = remove_file(&state.launcher_trigger_file); } print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top println!("\x1B[1m{}\x1B[0m\n", state.tr.launcher_title()); ensure_os_supported()?; println!("{}\n", state.tr.launcher_press_enter_to_install()); check_versions(&mut state); main_menu_loop(&state)?; // Write marker file to indicate we've completed the sync process write_sync_marker(&state)?; #[cfg(target_os = "macos")] { let cmd = build_python_command(&state, &[])?; platform::mac::prepare_for_launch_after_update(cmd, &uv_install_root)?; } if cfg!(unix) && !cfg!(target_os = "macos") { println!("\n{}", state.tr.launcher_press_enter_to_start()); let mut input = String::new(); let _ = stdin().read_line(&mut input); } else { // on Windows/macOS, the user needs to close the terminal/console // currently, but ideas on how we can avoid this would be good! println!(); println!("{}", state.tr.launcher_anki_will_start_shortly()); println!( "\x1B[1m{}\x1B[0m\n", state.tr.launcher_you_can_close_this_window() ); } // respawn the launcher as a disconnected subprocess for normal startup respawn_launcher()?; Ok(()) } fn extract_aqt_version(state: &State) -> Option { // Check if .venv exists first if !state.venv_folder.exists() { return None; } let output = uv_command(state) .ok()? .env("VIRTUAL_ENV", &state.venv_folder) .args(["pip", "show", "aqt"]) .output() .ok()?; if !output.status.success() { return None; } let stdout = String::from_utf8(output.stdout).ok()?; for line in stdout.lines() { if let Some(version) = line.strip_prefix("Version: ") { return Some(version.trim().to_string()); } } None } fn check_versions(state: &mut State) { // If sync_complete_marker is missing, do nothing if !state.sync_complete_marker.exists() { return; } // Determine current version by invoking uv pip show aqt match extract_aqt_version(state) { Some(version) => { state.current_version = Some(version); } None => { println!("Warning: Could not determine current Anki version"); } } // Read previous version from "previous-version" file let previous_version_path = state.uv_install_root.join("previous-version"); if let Ok(content) = read_file(&previous_version_path) { if let Ok(version_str) = String::from_utf8(content) { let version = version_str.trim().to_string(); if !version.is_empty() { state.previous_version = Some(version); } } } } fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Result<()> { update_pyproject_for_version(choice.clone(), state)?; // Extract current version before syncing (but don't write to file yet) let previous_version_to_save = extract_aqt_version(state); // Remove sync marker before attempting sync let _ = remove_file(&state.sync_complete_marker); println!("{}\n", state.tr.launcher_updating_anki()); let python_version_trimmed = if state.user_python_version_path.exists() { let python_version = read_file(&state.user_python_version_path)?; let python_version_str = String::from_utf8(python_version).context("Invalid UTF-8 in .python-version")?; Some(python_version_str.trim().to_string()) } else { None }; // Prepare to sync the venv let mut command = uv_command(state)?; if cfg!(target_os = "macos") { // remove CONDA_PREFIX/bin from PATH to avoid conda interference if let Ok(conda_prefix) = std::env::var("CONDA_PREFIX") { if let Ok(current_path) = std::env::var("PATH") { let conda_bin = format!("{conda_prefix}/bin"); let filtered_paths: Vec<&str> = current_path .split(':') .filter(|&path| path != conda_bin) .collect(); let new_path = filtered_paths.join(":"); command.env("PATH", new_path); } } // put our fake install_name_tool at the top of the path to override // potential conflicts if let Ok(current_path) = std::env::var("PATH") { let exe_dir = std::env::current_exe() .ok() .and_then(|exe| exe.parent().map(|p| p.to_path_buf())); if let Some(exe_dir) = exe_dir { let new_path = format!("{}:{}", exe_dir.display(), current_path); command.env("PATH", new_path); } } } // Create venv with system site packages if system Qt is enabled if state.system_qt { let mut venv_command = uv_command(state)?; venv_command.args([ "venv", "--no-managed-python", "--system-site-packages", "--no-config", ]); venv_command.ensure_success()?; } command .env("UV_PYTHON_INSTALL_DIR", &state.uv_python_install_dir) .env( "UV_HTTP_TIMEOUT", std::env::var("UV_HTTP_TIMEOUT").unwrap_or_else(|_| "180".to_string()), ); command.args(["sync", "--upgrade", "--no-config"]); if !state.system_qt { command.arg("--managed-python"); } // Add python version if .python-version file exists (but not for system Qt) if let Some(version) = &python_version_trimmed { if !state.system_qt { command.args(["--python", version]); } } match command.ensure_success() { Ok(_) => { // Sync succeeded if matches!(&choice, MainMenuChoice::Version(VersionKind::PyOxidizer(_))) { inject_helper_addon()?; } // Now that sync succeeded, save the previous version if let Some(current_version) = previous_version_to_save { let previous_version_path = state.uv_install_root.join("previous-version"); if let Err(e) = write_file(&previous_version_path, ¤t_version) { println!("Warning: Could not save previous version: {e}"); } } Ok(()) } Err(e) => { // If sync fails due to things like a missing wheel on pypi, // we need to remove the lockfile or uv will cache the bad result. let _ = remove_file(&state.uv_lock_path); println!("Install failed: {e:#}"); println!(); Err(e.into()) } } } fn main_menu_loop(state: &State) -> Result<()> { loop { let menu_choice = get_main_menu_choice(state)?; match menu_choice { MainMenuChoice::KeepExisting => { if state.pyproject_modified_by_user { // User has custom edits, sync them handle_version_install_or_update(state, MainMenuChoice::KeepExisting)?; } break; } MainMenuChoice::ToggleBetas => { // Toggle beta prerelease file if state.prerelease_marker.exists() { let _ = remove_file(&state.prerelease_marker); println!("{}", state.tr.launcher_beta_releases_disabled()); } else { write_file(&state.prerelease_marker, "")?; println!("{}", state.tr.launcher_beta_releases_enabled()); } println!(); continue; } MainMenuChoice::ToggleCache => { // Toggle cache disable file if state.no_cache_marker.exists() { let _ = remove_file(&state.no_cache_marker); println!("{}", state.tr.launcher_download_caching_enabled()); } else { write_file(&state.no_cache_marker, "")?; // Delete the cache directory and everything in it if state.uv_cache_dir.exists() { let _ = anki_io::remove_dir_all(&state.uv_cache_dir); } println!("{}", state.tr.launcher_download_caching_disabled()); } println!(); continue; } MainMenuChoice::DownloadMirror => { show_mirror_submenu(state)?; println!(); continue; } MainMenuChoice::Uninstall => { if handle_uninstall(state)? { std::process::exit(0); } continue; } choice @ (MainMenuChoice::Latest | MainMenuChoice::Version(_)) => { handle_version_install_or_update(state, choice.clone())?; break; } } } Ok(()) } fn write_sync_marker(state: &State) -> Result<()> { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .context("Failed to get system time")? .as_secs(); write_file(&state.sync_complete_marker, timestamp.to_string())?; Ok(()) } /// Get mtime of provided file, or 0 if unavailable fn file_timestamp_secs(path: &std::path::Path) -> i64 { modified_time(path) .map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64) .unwrap_or_default() } fn get_main_menu_choice(state: &State) -> Result { loop { println!("1) {}", state.tr.launcher_latest_anki()); println!("2) {}", state.tr.launcher_choose_a_version()); if let Some(current_version) = &state.current_version { let normalized_current = normalize_version(current_version); if state.pyproject_modified_by_user { println!("3) {}", state.tr.launcher_sync_project_changes()); } else { println!( "3) {}", state.tr.launcher_keep_existing_version(normalized_current) ); } } if let Some(prev_version) = &state.previous_version { if state.current_version.as_ref() != Some(prev_version) { let normalized_prev = normalize_version(prev_version); println!( "4) {}", state.tr.launcher_revert_to_previous(normalized_prev) ); } } println!(); let betas_enabled = state.prerelease_marker.exists(); println!( "5) {}", state.tr.launcher_allow_betas(if betas_enabled { state.tr.launcher_on() } else { state.tr.launcher_off() }) ); let cache_enabled = !state.no_cache_marker.exists(); println!( "6) {}", state.tr.launcher_cache_downloads(if cache_enabled { state.tr.launcher_on() } else { state.tr.launcher_off() }) ); let mirror_enabled = is_mirror_enabled(state); println!( "7) {}", state.tr.launcher_download_mirror(if mirror_enabled { state.tr.launcher_on() } else { state.tr.launcher_off() }) ); println!(); println!("8) {}", state.tr.launcher_uninstall()); print!("> "); let _ = stdout().flush(); let mut input = String::new(); let _ = stdin().read_line(&mut input); let input = input.trim(); println!(); return Ok(match input { "" | "1" => MainMenuChoice::Latest, "2" => { match get_version_kind(state)? { Some(version_kind) => MainMenuChoice::Version(version_kind), None => continue, // Return to main menu } } "3" => { if state.current_version.is_some() { MainMenuChoice::KeepExisting } else { println!("{}\n", state.tr.launcher_invalid_input()); continue; } } "4" => { if let Some(prev_version) = &state.previous_version { if state.current_version.as_ref() != Some(prev_version) { if let Some(version_kind) = parse_version_kind(prev_version) { return Ok(MainMenuChoice::Version(version_kind)); } } } println!("{}\n", state.tr.launcher_invalid_input()); continue; } "5" => MainMenuChoice::ToggleBetas, "6" => MainMenuChoice::ToggleCache, "7" => MainMenuChoice::DownloadMirror, "8" => MainMenuChoice::Uninstall, _ => { println!("{}\n", state.tr.launcher_invalid_input()); continue; } }); } } fn get_version_kind(state: &State) -> Result> { let releases = get_releases(state)?; let releases_str = releases .latest .iter() .map(|v| v.as_str()) .collect::>() .join(", "); println!("{}", state.tr.launcher_latest_releases(releases_str)); println!("{}", state.tr.launcher_enter_the_version_you_want()); print!("> "); let _ = stdout().flush(); let mut input = String::new(); let _ = stdin().read_line(&mut input); let input = input.trim(); if input.is_empty() { return Ok(None); } // Normalize the input version for comparison let normalized_input = normalize_version(input); // Check if the version exists in the available versions let version_exists = releases.all.iter().any(|v| v == &normalized_input); match (parse_version_kind(input), version_exists) { (Some(version_kind), true) => { println!(); Ok(Some(version_kind)) } (None, true) => { println!("{}", state.tr.launcher_versions_before_cant_be_installed()); Ok(None) } _ => { println!("{}\n", state.tr.launcher_invalid_version()); Ok(None) } } } fn with_only_latest_patch(versions: &[String]) -> Vec { // Assumes versions are sorted in descending order (newest first) // Only show the latest patch release for a given (major, minor), // and exclude pre-releases if a newer major_minor exists let mut seen_major_minor = std::collections::HashSet::new(); versions .iter() .filter(|v| { let (major, minor, _, is_prerelease) = parse_version_for_filtering(v); if major == 2 { return true; } let major_minor = (major, minor); if seen_major_minor.contains(&major_minor) { false } else if is_prerelease && seen_major_minor .iter() .any(|&(seen_major, seen_minor)| (seen_major, seen_minor) > (major, minor)) { // Exclude pre-release if a newer major_minor exists false } else { seen_major_minor.insert(major_minor); true } }) .cloned() .collect() } fn parse_version_for_filtering(version_str: &str) -> (u32, u32, u32, bool) { // Remove any build metadata after + let version_str = version_str.split('+').next().unwrap_or(version_str); // Check for prerelease markers let is_prerelease = ["a", "b", "rc", "alpha", "beta"] .iter() .any(|marker| version_str.to_lowercase().contains(marker)); // Extract numeric parts (stop at first non-digit/non-dot character) let numeric_end = version_str .find(|c: char| !c.is_ascii_digit() && c != '.') .unwrap_or(version_str.len()); let numeric_part = &version_str[..numeric_end]; let parts: Vec<&str> = numeric_part.split('.').collect(); let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0); let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0); let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); (major, minor, patch, is_prerelease) } fn normalize_version(version: &str) -> String { let (major, minor, patch, _is_prerelease) = parse_version_for_filtering(version); if major <= 2 { // Don't transform versions <= 2.x return version.to_string(); } // For versions > 2, pad the minor version with leading zero if < 10 let normalized_minor = if minor < 10 { format!("0{minor}") } else { minor.to_string() }; // Find any prerelease suffix let mut prerelease_suffix = ""; // Look for prerelease markers after the numeric part let numeric_end = version .find(|c: char| !c.is_ascii_digit() && c != '.') .unwrap_or(version.len()); if numeric_end < version.len() { let suffix_part = &version[numeric_end..]; let suffix_lower = suffix_part.to_lowercase(); for marker in ["alpha", "beta", "rc", "a", "b"] { if suffix_lower.starts_with(marker) { prerelease_suffix = &version[numeric_end..]; break; } } } // Reconstruct the version if version.matches('.').count() >= 2 { format!("{major}.{normalized_minor}.{patch}{prerelease_suffix}") } else { format!("{major}.{normalized_minor}{prerelease_suffix}") } } fn filter_and_normalize_versions( all_versions: Vec, include_prereleases: bool, ) -> Vec { let mut valid_versions: Vec = all_versions .into_iter() .map(|v| normalize_version(&v)) .collect(); // Reverse to get chronological order (newest first) valid_versions.reverse(); if !include_prereleases { valid_versions.retain(|v| { let (_, _, _, is_prerelease) = parse_version_for_filtering(v); !is_prerelease }); } valid_versions } fn fetch_versions(state: &State) -> Result> { let versions_script = state.resources_dir.join("versions.py"); let mut cmd = uv_command(state)?; cmd.args(["run", "--no-project", "--no-config", "--managed-python"]) .args(["--with", "pip-system-certs,requests[socks]"]); let python_version = read_file(&state.dist_python_version_path)?; let python_version_str = String::from_utf8(python_version).context("Invalid UTF-8 in .python-version")?; let version_trimmed = python_version_str.trim(); if !version_trimmed.is_empty() { cmd.args(["--python", version_trimmed]); } cmd.arg(&versions_script); let output = match cmd.utf8_output() { Ok(output) => output, Err(e) => { print!("{}\n\n", state.tr.launcher_unable_to_check_for_versions()); return Err(e.into()); } }; let versions = serde_json::from_str(&output.stdout).context("Failed to parse versions JSON")?; Ok(versions) } fn get_releases(state: &State) -> Result { println!("{}", state.tr.launcher_checking_for_updates()); let include_prereleases = state.prerelease_marker.exists(); let all_versions = fetch_versions(state)?; let all_versions = filter_and_normalize_versions(all_versions, include_prereleases); let latest_patches = with_only_latest_patch(&all_versions); let latest_releases: Vec = latest_patches.into_iter().take(5).collect(); Ok(Releases { latest: latest_releases, all: all_versions, }) } fn apply_version_kind(version_kind: &VersionKind, state: &State) -> Result<()> { let content = read_file(&state.dist_pyproject_path)?; let content_str = String::from_utf8(content).context("Invalid UTF-8 in pyproject.toml")?; let updated_content = match version_kind { VersionKind::PyOxidizer(version) => { // Replace package name and add PyQt6 dependencies content_str.replace( "anki-release", &format!( concat!( "aqt[qt6]=={}\",\n", " \"anki-audio==0.1.0; sys.platform == 'win32' or sys.platform == 'darwin'\",\n", " \"pyqt6==6.6.1\",\n", " \"pyqt6-qt6==6.6.2\",\n", " \"pyqt6-webengine==6.6.0\",\n", " \"pyqt6-webengine-qt6==6.6.2\",\n", " \"pyqt6_sip==13.6.0" ), version ), ) } VersionKind::Uv(version) => content_str.replace( "anki-release", &format!("anki-release=={version}\",\n \"anki=={version}\",\n \"aqt=={version}"), ), }; let final_content = if state.system_qt { format!( concat!( "{}\n\n[tool.uv]\n", "override-dependencies = [\n", " \"pyqt6; sys_platform=='never'\",\n", " \"pyqt6-qt6; sys_platform=='never'\",\n", " \"pyqt6-webengine; sys_platform=='never'\",\n", " \"pyqt6-webengine-qt6; sys_platform=='never'\",\n", " \"pyqt6_sip; sys_platform=='never'\"\n", "]\n" ), updated_content ) } else { updated_content }; write_file(&state.user_pyproject_path, &final_content)?; // Update .python-version based on version kind match version_kind { VersionKind::PyOxidizer(_) => { write_file(&state.user_python_version_path, "3.9")?; } VersionKind::Uv(_) => { copy_file( &state.dist_python_version_path, &state.user_python_version_path, )?; } } Ok(()) } fn update_pyproject_for_version(menu_choice: MainMenuChoice, state: &State) -> Result<()> { match menu_choice { MainMenuChoice::Latest => { // Get the latest release version and create a VersionKind for it let releases = get_releases(state)?; let latest_version = releases.latest.first().context("No latest version found")?; apply_version_kind(&VersionKind::Uv(latest_version.clone()), state)?; } MainMenuChoice::KeepExisting => { // Do nothing - keep existing pyproject.toml and .python-version } MainMenuChoice::ToggleBetas => { unreachable!(); } MainMenuChoice::ToggleCache => { unreachable!(); } MainMenuChoice::DownloadMirror => { unreachable!(); } MainMenuChoice::Uninstall => { unreachable!(); } MainMenuChoice::Version(version_kind) => { apply_version_kind(&version_kind, state)?; } } Ok(()) } fn parse_version_kind(version: &str) -> Option { let numeric_chars: String = version .chars() .filter(|c| c.is_ascii_digit() || *c == '.') .collect(); let parts: Vec<&str> = numeric_chars.split('.').collect(); if parts.len() < 2 { return None; } let major: u32 = match parts[0].parse() { Ok(val) => val, Err(_) => return None, }; let minor: u32 = match parts[1].parse() { Ok(val) => val, Err(_) => return None, }; let patch: u32 = if parts.len() >= 3 { match parts[2].parse() { Ok(val) => val, Err(_) => return None, } } else { 0 // Default patch to 0 if not provided }; // Reject versions < 2.1.50 if major == 2 && (minor != 1 || patch < 50) { return None; } if major < 25 || (major == 25 && minor < 6) { Some(VersionKind::PyOxidizer(version.to_string())) } else { Some(VersionKind::Uv(version.to_string())) } } fn inject_helper_addon() -> Result<()> { let addons21_path = get_anki_addons21_path()?; if !addons21_path.exists() { return Ok(()); } let addon_folder = addons21_path.join("anki-launcher"); // Remove existing anki-launcher folder if it exists if addon_folder.exists() { anki_io::remove_dir_all(&addon_folder)?; } // Create the anki-launcher folder create_dir_all(&addon_folder)?; // Write the embedded files let init_py_content = include_str!("../addon/__init__.py"); let manifest_json_content = include_str!("../addon/manifest.json"); write_file(addon_folder.join("__init__.py"), init_py_content)?; write_file(addon_folder.join("manifest.json"), manifest_json_content)?; Ok(()) } fn get_anki_base_path() -> Result { let anki_base_path = if cfg!(target_os = "windows") { // Windows: %APPDATA%\Anki2 dirs::config_dir() .context("Unable to determine config directory")? .join("Anki2") } else if cfg!(target_os = "macos") { // macOS: ~/Library/Application Support/Anki2 dirs::data_dir() .context("Unable to determine data directory")? .join("Anki2") } else { // Linux: ~/.local/share/Anki2 dirs::data_dir() .context("Unable to determine data directory")? .join("Anki2") }; Ok(anki_base_path) } fn get_anki_addons21_path() -> Result { Ok(get_anki_base_path()?.join("addons21")) } fn handle_uninstall(state: &State) -> Result { println!("{}", state.tr.launcher_uninstall_confirm()); print!("> "); let _ = stdout().flush(); let mut input = String::new(); let _ = stdin().read_line(&mut input); let input = input.trim().to_lowercase(); if input != "y" { println!("{}", state.tr.launcher_uninstall_cancelled()); println!(); return Ok(false); } // Remove program files if state.uv_install_root.exists() { anki_io::remove_dir_all(&state.uv_install_root)?; println!("{}", state.tr.launcher_program_files_removed()); } println!(); println!("{}", state.tr.launcher_remove_all_profiles_confirm()); print!("> "); let _ = stdout().flush(); let mut input = String::new(); let _ = stdin().read_line(&mut input); let input = input.trim().to_lowercase(); if input == "y" && state.anki_base_folder.exists() { anki_io::remove_dir_all(&state.anki_base_folder)?; println!("{}", state.tr.launcher_user_data_removed()); } println!(); // Platform-specific messages #[cfg(target_os = "macos")] platform::mac::finalize_uninstall(); #[cfg(target_os = "windows")] platform::windows::finalize_uninstall(); #[cfg(all(unix, not(target_os = "macos")))] platform::unix::finalize_uninstall(); Ok(true) } fn uv_command(state: &State) -> Result { let mut command = Command::new(&state.uv_path); command.current_dir(&state.uv_install_root); // remove UV_* environment variables to avoid interference for (key, _) in std::env::vars() { if key.starts_with("UV_") { command.env_remove(key); } } command .env_remove("VIRTUAL_ENV") .env_remove("SSLKEYLOGFILE"); // Add mirror environment variable if enabled if let Some((python_mirror, pypi_mirror)) = get_mirror_urls(state)? { command .env("UV_PYTHON_INSTALL_MIRROR", &python_mirror) .env("UV_DEFAULT_INDEX", &pypi_mirror); } if state.no_cache_marker.exists() { command.env("UV_NO_CACHE", "1"); } else { command.env("UV_CACHE_DIR", &state.uv_cache_dir); } // have uv use the system certstore instead of webpki-roots' command.env("UV_NATIVE_TLS", "1"); Ok(command) } fn build_python_command(state: &State, args: &[String]) -> Result { let python_exe = if cfg!(target_os = "windows") { let show_console = std::env::var("ANKI_CONSOLE").is_ok(); if show_console { state.venv_folder.join("Scripts/python.exe") } else { state.venv_folder.join("Scripts/pythonw.exe") } } else { state.venv_folder.join("bin/python") }; let mut cmd = Command::new(&python_exe); cmd.args(["-c", "import aqt, sys; sys.argv[0] = 'Anki'; aqt.run()"]); cmd.args(args); // tell the Python code it was invoked by the launcher, and updating is // available cmd.env("ANKI_LAUNCHER", std::env::current_exe()?.utf8()?.as_str()); // Set UV and Python paths for the Python code cmd.env("ANKI_LAUNCHER_UV", state.uv_path.utf8()?.as_str()); cmd.env("UV_PROJECT", state.uv_install_root.utf8()?.as_str()); cmd.env_remove("SSLKEYLOGFILE"); Ok(cmd) } fn is_mirror_enabled(state: &State) -> bool { state.mirror_path.exists() } fn get_mirror_urls(state: &State) -> Result> { if !state.mirror_path.exists() { return Ok(None); } let content = read_file(&state.mirror_path)?; let content_str = String::from_utf8(content).context("Invalid UTF-8 in mirror file")?; let lines: Vec<&str> = content_str.lines().collect(); if lines.len() >= 2 { Ok(Some(( lines[0].trim().to_string(), lines[1].trim().to_string(), ))) } else { Ok(None) } } fn show_mirror_submenu(state: &State) -> Result<()> { loop { println!("{}", state.tr.launcher_download_mirror_options()); println!("1) {}", state.tr.launcher_mirror_no_mirror()); println!("2) {}", state.tr.launcher_mirror_china()); print!("> "); let _ = stdout().flush(); let mut input = String::new(); let _ = stdin().read_line(&mut input); let input = input.trim(); match input { "1" => { // Remove mirror file if state.mirror_path.exists() { let _ = remove_file(&state.mirror_path); } println!("{}", state.tr.launcher_mirror_disabled()); break; } "2" => { // Write China mirror URLs let china_mirrors = "https://registry.npmmirror.com/-/binary/python-build-standalone/\nhttps://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/"; write_file(&state.mirror_path, china_mirrors)?; println!("{}", state.tr.launcher_mirror_china_enabled()); break; } "" => { // Empty input - return to main menu break; } _ => { println!("{}", state.tr.launcher_invalid_input()); continue; } } } Ok(()) } fn diff_launcher_was_installed(state: &State) -> Result { let launcher_version = option_env!("BUILDHASH").unwrap_or("dev").trim(); let launcher_version_path = state.uv_install_root.join("launcher-version"); if let Ok(content) = read_file(&launcher_version_path) { if let Ok(version_str) = String::from_utf8(content) { if version_str.trim() == launcher_version { return Ok(false); } } } write_file(launcher_version_path, launcher_version)?; Ok(true) } #[cfg(test)] mod tests { use super::*; #[test] fn test_normalize_version() { // Test versions <= 2.x (should not be transformed) assert_eq!(normalize_version("2.1.50"), "2.1.50"); // Test basic versions > 2 with zero-padding assert_eq!(normalize_version("25.7"), "25.07"); assert_eq!(normalize_version("25.07"), "25.07"); assert_eq!(normalize_version("25.10"), "25.10"); assert_eq!(normalize_version("24.6.1"), "24.06.1"); assert_eq!(normalize_version("24.06.1"), "24.06.1"); // Test prerelease versions assert_eq!(normalize_version("25.7a1"), "25.07a1"); assert_eq!(normalize_version("25.7.1a1"), "25.07.1a1"); // Test versions with patch = 0 assert_eq!(normalize_version("25.7.0"), "25.07.0"); assert_eq!(normalize_version("25.7.0a1"), "25.07.0a1"); } } ================================================ FILE: qt/launcher/src/platform/mac.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::io; use std::io::Write; use std::path::Path; use std::process::Command; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::sync::Arc; use std::thread; use std::time::Duration; use anki_process::CommandExt as AnkiCommandExt; use anyhow::Context; use anyhow::Result; pub fn prepare_for_launch_after_update(mut cmd: Command, root: &Path) -> Result<()> { // Pre-validate by running --version to trigger any Gatekeeper checks print!("\n\x1B[1mThis may take a few minutes. Please wait\x1B[0m"); io::stdout().flush().unwrap(); // Start progress indicator let running = Arc::new(AtomicBool::new(true)); let running_clone = running.clone(); let progress_thread = thread::spawn(move || { while running_clone.load(Ordering::Relaxed) { print!("."); io::stdout().flush().unwrap(); thread::sleep(Duration::from_secs(1)); } }); let _ = cmd .env("ANKI_FIRST_RUN", "1") .arg("--version") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .ensure_success(); if cfg!(target_os = "macos") { // older Anki versions had a short mpv timeout and didn't support // ANKI_FIRST_RUN, so we need to ensure mpv passes Gatekeeper // validation prior to launch let mpv_path = root.join(".venv/lib/python3.9/site-packages/anki_audio/mpv"); if mpv_path.exists() { let _ = Command::new(&mpv_path) .arg("--version") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .ensure_success(); } } // Stop progress indicator running.store(false, Ordering::Relaxed); progress_thread.join().unwrap(); println!(); // New line after dots Ok(()) } pub fn relaunch_in_terminal() -> Result<()> { let current_exe = std::env::current_exe().context("Failed to get current executable path")?; Command::new("open") .args(["-na", "Terminal"]) .arg(current_exe) .env_remove("ANKI_LAUNCHER_WANT_TERMINAL") .ensure_spawn()?; std::process::exit(0); } pub fn finalize_uninstall() { if let Ok(exe_path) = std::env::current_exe() { // Find the .app bundle by walking up the directory tree let mut app_bundle_path = exe_path.as_path(); while let Some(parent) = app_bundle_path.parent() { if let Some(name) = parent.file_name() { if name.to_string_lossy().ends_with(".app") { let result = Command::new("trash").arg(parent).output(); match result { Ok(output) if output.status.success() => { println!("Anki has been uninstalled."); return; } _ => { // Fall back to manual instructions println!( "Please manually drag Anki.app to the trash to complete uninstall." ); } } return; } } app_bundle_path = parent; } } } ================================================ FILE: qt/launcher/src/platform/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html #[cfg(all(unix, not(target_os = "macos")))] pub mod unix; #[cfg(target_os = "macos")] pub mod mac; #[cfg(target_os = "windows")] pub mod windows; use std::path::PathBuf; use anki_process::CommandExt; use anyhow::Context; use anyhow::Result; pub fn get_exe_and_resources_dirs() -> Result<(PathBuf, PathBuf)> { let exe_dir = std::env::current_exe() .context("Failed to get current executable path")? .parent() .context("Failed to get executable directory")? .to_owned(); let resources_dir = if cfg!(target_os = "macos") { // On macOS, resources are in ../Resources relative to the executable exe_dir .parent() .context("Failed to get parent directory")? .join("Resources") } else { // On other platforms, resources are in the same directory as executable exe_dir.clone() }; Ok((exe_dir, resources_dir)) } pub fn get_uv_binary_name() -> &'static str { if cfg!(target_os = "windows") { "uv.exe" } else if cfg!(target_os = "macos") { "uv" } else if cfg!(target_arch = "x86_64") { "uv.amd64" } else { "uv.arm64" } } pub fn respawn_launcher() -> Result<()> { use std::process::Stdio; let mut launcher_cmd = if cfg!(target_os = "macos") { // On macOS, we need to launch the .app bundle, not the executable directly let current_exe = std::env::current_exe().context("Failed to get current executable path")?; // Navigate from Contents/MacOS/launcher to the .app bundle let app_bundle = current_exe .parent() // MacOS .and_then(|p| p.parent()) // Contents .and_then(|p| p.parent()) // .app .context("Failed to find .app bundle")?; let mut cmd = std::process::Command::new("open"); cmd.arg(app_bundle); cmd } else { let current_exe = std::env::current_exe().context("Failed to get current executable path")?; std::process::Command::new(current_exe) }; launcher_cmd .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); #[cfg(windows)] { use std::os::windows::process::CommandExt; const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; const DETACHED_PROCESS: u32 = 0x00000008; launcher_cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS); } #[cfg(all(unix, not(target_os = "macos")))] { use std::os::unix::process::CommandExt; launcher_cmd.process_group(0); } let child = launcher_cmd.ensure_spawn()?; std::mem::forget(child); Ok(()) } pub fn launch_anki_normally(mut cmd: std::process::Command) -> Result<()> { #[cfg(windows)] { crate::platform::windows::prepare_to_launch_normally(); cmd.ensure_success()?; } #[cfg(unix)] cmd.ensure_exec()?; Ok(()) } #[cfg(windows)] pub use windows::ensure_terminal_shown; #[cfg(unix)] pub fn ensure_terminal_shown() -> Result<()> { use std::io::IsTerminal; let want_terminal = std::env::var("ANKI_LAUNCHER_WANT_TERMINAL").is_ok(); let stdout_is_terminal = IsTerminal::is_terminal(&std::io::stdout()); if want_terminal || !stdout_is_terminal { #[cfg(target_os = "macos")] mac::relaunch_in_terminal()?; #[cfg(not(target_os = "macos"))] unix::relaunch_in_terminal()?; } // Set terminal title to "Anki Launcher" print!("\x1b]2;Anki Launcher\x07"); Ok(()) } pub fn ensure_os_supported() -> Result<()> { #[cfg(all(unix, not(target_os = "macos")))] unix::ensure_glibc_supported()?; #[cfg(target_os = "windows")] windows::ensure_windows_version_supported()?; Ok(()) } ================================================ FILE: qt/launcher/src/platform/unix.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::process::Command; use anyhow::Context; use anyhow::Result; pub fn relaunch_in_terminal() -> Result<()> { let current_exe = std::env::current_exe().context("Failed to get current executable path")?; // Try terminals in roughly most specific to least specific. // First, try commonly used terminals for riced systems. // Second, try common defaults. // Finally, try x11 compatibility terminals. let terminals = [ // commonly used for riced systems ("alacritty", vec!["-e"]), ("kitty", vec![]), ("foot", vec![]), // the user's default terminal in Debian/Ubuntu ("x-terminal-emulator", vec!["-e"]), // default installs for the most common distros ("xfce4-terminal", vec!["-e"]), ("gnome-terminal", vec!["-e"]), ("konsole", vec!["-e"]), // x11-compatibility terminals ("urxvt", vec!["-e"]), ("xterm", vec!["-e"]), ]; for (terminal_cmd, args) in &terminals { // Check if terminal exists if Command::new("which") .arg(terminal_cmd) .output() .map(|o| o.status.success()) .unwrap_or(false) { // Try to launch the terminal let mut cmd = Command::new(terminal_cmd); if args.is_empty() { cmd.arg(¤t_exe); } else { cmd.args(args).arg(¤t_exe); } if cmd.spawn().is_ok() { std::process::exit(0); } } } // If no terminal worked, continue without relaunching Ok(()) } pub fn finalize_uninstall() { use std::io::stdin; use std::io::stdout; use std::io::Write; let uninstall_script = std::path::Path::new("/usr/local/share/anki/uninstall.sh"); if uninstall_script.exists() { println!("To finish uninstalling, run 'sudo /usr/local/share/anki/uninstall.sh'"); } else { println!("Anki has been uninstalled."); } println!("Press enter to quit."); let _ = stdout().flush(); let mut input = String::new(); let _ = stdin().read_line(&mut input); } pub fn ensure_glibc_supported() -> Result<()> { use std::ffi::CStr; let get_glibc_version = || -> Option<(u32, u32)> { let version_ptr = unsafe { libc::gnu_get_libc_version() }; if version_ptr.is_null() { return None; } let version_cstr = unsafe { CStr::from_ptr(version_ptr) }; let version_str = version_cstr.to_str().ok()?; // Parse version string (format: "2.36" or "2.36.1") let version_parts: Vec<&str> = version_str.split('.').collect(); if version_parts.len() < 2 { return None; } let major: u32 = version_parts[0].parse().ok()?; let minor: u32 = version_parts[1].parse().ok()?; Some((major, minor)) }; let (major, minor) = get_glibc_version().unwrap_or_default(); if major < 2 || (major == 2 && minor < 36) { anyhow::bail!("Anki requires a modern Linux distro with glibc 2.36 or later."); } Ok(()) } ================================================ FILE: qt/launcher/src/platform/windows.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::io::stdin; use std::process::Command; use anyhow::Context; use anyhow::Result; use widestring::u16cstr; use windows::core::PCWSTR; use windows::Wdk::System::SystemServices::RtlGetVersion; use windows::Win32::System::Console::AttachConsole; use windows::Win32::System::Console::GetConsoleWindow; use windows::Win32::System::Console::ATTACH_PARENT_PROCESS; use windows::Win32::System::Registry::RegCloseKey; use windows::Win32::System::Registry::RegOpenKeyExW; use windows::Win32::System::Registry::RegQueryValueExW; use windows::Win32::System::Registry::HKEY; use windows::Win32::System::Registry::HKEY_CURRENT_USER; use windows::Win32::System::Registry::KEY_READ; use windows::Win32::System::Registry::REG_SZ; use windows::Win32::System::SystemInformation::OSVERSIONINFOW; use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; /// Returns true if running on Windows 10 (not Windows 11) fn is_windows_10() -> bool { unsafe { let mut info = OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::() as u32, ..Default::default() }; if RtlGetVersion(&mut info).is_ok() { // Windows 10 has build numbers < 22000, Windows 11 >= 22000 info.dwBuildNumber < 22000 && info.dwMajorVersion == 10 } else { false } } } /// Ensures Windows 10 version 1809 or later pub fn ensure_windows_version_supported() -> Result<()> { unsafe { let mut info = OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::() as u32, ..Default::default() }; if RtlGetVersion(&mut info).is_err() { anyhow::bail!("Failed to get Windows version information"); } if info.dwBuildNumber >= 17763 { return Ok(()); } anyhow::bail!("Windows 10 version 1809 or later is required.") } } pub fn ensure_terminal_shown() -> Result<()> { unsafe { if !GetConsoleWindow().is_invalid() { // We already have a console, no need to spawn anki-console.exe return Ok(()); } } if std::env::var("ANKI_IMPLICIT_CONSOLE").is_ok() && attach_to_parent_console() { // This black magic triggers Windows to switch to the new // ANSI-supporting console host, which is usually only available // when the app is built with the console subsystem. // Only needed on Windows 10, not Windows 11. if is_windows_10() { let _ = Command::new("cmd").args(["/C", ""]).status(); } // Successfully attached to parent console reconnect_stdio_to_console(); return Ok(()); } // No console available, spawn anki-console.exe and exit let current_exe = std::env::current_exe().context("Failed to get current executable path")?; let exe_dir = current_exe .parent() .context("Failed to get executable directory")?; let console_exe = exe_dir.join("anki-console.exe"); if !console_exe.exists() { anyhow::bail!("anki-console.exe not found in the same directory"); } // Spawn anki-console.exe without waiting Command::new(&console_exe) .env("ANKI_IMPLICIT_CONSOLE", "1") .spawn() .context("Failed to spawn anki-console.exe")?; // Exit immediately after spawning std::process::exit(0); } pub fn attach_to_parent_console() -> bool { unsafe { if !GetConsoleWindow().is_invalid() { // we have a console already return false; } if AttachConsole(ATTACH_PARENT_PROCESS).is_ok() { // successfully attached to parent reconnect_stdio_to_console(); true } else { false } } } /// Reconnect stdin/stdout/stderr to the console. fn reconnect_stdio_to_console() { use std::ffi::CString; use libc_stdhandle::*; // we launched without a console, so we'll need to open stdin/out/err let conin = CString::new("CONIN$").unwrap(); let conout = CString::new("CONOUT$").unwrap(); let r = CString::new("r").unwrap(); let w = CString::new("w").unwrap(); // Python uses the CRT for I/O, and it requires the descriptors are reopened. unsafe { libc::freopen(conin.as_ptr(), r.as_ptr(), stdin()); libc::freopen(conout.as_ptr(), w.as_ptr(), stdout()); libc::freopen(conout.as_ptr(), w.as_ptr(), stderr()); } } pub fn finalize_uninstall() { let uninstaller_path = get_uninstaller_path(); match uninstaller_path { Some(path) => { println!("Launching Windows uninstaller..."); let result = Command::new(&path).env("ANKI_LAUNCHER", "1").spawn(); match result { Ok(_) => { println!("Uninstaller launched successfully."); return; } Err(e) => { println!("Failed to launch uninstaller: {e}"); println!("You can manually run: {}", path.display()); } } } None => { println!("Windows uninstaller not found."); println!("You may need to uninstall via Windows Settings > Apps."); } } println!("Press enter to close..."); let mut input = String::new(); let _ = stdin().read_line(&mut input); } fn get_uninstaller_path() -> Option { // Try to read install directory from registry if let Some(install_dir) = read_registry_install_dir() { let uninstaller = install_dir.join("uninstall.exe"); if uninstaller.exists() { return Some(uninstaller); } } // Fall back to default location let default_dir = dirs::data_local_dir()?.join("Programs").join("Anki"); let uninstaller = default_dir.join("uninstall.exe"); if uninstaller.exists() { return Some(uninstaller); } None } fn read_registry_install_dir() -> Option { unsafe { let mut hkey = HKEY::default(); // Convert the registry path to wide string let subkey = u16cstr!("SOFTWARE\\Anki"); // Open the registry key let result = RegOpenKeyExW( HKEY_CURRENT_USER, PCWSTR(subkey.as_ptr()), Some(0), KEY_READ, &mut hkey, ); if result.is_err() { return None; } // Query the Install_Dir64 value let value_name = u16cstr!("Install_Dir64"); let mut value_type = REG_SZ; let mut data_size = 0u32; // First call to get the size let result = RegQueryValueExW( hkey, PCWSTR(value_name.as_ptr()), None, Some(&mut value_type), None, Some(&mut data_size), ); if result.is_err() || data_size == 0 { let _ = RegCloseKey(hkey); return None; } // Allocate buffer and read the value let mut buffer: Vec = vec![0; (data_size / 2) as usize]; let result = RegQueryValueExW( hkey, PCWSTR(value_name.as_ptr()), None, Some(&mut value_type), Some(buffer.as_mut_ptr() as *mut u8), Some(&mut data_size), ); let _ = RegCloseKey(hkey); if result.is_ok() { // Convert wide string back to PathBuf let len = buffer.iter().position(|&x| x == 0).unwrap_or(buffer.len()); let path_str = String::from_utf16_lossy(&buffer[..len]); Some(std::path::PathBuf::from(path_str)) } else { None } } } pub fn prepare_to_launch_normally() { // Set the App User Model ID for Windows taskbar grouping unsafe { let _ = SetCurrentProcessExplicitAppUserModelID(PCWSTR(u16cstr!("Ankitects.Anki").as_ptr())); } attach_to_parent_console(); } ================================================ FILE: qt/launcher/versions.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import json import sys import pip_system_certs.wrapt_requests import requests pip_system_certs.wrapt_requests.inject_truststore() def main(): """Fetch and return all versions from PyPI, sorted by upload time.""" url = "https://pypi.org/pypi/aqt/json" try: response = requests.get(url, timeout=30) response.raise_for_status() data = response.json() releases = data.get("releases", {}) # Create list of (version, upload_time) tuples version_times = [] for version, files in releases.items(): if files: # Only include versions that have files # Use the upload time of the first file for each version upload_time = files[0].get("upload_time_iso_8601") if upload_time: version_times.append((version, upload_time)) # Sort by upload time version_times.sort(key=lambda x: x[1]) # Extract just the version names versions = [version for version, _ in version_times] print(json.dumps(versions)) except Exception as e: print(f"Error fetching versions: {e}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main() ================================================ FILE: qt/launcher/win/anki-manifest.rc ================================================ #define RT_MANIFEST 24 1 RT_MANIFEST "anki.exe.manifest" IDI_ICON1 ICON DISCARDABLE "anki-icon.ico" ================================================ FILE: qt/launcher/win/anki.exe.manifest ================================================ true PerMonitorV2 true UTF-8 ================================================ FILE: qt/launcher/win/anki.template.nsi ================================================ ;; This installer was written many years ago, and it is probably worth investigating modern ;; installer alternatives at one point. !addplugindir . !include "fileassoc.nsh" !include WinVer.nsh !include x64.nsh !define nsProcess::FindProcess `!insertmacro nsProcess::FindProcess` !macro nsProcess::FindProcess _FILE _ERR nsProcess::_FindProcess /NOUNLOAD `${_FILE}` Pop ${_ERR} !macroend ;-------------------------------- !pragma warning disable 6020 ; don't complain about missing installer in second invocation ; The name of the installer Name "Anki" Unicode true ; The file to write (relative to nsis directory) OutFile "..\launcher_exe\anki-launcher-ANKI_VERSION-windows.exe" ; Non elevated RequestExecutionLevel user ; The default installation directory InstallDir "$LOCALAPPDATA\Programs\Anki" ; Remember the install location InstallDirRegKey HKCU "Software\Anki" "Install_Dir64" AllowSkipFiles off !ifdef NO_COMPRESS SetCompress off !else SetCompressor /solid lzma !endif Function .onInit ${IfNot} ${AtLeastWin10} MessageBox MB_OK "Windows 10 or later required." Quit ${EndIf} ${IfNot} ${RunningX64} MessageBox MB_OK "64bit Windows is required." Quit ${EndIf} ${nsProcess::FindProcess} "anki.exe" $R0 StrCmp $R0 0 0 notRunning MessageBox MB_OK|MB_ICONEXCLAMATION "Anki.exe is already running. Please close it, then run the installer again." /SD IDOK Abort notRunning: FunctionEnd !ifdef WRITE_UNINSTALLER !uninstfinalize 'copy "%1" "uninstall.exe"' !endif ;-------------------------------- ; Pages Page directory Page instfiles ;; manifest removal script shared by installer and uninstaller ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; !define UninstLog "anki.install-manifest" Var UninstLog !macro removeManifestFiles un Function ${un}removeManifestFiles IfFileExists "$INSTDIR\${UninstLog}" proceed DetailPrint "No previous install manifest found, skipping cleanup." return ;; this code was based on an example found on the net, which I can no longer find proceed: Push $R0 Push $R1 Push $R2 SetFileAttributes "$INSTDIR\${UninstLog}" NORMAL FileOpen $UninstLog "$INSTDIR\${UninstLog}" r StrCpy $R1 -1 GetLineCount: ClearErrors FileRead $UninstLog $R0 IntOp $R1 $R1 + 1 StrCpy $R0 $R0 -2 Push $R0 IfErrors 0 GetLineCount Pop $R0 LoopRead: StrCmp $R1 0 LoopDone Pop $R0 ;; manifest is relative to instdir StrCpy $R0 "$INSTDIR\$R0" IfFileExists "$R0\*.*" 0 +3 RMDir $R0 #is dir Goto processed IfFileExists $R0 0 +3 Delete $R0 #is file Goto processed processed: IntOp $R1 $R1 - 1 Goto LoopRead LoopDone: FileClose $UninstLog Delete "$INSTDIR\${UninstLog}" RMDir "$INSTDIR" Pop $R2 Pop $R1 Pop $R0 FunctionEnd !macroend !insertmacro removeManifestFiles "" !insertmacro removeManifestFiles "un." ;-------------------------------- ; Macro from fileassoc changed to work non elevated !macro APP_ASSOCIATE_HKCU EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND ; Backup the previously associated file class ReadRegStr $R0 HKCU "Software\Classes\.${EXT}" "" WriteRegStr HKCU "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0" WriteRegStr HKCU "Software\Classes\.${EXT}" "" "${FILECLASS}" WriteRegStr HKCU "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}` WriteRegStr HKCU "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}` WriteRegStr HKCU "Software\Classes\${FILECLASS}\shell" "" "open" WriteRegStr HKCU "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}` WriteRegStr HKCU "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}` !macroend ; Macro from fileassoc changed to work non elevated !macro APP_UNASSOCIATE_HKCU EXT FILECLASS ; Backup the previously associated file class ReadRegStr $R0 HKCU "Software\Classes\.${EXT}" `${FILECLASS}_backup` WriteRegStr HKCU "Software\Classes\.${EXT}" "" "$R0" DeleteRegKey HKCU `Software\Classes\${FILECLASS}` !macroend ; The stuff to install Section "" SetShellVarContext current ; "Upgrade" from elevated anki ReadRegStr $0 HKLM "Software\WOW6432Node\Anki" "Install_Dir64" ${IF} $0 != "" ; old value exists, we want to inform the user that a manual uninstall is required first and then start the uninstall.exe MessageBox MB_ICONEXCLAMATION|MB_OK "A previous Anki version needs to be uninstalled first. After uninstallation completes, please run this installer again." ClearErrors ExecShell "open" "$0\uninstall.exe" IfErrors shellError Quit ${ELSE} goto notOldUpgrade ${ENDIF} shellError: MessageBox MB_OK|MB_ICONEXCLAMATION "Failed to uninstall the old version of Anki. Proceeding with installation." notOldUpgrade: Call removeManifestFiles ; Set output path to the installation directory. SetOutPath $INSTDIR CreateShortCut "$DESKTOP\Anki.lnk" "$INSTDIR\anki.exe" "" CreateShortCut "$SMPROGRAMS\Anki.lnk" "$INSTDIR\anki.exe" "" ; Add files to installer !ifndef WRITE_UNINSTALLER File /r ..\launcher\*.* !endif !insertmacro APP_ASSOCIATE_HKCU "apkg" "anki.apkg" \ "Anki deck package" "$INSTDIR\anki.exe,0" \ "Open with Anki" "$INSTDIR\anki.exe $\"%L$\"" !insertmacro APP_ASSOCIATE_HKCU "colpkg" "anki.colpkg" \ "Anki collection package" "$INSTDIR\anki.exe,0" \ "Open with Anki" "$INSTDIR\anki.exe $\"%L$\"" !insertmacro APP_ASSOCIATE_HKCU "ankiaddon" "anki.ankiaddon" \ "Anki add-on" "$INSTDIR\anki.exe,0" \ "Open with Anki" "$INSTDIR\anki.exe $\"%L$\"" !insertmacro UPDATEFILEASSOC ; Write the installation path into the registry ; WriteRegStr HKLM Software\Anki "Install_Dir64" "$INSTDIR" WriteRegStr HKCU Software\Anki "Install_Dir64" "$INSTDIR" ; Write the uninstall keys for Windows WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "DisplayName" "Anki Launcher" WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "DisplayVersion" "ANKI_VERSION" WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "UninstallString" '"$INSTDIR\uninstall.exe"' WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "QuietUninstallString" '"$INSTDIR\uninstall.exe" /S' WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "NoModify" 1 WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "NoRepair" 1 !ifdef WRITE_UNINSTALLER WriteUninstaller "uninstall.exe" !endif ; Ensure uv gets re-run Push "$INSTDIR\pyproject.toml" Call TouchFile ; Launch Anki after installation Exec "$INSTDIR\anki.exe" Quit SectionEnd ; end the section ;-------------------------------- ; Touch file function to update mtime using copy trick Function TouchFile Exch $R0 ; file path nsExec::Exec 'cmd /c "copy /B "$R0" +,,"' Pop $R0 FunctionEnd ;-------------------------------- ; Uninstaller function un.onInit ; Check for ANKI_LAUNCHER environment variable ReadEnvStr $R0 "ANKI_LAUNCHER" ${If} $R0 != "" ; Wait for launcher to exit Sleep 2000 Goto next ${Else} ; Try to launch anki.exe with ANKI_LAUNCHER_UNINSTALL=1 IfFileExists "$INSTDIR\anki.exe" 0 next nsExec::Exec 'cmd /c "set ANKI_LAUNCHER_UNINSTALL=1 && start /b "" "$INSTDIR\anki.exe""' Quit ${EndIf} next: functionEnd Section "Uninstall" SetShellVarContext current Call un.removeManifestFiles ; Remove other shortcuts Delete "$DESKTOP\Anki.lnk" Delete "$SMPROGRAMS\Anki.lnk" ; associations !insertmacro APP_UNASSOCIATE_HKCU "apkg" "anki.apkg" !insertmacro APP_UNASSOCIATE_HKCU "colpkg" "anki.colpkg" !insertmacro APP_UNASSOCIATE_HKCU "ankiaddon" "anki.ankiaddon" !insertmacro UPDATEFILEASSOC ; Schedule uninstaller for deletion on reboot Delete /REBOOTOK "$INSTDIR\uninstall.exe" ; try to remove top level folder if empty RMDir "$INSTDIR" ; Remove AnkiProgramData folder created during runtime RMDir /r "$LOCALAPPDATA\AnkiProgramFiles" ; Remove registry keys DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" DeleteRegKey HKCU Software\Anki SectionEnd ================================================ FILE: qt/launcher/win/build.bat ================================================ @echo off if "%NOCOMP%"=="1" ( set NO_COMPRESS=1 set CODESIGN=0 ) else ( set CODESIGN=1 set NO_COMPRESS=0 ) cargo run --bin build_win ================================================ FILE: qt/launcher/win/fileassoc.nsh ================================================ ; fileassoc.nsh ; https://nsis.sourceforge.io/File_Association ; File association helper macros ; Written by Saivert ; ; Features automatic backup system and UPDATEFILEASSOC macro for ; shell change notification. ; ; |> How to use <| ; To associate a file with an application so you can double-click it in explorer, use ; the APP_ASSOCIATE macro like this: ; ; Example: ; !insertmacro APP_ASSOCIATE "txt" "myapp.textfile" "$INSTDIR\myapp.exe,0" \ ; "Open with myapp" "$INSTDIR\myapp.exe $\"%1$\"" ; ; Never insert the APP_ASSOCIATE macro multiple times, it is only meant ; to associate an application with a single file and using the ; the "open" verb as default. To add more verbs (actions) to a file ; use the APP_ASSOCIATE_ADDVERB macro. ; ; Example: ; !insertmacro APP_ASSOCIATE_ADDVERB "myapp.textfile" "edit" "Edit with myapp" \ ; "$INSTDIR\myapp.exe /edit $\"%1$\"" ; ; To have access to more options when registering the file association use the ; APP_ASSOCIATE_EX macro. Here you can specify the verb and what verb is to be the ; standard action (default verb). ; ; And finally: To remove the association from the registry use the APP_UNASSOCIATE ; macro. Here is another example just to wrap it up: ; !insertmacro APP_UNASSOCIATE "txt" "myapp.textfile" ; ; |> Note <| ; When defining your file class string always use the short form of your application title ; then a period (dot) and the type of file. This keeps the file class sort of unique. ; Examples: ; Winamp.Playlist ; NSIS.Script ; Photoshop.JPEGFile ; ; |> Tech info <| ; The registry key layout for a file association is: ; HKEY_CLASSES_ROOT ; = <"description"> ; shell ; = <"menu-item text"> ; command = <"command string"> ; !macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND ; Backup the previously associated file class ReadRegStr $R0 HKCR ".${EXT}" "" WriteRegStr HKCR ".${EXT}" "${FILECLASS}_backup" "$R0" WriteRegStr HKCR ".${EXT}" "" "${FILECLASS}" WriteRegStr HKCR "${FILECLASS}" "" `${DESCRIPTION}` WriteRegStr HKCR "${FILECLASS}\DefaultIcon" "" `${ICON}` WriteRegStr HKCR "${FILECLASS}\shell" "" "open" WriteRegStr HKCR "${FILECLASS}\shell\open" "" `${COMMANDTEXT}` WriteRegStr HKCR "${FILECLASS}\shell\open\command" "" `${COMMAND}` !macroend !macro APP_ASSOCIATE_EX EXT FILECLASS DESCRIPTION ICON VERB DEFAULTVERB SHELLNEW COMMANDTEXT COMMAND ; Backup the previously associated file class ReadRegStr $R0 HKCR ".${EXT}" "" WriteRegStr HKCR ".${EXT}" "${FILECLASS}_backup" "$R0" WriteRegStr HKCR ".${EXT}" "" "${FILECLASS}" StrCmp "${SHELLNEW}" "0" +2 WriteRegStr HKCR ".${EXT}\ShellNew" "NullFile" "" WriteRegStr HKCR "${FILECLASS}" "" `${DESCRIPTION}` WriteRegStr HKCR "${FILECLASS}\DefaultIcon" "" `${ICON}` WriteRegStr HKCR "${FILECLASS}\shell" "" `${DEFAULTVERB}` WriteRegStr HKCR "${FILECLASS}\shell\${VERB}" "" `${COMMANDTEXT}` WriteRegStr HKCR "${FILECLASS}\shell\${VERB}\command" "" `${COMMAND}` !macroend !macro APP_ASSOCIATE_ADDVERB FILECLASS VERB COMMANDTEXT COMMAND WriteRegStr HKCR "${FILECLASS}\shell\${VERB}" "" `${COMMANDTEXT}` WriteRegStr HKCR "${FILECLASS}\shell\${VERB}\command" "" `${COMMAND}` !macroend !macro APP_ASSOCIATE_REMOVEVERB FILECLASS VERB DeleteRegKey HKCR `${FILECLASS}\shell\${VERB}` !macroend !macro APP_UNASSOCIATE EXT FILECLASS ; Backup the previously associated file class ReadRegStr $R0 HKCR ".${EXT}" `${FILECLASS}_backup` WriteRegStr HKCR ".${EXT}" "" "$R0" DeleteRegKey HKCR `${FILECLASS}` !macroend !macro APP_ASSOCIATE_GETFILECLASS OUTPUT EXT ReadRegStr ${OUTPUT} HKCR ".${EXT}" "" !macroend ; !defines for use with SHChangeNotify !ifdef SHCNE_ASSOCCHANGED !undef SHCNE_ASSOCCHANGED !endif !define SHCNE_ASSOCCHANGED 0x08000000 !ifdef SHCNF_FLUSH !undef SHCNF_FLUSH !endif !define SHCNF_FLUSH 0x1000 !macro UPDATEFILEASSOC ; Using the system.dll plugin to call the SHChangeNotify Win32 API function so we ; can update the shell. System::Call "shell32::SHChangeNotify(i,i,i,i) (${SHCNE_ASSOCCHANGED}, ${SHCNF_FLUSH}, 0, 0)" !macroend ;EOF ================================================ FILE: qt/mac/README.md ================================================ Helper library for macOS-specific functionality. ================================================ FILE: qt/mac/anki_mac_helper/__init__.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import sys from collections.abc import Callable from ctypes import CDLL, CFUNCTYPE, c_bool, c_char_p from pathlib import Path class _MacOSHelper: def __init__(self) -> None: # Look for the dylib in the same directory as this module module_dir = Path(__file__).parent path = module_dir / "libankihelper.dylib" self._dll = CDLL(str(path)) self._dll.system_is_dark.restype = c_bool def system_is_dark(self) -> bool: return self._dll.system_is_dark() def set_darkmode_enabled(self, enabled: bool) -> bool: return self._dll.set_darkmode_enabled(enabled) def start_wav_record(self, path: str, on_error: Callable[[str], None]) -> None: global _on_audio_error _on_audio_error = on_error self._dll.start_wav_record(path.encode("utf8"), _audio_error_callback) def end_wav_record(self) -> None: "On completion, file should be saved if no error has arrived." self._dll.end_wav_record() def disable_appnap(self) -> None: self._dll.disable_appnap() def enable_appnap(self) -> None: self._dll.enable_appnap() # this must not be overwritten or deallocated @CFUNCTYPE(None, c_char_p) # type: ignore def _audio_error_callback(msg: str) -> None: if handler := _on_audio_error: handler(msg) _on_audio_error: Callable[[str], None] | None = None macos_helper: _MacOSHelper | None = None if sys.platform == "darwin": try: macos_helper = _MacOSHelper() except Exception as e: print("macos_helper:", e) ================================================ FILE: qt/mac/anki_mac_helper/py.typed ================================================ ================================================ FILE: qt/mac/ankihelper.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 55; objects = { /* Begin PBXBuildFile section */ 137892AC275D90FC009D0B6E /* theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 137892AB275D90FC009D0B6E /* theme.swift */; }; 137892B0275DAE22009D0B6E /* record.swift in Sources */ = {isa = PBXBuildFile; fileRef = 137892AF275DAE22009D0B6E /* record.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 137892AB275D90FC009D0B6E /* theme.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = theme.swift; sourceTree = ""; }; 137892AF275DAE22009D0B6E /* record.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = record.swift; sourceTree = ""; }; 138B770F2746137F003A3E4F /* libankihelper.dylib */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.dylib"; includeInIndex = 0; path = libankihelper.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 138B770D2746137F003A3E4F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 138B77062746137F003A3E4F = { isa = PBXGroup; children = ( 137892AB275D90FC009D0B6E /* theme.swift */, 137892AF275DAE22009D0B6E /* record.swift */, 138B77102746137F003A3E4F /* Products */, ); sourceTree = ""; }; 138B77102746137F003A3E4F /* Products */ = { isa = PBXGroup; children = ( 138B770F2746137F003A3E4F /* libankihelper.dylib */, ); name = Products; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ 138B770B2746137F003A3E4F /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ 138B770E2746137F003A3E4F /* ankihelper */ = { isa = PBXNativeTarget; buildConfigurationList = 138B77182746137F003A3E4F /* Build configuration list for PBXNativeTarget "ankihelper" */; buildPhases = ( 138B770B2746137F003A3E4F /* Headers */, 138B770C2746137F003A3E4F /* Sources */, 138B770D2746137F003A3E4F /* Frameworks */, ); buildRules = ( ); dependencies = ( ); name = ankihelper; productName = ankihelper; productReference = 138B770F2746137F003A3E4F /* libankihelper.dylib */; productType = "com.apple.product-type.library.dynamic"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 138B77072746137F003A3E4F /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; LastUpgradeCheck = 1310; TargetAttributes = { 138B770E2746137F003A3E4F = { CreatedOnToolsVersion = 13.1; LastSwiftMigration = 1310; }; }; }; buildConfigurationList = 138B770A2746137F003A3E4F /* Build configuration list for PBXProject "ankihelper" */; compatibilityVersion = "Xcode 13.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 138B77062746137F003A3E4F; productRefGroup = 138B77102746137F003A3E4F /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 138B770E2746137F003A3E4F /* ankihelper */, ); }; /* End PBXProject section */ /* Begin PBXSourcesBuildPhase section */ 138B770C2746137F003A3E4F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 137892B0275DAE22009D0B6E /* record.swift in Sources */, 137892AC275D90FC009D0B6E /* theme.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ 138B77162746137F003A3E4F /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 11; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; }; name = Debug; }; 138B77172746137F003A3E4F /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 11; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; }; name = Release; }; 138B77192746137F003A3E4F /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 7ZM8SLJM4P; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; EXECUTABLE_PREFIX = lib; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; }; name = Debug; }; 138B771A2746137F003A3E4F /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 7ZM8SLJM4P; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; EXECUTABLE_PREFIX = lib; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 138B770A2746137F003A3E4F /* Build configuration list for PBXProject "ankihelper" */ = { isa = XCConfigurationList; buildConfigurations = ( 138B77162746137F003A3E4F /* Debug */, 138B77172746137F003A3E4F /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 138B77182746137F003A3E4F /* Build configuration list for PBXNativeTarget "ankihelper" */ = { isa = XCConfigurationList; buildConfigurations = ( 138B77192746137F003A3E4F /* Debug */, 138B771A2746137F003A3E4F /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; rootObject = 138B77072746137F003A3E4F /* Project object */; } ================================================ FILE: qt/mac/ankihelper.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: qt/mac/ankihelper.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: qt/mac/ankihelper.xcodeproj/project.xcworkspace/xcuserdata/dae.xcuserdatad/xcschemes/xcschememanagement.plist ================================================ ================================================ FILE: qt/mac/ankihelper.xcodeproj/xcuserdata/dae.xcuserdatad/xcschemes/ankihelper.xcscheme ================================================ ================================================ FILE: qt/mac/ankihelper.xcodeproj/xcuserdata/dae.xcuserdatad/xcschemes/xcschememanagement.plist ================================================ SchemeUserState ankihelper.xcscheme orderHint 0 ================================================ FILE: qt/mac/appnap.swift ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import Foundation private var currentActivity: NSObjectProtocol? @_cdecl("disable_appnap") public func disableAppNap() { // No-op if already assigned guard currentActivity == nil else { return } currentActivity = ProcessInfo.processInfo.beginActivity( options: .userInitiatedAllowingIdleSystemSleep, reason: "AppNap is disabled" ) } @_cdecl("enable_appnap") public func enableAppNap() { guard let activity = currentActivity else { return } ProcessInfo.processInfo.endActivity(activity) currentActivity = nil } ================================================ FILE: qt/mac/build.sh ================================================ #!/bin/bash # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html set -e # Get the project root directory SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJ_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" # Build the dylib first echo "Building macOS helper dylib..." "$PROJ_ROOT/out/pyenv/bin/python" "$SCRIPT_DIR/helper_build.py" # Create the wheel using uv echo "Creating wheel..." cd "$SCRIPT_DIR" rm -rf dist "$PROJ_ROOT/out/extracted/uv/uv" build --wheel echo "Build complete!" ================================================ FILE: qt/mac/helper_build.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import subprocess import sys from pathlib import Path # If no arguments provided, build for the anki_mac_helper package if len(sys.argv) == 1: script_dir = Path(__file__).parent out_dylib = script_dir / "anki_mac_helper" / "libankihelper.dylib" src_files = list(script_dir.glob("*.swift")) else: out_dylib, *src_files = sys.argv[1:] out_dylib = Path(out_dylib) out_dir = out_dylib.parent.resolve() src_dir = Path(src_files[0]).parent.resolve() # Build for both architectures architectures = ["arm64", "x86_64"] temp_files = [] for arch in architectures: target = f"{arch}-apple-macos11" temp_out = out_dir / f"temp_{arch}.dylib" temp_files.append(temp_out) args = [ "swiftc", "-target", target, "-emit-library", "-module-name", "ankihelper", "-O", ] if isinstance(src_files[0], Path): args.extend(src_files) else: args.extend(src_dir / Path(file).name for file in src_files) args.extend(["-o", str(temp_out)]) subprocess.run(args, check=True, cwd=out_dir) # Ensure output directory exists out_dir.mkdir(parents=True, exist_ok=True) # Create universal binary lipo_args = ["lipo", "-create", "-output", str(out_dylib)] + [ str(f) for f in temp_files ] subprocess.run(lipo_args, check=True) # Clean up temporary files for temp_file in temp_files: temp_file.unlink() ================================================ FILE: qt/mac/pyproject.toml ================================================ [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "anki-mac-helper" version = "0.1.1" description = "Small support library for Anki on Macs" requires-python = ">=3.9" license = { text = "AGPL-3.0-or-later" } authors = [ { name = "Anki Team" }, ] urls = { Homepage = "https://github.com/ankitects/anki" } [tool.hatch.build.targets.wheel] packages = ["anki_mac_helper"] ================================================ FILE: qt/mac/record.swift ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import Foundation import AVKit enum RecordError: Error { case noPermission case audioFormat case recordInvoke case stoppedWithFailure case encodingFailure } @_cdecl("start_wav_record") public func startWavRecord( path: UnsafePointer, onError: @escaping @convention(c) (UnsafePointer) -> Void ) { let url = URL(fileURLWithPath: String(cString: path)) AudioRecorder.shared.beginRecording(url: url, onError: { error in error.localizedDescription.withCString { cString in onError(cString) } }) } @_cdecl("end_wav_record") public func endWavRecord() { AudioRecorder.shared.endRecording() } private class AudioRecorder: NSObject, AVAudioRecorderDelegate { static let shared = AudioRecorder() private var audioRecorder: AVAudioRecorder? private var onError: ((RecordError) -> Void)? func beginRecording(url: URL, onError: @escaping (Error) -> Void) { self.endRecording() requestPermission { success in if !success { onError(RecordError.noPermission) return } do { try self.beginRecordingInner(url: url) } catch { onError(error) return } self.onError = onError } } func endRecording() { if let recorder = audioRecorder { recorder.stop() } audioRecorder = nil onError = nil } /// Request permission, then call provided callback (true on success). private func requestPermission(completionHandler: @escaping (Bool) -> Void) { switch AVCaptureDevice.authorizationStatus(for: .audio) { case .notDetermined: AVCaptureDevice.requestAccess( for: .audio, completionHandler: completionHandler ) return case .authorized: completionHandler(true) return case .restricted: print("recording restricted") case .denied: print("recording denied") @unknown default: print("recording unknown permission") } completionHandler(false) } private func beginRecordingInner(url: URL) throws { guard let audioFormat = AVAudioFormat.init( commonFormat: .pcmFormatInt16, sampleRate: 44100, channels: 1, interleaved: true ) else { throw RecordError.audioFormat } let recorder = try AVAudioRecorder(url: url, format: audioFormat) if !recorder.record() { throw RecordError.recordInvoke } audioRecorder = recorder } func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { if !flag { onError?(.stoppedWithFailure) } } func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { onError?(.encodingFailure) } } ================================================ FILE: qt/mac/theme.swift ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import AppKit import Foundation /// Force our app to be either light or dark mode. @_cdecl("set_darkmode_enabled") public func setDarkmodeEnabled(_ enabled: Bool) { NSApplication.shared.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua) } /// True if the system is set to dark mode. @_cdecl("system_is_dark") public func systemIsDark() -> Bool { let styleSet = UserDefaults.standard.object(forKey: "AppleInterfaceStyle") != nil return styleSet } ================================================ FILE: qt/mac/update-launcher-env ================================================ #!/bin/bash # # Build and install into the launcher venv set -e ./build.sh if [[ "$OSTYPE" == "darwin"* ]]; then export VIRTUAL_ENV=$HOME/Library/Application\ Support/AnkiProgramFiles/.venv else export VIRTUAL_ENV=$HOME/.local/share/AnkiProgramFiles/.venv fi ../../out/extracted/uv/uv pip install dist/*.whl ================================================ FILE: qt/pyproject.toml ================================================ [project] name = "aqt" dynamic = ["version"] requires-python = ">=3.9" license = "AGPL-3.0-or-later" dependencies = [ "beautifulsoup4", "flask", "flask_cors", "jsonschema", "requests", "send2trash", "waitress>=2.0.0", "pywin32; sys.platform == 'win32'", "anki-mac-helper>=0.1.1; sys.platform == 'darwin'", "pip-system-certs!=5.1", "pyqt6>=6.2", "pyqt6-webengine>=6.2", # anki dependency is added dynamically in hatch_build.py with exact version ] [project.optional-dependencies] audio = [ "anki-audio==0.1.0; sys.platform == 'win32' or sys.platform == 'darwin'", ] qt66 = [ "pyqt6==6.6.1", "pyqt6-qt6==6.6.2", "pyqt6-webengine==6.6.0", "pyqt6-webengine-qt6==6.6.2", "pyqt6_sip==13.6.0", ] qt67 = [ "pyqt6==6.7.1", "pyqt6-qt6==6.7.3", "pyqt6-webengine==6.7.0", "pyqt6-webengine-qt6==6.7.3", "pyqt6_sip==13.10.2", ] qt = [ "pyqt6==6.9.1", "pyqt6-qt6==6.9.1", "pyqt6-webengine==6.8.0", "pyqt6-webengine-qt6==6.8.2", "pyqt6_sip==13.10.2", ] qt68 = [ "pyqt6==6.8.0", "pyqt6-qt6==6.8.1", "pyqt6-webengine==6.8.0", "pyqt6-webengine-qt6==6.8.1", "pyqt6_sip==13.10.2", ] [tool.uv] conflicts = [ [ { extra = "qt" }, { extra = "qt66" }, { extra = "qt67" }, { extra = "qt68" }, ], ] [tool.uv.sources] anki = { workspace = true } [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project.scripts] anki = "aqt:run" [project.gui-scripts] ankiw = "aqt:run" [tool.hatch.build.targets.wheel] packages = ["aqt"] exclude = ["aqt/data", "**/*.ui"] [tool.hatch.version] source = "code" path = "../python/version.py" [tool.hatch.build.hooks.custom] path = "hatch_build.py" ================================================ FILE: qt/release/.gitignore ================================================ pyproject.toml pyproject.toml.old ================================================ FILE: qt/release/build.sh ================================================ #!/bin/bash set -e test -f build.sh || { echo "run from release folder" exit 1 } # Get the project root (two levels up from qt/release) PROJ_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" # Use extracted uv binary UV="$PROJ_ROOT/out/extracted/uv/uv" # Read version from .version file VERSION=$(cat "$PROJ_ROOT/.version" | tr -d '[:space:]') # Copy existing pyproject.toml to .old if it exists if [ -f pyproject.toml ]; then cp pyproject.toml pyproject.toml.old fi # Export dependencies using uv echo "Exporting dependencies..." rm -f pyproject.toml DEPS=$(cd "$PROJ_ROOT" && "$UV" export --no-hashes --no-annotate --no-header --extra audio --extra qt --all-packages --no-dev --no-emit-workspace) # Generate the pyproject.toml file cat > pyproject.toml << EOF [project] name = "anki-release" version = "$VERSION" description = "A package to lock Anki's dependencies" requires-python = ">=3.9" dependencies = [ "anki==$VERSION", "aqt==$VERSION", EOF # Add the exported dependencies to the file echo "$DEPS" | while IFS= read -r line; do if [[ -n "$line" ]]; then echo " \"$line\"," >> pyproject.toml fi done # Complete the pyproject.toml file cat >> pyproject.toml << 'EOF' ] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" # hatch throws an error if nothing is included [tool.hatch.build.targets.wheel] include = ["no-such-file"] EOF echo "Generated pyproject.toml with version $VERSION" # Show diff if .old file exists if [ -f pyproject.toml.old ]; then echo echo "Differences from previous release version:" diff -u --color=always pyproject.toml.old pyproject.toml || true fi echo "Building wheel..." "$UV" build --wheel --out-dir "$PROJ_ROOT/out/wheels" ================================================ FILE: qt/runanki.py ================================================ #!/usr/bin/env python3 # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import os import sys try: import bazelfixes bazelfixes.fix_pywin32_in_bazel() bazelfixes.fix_extraneous_path_in_bazel() bazelfixes.fix_run_on_macos() except ImportError: pass import aqt if not os.environ.get("ANKI_IMPORT_ONLY"): aqt.run() ================================================ FILE: qt/tests/__init__.py ================================================ ================================================ FILE: qt/tests/test_addons.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import os.path from tempfile import TemporaryDirectory from zipfile import ZipFile from mock import MagicMock from aqt.addons import AddonManager, package_name_valid def test_readMinimalManifest(): assertReadManifest( '{"package": "yes", "name": "no"}', {"package": "yes", "name": "no"} ) def test_readExtraKeys(): assertReadManifest( '{"package": "a", "name": "b", "mod": 3, "conflicts": ["d", "e"]}', {"package": "a", "name": "b", "mod": 3, "conflicts": ["d", "e"]}, ) def test_invalidManifest(): assertReadManifest('{"one": 1}', {}) def test_mustHaveName(): assertReadManifest('{"package": "something"}', {}) def test_mustHavePackage(): assertReadManifest('{"name": "something"}', {}) def test_invalidJson(): assertReadManifest("this is not a JSON dictionary", {}) def test_missingManifest(): assertReadManifest( '{"package": "what", "name": "ever"}', {}, nameInZip="not-manifest.bin" ) def test_ignoreExtraKeys(): assertReadManifest( '{"package": "a", "name": "b", "game": "c"}', {"package": "a", "name": "b"} ) def test_conflictsMustBeStrings(): assertReadManifest( '{"package": "a", "name": "b", "conflicts": ["c", 4, {"d": "e"}]}', {} ) def assertReadManifest(contents, expectedManifest, nameInZip="manifest.json"): with TemporaryDirectory() as td: zfn = os.path.join(td, "addon.zip") with ZipFile(zfn, "w") as zfile: zfile.writestr(nameInZip, contents) adm = AddonManager(MagicMock()) with ZipFile(zfn, "r") as zfile: assert adm.readManifestFile(zfile) == expectedManifest def test_package_name_validation(): assert not package_name_valid("") assert not package_name_valid("/") assert not package_name_valid("a/b") assert not package_name_valid("..") assert package_name_valid("ab") ================================================ FILE: qt/tests/test_i18n.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import anki.lang def test_no_collection_i18n(): anki.lang.set_lang("zz") tr = anki.lang.tr_legacyglobal no_uni = anki.lang.without_unicode_isolation assert no_uni(tr.statistics_reviews(reviews=2)) == "2 reviews" anki.lang.set_lang("ja") assert no_uni(tr.statistics_reviews(reviews=2)) == "2枚" def test_legacy_enum(): anki.lang.set_lang("ja") TR = anki.lang.TR tr = anki.lang.tr_legacyglobal no_uni = anki.lang.without_unicode_isolation assert no_uni(tr(TR.STATISTICS_REVIEWS, reviews=2)) == "2枚" ================================================ FILE: qt/tools/build_qrc.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import os import sys qrc_file = os.path.abspath(sys.argv[1]) icons = sys.argv[2:] file_skeleton = """ FILES """.strip() indent = " " * 8 lines = [] for icon in icons: base = os.path.basename(icon) path = os.path.relpath(icon, start=os.path.dirname(qrc_file)) line = f'{indent}{path}' lines.append(line) with open(qrc_file, "w") as file: file.write(file_skeleton.replace("FILES", "\n".join(lines))) ================================================ FILE: qt/tools/build_ui.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import io import re import sys from dataclasses import dataclass from pathlib import Path from PyQt6.uic import compileUi def compile(ui_file: str | Path) -> str: buf = io.StringIO() with open(ui_file) as f: compileUi(f, buf) return buf.getvalue() def with_fixes_for_qt6(code: str) -> str: code = code.replace( "from PyQt6 import QtCore, QtGui, QtWidgets", "from PyQt6 import QtCore, QtGui, QtWidgets\nfrom aqt.utils import tr\n", ) code = re.sub( r'(?:QtGui\.QApplication\.)?_?translate\(".*?", "(.*?)"', "tr.\\1(", code ) outlines = [] qt_bad_types = [ ".connect(", ] for line in code.splitlines(): for substr in qt_bad_types: if substr in line: line = line + " # type: ignore" break if line == "from . import icons_rc": continue line = line.replace(":/icons/", "icons:") line = line.replace( "QAction.PreferencesRole", "QAction.MenuRole.PreferencesRole" ) line = line.replace("QAction.AboutRole", "QAction.MenuRole.AboutRole") outlines.append(line) return "\n".join(outlines) @dataclass class UiFileAndOutputs: ui_file: Path qt6_file: str def get_files() -> list[UiFileAndOutputs]: "The ui->py map, and output __init__.py path" ui_folder = Path("qt/aqt/forms") out_folder = Path(sys.argv[1]).parent out = [] for path in ui_folder.iterdir(): if path.suffix == ".ui": outpath = str(out_folder / path.name) out.append( UiFileAndOutputs( ui_file=path, qt6_file=outpath.replace(".ui", "_qt6.py"), ) ) return out if __name__ == "__main__": for entry in get_files(): stock = compile(entry.ui_file) for_qt6 = with_fixes_for_qt6(stock) with open(entry.qt6_file, "w") as file: file.write(for_qt6) ================================================ FILE: qt/tools/color_svg.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import re import sys from pathlib import Path sys.path.append("out/qt") from _aqt import colors input_path = Path(sys.argv[1]) input_name = input_path.stem color_names = sys.argv[2].split(":") # two files created for each additional color offset = len(color_names) * 2 svg_paths = sys.argv[3 : 3 + offset] with open(input_path, "r") as f: svg_data = f.read() for color_name in color_names: color = getattr(colors, color_name) light_svg = dark_svg = "" if color_name == "FG": prefix = input_name else: prefix = f"{input_name}-{color_name}" for path in svg_paths: if f"{prefix}-light.svg" in path: light_svg = path elif f"{prefix}-dark.svg" in path: dark_svg = path def substitute(data: str, filename: str, mode: str) -> None: if "fill" in data: data = re.sub(r"fill=\"#.+?\"", f'fill="{color[mode]}"', data) else: data = re.sub(r" is toggled by the user.""", ), # Main window states ################### # these refer to things like deckbrowser, overview and reviewer state, Hook( name="state_will_change", args=[ "new_state: aqt.main.MainWindowState", "old_state: aqt.main.MainWindowState", ], legacy_hook="beforeStateChange", ), Hook( name="state_did_change", args=[ "new_state: aqt.main.MainWindowState", "old_state: aqt.main.MainWindowState", ], legacy_hook="afterStateChange", ), # different sig to original Hook( name="state_shortcuts_will_change", args=[ "state: aqt.main.MainWindowState", "shortcuts: list[tuple[str, Callable]]", ], ), # UI state/refreshing ################### Hook( name="state_did_undo", args=["changes: OpChangesAfterUndo"], doc="Called after backend undoes a change.", ), Hook( name="state_did_reset", legacy_hook="reset", doc="""Legacy 'reset' hook. Called by mw.reset() and CollectionOp() to redraw the UI. New code should use `operation_did_execute` instead. """, ), Hook( name="operation_did_execute", args=["changes: anki.collection.OpChanges", "handler: object | None"], doc="""Called after an operation completes. Changes can be inspected to determine whether the UI needs updating. This will also be called when the legacy mw.reset() is used. """, ), Hook( name="focus_did_change", args=[ "new: QWidget | None", "old: QWidget | None", ], doc="""Called each time the focus changes. Can be used to defer updates from `operation_did_execute` until a window is brought to the front.""", ), Hook( name="backend_will_block", doc="""Called before one or more DB tasks are run in the background. Subscribers can use this to set a flag to avoid DB queries until the operation completes, as doing so will freeze the UI until the long-running operation completes. """, ), Hook( name="backend_did_block", doc="""Called after one or more DB tasks finish running in the background. Called regardless of the success of individual operations, and only called when there are no outstanding ops. """, ), Hook( name="theme_did_change", doc="Called after night mode is toggled.", ), Hook( name="body_classes_need_update", doc="Called when a setting involving a webview body class is toggled.", ), # Webview ################### Hook( name="webview_did_receive_js_message", args=["handled: tuple[bool, Any]", "message: str", "context: Any"], return_type="tuple[bool, Any]", doc="""Used to handle pycmd() messages sent from Javascript. Message is the string passed to pycmd(). For messages you don't want to handle, return 'handled' unchanged. If you handle a message and don't want it passed to the original bridge command handler, return (True, None). If you want to pass a value to pycmd's result callback, you can return it with (True, some_value). Context is the instance that was passed to set_bridge_command(). It can be inspected to check which screen this hook is firing in, and to get a reference to the screen. For example, if your code wishes to function only in the review screen, you could do: if not isinstance(context, aqt.reviewer.Reviewer): # not reviewer, pass on message return handled if message == "my-mark-action": # our message, call onMark() on the reviewer instance context.onMark() # and don't pass message to other handlers return (True, None) else: # some other command, pass it on return handled """, ), Hook( name="webview_will_set_content", args=[ "web_content: aqt.webview.WebContent", "context: object | None", ], doc="""Used to modify web content before it is rendered. Web_content contains the HTML, JS, and CSS the web view will be populated with. Context is the instance that was passed to stdHtml(). It can be inspected to check which screen this hook is firing in, and to get a reference to the screen. For example, if your code wishes to function only in the review screen, you could do: def on_webview_will_set_content(web_content: WebContent, context): if not isinstance(context, aqt.reviewer.Reviewer): # not reviewer, do not modify content return # reviewer, perform changes to content context: aqt.reviewer.Reviewer addon_package = mw.addonManager.addonFromModule(__name__) web_content.css.append( f"/_addons/{addon_package}/web/my-addon.css") web_content.js.append( f"/_addons/{addon_package}/web/my-addon.js") web_content.head += "" web_content.body += "
" """, ), Hook( name="webview_will_show_context_menu", args=["webview: aqt.webview.AnkiWebView", "menu: QMenu"], legacy_hook="AnkiWebView.contextMenuEvent", ), Hook( name="webview_did_inject_style_into_page", args=["webview: aqt.webview.AnkiWebView"], doc='''Called after standard styling is injected into an external html file, such as when loading the new graphs. You can use this hook to mutate the DOM before the page is revealed. For example: def mytest(webview: AnkiWebView): if webview.kind != AnkiWebViewKind.DECK_STATS: return webview.eval( """ div = document.createElement("div"); div.innerHTML = 'hello'; document.body.appendChild(div); """ ) gui_hooks.webview_did_inject_style_into_page.append(mytest) ''', ), # Main ################### Hook( name="main_window_did_init", doc="""Executed after the main window is fully initialized A sample use case for this hook would be to delay actions until Anki objects like the profile or collection are fully initialized. In contrast to `profile_did_open`, this hook will only fire once per Anki session and is thus suitable for single-shot subscribers. """, ), Hook( name="main_window_should_require_reset", args=[ "will_reset: bool", "reason: aqt.main.ResetReason | str", "context: object | None", ], return_type="bool", doc="""Executed before the main window will require a reset This hook can be used to change the behavior of the main window, when other dialogs, like the AddCards or Browser, require a reset from the main window. If you decide to use this hook, make you sure you check the reason for the reset. Some reasons require more attention than others, and skipping important ones might put the main window into an invalid state (e.g. display a deleted note). """, ), Hook(name="backup_did_complete"), Hook( name="profile_did_open", legacy_hook="profileLoaded", doc="""Executed whenever a user profile has been opened Please note that this hook will also be called on profile switches, so if you are looking to simply delay an add-on action in a single-shot manner, `main_window_did_init` is likely the more suitable choice. """, ), Hook(name="profile_will_close", legacy_hook="unloadProfile"), Hook( name="collection_will_temporarily_close", args=["col: anki.collection.Collection"], doc="""Called before one-way syncs and colpkg imports/exports.""", ), Hook( name="collection_did_temporarily_close", args=["col: anki.collection.Collection"], doc="""Called after one-way syncs and colpkg imports/exports.""", ), Hook( name="collection_did_load", args=["col: anki.collection.Collection"], legacy_hook="colLoading", ), Hook(name="undo_state_did_change", args=["info: UndoActionsInfo"]), Hook( name="style_did_init", args=["style: str"], return_type="str", legacy_hook="setupStyle", ), Hook( name="top_toolbar_did_init_links", args=["links: list[str]", "top_toolbar: aqt.toolbar.Toolbar"], doc="""Used to modify or add links in the top toolbar of Anki's main window 'links' is a list of HTML link elements. Add-ons can generate their own links by using aqt.toolbar.Toolbar.create_link. Links created in that way can then be appended to the link list, e.g.: def on_top_toolbar_did_init_links(links, toolbar): my_link = toolbar.create_link(...) links.append(my_link) """, ), Hook( name="top_toolbar_will_set_left_tray_content", args=["content: list[str]", "top_toolbar: aqt.toolbar.Toolbar"], doc="""Used to add custom add-on components to the *left* area of Anki's main window toolbar 'content' is a list of HTML strings added by add-ons which you can append your own components or elements to. To equip your components with logic and styling please see `webview_will_set_content` and `webview_did_receive_js_message`. Please note that Anki's main screen is due to undergo a significant refactor in the future and, as a result, add-ons subscribing to this hook will likely require changes to continue working. """, ), Hook( name="top_toolbar_will_set_right_tray_content", args=["content: list[str]", "top_toolbar: aqt.toolbar.Toolbar"], doc="""Used to add custom add-on components to the *right* area of Anki's main window toolbar 'content' is a list of HTML strings added by add-ons which you can append your own components or elements to. To equip your components with logic and styling please see `webview_will_set_content` and `webview_did_receive_js_message`. Please note that Anki's main screen is due to undergo a significant refactor in the future and, as a result, add-ons subscribing to this hook will likely require changes to continue working. """, ), Hook( name="top_toolbar_did_redraw", args=["top_toolbar: aqt.toolbar.Toolbar"], doc="""Executed when the top toolbar is redrawn""", ), Hook( name="media_sync_did_progress", args=["entry: str"], ), Hook(name="media_sync_did_start_or_stop", args=["running: bool"]), Hook( name="empty_cards_will_show", args=["diag: aqt.emptycards.EmptyCardsDialog"], doc="""Allows changing the list of cards to delete.""", ), Hook(name="sync_will_start", args=[]), Hook( name="sync_did_finish", args=[], doc="""Executes after the sync of the collection concluded. Note that the media sync did not necessarily finish at this point.""", ), Hook(name="media_check_will_start", args=[]), Hook( name="media_check_did_finish", args=["output: anki.media.CheckMediaResponse"], doc="""Called after Media Check finishes. `output` provides access to the unused/missing file lists and the text output that will be shown in the Check Media screen.""", ), Hook(name="day_did_change", doc="""Called when Anki moves to the next day."""), # Importing/exporting data ################### Hook( name="exporter_will_export", args=[ "export_options: aqt.import_export.exporting.ExportOptions", "exporter: aqt.import_export.exporting.Exporter", ], return_type="aqt.import_export.exporting.ExportOptions", doc="""Called before collection and deck exports. Allows add-ons to be notified of impending deck exports and potentially modify the export options. To perform the export unaltered, please return `export_options` as is, e.g.: def on_exporter_will_export(export_options: ExportOptions, exporter: Exporter): if not isinstance(exporter, ApkgExporter): return export_options export_options.limit = ... return export_options """, ), Hook( name="exporter_did_export", args=[ "export_options: aqt.import_export.exporting.ExportOptions", "exporter: aqt.import_export.exporting.Exporter", ], doc="""Called after collection and deck exports.""", ), Hook( name="legacy_exporter_will_export", args=["legacy_exporter: anki.exporting.Exporter"], doc="""Called before collection and deck exports performed by legacy exporters.""", ), Hook( name="legacy_exporter_did_export", args=["legacy_exporter: anki.exporting.Exporter"], doc="""Called after collection and deck exports performed by legacy exporters.""", ), Hook( name="exporters_list_did_initialize", args=["exporters: list[Type[aqt.import_export.exporting.Exporter]]"], doc="""Called after the list of exporter classes is created. Allows you to register custom exporters and/or replace existing ones by modifying the exporter list. """, ), # Dialog Manager ################### Hook( name="dialog_manager_did_open_dialog", args=[ "dialog_manager: aqt.DialogManager", "dialog_name: str", "dialog_instance: QWidget", ], doc="""Executed after aqt.dialogs creates a dialog window""", ), # Adding cards ################### Hook( name="add_cards_will_show_history_menu", args=["addcards: aqt.addcards.AddCards", "menu: QMenu"], legacy_hook="AddCards.onHistory", ), Hook( name="add_cards_did_init", args=["addcards: aqt.addcards.AddCards"], ), Hook( name="add_cards_did_add_note", args=["note: anki.notes.Note"], legacy_hook="AddCards.noteAdded", ), Hook( name="add_cards_will_add_note", args=["problem: str | None", "note: anki.notes.Note"], return_type="str | None", doc="""Decides whether the note should be added to the collection or not. It is assumed to come from the addCards window. reason_to_already_reject is the first reason to reject that was found, or None. If your filter wants to reject, it should replace return the reason to reject. Otherwise return the input.""", ), Hook( name="add_cards_might_add_note", args=["optional_problems: list[str]", "note: anki.notes.Note"], doc=""" Allows you to provide an optional reason to reject a note. A yes / no dialog will open displaying the problem, to which the user can decide if they would like to add the note anyway. optional_problems is a list containing the optional reasons for which you might reject a note. If your add-on wants to add a reason, it should append the reason to the list. An example add-on that asks the user for confirmation before adding a card without tags: def might_reject_empty_tag(optional_problems, note): if not any(note.tags): optional_problems.append("Add cards without tags?") """, ), Hook( name="addcards_will_add_history_entry", args=["line: str", "note: anki.notes.Note"], return_type="str", doc="""Allows changing the history line in the add-card window.""", ), Hook( name="add_cards_did_change_note_type", args=["old: anki.models.NoteType", "new: anki.models.NoteType"], doc="""Deprecated. Use addcards_did_change_note_type instead. Executed after the user selects a new note type when adding cards.""", ), Hook( name="addcards_did_change_note_type", args=[ "addcards: aqt.addcards.AddCards", "old: anki.models.NoteType", "new: anki.models.NoteType", ], replaces="add_cards_did_change_note_type", replaced_hook_args=["old: anki.models.NoteType", "new: anki.models.NoteType"], doc="""Executed after the user selects a new note type when adding cards.""", ), Hook( name="add_cards_did_change_deck", args=["new_deck_id: int"], doc="""Executed after the user selects a new different deck when adding cards.""", ), # Editing ################### Hook( name="editor_did_init_left_buttons", args=["buttons: list[str]", "editor: aqt.editor.Editor"], ), Hook( name="editor_did_init_buttons", args=["buttons: list[str]", "editor: aqt.editor.Editor"], ), Hook( name="editor_did_init_shortcuts", args=["shortcuts: list[tuple]", "editor: aqt.editor.Editor"], legacy_hook="setupEditorShortcuts", ), Hook( name="editor_will_show_context_menu", args=["editor_webview: aqt.editor.EditorWebView", "menu: QMenu"], legacy_hook="EditorWebView.contextMenuEvent", ), Hook( name="editor_did_fire_typing_timer", args=["note: anki.notes.Note"], legacy_hook="editTimer", ), Hook( name="editor_did_focus_field", args=["note: anki.notes.Note", "current_field_idx: int"], legacy_hook="editFocusGained", ), Hook( name="editor_did_unfocus_field", args=["changed: bool", "note: anki.notes.Note", "current_field_idx: int"], return_type="bool", legacy_hook="editFocusLost", ), Hook( name="editor_did_load_note", args=["editor: aqt.editor.Editor"], legacy_hook="loadNote", ), Hook( name="editor_did_update_tags", args=["note: anki.notes.Note"], legacy_hook="tagsUpdated", ), Hook( name="editor_will_munge_html", args=["txt: str", "editor: aqt.editor.Editor"], return_type="str", doc="""Allows manipulating the text that will be saved by the editor""", ), Hook( name="editor_will_use_font_for_field", args=["font: str"], return_type="str", legacy_hook="mungeEditingFontName", ), Hook( name="editor_web_view_did_init", args=["editor_web_view: aqt.editor.EditorWebView"], ), Hook( name="editor_did_init", args=["editor: aqt.editor.Editor"], ), Hook( name="editor_will_load_note", args=["js: str", "note: anki.notes.Note", "editor: aqt.editor.Editor"], return_type="str", doc="""Allows changing the javascript commands to load note before executing it and do change in the QT editor.""", ), Hook( name="editor_did_paste", args=[ "editor: aqt.editor.Editor", "html: str", "internal: bool", "extended: bool", ], doc="""Called after some data is pasted by python into an editor field.""", ), Hook( name="editor_will_process_mime", args=[ "mime: QMimeData", "editor_web_view: aqt.editor.EditorWebView", "internal: bool", "extended: bool", "drop_event: bool", ], return_type="QMimeData", doc=""" Used to modify MIME data stored in the clipboard after a drop or a paste. Called after the user pastes or drag-and-drops something to Anki before Anki processes the data. The function should return a new or existing QMimeData object. "mime" contains the corresponding QMimeData object. "internal" indicates whether the drop or paste is performed between Anki fields. Most likely you want to skip processing if "internal" was set to True. "extended" indicates whether the user requested an extended paste. "drop_event" indicates whether the event was triggered by a drag-and-drop or by a right-click paste. """, ), Hook( name="editor_state_did_change", args=[ "editor: aqt.editor.Editor", "new_state: aqt.editor.EditorState", "old_state: aqt.editor.EditorState", ], doc="""Called when the input state of the editor changes, e.g. when switching to an image occlusion note type.""", ), Hook( name="editor_mask_editor_did_load_image", args=["editor: aqt.editor.Editor", "path_or_nid: str | anki.notes.NoteId"], doc="""Called when the image occlusion mask editor has completed loading an image. When adding new notes `path_or_nid` will be the path to the image file. When editing existing notes `path_or_nid` will be the note id.""", ), # Tag ################### Hook(name="tag_editor_did_process_key", args=["tag_edit: TagEdit", "evt: QEvent"]), # Sound/video ################### Hook(name="av_player_will_play", args=["tag: anki.sound.AVTag"]), Hook( name="av_player_did_begin_playing", args=["player: aqt.sound.Player", "tag: anki.sound.AVTag"], ), Hook(name="av_player_did_end_playing", args=["player: aqt.sound.Player"]), Hook( name="av_player_will_play_tags", args=[ "tags: list[anki.sound.AVTag]", "side: str", "context: Any", ], doc="""Called before playing a card side's sounds. `tags` can be used to inspect and manipulate the sounds that will be played (if any). `side` can either be "question" or "answer". `context` is the screen where the sounds will be played (e.g., Reviewer, Previewer, and CardLayout). This won't be called when the user manually plays sounds using `Replay Audio`. Note that this hook is called even when the `Automatically play audio` option is unchecked; This is so as to allow playing custom sounds regardless of that option.""", ), # Addon ################### Hook( name="addon_config_editor_will_display_json", args=["text: str"], return_type="str", doc="""Allows changing the text of the json configuration before actually displaying it to the user. For example, you can replace "\\\\n" by some actual new line. Then you can replace the new lines by "\\\\n" while reading the file and let the user uses real new line in string instead of its encoding.""", ), Hook( name="addon_config_editor_will_save_json", args=["text: str"], return_type="str", doc="""Deprecated. Use addon_config_editor_will_update_json instead. Allows changing the text of the json configuration that was received from the user before actually reading it. For example, you can replace new line in strings by some "\\\\n".""", ), Hook( name="addon_config_editor_will_update_json", args=["text: str", "addon: str"], return_type="str", replaces="addon_config_editor_will_save_json", replaced_hook_args=["text: str"], doc="""Allows changing the text of the json configuration that was received from the user before actually reading it. For example, you can replace new line in strings by some "\\\\n".""", ), Hook( name="addons_dialog_will_show", args=["dialog: aqt.addons.AddonsDialog"], doc="""Allows changing the add-on dialog before it is shown. E.g. add buttons.""", ), Hook( name="addons_dialog_did_change_selected_addon", args=["dialog: aqt.addons.AddonsDialog", "add_on: aqt.addons.AddonMeta"], doc="""Allows doing an action when a single add-on is selected.""", ), Hook( name="addons_dialog_will_delete_addons", args=["dialog: aqt.addons.AddonsDialog", "ids: list[str]"], doc="""Allows doing an action before an add-on is deleted.""", ), Hook( name="addon_manager_will_install_addon", args=["manager: aqt.addons.AddonManager", "module: str"], doc="""Called before installing or updating an addon. Can be used to release DB connections or open files that would prevent an update from succeeding.""", ), Hook( name="addon_manager_did_install_addon", args=["manager: aqt.addons.AddonManager", "module: str"], doc="""Called after installing or updating an addon. Can be used to restore DB connections or open files after an add-on has been updated.""", ), # Model ################### Hook( name="models_advanced_will_show", args=["advanced: QDialog"], ), Hook( name="models_did_init_buttons", args=[ "buttons: list[tuple[str, Callable[[], None]]]", "models: aqt.models.Models", ], return_type="list[tuple[str, Callable[[], None]]]", doc="""Allows adding buttons to the Model dialog""", ), # Fields ################### Hook( name="fields_did_add_field", args=["dialog: aqt.fields.FieldDialog", "field: anki.models.FieldDict"], ), Hook( name="fields_did_rename_field", args=[ "dialog: aqt.fields.FieldDialog", "field: anki.models.FieldDict", "old_name: str", ], ), Hook( name="fields_did_delete_field", args=["dialog: aqt.fields.FieldDialog", "field: anki.models.FieldDict"], ), # Stats ################### Hook( name="stats_dialog_will_show", args=["dialog: aqt.stats.NewDeckStats"], doc="""Allows changing the stats dialog before it is shown.""", ), Hook( name="stats_dialog_old_will_show", args=["dialog: aqt.stats.DeckStats"], doc="""Allows changing the old stats dialog before it is shown.""", ), # Other ################### Hook( name="current_note_type_did_change", args=["notetype: NotetypeDict"], legacy_hook="currentModelChanged", legacy_no_args=True, ), Hook(name="sidebar_should_refresh_decks", doc="Legacy, do not use."), Hook(name="sidebar_should_refresh_notetypes", doc="Legacy, do not use."), Hook( name="deck_browser_will_show_options_menu", args=["menu: QMenu", "deck_id: int"], legacy_hook="showDeckOptions", ), Hook( name="flag_label_did_change", args=[], doc="Used to update the GUI when a new flag label is assigned.", ), ] suffix = "" if __name__ == "__main__": path = sys.argv[1] write_file(path, hooks, prefix, suffix) ================================================ FILE: qt/tools/runanki.system.in ================================================ #!/usr/bin/env python3 import sys sys.path.append("@PREFIX@/share/anki") import aqt aqt.run() ================================================ FILE: rslib/.gitignore ================================================ Cargo.lock .build target ================================================ FILE: rslib/Cargo.toml ================================================ [package] name = "anki" version.workspace = true authors.workspace = true edition.workspace = true license.workspace = true publish = false rust-version.workspace = true workspace = ".." description = "Anki's Rust library code" [features] bench = ["criterion"] rustls = ["reqwest/rustls-tls", "reqwest/rustls-tls-native-roots"] native-tls = ["reqwest/native-tls"] [[bench]] name = "benchmark" harness = false required-features = ["bench"] [build-dependencies] anki_io.workspace = true anki_proto.workspace = true anki_proto_gen.workspace = true anyhow.workspace = true inflections.workspace = true itertools.workspace = true prettyplease.workspace = true prost.workspace = true prost-reflect.workspace = true syn.workspace = true [dev-dependencies] async-stream.workspace = true reqwest = { workspace = true, features = ["native-tls"] } wiremock.workspace = true [dependencies] criterion = { workspace = true, optional = true } ammonia.workspace = true anki_i18n.workspace = true anki_io.workspace = true anki_proto.workspace = true async-compression.workspace = true async-trait.workspace = true axum.workspace = true axum-client-ip.workspace = true axum-extra.workspace = true bitflags.workspace = true blake3.workspace = true bytes.workspace = true chrono.workspace = true coarsetime.workspace = true convert_case.workspace = true csv.workspace = true data-encoding.workspace = true difflib.workspace = true dirs.workspace = true envy.workspace = true flate2.workspace = true fluent.workspace = true fluent-bundle.workspace = true fnv.workspace = true fsrs.workspace = true futures.workspace = true hex.workspace = true htmlescape.workspace = true hyper.workspace = true id_tree.workspace = true itertools.workspace = true nom.workspace = true num_cpus.workspace = true num_enum.workspace = true once_cell.workspace = true pbkdf2.workspace = true percent-encoding-iri.workspace = true permutation.workspace = true phf.workspace = true pin-project.workspace = true prost.workspace = true pulldown-cmark.workspace = true rand.workspace = true rayon.workspace = true regex.workspace = true reqwest.workspace = true rusqlite.workspace = true rustls-pemfile.workspace = true scopeguard.workspace = true serde.workspace = true serde-aux.workspace = true serde_json.workspace = true serde_repr.workspace = true serde_tuple.workspace = true sha1.workspace = true snafu.workspace = true strum.workspace = true tempfile.workspace = true tokio.workspace = true tokio-util.workspace = true tower-http.workspace = true tracing.workspace = true tracing-appender.workspace = true tracing-subscriber.workspace = true unic-ucd-category.workspace = true unicase.workspace = true unicode-normalization.workspace = true zip.workspace = true zstd.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true ================================================ FILE: rslib/README.md ================================================ Anki's Rust code. ================================================ FILE: rslib/bench.sh ================================================ #!/bin/bash cargo install cargo-criterion --version 1.1.0 cargo criterion --bench benchmark --features bench ================================================ FILE: rslib/benches/benchmark.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki::card_rendering::anki_directive_benchmark; use criterion::criterion_group; use criterion::criterion_main; use criterion::Criterion; pub fn criterion_benchmark(c: &mut Criterion) { c.bench_function("anki_tag_parse", |b| b.iter(|| anki_directive_benchmark())); } criterion_group!(benches, criterion_benchmark); criterion_main!(benches); ================================================ FILE: rslib/build.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod rust_interface; use std::fs; use anki_proto_gen::descriptors_path; use anyhow::Result; use prost_reflect::DescriptorPool; fn main() -> Result<()> { println!("cargo:rerun-if-changed=../out/buildhash"); let buildhash = fs::read_to_string("../out/buildhash").unwrap_or_default(); println!("cargo:rustc-env=BUILDHASH={buildhash}"); let descriptors_path = descriptors_path(); println!("cargo:rerun-if-changed={}", descriptors_path.display()); let pool = DescriptorPool::decode(std::fs::read(descriptors_path)?.as_ref())?; rust_interface::write_rust_interface(&pool)?; Ok(()) } ================================================ FILE: rslib/i18n/Cargo.toml ================================================ [package] name = "anki_i18n" version.workspace = true authors.workspace = true edition.workspace = true license.workspace = true publish = false rust-version.workspace = true description = "Anki's Rust library i18n code" [lib] name = "anki_i18n" path = "src/lib.rs" [build-dependencies] fluent-syntax.workspace = true fluent.workspace = true unic-langid.workspace = true serde.workspace = true serde_json.workspace = true inflections.workspace = true anki_io.workspace = true anyhow.workspace = true itertools.workspace = true regex.workspace = true [dependencies] fluent.workspace = true fluent-bundle.workspace = true intl-memoizer.workspace = true num-format.workspace = true phf.workspace = true serde.workspace = true serde_json.workspace = true unic-langid.workspace = true ================================================ FILE: rslib/i18n/build.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod check; mod extract; mod gather; mod python; mod typescript; mod write_strings; use std::path::PathBuf; use anki_io::create_dir_all; use anki_io::write_file_if_changed; use anyhow::Result; use check::check; use extract::get_modules; use gather::get_ftl_data; use write_strings::write_strings; // fixme: check all variables are present in translations as well? fn main() -> Result<()> { // generate our own requirements let mut map = get_ftl_data(); check(&map); let mut modules = get_modules(&map); write_strings(&map, &modules, "strings.rs", "All"); typescript::write_ts_interface(&modules)?; python::write_py_interface(&modules)?; // write strings.json file to requested path if let Some(path) = option_env!("STRINGS_JSON") { if !path.is_empty() { let path = PathBuf::from(path); let meta_json = serde_json::to_string_pretty(&modules).unwrap(); create_dir_all(path.parent().unwrap())?; write_file_if_changed(path, meta_json)?; } } // generate strings for the launcher map.iter_mut() .for_each(|(_, modules)| modules.retain(|module, _| module == "launcher")); modules.retain(|module| module.name == "launcher"); write_strings(&map, &modules, "strings_launcher.rs", "Launcher"); Ok(()) } ================================================ FILE: rslib/i18n/check.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html //! Check the .ftl files at build time to ensure we don't get runtime load //! failures. use fluent::FluentBundle; use fluent::FluentResource; use unic_langid::LanguageIdentifier; use super::gather::TranslationsByLang; pub fn check(lang_map: &TranslationsByLang) { for (lang, files_map) in lang_map { for (fname, content) in files_map { check_content(lang, fname, content); } } } fn check_content(lang: &str, fname: &str, content: &str) { let lang_id: LanguageIdentifier = "en-US".parse().unwrap(); let resource = FluentResource::try_new(content.into()).unwrap_or_else(|e| { panic!("{content}\nUnable to parse {lang}/{fname}: {e:?}"); }); let mut bundle: FluentBundle = FluentBundle::new(vec![lang_id]); bundle.add_resource(resource).unwrap_or_else(|e| { panic!("{content}\nUnable to bundle - duplicate key? {lang}/{fname}: {e:?}"); }); } ================================================ FILE: rslib/i18n/extract.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashSet; use std::fmt::Write; use fluent_syntax::ast::Entry; use fluent_syntax::ast::Expression; use fluent_syntax::ast::InlineExpression; use fluent_syntax::ast::Pattern; use fluent_syntax::ast::PatternElement; use fluent_syntax::parser::parse; use serde::Serialize; use crate::gather::TranslationsByLang; #[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Serialize)] pub struct Module { pub name: String, pub translations: Vec, pub index: usize, } #[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Serialize)] pub struct Translation { pub key: String, pub text: String, pub variables: Vec, pub index: usize, } #[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Serialize)] pub struct Variable { pub name: String, pub kind: VariableKind, } #[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Serialize)] pub enum VariableKind { Int, Float, String, Any, } pub fn get_modules(data: &TranslationsByLang) -> Vec { let mut output = vec![]; for (module, text) in &data["templates"] { output.push(Module { name: module.to_string(), translations: extract_metadata(text), index: 0, }); } output.sort_unstable(); for (module_idx, module) in output.iter_mut().enumerate() { module.index = module_idx; for (entry_idx, entry) in module.translations.iter_mut().enumerate() { entry.index = entry_idx; } } output } fn extract_metadata(ftl_text: &str) -> Vec { let res = parse(ftl_text).unwrap(); let mut output = vec![]; for entry in res.body { if let Entry::Message(m) = entry { if let Some(pattern) = m.value { let mut visitor = Visitor::default(); visitor.visit_pattern(&pattern); let key = m.id.name.to_string(); // special case translations that were ported from gettext, and use embedded // terms that reference other variables that aren't visible to our visitor if key == "statistics-studied-today" { visitor.variables.push("amount".to_string()); visitor.variables.push("cards".to_string()); } else if key == "statistics-average-answer-time" { visitor.variables.push("cards-per-minute".to_string()); } let (text, variables) = visitor.into_output(); output.push(Translation { key, text, variables, index: 0, }) } } } output.sort_unstable(); output } /// Gather variable names and (rough) text from Fluent AST. #[derive(Default)] struct Visitor { text: String, variables: Vec, } impl Visitor { fn into_output(self) -> (String, Vec) { // make unique, preserving order let mut seen = HashSet::new(); let vars: Vec<_> = self .variables .into_iter() .filter(|v| { if seen.contains(v) { false } else { seen.insert(v.clone()) } }) .map(Into::into) .collect(); (self.text, vars) } fn visit_pattern(&mut self, pattern: &Pattern<&str>) { for element in &pattern.elements { match element { PatternElement::TextElement { value } => self.text.push_str(value), PatternElement::Placeable { expression } => self.visit_expression(expression), } } } fn visit_inline_expression(&mut self, expr: &InlineExpression<&str>, in_select: bool) { match expr { InlineExpression::VariableReference { id } => { if !in_select { write!(self.text, "{{${}}}", id.name).unwrap(); } self.variables.push(id.name.to_string()); } InlineExpression::Placeable { expression } => { self.visit_expression(expression); } _ => {} } } fn visit_expression(&mut self, expression: &Expression<&str>) { match expression { Expression::Select { selector, variants } => { self.visit_inline_expression(selector, true); self.visit_pattern(&variants.last().unwrap().value) } Expression::Inline(expr) => self.visit_inline_expression(expr, false), } } } impl From for Variable { fn from(name: String) -> Self { // rather than adding more items here as we add new strings, we should probably // try to either reuse existing ones, or consider some sort of Hungarian // notation let kind = match name.as_str() { "cards" | "notes" | "count" | "amount" | "reviews" | "total" | "selected" | "kilobytes" | "daysStart" | "daysEnd" | "days" | "secs-per-card" | "remaining" | "hourStart" | "hourEnd" | "correct" | "decks" | "changed" => VariableKind::Int, "average-seconds" | "cards-per-minute" => VariableKind::Float, "val" | "found" | "expected" | "part" | "percent" | "day" | "number" | "up" | "down" | "seconds" | "megs" => VariableKind::Any, term => { let term = term.to_ascii_lowercase(); if term.ends_with("count") { VariableKind::Int } else if term.starts_with("num") { VariableKind::Any } else { VariableKind::String } } }; Variable { name, kind } } } ================================================ FILE: rslib/i18n/gather.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html //! By default, the Qt translations will be included in rslib. EXTRA_FTL_ROOT //! can be set to an external folder so the mobile clients can use their own //! translations instead. use std::collections::HashMap; use std::fs; use std::path::Path; use std::path::PathBuf; pub type TranslationsByFile = HashMap; pub type TranslationsByLang = HashMap; /// Read the contents of the FTL files into a TranslationMap structure. pub fn get_ftl_data() -> TranslationsByLang { let mut map = TranslationsByLang::default(); // English core templates are taken from this repo let ftl_base = source_tree_root(); add_folder(&mut map, &ftl_base.join("core"), "templates"); // And core translations from submodule add_translation_root(&mut map, &ftl_base.join("core-repo/core"), true); if let Some(path) = extra_ftl_root() { // Mobile client has requested its own extra translations add_translation_root(&mut map, &path, false); } else { // Qt core templates from this repo add_folder(&mut map, &ftl_base.join("qt"), "templates"); // And translations from submodule add_translation_root(&mut map, &ftl_base.join("qt-repo/desktop"), true) } map } /// For each .ftl file in the provided folder, add it to the translation map. fn add_folder(map: &mut TranslationsByLang, folder: &Path, lang: &str) { let map_entry = map.entry(lang.to_string()).or_default(); for entry in fs::read_dir(folder).unwrap() { let entry = entry.unwrap(); let fname = entry.file_name().to_string_lossy().to_string(); if !fname.ends_with(".ftl") { continue; } let module = fname.trim_end_matches(".ftl").replace('-', "_"); let text = fs::read_to_string(entry.path()).unwrap(); assert!( text.ends_with('\n'), "file was missing final newline: {entry:?}" ); map_entry.entry(module).or_default().push_str(&text); println!("cargo:rerun-if-changed={}", entry.path().to_str().unwrap()); } } /// For each language folder in `root`, add the ftl files stored inside. /// If ignore_templates is true, the templates/ folder will be ignored, on the /// assumption the templates were extracted from the source tree. fn add_translation_root(map: &mut TranslationsByLang, root: &Path, ignore_templates: bool) { for entry in fs::read_dir(root).unwrap() { let entry = entry.unwrap(); let lang = entry.file_name().to_string_lossy().to_string(); if ignore_templates && lang == "templates" { continue; } add_folder(map, &entry.path(), &lang); } } fn source_tree_root() -> PathBuf { PathBuf::from("../../ftl") } fn extra_ftl_root() -> Option { std::env::var("EXTRA_FTL_ROOT").ok().map(PathBuf::from) } ================================================ FILE: rslib/i18n/python.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::fmt::Write; use std::path::PathBuf; use anki_io::create_dir_all; use anki_io::write_file_if_changed; use anyhow::Result; use inflections::Inflect; use itertools::Itertools; use crate::extract::Module; use crate::extract::Variable; use crate::extract::VariableKind; pub fn write_py_interface(modules: &[Module]) -> Result<()> { let mut out = header(); render_methods(modules, &mut out); render_legacy_enum(modules, &mut out); if let Some(path) = option_env!("STRINGS_PY") { let path = PathBuf::from(path); create_dir_all(path.parent().unwrap())?; write_file_if_changed(path, out)?; } Ok(()) } fn render_legacy_enum(modules: &[Module], out: &mut String) { out.push_str("class LegacyTranslationEnum:\n"); for (mod_idx, module) in modules.iter().enumerate() { for (str_idx, translation) in module.translations.iter().enumerate() { let upper = translation.key.replace('-', "_").to_upper_case(); writeln!(out, r#" {upper} = ({mod_idx}, {str_idx})"#).unwrap(); } } } fn render_methods(modules: &[Module], out: &mut String) { for (mod_idx, module) in modules.iter().enumerate() { for (str_idx, translation) in module.translations.iter().enumerate() { let text = &translation.text; let key = &translation.key; let func_name = key.replace('-', "_").to_snake_case(); let arg_types = get_arg_types(&translation.variables); let args = get_args(&translation.variables); writeln!( out, r#" def {func_name}(self, {arg_types}) -> str: r''' {text} ''' return self._translate({mod_idx}, {str_idx}, {{{args}}}) "#, ) .unwrap(); } } } fn header() -> String { "# Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # This file is automatically generated from the *.ftl files. from __future__ import annotations from typing import Union FluentVariable = Union[str, int, float] class GeneratedTranslations: def _translate(self, module: int, translation: int, args: dict) -> str: raise Exception('not implemented') " .to_string() } fn get_arg_types(args: &[Variable]) -> String { let args = args .iter() .map(|arg| format!("{}: {}", arg.name.to_snake_case(), arg_kind(&arg.kind))) .join(", "); if args.is_empty() { "".into() } else { args } } fn get_args(args: &[Variable]) -> String { args.iter() .map(|arg| format!("\"{}\": {}", arg.name, arg.name.to_snake_case())) .join(", ") } fn arg_kind(kind: &VariableKind) -> &str { match kind { VariableKind::Int => "int", VariableKind::Float => "float", VariableKind::String => "str", VariableKind::Any => "FluentVariable", } } ================================================ FILE: rslib/i18n/src/generated.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html #![allow(clippy::all)] #[derive(Clone)] pub struct All; // Include auto-generated content include!(concat!(env!("OUT_DIR"), "/strings.rs")); impl Translations for All { const STRINGS: &phf::Map<&str, &phf::Map<&str, &str>> = &_STRINGS; const KEYS_BY_MODULE: &[&[&str]] = &_KEYS_BY_MODULE; } ================================================ FILE: rslib/i18n/src/generated_launcher.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html #![allow(clippy::all)] #[derive(Clone)] pub struct Launcher; // Include auto-generated content include!(concat!(env!("OUT_DIR"), "/strings_launcher.rs")); impl Translations for Launcher { const STRINGS: &phf::Map<&str, &phf::Map<&str, &str>> = &_STRINGS; const KEYS_BY_MODULE: &[&[&str]] = &_KEYS_BY_MODULE; } ================================================ FILE: rslib/i18n/src/lib.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod generated; mod generated_launcher; use std::borrow::Cow; use std::marker::PhantomData; use std::sync::Arc; use std::sync::Mutex; use fluent::types::FluentNumber; use fluent::FluentArgs; use fluent::FluentResource; use fluent::FluentValue; use fluent_bundle::bundle::FluentBundle as FluentBundleOrig; use num_format::Locale; use serde::Serialize; use unic_langid::LanguageIdentifier; type FluentBundle = FluentBundleOrig; pub use fluent::fluent_args as tr_args; pub use crate::generated::All; pub use crate::generated_launcher::Launcher; pub trait Number: Into { fn round(self) -> Self; } impl Number for i32 { #[inline] fn round(self) -> Self { self } } impl Number for i64 { #[inline] fn round(self) -> Self { self } } impl Number for u32 { #[inline] fn round(self) -> Self { self } } impl Number for f32 { // round to 2 decimal places #[inline] fn round(self) -> Self { (self * 100.0).round() / 100.0 } } impl Number for u64 { #[inline] fn round(self) -> Self { self } } impl Number for usize { #[inline] fn round(self) -> Self { self } } fn remapped_lang_name(lang: &LanguageIdentifier) -> &str { let region = lang.region.as_ref().map(|v| v.as_str()); match lang.language.as_str() { "en" => { match region { Some("GB") | Some("AU") => "en-GB", // go directly to fallback _ => "templates", } } "zh" => match region { Some("TW") | Some("HK") => "zh-TW", _ => "zh-CN", }, "pt" => { if let Some("PT") = region { "pt-PT" } else { "pt-BR" } } "ga" => "ga-IE", "hy" => "hy-AM", "nb" => "nb-NO", "sv" => "sv-SE", other => other, } } /// Some sample text for testing purposes. fn test_en_text() -> &'static str { " valid-key = a valid key only-in-english = not translated two-args-key = two args: {$one} and {$two} plural = You have {$hats -> [one] 1 hat *[other] {$hats} hats }. " } fn test_jp_text() -> &'static str { " valid-key = キー two-args-key = {$one}と{$two} " } fn test_pl_text() -> &'static str { " one-arg-key = fake Polish {$one} " } /// Parse resource text into an AST for inclusion in a bundle. /// Returns None if text contains errors. /// extra_text may contain resources loaded from the filesystem /// at runtime. If it contains errors, they will not prevent a /// bundle from being returned. fn get_bundle( text: &str, extra_text: String, locales: &[LanguageIdentifier], ) -> Option> { let res = FluentResource::try_new(text.into()) .map_err(|e| { println!("Unable to parse translations file: {e:?}"); }) .ok()?; let mut bundle: FluentBundle = FluentBundle::new_concurrent(locales.to_vec()); bundle .add_resource(res) .map_err(|e| { println!("Duplicate key detected in translation file: {e:?}"); }) .ok()?; if !extra_text.is_empty() { match FluentResource::try_new(extra_text) { Ok(res) => bundle.add_resource_overriding(res), Err((_res, e)) => println!("Unable to parse translations file: {e:?}"), } } // add numeric formatter set_bundle_formatter_for_langs(&mut bundle, locales); Some(bundle) } /// Get a bundle that includes any filesystem overrides. fn get_bundle_with_extra( text: &str, lang: Option, ) -> Option> { let mut extra_text = "".into(); if cfg!(test) { // inject some test strings in test mode match &lang { None => { extra_text += test_en_text(); } Some(lang) if lang.language == "ja" => { extra_text += test_jp_text(); } Some(lang) if lang.language == "pl" => { extra_text += test_pl_text(); } _ => {} } } let mut locales = if let Some(lang) = lang { vec![lang] } else { vec![] }; locales.push("en-US".parse().unwrap()); get_bundle(text, extra_text, &locales) } pub trait Translations { const STRINGS: &phf::Map<&str, &phf::Map<&str, &str>>; const KEYS_BY_MODULE: &[&[&str]]; } #[derive(Clone)] pub struct I18n { inner: Arc>, _translations_type: std::marker::PhantomData

, } impl I18n

{ fn get_key(module_idx: usize, translation_idx: usize) -> &'static str { P::KEYS_BY_MODULE .get(module_idx) .and_then(|translations| translations.get(translation_idx)) .cloned() .unwrap_or("invalid-module-or-translation-index") } fn get_modules(langs: &[LanguageIdentifier], desired_modules: &[String]) -> Vec { langs .iter() .map(|lang| { let mut buf = String::new(); let lang_name = remapped_lang_name(lang); if let Some(strings) = P::STRINGS.get(lang_name) { if desired_modules.is_empty() { // empty list, provide all modules for value in strings.values() { buf.push_str(value) } } else { for module_name in desired_modules { if let Some(text) = strings.get(module_name.as_str()) { buf.push_str(text); } } } } buf }) .collect() } /// This temporarily behaves like the older code; in the future we could /// either access each &str separately, or load them on demand. fn ftl_localized_text(lang: &LanguageIdentifier) -> Option { let lang = remapped_lang_name(lang); if let Some(module) = P::STRINGS.get(lang) { let mut text = String::new(); for module_text in module.values() { text.push_str(module_text) } Some(text) } else { None } } pub fn template_only() -> Self { Self::new::<&str>(&[]) } pub fn new>(locale_codes: &[S]) -> Self { let mut input_langs = vec![]; let mut bundles = Vec::with_capacity(locale_codes.len() + 1); for code in locale_codes { let code = code.as_ref(); if let Ok(lang) = code.parse::() { input_langs.push(lang.clone()); if lang.language == "en" { // if English was listed, any further preferences are skipped, // as the template has 100% coverage, and we need to ensure // it is tried prior to any other langs. break; } } } let mut output_langs = vec![]; for lang in input_langs { // if the language is bundled in the binary if let Some(text) = Self::ftl_localized_text(&lang).or_else(|| { // when testing, allow missing translations if cfg!(test) { Some(String::new()) } else { None } }) { if let Some(bundle) = get_bundle_with_extra(&text, Some(lang.clone())) { bundles.push(bundle); output_langs.push(lang); } else { println!("Failed to create bundle for {:?}", lang.language) } } } // add English templates let template_lang = "en-US".parse().unwrap(); let template_text = Self::ftl_localized_text(&template_lang).unwrap(); let template_bundle = get_bundle_with_extra(&template_text, None).unwrap(); bundles.push(template_bundle); output_langs.push(template_lang); if locale_codes.is_empty() || cfg!(test) { // disable isolation characters in test mode for bundle in &mut bundles { bundle.set_use_isolating(false); } } Self { inner: Arc::new(Mutex::new(I18nInner { bundles, langs: output_langs, })), _translations_type: PhantomData, } } pub fn translate_via_index( &self, module_index: usize, message_index: usize, args: FluentArgs, ) -> String { let key = Self::get_key(module_index, message_index); self.translate(key, Some(args)).into() } fn translate<'a>(&'a self, key: &str, args: Option) -> Cow<'a, str> { for bundle in &self.inner.lock().unwrap().bundles { let msg = match bundle.get_message(key) { Some(msg) => msg, // not translated in this bundle None => continue, }; let pat = match msg.value() { Some(val) => val, // empty value None => continue, }; let mut errs = vec![]; let out = bundle.format_pattern(pat, args.as_ref(), &mut errs); if !errs.is_empty() { println!("Error(s) in translation '{key}': {errs:?}"); } // clone so we can discard args return out.to_string().into(); } // return the key name if it was missing key.to_string().into() } /// Return text from configured locales for use with the JS Fluent /// implementation. pub fn resources_for_js(&self, desired_modules: &[String]) -> ResourcesForJavascript { let inner = self.inner.lock().unwrap(); let resources = Self::get_modules(&inner.langs, desired_modules); ResourcesForJavascript { langs: inner.langs.iter().map(ToString::to_string).collect(), resources, } } } struct I18nInner { // bundles in preferred language order, with template English as the // last element bundles: Vec>, langs: Vec, } // Simple number formatting implementation fn set_bundle_formatter_for_langs(bundle: &mut FluentBundle, langs: &[LanguageIdentifier]) { let formatter = if want_comma_as_decimal_separator(langs) { format_decimal_with_comma } else { format_decimal_with_period }; bundle.set_formatter(Some(formatter)); } fn first_available_num_format_locale(langs: &[LanguageIdentifier]) -> Option { for lang in langs { if let Some(locale) = num_format_locale(lang) { return Some(locale); } } None } // try to locate a num_format locale for a given language identifier fn num_format_locale(lang: &LanguageIdentifier) -> Option { // region provided? if let Some(region) = lang.region { let code = format!("{}_{}", lang.language, region); if let Ok(locale) = Locale::from_name(code) { return Some(locale); } } // try the language alone Locale::from_name(lang.language.as_str()).ok() } fn want_comma_as_decimal_separator(langs: &[LanguageIdentifier]) -> bool { let separator = if let Some(locale) = first_available_num_format_locale(langs) { locale.decimal() } else { "." }; separator == "," } fn format_decimal_with_comma( val: &FluentValue, _intl: &intl_memoizer::concurrent::IntlLangMemoizer, ) -> Option { format_number_values(val, Some(",")) } fn format_decimal_with_period( val: &FluentValue, _intl: &intl_memoizer::concurrent::IntlLangMemoizer, ) -> Option { format_number_values(val, None) } #[inline] fn format_number_values(val: &FluentValue, alt_separator: Option<&'static str>) -> Option { match val { FluentValue::Number(num) => { // create a string with desired maximum digits let max_frac_digits = 2; let with_max_precision = format!( "{number:.precision$}", number = num.value, precision = max_frac_digits ); // remove any excess trailing zeros let mut val: Cow = with_max_precision.trim_end_matches('0').into(); // adding back any required to meet minimum_fraction_digits if let Some(minfd) = num.options.minimum_fraction_digits { let pos = val.find('.').expect("expected . in formatted string"); let frac_num = val.len() - pos - 1; let zeros_needed = minfd - frac_num; if zeros_needed > 0 { val = format!("{}{}", val, "0".repeat(zeros_needed)).into(); } } // lop off any trailing '.' let result = val.trim_end_matches('.'); if let Some(sep) = alt_separator { Some(result.replace('.', sep)) } else { Some(result.to_string()) } } _ => None, } } #[derive(Serialize)] pub struct ResourcesForJavascript { langs: Vec, resources: Vec, } pub fn without_unicode_isolation(s: &str) -> String { s.replace(['\u{2068}', '\u{2069}'], "") } #[cfg(test)] mod test { use unic_langid::langid; use super::*; #[test] fn numbers() { assert!(!want_comma_as_decimal_separator(&[langid!("en-US")])); assert!(want_comma_as_decimal_separator(&[langid!("pl-PL")])); } #[test] fn decimal_rounding() { let tr = I18n::new(&["en"]); assert_eq!(tr.browsing_cards_deleted(1.001), "1 card deleted."); assert_eq!(tr.browsing_cards_deleted(1.01), "1.01 cards deleted."); } #[test] fn i18n() { // English template let tr = I18n::::new(&["zz"]); assert_eq!(tr.translate("valid-key", None), "a valid key"); assert_eq!(tr.translate("invalid-key", None), "invalid-key"); assert_eq!( tr.translate("two-args-key", Some(tr_args!["one"=>1.1, "two"=>"2"])), "two args: 1.1 and 2" ); assert_eq!( tr.translate("plural", Some(tr_args!["hats"=>1.0])), "You have 1 hat." ); assert_eq!( tr.translate("plural", Some(tr_args!["hats"=>1.1])), "You have 1.1 hats." ); assert_eq!( tr.translate("plural", Some(tr_args!["hats"=>3])), "You have 3 hats." ); // Another language let tr = I18n::::new(&["ja_JP"]); assert_eq!(tr.translate("valid-key", None), "キー"); assert_eq!(tr.translate("only-in-english", None), "not translated"); assert_eq!(tr.translate("invalid-key", None), "invalid-key"); assert_eq!( tr.translate("two-args-key", Some(tr_args!["one"=>1, "two"=>"2"])), "1と2" ); // Decimal separator let tr = I18n::::new(&["pl-PL"]); // Polish will use a comma if the string is translated assert_eq!( tr.translate("one-arg-key", Some(tr_args!["one"=>2.07])), "fake Polish 2,07" ); // but if it falls back on English, it will use an English separator assert_eq!( tr.translate("two-args-key", Some(tr_args!["one"=>1, "two"=>2.07])), "two args: 1 and 2.07" ); } } ================================================ FILE: rslib/i18n/typescript.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::fmt::Write; use std::path::PathBuf; use anki_io::create_dir_all; use anki_io::write_file_if_changed; use anyhow::Result; use inflections::Inflect; use itertools::Itertools; use crate::extract::Module; use crate::extract::Variable; use crate::extract::VariableKind; pub fn write_ts_interface(modules: &[Module]) -> Result<()> { let mut ts_out = header(); write_imports(&mut ts_out); render_module_map(modules, &mut ts_out); render_methods(modules, &mut ts_out); if let Some(path) = option_env!("STRINGS_TS") { let path = PathBuf::from(path); create_dir_all(path.parent().unwrap())?; write_file_if_changed(path, ts_out)?; } Ok(()) } fn render_module_map(modules: &[Module], ts_out: &mut String) { ts_out.push_str("export enum ModuleName {\n"); for module in modules { let name = &module.name; let upper = name.to_upper_case(); writeln!(ts_out, r#" {upper} = "{name}","#).unwrap(); } ts_out.push('}'); } fn render_methods(modules: &[Module], ts_out: &mut String) { for module in modules { for translation in &module.translations { let text = &translation.text; let key = &translation.key; let func_name = key.replace('-', "_").to_camel_case(); let arg_types = get_arg_types(&translation.variables); let args = get_args(&translation.variables); let maybe_args = if translation.variables.is_empty() { "".to_string() } else { arg_types }; writeln!( ts_out, r#" /** {text} */ export function {func_name}({maybe_args}) {{ return translate("{key}", {args}) }}"#, ) .unwrap(); } } } fn get_args(variables: &[Variable]) -> String { if variables.is_empty() { "".into() } else if variables .iter() .all(|v| v.name == typescript_arg_name(&v.name)) { // can use as-is "args".into() } else { let out = variables .iter() .map(|v| format!("\"{}\": args.{}", v.name, typescript_arg_name(&v.name))) .join(", "); format!("{{{out}}}") } } fn typescript_arg_name(name: &str) -> String { name.replace('-', "_").to_camel_case() } fn write_imports(buf: &mut String) { buf.push_str( " import { translate } from './ftl-helpers'; export { firstLanguage, setBundles } from './ftl-helpers'; export { FluentBundle, FluentResource } from '@fluent/bundle'; ", ); } fn header() -> String { "// Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html " .to_string() } fn get_arg_types(args: &[Variable]) -> String { let args = args .iter() .map(|arg| format!("{}: {}", arg.name.to_camel_case(), arg_kind(&arg.kind))) .join(", "); if args.is_empty() { "".into() } else { format!("args: {{{args}}}",) } } fn arg_kind(kind: &VariableKind) -> &str { match kind { VariableKind::Int | VariableKind::Float => "number", VariableKind::String => "string", VariableKind::Any => "number | string", } } ================================================ FILE: rslib/i18n/write_strings.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html //! Write strings to a strings.rs file that will be compiled into the binary. use std::fmt::Write; use std::fs; use std::path::PathBuf; use inflections::Inflect; use crate::extract::Module; use crate::extract::Translation; use crate::extract::VariableKind; use crate::gather::TranslationsByFile; use crate::gather::TranslationsByLang; pub fn write_strings(map: &TranslationsByLang, modules: &[Module], out_fn: &str, tag: &str) { let mut buf = String::new(); // lang->module map write_lang_map(map, &mut buf); // module name->translations write_translations_by_module(map, &mut buf); // ordered list of translations by module write_translation_key_index(modules, &mut buf); // methods to generate messages write_methods(modules, &mut buf, tag); let dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); let path = dir.join(out_fn); fs::write(path, buf).unwrap(); } fn write_methods(modules: &[Module], buf: &mut String, tag: &str) { buf.push_str( r#" #[allow(unused_imports)] use crate::{I18n,Number,Translations}; #[allow(unused_imports)] use fluent::{FluentValue, FluentArgs}; use std::borrow::Cow; "#, ); writeln!(buf, "impl I18n<{tag}> {{").unwrap(); for module in modules { for translation in &module.translations { let func = translation.key.to_snake_case(); let key = &translation.key; let doc = translation.text.replace('\n', " "); let in_args; let out_args; let var_build; if translation.variables.is_empty() { in_args = "".to_string(); out_args = ", None".to_string(); var_build = "".to_string(); } else { in_args = build_in_args(translation); var_build = build_vars(translation); out_args = ", Some(args)".to_string(); } writeln!( buf, r#" /// {doc} #[inline] pub fn {func}<'a>(&'a self{in_args}) -> Cow<'a, str> {{ {var_build} self.translate("{key}"{out_args}) }}"#, ) .unwrap(); } } buf.push_str("}\n"); } fn build_vars(translation: &Translation) -> String { if translation.variables.is_empty() { "let args = None;\n".into() } else { let mut buf = String::from( r#" let mut args = FluentArgs::new(); "#, ); for v in &translation.variables { let fluent_name = &v.name; let rust_name = v.name.to_snake_case(); let trailer = match v.kind { VariableKind::Any => "", VariableKind::Int | VariableKind::Float => ".round().into()", VariableKind::String => ".into()", }; writeln!( buf, r#" args.set("{fluent_name}", {rust_name}{trailer});"#, ) .unwrap(); } buf } } fn build_in_args(translation: &Translation) -> String { let v: Vec<_> = translation .variables .iter() .map(|var| { let kind = match var.kind { VariableKind::Int => "impl Number", VariableKind::Float => "impl Number", VariableKind::String => "impl Into", // VariableKind::Any => "&str", _ => "impl Into>", }; format!("{}: {}", var.name.to_snake_case(), kind) }) .collect(); format!(", {}", v.join(", ")) } fn write_translation_key_index(modules: &[Module], buf: &mut String) { for module in modules { writeln!( buf, "pub(crate) const {key}: [&str; {count}] = [", key = module_constant_name(&module.name), count = module.translations.len(), ) .unwrap(); for translation in &module.translations { writeln!(buf, r#" "{key}","#, key = translation.key).unwrap(); } buf.push_str("];\n") } writeln!( buf, "pub(crate) const _KEYS_BY_MODULE: [&[&str]; {count}] = [", count = modules.len(), ) .unwrap(); for module in modules { writeln!( buf, r#" &{module_slice},"#, module_slice = module_constant_name(&module.name) ) .unwrap(); } buf.push_str("];\n") } fn write_lang_map(map: &TranslationsByLang, buf: &mut String) { buf.push_str( " pub(crate) const _STRINGS: phf::Map<&str, &phf::Map<&str, &str>> = phf::phf_map! { ", ); for lang in map.keys() { writeln!( buf, r#" "{lang}" => &{constant},"#, lang = lang, constant = lang_constant_name(lang) ) .unwrap(); } buf.push_str("};\n"); } fn write_translations_by_module(map: &TranslationsByLang, buf: &mut String) { for (lang, modules) in map { write_module_map(buf, lang, modules); } } fn write_module_map(buf: &mut String, lang: &str, modules: &TranslationsByFile) { writeln!( buf, " pub(crate) const {lang_name}: phf::Map<&str, &str> = phf::phf_map! {{", lang_name = lang_constant_name(lang) ) .unwrap(); for (module, contents) in modules { let escaped_contents = escape_unicode_control_chars(contents); writeln!( buf, r###" "{module}" => r##"{escaped_contents}"##,"### ) .unwrap(); } buf.push_str("};\n"); } fn escape_unicode_control_chars(input: &str) -> String { use regex::Regex; static RE: std::sync::OnceLock = std::sync::OnceLock::new(); let re = RE.get_or_init(|| Regex::new(r"[\u{202a}-\u{202e}\u{2066}-\u{2069}]").unwrap()); re.replace_all(input, |caps: ®ex::Captures| { let c = caps.get(0).unwrap().as_str().chars().next().unwrap(); format!("\\u{{{:04x}}}", c as u32) }) .into_owned() } fn lang_constant_name(lang: &str) -> String { lang.to_ascii_uppercase().replace('-', "_") } fn module_constant_name(module: &str) -> String { format!("{}_KEYS", module.to_ascii_uppercase()) } ================================================ FILE: rslib/io/Cargo.toml ================================================ [package] name = "anki_io" version.workspace = true authors.workspace = true edition.workspace = true license.workspace = true publish = false rust-version.workspace = true description = "Utils for better I/O error reporting" [dependencies] camino.workspace = true snafu.workspace = true tempfile.workspace = true ================================================ FILE: rslib/io/src/error.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::path::PathBuf; use snafu::Snafu; /// Wrapper for [std::io::Error] with additional information on the attempted /// operation. #[derive(Debug, Snafu)] #[snafu(visibility(pub), display("{op:?} {path:?}"))] pub struct FileIoError { pub path: PathBuf, pub op: FileOp, pub source: std::io::Error, } impl PartialEq for FileIoError { fn eq(&self, other: &Self) -> bool { self.path == other.path && self.op == other.op } } impl Eq for FileIoError {} #[derive(Debug, PartialEq, Clone, Eq)] pub enum FileOp { Read, Open, Create, Write, Remove, CopyFrom(PathBuf), Persist, Sync, Metadata, DecodeUtf8Filename, SetFileTimes, /// For legacy errors without any context. Unknown, } impl FileOp { pub fn copy(from: impl Into) -> Self { Self::CopyFrom(from.into()) } } impl FileIoError { pub fn message(&self) -> String { format!( "Failed to {} '{}': {}", match &self.op { FileOp::Unknown => return format!("{}", self.source), FileOp::Open => "open".into(), FileOp::Read => "read".into(), FileOp::Create => "create file in".into(), FileOp::Write => "write".into(), FileOp::Remove => "remove".into(), FileOp::CopyFrom(p) => format!("copy from '{}' to", p.to_string_lossy()), FileOp::Persist => "persist".into(), FileOp::Sync => "sync".into(), FileOp::Metadata => "get metadata".into(), FileOp::DecodeUtf8Filename => "decode utf8 filename".into(), FileOp::SetFileTimes => "set file times".into(), }, self.path.to_string_lossy(), self.source ) } pub fn is_not_found(&self) -> bool { self.source.kind() == std::io::ErrorKind::NotFound } } impl From for FileIoError { fn from(err: tempfile::PathPersistError) -> Self { FileIoError { path: err.path.to_path_buf(), op: FileOp::Persist, source: err.error, } } } impl From for FileIoError { fn from(err: tempfile::PersistError) -> Self { FileIoError { path: err.file.path().into(), op: FileOp::Persist, source: err.error, } } } ================================================ FILE: rslib/io/src/lib.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod error; use std::fs::File; use std::fs::FileTimes; use std::fs::OpenOptions; use std::io::Read; use std::io::Seek; use std::io::Write; use std::path::Component; use std::path::Path; use std::path::PathBuf; use camino::Utf8Path; use camino::Utf8PathBuf; use snafu::ResultExt; use tempfile::NamedTempFile; pub use crate::error::FileIoError; pub use crate::error::FileIoSnafu; pub use crate::error::FileOp; pub type Result = std::result::Result; /// See [File::create]. pub fn create_file(path: impl AsRef) -> Result { File::create(&path).context(FileIoSnafu { path: path.as_ref(), op: FileOp::Create, }) } /// See [File::open]. pub fn open_file(path: impl AsRef) -> Result { File::open(&path).context(FileIoSnafu { path: path.as_ref(), op: FileOp::Open, }) } pub fn open_file_ext(path: impl AsRef, options: OpenOptions) -> Result { options.open(&path).context(FileIoSnafu { path: path.as_ref(), op: FileOp::Open, }) } /// See [std::fs::write]. pub fn write_file(path: impl AsRef, contents: impl AsRef<[u8]>) -> Result<()> { std::fs::write(&path, contents).context(FileIoSnafu { path: path.as_ref(), op: FileOp::Write, }) } pub fn write_file_and_flush( path: impl AsRef + Clone, contents: impl AsRef<[u8]>, ) -> Result<()> { let mut file = create_file(path.clone())?; file.write_all(contents.as_ref()).context(FileIoSnafu { path: path.clone().as_ref(), op: FileOp::Write, })?; file.sync_all().context(FileIoSnafu { path: path.as_ref(), op: FileOp::Sync, }) } /// See [File::set_times]. pub fn set_file_times(path: impl AsRef, times: FileTimes) -> Result<()> { #[cfg(not(windows))] let file = open_file(&path)?; #[cfg(windows)] let file = { use std::os::windows::fs::OpenOptionsExt; open_file_ext( &path, OpenOptions::new() .write(true) // It's required to modify the time attributes of a directory in windows system. .custom_flags(0x02000000) // FILE_FLAG_BACKUP_SEMANTICS .to_owned(), )? }; file.set_times(times).context(FileIoSnafu { path: path.as_ref(), op: FileOp::SetFileTimes, }) } /// See [std::fs::remove_file]. #[allow(dead_code)] pub fn remove_file(path: impl AsRef) -> Result<()> { std::fs::remove_file(&path).context(FileIoSnafu { path: path.as_ref(), op: FileOp::Remove, }) } /// See [std::fs::remove_dir_all]. #[allow(dead_code)] pub fn remove_dir_all(path: impl AsRef) -> Result<()> { std::fs::remove_dir_all(&path).context(FileIoSnafu { path: path.as_ref(), op: FileOp::Remove, }) } /// See [std::fs::create_dir]. pub fn create_dir(path: impl AsRef) -> Result<()> { std::fs::create_dir(&path).context(FileIoSnafu { path: path.as_ref(), op: FileOp::Create, }) } /// See [std::fs::create_dir_all]. pub fn create_dir_all(path: impl AsRef) -> Result<()> { std::fs::create_dir_all(&path).context(FileIoSnafu { path: path.as_ref(), op: FileOp::Create, }) } /// See [std::fs::read]. pub fn read_file(path: impl AsRef) -> Result> { std::fs::read(&path).context(FileIoSnafu { path: path.as_ref(), op: FileOp::Read, }) } /// See [std::fs::read_to_string]. pub fn read_to_string(path: impl AsRef) -> Result { std::fs::read_to_string(&path).context(FileIoSnafu { path: path.as_ref(), op: FileOp::Read, }) } /// See [std::fs::copy]. pub fn copy_file(src: impl AsRef, dst: impl AsRef) -> Result { std::fs::copy(&src, &dst).context(FileIoSnafu { path: dst.as_ref(), op: FileOp::CopyFrom(src.as_ref().to_owned()), }) } /// Copy a file from src to dst if dst doesn't exist or if src is newer than /// dst. Preserves the modification time from the source file. pub fn copy_if_newer(src: impl AsRef, dst: impl AsRef) -> Result { let src = src.as_ref(); let dst = dst.as_ref(); let should_copy = if !dst.exists() { true } else { let src_time = modified_time(src)?; let dst_time = modified_time(dst)?; src_time > dst_time }; if should_copy { copy_file(src, dst)?; // Preserve the modification time from the source file let src_mtime = modified_time(src)?; let times = FileTimes::new().set_modified(src_mtime); set_file_times(dst, times)?; Ok(true) } else { Ok(false) } } /// Like [read_file], but skips the section that is potentially locked by /// SQLite. pub fn read_locked_db_file(path: impl AsRef) -> Result> { read_locked_db_file_inner(&path).context(FileIoSnafu { path: path.as_ref(), op: FileOp::Read, }) } const LOCKED_SECTION_START_BYTE: usize = 1024 * 1024 * 1024; const LOCKED_SECTION_LEN_BYTES: usize = 512; const LOCKED_SECTION_END_BYTE: usize = LOCKED_SECTION_START_BYTE + LOCKED_SECTION_LEN_BYTES; fn read_locked_db_file_inner(path: impl AsRef) -> std::io::Result> { let size = std::fs::metadata(&path)?.len() as usize; if size < LOCKED_SECTION_END_BYTE { return std::fs::read(path); } let mut file = File::open(&path)?; let mut buf = vec![0; size]; file.read_exact(&mut buf[..LOCKED_SECTION_START_BYTE])?; file.seek(std::io::SeekFrom::Current(LOCKED_SECTION_LEN_BYTES as i64))?; file.read_exact(&mut buf[LOCKED_SECTION_END_BYTE..])?; Ok(buf) } /// See [std::fs::metadata]. pub fn metadata(path: impl AsRef) -> Result { std::fs::metadata(&path).context(FileIoSnafu { path: path.as_ref(), op: FileOp::Metadata, }) } /// Get the modification time of a file. pub fn modified_time(path: impl AsRef) -> Result { metadata(&path)?.modified().context(FileIoSnafu { path: path.as_ref(), op: FileOp::Metadata, }) } pub fn new_tempfile() -> Result { NamedTempFile::new().context(FileIoSnafu { path: std::env::temp_dir(), op: FileOp::Create, }) } pub fn new_tempfile_in(dir: impl AsRef) -> Result { NamedTempFile::new_in(&dir).context(FileIoSnafu { path: dir.as_ref(), op: FileOp::Create, }) } pub fn new_tempfile_in_parent_of(file: &Path) -> Result { let dir = file.parent().unwrap_or(file); NamedTempFile::new_in(dir).context(FileIoSnafu { path: dir, op: FileOp::Create, }) } /// Atomically replace the target path with the provided temp file. /// /// If `fsync` is true, file data is synced to disk prior to renaming, and the /// folder is synced on UNIX platforms after renaming. This minimizes the /// chances of corruption if there is a crash or power loss directly after the /// op, but it can be considerably slower. pub fn atomic_rename(file: NamedTempFile, target: &Path, fsync: bool) -> Result<()> { if fsync { file.as_file().sync_all().context(FileIoSnafu { path: file.path(), op: FileOp::Sync, })?; } file.persist(target)?; #[cfg(not(windows))] if fsync { if let Some(parent) = target.parent() { open_file(parent)?.sync_all().context(FileIoSnafu { path: parent, op: FileOp::Sync, })?; } } Ok(()) } /// Like [std::fs::read_dir], but only yielding files. [Err]s are not filtered. pub fn read_dir_files(path: impl AsRef) -> Result { std::fs::read_dir(&path) .map(ReadDirFiles) .context(FileIoSnafu { path: path.as_ref(), op: FileOp::Read, }) } /// A shortcut for gathering the utf8 paths in a folder into a vec. Will /// abort if any dir entry is unreadable. Does not gather files from subfolders. pub fn paths_in_dir(path: impl AsRef) -> Result> { read_dir_files(path.as_ref())? .map(|entry| { let entry = entry.context(FileIoSnafu { path: path.as_ref(), op: FileOp::Read, })?; entry.path().utf8() }) .collect() } /// True if name does not contain any path separators. pub fn filename_is_safe(name: &str) -> bool { let mut components = Path::new(name).components(); let first_element_normal = components .next() .map(|component| matches!(component, Component::Normal(_))) .unwrap_or_default(); first_element_normal && components.next().is_none() } pub struct ReadDirFiles(std::fs::ReadDir); impl Iterator for ReadDirFiles { type Item = std::io::Result; fn next(&mut self) -> Option { let next = self.0.next(); if let Some(Ok(entry)) = next.as_ref() { match entry.metadata().map(|metadata| metadata.is_file()) { Ok(true) => next, Ok(false) => self.next(), Err(error) => Some(Err(error)), } } else { next } } } /// True if changed. pub fn write_file_if_changed(path: impl AsRef, contents: impl AsRef<[u8]>) -> Result { let path = path.as_ref(); let contents = contents.as_ref(); let changed = { read_file(path) .map(|existing| existing != contents) .unwrap_or(true) }; match std::env::var("CARGO_PKG_NAME") { Ok(pkg) if pkg == "anki_proto" || pkg == "anki_i18n" => { // at comptime for the proto/i18n crates, register implicit output as input println!("cargo:rerun-if-changed={}", path.to_str().unwrap()); } _ => {} } if changed { write_file(path, contents)?; Ok(true) } else { Ok(false) } } pub trait ToUtf8PathBuf { fn utf8(self) -> Result; } impl ToUtf8PathBuf for PathBuf { fn utf8(self) -> Result { Utf8PathBuf::from_path_buf(self).map_err(|path| FileIoError { path, op: FileOp::DecodeUtf8Filename, source: std::io::Error::from(std::io::ErrorKind::InvalidData), }) } } pub trait ToUtf8Path { fn utf8(&self) -> Result<&Utf8Path>; } impl ToUtf8Path for Path { fn utf8(&self) -> Result<&Utf8Path> { Utf8Path::from_path(self).ok_or_else(|| FileIoError { path: self.into(), op: FileOp::DecodeUtf8Filename, source: std::io::Error::from(std::io::ErrorKind::InvalidData), }) } } #[cfg(test)] mod test { use super::*; #[test] fn path_traversal() { assert!(filename_is_safe("foo")); assert!(!filename_is_safe("..")); assert!(!filename_is_safe("foo/bar")); assert!(!filename_is_safe("/foo")); assert!(!filename_is_safe("../foo")); if cfg!(windows) { assert!(!filename_is_safe("foo\\bar")); assert!(!filename_is_safe("c:\\foo")); assert!(!filename_is_safe("\\foo")); } } } ================================================ FILE: rslib/linkchecker/Cargo.toml ================================================ [package] name = "linkchecker" version.workspace = true authors.workspace = true edition.workspace = true license.workspace = true publish = false rust-version.workspace = true [dependencies] anki.workspace = true futures.workspace = true itertools.workspace = true linkcheck.workspace = true regex.workspace = true reqwest.workspace = true strum.workspace = true tokio.workspace = true [features] rustls = ["reqwest/rustls-tls", "reqwest/rustls-tls-native-roots"] native-tls = ["reqwest/native-tls"] ================================================ FILE: rslib/linkchecker/src/lib.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html ================================================ FILE: rslib/linkchecker/tests/links.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html #![cfg(test)] use std::borrow::Cow; use std::env; use std::iter; use std::sync::LazyLock; use std::time::Duration; use anki::links::help_page_link_suffix; use anki::links::help_page_to_link; use anki::links::HelpPage; use futures::StreamExt; use itertools::Itertools; use linkcheck::validation::check_web; use linkcheck::validation::Context; use linkcheck::validation::Reason; use linkcheck::BasicContext; use regex::Regex; use reqwest::Url; use strum::IntoEnumIterator; const WEB_TIMEOUT: Duration = Duration::from_secs(60); /// Aggregates [`Outcome`]s by collecting the error messages of the invalid /// ones. #[derive(Default)] struct Outcomes(Vec); #[derive(Debug)] enum Outcome { Valid, Invalid(String), } #[derive(Clone)] enum CheckableUrl { HelpPage(HelpPage), String(&'static str), } impl CheckableUrl { fn url(&self) -> Cow<'_, str> { match *self { Self::HelpPage(page) => help_page_to_link(page).into(), Self::String(s) => s.into(), } } fn anchor(&self) -> Cow<'_, str> { match *self { Self::HelpPage(page) => help_page_link_suffix(page).into(), Self::String(s) => s.split('#').next_back().unwrap_or_default().into(), } } } impl From for CheckableUrl { fn from(value: HelpPage) -> Self { Self::HelpPage(value) } } impl From<&'static str> for CheckableUrl { fn from(value: &'static str) -> Self { Self::String(value) } } fn ts_help_pages() -> impl Iterator { static QUOTED_URL: LazyLock = LazyLock::new(|| Regex::new("\"(http.+)\"").unwrap()); QUOTED_URL .captures_iter(include_str!("../../../ts/lib/tslib/help-page.ts")) .map(|caps| caps.get(1).unwrap().as_str()) } #[tokio::test] async fn check_links() { if env::var("ONLINE_TESTS").is_err() { println!("test disabled; ONLINE_TESTS not set"); return; } let ctx = BasicContext::default(); let result = futures::stream::iter( HelpPage::iter() .map(CheckableUrl::from) .chain(ts_help_pages().map(CheckableUrl::from)), ) .map(|page| check_url(page, &ctx)) .buffer_unordered(ctx.concurrency()) .collect::() .await; if !result.0.is_empty() { panic!("{}", result.message()); } } async fn check_url(page: CheckableUrl, ctx: &BasicContext) -> Outcome { let link = page.url(); match Url::parse(&link) { Ok(url) if url.as_str() == link => { let future = check_web(&url, ctx); let timeout = tokio::time::timeout(WEB_TIMEOUT, future); match timeout.await { Err(_) => Outcome::Invalid(format!("Timed out: {link}")), Ok(Ok(())) => Outcome::Valid, Ok(Err(Reason::Dom)) => Outcome::Invalid(format!( "'#{}' not found on '{}{}'", url.fragment().unwrap(), url.domain().unwrap(), url.path(), )), Ok(Err(Reason::Web(err))) => Outcome::Invalid(err.to_string()), _ => unreachable!(), } } Ok(_) => Outcome::Invalid(format!("'{}' is not a valid URL part", page.anchor(),)), Err(err) => Outcome::Invalid(err.to_string()), } } impl Extend for Outcomes { fn extend>(&mut self, items: T) { for outcome in items { match outcome { Outcome::Valid => (), Outcome::Invalid(err) => self.0.push(err), } } } } impl Outcomes { fn message(&self) -> String { iter::once("invalid links found:") .chain(self.0.iter().map(String::as_str)) .join("\n - ") } } ================================================ FILE: rslib/process/Cargo.toml ================================================ [package] name = "anki_process" version.workspace = true authors.workspace = true edition.workspace = true license.workspace = true publish = false rust-version.workspace = true description = "Utils for better process error reporting" [dependencies] itertools.workspace = true snafu.workspace = true ================================================ FILE: rslib/process/src/lib.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::ffi::OsStr; use std::iter::once; use std::process::Command; use std::string::FromUtf8Error; use itertools::Itertools; use snafu::ensure; use snafu::ResultExt; use snafu::Snafu; #[derive(Debug)] pub struct CodeDisplay(Option); impl std::fmt::Display for CodeDisplay { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self.0 { Some(code) => write!(f, "{code}"), None => write!(f, "?"), } } } impl From> for CodeDisplay { fn from(code: Option) -> Self { CodeDisplay(code) } } #[derive(Debug, Snafu)] pub enum Error { #[snafu(display("Failed to execute: {cmdline}"))] DidNotExecute { cmdline: String, source: std::io::Error, }, #[snafu(display("Failed to run ({code}): {cmdline}"))] ReturnedError { cmdline: String, code: CodeDisplay }, #[snafu(display("Failed to run ({code}): {cmdline}: {stdout}{stderr}"))] ReturnedWithOutputError { cmdline: String, code: CodeDisplay, stdout: String, stderr: String, }, #[snafu(display("Couldn't decode stdout/stderr as utf8"))] InvalidUtf8 { cmdline: String, source: FromUtf8Error, }, } pub type Result = std::result::Result; pub struct Utf8Output { pub stdout: String, pub stderr: String, } pub trait CommandExt { /// A shortcut for when the command and its args are known up-front and have /// no spaces in them. fn run(cmd_and_args: impl AsRef) -> Result<()> { let mut all_args = cmd_and_args.as_ref().split(' '); Command::new(all_args.next().unwrap()) .args(all_args) .ensure_success()?; Ok(()) } fn run_with_output(cmd_and_args: I) -> Result where I: IntoIterator, S: AsRef, { let mut all_args = cmd_and_args.into_iter(); Command::new(all_args.next().unwrap()) .args(all_args) .utf8_output() } fn ensure_success(&mut self) -> Result<&mut Self>; fn utf8_output(&mut self) -> Result; fn ensure_spawn(&mut self) -> Result; #[cfg(unix)] fn ensure_exec(&mut self) -> Result<()>; } impl CommandExt for Command { fn ensure_success(&mut self) -> Result<&mut Self> { let status = self.status().with_context(|_| DidNotExecuteSnafu { cmdline: get_cmdline(self), })?; ensure!( status.success(), ReturnedSnafu { cmdline: get_cmdline(self), code: CodeDisplay::from(status.code()), } ); Ok(self) } fn utf8_output(&mut self) -> Result { let cmdline = get_cmdline(self); let output = self.output().with_context(|_| DidNotExecuteSnafu { cmdline: cmdline.clone(), })?; let stdout = String::from_utf8(output.stdout).with_context(|_| InvalidUtf8Snafu { cmdline: cmdline.clone(), })?; let stderr = String::from_utf8(output.stderr).with_context(|_| InvalidUtf8Snafu { cmdline: cmdline.clone(), })?; ensure!( output.status.success(), ReturnedWithOutputSnafu { cmdline, code: CodeDisplay::from(output.status.code()), stdout: stdout.clone(), stderr: stderr.clone(), } ); Ok(Utf8Output { stdout, stderr }) } fn ensure_spawn(&mut self) -> Result { self.spawn().with_context(|_| DidNotExecuteSnafu { cmdline: get_cmdline(self), }) } #[cfg(unix)] fn ensure_exec(&mut self) -> Result<()> { use std::os::unix::process::CommandExt as UnixCommandExt; let cmdline = get_cmdline(self); let error = self.exec(); Err(Error::DidNotExecute { cmdline, source: error, }) } } fn get_cmdline(arg: &mut Command) -> String { once(arg.get_program().to_string_lossy()) .chain(arg.get_args().map(|arg| arg.to_string_lossy())) .join(" ") } #[cfg(test)] mod test { use super::*; #[test] fn test_run() { assert_eq!( Command::run("fakefake 1 2").unwrap_err().to_string(), "Failed to execute: fakefake 1 2" ); #[cfg(not(windows))] assert!(matches!( Command::new("false").ensure_success(), Err(Error::ReturnedError { code: CodeDisplay(_), .. }) )); } } ================================================ FILE: rslib/proto/Cargo.toml ================================================ [package] name = "anki_proto" version.workspace = true authors.workspace = true edition.workspace = true license.workspace = true publish = false rust-version.workspace = true description = "Anki's Rust library protobuf code" [build-dependencies] anki_io.workspace = true anki_proto_gen.workspace = true anyhow.workspace = true inflections.workspace = true itertools.workspace = true prost-build.workspace = true prost-reflect.workspace = true prost-types.workspace = true [dependencies] prost.workspace = true serde.workspace = true snafu.workspace = true strum.workspace = true ================================================ FILE: rslib/proto/build.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub mod python; pub mod rust; pub mod typescript; use anki_proto_gen::descriptors_path; use anki_proto_gen::get_services; use anyhow::Result; fn main() -> Result<()> { let descriptors_path = descriptors_path(); let pool = rust::write_rust_protos(descriptors_path)?; let (_, services) = get_services(&pool); python::write_python_interface(&services)?; typescript::write_ts_interface(&services)?; Ok(()) } ================================================ FILE: rslib/proto/python.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::io::Cursor; use std::io::Write; use std::path::Path; use anki_io::create_dir_all; use anki_io::write_file_if_changed; use anki_proto_gen::BackendService; use anki_proto_gen::Method; use anyhow::Result; use inflections::Inflect; use prost_reflect::FieldDescriptor; use prost_reflect::Kind; use prost_reflect::MessageDescriptor; pub(crate) fn write_python_interface(services: &[BackendService]) -> Result<()> { let output_path = Path::new("../../out/pylib/anki/_backend_generated.py"); create_dir_all(output_path.parent().unwrap())?; let mut out = Cursor::new(Vec::new()); write_header(&mut out)?; for service in services { if ["BackendAnkidroidService", "BackendFrontendService"].contains(&service.name.as_str()) { continue; } for method in service.all_methods() { render_method(service, method, &mut out); } } write_file_if_changed(output_path, out.into_inner())?; Ok(()) } /// Generates text like the following: /// /// def get_field_names_raw(self, message: bytes) -> bytes: /// return self._run_command(7, 16, message) /// /// def get_field_names(self, ntid: int) -> Sequence[str]: /// message = anki.notetypes_pb2.NotetypeId(ntid=ntid) /// raw_bytes = self._run_command(7, 16, message.SerializeToString()) /// output = anki.generic_pb2.StringList() /// output.ParseFromString(raw_bytes) /// return output.vals fn render_method(service: &BackendService, method: &Method, out: &mut impl Write) { let method_name = method.name.to_snake_case(); let input = method.proto.input(); let output = method.proto.output(); let service_idx = service.index; let method_idx = method.index; let comments = format_comments(&method.comments); // raw bytes write!( out, r#" def {method_name}_raw(self, message: bytes) -> bytes: {comments}return self._run_command({service_idx}, {method_idx}, message) "# ) .unwrap(); // (possibly destructured) message let (input_params, input_assign) = maybe_destructured_input(&input); let output_constructor = full_name_to_python(output.full_name()); let (output_msg_or_single_field, output_type) = maybe_destructured_output(&output); write!( out, r#" def {method_name}({input_params}) -> {output_type}: {comments}{input_assign} raw_bytes = self._run_command({service_idx}, {method_idx}, message.SerializeToString()) output = {output_constructor}() output.ParseFromString(raw_bytes) return {output_msg_or_single_field} "# ) .unwrap(); } fn format_comments(comments: &Option) -> String { comments .as_ref() .map(|c| { format!( r#""""{c}""" "# ) }) .unwrap_or_default() } /// If any of the following apply to the input type: /// - it has a single field /// - its name ends in Request /// - it has any optional fields /// /// ...then destructuring will be skipped, and the method will take the input /// message directly. Returns (params_line, assignment_lines) fn maybe_destructured_input(input: &MessageDescriptor) -> (String, String) { if (input.name().ends_with("Request") || input.fields().len() < 2) && input.oneofs().next().is_none() { // destructure let method_args = build_method_arguments(input); let input_type = full_name_to_python(input.full_name()); let input_message_args = build_input_message_arguments(input); let assignment = format!("message = {input_type}({input_message_args})",); (method_args, assignment) } else { // no destructure let params = format!("self, message: {}", full_name_to_python(input.full_name())); let assignment = String::new(); (params, assignment) } } /// e.g. "self, *, note_ids: Sequence[int], new_fields: Sequence[int]" fn build_method_arguments(input: &MessageDescriptor) -> String { let fields = input.fields(); let mut args = vec!["self".to_string()]; if fields.len() >= 2 { args.push("*".to_string()); } for field in fields { let arg = format!("{}: {}", field.name(), python_type(&field, false)); args.push(arg); } args.join(", ") } /// e.g. "note_ids=note_ids, new_fields=new_fields" fn build_input_message_arguments(input: &MessageDescriptor) -> String { input .fields() .map(|field| { let name = field.name(); format!("{name}={name}") }) .collect::>() .join(", ") } // If output type has a single field and is not an enum, we return its single // field value directly. Returns (expr, type), where expr is 'output' or // 'output.'. fn maybe_destructured_output(output: &MessageDescriptor) -> (String, String) { let first_field = output.fields().next(); if output.fields().len() == 1 && !matches!(first_field.as_ref().unwrap().kind(), Kind::Enum(_)) { let field = first_field.unwrap(); ( format!("output.{}", field.name()), python_type(&field, true), ) } else { ("output".into(), full_name_to_python(output.full_name())) } } /// e.g. uint32 -> int; repeated bool -> Sequence[bool] fn python_type(field: &FieldDescriptor, output: bool) -> String { let kind = match field.kind() { Kind::Int32 | Kind::Int64 | Kind::Uint32 | Kind::Uint64 | Kind::Sint32 | Kind::Sint64 | Kind::Fixed32 | Kind::Fixed64 | Kind::Sfixed32 | Kind::Sfixed64 => "int".into(), Kind::Float | Kind::Double => "float".into(), Kind::Bool => "bool".into(), Kind::String => "str".into(), Kind::Bytes => "bytes".into(), Kind::Message(msg) => full_name_to_python(msg.full_name()), Kind::Enum(en) => format!("{}.V", full_name_to_python(en.full_name())), }; if field.is_list() { if output { format!("Sequence[{kind}]") } else { format!("Iterable[{kind}]") } } else if field.is_map() { let map_kind = field.kind(); let map_kind = map_kind.as_message().unwrap(); let map_kv: Vec<_> = map_kind.fields().map(|f| python_type(&f, output)).collect(); format!("Mapping[{}, {}]", map_kv[0], map_kv[1]) } else { kind } } // e.g. anki.import_export.ImportResponse -> // anki.import_export_pb2.ImportResponse fn full_name_to_python(name: &str) -> String { let mut name = name.splitn(3, '.'); format!( "{}.{}_pb2.{}", name.next().unwrap(), name.next().unwrap(), name.next().unwrap() ) } fn write_header(out: &mut impl Write) -> Result<()> { out.write_all( br#"# Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; https://www.gnu.org/licenses/agpl.html from __future__ import annotations """ THIS FILE IS AUTOMATICALLY GENERATED - DO NOT EDIT. Please do not access methods on the backend directly - they may be changed or removed at any time. Instead, please use the methods on the collection instead. Eg, don't use col.backend.all_deck_config(), instead use col.decks.all_config() """ from typing import * import anki import anki.ankiweb_pb2 import anki.backend_pb2 import anki.card_rendering_pb2 import anki.cards_pb2 import anki.collection_pb2 import anki.config_pb2 import anki.deck_config_pb2 import anki.decks_pb2 import anki.i18n_pb2 import anki.image_occlusion_pb2 import anki.import_export_pb2 import anki.links_pb2 import anki.media_pb2 import anki.notes_pb2 import anki.notetypes_pb2 import anki.scheduler_pb2 import anki.search_pb2 import anki.stats_pb2 import anki.sync_pb2 import anki.tags_pb2 import anki.ankihub_pb2 class RustBackendGenerated: def _run_command(self, service: int, method: int, input: Any) -> bytes: raise Exception("not implemented") "#, )?; Ok(()) } ================================================ FILE: rslib/proto/rust.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::env; use std::path::Path; use std::path::PathBuf; use anki_io::create_dir_all; use anki_io::read_file; use anki_io::write_file_if_changed; use anki_proto_gen::add_must_use_annotations; use anki_proto_gen::determine_if_message_is_empty; use anyhow::Context; use anyhow::Result; use prost_reflect::DescriptorPool; pub fn write_rust_protos(descriptors_path: PathBuf) -> Result { set_protoc_path(); let proto_dir = PathBuf::from("../../proto"); let paths = gather_proto_paths(&proto_dir)?; let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); let tmp_descriptors = out_dir.join("descriptors.tmp"); prost_build::Config::new() .out_dir(&out_dir) .file_descriptor_set_path(&tmp_descriptors) .type_attribute( "Deck.Filtered.SearchTerm.Order", "#[derive(strum::EnumIter)]", ) .type_attribute( "Deck.Normal.DayLimit", "#[derive(Eq, serde::Deserialize, serde::Serialize)]", ) .type_attribute("HelpPageLinkRequest.HelpPage", "#[derive(strum::EnumIter)]") .type_attribute("CsvMetadata.Delimiter", "#[derive(strum::EnumIter)]") .type_attribute( "Preferences.BackupLimits", "#[derive(serde::Deserialize, serde::Serialize)]", ) .type_attribute( "CsvMetadata.DupeResolution", "#[derive(serde::Deserialize, serde::Serialize)]", ) .type_attribute( "CsvMetadata.MatchScope", "#[derive(serde::Deserialize, serde::Serialize)]", ) .type_attribute( "ImportAnkiPackageUpdateCondition", "#[derive(serde::Deserialize, serde::Serialize)]", ) .compile_protos(paths.as_slice(), &[proto_dir]) .context("prost build")?; let descriptors = read_file(&tmp_descriptors)?; create_dir_all( descriptors_path .parent() .context("missing parent of descriptor")?, )?; write_file_if_changed(descriptors_path, &descriptors)?; let pool = DescriptorPool::decode(descriptors.as_ref())?; add_must_use_annotations( &out_dir, |path| path.file_name().unwrap().starts_with("anki."), |path, name| determine_if_message_is_empty(&pool, path, name), )?; Ok(pool) } fn gather_proto_paths(proto_dir: &Path) -> Result> { let subfolders = &["anki"]; let mut paths = vec![]; for subfolder in subfolders { for entry in proto_dir.join(subfolder).read_dir().unwrap() { let entry = entry.unwrap(); let path = entry.path(); if path .file_name() .unwrap() .to_str() .unwrap() .ends_with(".proto") { println!("cargo:rerun-if-changed={}", path.to_str().unwrap()); paths.push(path); } } } paths.sort(); Ok(paths) } /// Set PROTOC to the custom path provided by PROTOC_BINARY, or add .exe to /// the standard path if on Windows. fn set_protoc_path() { if let Ok(custom_protoc) = env::var("PROTOC_BINARY") { env::set_var("PROTOC", custom_protoc); } else if let Ok(bundled_protoc) = env::var("PROTOC") { if cfg!(windows) && !bundled_protoc.ends_with(".exe") { env::set_var("PROTOC", format!("{bundled_protoc}.exe")); } } } ================================================ FILE: rslib/proto/src/generic_helpers.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html impl From> for crate::generic::Json { fn from(json: Vec) -> Self { crate::generic::Json { json } } } impl From for crate::generic::String { fn from(val: String) -> Self { crate::generic::String { val } } } impl From> for crate::generic::StringList { fn from(vals: Vec) -> Self { crate::generic::StringList { vals } } } impl From for crate::generic::Bool { fn from(val: bool) -> Self { crate::generic::Bool { val } } } impl From for crate::generic::Int32 { fn from(val: i32) -> Self { crate::generic::Int32 { val } } } impl From for crate::generic::Int64 { fn from(val: i64) -> Self { crate::generic::Int64 { val } } } impl From for crate::generic::UInt32 { fn from(val: u32) -> Self { crate::generic::UInt32 { val } } } impl From for crate::generic::UInt32 { fn from(val: usize) -> Self { crate::generic::UInt32 { val: val as u32 } } } ================================================ FILE: rslib/proto/src/lib.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // DeckConfig inside deck_config.proto #![allow(clippy::module_inception)] mod generic_helpers; macro_rules! protobuf { ($ident:ident, $name:literal) => { pub mod $ident { include!(concat!(env!("OUT_DIR"), "/anki.", $name, ".rs")); } }; } protobuf!(ankidroid, "ankidroid"); protobuf!(ankiweb, "ankiweb"); protobuf!(backend, "backend"); protobuf!(card_rendering, "card_rendering"); protobuf!(cards, "cards"); protobuf!(collection, "collection"); protobuf!(config, "config"); protobuf!(deck_config, "deck_config"); protobuf!(decks, "decks"); protobuf!(generic, "generic"); protobuf!(i18n, "i18n"); protobuf!(image_occlusion, "image_occlusion"); protobuf!(import_export, "import_export"); protobuf!(links, "links"); protobuf!(media, "media"); protobuf!(notes, "notes"); protobuf!(notetypes, "notetypes"); protobuf!(scheduler, "scheduler"); protobuf!(search, "search"); protobuf!(stats, "stats"); protobuf!(sync, "sync"); protobuf!(tags, "tags"); protobuf!(ankihub, "ankihub"); ================================================ FILE: rslib/proto/typescript.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashSet; use std::fmt::Write as WriteFmt; use std::path::Path; use anki_io::create_dir_all; use anki_io::write_file_if_changed; use anki_proto_gen::BackendService; use anki_proto_gen::Method; use anyhow::Result; use inflections::Inflect; use itertools::Itertools; pub(crate) fn write_ts_interface(services: &[BackendService]) -> Result<()> { let root = Path::new("../../out/ts/lib/generated"); create_dir_all(root)?; let mut ts_out = String::new(); let mut referenced_packages = HashSet::new(); for service in services { if service.name == "BackendAnkidroidService" { continue; } for method in service.all_methods() { let method = MethodDetails::from_method(method); record_referenced_type(&mut referenced_packages, &method.input_type); record_referenced_type(&mut referenced_packages, &method.output_type); write_ts_method(&method, &mut ts_out); } } let imports = imports(referenced_packages); write_file_if_changed( root.join("backend.ts"), format!("{}{}{}", ts_header(), imports, ts_out), )?; Ok(()) } fn ts_header() -> String { r#"// Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; https://www.gnu.org/licenses/agpl.html import type { PlainMessage } from "@bufbuild/protobuf"; import type { PostProtoOptions } from "./post"; import { postProto } from "./post"; "# .into() } fn imports(referenced_packages: HashSet) -> String { let mut out = String::new(); for package in referenced_packages.iter().sorted() { writeln!( &mut out, "import * as {} from \"./anki/{}_pb\";", package, package.to_snake_case() ) .unwrap(); } out } fn write_ts_method( MethodDetails { method_name, input_type, output_type, comments, }: &MethodDetails, out: &mut String, ) { let comments = format_comments(comments); writeln!( out, r#"{comments}export async function {method_name}(input: PlainMessage<{input_type}>, options?: PostProtoOptions): Promise<{output_type}> {{ return await postProto("{method_name}", new {input_type}(input), {output_type}, options); }}"# ).unwrap() } fn format_comments(comments: &Option) -> String { comments .as_ref() .map(|s| format!("/** {s} */\n")) .unwrap_or_default() } struct MethodDetails { method_name: String, input_type: String, output_type: String, comments: Option, } impl MethodDetails { fn from_method(method: &Method) -> MethodDetails { let name = method.name.to_camel_case(); let input_type = full_name_to_imported_reference(method.proto.input().full_name()); let output_type = full_name_to_imported_reference(method.proto.output().full_name()); let comments = method.comments.clone(); Self { method_name: name, input_type, output_type, comments, } } } fn record_referenced_type(referenced_packages: &mut HashSet, type_name: &str) { referenced_packages.insert(type_name.split('.').next().unwrap().to_string()); } // e.g. anki.import_export.ImportResponse -> // importExport.ImportResponse fn full_name_to_imported_reference(name: &str) -> String { let mut name = name.splitn(3, '.'); name.next().unwrap(); format!( "{}.{}", name.next().unwrap().to_camel_case(), name.next().unwrap() ) } ================================================ FILE: rslib/proto_gen/Cargo.toml ================================================ [package] name = "anki_proto_gen" publish = false description = "Helpers for interface code generation" version.workspace = true authors.workspace = true edition.workspace = true license.workspace = true rust-version.workspace = true [dependencies] anki_io.workspace = true anyhow.workspace = true camino.workspace = true inflections.workspace = true itertools.workspace = true prost-reflect.workspace = true prost-types.workspace = true regex.workspace = true walkdir.workspace = true ================================================ FILE: rslib/proto_gen/src/lib.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html //! Some helpers for code generation in external crates, that ensure indexes //! match. use std::collections::HashMap; use std::env; use std::path::PathBuf; use std::sync::LazyLock; use anki_io::read_to_string; use anki_io::write_file_if_changed; use anki_io::ToUtf8Path; use anyhow::Result; use camino::Utf8Path; use inflections::Inflect; use itertools::Either; use itertools::Itertools; use prost_reflect::DescriptorPool; use prost_reflect::MessageDescriptor; use prost_reflect::MethodDescriptor; use prost_reflect::ServiceDescriptor; use regex::Captures; use regex::Regex; use walkdir::WalkDir; /// We look for ExampleService and BackedExampleService, both of which are /// expected to exist (but may be empty). /// /// - If a method is listed in BackendExampleService and not in ExampleService, /// that method is only available with a Backend. /// - If a method is listed in both services, you can provide separate /// implementations for each of the traits. /// - If a method is listed only in ExampleService, a forwarding method on /// Backend is automatically implemented. This bypasses the trait and /// implements directly on Backend. /// /// It's important that service and method indices are the same for /// client-generated code, so the client code should use the .index fields /// of Service and Method provided by get_services(), and not /// .enumerate() or .proto.index() /// /// Client code will want to ignore CollectionServices, and focus on /// BackendServices. pub fn get_services(pool: &DescriptorPool) -> (Vec, Vec) { // split services into backend and collection let (mut col_services, mut backend_services): (Vec<_>, Vec<_>) = pool.services().partition_map(|service| { if service.name().starts_with("Backend") { Either::Right(BackendService::from_proto(service)) } else { Either::Left(CollectionService::from_proto(service)) } }); // frontend.proto is only in col_services assert_eq!(col_services.len(), backend_services.len()); // copy collection methods into backend services if they don't have one with // a matching name for service in &mut backend_services { // locate associated collection service let Some(col_service) = col_services .iter() .find(|cs| cs.name == service.name.trim_start_matches("Backend")) else { panic!("missing associated service: {}", service.name) }; // add any methods that don't exist in backend trait methods to the delegating // methods service.delegating_methods = col_service .trait_methods .iter() .filter(|m| service.trait_methods.iter().all(|bm| bm.name != m.name)) .map(|method| Method { index: method.index + service.trait_methods.len(), ..method.clone() }) .collect(); } // fill comments in let comments = MethodComments::from_pool(pool); for service in &mut col_services { for method in &mut service.trait_methods { method.comments = comments.get_for_method(&method.proto); } } for service in &mut backend_services { for method in &mut service.trait_methods { method.comments = comments.get_for_method(&method.proto); } for method in &mut service.delegating_methods { method.comments = comments.get_for_method(&method.proto); } } (col_services, backend_services) } #[derive(Debug)] pub struct CollectionService { pub name: String, pub index: usize, pub trait_methods: Vec, pub proto: ServiceDescriptor, } #[derive(Debug)] pub struct BackendService { pub name: String, pub index: usize, pub trait_methods: Vec, pub delegating_methods: Vec, pub proto: ServiceDescriptor, } #[derive(Debug, Clone)] pub struct Method { pub name: String, pub index: usize, pub comments: Option, pub proto: MethodDescriptor, } impl CollectionService { pub fn from_proto(service: prost_reflect::ServiceDescriptor) -> Self { CollectionService { name: service.name().to_string(), index: service.index(), trait_methods: service.methods().map(Method::from_proto).collect(), proto: service, } } } impl BackendService { pub fn from_proto(service: prost_reflect::ServiceDescriptor) -> Self { BackendService { name: service.name().to_string(), index: service.index(), trait_methods: service.methods().map(Method::from_proto).collect(), proto: service, // filled in later delegating_methods: vec![], } } pub fn all_methods(&self) -> impl Iterator { self.trait_methods .iter() .chain(self.delegating_methods.iter()) } } impl Method { pub fn from_proto(method: prost_reflect::MethodDescriptor) -> Self { Method { name: method.name().to_snake_case(), index: method.index(), proto: method, // filled in later comments: None, } } /// The input type, if not empty. pub fn input(&self) -> Option { msg_if_not_empty(self.proto.input()) } /// The output type, if not empty. pub fn output(&self) -> Option { msg_if_not_empty(self.proto.output()) } } fn msg_if_not_empty(msg: MessageDescriptor) -> Option { if msg.full_name() == "anki.generic.Empty" { None } else { Some(msg) } } #[derive(Debug)] struct MethodComments<'a> { // package name -> method path -> comment by_package_and_path: HashMap<&'a str, HashMap, String>>, } impl<'a> MethodComments<'a> { pub fn from_pool(pool: &'a DescriptorPool) -> MethodComments<'a> { let mut by_package_and_path = HashMap::new(); for file in pool.file_descriptor_protos() { let path_map = file .source_code_info .as_ref() .unwrap() .location .iter() .map(|l| (l.path.clone(), l.leading_comments().trim().to_string())) .collect(); by_package_and_path.insert(file.package(), path_map); } Self { by_package_and_path, } } pub fn get_for_method(&self, method: &MethodDescriptor) -> Option { self.by_package_and_path .get(method.parent_file().package_name()) .and_then(|by_path| by_path.get(method.path())) .and_then(|s| if s.is_empty() { None } else { Some(s.into()) }) } } pub fn add_must_use_annotations( out_dir: &PathBuf, should_process_path: P, is_empty: E, ) -> Result<()> where P: Fn(&Utf8Path) -> bool, E: Fn(&Utf8Path, &str) -> bool, { for file in WalkDir::new(out_dir).into_iter() { let file = file?; let path = file.path().utf8()?; if path.file_name().unwrap().ends_with(".rs") && should_process_path(path) { add_must_use_annotations_to_file(path, &is_empty)?; } } Ok(()) } pub fn add_must_use_annotations_to_file(path: &Utf8Path, is_empty: E) -> Result<()> where E: Fn(&Utf8Path, &str) -> bool, { static MESSAGE_OR_ENUM_RE: LazyLock = LazyLock::new(|| Regex::new(r"pub (struct|enum) ([[:alnum:]]+?)\s").unwrap()); let contents = read_to_string(path)?; let contents = MESSAGE_OR_ENUM_RE.replace_all(&contents, |caps: &Captures| { let is_enum = caps.get(1).unwrap().as_str() == "enum"; let name = caps.get(2).unwrap().as_str(); if is_enum || !is_empty(path, name) { format!("#[must_use]\n{}", caps.get(0).unwrap().as_str()) } else { caps.get(0).unwrap().as_str().to_string() } }); write_file_if_changed(path, contents.as_ref())?; Ok(()) } /// Given a generated prost filename and a struct name, try to determine whether /// the message has 0 fields. /// /// This is unfortunately rather circuitous, as Prost doesn't allow us to easily /// alter the code generation with access to the associated proto descriptor. So /// we need to infer the full proto path based on the filename and the Rust type /// name, which we can only do for top-level elements. For any nested messages /// we can't find, we assume they must be used. pub fn determine_if_message_is_empty(pool: &DescriptorPool, path: &Utf8Path, name: &str) -> bool { let package = path.file_stem().unwrap(); let full_name = format!("{package}.{name}"); if let Some(msg) = pool.get_message_by_name(&full_name) { msg.fields().count() == 0 } else { false } } /// - When building via a local checkout, the path defined in .cargo/config /// - When building via cargo install or a third-party crate, /// OUT_DIR/../../anki_descriptors.bin (so it can be seen by the rslib crate) pub fn descriptors_path() -> PathBuf { if let Ok(path) = env::var("DESCRIPTORS_BIN") { PathBuf::from(path) } else { PathBuf::from(env::var("OUT_DIR").unwrap()).join("../../anki_descriptors.bin") } } ================================================ FILE: rslib/rust_interface.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::env; use std::fmt::Write; use std::path::PathBuf; use anki_io::write_file_if_changed; use anki_proto_gen::get_services; use anki_proto_gen::BackendService; use anki_proto_gen::CollectionService; use anki_proto_gen::Method; use anyhow::Context; use anyhow::Result; use inflections::Inflect; use itertools::Itertools; use prost_reflect::DescriptorPool; pub fn write_rust_interface(pool: &DescriptorPool) -> Result<()> { let mut buf = String::new(); buf.push_str("use crate::error::Result; use prost::Message;"); let (col_services, backend_services) = get_services(pool); let col_services = col_services .into_iter() .filter(|s| s.name != "FrontendService") .collect_vec(); let backend_services = backend_services .into_iter() .filter(|s| s.name != "BackendFrontendService") .collect_vec(); render_collection_services(&col_services, &mut buf)?; render_backend_services(&backend_services, &mut buf)?; let buf = format_code(buf)?; // println!("{}", &buf); // panic!(); let out_dir = env::var("OUT_DIR").unwrap(); let path = PathBuf::from(out_dir).join("backend.rs"); write_file_if_changed(path, buf).context("write file")?; Ok(()) } fn render_collection_services(col_services: &[CollectionService], buf: &mut String) -> Result<()> { for service in col_services { render_collection_trait(service, buf); render_individual_service_run_method_for_collection(buf, service); } render_top_level_run_method( col_services.iter().map(|s| (s.index, s.name.as_str())), "&mut self", "crate::collection::Collection", buf, ); Ok(()) } fn render_backend_services(backend_services: &[BackendService], buf: &mut String) -> Result<()> { for service in backend_services { render_backend_trait(service, buf); render_delegating_backend_methods(service, buf); render_individual_service_run_method_for_backend(buf, service); } render_top_level_run_method( backend_services.iter().map(|s| (s.index, s.name.as_str())), "&self", "crate::backend::Backend", buf, ); Ok(()) } fn format_code(code: String) -> Result { let syntax_tree = syn::parse_file(&code)?; Ok(prettyplease::unparse(&syntax_tree)) } fn render_collection_trait(service: &CollectionService, buf: &mut String) { let name = &service.name; writeln!(buf, "pub trait {name} {{").unwrap(); for method in &service.trait_methods { render_trait_method(method, "&mut self", buf); } buf.push('}'); } fn render_trait_method(method: &Method, self_kind: &str, buf: &mut String) { let method_name = &method.name; let input_with_label = method.get_input_arg_with_label(); let output_type = method.get_output_type(); writeln!( buf, "fn {method_name}({self_kind}, {input_with_label}) -> Result<{output_type}>;" ) .unwrap(); } fn render_backend_trait(service: &BackendService, buf: &mut String) { let name = &service.name; writeln!(buf, "pub trait {name} {{").unwrap(); for method in &service.trait_methods { render_trait_method(method, "&self", buf); } buf.push('}'); } fn render_delegating_backend_methods(service: &BackendService, buf: &mut String) { buf.push_str("impl crate::backend::Backend {"); for method in &service.delegating_methods { render_delegating_backend_method(method, service.name.trim_start_matches("Backend"), buf); } buf.push('}'); } fn render_delegating_backend_method(method: &Method, method_qualifier: &str, buf: &mut String) { let method_name = &method.name; let input_with_label = method.get_input_arg_with_label(); let input = method.text_if_input_not_empty(|_| "input".into()); let output_type = method.get_output_type(); writeln!( buf, "fn {method_name}(&self, {input_with_label}) -> Result<{output_type}> {{ self.with_col(|col| {method_qualifier}::{method_name}(col, {input})) }}", ) .unwrap(); } // Matches all service types and delegates to the revelant self.run_foo_method() fn render_top_level_run_method<'a>( // (index, name) services: impl Iterator, self_kind: &str, struct_name: &str, buf: &mut String, ) { writeln!(buf, r#" impl {struct_name} {{ pub fn run_service_method({self_kind}, service: u32, method: u32, input: &[u8]) -> Result, Vec> {{ match service {{ "#, ).unwrap(); for (idx, service) in services { writeln!( buf, "{idx} => self.run_{service}_method(method, input),", service = service.to_snake_case() ) .unwrap(); } buf.push_str( r#" _ => Err(crate::error::AnkiError::InvalidServiceIndex), } .map_err(|err| { let backend_err = err.into_protobuf(&self.tr); let mut bytes = Vec::new(); backend_err.encode(&mut bytes).unwrap(); bytes }) } }"#, ); } fn render_individual_service_run_method_for_collection( buf: &mut String, service: &CollectionService, ) { let service_name = &service.name.to_snake_case(); writeln!( buf, "#[allow(unused_variables, clippy::match_single_binding)] impl crate::collection::Collection {{ pub(crate) fn run_{service_name}_method(&mut self, method: u32, input: &[u8]) -> Result> {{ match method {{", ) .unwrap(); for method in &service.trait_methods { render_method_in_match_expression(method, &service.name, buf); } buf.push_str( r#" _ => Err(crate::error::AnkiError::InvalidMethodIndex), } } } "#, ); } fn render_individual_service_run_method_for_backend(buf: &mut String, service: &BackendService) { let service_name = &service.name.to_snake_case(); writeln!( buf, "#[allow(unused_variables, clippy::match_single_binding)] impl crate::backend::Backend {{ pub(crate) fn run_{service_name}_method(&self, method: u32, input: &[u8]) -> Result> {{ match method {{", ) .unwrap(); for method in &service.trait_methods { render_method_in_match_expression(method, &service.name, buf); } for method in &service.delegating_methods { render_method_in_match_expression(method, "crate::backend::Backend", buf); } buf.push_str( r#" _ => Err(crate::error::AnkiError::InvalidMethodIndex), } } } "#, ); } fn render_method_in_match_expression(method: &Method, method_qualifier: &str, buf: &mut String) { let decode_input = method.text_if_input_not_empty(|kind| format!("let input = {kind}::decode(input)?;")); let rust_method = &method.name; let input = method.text_if_input_not_empty(|_| "input".into()); let output_assign = method.text_if_output_not_empty(|_| "let output = ".into()); let idx = method.index; let output = if method.output().is_none() { "Vec::new()" } else { "{ let mut out_bytes = Vec::new(); output.encode(&mut out_bytes)?; out_bytes }" }; writeln!( buf, "{idx} => {{ {decode_input} {output_assign} {method_qualifier}::{rust_method}(self, {input})?; Ok({output}) }},", ) .unwrap(); } trait MethodHelpers { fn input_type(&self) -> Option; fn output_type(&self) -> Option; fn text_if_input_not_empty(&self, text: impl Fn(&String) -> String) -> String; fn get_input_arg_with_label(&self) -> String; fn get_output_type(&self) -> String; fn text_if_output_not_empty(&self, text: impl Fn(&String) -> String) -> String; } impl MethodHelpers for Method { fn input_type(&self) -> Option { self.input().map(|t| rust_type(t.full_name())) } fn output_type(&self) -> Option { self.output().map(|t| rust_type(t.full_name())) } /// No text if generic::Empty fn text_if_input_not_empty(&self, text: impl Fn(&String) -> String) -> String { self.input_type().as_ref().map(text).unwrap_or_default() } /// No text if generic::Empty fn get_input_arg_with_label(&self) -> String { self.input_type() .as_ref() .map(|t| format!("input: {t}")) .unwrap_or_default() } /// () if generic::Empty fn get_output_type(&self) -> String { self.output_type().as_deref().unwrap_or("()").into() } fn text_if_output_not_empty(&self, text: impl Fn(&String) -> String) -> String { self.output_type().as_ref().map(text).unwrap_or_default() } } fn rust_type(name: &str) -> String { let Some((head, tail)) = name.rsplit_once('.') else { panic!() }; format!( "{}::{}", head.to_snake_case() .replace('.', "::") .replace("anki::", "anki_proto::"), tail ) } ================================================ FILE: rslib/src/adding.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::sync::Arc; use crate::prelude::*; pub struct DeckAndNotetype { pub deck_id: DeckId, pub notetype_id: NotetypeId, } impl Collection { /// An option in the preferences screen governs the behaviour here. /// /// - When 'default to the current deck' is enabled, we use the current deck /// if it's normal, the provided reviewer card's deck as a fallback, and /// Default as a final fallback. We then fetch the last used notetype /// stored in the deck, falling back to the global notetype, or the first /// available one. /// /// - Otherwise, each note type remembers the last deck cards were added to, /// and we use that, defaulting to the current deck if missing, and /// Default otherwise. pub fn defaults_for_adding( &mut self, home_deck_of_reviewer_card: DeckId, ) -> Result { let deck_id; let notetype_id; if self.get_config_bool(BoolKey::AddingDefaultsToCurrentDeck) { deck_id = self .get_current_deck_for_adding(home_deck_of_reviewer_card)? .id; notetype_id = self.default_notetype_for_deck(deck_id)?.id; } else { notetype_id = self.get_current_notetype_for_adding()?.id; deck_id = if let Some(deck_id) = self.default_deck_for_notetype(notetype_id)? { deck_id } else { // default not set in notetype; fall back to current deck self.get_current_deck_for_adding(home_deck_of_reviewer_card)? .id }; } Ok(DeckAndNotetype { deck_id, notetype_id, }) } /// The currently selected deck, the home deck of the provided card if /// current deck is filtered, or the default deck. fn get_current_deck_for_adding( &mut self, home_deck_of_reviewer_card: DeckId, ) -> Result> { // current deck, if not filtered if let Some(current) = self.get_deck(self.get_current_deck_id())? { if !current.is_filtered() { return Ok(current); } } // provided reviewer card's home deck if let Some(home_deck) = self.get_deck(home_deck_of_reviewer_card)? { return Ok(home_deck); } // default deck self.get_deck(DeckId(1))?.or_not_found(DeckId(1)) } fn get_current_notetype_for_adding(&mut self) -> Result> { // try global 'current' notetype if let Some(ntid) = self.get_current_notetype_id() { if let Some(nt) = self.get_notetype(ntid)? { return Ok(nt); } } // try first available notetype if let Some((ntid, _)) = self.storage.get_all_notetype_names()?.first() { Ok(self.get_notetype(*ntid)?.unwrap()) } else { invalid_input!("collection has no notetypes"); } } fn default_notetype_for_deck(&mut self, deck: DeckId) -> Result> { // try last notetype used by deck if let Some(ntid) = self.get_last_notetype_for_deck(deck) { if let Some(nt) = self.get_notetype(ntid)? { return Ok(nt); } } // fall back self.get_current_notetype_for_adding() } /// Returns the last deck added to with this notetype, provided it is valid. /// This is optional due to the inconsistent handling, where changes in /// notetype may need to update the current deck, but not vice versa. If /// a previous deck is not set, we want to keep the current selection, /// instead of resetting it. pub(crate) fn default_deck_for_notetype(&mut self, ntid: NotetypeId) -> Result> { if let Some(last_deck_id) = self.get_last_deck_added_to_for_notetype(ntid) { if let Some(deck) = self.get_deck(last_deck_id)? { if !deck.is_filtered() { return Ok(Some(deck.id)); } } } Ok(None) } } ================================================ FILE: rslib/src/ankidroid/db.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::mem::size_of; use std::sync::atomic::AtomicI32; use std::sync::atomic::Ordering; use std::sync::LazyLock; use std::sync::Mutex; use anki_proto::ankidroid::sql_value::Data; use anki_proto::ankidroid::DbResponse; use anki_proto::ankidroid::DbResult; use anki_proto::ankidroid::Row; use anki_proto::ankidroid::SqlValue; use itertools::FoldWhile; use itertools::FoldWhile::Continue; use itertools::FoldWhile::Done; use itertools::Itertools; use rusqlite::ToSql; use serde::Deserialize; use crate::collection::Collection; use crate::error::Result; /// A pointer to the SqliteStorage object stored in a collection, used to /// uniquely index results from multiple open collections at once. impl Collection { fn id_for_db_cache(&self) -> CollectionId { CollectionId((&self.storage as *const _) as i64) } } #[derive(Hash, PartialEq, Eq)] struct CollectionId(i64); #[derive(Deserialize)] struct DBArgs { sql: String, args: Vec, } pub trait Sizable { /** Estimates the heap size of the value, in bytes */ fn estimate_size(&self) -> usize; } impl Sizable for Data { fn estimate_size(&self) -> usize { match self { Data::StringValue(s) => s.len(), Data::LongValue(_) => size_of::(), Data::DoubleValue(_) => size_of::(), Data::BlobValue(b) => b.len(), } } } impl Sizable for SqlValue { fn estimate_size(&self) -> usize { // Add a byte for the optional self.data .as_ref() .map(|f| f.estimate_size() + 1) .unwrap_or(1) } } impl Sizable for Row { fn estimate_size(&self) -> usize { self.fields.iter().map(|x| x.estimate_size()).sum() } } impl Sizable for DbResult { fn estimate_size(&self) -> usize { // Performance: It might be best to take the first x rows and determine the data // types If we have floats or longs, they'll be a fixed size (excluding // nulls) and should speed up the calculation as we'll only calculate a // subset of the columns. self.rows.iter().map(|x| x.estimate_size()).sum() } } pub(crate) fn select_next_slice<'a>(rows: impl Iterator) -> Vec { select_slice_of_size(rows, get_max_page_size()) .into_inner() .1 } fn select_slice_of_size<'a>( mut rows: impl Iterator, max_size: usize, ) -> FoldWhile<(usize, Vec)> { let init: Vec = Vec::new(); rows.fold_while((0, init), |mut acc, x| { let new_size = acc.0 + x.estimate_size(); // If the accumulator is 0, but we're over the size: return a single result so // we don't loop forever. Theoretically, this shouldn't happen as data // should be reasonably sized if new_size > max_size && acc.0 > 0 { Done(acc) } else { // PERF: should be faster to return (size, numElements) then bulk copy/slice acc.1.push(x.to_owned()); Continue((new_size, acc.1)) } }) } type SequenceNumber = i32; static HASHMAP: LazyLock>>> = LazyLock::new(|| Mutex::new(HashMap::new())); pub(crate) fn flush_single_result(col: &Collection, sequence_number: i32) { HASHMAP .lock() .unwrap() .get_mut(&col.id_for_db_cache()) .map(|storage| storage.remove(&sequence_number)); } pub(crate) fn flush_collection(col: &Collection) { HASHMAP.lock().unwrap().remove(&col.id_for_db_cache()); } pub(crate) fn active_sequences(col: &Collection) -> Vec { HASHMAP .lock() .unwrap() .get(&col.id_for_db_cache()) .map(|h| h.keys().copied().collect()) .unwrap_or_default() } /** Store the data in the cache if larger than than the page size.
Returns: The data capped to the page size */ pub(crate) fn trim_and_cache_remaining( col: &Collection, values: DbResult, sequence_number: i32, ) -> DbResponse { let start_index = 0; // PERF: Could speed this up by not creating the vector and just calculating the // count let first_result = select_next_slice(values.rows.iter()); let row_count = values.rows.len() as i32; if first_result.len() < values.rows.len() { let to_store = DbResponse { result: Some(values), sequence_number, row_count, start_index, }; insert_cache(col, to_store); DbResponse { result: Some(DbResult { rows: first_result }), sequence_number, row_count, start_index, } } else { DbResponse { result: Some(values), sequence_number, row_count, start_index, } } } fn insert_cache(col: &Collection, result: DbResponse) { HASHMAP .lock() .unwrap() .entry(col.id_for_db_cache()) .or_default() .insert(result.sequence_number, result); } pub(crate) fn get_next( col: &Collection, sequence_number: i32, start_index: i64, ) -> Option { let result = get_next_result(col, &sequence_number, start_index); if let Some(resp) = result.as_ref() { if resp.result.is_none() || resp.result.as_ref().unwrap().rows.is_empty() { flush_single_result(col, sequence_number) } } result } fn get_next_result( col: &Collection, sequence_number: &i32, start_index: i64, ) -> Option { let map = HASHMAP.lock().unwrap(); let result_map = map.get(&col.id_for_db_cache())?; let current_result = result_map.get(sequence_number)?; // TODO: This shouldn't need to exist let tmp: Vec = Vec::new(); let next_rows = current_result .result .as_ref() .map(|x| x.rows.iter()) .unwrap_or_else(|| tmp.iter()); let skipped_rows = next_rows.clone().skip(start_index as usize).collect_vec(); println!("{}", skipped_rows.len()); let filtered_rows = select_next_slice(next_rows.skip(start_index as usize)); let result = DbResult { rows: filtered_rows, }; let trimmed_result = DbResponse { result: Some(result), sequence_number: current_result.sequence_number, row_count: current_result.row_count, start_index, }; Some(trimmed_result) } static SEQUENCE_NUMBER: AtomicI32 = AtomicI32::new(0); pub(crate) fn next_sequence_number() -> i32 { SEQUENCE_NUMBER.fetch_add(1, Ordering::SeqCst) } // same as we get from // io.requery.android.database.CursorWindow.sCursorWindowSize static DB_COMMAND_PAGE_SIZE: LazyLock> = LazyLock::new(|| Mutex::new(1024 * 1024 * 2)); pub(crate) fn set_max_page_size(size: usize) { let mut state = DB_COMMAND_PAGE_SIZE.lock().expect("Could not lock mutex"); *state = size; } fn get_max_page_size() -> usize { *DB_COMMAND_PAGE_SIZE.lock().unwrap() } fn get_args(in_bytes: &[u8]) -> Result { let ret: DBArgs = serde_json::from_slice(in_bytes)?; Ok(ret) } pub(crate) fn insert_for_id(col: &Collection, json: &[u8]) -> Result { let req = get_args(json)?; let args: Vec<_> = req.args.iter().map(|a| a as &dyn ToSql).collect(); col.storage.db.execute(&req.sql, &args[..])?; Ok(col.storage.db.last_insert_rowid()) } pub(crate) fn execute_for_row_count(col: &Collection, req: &[u8]) -> Result { let req = get_args(req)?; let args: Vec<_> = req.args.iter().map(|a| a as &dyn ToSql).collect(); let count = col.storage.db.execute(&req.sql, &args[..])?; Ok(count as i64) } #[cfg(test)] mod tests { use anki_proto::ankidroid::sql_value; use anki_proto::ankidroid::Row; use anki_proto::ankidroid::SqlValue; use super::*; use crate::ankidroid::db::select_slice_of_size; use crate::ankidroid::db::Sizable; fn gen_data() -> Vec { vec![ SqlValue { data: Some(sql_value::Data::DoubleValue(12.0)), }, SqlValue { data: Some(sql_value::Data::LongValue(12)), }, SqlValue { data: Some(sql_value::Data::StringValue( "Hellooooooo World".to_string(), )), }, SqlValue { data: Some(sql_value::Data::BlobValue(vec![])), }, ] } #[test] fn test_size_estimate() { let row = Row { fields: gen_data() }; let result = DbResult { rows: vec![row.clone(), row], }; let actual_size = result.estimate_size(); let expected_size = (17 + 8 + 8) * 2; // 1 variable string, 1 long, 1 float let expected_overhead = 4 * 2; // 4 optional columns assert_eq!(actual_size, expected_overhead + expected_size); } #[test] fn test_stream_size() { let row = Row { fields: gen_data() }; let result = DbResult { rows: vec![row.clone(), row.clone(), row], }; let limit = 74 + 1; // two rows are 74 let result = select_slice_of_size(result.rows.iter(), limit).into_inner(); assert_eq!( 2, result.1.len(), "The final element should not be included" ); assert_eq!( 74, result.0, "The size should be the size of the first two objects" ); } #[test] fn test_stream_size_too_small() { let row = Row { fields: gen_data() }; let result = DbResult { rows: vec![row] }; let limit = 1; let result = select_slice_of_size(result.rows.iter(), limit).into_inner(); assert_eq!( 1, result.1.len(), "If the limit is too small, a result is still returned" ); assert_eq!( 37, result.0, "The size should be the size of the first objects" ); } const SEQUENCE_NUMBER: i32 = 1; fn get(col: &Collection, index: i64) -> Option { get_next(col, SEQUENCE_NUMBER, index) } fn get_first(col: &Collection, result: DbResult) -> DbResponse { trim_and_cache_remaining(col, result, SEQUENCE_NUMBER) } fn seq_number_used(col: &Collection) -> bool { HASHMAP .lock() .unwrap() .get(&col.id_for_db_cache()) .unwrap() .contains_key(&SEQUENCE_NUMBER) } #[test] fn integration_test() { let col = Collection::new(); let row = Row { fields: gen_data() }; // return one row at a time set_max_page_size(row.estimate_size() - 1); let db_query_result = DbResult { rows: vec![row.clone(), row], }; let first_jni_response = get_first(&col, db_query_result); assert_eq!( row_count(&first_jni_response), 1, "The first call should only return one row" ); let next_index = first_jni_response.start_index + row_count(&first_jni_response); let second_response = get(&col, next_index); assert!( second_response.is_some(), "The second response should return a value" ); let valid_second_response = second_response.unwrap(); assert_eq!(row_count(&valid_second_response), 1); let final_index = valid_second_response.start_index + row_count(&valid_second_response); assert!(seq_number_used(&col), "The sequence number is assigned"); let final_response = get(&col, final_index); assert!( final_response.is_some(), "The third call should return something with no rows" ); assert_eq!( row_count(&final_response.unwrap()), 0, "The third call should return something with no rows" ); assert!( !seq_number_used(&col), "Sequence number data has been cleared" ); } fn row_count(resp: &DbResponse) -> i64 { resp.result.as_ref().map(|x| x.rows.len()).unwrap_or(0) as i64 } } ================================================ FILE: rslib/src/ankidroid/error.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::error::DbError; use crate::error::DbErrorKind as DB; use crate::error::FilteredDeckError; use crate::error::InvalidInputError; use crate::error::NetworkError; use crate::error::NetworkErrorKind as Net; use crate::error::NotFoundError; use crate::error::SearchErrorKind; use crate::error::SyncError; use crate::error::SyncErrorKind as Sync; use crate::prelude::AnkiError; pub(crate) fn debug_produce_error(s: &str) -> AnkiError { let info = "error_value".to_string(); match s { "TemplateError" => AnkiError::TemplateError { info }, "DbErrorFileTooNew" => AnkiError::DbError { source: DbError { info, kind: DB::FileTooNew, }, }, "DbErrorFileTooOld" => AnkiError::DbError { source: DbError { info, kind: DB::FileTooOld, }, }, "DbErrorMissingEntity" => AnkiError::DbError { source: DbError { info, kind: DB::MissingEntity, }, }, "DbErrorCorrupt" => AnkiError::DbError { source: DbError { info, kind: DB::Corrupt, }, }, "DbErrorLocked" => AnkiError::DbError { source: DbError { info, kind: DB::Locked, }, }, "DbErrorOther" => AnkiError::DbError { source: DbError { info, kind: DB::Other, }, }, "NetworkError" => AnkiError::NetworkError { source: NetworkError { info, kind: Net::Offline, }, }, "SyncErrorConflict" => AnkiError::SyncError { source: SyncError { info, kind: Sync::Conflict, }, }, "SyncErrorServerError" => AnkiError::SyncError { source: SyncError { info, kind: Sync::ServerError, }, }, "SyncErrorClientTooOld" => AnkiError::SyncError { source: SyncError { info, kind: Sync::ClientTooOld, }, }, "SyncErrorAuthFailed" => AnkiError::SyncError { source: SyncError { info, kind: Sync::AuthFailed, }, }, "SyncErrorServerMessage" => AnkiError::SyncError { source: SyncError { info, kind: Sync::ServerMessage, }, }, "SyncErrorClockIncorrect" => AnkiError::SyncError { source: SyncError { info, kind: Sync::ClockIncorrect, }, }, "SyncErrorOther" => AnkiError::SyncError { source: SyncError { info, kind: Sync::Other, }, }, "SyncErrorResyncRequired" => AnkiError::SyncError { source: SyncError { info, kind: Sync::ResyncRequired, }, }, "SyncErrorDatabaseCheckRequired" => AnkiError::SyncError { source: SyncError { info, kind: Sync::DatabaseCheckRequired, }, }, "JSONError" => AnkiError::JsonError { info }, "ProtoError" => AnkiError::ProtoError { info }, "Interrupted" => AnkiError::Interrupted, "CollectionNotOpen" => AnkiError::CollectionNotOpen, "CollectionAlreadyOpen" => AnkiError::CollectionAlreadyOpen, "NotFound" => AnkiError::NotFound { source: NotFoundError { type_name: "".to_string(), identifier: "".to_string(), backtrace: None, }, }, "Existing" => AnkiError::Existing, "FilteredDeckError" => AnkiError::FilteredDeckError { source: FilteredDeckError::FilteredDeckRequired, }, "SearchError" => AnkiError::SearchError { source: SearchErrorKind::EmptyGroup, }, _ => AnkiError::InvalidInput { source: InvalidInputError { message: info, source: None, backtrace: None, }, }, } } ================================================ FILE: rslib/src/ankidroid/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub(crate) mod db; pub(crate) mod error; pub mod service; ================================================ FILE: rslib/src/ankidroid/service.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::ankidroid::DbResponse; use anki_proto::ankidroid::GetActiveSequenceNumbersResponse; use anki_proto::ankidroid::GetNextResultPageRequest; use anki_proto::generic; use crate::ankidroid::db; use crate::ankidroid::db::active_sequences; use crate::ankidroid::db::execute_for_row_count; use crate::ankidroid::db::insert_for_id; use crate::backend::dbproxy::db_command_bytes; use crate::backend::dbproxy::db_command_proto; use crate::collection::Collection; use crate::error; use crate::error::OrInvalid; impl crate::services::AnkidroidService for Collection { fn run_db_command(&mut self, input: generic::Json) -> error::Result { db_command_bytes(self, &input.json).map(|json| generic::Json { json }) } fn run_db_command_proto(&mut self, input: generic::Json) -> error::Result { db_command_proto(self, &input.json) } fn run_db_command_for_row_count( &mut self, input: generic::Json, ) -> error::Result { execute_for_row_count(self, &input.json).map(|val| generic::Int64 { val }) } fn flush_all_queries(&mut self) -> error::Result<()> { db::flush_collection(self); Ok(()) } fn flush_query(&mut self, input: generic::Int32) -> error::Result<()> { db::flush_single_result(self, input.val); Ok(()) } fn get_next_result_page( &mut self, input: GetNextResultPageRequest, ) -> error::Result { db::get_next(self, input.sequence, input.index).or_invalid("missing result page") } fn insert_for_id(&mut self, input: generic::Json) -> error::Result { insert_for_id(self, &input.json).map(Into::into) } fn get_column_names_from_query( &mut self, input: generic::String, ) -> error::Result { let stmt = self.storage.db.prepare(&input.val)?; let names = stmt.column_names(); let names: Vec<_> = names.iter().map(ToString::to_string).collect(); Ok(names.into()) } fn get_active_sequence_numbers(&mut self) -> error::Result { Ok(GetActiveSequenceNumbersResponse { numbers: active_sequences(self), }) } } ================================================ FILE: rslib/src/ankihub/http_client/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::env; use reqwest::Client; use reqwest::Response; use reqwest::Result; use reqwest::Url; use serde::Serialize; use crate::ankihub::login::LoginRequest; static API_VERSION: &str = "19.0"; static DEFAULT_API_URL: &str = "https://app.ankihub.net/api/"; #[derive(Clone)] pub struct HttpAnkiHubClient { pub token: String, pub endpoint: Url, client: Client, } impl HttpAnkiHubClient { pub fn new>(token: S, client: Client) -> HttpAnkiHubClient { let endpoint = match env::var("ANKIHUB_APP_URL") { Ok(url) => { if let Ok(u) = Url::try_from(url.as_str()) { u.join("api/").unwrap().to_string() } else { DEFAULT_API_URL.to_string() } } Err(_) => DEFAULT_API_URL.to_string(), }; HttpAnkiHubClient { token: token.into(), endpoint: Url::try_from(endpoint.as_str()).unwrap(), client, } } async fn request(&self, method: &str, data: &T) -> Result { let url = self.endpoint.join(method).unwrap(); let mut builder = self.client.post(url).header( reqwest::header::ACCEPT, format!("application/json; version={API_VERSION}"), ); if !self.token.is_empty() { builder = builder.header("Authorization", format!("Token {}", self.token)); } builder.json(&data).send().await } pub async fn login(&self, data: LoginRequest) -> Result { self.request("login/", &data).await } pub async fn logout(&self) -> Result { self.request("logout/", "").await } } ================================================ FILE: rslib/src/ankihub/login.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::sync::LazyLock; use regex::Regex; use reqwest::Client; use serde; use serde::Deserialize; use serde::Serialize; use crate::ankihub::http_client::HttpAnkiHubClient; use crate::prelude::*; #[derive(Serialize, Deserialize, Debug)] pub struct LoginRequest { #[serde(skip_serializing_if = "Option::is_none")] pub username: Option, #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, pub password: String, } #[derive(Serialize, Deserialize, Debug)] pub struct LoginResponse { pub token: Option, } pub async fn ankihub_login>( id: S, password: S, client: Client, ) -> Result { let client = HttpAnkiHubClient::new("", client); static EMAIL_RE: LazyLock = LazyLock::new(|| { Regex::new(r"^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$").unwrap() }); let mut request = LoginRequest { username: None, email: None, password: password.into(), }; let id: String = id.into(); if EMAIL_RE.is_match(&id) { request.email = Some(id); } else { request.username = Some(id); } client .login(request) .await? .json::() .await .map_err(|e| e.into()) } pub async fn ankihub_logout>(token: S, client: Client) -> Result<()> { let client = HttpAnkiHubClient::new(token, client); client.logout().await?; Ok(()) } ================================================ FILE: rslib/src/ankihub/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub mod http_client; pub mod login; ================================================ FILE: rslib/src/backend/adding.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::notes::DeckAndNotetype as DeckAndNotetypeProto; use crate::adding::DeckAndNotetype; impl From for DeckAndNotetypeProto { fn from(s: DeckAndNotetype) -> Self { DeckAndNotetypeProto { deck_id: s.deck_id.0, notetype_id: s.notetype_id.0, } } } ================================================ FILE: rslib/src/backend/ankidroid.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::generic; use super::Backend; use crate::ankidroid::db; use crate::ankidroid::error::debug_produce_error; use crate::prelude::*; use crate::scheduler::timing; use crate::scheduler::timing::fixed_offset_from_minutes; use crate::services::BackendAnkidroidService; impl BackendAnkidroidService for Backend { fn sched_timing_today_legacy( &self, input: anki_proto::ankidroid::SchedTimingTodayLegacyRequest, ) -> Result { let result = timing::sched_timing_today( TimestampSecs::from(input.created_secs), TimestampSecs::from(input.now_secs), input.created_mins_west.map(fixed_offset_from_minutes), fixed_offset_from_minutes(input.now_mins_west), Some(input.rollover_hour as u8), )?; Ok(anki_proto::scheduler::SchedTimingTodayResponse::from( result, )) } fn local_minutes_west_legacy(&self, input: generic::Int64) -> Result { Ok(generic::Int32 { val: timing::local_minutes_west_for_stamp(input.val.into())?, }) } fn set_page_size(&self, input: generic::Int64) -> Result<()> { // we don't require an open collection, but should avoid modifying this // concurrently let _guard = self.col.lock(); db::set_max_page_size(input.val as usize); Ok(()) } fn debug_produce_error(&self, input: generic::String) -> Result<()> { Err(debug_produce_error(&input.val)) } } impl From for anki_proto::scheduler::SchedTimingTodayResponse { fn from( t: crate::scheduler::timing::SchedTimingToday, ) -> anki_proto::scheduler::SchedTimingTodayResponse { anki_proto::scheduler::SchedTimingTodayResponse { days_elapsed: t.days_elapsed, next_day_at: t.next_day_at.0, } } } ================================================ FILE: rslib/src/backend/ankihub.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::Backend; use crate::ankihub::login::ankihub_login; use crate::ankihub::login::ankihub_logout; use crate::ankihub::login::LoginResponse; use crate::prelude::*; impl From for anki_proto::ankihub::LoginResponse { fn from(value: LoginResponse) -> Self { anki_proto::ankihub::LoginResponse { token: value.token.unwrap_or_default(), } } } impl crate::services::BackendAnkiHubService for Backend { fn ankihub_login( &self, input: anki_proto::ankihub::LoginRequest, ) -> Result { let rt = self.runtime_handle(); let fut = ankihub_login(input.id, input.password, self.web_client()); rt.block_on(fut).map(|a| a.into()) } fn ankihub_logout(&self, input: anki_proto::ankihub::LogoutRequest) -> Result<()> { let rt = self.runtime_handle(); let fut = ankihub_logout(input.token, self.web_client()); rt.block_on(fut) } } ================================================ FILE: rslib/src/backend/ankiweb.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::time::Duration; use anki_proto::ankiweb::CheckForUpdateRequest; use anki_proto::ankiweb::CheckForUpdateResponse; use anki_proto::ankiweb::GetAddonInfoRequest; use anki_proto::ankiweb::GetAddonInfoResponse; use prost::Message; use super::Backend; use crate::prelude::*; use crate::services::BackendAnkiwebService; fn service_url(service: &str) -> String { format!("https://ankiweb.net/svc/{service}") } impl Backend { fn post(&self, service: &str, input: I) -> Result where I: Message, O: Message + Default, { self.runtime_handle().block_on(async move { let out = self .web_client() .post(service_url(service)) .body(input.encode_to_vec()) .timeout(Duration::from_secs(60)) .send() .await? .error_for_status()? .bytes() .await?; let out: O = O::decode(&out[..])?; Ok(out) }) } } impl BackendAnkiwebService for Backend { fn get_addon_info(&self, input: GetAddonInfoRequest) -> Result { self.post("desktop/addon-info", input) } fn check_for_update(&self, input: CheckForUpdateRequest) -> Result { self.post("desktop/check-for-update", input) } } #[cfg(test)] mod tests { use super::*; #[test] fn addon_info() -> Result<()> { if std::env::var("ONLINE_TESTS").is_err() { println!("test disabled; ONLINE_TESTS not set"); return Ok(()); } let backend = Backend::new(I18n::template_only(), false); let info = backend.get_addon_info(GetAddonInfoRequest { client_version: 30, addon_ids: vec![3918629684], })?; assert_eq!(info.info[0].min_version, 0); assert_eq!(info.info[0].max_version, 49); Ok(()) } } ================================================ FILE: rslib/src/backend/card_rendering.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::card_rendering::StripHtmlRequest; use crate::backend::Backend; use crate::card_rendering::service::strip_html_proto; use crate::card_rendering::tts; use crate::prelude::*; use crate::services::BackendCardRenderingService; impl BackendCardRenderingService for Backend { fn strip_html( &self, input: StripHtmlRequest, ) -> crate::error::Result { strip_html_proto(input) } fn all_tts_voices( &self, input: anki_proto::card_rendering::AllTtsVoicesRequest, ) -> Result { tts::all_voices(input.validate) .map(|voices| anki_proto::card_rendering::AllTtsVoicesResponse { voices }) } fn write_tts_stream( &self, request: anki_proto::card_rendering::WriteTtsStreamRequest, ) -> Result<()> { tts::write_stream( &request.path, &request.voice_id, request.speed, &request.text, ) } } ================================================ FILE: rslib/src/backend/collection.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::sync::MutexGuard; use anki_proto::generic; use tracing::error; use super::Backend; use crate::collection::CollectionBuilder; use crate::prelude::*; use crate::progress::progress_to_proto; use crate::services::BackendCollectionService; use crate::storage::SchemaVersion; impl BackendCollectionService for Backend { fn open_collection(&self, input: anki_proto::collection::OpenCollectionRequest) -> Result<()> { let mut guard = self.lock_closed_collection()?; let mut builder = CollectionBuilder::new(input.collection_path); builder .set_media_paths(input.media_folder_path, input.media_db_path) .set_server(self.server) .set_tr(self.tr.clone()) .set_shared_progress_state(self.progress_state.clone()); *guard = Some(builder.build()?); Ok(()) } fn close_collection( &self, input: anki_proto::collection::CloseCollectionRequest, ) -> Result<()> { let desired_version = if input.downgrade_to_schema11 { Some(SchemaVersion::V11) } else { None }; self.abort_media_sync_and_wait(); let mut guard = self.lock_open_collection()?; let col_inner = guard.take().unwrap(); if let Err(e) = col_inner.close(desired_version) { error!(" failed: {:?}", e); } Ok(()) } fn create_backup( &self, input: anki_proto::collection::CreateBackupRequest, ) -> Result { // lock collection let mut col_lock = self.lock_open_collection()?; let col = col_lock.as_mut().unwrap(); // await any previous backup first let mut task_lock = self.backup_task.lock().unwrap(); if let Some(task) = task_lock.take() { task.join().unwrap()?; } // start the new backup let created = if let Some(task) = col.maybe_backup(input.backup_folder, input.force)? { if input.wait_for_completion { drop(col_lock); task.join().unwrap()?; } else { *task_lock = Some(task); } true } else { false }; Ok(created.into()) } fn await_backup_completion(&self) -> Result<()> { self.await_backup_completion()?; Ok(()) } fn latest_progress(&self) -> Result { let progress = self.progress_state.lock().unwrap().last_progress; Ok(progress_to_proto(progress, &self.tr)) } fn set_wants_abort(&self) -> Result<()> { self.progress_state.lock().unwrap().want_abort = true; Ok(()) } } impl Backend { pub(super) fn lock_open_collection(&self) -> Result>> { let guard = self.col.lock().unwrap(); guard .is_some() .then_some(guard) .ok_or(AnkiError::CollectionNotOpen) } pub(super) fn lock_closed_collection(&self) -> Result>> { let guard = self.col.lock().unwrap(); guard .is_none() .then_some(guard) .ok_or(AnkiError::CollectionAlreadyOpen) } fn await_backup_completion(&self) -> Result<()> { if let Some(task) = self.backup_task.lock().unwrap().take() { task.join().unwrap()?; } Ok(()) } } ================================================ FILE: rslib/src/backend/config.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::config::config_key::Bool as BoolKeyProto; use anki_proto::config::config_key::String as StringKeyProto; use anki_proto::generic; use serde_json::Value; use crate::config::BoolKey; use crate::config::StringKey; use crate::prelude::*; impl From for BoolKey { fn from(k: BoolKeyProto) -> Self { match k { BoolKeyProto::BrowserTableShowNotesMode => BoolKey::BrowserTableShowNotesMode, BoolKeyProto::PreviewBothSides => BoolKey::PreviewBothSides, BoolKeyProto::CollapseTags => BoolKey::CollapseTags, BoolKeyProto::CollapseNotetypes => BoolKey::CollapseNotetypes, BoolKeyProto::CollapseDecks => BoolKey::CollapseDecks, BoolKeyProto::CollapseSavedSearches => BoolKey::CollapseSavedSearches, BoolKeyProto::CollapseToday => BoolKey::CollapseToday, BoolKeyProto::CollapseCardState => BoolKey::CollapseCardState, BoolKeyProto::CollapseFlags => BoolKey::CollapseFlags, BoolKeyProto::Sched2021 => BoolKey::Sched2021, BoolKeyProto::AddingDefaultsToCurrentDeck => BoolKey::AddingDefaultsToCurrentDeck, BoolKeyProto::HideAudioPlayButtons => BoolKey::HideAudioPlayButtons, BoolKeyProto::InterruptAudioWhenAnswering => BoolKey::InterruptAudioWhenAnswering, BoolKeyProto::PasteImagesAsPng => BoolKey::PasteImagesAsPng, BoolKeyProto::PasteStripsFormatting => BoolKey::PasteStripsFormatting, BoolKeyProto::NormalizeNoteText => BoolKey::NormalizeNoteText, BoolKeyProto::IgnoreAccentsInSearch => BoolKey::IgnoreAccentsInSearch, BoolKeyProto::RestorePositionBrowser => BoolKey::RestorePositionBrowser, BoolKeyProto::RestorePositionReviewer => BoolKey::RestorePositionReviewer, BoolKeyProto::ResetCountsBrowser => BoolKey::ResetCountsBrowser, BoolKeyProto::ResetCountsReviewer => BoolKey::ResetCountsReviewer, BoolKeyProto::RandomOrderReposition => BoolKey::RandomOrderReposition, BoolKeyProto::ShiftPositionOfExistingCards => BoolKey::ShiftPositionOfExistingCards, BoolKeyProto::RenderLatex => BoolKey::RenderLatex, BoolKeyProto::LoadBalancerEnabled => BoolKey::LoadBalancerEnabled, BoolKeyProto::FsrsShortTermWithStepsEnabled => BoolKey::FsrsShortTermWithStepsEnabled, BoolKeyProto::FsrsLegacyEvaluate => BoolKey::FsrsLegacyEvaluate, } } } impl From for StringKey { fn from(k: StringKeyProto) -> Self { match k { StringKeyProto::SetDueBrowser => StringKey::SetDueBrowser, StringKeyProto::SetDueReviewer => StringKey::SetDueReviewer, StringKeyProto::DefaultSearchText => StringKey::DefaultSearchText, StringKeyProto::CardStateCustomizer => StringKey::CardStateCustomizer, } } } impl crate::services::ConfigService for Collection { fn get_config_json(&mut self, input: generic::String) -> Result { let val: Option = self.get_config_optional(input.val.as_str()); val.or_not_found(input.val) .and_then(|v| serde_json::to_vec(&v).map_err(Into::into)) .map(Into::into) } fn set_config_json( &mut self, input: anki_proto::config::SetConfigJsonRequest, ) -> Result { let val: Value = serde_json::from_slice(&input.value_json)?; self.set_config_json(input.key.as_str(), &val, input.undoable) .map(Into::into) } fn set_config_json_no_undo( &mut self, input: anki_proto::config::SetConfigJsonRequest, ) -> Result<()> { let val: Value = serde_json::from_slice(&input.value_json)?; self.transact_no_undo(|col| col.set_config(input.key.as_str(), &val).map(|_| ())) } fn remove_config( &mut self, input: generic::String, ) -> Result { self.remove_config(input.val.as_str()).map(Into::into) } fn get_all_config(&mut self) -> Result { let conf = self.storage.get_all_config()?; serde_json::to_vec(&conf) .map_err(Into::into) .map(Into::into) } fn get_config_bool( &mut self, input: anki_proto::config::GetConfigBoolRequest, ) -> Result { Ok(generic::Bool { val: Collection::get_config_bool(self, input.key().into()), }) } fn set_config_bool( &mut self, input: anki_proto::config::SetConfigBoolRequest, ) -> Result { self.set_config_bool(input.key().into(), input.value, input.undoable) .map(Into::into) } fn get_config_string( &mut self, input: anki_proto::config::GetConfigStringRequest, ) -> Result { Ok(generic::String { val: Collection::get_config_string(self, input.key().into()), }) } fn set_config_string( &mut self, input: anki_proto::config::SetConfigStringRequest, ) -> Result { self.set_config_string(input.key().into(), &input.value, input.undoable) .map(Into::into) } fn get_preferences(&mut self) -> Result { Collection::get_preferences(self) } fn set_preferences( &mut self, input: anki_proto::config::Preferences, ) -> Result { self.set_preferences(input).map(Into::into) } } ================================================ FILE: rslib/src/backend/dbproxy.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::ankidroid::sql_value::Data; use anki_proto::ankidroid::DbResponse; use anki_proto::ankidroid::DbResult as ProtoDbResult; use anki_proto::ankidroid::SqlValue as pb_SqlValue; use rusqlite::params_from_iter; use rusqlite::types::FromSql; use rusqlite::types::FromSqlError; use rusqlite::types::ToSql; use rusqlite::types::ToSqlOutput; use rusqlite::types::ValueRef; use rusqlite::OptionalExtension; use serde::Deserialize; use serde::Serialize; use crate::ankidroid::db::next_sequence_number; use crate::ankidroid::db::trim_and_cache_remaining; use crate::prelude::*; use crate::storage::SqliteStorage; #[derive(Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub(super) enum DbRequest { Query { sql: String, args: Vec, first_row_only: bool, }, Begin, Commit, Rollback, ExecuteMany { sql: String, args: Vec>, }, } #[derive(Serialize)] #[serde(untagged)] pub(super) enum DbResult { Rows(Vec>), None, } #[derive(Serialize, Deserialize, Debug)] #[serde(untagged)] pub(crate) enum SqlValue { Null, String(String), Int(i64), Double(f64), Blob(Vec), } impl ToSql for SqlValue { fn to_sql(&self) -> std::result::Result, rusqlite::Error> { let val = match self { SqlValue::Null => ValueRef::Null, SqlValue::String(v) => ValueRef::Text(v.as_bytes()), SqlValue::Int(v) => ValueRef::Integer(*v), SqlValue::Double(v) => ValueRef::Real(*v), SqlValue::Blob(v) => ValueRef::Blob(v), }; Ok(ToSqlOutput::Borrowed(val)) } } impl From<&SqlValue> for anki_proto::ankidroid::SqlValue { fn from(item: &SqlValue) -> Self { match item { SqlValue::Null => pb_SqlValue { data: Option::None }, SqlValue::String(s) => pb_SqlValue { data: Some(Data::StringValue(s.to_string())), }, SqlValue::Int(i) => pb_SqlValue { data: Some(Data::LongValue(*i)), }, SqlValue::Double(d) => pb_SqlValue { data: Some(Data::DoubleValue(*d)), }, SqlValue::Blob(b) => pb_SqlValue { data: Some(Data::BlobValue(b.clone())), }, } } } fn row_to_proto(row: &[SqlValue]) -> anki_proto::ankidroid::Row { anki_proto::ankidroid::Row { fields: row .iter() .map(anki_proto::ankidroid::SqlValue::from) .collect(), } } fn rows_to_proto(rows: &[Vec]) -> anki_proto::ankidroid::DbResult { anki_proto::ankidroid::DbResult { rows: rows.iter().map(|r| row_to_proto(r)).collect(), } } impl FromSql for SqlValue { fn column_result(value: ValueRef<'_>) -> std::result::Result { let val = match value { ValueRef::Null => SqlValue::Null, ValueRef::Integer(i) => SqlValue::Int(i), ValueRef::Real(v) => SqlValue::Double(v), ValueRef::Text(v) => SqlValue::String(String::from_utf8_lossy(v).to_string()), ValueRef::Blob(v) => SqlValue::Blob(v.to_vec()), }; Ok(val) } } pub(crate) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result> { serde_json::to_vec(&db_command_bytes_inner(col, input)?).map_err(Into::into) } pub(super) fn db_command_bytes_inner(col: &mut Collection, input: &[u8]) -> Result { let req: DbRequest = serde_json::from_slice(input)?; let resp = match req { DbRequest::Query { sql, args, first_row_only, } => { update_state_after_modification(col, &sql); if first_row_only { db_query_row(&col.storage, &sql, &args)? } else { db_query(&col.storage, &sql, &args)? } } DbRequest::Begin => { col.storage.begin_trx()?; DbResult::None } DbRequest::Commit => { if col.state.modified_by_dbproxy { col.storage.set_modified_time(TimestampMillis::now())?; col.state.modified_by_dbproxy = false; } col.storage.commit_trx()?; DbResult::None } DbRequest::Rollback => { col.clear_caches(); col.storage.rollback_trx()?; DbResult::None } DbRequest::ExecuteMany { sql, args } => { update_state_after_modification(col, &sql); db_execute_many(&col.storage, &sql, &args)? } }; Ok(resp) } fn update_state_after_modification(col: &mut Collection, sql: &str) { if !is_dql(sql) { // println!("clearing undo+study due to {}", sql); col.update_state_after_dbproxy_modification(); } } /// Anything other than a select statement is false. fn is_dql(sql: &str) -> bool { let head: String = sql .trim_start() .chars() .take(10) .map(|c| c.to_ascii_lowercase()) .collect(); head.starts_with("select") } pub(crate) fn db_command_proto(col: &mut Collection, input: &[u8]) -> Result { let result = db_command_bytes_inner(col, input)?; let proto_resp = match result { DbResult::None => ProtoDbResult { rows: Vec::new() }, DbResult::Rows(rows) => rows_to_proto(&rows), }; let trimmed = trim_and_cache_remaining(col, proto_resp, next_sequence_number()); Ok(trimmed) } pub(super) fn db_query_row(ctx: &SqliteStorage, sql: &str, args: &[SqlValue]) -> Result { let mut stmt = ctx.db.prepare_cached(sql)?; let columns = stmt.column_count(); let row = stmt .query_row(params_from_iter(args), |row| { let mut orow = Vec::with_capacity(columns); for i in 0..columns { let v: SqlValue = row.get(i)?; orow.push(v); } Ok(orow) }) .optional()?; let rows = if let Some(row) = row { vec![row] } else { vec![] }; Ok(DbResult::Rows(rows)) } pub(super) fn db_query(ctx: &SqliteStorage, sql: &str, args: &[SqlValue]) -> Result { let mut stmt = ctx.db.prepare_cached(sql)?; let columns = stmt.column_count(); let res: std::result::Result>, rusqlite::Error> = stmt .query_map(params_from_iter(args), |row| { let mut orow = Vec::with_capacity(columns); for i in 0..columns { let v: SqlValue = row.get(i)?; orow.push(v); } Ok(orow) })? .collect(); Ok(DbResult::Rows(res?)) } pub(super) fn db_execute_many( ctx: &SqliteStorage, sql: &str, args: &[Vec], ) -> Result { let mut stmt = ctx.db.prepare_cached(sql)?; for params in args { stmt.execute(params_from_iter(params))?; } Ok(DbResult::None) } ================================================ FILE: rslib/src/backend/error.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::backend::backend_error::Kind; use crate::error::AnkiError; use crate::error::SyncErrorKind; use crate::prelude::*; impl AnkiError { pub fn into_protobuf(self, tr: &I18n) -> anki_proto::backend::BackendError { let message = self.message(tr); let help_page = self.help_page().map(|page| page as i32); let context = self.context(); let backtrace = self.backtrace(); let kind = match self { AnkiError::InvalidInput { .. } => Kind::InvalidInput, AnkiError::TemplateError { .. } => Kind::TemplateParse, AnkiError::DbError { .. } => Kind::DbError, AnkiError::NetworkError { .. } => Kind::NetworkError, AnkiError::SyncError { source } => source.kind.into(), AnkiError::Interrupted => Kind::Interrupted, AnkiError::CollectionNotOpen => Kind::InvalidInput, AnkiError::CollectionAlreadyOpen => Kind::InvalidInput, AnkiError::JsonError { .. } => Kind::JsonError, AnkiError::ProtoError { .. } => Kind::ProtoError, AnkiError::NotFound { .. } => Kind::NotFoundError, AnkiError::Deleted => Kind::Deleted, AnkiError::Existing => Kind::Exists, AnkiError::FilteredDeckError { .. } => Kind::FilteredDeckError, AnkiError::SearchError { .. } => Kind::SearchError, AnkiError::CardTypeError { .. } => Kind::CardTypeError, AnkiError::ParseNumError => Kind::InvalidInput, AnkiError::InvalidRegex { .. } => Kind::InvalidInput, AnkiError::UndoEmpty => Kind::UndoEmpty, AnkiError::MultipleNotetypesSelected => Kind::InvalidInput, AnkiError::DatabaseCheckRequired => Kind::InvalidInput, AnkiError::CustomStudyError { .. } => Kind::CustomStudyError, AnkiError::ImportError { .. } => Kind::ImportError, AnkiError::FileIoError { .. } => Kind::IoError, AnkiError::MediaCheckRequired => Kind::InvalidInput, AnkiError::InvalidId => Kind::InvalidInput, AnkiError::InvalidMethodIndex | AnkiError::InvalidServiceIndex | AnkiError::FsrsParamsInvalid | AnkiError::FsrsUnableToDetermineDesiredRetention | AnkiError::FsrsInsufficientData => Kind::InvalidInput, #[cfg(windows)] AnkiError::WindowsError { .. } => Kind::OsError, AnkiError::SchedulerUpgradeRequired => Kind::SchedulerUpgradeRequired, AnkiError::FsrsInsufficientReviews { .. } => Kind::InvalidInput, AnkiError::InvalidCertificateFormat => Kind::InvalidCertificateFormat, }; anki_proto::backend::BackendError { kind: kind as i32, message, help_page, context, backtrace, } } } impl From for Kind { fn from(err: SyncErrorKind) -> Self { match err { SyncErrorKind::AuthFailed => Kind::SyncAuthError, SyncErrorKind::ServerMessage => Kind::SyncServerMessage, _ => Kind::SyncOtherError, } } } ================================================ FILE: rslib/src/backend/i18n.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::generic; use anki_proto::i18n::FormatTimespanRequest; use anki_proto::i18n::I18nResourcesRequest; use anki_proto::i18n::TranslateStringRequest; use super::Backend; use crate::i18n::service; use crate::prelude::*; // We avoid delegating to collection for these, as tr doesn't require a // collection lock. impl crate::services::BackendI18nService for Backend { fn translate_string(&self, input: TranslateStringRequest) -> Result { service::translate_string(&self.tr, input) } fn format_timespan(&self, input: FormatTimespanRequest) -> Result { service::format_timespan(&self.tr, input) } fn i18n_resources(&self, input: I18nResourcesRequest) -> Result { service::i18n_resources(&self.tr, input) } } ================================================ FILE: rslib/src/backend/import_export.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::path::Path; use super::Backend; use crate::import_export::package::import_colpkg; use crate::prelude::*; use crate::services::BackendImportExportService; impl BackendImportExportService for Backend { fn export_collection_package( &self, input: anki_proto::import_export::ExportCollectionPackageRequest, ) -> Result<()> { self.abort_media_sync_and_wait(); let mut guard = self.lock_open_collection()?; let col_inner = guard.take().unwrap(); col_inner.export_colpkg(input.out_path, input.include_media, input.legacy) } fn import_collection_package( &self, input: anki_proto::import_export::ImportCollectionPackageRequest, ) -> Result<()> { let _guard = self.lock_closed_collection()?; import_colpkg( &input.backup_path, &input.col_path, Path::new(&input.media_folder), Path::new(&input.media_db), self.new_progress_handler(), ) } } ================================================ FILE: rslib/src/backend/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod adding; mod ankidroid; mod ankihub; mod ankiweb; mod card_rendering; mod collection; mod config; pub(crate) mod dbproxy; mod error; mod i18n; mod import_export; mod ops; mod sync; use std::ops::Deref; use std::result; use std::sync::Arc; use std::sync::Mutex; use std::sync::OnceLock; use std::thread::JoinHandle; use futures::future::AbortHandle; use prost::Message; use reqwest::Client; use tokio::runtime; use tokio::runtime::Runtime; use crate::backend::dbproxy::db_command_bytes; use crate::backend::sync::SyncState; use crate::prelude::*; use crate::progress::Progress; use crate::progress::ProgressState; use crate::progress::ThrottlingProgressHandler; #[derive(Clone)] #[repr(transparent)] pub struct Backend(Arc); impl Deref for Backend { type Target = BackendInner; fn deref(&self) -> &Self::Target { &self.0 } } pub struct BackendInner { col: Mutex>, pub(crate) tr: I18n, server: bool, sync_abort: Mutex>, progress_state: Arc>, runtime: OnceLock, state: Mutex, backup_task: Mutex>>>, media_sync_task: Mutex>>>, web_client: Mutex>, } #[derive(Default)] struct BackendState { sync: SyncState, } pub fn init_backend(init_msg: &[u8]) -> result::Result { let input: anki_proto::backend::BackendInit = match anki_proto::backend::BackendInit::decode(init_msg) { Ok(req) => req, Err(_) => return Err("couldn't decode init request".into()), }; let tr = I18n::new(&input.preferred_langs); Ok(Backend::new(tr, input.server)) } impl Backend { pub fn new(tr: I18n, server: bool) -> Backend { Backend(Arc::new(BackendInner { col: Mutex::new(None), tr, server, sync_abort: Mutex::new(None), progress_state: Arc::new(Mutex::new(ProgressState { want_abort: false, last_progress: None, })), runtime: OnceLock::new(), state: Mutex::new(BackendState::default()), backup_task: Mutex::new(None), media_sync_task: Mutex::new(None), web_client: Mutex::new(None), })) } pub fn i18n(&self) -> &I18n { &self.tr } pub fn run_db_command_bytes(&self, input: &[u8]) -> result::Result, Vec> { self.db_command(input).map_err(|err| { let backend_err = err.into_protobuf(&self.tr); let mut bytes = Vec::new(); backend_err.encode(&mut bytes).unwrap(); bytes }) } /// If collection is open, run the provided closure while holding /// the mutex. /// If collection is not open, return an error. pub(crate) fn with_col(&self, func: F) -> Result where F: FnOnce(&mut Collection) -> Result, { func( self.col .lock() .unwrap() .as_mut() .ok_or(AnkiError::CollectionNotOpen)?, ) } fn runtime_handle(&self) -> runtime::Handle { self.runtime .get_or_init(|| { runtime::Builder::new_multi_thread() .worker_threads(1) .enable_all() .build() .unwrap() }) .handle() .clone() } #[cfg(feature = "rustls")] fn set_custom_certificate_inner(&self, cert_str: String) -> Result<()> { use std::io::Cursor; use std::io::Read; use reqwest::Certificate; let mut web_client = self.web_client.lock().unwrap(); if cert_str.is_empty() { let _ = web_client.insert(Client::builder().http1_only().build().unwrap()); return Ok(()); } if rustls_pemfile::read_all(Cursor::new(cert_str.as_bytes()).by_ref()).count() != 1 { return Err(AnkiError::InvalidCertificateFormat); } if let Ok(certificate) = Certificate::from_pem(cert_str.as_bytes()) { if let Ok(new_client) = Client::builder() .use_rustls_tls() .add_root_certificate(certificate) .http1_only() .build() { let _ = web_client.insert(new_client); return Ok(()); } } Err(AnkiError::InvalidCertificateFormat) } fn web_client(&self) -> Client { // currently limited to http1, as nginx doesn't support http2 proxies let mut web_client = self.web_client.lock().unwrap(); web_client .get_or_insert_with(|| Client::builder().http1_only().build().unwrap()) .clone() } fn db_command(&self, input: &[u8]) -> Result> { self.with_col(|col| db_command_bytes(col, input)) } /// Useful for operations that function with a closed collection, such as /// a colpkg import. For collection operations, you can use /// [Collection::new_progress_handler] instead. pub(crate) fn new_progress_handler + Default + Clone>( &self, ) -> ThrottlingProgressHandler

{ ThrottlingProgressHandler::new(self.progress_state.clone()) } } ================================================ FILE: rslib/src/backend/ops.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::ops::OpChanges; use crate::prelude::*; use crate::undo::UndoOutput; use crate::undo::UndoStatus; impl From for anki_proto::collection::OpChanges { fn from(c: OpChanges) -> Self { anki_proto::collection::OpChanges { card: c.changes.card, note: c.changes.note, deck: c.changes.deck, tag: c.changes.tag, notetype: c.changes.notetype, config: c.changes.config, deck_config: c.changes.deck_config, mtime: c.changes.mtime, browser_table: c.requires_browser_table_redraw(), browser_sidebar: c.requires_browser_sidebar_redraw(), note_text: c.requires_note_text_redraw(), study_queues: c.requires_study_queue_rebuild(), } } } impl UndoStatus { pub(crate) fn into_protobuf(self, tr: &I18n) -> anki_proto::collection::UndoStatus { anki_proto::collection::UndoStatus { undo: self.undo.map(|op| op.describe(tr)).unwrap_or_default(), redo: self.redo.map(|op| op.describe(tr)).unwrap_or_default(), last_step: self.last_step as u32, } } } impl From> for anki_proto::collection::OpChanges { fn from(o: OpOutput<()>) -> Self { o.changes.into() } } impl From> for anki_proto::collection::OpChangesWithCount { fn from(out: OpOutput) -> Self { anki_proto::collection::OpChangesWithCount { count: out.output as u32, changes: Some(out.changes.into()), } } } impl From> for anki_proto::collection::OpChangesWithId { fn from(out: OpOutput) -> Self { anki_proto::collection::OpChangesWithId { id: out.output, changes: Some(out.changes.into()), } } } impl OpOutput { pub(crate) fn into_protobuf(self, tr: &I18n) -> anki_proto::collection::OpChangesAfterUndo { anki_proto::collection::OpChangesAfterUndo { changes: Some(self.changes.into()), operation: self.output.undone_op.describe(tr), reverted_to_timestamp: self.output.reverted_to.0, new_status: Some(self.output.new_undo_status.into_protobuf(tr)), counter: self.output.counter as u32, } } } ================================================ FILE: rslib/src/backend/sync.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::sync::sync_status_response::Required; use anki_proto::sync::MediaSyncStatusResponse; use anki_proto::sync::SyncStatusResponse; use futures::future::AbortHandle; use futures::future::AbortRegistration; use futures::future::Abortable; use reqwest::Url; use super::Backend; use crate::prelude::*; use crate::services::BackendCollectionService; use crate::sync::collection::normal::ClientSyncState; use crate::sync::collection::normal::SyncActionRequired; use crate::sync::collection::normal::SyncOutput; use crate::sync::collection::progress::sync_abort; use crate::sync::collection::status::online_sync_status_check; use crate::sync::http_client::HttpSyncClient; use crate::sync::login::sync_login; use crate::sync::login::SyncAuth; #[derive(Default)] pub(super) struct SyncState { remote_sync_status: RemoteSyncStatus, media_sync_abort: Option, } #[derive(Default, Debug)] pub(super) struct RemoteSyncStatus { pub last_check: TimestampSecs, pub last_response: Required, } impl RemoteSyncStatus { pub(super) fn update(&mut self, required: Required) { self.last_check = TimestampSecs::now(); self.last_response = required } } impl From for anki_proto::sync::SyncCollectionResponse { fn from(o: SyncOutput) -> Self { anki_proto::sync::SyncCollectionResponse { host_number: o.host_number, server_message: o.server_message, new_endpoint: o.new_endpoint, required: match o.required { SyncActionRequired::NoChanges => { anki_proto::sync::sync_collection_response::ChangesRequired::NoChanges as i32 } SyncActionRequired::FullSyncRequired { upload_ok, download_ok, } => { if !upload_ok { anki_proto::sync::sync_collection_response::ChangesRequired::FullDownload as i32 } else if !download_ok { anki_proto::sync::sync_collection_response::ChangesRequired::FullUpload as i32 } else { anki_proto::sync::sync_collection_response::ChangesRequired::FullSync as i32 } } SyncActionRequired::NormalSyncRequired => { anki_proto::sync::sync_collection_response::ChangesRequired::NormalSync as i32 } }, server_media_usn: o.server_media_usn.0, } } } impl TryFrom for SyncAuth { type Error = AnkiError; fn try_from(value: anki_proto::sync::SyncAuth) -> std::result::Result { Ok(SyncAuth { hkey: value.hkey, endpoint: value .endpoint .map(|v| { Url::try_from(v.as_str()) // Without the next line, incomplete URLs like computer.local without the http:// // are detected but URLs like computer.local:8000 are not. // By calling join() now, these URLs are detected too and later code that // uses and unwraps the result of join() doesn't panic .and_then(|x| x.join("./")) .or_invalid("Invalid sync server specified. Please check the preferences.") }) .transpose()?, io_timeout_secs: value.io_timeout_secs, }) } } impl crate::services::BackendSyncService for Backend { fn sync_media(&self, input: anki_proto::sync::SyncAuth) -> Result<()> { let auth = input.try_into()?; self.sync_media_in_background(auth, None) } fn media_sync_status(&self) -> Result { self.get_media_sync_status() } fn abort_sync(&self) -> Result<()> { if let Some(handle) = self.sync_abort.lock().unwrap().take() { handle.abort(); } Ok(()) } /// Abort the media sync. Does not wait for completion. fn abort_media_sync(&self) -> Result<()> { let guard = self.state.lock().unwrap(); if let Some(handle) = &guard.sync.media_sync_abort { handle.abort(); } Ok(()) } fn sync_login( &self, input: anki_proto::sync::SyncLoginRequest, ) -> Result { self.sync_login_inner(input) } fn sync_status( &self, input: anki_proto::sync::SyncAuth, ) -> Result { self.sync_status_inner(input) } fn sync_collection( &self, input: anki_proto::sync::SyncCollectionRequest, ) -> Result { self.sync_collection_inner(input) } fn full_upload_or_download( &self, input: anki_proto::sync::FullUploadOrDownloadRequest, ) -> Result<()> { self.full_sync_inner( input.auth.or_invalid("missing auth")?, input.server_usn.map(Usn), input.upload, )?; Ok(()) } fn set_custom_certificate( &self, _input: anki_proto::generic::String, ) -> Result { #[cfg(feature = "rustls")] return Ok(self.set_custom_certificate_inner(_input.val).is_ok().into()); #[cfg(not(feature = "rustls"))] return Ok(false.into()); } } impl Backend { /// Return a handle for regular (non-media) syncing. #[allow(clippy::type_complexity)] fn sync_abort_handle( &self, ) -> Result<( scopeguard::ScopeGuard, AbortRegistration, )> { let (abort_handle, abort_reg) = AbortHandle::new_pair(); // Register the new abort_handle. self.sync_abort.lock().unwrap().replace(abort_handle); // Clear the abort handle after the caller is done and drops the guard. let guard = scopeguard::guard(self.clone(), |backend| { backend.sync_abort.lock().unwrap().take(); }); Ok((guard, abort_reg)) } pub(super) fn sync_media_in_background( &self, auth: SyncAuth, server_usn: Option, ) -> Result<()> { let mut task = self.media_sync_task.lock().unwrap(); if let Some(handle) = &*task { if !handle.is_finished() { // already running return Ok(()); } else { // clean up task.take(); } } let backend = self.clone(); *task = Some(std::thread::spawn(move || { backend.sync_media_blocking(auth, server_usn) })); Ok(()) } /// True if active. Will throw if terminated with error. fn get_media_sync_status(&self) -> Result { let mut task = self.media_sync_task.lock().unwrap(); let active = if let Some(handle) = &*task { if !handle.is_finished() { true } else { match task.take().unwrap().join() { Ok(inner_result) => inner_result?, Err(panic) => invalid_input!("{:?}", panic), }; false } } else { false }; let progress = self.latest_progress()?; let progress = if let Some(anki_proto::collection::progress::Value::MediaSync(progress)) = progress.value { Some(progress) } else { None }; Ok(MediaSyncStatusResponse { active, progress }) } pub(super) fn sync_media_blocking( &self, auth: SyncAuth, server_usn: Option, ) -> Result<()> { // abort handle let (abort_handle, abort_reg) = AbortHandle::new_pair(); self.state.lock().unwrap().sync.media_sync_abort = Some(abort_handle); // start the sync let (mgr, progress) = { let mut col = self.col.lock().unwrap(); let col = col.as_mut().unwrap(); (col.media()?, col.new_progress_handler()) }; let rt = self.runtime_handle(); let sync_fut = mgr.sync_media(progress, auth, self.web_client().clone(), server_usn); let abortable_sync = Abortable::new(sync_fut, abort_reg); let result = rt.block_on(abortable_sync); // clean up the handle self.state.lock().unwrap().sync.media_sync_abort.take(); // return result match result { Ok(sync_result) => sync_result, Err(_) => { // aborted sync Err(AnkiError::Interrupted) } } } /// Abort the media sync. Won't return until aborted. pub(super) fn abort_media_sync_and_wait(&self) { let guard = self.state.lock().unwrap(); if let Some(handle) = &guard.sync.media_sync_abort { handle.abort(); self.progress_state.lock().unwrap().want_abort = true; } drop(guard); // block until it aborts while self.state.lock().unwrap().sync.media_sync_abort.is_some() { std::thread::sleep(std::time::Duration::from_millis(100)); self.progress_state.lock().unwrap().want_abort = true; } } pub(super) fn sync_login_inner( &self, input: anki_proto::sync::SyncLoginRequest, ) -> Result { let (_guard, abort_reg) = self.sync_abort_handle()?; let rt = self.runtime_handle(); let sync_fut = sync_login( input.username, input.password, input.endpoint.clone(), self.web_client(), ); let abortable_sync = Abortable::new(sync_fut, abort_reg); let ret = match rt.block_on(abortable_sync) { Ok(sync_result) => sync_result, Err(_) => Err(AnkiError::Interrupted), }; ret.map(|a| anki_proto::sync::SyncAuth { hkey: a.hkey, endpoint: input.endpoint, io_timeout_secs: None, }) } pub(super) fn sync_status_inner( &self, input: anki_proto::sync::SyncAuth, ) -> Result { // any local changes mean we can skip the network round-trip let req = self.with_col(|col| col.sync_status_offline())?; if req != Required::NoChanges { return Ok(status_response_from_required(req)); } // return cached server response if only a short time has elapsed { let guard = self.state.lock().unwrap(); if guard.sync.remote_sync_status.last_check.elapsed_secs() < 300 { return Ok(status_response_from_required( guard.sync.remote_sync_status.last_response, )); } } // fetch and cache result let auth = input.try_into()?; let rt = self.runtime_handle(); let time_at_check_begin = TimestampSecs::now(); let local = self.with_col(|col| col.sync_meta())?; let mut client = HttpSyncClient::new(auth, self.web_client()); let state = rt.block_on(online_sync_status_check(local, &mut client))?; { let mut guard = self.state.lock().unwrap(); // On startup, the sync status check will block on network access, and then // automatic syncing begins, taking hold of the mutex. By the time // we reach here, our network status may be out of date, // so we discard it if stale. if guard.sync.remote_sync_status.last_check < time_at_check_begin { guard.sync.remote_sync_status.last_check = time_at_check_begin; guard.sync.remote_sync_status.last_response = state.required.into(); } } Ok(state.into()) } pub(super) fn sync_collection_inner( &self, input: anki_proto::sync::SyncCollectionRequest, ) -> Result { let auth: SyncAuth = input.auth.or_invalid("missing auth")?.try_into()?; let (_guard, abort_reg) = self.sync_abort_handle()?; let rt = self.runtime_handle(); let client = self.web_client(); let auth2 = auth.clone(); let ret = self.with_col(|col| { let sync_fut = col.normal_sync(auth.clone(), client.clone()); let abortable_sync = Abortable::new(sync_fut, abort_reg); match rt.block_on(abortable_sync) { Ok(sync_result) => sync_result, Err(_) => { // if the user aborted, we'll need to clean up the transaction col.storage.rollback_trx()?; // and tell AnkiWeb to clean up let _handle = std::thread::spawn(move || { let _ = rt.block_on(sync_abort(auth, client)); }); Err(AnkiError::Interrupted) } } }); let output: SyncOutput = ret?; if input.sync_media && !matches!(output.required, SyncActionRequired::FullSyncRequired { .. }) { self.sync_media_in_background(auth2, Some(output.server_media_usn))?; } self.state .lock() .unwrap() .sync .remote_sync_status .update(output.required.into()); Ok(output.into()) } pub(super) fn full_sync_inner( &self, input: anki_proto::sync::SyncAuth, server_usn: Option, upload: bool, ) -> Result<()> { let auth: SyncAuth = input.try_into()?; let auth2 = auth.clone(); self.abort_media_sync_and_wait(); let rt = self.runtime_handle(); let mut col = self.col.lock().unwrap(); if col.is_none() { return Err(AnkiError::CollectionNotOpen); } let col_inner = col.take().unwrap(); let (_guard, abort_reg) = self.sync_abort_handle()?; let mut builder = col_inner.as_builder(); let result = if upload { let sync_fut = col_inner.full_upload(auth, self.web_client().clone()); let abortable_sync = Abortable::new(sync_fut, abort_reg); rt.block_on(abortable_sync) } else { let sync_fut = col_inner.full_download(auth, self.web_client().clone()); let abortable_sync = Abortable::new(sync_fut, abort_reg); rt.block_on(abortable_sync) }; // ensure re-opened regardless of outcome col.replace(builder.build()?); let result = match result { Ok(sync_result) => { if sync_result.is_ok() { self.state .lock() .unwrap() .sync .remote_sync_status .update(Required::NoChanges); } sync_result } Err(_) => Err(AnkiError::Interrupted), }; if result.is_ok() && server_usn.is_some() { self.sync_media_in_background(auth2, server_usn)?; } result } } fn status_response_from_required(required: Required) -> SyncStatusResponse { SyncStatusResponse { required: required.into(), new_endpoint: None, } } impl From for SyncStatusResponse { fn from(r: ClientSyncState) -> Self { SyncStatusResponse { required: Required::from(r.required).into(), new_endpoint: r.new_endpoint, } } } impl From for Required { fn from(r: SyncActionRequired) -> Self { match r { SyncActionRequired::NoChanges => Required::NoChanges, SyncActionRequired::FullSyncRequired { .. } => Required::FullSync, SyncActionRequired::NormalSyncRequired => Required::NormalSync, } } } ================================================ FILE: rslib/src/browser_table.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::sync::Arc; use fsrs::FSRS; use fsrs::FSRS5_DEFAULT_DECAY; use itertools::Itertools; use strum::Display; use strum::EnumIter; use strum::EnumString; use strum::IntoEnumIterator; use crate::card::CardQueue; use crate::card::CardType; use crate::card_rendering::prettify_av_tags; use crate::notetype::CardTemplate; use crate::notetype::NotetypeKind; use crate::prelude::*; use crate::scheduler::timespan::time_span; use crate::scheduler::timing::SchedTimingToday; use crate::template::RenderedNode; use crate::text::html_to_text_line; #[derive(Debug, PartialEq, Eq, Clone, Copy, Display, EnumIter, EnumString)] #[strum(serialize_all = "camelCase")] #[derive(Default)] pub enum Column { #[strum(serialize = "")] #[default] Custom, Answer, CardMod, #[strum(serialize = "template")] Cards, Deck, #[strum(serialize = "cardDue")] Due, #[strum(serialize = "cardEase")] Ease, #[strum(serialize = "cardLapses")] Lapses, #[strum(serialize = "cardIvl")] Interval, #[strum(serialize = "noteCrt")] NoteCreation, NoteMod, #[strum(serialize = "note")] Notetype, OriginalPosition, Question, #[strum(serialize = "cardReps")] Reps, #[strum(serialize = "noteFld")] SortField, #[strum(serialize = "noteTags")] Tags, Stability, Difficulty, Retrievability, } struct RowContext { notes_mode: bool, cards: Vec, note: Note, notetype: Arc, deck: Arc, original_deck: Option>, tr: I18n, timing: SchedTimingToday, render_context: RenderContext, } enum RenderContext { // The answer string needs the question string, but not the other way around, // so only build the answer string when needed. Ok { question: String, answer_nodes: Vec, }, Err(String), Unset, } fn card_render_required(columns: &[Column]) -> bool { columns .iter() .any(|c| matches!(c, Column::Question | Column::Answer)) } impl Card { fn is_new_type_or_queue(&self) -> bool { self.queue == CardQueue::New || self.ctype == CardType::New } fn is_filtered_deck(&self) -> bool { self.original_deck_id != DeckId(0) } /// Returns true if the card can not be due as it's buried or suspended. fn is_undue_queue(&self) -> bool { (self.queue as i8) < 0 } /// Returns true if the card has a due date in terms of days. fn is_due_in_days(&self) -> bool { self.ctype != CardType::New && self.original_or_current_due() <= 365_000 // keep consistent with SQL || matches!(self.queue, CardQueue::DayLearn | CardQueue::Review) || (self.ctype == CardType::Review && self.is_undue_queue()) } /// Returns the card's due date as a timestamp if it has one. fn due_time(&self, timing: &SchedTimingToday) -> Option { if self.queue == CardQueue::Learn { Some(TimestampSecs(self.original_or_current_due() as i64)) } else if self.is_due_in_days() { Some( TimestampSecs::now().adding_secs( (self.original_or_current_due() as i64 - timing.days_elapsed as i64) .saturating_mul(86400), ), ) } else { None } } /// If last_review_date isn't stored in the card, this uses card.due and /// card.ivl to infer the elapsed time, which won't be accurate if /// 'set due date' or an add-on has changed the due date. pub(crate) fn seconds_since_last_review(&self, timing: &SchedTimingToday) -> Option { if let Some(last_review_time) = self.last_review_time { Some(timing.now.elapsed_secs_since(last_review_time) as u32) } else if self.is_due_in_days() { self.due_time(timing).map(|due| { (due.adding_secs(-86_400 * self.interval as i64) .elapsed_secs()) as u32 }) } else { let last_review_time = TimestampSecs(self.original_or_current_due() as i64); Some(timing.now.elapsed_secs_since(last_review_time) as u32) } } } impl Note { fn is_marked(&self) -> bool { self.tags .iter() .any(|tag| tag.eq_ignore_ascii_case("marked")) } } impl Column { pub fn cards_mode_label(self, tr: &I18n) -> String { match self { Self::Answer => tr.browsing_answer(), Self::CardMod => tr.search_card_modified(), Self::Cards => tr.card_stats_card_template(), Self::Deck => tr.decks_deck(), Self::Due => tr.statistics_due_date(), Self::Custom => tr.browsing_addon(), Self::Ease => tr.browsing_ease(), Self::Interval => tr.browsing_interval(), Self::Lapses => tr.scheduling_lapses(), Self::NoteCreation => tr.browsing_created(), Self::NoteMod => tr.search_note_modified(), Self::Notetype => tr.card_stats_note_type(), Self::OriginalPosition => tr.card_stats_new_card_position(), Self::Question => tr.browsing_question(), Self::Reps => tr.scheduling_reviews(), Self::SortField => tr.browsing_sort_field(), Self::Tags => tr.editing_tags(), Self::Stability => tr.card_stats_fsrs_stability(), Self::Difficulty => tr.card_stats_fsrs_difficulty(), Self::Retrievability => tr.card_stats_fsrs_retrievability(), } .into() } pub fn notes_mode_label(self, tr: &I18n) -> String { match self { Self::Cards => tr.editing_cards(), Self::Ease => tr.browsing_average_ease(), Self::Interval => tr.browsing_average_interval(), _ => return self.cards_mode_label(tr), } .into() } pub fn cards_mode_tooltip(self, tr: &I18n) -> String { match self { Self::Answer => tr.browsing_tooltip_answer(), Self::CardMod => tr.browsing_tooltip_card_modified(), Self::Cards => tr.browsing_tooltip_card(), Self::NoteMod => tr.browsing_tooltip_note_modified(), Self::Notetype => tr.browsing_tooltip_notetype(), Self::Question => tr.browsing_tooltip_question(), _ => "".into(), } .into() } pub fn notes_mode_tooltip(self, tr: &I18n) -> String { match self { Self::Cards => tr.browsing_tooltip_cards(), _ => return self.cards_mode_label(tr), } .into() } pub fn default_cards_order(self) -> anki_proto::search::browser_columns::Sorting { self.default_order(false) } pub fn default_notes_order(self) -> anki_proto::search::browser_columns::Sorting { self.default_order(true) } fn default_order(self, notes: bool) -> anki_proto::search::browser_columns::Sorting { use anki_proto::search::browser_columns::Sorting; match self { Column::Question | Column::Answer | Column::Custom => Sorting::None, Column::SortField | Column::Tags | Column::Notetype | Column::Deck => { Sorting::Ascending } Column::CardMod | Column::Cards | Column::Due | Column::Ease | Column::Lapses | Column::Interval | Column::NoteCreation | Column::NoteMod | Column::OriginalPosition | Column::Reps => Sorting::Descending, Column::Stability | Column::Difficulty | Column::Retrievability => { if notes { Sorting::None } else { Sorting::Descending } } } } pub fn uses_cell_font(self) -> bool { matches!(self, Self::Question | Self::Answer | Self::SortField) } pub fn alignment(self) -> anki_proto::search::browser_columns::Alignment { use anki_proto::search::browser_columns::Alignment; match self { Self::Question | Self::Answer | Self::Cards | Self::Deck | Self::SortField | Self::Notetype | Self::Tags => Alignment::Start, _ => Alignment::Center, } } } impl Collection { pub fn all_browser_columns(&self) -> anki_proto::search::BrowserColumns { let mut columns: Vec = Column::iter() .filter(|&c| c != Column::Custom) .map(|c| c.to_pb_column(&self.tr)) .collect(); columns.sort_by(|c1, c2| c1.cards_mode_label.cmp(&c2.cards_mode_label)); anki_proto::search::BrowserColumns { columns } } pub fn browser_row_for_id(&mut self, id: i64) -> Result { let notes_mode = self.get_config_bool(BoolKey::BrowserTableShowNotesMode); let columns = Arc::clone( self.state .active_browser_columns .as_ref() .or_invalid("Active browser columns not set.")?, ); RowContext::new(self, id, notes_mode, card_render_required(&columns))?.browser_row(&columns) } fn get_note_maybe_with_fields(&self, id: NoteId, _with_fields: bool) -> Result { // todo: After note.sort_field has been modified so it can be displayed in the // browser, we can update note_field_str() and only load the note with // fields if a card render is necessary (see #1082). if true { self.storage.get_note(id)? } else { self.storage.get_note_without_fields(id)? } .or_not_found(id) } } impl RenderContext { fn new(col: &mut Collection, card: &Card, note: &Note, notetype: &Notetype) -> Self { match notetype .get_template(card.template_idx) .and_then(|template| col.render_card(note, card, notetype, template, true, true)) { Ok(render) => RenderContext::Ok { question: rendered_nodes_to_str(&render.qnodes), answer_nodes: render.anodes, }, Err(err) => RenderContext::Err(err.message(&col.tr)), } } fn side_str(&self, is_answer: bool) -> String { let back; let html = match self { Self::Ok { question, answer_nodes, } => { if is_answer { back = rendered_nodes_to_str(answer_nodes); back.strip_prefix(question).unwrap_or(&back) } else { question } } Self::Err(err) => err, Self::Unset => "Invalid input: RenderContext unset", }; html_to_text_line(html, true).into() } } fn rendered_nodes_to_str(nodes: &[RenderedNode]) -> String { let txt = nodes .iter() .map(|node| match node { RenderedNode::Text { text } => text, RenderedNode::Replacement { current_text, .. } => current_text, }) .join(""); prettify_av_tags(txt) } impl RowContext { fn new( col: &mut Collection, id: i64, notes_mode: bool, with_card_render: bool, ) -> Result { let cards; let note; if notes_mode { note = col .get_note_maybe_with_fields(NoteId(id), with_card_render) .map_err(|e| match e { AnkiError::NotFound { .. } => AnkiError::Deleted, _ => e, })?; cards = col.storage.all_cards_of_note(note.id)?; if cards.is_empty() { return Err(AnkiError::DatabaseCheckRequired); } } else { cards = vec![col .storage .get_card(CardId(id))? .ok_or(AnkiError::Deleted)?]; note = col.get_note_maybe_with_fields(cards[0].note_id, with_card_render)?; } let notetype = col .get_notetype(note.notetype_id)? .or_not_found(note.notetype_id)?; let deck = col .get_deck(cards[0].deck_id)? .or_not_found(cards[0].deck_id)?; let original_deck = if cards[0].original_deck_id.0 != 0 { Some( col.get_deck(cards[0].original_deck_id)? .or_not_found(cards[0].original_deck_id)?, ) } else { None }; let timing = col.timing_today()?; let render_context = if with_card_render { RenderContext::new(col, &cards[0], ¬e, ¬etype) } else { RenderContext::Unset }; Ok(RowContext { notes_mode, cards, note, notetype, deck, original_deck, tr: col.tr.clone(), timing, render_context, }) } fn browser_row(&self, columns: &[Column]) -> Result { Ok(anki_proto::search::BrowserRow { cells: columns .iter() .map(|&column| self.get_cell(column)) .collect::>()?, color: self.get_row_color() as i32, font_name: self.get_row_font_name()?, font_size: self.get_row_font_size()?, }) } fn get_cell(&self, column: Column) -> Result { Ok(anki_proto::search::browser_row::Cell { text: self.get_cell_text(column)?, is_rtl: self.get_is_rtl(column), elide_mode: self.get_elide_mode(column) as i32, }) } fn get_cell_text(&self, column: Column) -> Result { Ok(match column { Column::Question => self.render_context.side_str(false), Column::Answer => self.render_context.side_str(true), Column::Deck => self.deck_str(), Column::Due => self.due_str(), Column::Ease => self.ease_str(), Column::Interval => self.interval_str(), Column::Lapses => self.cards.iter().map(|c| c.lapses).sum::().to_string(), Column::CardMod => self.card_mod_str(), Column::Reps => self.cards.iter().map(|c| c.reps).sum::().to_string(), Column::Cards => self.cards_str()?, Column::NoteCreation => self.note_creation_str(), Column::SortField => self.note_field_str(), Column::NoteMod => self.note.mtime.date_and_time_string(), Column::OriginalPosition => self.card_original_position(), Column::Tags => self.note.tags.join(" "), Column::Notetype => self.notetype.name.to_owned(), Column::Stability => self.fsrs_stability_str(), Column::Difficulty => self.fsrs_difficulty_str(), Column::Retrievability => self.fsrs_retrievability_str(), Column::Custom => "".to_string(), }) } fn card_original_position(&self) -> String { let card = &self.cards[0]; if let Some(pos) = &card.original_position { pos.to_string() } else if card.ctype == CardType::New { card.due.to_string() } else { String::new() } } fn note_creation_str(&self) -> String { TimestampMillis(self.note.id.into()) .as_secs() .date_and_time_string() } fn note_field_str(&self) -> String { let index = self.notetype.config.sort_field_idx as usize; html_to_text_line(&self.note.fields()[index], true).into() } fn get_is_rtl(&self, column: Column) -> bool { match column { Column::SortField => { let index = self.notetype.config.sort_field_idx as usize; self.notetype.fields[index].config.rtl } _ => false, } } fn get_elide_mode( &self, column: Column, ) -> anki_proto::search::browser_row::cell::TextElideMode { use anki_proto::search::browser_row::cell::TextElideMode; match column { Column::Deck => TextElideMode::ElideMiddle, _ => TextElideMode::ElideRight, } } fn template(&self) -> Result<&CardTemplate> { self.notetype.get_template(self.cards[0].template_idx) } fn due_str(&self) -> String { if self.notes_mode { self.note_due_str() } else { self.card_due_str() } } fn card_due_str(&self) -> String { let due = if self.cards[0].is_filtered_deck() { self.tr.browsing_filtered() } else if self.cards[0].is_new_type_or_queue() { self.tr.statistics_due_for_new_card(self.cards[0].due) } else if let Some(time) = self.cards[0].due_time(&self.timing) { time.date_string().into() } else { return "".into(); }; if self.cards[0].is_undue_queue() { format!("({due})") } else { due.into() } } fn fsrs_stability_str(&self) -> String { self.cards[0] .memory_state .as_ref() .map(|s| time_span(s.stability * 86400.0, &self.tr, false)) .unwrap_or_default() } fn fsrs_difficulty_str(&self) -> String { self.cards[0] .memory_state .as_ref() .map(|s| format!("{:.0}%", s.difficulty() * 100.0)) .unwrap_or_default() } fn fsrs_retrievability_str(&self) -> String { self.cards[0] .memory_state .as_ref() .zip(self.cards[0].seconds_since_last_review(&self.timing)) .zip(Some(self.cards[0].decay.unwrap_or(FSRS5_DEFAULT_DECAY))) .map(|((state, seconds), decay)| { let r = FSRS::new(None).unwrap().current_retrievability_seconds( (*state).into(), seconds, decay, ); format!("{:.0}%", r * 100.) }) .unwrap_or_default() } /// Returns the due date of the next due card that is not in a filtered /// deck, new, suspended or buried or the empty string if there is no /// such card. fn note_due_str(&self) -> String { self.cards .iter() .filter(|c| !(c.is_filtered_deck() || c.is_new_type_or_queue() || c.is_undue_queue())) .filter_map(|c| c.due_time(&self.timing)) .min() .map(|time| time.date_string()) .unwrap_or_else(|| "".into()) } /// Returns the average ease of the non-new cards or a hint if there aren't /// any. fn ease_str(&self) -> String { let eases: Vec = self .cards .iter() .filter(|c| c.ctype != CardType::New) .map(|c| c.ease_factor) .collect(); if eases.is_empty() { self.tr.browsing_new().into() } else { format!("{}%", eases.iter().sum::() / eases.len() as u16 / 10) } } /// Returns the average interval of the review and relearn cards if there /// are any. fn interval_str(&self) -> String { if !self.notes_mode { match self.cards[0].ctype { CardType::New => return self.tr.browsing_new().into(), CardType::Learn => return self.tr.browsing_learning().into(), CardType::Review | CardType::Relearn => (), } } let intervals: Vec = self .cards .iter() .filter(|c| matches!(c.ctype, CardType::Review | CardType::Relearn)) .map(|c| c.interval) .collect(); if intervals.is_empty() { "".into() } else { time_span( (intervals.iter().sum::() * 86400 / (intervals.len() as u32)) as f32, &self.tr, false, ) } } fn card_mod_str(&self) -> String { self.cards .iter() .map(|c| c.mtime) .max() .expect("cards missing from RowContext") .date_and_time_string() } fn deck_str(&self) -> String { if self.notes_mode { let decks = self.cards.iter().map(|c| c.deck_id).unique().count(); if decks > 1 { return format!("({decks})"); } } let deck_name = self.deck.human_name(); if let Some(original_deck) = &self.original_deck { format!("{} ({})", &deck_name, &original_deck.human_name()) } else { deck_name } } fn cards_str(&self) -> Result { Ok(if self.notes_mode { self.cards.len().to_string() } else { let name = &self.template()?.name; match self.notetype.config.kind() { NotetypeKind::Normal => name.to_owned(), NotetypeKind::Cloze => format!("{} {}", name, self.cards[0].template_idx + 1), } }) } fn get_row_font_name(&self) -> Result { Ok(self.template()?.config.browser_font_name.to_owned()) } fn get_row_font_size(&self) -> Result { Ok(self.template()?.config.browser_font_size) } fn get_row_color(&self) -> anki_proto::search::browser_row::Color { use anki_proto::search::browser_row::Color; if self.notes_mode { if self.note.is_marked() { Color::Marked } else { Color::Default } } else { match self.cards[0].flags { 1 => Color::FlagRed, 2 => Color::FlagOrange, 3 => Color::FlagGreen, 4 => Color::FlagBlue, 5 => Color::FlagPink, 6 => Color::FlagTurquoise, 7 => Color::FlagPurple, _ => { if self.note.is_marked() { Color::Marked } else { match self.cards[0].queue { CardQueue::Suspended => Color::Suspended, CardQueue::UserBuried | CardQueue::SchedBuried => Color::Buried, _ => Color::Default, } } } } } } } ================================================ FILE: rslib/src/card/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod service; pub(crate) mod undo; use std::collections::hash_map::Entry; use std::collections::HashMap; use std::collections::HashSet; use fsrs::MemoryState; use num_enum::TryFromPrimitive; use serde_repr::Deserialize_repr; use serde_repr::Serialize_repr; use crate::collection::Collection; use crate::config::SchedulerVersion; use crate::deckconfig::DeckConfig; use crate::decks::DeckId; use crate::define_newtype; use crate::error::AnkiError; use crate::error::FilteredDeckError; use crate::error::Result; use crate::notes::NoteId; use crate::ops::StateChanges; use crate::prelude::*; use crate::timestamp::TimestampSecs; use crate::types::Usn; define_newtype!(CardId, i64); impl CardId { pub fn as_secs(self) -> TimestampSecs { TimestampSecs(self.0 / 1000) } } #[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Eq, TryFromPrimitive, Clone, Copy)] #[repr(u8)] pub enum CardType { New = 0, Learn = 1, Review = 2, Relearn = 3, } #[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Eq, TryFromPrimitive, Clone, Copy)] #[repr(i8)] pub enum CardQueue { /// due is the order cards are shown in New = 0, /// due is a unix timestamp Learn = 1, /// due is days since creation date Review = 2, DayLearn = 3, /// due is a unix timestamp. /// preview cards only placed here when failed. PreviewRepeat = 4, /// cards are not due in these states Suspended = -1, SchedBuried = -2, UserBuried = -3, } /// Which of the blue/red/green numbers this card maps to. pub enum CardQueueNumber { New, Learning, Review, /// Suspended/buried cards should not be included. Invalid, } #[derive(Debug, Clone, PartialEq)] pub struct Card { pub(crate) id: CardId, pub(crate) note_id: NoteId, pub(crate) deck_id: DeckId, pub(crate) template_idx: u16, pub(crate) mtime: TimestampSecs, pub(crate) usn: Usn, pub(crate) ctype: CardType, pub(crate) queue: CardQueue, pub(crate) due: i32, pub(crate) interval: u32, pub(crate) ease_factor: u16, pub(crate) reps: u32, pub(crate) lapses: u32, pub(crate) remaining_steps: u32, pub(crate) original_due: i32, pub(crate) original_deck_id: DeckId, pub(crate) flags: u8, /// The position in the new queue before leaving it. pub(crate) original_position: Option, pub(crate) memory_state: Option, pub(crate) desired_retention: Option, pub(crate) decay: Option, pub(crate) last_review_time: Option, /// JSON object or empty; exposed through the reviewer for persisting custom /// state pub(crate) custom_data: String, } #[derive(Debug, Clone, Copy, PartialEq)] pub struct FsrsMemoryState { /// The expected memory stability, in days. pub stability: f32, /// A number in the range 1.0-10.0. Use difficulty() for a normalized /// number. pub difficulty: f32, } impl FsrsMemoryState { /// Returns the difficulty normalized to a 0.0-1.0 range. pub(crate) fn difficulty(&self) -> f32 { (self.difficulty - 1.0) / 9.0 } /// Returns the difficulty normalized to a 0.1-1.1 range, /// which is used in revlog entries. pub(crate) fn difficulty_shifted(&self) -> f32 { self.difficulty() + 0.1 } } impl Default for Card { fn default() -> Self { Self { id: CardId(0), note_id: NoteId(0), deck_id: DeckId(0), template_idx: 0, mtime: TimestampSecs(0), usn: Usn(0), ctype: CardType::New, queue: CardQueue::New, due: 0, interval: 0, ease_factor: 0, reps: 0, lapses: 0, remaining_steps: 0, original_due: 0, original_deck_id: DeckId(0), flags: 0, original_position: None, memory_state: None, desired_retention: None, decay: None, last_review_time: None, custom_data: String::new(), } } } impl Card { pub fn id(&self) -> CardId { self.id } pub fn note_id(&self) -> NoteId { self.note_id } pub fn deck_id(&self) -> DeckId { self.deck_id } pub fn template_idx(&self) -> u16 { self.template_idx } pub fn queue_number(&self) -> CardQueueNumber { match self.queue { CardQueue::New => CardQueueNumber::New, CardQueue::PreviewRepeat | CardQueue::Learn => CardQueueNumber::Learning, CardQueue::DayLearn | CardQueue::Review => CardQueueNumber::Review, CardQueue::Suspended | CardQueue::SchedBuried | CardQueue::UserBuried => { CardQueueNumber::Invalid } } } pub fn set_modified(&mut self, usn: Usn) { self.mtime = TimestampSecs::now(); self.usn = usn; } pub fn clear_fsrs_data(&mut self) { self.memory_state = None; self.desired_retention = None; self.decay = None; } /// Caller must ensure provided deck exists and is not filtered. fn set_deck(&mut self, deck: DeckId) { self.remove_from_filtered_deck_restoring_queue(); self.clear_fsrs_data(); self.deck_id = deck; } /// True if flag changed. fn set_flag(&mut self, flag: u8) -> bool { // The first 3 bits represent one of the 7 supported flags, the rest of // the flag byte is preserved. let updated_flags = (self.flags & !0b111) | flag; if self.flags != updated_flags { self.flags = updated_flags; true } else { false } } /// Return the total number of steps left to do, ignoring the /// "steps today" number packed into the DB representation. pub fn remaining_steps(&self) -> u32 { self.remaining_steps % 1000 } /// Return ease factor as a multiplier (eg 2.5) pub fn ease_factor(&self) -> f32 { (self.ease_factor as f32) / 1000.0 } /// Don't use this in situations where you may be using original_due, since /// it only applies to the current due date. You may want to use /// is_unix_epoch_timestap() instead. pub fn is_intraday_learning(&self) -> bool { matches!(self.queue, CardQueue::Learn | CardQueue::PreviewRepeat) } pub fn new(note_id: NoteId, template_idx: u16, deck_id: DeckId, due: i32) -> Self { Card { note_id, deck_id, template_idx, due, ..Default::default() } } /// Remaining steps after configured steps have changed, disregarding /// "remaining today". [None] if same as before. A step counts as /// remaining if the card has not passed a step with the same or a /// greater delay, but output will be at least 1. fn new_remaining_steps(&self, new_steps: &[f32], old_steps: &[f32]) -> Option { let remaining = self.remaining_steps(); let new_remaining = if old_steps.is_empty() { remaining } else { old_steps .len() .checked_sub(remaining as usize + 1) .and_then(|last_index| { new_steps .iter() .rev() .position(|&step| step <= old_steps[last_index]) }) // no last delay or last delay is less than all new steps → all steps remain .unwrap_or(new_steps.len()) // (re)learning card must have at least 1 step remaining .max(1) as u32 }; (remaining != new_remaining).then_some(new_remaining) } /// Supposedly unique across all reviews in the collection. pub fn review_seed(&self) -> u64 { (self.id.0 as u64) .rotate_left(8) .wrapping_add(self.reps as u64) } } impl Collection { pub(crate) fn update_cards_maybe_undoable( &mut self, cards: Vec, undoable: bool, ) -> Result> { if undoable { self.transact(Op::UpdateCard, |col| { for mut card in cards { let existing = col.storage.get_card(card.id)?.or_not_found(card.id)?; col.update_card_inner(&mut card, existing, col.usn()?)? } Ok(()) }) } else { self.transact_no_undo(|col| { for mut card in cards { let existing = col.storage.get_card(card.id)?.or_not_found(card.id)?; col.update_card_inner(&mut card, existing, col.usn()?)?; } Ok(OpOutput { output: (), changes: OpChanges { op: Op::UpdateCard, changes: StateChanges { card: true, ..Default::default() }, }, }) }) } } #[cfg(test)] pub(crate) fn get_and_update_card(&mut self, cid: CardId, func: F) -> Result where F: FnOnce(&mut Card) -> Result, { let orig = self.storage.get_card(cid)?.or_invalid("no such card")?; let mut card = orig.clone(); func(&mut card)?; self.update_card_inner(&mut card, orig, self.usn()?)?; Ok(card) } /// Marks the card as modified, then saves it. pub(crate) fn update_card_inner( &mut self, card: &mut Card, original: Card, usn: Usn, ) -> Result<()> { card.set_modified(usn); self.update_card_undoable(card, original) } pub(crate) fn add_card(&mut self, card: &mut Card) -> Result<()> { require!(card.id.0 == 0, "card id already set"); card.mtime = TimestampSecs::now(); card.usn = self.usn()?; self.add_card_undoable(card) } /// Remove cards and any resulting orphaned notes. /// Expects a transaction. pub(crate) fn remove_cards_and_orphaned_notes(&mut self, cids: &[CardId]) -> Result { let usn = self.usn()?; let mut nids = HashSet::new(); let mut card_count = 0; for cid in cids { if let Some(card) = self.storage.get_card(*cid)? { nids.insert(card.note_id); self.remove_card_and_add_grave_undoable(card, usn)?; card_count += 1; } } for nid in nids { if self.storage.note_is_orphaned(nid)? { self.remove_note_only_undoable(nid, usn)?; } } Ok(card_count) } pub fn set_deck(&mut self, cards: &[CardId], deck_id: DeckId) -> Result> { let sched = self.scheduler_version(); if sched == SchedulerVersion::V1 { return Err(AnkiError::SchedulerUpgradeRequired); } let deck = self.get_deck(deck_id)?.or_not_found(deck_id)?; let config_id = deck.config_id().ok_or(AnkiError::FilteredDeckError { source: FilteredDeckError::CanNotMoveCardsInto, })?; let config = self.get_deck_config(config_id, true)?.unwrap(); let mut steps_adjuster = RemainingStepsAdjuster::new(&config); let usn = self.usn()?; self.transact(Op::SetCardDeck, |col| { let mut count = 0; for mut card in col.all_cards_for_ids(cards, false)? { if card.deck_id == deck_id { continue; } count += 1; let original = card.clone(); steps_adjuster.adjust_remaining_steps(col, &mut card)?; card.set_deck(deck_id); col.update_card_inner(&mut card, original, usn)?; } Ok(count) }) } pub fn set_card_flag(&mut self, cards: &[CardId], flag: u32) -> Result> { require!(flag < 8, "invalid flag"); let flag = flag as u8; let usn = self.usn()?; self.transact(Op::SetFlag, |col| { let mut count = 0; for mut card in col.all_cards_for_ids(cards, false)? { let original = card.clone(); if card.set_flag(flag) { // To avoid having to rebuild the study queues, we mark the card as requiring // a sync, but do not change its modification time. card.usn = usn; col.update_card_undoable(&mut card, original)?; count += 1; } } Ok(count) }) } /// Get deck config for the given card. If missing, return default values. #[allow(dead_code)] pub(crate) fn deck_config_for_card(&mut self, card: &Card) -> Result { if let Some(deck) = self.get_deck(card.original_or_current_deck_id())? { if let Some(conf_id) = deck.config_id() { return Ok(self.get_deck_config(conf_id, true)?.unwrap()); } } Ok(DeckConfig::default()) } /// Adjust the remaining steps of the card according to the steps change. /// Steps must be learning or relearning steps according to the card's type. pub(crate) fn adjust_remaining_steps( &mut self, card: &mut Card, old_steps: &[f32], new_steps: &[f32], usn: Usn, ) -> Result<()> { if let Some(new_remaining) = card.new_remaining_steps(new_steps, old_steps) { let original = card.clone(); card.remaining_steps = new_remaining; self.update_card_inner(card, original, usn) } else { Ok(()) } } } /// Adjusts the remaining steps of cards after their deck config has changed. struct RemainingStepsAdjuster<'a> { learn_steps: &'a [f32], relearn_steps: &'a [f32], configs: HashMap, } impl<'a> RemainingStepsAdjuster<'a> { fn new(new_config: &'a DeckConfig) -> Self { RemainingStepsAdjuster { learn_steps: &new_config.inner.learn_steps, relearn_steps: &new_config.inner.relearn_steps, configs: HashMap::new(), } } fn adjust_remaining_steps(&mut self, col: &mut Collection, card: &mut Card) -> Result<()> { if let Some(remaining) = match card.ctype { CardType::Learn => card.new_remaining_steps( self.learn_steps, &self.config_for_card(col, card)?.inner.learn_steps, ), CardType::Relearn => card.new_remaining_steps( self.relearn_steps, &self.config_for_card(col, card)?.inner.relearn_steps, ), _ => None, } { card.remaining_steps = remaining; } Ok(()) } fn config_for_card(&mut self, col: &mut Collection, card: &Card) -> Result<&mut DeckConfig> { Ok( match self.configs.entry(card.original_or_current_deck_id()) { Entry::Occupied(e) => e.into_mut(), Entry::Vacant(e) => e.insert(col.deck_config_for_card(card)?), }, ) } } impl From for MemoryState { fn from(value: FsrsMemoryState) -> Self { MemoryState { stability: value.stability, difficulty: value.difficulty, } } } impl From for FsrsMemoryState { fn from(value: MemoryState) -> Self { FsrsMemoryState { stability: value.stability, difficulty: value.difficulty, } } } #[cfg(test)] mod test { use crate::prelude::*; use crate::tests::open_test_collection_with_learning_card; use crate::tests::open_test_collection_with_relearning_card; use crate::tests::DeckAdder; #[test] fn should_increase_remaining_learning_steps_if_new_deck_has_more_unpassed_ones() { let mut col = open_test_collection_with_learning_card(); let deck = DeckAdder::new("target") .with_config(|config| config.inner.learn_steps.push(100.)) .add(&mut col); let card_id = col.get_first_card().id; col.set_deck(&[card_id], deck.id).unwrap(); assert_eq!(col.get_first_card().remaining_steps, 3); } #[test] fn should_increase_remaining_relearning_steps_if_new_deck_has_more_unpassed_ones() { let mut col = open_test_collection_with_relearning_card(); let deck = DeckAdder::new("target") .with_config(|config| config.inner.relearn_steps.push(100.)) .add(&mut col); let card_id = col.get_first_card().id; col.set_deck(&[card_id], deck.id).unwrap(); assert_eq!(col.get_first_card().remaining_steps, 2); } #[test] fn should_not_recalculate_remaining_steps_if_there_are_no_old_steps() -> Result<(), AnkiError> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); col.add_note(&mut note, DeckId(1))?; let card_id = col.get_first_card().id; col.set_deck(&[card_id], DeckId(1))?; col.set_default_learn_steps(vec![1., 10.]); let _post_answer = col.answer_good(); col.set_default_learn_steps(vec![]); col.set_default_learn_steps(vec![1., 10.]); assert_eq!(col.get_first_card().remaining_steps, 1); Ok(()) } } ================================================ FILE: rslib/src/card/service.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::card::Card; use crate::card::CardId; use crate::card::CardQueue; use crate::card::CardType; use crate::card::FsrsMemoryState; use crate::collection::Collection; use crate::decks::DeckId; use crate::error; use crate::error::AnkiError; use crate::error::OrInvalid; use crate::error::OrNotFound; use crate::notes::NoteId; use crate::prelude::TimestampSecs; use crate::prelude::Usn; use crate::undo::Op; impl crate::services::CardsService for Collection { fn get_card( &mut self, input: anki_proto::cards::CardId, ) -> error::Result { let cid = input.into(); self.storage .get_card(cid) .and_then(|opt| opt.or_not_found(cid)) .map(Into::into) } fn update_cards( &mut self, input: anki_proto::cards::UpdateCardsRequest, ) -> error::Result { let cards = input .cards .into_iter() .map(TryInto::try_into) .collect::, AnkiError>>()?; for card in &cards { card.validate_custom_data()?; } self.update_cards_maybe_undoable(cards, !input.skip_undo_entry) .map(Into::into) } fn remove_cards( &mut self, input: anki_proto::cards::RemoveCardsRequest, ) -> error::Result { self.transact(Op::EmptyCards, |col| { col.remove_cards_and_orphaned_notes( &input .card_ids .into_iter() .map(Into::into) .collect::>(), ) }) .map(Into::into) } fn set_deck( &mut self, input: anki_proto::cards::SetDeckRequest, ) -> error::Result { let cids: Vec<_> = input.card_ids.into_iter().map(CardId).collect(); let deck_id = input.deck_id.into(); self.set_deck(&cids, deck_id).map(Into::into) } fn set_flag( &mut self, input: anki_proto::cards::SetFlagRequest, ) -> error::Result { self.set_card_flag(&to_card_ids(input.card_ids), input.flag) .map(Into::into) } } impl TryFrom for Card { type Error = AnkiError; fn try_from(c: anki_proto::cards::Card) -> error::Result { let ctype = CardType::try_from(c.ctype as u8).or_invalid("invalid card type")?; let queue = CardQueue::try_from(c.queue as i8).or_invalid("invalid card queue")?; Ok(Card { id: CardId(c.id), note_id: NoteId(c.note_id), deck_id: DeckId(c.deck_id), template_idx: c.template_idx as u16, mtime: TimestampSecs(c.mtime_secs), usn: Usn(c.usn), ctype, queue, due: c.due, interval: c.interval, ease_factor: c.ease_factor as u16, reps: c.reps, lapses: c.lapses, remaining_steps: c.remaining_steps, original_due: c.original_due, original_deck_id: DeckId(c.original_deck_id), flags: c.flags as u8, original_position: c.original_position, memory_state: c.memory_state.map(Into::into), desired_retention: c.desired_retention, decay: c.decay, last_review_time: c.last_review_time_secs.map(TimestampSecs), custom_data: c.custom_data, }) } } impl From for anki_proto::cards::Card { fn from(c: Card) -> Self { anki_proto::cards::Card { id: c.id.0, note_id: c.note_id.0, deck_id: c.deck_id.0, template_idx: c.template_idx as u32, mtime_secs: c.mtime.0, usn: c.usn.0, ctype: c.ctype as u32, queue: c.queue as i32, due: c.due, interval: c.interval, ease_factor: c.ease_factor as u32, reps: c.reps, lapses: c.lapses, remaining_steps: c.remaining_steps, original_due: c.original_due, original_deck_id: c.original_deck_id.0, flags: c.flags as u32, original_position: c.original_position, memory_state: c.memory_state.map(Into::into), desired_retention: c.desired_retention, decay: c.decay, last_review_time_secs: c.last_review_time.map(|t| t.0), custom_data: c.custom_data, } } } fn to_card_ids(v: Vec) -> Vec { v.into_iter().map(CardId).collect() } impl From for CardId { fn from(cid: anki_proto::cards::CardId) -> Self { CardId(cid.cid) } } impl From for FsrsMemoryState { fn from(value: anki_proto::cards::FsrsMemoryState) -> Self { FsrsMemoryState { stability: value.stability, difficulty: value.difficulty, } } } impl From for anki_proto::cards::FsrsMemoryState { fn from(value: FsrsMemoryState) -> Self { anki_proto::cards::FsrsMemoryState { stability: value.stability, difficulty: value.difficulty, } } } ================================================ FILE: rslib/src/card/undo.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::prelude::*; #[derive(Debug)] pub(crate) enum UndoableCardChange { Added(Box), Updated(Box), Removed(Box), GraveAdded(Box<(CardId, Usn)>), GraveRemoved(Box<(CardId, Usn)>), } impl Collection { pub(crate) fn undo_card_change(&mut self, change: UndoableCardChange) -> Result<()> { match change { UndoableCardChange::Added(card) => self.remove_card_only(*card), UndoableCardChange::Updated(mut card) => { let current = self .storage .get_card(card.id)? .or_invalid("card disappeared")?; self.update_card_undoable(&mut card, current) } UndoableCardChange::Removed(card) => self.restore_deleted_card(*card), UndoableCardChange::GraveAdded(e) => self.remove_card_grave(e.0, e.1), UndoableCardChange::GraveRemoved(e) => self.add_card_grave_undoable(e.0, e.1), } } pub(super) fn add_card_undoable(&mut self, card: &mut Card) -> Result<(), AnkiError> { self.storage.add_card(card)?; self.save_undo(UndoableCardChange::Added(Box::new(card.clone()))); Ok(()) } pub(crate) fn add_card_if_unique_undoable(&mut self, card: &Card) -> Result { let added = self.storage.add_card_if_unique(card)?; if added { self.save_undo(UndoableCardChange::Added(Box::new(card.clone()))); } Ok(added) } pub(super) fn update_card_undoable(&mut self, card: &mut Card, original: Card) -> Result<()> { require!(card.id.0 != 0, "card id not set"); self.save_undo(UndoableCardChange::Updated(Box::new(original))); self.storage.update_card(card) } pub(crate) fn remove_card_and_add_grave_undoable( &mut self, card: Card, usn: Usn, ) -> Result<()> { self.add_card_grave_undoable(card.id, usn)?; self.storage.remove_card(card.id)?; self.save_undo(UndoableCardChange::Removed(Box::new(card))); Ok(()) } fn restore_deleted_card(&mut self, card: Card) -> Result<()> { self.storage.add_or_update_card(&card)?; self.save_undo(UndoableCardChange::Added(Box::new(card))); Ok(()) } fn remove_card_only(&mut self, card: Card) -> Result<()> { self.storage.remove_card(card.id)?; self.save_undo(UndoableCardChange::Removed(Box::new(card))); Ok(()) } fn add_card_grave_undoable(&mut self, cid: CardId, usn: Usn) -> Result<()> { self.save_undo(UndoableCardChange::GraveAdded(Box::new((cid, usn)))); self.storage.add_card_grave(cid, usn) } fn remove_card_grave(&mut self, cid: CardId, usn: Usn) -> Result<()> { self.save_undo(UndoableCardChange::GraveRemoved(Box::new((cid, usn)))); self.storage.remove_card_grave(cid) } } ================================================ FILE: rslib/src/card_rendering/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use crate::prelude::*; mod parser; pub(crate) mod service; pub mod tts; mod writer; pub fn strip_av_tags + AsRef>(txt: S) -> String { nodes_or_text_only(txt.as_ref()) .map(|nodes| nodes.write_without_av_tags()) .unwrap_or_else(|| txt.into()) } pub fn extract_av_tags + AsRef>( txt: S, question_side: bool, tr: &I18n, ) -> (String, Vec) { nodes_or_text_only(txt.as_ref()) .map(|nodes| nodes.write_and_extract_av_tags(question_side, tr)) .unwrap_or_else(|| (txt.into(), vec![])) } pub fn prettify_av_tags + AsRef>(txt: S) -> String { nodes_or_text_only(txt.as_ref()) .map(|nodes| nodes.write_with_pretty_av_tags()) .unwrap_or_else(|| txt.into()) } /// Parse `txt` into [CardNodes] and return the result, /// or [None] if it only contains text nodes. fn nodes_or_text_only(txt: &str) -> Option> { let nodes = CardNodes::parse(txt); (!nodes.text_only).then_some(nodes) } #[derive(Debug, PartialEq)] struct CardNodes<'a> { nodes: Vec>, text_only: bool, } impl<'iter, 'nodes> IntoIterator for &'iter CardNodes<'nodes> { type Item = &'iter Node<'nodes>; type IntoIter = std::slice::Iter<'iter, Node<'nodes>>; fn into_iter(self) -> Self::IntoIter { self.nodes.iter() } } #[derive(Debug, PartialEq)] enum Node<'a> { Text(&'a str), SoundOrVideo(&'a str), Directive(Directive<'a>), } #[derive(Debug, PartialEq)] enum Directive<'a> { Tts(TtsDirective<'a>), Other(OtherDirective<'a>), } #[derive(Debug, PartialEq)] struct TtsDirective<'a> { content: &'a str, lang: &'a str, voices: Vec<&'a str>, speed: f32, blank: Option<&'a str>, options: HashMap<&'a str, &'a str>, } #[derive(Debug, PartialEq, Eq)] struct OtherDirective<'a> { name: &'a str, content: &'a str, options: HashMap<&'a str, &'a str>, } #[cfg(feature = "bench")] #[inline] pub fn anki_directive_benchmark() { CardNodes::parse("[anki:foo bar=baz][/anki:foo][anki:tts lang=jp_JP voices=Alice,Bob speed=0.5 cloze_blank= bar=baz][/anki:tts]"); } #[cfg(test)] mod test { use super::*; /// Strip av tags and assert equality with input or separately passed /// output. macro_rules! assert_av_stripped { ($input:expr) => { assert_eq!($input, strip_av_tags($input)); }; ($input:expr, $output:expr) => { assert_eq!(strip_av_tags($input), $output); }; } #[test] fn av_stripping() { assert_av_stripped!("foo [sound:bar] baz", "foo baz"); assert_av_stripped!("[anki:tts bar=baz]spam[/anki:tts]", ""); assert_av_stripped!("[anki:foo bar=baz]spam[/anki:foo]"); } #[test] fn av_extracting() { let tr = I18n::template_only(); let (txt, tags) = extract_av_tags( "foo [sound:bar.mp3] baz [anki:tts lang=en_US][...][/anki:tts]", true, &tr, ); assert_eq!( (txt.as_str(), tags), ( "foo [anki:play:q:0] baz [anki:play:q:1]", vec![ anki_proto::card_rendering::AvTag { value: Some(anki_proto::card_rendering::av_tag::Value::SoundOrVideo( "bar.mp3".to_string() )) }, anki_proto::card_rendering::AvTag { value: Some(anki_proto::card_rendering::av_tag::Value::Tts( anki_proto::card_rendering::TtsTag { field_text: tr.card_templates_blank().to_string(), lang: "en_US".to_string(), voices: vec![], speed: 1.0, other_args: vec![], } )) } ], ), ); assert_eq!( extract_av_tags("[anki:tts]foo[/anki:tts]", true, &tr), ( format!( "[{}]", tr.errors_bad_directive("anki:tts", tr.errors_option_not_set("lang")) ), vec![], ), ); } } ================================================ FILE: rslib/src/card_rendering/parser.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use nom::branch::alt; use nom::bytes::complete::is_not; use nom::bytes::complete::tag; use nom::character::complete::anychar; use nom::character::complete::multispace0; use nom::combinator::map; use nom::combinator::not; use nom::combinator::recognize; use nom::combinator::rest; use nom::combinator::success; use nom::combinator::value; use nom::multi::many0; use nom::sequence::delimited; use nom::sequence::pair; use nom::sequence::preceded; use nom::sequence::separated_pair; use nom::sequence::terminated; use nom::Input; use nom::Parser; use super::CardNodes; use super::Directive; use super::Node; use super::OtherDirective; use super::TtsDirective; type IResult<'a, O> = nom::IResult<&'a str, O>; impl<'a> CardNodes<'a> { pub(super) fn parse(mut txt: &'a str) -> Self { let mut nodes = Vec::new(); let mut text_only = true; while let Ok((remaining, node)) = node(txt) { text_only &= matches!(node, Node::Text(_)); txt = remaining; nodes.push(node); } Self { nodes, text_only } } } impl<'a> Directive<'a> { fn new(name: &'a str, options: Vec<(&'a str, &'a str)>, content: &'a str) -> Self { match name { "tts" => { let mut lang = ""; let mut voices = vec![]; let mut speed = 1.0; let mut blank = None; let mut other_options = HashMap::new(); for option in options { match option.0 { "lang" => lang = option.1, "voices" => voices = option.1.split(',').collect(), "speed" => speed = option.1.parse().unwrap_or(1.0), "cloze_blank" => blank = Some(option.1), _ => { other_options.insert(option.0, option.1); } } } Self::Tts(TtsDirective { content, lang, voices, speed, blank, options: other_options, }) } _ => Self::Other(OtherDirective { name, content, options: options.into_iter().collect(), }), } } } /// Consume 0 or more of anything in " \t\r\n" after `parser`. fn trailing_whitespace0(parser: P) -> impl Parser where I: Input, ::Item: nom::AsChar, E: nom::error::ParseError, P: Parser, { terminated(parser, multispace0) } /// Parse until char in `arr` is found. Always succeeds. fn is_not0<'parser, 'arr: 'parser, 's: 'parser>( arr: &'arr str, ) -> impl FnMut(&'s str) -> IResult<'s, &'s str> + 'parser { move |s| alt((is_not(arr), success(""))).parse(s) } fn node(s: &str) -> IResult<'_, Node<'_>> { alt((sound_node, tag_node, text_node)).parse(s) } /// A sound tag `[sound:resource]`, where `resource` is pointing to a sound or /// video file. fn sound_node(s: &str) -> IResult<'_, Node<'_>> { map( delimited(tag("[sound:"), is_not("]"), tag("]")), Node::SoundOrVideo, ) .parse(s) } fn take_till_potential_tag_start(s: &str) -> IResult<'_, &str> { // first char could be '[', but wasn't part of a node, so skip (eof ends parse) let (after, offset) = anychar(s).map(|(s, c)| (s, c.len_utf8()))?; Ok(match after.find('[') { Some(pos) => s.take_split(offset + pos), _ => rest(s)?, }) } /// An Anki tag `[anki:tag...]...[/anki:tag]`. fn tag_node(s: &str) -> IResult<'_, Node<'_>> { /// Match the start of an opening tag and return its name. fn name(s: &str) -> IResult<'_, &str> { preceded(tag("[anki:"), is_not("] \t\r\n")).parse(s) } /// Return a parser to match an opening `name` tag and return its options. fn opening_parser<'name, 's: 'name>( name: &'name str, ) -> impl FnMut(&'s str) -> IResult<'s, Vec<(&'s str, &'s str)>> + 'name { /// List of whitespace-separated `key=val` tuples, where `val` may be /// empty. fn options(s: &str) -> IResult<'_, Vec<(&str, &str)>> { fn key(s: &str) -> IResult<'_, &str> { is_not("] \t\r\n=").parse(s) } fn val(s: &str) -> IResult<'_, &str> { alt(( delimited(tag("\""), is_not0("\""), tag("\"")), is_not0("] \t\r\n\""), )) .parse(s) } many0(trailing_whitespace0(separated_pair(key, tag("="), val))).parse(s) } move |s| { delimited( pair(tag("[anki:"), trailing_whitespace0(tag(name))), options, tag("]"), ) .parse(s) } } /// Return a parser to match a closing `name` tag. fn closing_parser<'parser, 'name: 'parser, 's: 'parser>( name: &'name str, ) -> impl FnMut(&'s str) -> IResult<'s, ()> + 'parser { move |s| value((), (tag("[/anki:"), tag(name), tag("]"))).parse(s) } /// Return a parser to match and return anything until a closing `name` tag /// is found. fn content_parser<'parser, 'name: 'parser, 's: 'parser>( name: &'name str, ) -> impl FnMut(&'s str) -> IResult<'s, &'s str> + 'parser { move |s| { recognize(many0(pair( not(closing_parser(name)), take_till_potential_tag_start, ))) .parse(s) } } let (_, tag_name) = name(s)?; map( terminated( pair(opening_parser(tag_name), content_parser(tag_name)), closing_parser(tag_name), ), |(options, content)| Node::Directive(Directive::new(tag_name, options, content)), ) .parse(s) } fn text_node(s: &str) -> IResult<'_, Node<'_>> { map(take_till_potential_tag_start, Node::Text).parse(s) } #[cfg(test)] mod test { use super::*; macro_rules! assert_parsed_nodes { ($txt:expr $(, $node:expr)*) => { assert_eq!(CardNodes::parse($txt).nodes, vec![$($node),*]); } } #[test] fn parsing() { use Node::*; // empty assert_parsed_nodes!(""); // text assert_parsed_nodes!("foo", Text("foo")); // broken sound/tags are just text as well assert_parsed_nodes!("[sound:]", Text("[sound:]")); assert_parsed_nodes!("[anki:][/anki:]", Text("[anki:]"), Text("[/anki:]")); assert_parsed_nodes!( "[anki:foo][/anki:bar]", Text("[anki:foo]"), Text("[/anki:bar]") ); assert_parsed_nodes!( "abc[anki:foo]def[/anki:bar]ghi][[anki:bar][", Text("abc"), Text("[anki:foo]def"), Text("[/anki:bar]ghi]"), Text("["), Text("[anki:bar]"), Text("[") ); // sound assert_parsed_nodes!("[sound:foo]", SoundOrVideo("foo")); assert_parsed_nodes!( "foo [sound:bar] baz", Text("foo "), SoundOrVideo("bar"), Text(" baz") ); assert_parsed_nodes!( "[sound:foo][sound:bar]", SoundOrVideo("foo"), SoundOrVideo("bar") ); // tags assert_parsed_nodes!( "[anki:foo]bar[/anki:foo]", Directive(super::Directive::Other(OtherDirective { name: "foo", content: "bar", options: HashMap::new() })) ); assert_parsed_nodes!( "[anki:foo]]bar[[/anki:foo]", Directive(super::Directive::Other(OtherDirective { name: "foo", content: "]bar[", options: HashMap::new() })) ); assert_parsed_nodes!( "[anki:foo bar=baz][/anki:foo]", Directive(super::Directive::Other(OtherDirective { name: "foo", content: "", options: [("bar", "baz")].into_iter().collect(), })) ); // unquoted white space separates options, "]" terminates assert_parsed_nodes!( "[anki:foo\na=b\tc=d e=f][/anki:foo]", Directive(super::Directive::Other(OtherDirective { name: "foo", content: "", options: [("a", "b"), ("c", "d"), ("e", "f")].into_iter().collect(), })) ); assert_parsed_nodes!( "[anki:foo a=\"b \t\n c ]\"][/anki:foo]", Directive(super::Directive::Other(OtherDirective { name: "foo", content: "", options: [("a", "b \t\n c ]")].into_iter().collect(), })) ); // tts tags assert_parsed_nodes!( "[anki:tts lang=jp_JP voices=Alice,Bob speed=0.5 cloze_blank= bar=baz][/anki:tts]", Directive(super::Directive::Tts(TtsDirective { content: "", lang: "jp_JP", voices: vec!["Alice", "Bob"], speed: 0.5, blank: Some(""), options: [("bar", "baz")].into_iter().collect(), })) ); assert_parsed_nodes!( "[anki:tts speed=foo][/anki:tts]", Directive(super::Directive::Tts(TtsDirective { content: "", lang: "", voices: vec![], speed: 1.0, blank: None, options: HashMap::new(), })) ); } } ================================================ FILE: rslib/src/card_rendering/service.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::card_rendering::ExtractClozeForTypingRequest; use anki_proto::generic; use crate::card::CardId; use crate::card_rendering::extract_av_tags; use crate::card_rendering::strip_av_tags; use crate::cloze::extract_cloze_for_typing; use crate::collection::Collection; use crate::error::OrInvalid; use crate::error::Result; use crate::latex::extract_latex; use crate::latex::extract_latex_expanding_clozes; use crate::latex::ExtractedLatex; use crate::markdown::render_markdown; use crate::notetype::CardTemplateSchema11; use crate::notetype::RenderCardOutput; use crate::template::RenderedNode; use crate::text::decode_iri_paths; use crate::text::encode_iri_paths; use crate::text::html_to_text_line; use crate::text::sanitize_html_no_images; use crate::text::strip_html; use crate::text::strip_html_preserving_media_filenames; use crate::typeanswer::compare_answer; /// While the majority of these methods do not actually require a collection, /// they are unlikely to be executed without one, so we only bother implementing /// them for the collection. impl crate::services::CardRenderingService for Collection { fn extract_av_tags( &mut self, input: anki_proto::card_rendering::ExtractAvTagsRequest, ) -> Result { let out = extract_av_tags(input.text, input.question_side, &self.tr); Ok(anki_proto::card_rendering::ExtractAvTagsResponse { text: out.0, av_tags: out.1, }) } fn extract_latex( &mut self, input: anki_proto::card_rendering::ExtractLatexRequest, ) -> Result { let func = if input.expand_clozes { extract_latex_expanding_clozes } else { extract_latex }; let (text, extracted) = func(&input.text, input.svg); Ok(anki_proto::card_rendering::ExtractLatexResponse { text: text.into_owned(), latex: extracted .into_iter() .map( |e: ExtractedLatex| anki_proto::card_rendering::ExtractedLatex { filename: e.fname, latex_body: e.latex, }, ) .collect(), }) } fn get_empty_cards(&mut self) -> Result { let mut empty = self.empty_cards()?; let report = self.empty_cards_report(&mut empty)?; let mut outnotes = vec![]; for (_ntid, notes) in empty { outnotes.extend(notes.into_iter().map(|e| { anki_proto::card_rendering::empty_cards_report::NoteWithEmptyCards { note_id: e.nid.0, will_delete_note: e.empty.len() == e.current_count, card_ids: e.empty.into_iter().map(|(_ord, id)| id.0).collect(), } })) } Ok(anki_proto::card_rendering::EmptyCardsReport { report, notes: outnotes, }) } fn render_existing_card( &mut self, input: anki_proto::card_rendering::RenderExistingCardRequest, ) -> Result { self.render_existing_card(CardId(input.card_id), input.browser, input.partial_render) .map(Into::into) } fn render_uncommitted_card( &mut self, input: anki_proto::card_rendering::RenderUncommittedCardRequest, ) -> Result { let template = input.template.or_invalid("missing template")?.into(); let mut note = input.note.or_invalid("missing note")?.into(); let ord = input.card_ord as u16; let fill_empty = input.fill_empty; self.render_uncommitted_card(&mut note, &template, ord, fill_empty, input.partial_render) .map(Into::into) } fn render_uncommitted_card_legacy( &mut self, input: anki_proto::card_rendering::RenderUncommittedCardLegacyRequest, ) -> Result { let schema11: CardTemplateSchema11 = serde_json::from_slice(&input.template)?; let template = schema11.into(); let mut note = input.note.or_invalid("missing note")?.into(); let ord = input.card_ord as u16; let fill_empty = input.fill_empty; self.render_uncommitted_card(&mut note, &template, ord, fill_empty, input.partial_render) .map(Into::into) } fn strip_av_tags(&mut self, input: generic::String) -> Result { Ok(strip_av_tags(input.val).into()) } fn render_markdown( &mut self, input: anki_proto::card_rendering::RenderMarkdownRequest, ) -> Result { let mut text = render_markdown(&input.markdown); if input.sanitize { // currently no images text = sanitize_html_no_images(&text); } Ok(text.into()) } fn encode_iri_paths(&mut self, input: generic::String) -> Result { Ok(encode_iri_paths(&input.val).to_string().into()) } fn decode_iri_paths(&mut self, input: generic::String) -> Result { Ok(decode_iri_paths(&input.val).to_string().into()) } fn strip_html( &mut self, input: anki_proto::card_rendering::StripHtmlRequest, ) -> Result { strip_html_proto(input) } fn html_to_text_line( &mut self, input: anki_proto::card_rendering::HtmlToTextLineRequest, ) -> Result { Ok( html_to_text_line(&input.text, input.preserve_media_filenames) .to_string() .into(), ) } fn compare_answer( &mut self, input: anki_proto::card_rendering::CompareAnswerRequest, ) -> Result { Ok(compare_answer(&input.expected, &input.provided, input.combining).into()) } fn extract_cloze_for_typing( &mut self, input: ExtractClozeForTypingRequest, ) -> Result { Ok(extract_cloze_for_typing(&input.text, input.ordinal as u16) .to_string() .into()) } } fn rendered_nodes_to_proto( nodes: Vec, ) -> Vec { nodes .into_iter() .map(|n| anki_proto::card_rendering::RenderedTemplateNode { value: Some(rendered_node_to_proto(n)), }) .collect() } fn rendered_node_to_proto( node: RenderedNode, ) -> anki_proto::card_rendering::rendered_template_node::Value { match node { RenderedNode::Text { text } => { anki_proto::card_rendering::rendered_template_node::Value::Text(text) } RenderedNode::Replacement { field_name, current_text, filters, } => anki_proto::card_rendering::rendered_template_node::Value::Replacement( anki_proto::card_rendering::RenderedTemplateReplacement { field_name, current_text, filters, }, ), } } impl From for anki_proto::card_rendering::RenderCardResponse { fn from(o: RenderCardOutput) -> Self { anki_proto::card_rendering::RenderCardResponse { question_nodes: rendered_nodes_to_proto(o.qnodes), answer_nodes: rendered_nodes_to_proto(o.anodes), css: o.css, latex_svg: o.latex_svg, is_empty: o.is_empty, } } } pub(crate) fn strip_html_proto( input: anki_proto::card_rendering::StripHtmlRequest, ) -> Result { Ok(match input.mode() { anki_proto::card_rendering::strip_html_request::Mode::Normal => strip_html(&input.text), anki_proto::card_rendering::strip_html_request::Mode::PreserveMediaFilenames => { strip_html_preserving_media_filenames(&input.text) } } .to_string() .into()) } ================================================ FILE: rslib/src/card_rendering/tts/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::card_rendering::all_tts_voices_response::TtsVoice; use crate::prelude::*; #[cfg(windows)] #[path = "windows.rs"] mod inner; #[cfg(not(windows))] #[path = "other.rs"] mod inner; pub fn all_voices(validate: bool) -> Result> { inner::all_voices(validate) } pub fn write_stream(path: &str, voice_id: &str, speed: f32, text: &str) -> Result<()> { inner::write_stream(path, voice_id, speed, text) } ================================================ FILE: rslib/src/card_rendering/tts/other.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::card_rendering::all_tts_voices_response::TtsVoice; use crate::prelude::*; pub(super) fn all_voices(_validate: bool) -> Result> { invalid_input!("not implemented for this OS"); } pub(super) fn write_stream(_path: &str, _voice_id: &str, _speed: f32, _text: &str) -> Result<()> { invalid_input!("not implemented for this OS"); } ================================================ FILE: rslib/src/card_rendering/tts/windows.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::fs::File; use std::io::Write; use anki_proto::card_rendering::all_tts_voices_response::TtsVoice; use windows::core::Interface; use windows::core::HSTRING; use windows::Media::SpeechSynthesis::SpeechSynthesisStream; use windows::Media::SpeechSynthesis::SpeechSynthesizer; use windows::Media::SpeechSynthesis::VoiceInformation; use windows::Storage::Streams::DataReader; use windows::Storage::Streams::IRandomAccessStream; use crate::error::windows::WindowsErrorDetails; use crate::error::windows::WindowsSnafu; use crate::prelude::*; const MAX_BUFFER_SIZE: usize = 128 * 1024; pub(super) fn all_voices(validate: bool) -> Result> { SpeechSynthesizer::AllVoices()? .into_iter() .map(|info| tts_voice_from_information(info, validate)) .collect() } pub(super) fn write_stream(path: &str, voice_id: &str, speed: f32, text: &str) -> Result<()> { let voice = find_voice(voice_id)?; let stream = synthesize_stream(&voice, speed, text)?; write_stream_to_path(stream, path)?; Ok(()) } fn find_voice(voice_id: &str) -> Result { SpeechSynthesizer::AllVoices()? .into_iter() .find(|info| { info.Id() .map(|id| id.to_string_lossy().eq(voice_id)) .unwrap_or_default() }) .or_invalid("voice id not found") } fn to_hstring(text: &str) -> HSTRING { let utf16: Vec = text.encode_utf16().collect(); HSTRING::from_wide(&utf16) } fn synthesize_stream( voice: &VoiceInformation, speed: f32, text: &str, ) -> Result { let synthesizer = SpeechSynthesizer::new()?; synthesizer.SetVoice(voice).with_context(|_| WindowsSnafu { details: WindowsErrorDetails::SettingVoice(voice.clone()), })?; synthesizer .Options()? .SetSpeakingRate(speed as f64) .context(WindowsSnafu { details: WindowsErrorDetails::SettingRate(speed), })?; let async_op = synthesizer.SynthesizeTextToStreamAsync(&to_hstring(text))?; let stream = async_op.get().context(WindowsSnafu { details: WindowsErrorDetails::Synthesizing, })?; Ok(stream) } fn write_stream_to_path(stream: SpeechSynthesisStream, path: &str) -> Result<()> { let random_access_stream: IRandomAccessStream = stream.cast()?; let input_stream = random_access_stream.GetInputStreamAt(0)?; let date_reader = DataReader::CreateDataReader(&input_stream)?; let stream_size = random_access_stream .Size()? .try_into() .or_invalid("stream too large")?; date_reader.LoadAsync(stream_size)?; let mut file = File::create(path)?; write_reader_to_file(date_reader, &mut file, stream_size as usize) } fn write_reader_to_file(reader: DataReader, file: &mut File, stream_size: usize) -> Result<()> { let mut bytes_remaining = stream_size; let mut buf = [0u8; MAX_BUFFER_SIZE]; while bytes_remaining > 0 { let chunk_size = bytes_remaining.min(MAX_BUFFER_SIZE); reader.ReadBytes(&mut buf[..chunk_size])?; file.write_all(&buf[..chunk_size])?; bytes_remaining -= chunk_size; } Ok(()) } fn tts_voice_from_information(info: VoiceInformation, validate: bool) -> Result { Ok(TtsVoice { id: info.Id()?.to_string_lossy(), name: info.DisplayName()?.to_string_lossy(), language: info.Language()?.to_string_lossy(), // Windows lists voices that fail when actually trying to use them. This has been // observed with voices from an uninstalled language pack. // Validation is optional because it may be slow. available: validate.then(|| synthesize_stream(&info, 1.0, "").is_ok()), }) } ================================================ FILE: rslib/src/card_rendering/writer.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::fmt::Write as _; use super::CardNodes; use super::Directive; use super::Node; use super::OtherDirective; use super::TtsDirective; use crate::prelude::*; use crate::text::decode_entities; use crate::text::strip_html_for_tts; impl CardNodes<'_> { pub(super) fn write_without_av_tags(&self) -> String { AvStripper::new().write(self) } pub(super) fn write_and_extract_av_tags( &self, question_side: bool, tr: &I18n, ) -> (String, Vec) { let mut extractor = AvExtractor::new(question_side, tr); (extractor.write(self), extractor.tags) } pub(super) fn write_with_pretty_av_tags(&self) -> String { AvPrettifier::new().write(self) } } trait Write { fn write<'iter, 'nodes: 'iter, T>(&mut self, nodes: T) -> String where T: IntoIterator>, { let mut buf = String::new(); for node in nodes { match node { Node::Text(s) => self.write_text(&mut buf, s), Node::SoundOrVideo(r) => self.write_sound(&mut buf, r), Node::Directive(directive) => self.write_directive(&mut buf, directive), }; } buf } fn write_text(&mut self, buf: &mut String, txt: &str) { buf.push_str(txt); } fn write_sound(&mut self, buf: &mut String, resource: &str) { write!(buf, "[sound:{resource}]").unwrap(); } fn write_directive(&mut self, buf: &mut String, directive: &Directive) { match directive { Directive::Tts(directive) => self.write_tts_directive(buf, directive), Directive::Other(directive) => self.write_other_directive(buf, directive), }; } fn write_tts_directive(&mut self, buf: &mut String, directive: &TtsDirective) { write!(buf, "[anki:tts").unwrap(); for (key, val) in [ ("lang", directive.lang), ("voices", &directive.voices.join(",")), ("speed", &directive.speed.to_string()), ] { self.write_directive_option(buf, key, val); } if let Some(blank) = directive.blank { self.write_directive_option(buf, "cloze_blank", blank); } for (key, val) in &directive.options { self.write_directive_option(buf, key, val); } write!(buf, "]{}[/anki:tts]", directive.content).unwrap(); } fn write_other_directive(&mut self, buf: &mut String, directive: &OtherDirective) { write!(buf, "[anki:{}", directive.name).unwrap(); for (key, val) in &directive.options { self.write_directive_option(buf, key, val); } buf.push(']'); self.write_directive_content(buf, directive.content); write!(buf, "[/anki:{}]", directive.name).unwrap(); } fn write_directive_option(&mut self, buf: &mut String, key: &str, val: &str) { if val.contains([']', ' ', '\t', '\r', '\n']) { write!(buf, " {key}=\"{val}\"").unwrap(); } else { write!(buf, " {key}={val}").unwrap(); } } fn write_directive_content(&mut self, buf: &mut String, content: &str) { buf.push_str(content); } } struct AvStripper; impl AvStripper { fn new() -> Self { Self {} } } impl Write for AvStripper { fn write_sound(&mut self, _buf: &mut String, _resource: &str) {} fn write_tts_directive(&mut self, _buf: &mut String, _directive: &TtsDirective) {} } struct AvExtractor<'a> { side: char, tags: Vec, tr: &'a I18n, } impl<'a> AvExtractor<'a> { fn new(question_side: bool, tr: &'a I18n) -> Self { Self { side: if question_side { 'q' } else { 'a' }, tags: vec![], tr, } } fn write_play_tag(&self, buf: &mut String) { write!(buf, "[anki:play:{}:{}]", self.side, self.tags.len()).unwrap(); } fn transform_tts_content(&self, directive: &TtsDirective) -> String { strip_html_for_tts(directive.content).replace( "[...]", directive.blank.unwrap_or(&self.tr.card_templates_blank()), ) } } impl Write for AvExtractor<'_> { fn write_sound(&mut self, buf: &mut String, resource: &str) { self.write_play_tag(buf); self.tags.push(anki_proto::card_rendering::AvTag { value: Some(anki_proto::card_rendering::av_tag::Value::SoundOrVideo( decode_entities(resource).into(), )), }); } fn write_tts_directive(&mut self, buf: &mut String, directive: &TtsDirective) { if let Some(error) = directive.error(self.tr) { write!(buf, "[{error}]").unwrap(); return; } self.write_play_tag(buf); self.tags.push(anki_proto::card_rendering::AvTag { value: Some(anki_proto::card_rendering::av_tag::Value::Tts( anki_proto::card_rendering::TtsTag { field_text: self.transform_tts_content(directive), lang: directive.lang.into(), voices: directive.voices.iter().map(ToString::to_string).collect(), speed: directive.speed, other_args: directive .options .iter() .map(|(key, val)| format!("{key}={val}")) .collect(), }, )), }); } } impl TtsDirective<'_> { fn error(&self, tr: &I18n) -> Option { if self.lang.is_empty() { Some( tr.errors_bad_directive("anki:tts", tr.errors_option_not_set("lang")) .into(), ) } else { None } } } struct AvPrettifier; impl AvPrettifier { fn new() -> Self { Self {} } } impl Write for AvPrettifier { fn write_sound(&mut self, buf: &mut String, resource: &str) { write!(buf, "🔉{resource}🔉").unwrap(); } fn write_tts_directive(&mut self, buf: &mut String, directive: &TtsDirective) { write!(buf, "💬{}💬", directive.content).unwrap(); } } #[cfg(test)] mod test { use super::*; struct Writer; impl Write for Writer {} impl Writer { fn new() -> Self { Self {} } } /// Parse input, write it out, and assert equality with input or separately /// passed output. macro_rules! roundtrip { ($input:expr) => { assert_eq!($input, Writer::new().write(&CardNodes::parse($input))); }; ($input:expr, $output:expr) => { assert_eq!(Writer::new().write(&CardNodes::parse($input)), $output); }; } #[test] fn writing() { roundtrip!("foo"); roundtrip!("[sound:foo]"); roundtrip!("[anki:foo bar=baz]spam[/anki:foo]"); // normalizing (not currently exposed) roundtrip!( "[anki:foo\nbar=baz ][/anki:foo]", "[anki:foo bar=baz][/anki:foo]" ); } } ================================================ FILE: rslib/src/cloze.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; use std::fmt::Write; use std::sync::LazyLock; use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusion; use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusionShape; use htmlescape::encode_attribute; use itertools::Itertools; use nom::branch::alt; use nom::bytes::complete::tag; use nom::bytes::complete::take_while; use nom::combinator::map; use nom::IResult; use nom::Parser; use regex::Captures; use regex::Regex; use crate::image_occlusion::imageocclusion::get_image_cloze_data; use crate::image_occlusion::imageocclusion::parse_image_cloze; use crate::latex::contains_latex; use crate::template::RenderContext; use crate::text::strip_html_preserving_entities; static CLOZE: LazyLock = LazyLock::new(|| Regex::new(r"(?s)\{\{c[\d,]+::(.*?)(::.*?)?\}\}").unwrap()); static MATHJAX: LazyLock = LazyLock::new(|| { Regex::new( r"(?xsi) (\\[(\[]) # 1 = mathjax opening tag (.*?) # 2 = inner content (\\[])]) # 3 = mathjax closing tag ", ) .unwrap() }); mod mathjax_caps { pub const OPENING_TAG: usize = 1; pub const INNER_TEXT: usize = 2; pub const CLOSING_TAG: usize = 3; } #[derive(Debug)] enum Token<'a> { // The parameter is the cloze number as is appears in the field content. OpenCloze(Vec), Text(&'a str), CloseCloze, } /// Tokenize string fn tokenize(mut text: &str) -> impl Iterator> { fn open_cloze(text: &str) -> IResult<&str, Token<'_>> { // opening brackets and 'c' let (text, _opening_brackets_and_c) = tag("{{c")(text)?; // following comma-seperated numbers let (text, ordinals) = take_while(|c: char| c.is_ascii_digit() || c == ',')(text)?; let ordinals: Vec = ordinals .split(',') .filter_map(|s| s.parse().ok()) .collect::>() // deduplicate .into_iter() .sorted() // set conversion can de-order .collect(); if ordinals.is_empty() { return Err(nom::Err::Error(nom::error::make_error( text, nom::error::ErrorKind::Digit, ))); } // :: let (text, _colons) = tag("::")(text)?; Ok((text, Token::OpenCloze(ordinals))) } fn close_cloze(text: &str) -> IResult<&str, Token<'_>> { map(tag("}}"), |_| Token::CloseCloze).parse(text) } /// Match a run of text until an open/close marker is encountered. fn normal_text(text: &str) -> IResult<&str, Token<'_>> { if text.is_empty() { return Err(nom::Err::Error(nom::error::make_error( text, nom::error::ErrorKind::Eof, ))); } let mut other_token = alt((open_cloze, close_cloze)); // start with the no-match case let mut index = text.len(); for (idx, _) in text.char_indices() { if other_token.parse(&text[idx..]).is_ok() { index = idx; break; } } Ok((&text[index..], Token::Text(&text[0..index]))) } std::iter::from_fn(move || { if text.is_empty() { None } else { let (remaining_text, token) = alt((open_cloze, close_cloze, normal_text)) .parse(text) .unwrap(); text = remaining_text; Some(token) } }) } #[derive(Debug)] enum TextOrCloze<'a> { Text(&'a str), Cloze(ExtractedCloze<'a>), } #[derive(Debug)] struct ExtractedCloze<'a> { // `ordinal` is the cloze number as is appears in the field content. ordinals: Vec, nodes: Vec>, hint: Option<&'a str>, } /// Generate a string representation of the ordinals for HTML fn ordinals_str(ordinals: &[u16]) -> String { ordinals .iter() .map(|o| o.to_string()) .collect::>() .join(",") } impl ExtractedCloze<'_> { /// Return the cloze's hint, or "..." if none was provided. fn hint(&self) -> &str { self.hint.unwrap_or("...") } fn clozed_text(&self) -> Cow<'_, str> { // happy efficient path? if self.nodes.len() == 1 { if let TextOrCloze::Text(text) = self.nodes.last().unwrap() { return (*text).into(); } } let mut buf = String::new(); for node in &self.nodes { match node { TextOrCloze::Text(text) => buf.push_str(text), TextOrCloze::Cloze(cloze) => buf.push_str(&cloze.clozed_text()), } } buf.into() } /// Checks if this cloze is active for a given ordinal fn contains_ordinal(&self, ordinal: u16) -> bool { self.ordinals.contains(&ordinal) } /// If cloze starts with image-occlusion:, return the text following that. fn image_occlusion(&self) -> Option<&str> { let TextOrCloze::Text(text) = self.nodes.first()? else { return None; }; text.strip_prefix("image-occlusion:") } } fn parse_text_with_clozes(text: &str) -> Vec> { let mut open_clozes: Vec = vec![]; let mut output = vec![]; for token in tokenize(text) { match token { Token::OpenCloze(ordinals) => { if open_clozes.len() < 10 { open_clozes.push(ExtractedCloze { ordinals, nodes: Vec::with_capacity(1), // common case hint: None, }) } } Token::Text(mut text) => { if let Some(cloze) = open_clozes.last_mut() { // extract hint if found if let Some((head, tail)) = text.split_once("::") { text = head; cloze.hint = Some(tail); } cloze.nodes.push(TextOrCloze::Text(text)); } else { output.push(TextOrCloze::Text(text)); } } Token::CloseCloze => { // take the currently active cloze if let Some(cloze) = open_clozes.pop() { let target = if let Some(outer_cloze) = open_clozes.last_mut() { // and place it into the cloze layer above &mut outer_cloze.nodes } else { // or the top level if no other clozes active &mut output }; target.push(TextOrCloze::Cloze(cloze)); } else { // closing marker outside of any clozes output.push(TextOrCloze::Text("}}")) } } } } output } fn reveal_cloze_text_in_nodes( node: &TextOrCloze, cloze_ord: u16, question: bool, output: &mut Vec, ) { if let TextOrCloze::Cloze(cloze) = node { if cloze.contains_ordinal(cloze_ord) { if question { output.push(cloze.hint().into()) } else { output.push(cloze.clozed_text().into()) } } for node in &cloze.nodes { reveal_cloze_text_in_nodes(node, cloze_ord, question, output); } } } fn reveal_cloze( cloze: &ExtractedCloze, cloze_ord: u16, question: bool, active_cloze_found_in_text: &mut bool, buf: &mut String, ) { let active = cloze.contains_ordinal(cloze_ord); *active_cloze_found_in_text |= active; if let Some(image_occlusion_text) = cloze.image_occlusion() { buf.push_str(&render_image_occlusion( image_occlusion_text, question, active, &cloze.ordinals, )); return; } match (question, active) { (true, true) => { // question side with active cloze; all inner content is elided let mut content_buf = String::new(); for node in &cloze.nodes { match node { TextOrCloze::Text(text) => content_buf.push_str(text), TextOrCloze::Cloze(cloze) => reveal_cloze( cloze, cloze_ord, question, active_cloze_found_in_text, &mut content_buf, ), } } write!( buf, r#"[{}]"#, encode_attribute(&content_buf), ordinals_str(&cloze.ordinals), cloze.hint() ) .unwrap(); } (false, true) => { write!( buf, r#""#, ordinals_str(&cloze.ordinals) ) .unwrap(); for node in &cloze.nodes { match node { TextOrCloze::Text(text) => buf.push_str(text), TextOrCloze::Cloze(cloze) => { reveal_cloze(cloze, cloze_ord, question, active_cloze_found_in_text, buf) } } } buf.push_str(""); } (_, false) => { // question or answer side inactive cloze; text shown, children may be active write!( buf, r#""#, ordinals_str(&cloze.ordinals) ) .unwrap(); for node in &cloze.nodes { match node { TextOrCloze::Text(text) => buf.push_str(text), TextOrCloze::Cloze(cloze) => { reveal_cloze(cloze, cloze_ord, question, active_cloze_found_in_text, buf) } } } buf.push_str("") } } } fn render_image_occlusion( text: &str, question_side: bool, active: bool, ordinals: &[u16], ) -> String { if (question_side && active) || ordinals.contains(&0) { format!( r#"

"#, ordinals_str(ordinals), &get_image_cloze_data(text) ) } else if !active { format!( r#"
"#, ordinals_str(ordinals), &get_image_cloze_data(text) ) } else if !question_side && active { format!( r#"
"#, ordinals_str(ordinals), &get_image_cloze_data(text) ) } else { "".into() } } pub fn parse_image_occlusions(text: &str) -> Vec { let mut occlusions: HashMap> = HashMap::new(); for node in parse_text_with_clozes(text) { if let TextOrCloze::Cloze(cloze) = node { if cloze.image_occlusion().is_some() { if let Some(shape) = parse_image_cloze(cloze.image_occlusion().unwrap()) { // Associate this occlusion with all ordinals in this cloze for &ordinal in &cloze.ordinals { occlusions.entry(ordinal).or_default().push(shape.clone()); } } } } } occlusions .iter() .map(|(k, v)| ImageOcclusion { ordinal: *k as u32, shapes: v.to_vec(), }) .collect() } pub fn reveal_cloze_text(text: &str, cloze_ord: u16, question: bool) -> Cow<'_, str> { let mut buf = String::new(); let mut active_cloze_found_in_text = false; for node in &parse_text_with_clozes(text) { match node { // top-level text is indiscriminately added TextOrCloze::Text(text) => buf.push_str(text), TextOrCloze::Cloze(cloze) => reveal_cloze( cloze, cloze_ord, question, &mut active_cloze_found_in_text, &mut buf, ), } } if active_cloze_found_in_text { buf.into() } else { Cow::from("") } } pub fn reveal_cloze_text_only(text: &str, cloze_ord: u16, question: bool) -> Cow<'_, str> { let mut output = Vec::new(); for node in &parse_text_with_clozes(text) { reveal_cloze_text_in_nodes(node, cloze_ord, question, &mut output); } output.join(", ").into() } pub fn extract_cloze_for_typing(text: &str, cloze_ord: u16) -> Cow<'_, str> { let mut output = Vec::new(); for node in &parse_text_with_clozes(text) { reveal_cloze_text_in_nodes(node, cloze_ord, false, &mut output); } if output.is_empty() { "".into() } else if output.iter().min() == output.iter().max() { // If all matches are identical text, they get collapsed into a single entry output.pop().unwrap().into() } else { output.join(", ").into() } } /// If text contains any LaTeX tags, render the front and back /// of each cloze deletion so that LaTeX can be generated. If /// no LaTeX is found, returns an empty string. pub fn expand_clozes_to_reveal_latex(text: &str) -> String { if !contains_latex(text) { return "".into(); } let ords = cloze_numbers_in_string(text); let mut buf = String::new(); for ord in ords { buf += reveal_cloze_text(text, ord, true).as_ref(); buf += reveal_cloze_text(text, ord, false).as_ref(); } buf } // Whether `text` contains any cloze number above 0 pub(crate) fn contains_cloze(text: &str) -> bool { parse_text_with_clozes(text) .iter() .any(|node| matches!(node, TextOrCloze::Cloze(e) if e.ordinals.iter().any(|&o| o != 0))) } /// Returns the set of cloze number as they appear in the fields's content. pub fn cloze_numbers_in_string(html: &str) -> HashSet { let mut set = HashSet::with_capacity(4); add_cloze_numbers_in_string(html, &mut set); set } fn add_cloze_numbers_in_text_with_clozes(nodes: &[TextOrCloze], set: &mut HashSet) { for node in nodes { if let TextOrCloze::Cloze(cloze) = node { for &ordinal in &cloze.ordinals { if ordinal != 0 { set.insert(ordinal); } } add_cloze_numbers_in_text_with_clozes(&cloze.nodes, set); } } } /// Add to `set` the cloze numbers as they appear in `field`. #[allow(clippy::implicit_hasher)] pub fn add_cloze_numbers_in_string(field: &str, set: &mut HashSet) { add_cloze_numbers_in_text_with_clozes(&parse_text_with_clozes(field), set) } /// The set of cloze numbers as they appear in any of the fields from `fields`. pub fn cloze_number_in_fields(fields: impl IntoIterator>) -> HashSet { let mut set = HashSet::with_capacity(4); for field in fields { add_cloze_numbers_in_string(field.as_ref(), &mut set); } set } pub(crate) fn strip_clozes(text: &str) -> Cow<'_, str> { CLOZE.replace_all(text, "$1") } fn strip_html_inside_mathjax(text: &str) -> Cow<'_, str> { MATHJAX.replace_all(text, |caps: &Captures| -> String { format!( "{}{}{}", caps.get(mathjax_caps::OPENING_TAG).unwrap().as_str(), strip_html_preserving_entities(caps.get(mathjax_caps::INNER_TEXT).unwrap().as_str()) .as_ref(), caps.get(mathjax_caps::CLOSING_TAG).unwrap().as_str() ) }) } pub(crate) fn cloze_filter<'a>(text: &'a str, context: &RenderContext) -> Cow<'a, str> { strip_html_inside_mathjax( reveal_cloze_text(text, context.card_ord + 1, context.frontside.is_none()).as_ref(), ) .into_owned() .into() } pub(crate) fn cloze_only_filter<'a>(text: &'a str, context: &RenderContext) -> Cow<'a, str> { reveal_cloze_text_only(text, context.card_ord + 1, context.frontside.is_none()) } #[cfg(test)] mod test { use std::collections::HashSet; use super::*; use crate::text::strip_html; #[test] fn cloze() { assert_eq!( cloze_numbers_in_string("test"), vec![].into_iter().collect::>() ); assert_eq!( cloze_numbers_in_string("{{c2::te}}{{c1::s}}t{{"), vec![1, 2].into_iter().collect::>() ); assert_eq!( cloze_numbers_in_string("{{c0::te}}s{{c2::t}}s"), vec![2].into_iter().collect::>() ); assert_eq!( expand_clozes_to_reveal_latex("{{c1::foo}} {{c2::bar::baz}}"), "".to_string() ); let expanded = expand_clozes_to_reveal_latex("[latex]{{c1::foo}} {{c2::bar::baz}}[/latex]"); let expanded = strip_html(expanded.as_ref()); assert!(expanded.contains("foo [baz]")); assert!(expanded.contains("[...] bar")); assert!(expanded.contains("foo bar")); } #[test] fn cloze_only() { assert_eq!(reveal_cloze_text_only("foo", 1, true), ""); assert_eq!(reveal_cloze_text_only("foo {{c1::bar}}", 1, true), "..."); assert_eq!( reveal_cloze_text_only("foo {{c1::bar::baz}}", 1, true), "baz" ); assert_eq!(reveal_cloze_text_only("foo {{c1::bar}}", 1, false), "bar"); assert_eq!(reveal_cloze_text_only("foo {{c1::bar}}", 2, false), ""); assert_eq!( reveal_cloze_text_only("{{c1::foo}} {{c1::bar}}", 1, false), "foo, bar" ); } #[test] fn clozes_for_typing() { assert_eq!(extract_cloze_for_typing("{{c2::foo}}", 1), ""); assert_eq!( extract_cloze_for_typing("{{c1::foo}} {{c1::bar}} {{c1::foo}}", 1), "foo, bar, foo" ); assert_eq!( extract_cloze_for_typing("{{c1::foo}} {{c1::foo}} {{c1::foo}}", 1), "foo" ); } #[test] fn nested_cloze_plain_text() { assert_eq!( strip_html(reveal_cloze_text("foo {{c1::bar {{c2::baz}}}}", 1, true).as_ref()), "foo [...]" ); assert_eq!( strip_html(reveal_cloze_text("foo {{c1::bar {{c2::baz}}}}", 1, false).as_ref()), "foo bar baz" ); assert_eq!( strip_html(reveal_cloze_text("foo {{c1::bar {{c2::baz}}::qux}}", 2, true).as_ref()), "foo bar [...]" ); assert_eq!( strip_html(reveal_cloze_text("foo {{c1::bar {{c2::baz}}::qux}}", 2, false).as_ref()), "foo bar baz" ); assert_eq!( strip_html(reveal_cloze_text("foo {{c1::bar {{c2::baz}}::qux}}", 1, true).as_ref()), "foo [qux]" ); assert_eq!( strip_html(reveal_cloze_text("foo {{c1::bar {{c2::baz}}::qux}}", 1, false).as_ref()), "foo bar baz" ); } #[test] fn nested_cloze_html() { assert_eq!( cloze_numbers_in_string("{{c2::te{{c1::s}}}}t{{"), vec![1, 2].into_iter().collect::>() ); assert_eq!( reveal_cloze_text("foo {{c1::bar {{c2::baz}}}}", 1, true), format!( r#"foo [...]"#, htmlescape::encode_attribute( r#"bar baz"# ) ) ); assert_eq!( reveal_cloze_text("foo {{c1::bar {{c2::baz}}}}", 1, false), r#"foo bar baz"# ); assert_eq!( reveal_cloze_text("foo {{c1::bar {{c2::baz}}::qux}}", 2, true), r#"foo bar [...]"# ); assert_eq!( reveal_cloze_text("foo {{c1::bar {{c2::baz}}::qux}}", 2, false), r#"foo bar baz"# ); assert_eq!( reveal_cloze_text("foo {{c1::bar {{c2::baz}}::qux}}", 1, true), format!( r#"foo [qux]"#, htmlescape::encode_attribute( r#"bar baz"# ) ) ); assert_eq!( reveal_cloze_text("foo {{c1::bar {{c2::baz}}::qux}}", 1, false), r#"foo bar baz"# ); } #[test] fn strip_clozes_regex() { assert_eq!( strip_clozes( r#"The {{c1::moon::🌛}} {{c2::orbits::this hint has "::" in it}} the {{c3::🌏}}."# ), "The moon orbits the 🌏." ); } #[test] fn mathjax_html() { // escaped angle brackets should be preserved assert_eq!( strip_html_inside_mathjax(r"\(<>\)"), r"\(<>\)" ); } #[test] fn non_latin() { assert!(cloze_numbers_in_string("öaöaöööaö").is_empty()); } #[test] fn image_cloze() { assert_eq!( reveal_cloze_text( "{{c1::image-occlusion:rect:left=10.0:top=20:width=30:height=10}}", 1, true ), format!( r#"
"#, ) ); } #[test] fn multi_card_card_generation() { let text = "{{c1,2,3::multi}}"; assert_eq!( cloze_number_in_fields(vec![text]), vec![1, 2, 3].into_iter().collect::>() ); } #[test] fn multi_card_cloze_basic() { let text = "{{c1,2::shared}} word and {{c1::first}} vs {{c2::second}}"; assert_eq!( strip_html(&reveal_cloze_text(text, 1, true)).as_ref(), "[...] word and [...] vs second" ); assert_eq!( strip_html(&reveal_cloze_text(text, 2, true)).as_ref(), "[...] word and first vs [...]" ); assert_eq!( strip_html(&reveal_cloze_text(text, 1, false)).as_ref(), "shared word and first vs second" ); assert_eq!( strip_html(&reveal_cloze_text(text, 2, false)).as_ref(), "shared word and first vs second" ); assert_eq!( cloze_numbers_in_string(text), vec![1, 2].into_iter().collect::>() ); } #[test] fn multi_card_cloze_html_attributes() { let text = "{{c1,2,3::multi}}"; let card1_html = reveal_cloze_text(text, 1, true); assert!(card1_html.contains(r#"data-ordinal="1,2,3""#)); let card2_html = reveal_cloze_text(text, 2, true); assert!(card2_html.contains(r#"data-ordinal="1,2,3""#)); let card3_html = reveal_cloze_text(text, 3, true); assert!(card3_html.contains(r#"data-ordinal="1,2,3""#)); } #[test] fn multi_card_cloze_with_hints() { let text = "{{c1,2::answer::hint}}"; assert_eq!( strip_html(&reveal_cloze_text(text, 1, true)).as_ref(), "[hint]" ); assert_eq!( strip_html(&reveal_cloze_text(text, 2, true)).as_ref(), "[hint]" ); assert_eq!( strip_html(&reveal_cloze_text(text, 1, false)).as_ref(), "answer" ); assert_eq!( strip_html(&reveal_cloze_text(text, 2, false)).as_ref(), "answer" ); } #[test] fn multi_card_cloze_edge_cases() { assert_eq!( cloze_numbers_in_string("{{c1,1,2::test}}"), vec![1, 2].into_iter().collect::>() ); assert_eq!( cloze_numbers_in_string("{{c0,1,2::test}}"), vec![1, 2].into_iter().collect::>() ); assert_eq!( cloze_numbers_in_string("{{c1,,3::test}}"), vec![1, 3].into_iter().collect::>() ); } #[test] fn multi_card_cloze_only_filter() { let text = "{{c1,2::shared}} and {{c1::first}} vs {{c2::second}}"; assert_eq!(reveal_cloze_text_only(text, 1, true), "..., ..."); assert_eq!(reveal_cloze_text_only(text, 2, true), "..., ..."); assert_eq!(reveal_cloze_text_only(text, 1, false), "shared, first"); assert_eq!(reveal_cloze_text_only(text, 2, false), "shared, second"); } #[test] fn multi_card_nested_cloze() { let text = "{{c1,2::outer {{c3::inner}}}}"; assert_eq!( strip_html(&reveal_cloze_text(text, 1, true)).as_ref(), "[...]" ); assert_eq!( strip_html(&reveal_cloze_text(text, 2, true)).as_ref(), "[...]" ); assert_eq!( strip_html(&reveal_cloze_text(text, 3, true)).as_ref(), "outer [...]" ); assert_eq!( cloze_numbers_in_string(text), vec![1, 2, 3].into_iter().collect::>() ); } #[test] fn nested_parent_child_card_same_cloze() { let text = "{{c1::outer {{c1::inner}}}}"; assert_eq!( strip_html(&reveal_cloze_text(text, 1, true)).as_ref(), "[...]" ); assert_eq!( cloze_numbers_in_string(text), vec![1].into_iter().collect::>() ); } #[test] fn multi_card_image_occlusion() { let text = "{{c1,2::image-occlusion:rect:left=10:top=20:width=30:height=40}}"; let occlusions = parse_image_occlusions(text); assert_eq!(occlusions.len(), 2); assert!(occlusions.iter().any(|o| o.ordinal == 1)); assert!(occlusions.iter().any(|o| o.ordinal == 2)); let card1_html = reveal_cloze_text(text, 1, true); assert!(card1_html.contains(r#"data-ordinal="1,2""#)); let card2_html = reveal_cloze_text(text, 2, true); assert!(card2_html.contains(r#"data-ordinal="1,2""#)); } } ================================================ FILE: rslib/src/collection/backup.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::ffi::OsStr; use std::fs::read_dir; use std::fs::remove_file; use std::fs::DirEntry; use std::path::Path; use std::path::PathBuf; use std::thread; use std::thread::JoinHandle; use std::time::SystemTime; use anki_io::read_locked_db_file; use anki_proto::config::preferences::BackupLimits; use chrono::prelude::*; use itertools::Itertools; use tracing::error; use crate::import_export::package::export_colpkg_from_data; use crate::prelude::*; const BACKUP_FORMAT_STRING: &str = "backup-%Y-%m-%d-%H.%M.%S.colpkg"; impl Collection { /// Create a backup if enough time has elapsed, or if forced. /// Returns a handle that can be awaited if a backup was created. pub fn maybe_backup( &mut self, backup_folder: impl AsRef + Send + 'static, force: bool, ) -> Result>>> { if !self.changed_since_last_backup()? { return Ok(None); } let limits = self.get_backup_limits(); if should_skip_backup(force, limits.minimum_interval_mins, backup_folder.as_ref())? { Ok(None) } else { let tr = self.tr.clone(); self.storage.checkpoint()?; let col_data = read_locked_db_file(&self.col_path)?; self.update_last_backup_timestamp()?; Ok(Some(thread::spawn(move || { backup_inner(&col_data, &backup_folder, limits, &tr) }))) } } } fn should_skip_backup( force: bool, minimum_interval_mins: u32, backup_folder: &Path, ) -> Result { if force { Ok(false) } else { has_recent_backup(backup_folder, minimum_interval_mins) } } fn has_recent_backup(backup_folder: &Path, recent_mins: u32) -> Result { let recent_secs = (recent_mins * 60) as u64; let now = SystemTime::now(); Ok(read_dir(backup_folder)? .filter_map(|res| res.ok()) .filter_map(|entry| entry.metadata().ok()) .filter_map(|meta| { // created time unsupported on Android #[cfg(target_os = "android")] { meta.modified().ok() } #[cfg(not(target_os = "android"))] { meta.created().ok() } }) .filter_map(|time| now.duration_since(time).ok()) .any(|duration| duration.as_secs() < recent_secs)) } fn backup_inner>( col_data: &[u8], backup_folder: P, limits: BackupLimits, tr: &I18n, ) -> Result<()> { write_backup(col_data, backup_folder.as_ref(), tr)?; thin_backups(backup_folder, limits) } fn write_backup>(col_data: &[u8], backup_folder: S, tr: &I18n) -> Result<()> { let out_path = Path::new(&backup_folder).join(format!("{}", Local::now().format(BACKUP_FORMAT_STRING))); export_colpkg_from_data(out_path, col_data, tr) } fn thin_backups>(backup_folder: P, limits: BackupLimits) -> Result<()> { let backups = read_dir(backup_folder)?.filter_map(|entry| entry.ok().and_then(Backup::from_entry)); let obsolete_backups = BackupFilter::new(Local::now(), limits).obsolete_backups(backups); for backup in obsolete_backups { if let Err(error) = remove_file(&backup.path) { error!("failed to remove {:?}: {error:?}", &backup.path); }; } Ok(()) } fn datetime_from_file_name(file_name: &str) -> Option> { NaiveDateTime::parse_from_str(file_name, BACKUP_FORMAT_STRING) .ok() .and_then(|datetime| Local.from_local_datetime(&datetime).latest()) } #[derive(Debug, PartialEq, Eq, Clone)] struct Backup { path: PathBuf, datetime: DateTime, } impl Backup { /// Serial day number fn day(&self) -> i32 { self.datetime.num_days_from_ce() } /// Serial week number, starting on Monday fn week(&self) -> i32 { // Day 1 (01/01/01) was a Monday, meaning week rolled over on Sunday (when day % // 7 == 0). We subtract 1 to shift the rollover to Monday. (self.day() - 1) / 7 } /// Serial month number fn month(&self) -> u32 { self.datetime.year() as u32 * 12 + self.datetime.month() } } impl Backup { fn from_entry(entry: DirEntry) -> Option { entry .file_name() .to_str() .and_then(datetime_from_file_name) .map(|datetime| Self { path: entry.path(), datetime, }) } } #[derive(Debug)] struct BackupFilter { yesterday: i32, last_kept_day: i32, last_kept_week: i32, last_kept_month: u32, limits: BackupLimits, obsolete: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum BackupStage { Daily, Weekly, Monthly, } impl BackupFilter { fn new(today: DateTime, limits: BackupLimits) -> Self { Self { yesterday: today.num_days_from_ce() - 1, last_kept_day: i32::MAX, last_kept_week: i32::MAX, last_kept_month: u32::MAX, limits, obsolete: Vec::new(), } } fn obsolete_backups(mut self, backups: impl Iterator) -> Vec { use BackupStage::*; for backup in backups .sorted_unstable_by_key(|b| b.datetime.timestamp()) .rev() { if self.is_recent(&backup) { self.mark_fresh(None, backup); } else if self.remaining(Daily) { self.mark_fresh_or_obsolete(Daily, backup); } else if self.remaining(Weekly) { self.mark_fresh_or_obsolete(Weekly, backup); } else if self.remaining(Monthly) { self.mark_fresh_or_obsolete(Monthly, backup); } else { self.mark_obsolete(backup); } } self.obsolete } fn is_recent(&self, backup: &Backup) -> bool { backup.day() >= self.yesterday } fn remaining(&self, stage: BackupStage) -> bool { match stage { BackupStage::Daily => self.limits.daily > 0, BackupStage::Weekly => self.limits.weekly > 0, BackupStage::Monthly => self.limits.monthly > 0, } } fn mark_fresh_or_obsolete(&mut self, stage: BackupStage, backup: Backup) { let keep = match stage { BackupStage::Daily => backup.day() < self.last_kept_day, BackupStage::Weekly => backup.week() < self.last_kept_week, BackupStage::Monthly => backup.month() < self.last_kept_month, }; if keep { self.mark_fresh(Some(stage), backup); } else { self.mark_obsolete(backup); } } /// Adjusts limits as per the stage of the kept backup, and last kept times. fn mark_fresh(&mut self, stage: Option, backup: Backup) { self.last_kept_day = backup.day(); self.last_kept_week = backup.week(); self.last_kept_month = backup.month(); match stage { None => (), Some(BackupStage::Daily) => self.limits.daily -= 1, Some(BackupStage::Weekly) => self.limits.weekly -= 1, Some(BackupStage::Monthly) => self.limits.monthly -= 1, } } fn mark_obsolete(&mut self, backup: Backup) { self.obsolete.push(backup); } } #[cfg(test)] mod test { use super::*; macro_rules! backup { ($num_days_from_ce:expr) => { Backup { datetime: Local .from_local_datetime( &NaiveDate::from_num_days_from_ce_opt($num_days_from_ce) .unwrap() .and_hms_opt(0, 0, 0) .unwrap(), ) .latest() .unwrap(), path: PathBuf::new(), } }; ($year:expr, $month:expr, $day:expr) => { Backup { datetime: Local .with_ymd_and_hms($year, $month, $day, 0, 0, 0) .latest() .unwrap(), path: PathBuf::new(), } }; ($year:expr, $month:expr, $day:expr, $hour:expr, $min:expr, $sec:expr) => { Backup { datetime: Local .with_ymd_and_hms($year, $month, $day, $hour, $min, $sec) .latest() .unwrap(), path: PathBuf::new(), } }; } #[test] fn thinning_manual() { let today = Local .with_ymd_and_hms(2022, 2, 22, 0, 0, 0) .latest() .unwrap(); let limits = BackupLimits { daily: 3, weekly: 2, monthly: 1, ..Default::default() }; // true => should be removed let backups = [ // grace period (backup!(2022, 2, 22), false), (backup!(2022, 2, 22), false), (backup!(2022, 2, 21), false), // daily (backup!(2022, 2, 20, 6, 0, 0), true), (backup!(2022, 2, 20, 18, 0, 0), false), (backup!(2022, 2, 10), false), (backup!(2022, 2, 9), false), // weekly (backup!(2022, 2, 7), true), // Monday, week already backed up (backup!(2022, 2, 6, 1, 0, 0), true), (backup!(2022, 2, 6, 2, 0, 0), false), (backup!(2022, 1, 6), false), // monthly (backup!(2022, 1, 5), true), (backup!(2021, 12, 24), false), (backup!(2021, 12, 1), true), (backup!(2021, 11, 1), true), ]; let expected: Vec<_> = backups .iter() .filter(|b| b.1) .map(|b| b.0.clone()) .collect(); let obsolete_backups = BackupFilter::new(today, limits).obsolete_backups(backups.into_iter().map(|b| b.0)); assert_eq!(obsolete_backups, expected); } #[test] fn thinning_generic() { let today = Local .with_ymd_and_hms(2022, 1, 1, 0, 0, 0) .latest() .unwrap(); let today_ce_days = today.num_days_from_ce(); let limits = BackupLimits { // config defaults daily: 12, weekly: 10, monthly: 9, ..Default::default() }; let backups: Vec<_> = (1..366).map(|i| backup!(today_ce_days - i)).collect(); let mut expected = Vec::new(); // one day grace period, then daily backups let mut backup_iter = backups.iter().skip(1 + limits.daily as usize); // weekly backups from the last day of the week (Sunday) for _ in 0..limits.weekly { for backup in backup_iter.by_ref() { if backup.datetime.weekday() == Weekday::Sun { break; } else { expected.push(backup.clone()) } } } // monthly backups from the last day of the month for _ in 0..limits.monthly { for backup in backup_iter.by_ref() { if backup.datetime.month() != backup.datetime.date_naive().succ_opt().unwrap().month() { break; } else { expected.push(backup.clone()) } } } // limits reached; collect rest backup_iter .cloned() .for_each(|backup| expected.push(backup)); let obsolete_backups = BackupFilter::new(today, limits).obsolete_backups(backups.into_iter()); assert_eq!(obsolete_backups, expected); } } ================================================ FILE: rslib/src/collection/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub mod backup; mod service; pub(crate) mod timestamps; mod transact; pub(crate) mod undo; use std::collections::HashMap; use std::fmt::Debug; use std::fmt::Formatter; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; use anki_i18n::I18n; use anki_io::create_dir_all; use crate::browser_table; use crate::decks::Deck; use crate::decks::DeckId; use crate::error::Result; use crate::notetype::Notetype; use crate::notetype::NotetypeId; use crate::progress::ProgressState; use crate::scheduler::queue::CardQueues; use crate::scheduler::SchedulerInfo; use crate::storage::SchemaVersion; use crate::storage::SqliteStorage; use crate::timestamp::TimestampMillis; use crate::types::Usn; use crate::undo::UndoManager; #[derive(Default)] pub struct CollectionBuilder { collection_path: Option, media_folder: Option, media_db: Option, server: Option, tr: Option, check_integrity: bool, progress_handler: Option>>, } impl CollectionBuilder { /// Create a new builder with the provided collection path. /// If an in-memory database is desired, used ::default() instead. pub fn new(col_path: impl Into) -> Self { let mut builder = Self::default(); builder.set_collection_path(col_path); builder } pub fn build(&mut self) -> Result { let col_path = self .collection_path .clone() .unwrap_or_else(|| PathBuf::from(":memory:")); let tr = self.tr.clone().unwrap_or_else(I18n::template_only); let server = self.server.unwrap_or_default(); let media_folder = self.media_folder.clone().unwrap_or_default(); let media_db = self.media_db.clone().unwrap_or_default(); let storage = SqliteStorage::open_or_create(&col_path, &tr, server, self.check_integrity)?; let col = Collection { storage, col_path, media_folder, media_db, tr, server, state: CollectionState { progress: self.progress_handler.clone().unwrap_or_default(), ..Default::default() }, }; Ok(col) } pub fn set_collection_path>(&mut self, collection: P) -> &mut Self { self.collection_path = Some(collection.into()); self } pub fn set_media_paths>(&mut self, media_folder: P, media_db: P) -> &mut Self { self.media_folder = Some(media_folder.into()); self.media_db = Some(media_db.into()); self } /// For a `foo.anki2` file, use `foo.media` and `foo.mdb`. Mobile clients /// use different paths, so the backend must continue to use /// [set_media_paths]. pub fn with_desktop_media_paths(&mut self) -> &mut Self { let col_path = self.collection_path.as_ref().unwrap(); let media_folder = col_path.with_extension("media"); create_dir_all(&media_folder).expect("creating media folder"); let media_db = col_path.with_extension("mdb"); self.set_media_paths(media_folder, media_db) } pub fn set_server(&mut self, server: bool) -> &mut Self { self.server = Some(server); self } pub fn set_tr(&mut self, tr: I18n) -> &mut Self { self.tr = Some(tr); self } pub fn set_check_integrity(&mut self, check_integrity: bool) -> &mut Self { self.check_integrity = check_integrity; self } /// If provided, progress info will be written to the provided mutex, and /// can be tracked on a separate thread. pub fn set_shared_progress_state(&mut self, state: Arc>) -> &mut Self { self.progress_handler = Some(state); self } } #[derive(Debug, Default)] pub struct CollectionState { pub(crate) undo: UndoManager, pub(crate) notetype_cache: HashMap>, pub(crate) deck_cache: HashMap>, pub(crate) scheduler_info: Option, pub(crate) card_queues: Option, pub(crate) active_browser_columns: Option>>, /// True if legacy Python code has executed SQL that has modified the /// database, requiring modification time to be bumped. pub(crate) modified_by_dbproxy: bool, /// The modification time at the last backup, so we don't create multiple /// identical backups. pub(crate) last_backup_modified: Option, pub(crate) progress: Arc>, } pub struct Collection { pub storage: SqliteStorage, pub(crate) col_path: PathBuf, pub(crate) media_folder: PathBuf, pub(crate) media_db: PathBuf, pub(crate) tr: I18n, pub(crate) server: bool, pub(crate) state: CollectionState, } impl Debug for Collection { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.debug_struct("Collection") .field("col_path", &self.col_path) .finish() } } impl Collection { pub fn as_builder(&self) -> CollectionBuilder { let mut builder = CollectionBuilder::new(&self.col_path); builder .set_media_paths(self.media_folder.clone(), self.media_db.clone()) .set_server(self.server) .set_tr(self.tr.clone()) .set_shared_progress_state(self.state.progress.clone()); builder } // A count of all changed rows since the collection was opened, which can be // used to detect if the collection was modified or not. pub fn changes_since_open(&self) -> Result { self.storage .db .query_row("select total_changes()", [], |row| row.get(0)) .map_err(Into::into) } pub fn close(self, desired_version: Option) -> Result<()> { self.storage.close(desired_version) } pub(crate) fn usn(&self) -> Result { // if we cache this in the future, must make sure to invalidate cache when usn // bumped in sync.finish() self.storage.usn(self.server) } /// Prepare for upload. Caller should not create transaction. pub(crate) fn before_upload(&mut self) -> Result<()> { self.transact_no_undo(|col| { col.storage.clear_all_graves()?; col.storage.clear_pending_note_usns()?; col.storage.clear_pending_card_usns()?; col.storage.clear_pending_revlog_usns()?; col.storage.clear_tag_usns()?; col.storage.clear_deck_conf_usns()?; col.storage.clear_deck_usns()?; col.storage.clear_notetype_usns()?; col.storage.increment_usn()?; col.set_schema_modified()?; col.storage .set_last_sync(col.storage.get_collection_timestamps()?.schema_change) })?; self.storage.optimize() } pub(crate) fn clear_caches(&mut self) { self.state.deck_cache.clear(); self.state.notetype_cache.clear(); } pub fn tr(&self) -> &I18n { &self.tr } } ================================================ FILE: rslib/src/collection/service.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::collection::GetCustomColoursResponse; use anki_proto::generic; use crate::collection::Collection; use crate::config::ConfigKey; use crate::error; use crate::prelude::BoolKey; use crate::prelude::Op; use crate::progress::progress_to_proto; impl crate::services::CollectionService for Collection { fn check_database(&mut self) -> error::Result { { self.check_database() .map(|problems| anki_proto::collection::CheckDatabaseResponse { problems: problems.to_i18n_strings(&self.tr), }) } } fn get_undo_status(&mut self) -> error::Result { Ok(self.undo_status().into_protobuf(&self.tr)) } fn undo(&mut self) -> error::Result { self.undo().map(|out| out.into_protobuf(&self.tr)) } fn redo(&mut self) -> error::Result { self.redo().map(|out| out.into_protobuf(&self.tr)) } fn add_custom_undo_entry(&mut self, input: generic::String) -> error::Result { Ok(self.add_custom_undo_step(input.val).into()) } fn merge_undo_entries( &mut self, input: generic::UInt32, ) -> error::Result { let starting_from = input.val as usize; self.merge_undoable_ops(starting_from).map(Into::into) } fn latest_progress(&mut self) -> error::Result { let progress = self.state.progress.lock().unwrap().last_progress; Ok(progress_to_proto(progress, &self.tr)) } fn set_wants_abort(&mut self) -> error::Result<()> { self.state.progress.lock().unwrap().want_abort = true; Ok(()) } fn set_load_balancer_enabled( &mut self, input: generic::Bool, ) -> error::Result { self.transact(Op::ToggleLoadBalancer, |col| { col.set_config(BoolKey::LoadBalancerEnabled, &input.val)?; Ok(()) }) .map(Into::into) } fn get_custom_colours( &mut self, ) -> error::Result { let colours = self .get_config_optional(ConfigKey::CustomColorPickerPalette) .unwrap_or_default(); Ok(GetCustomColoursResponse { colours }) } } ================================================ FILE: rslib/src/collection/timestamps.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::prelude::*; pub(crate) struct CollectionTimestamps { pub collection_change: TimestampMillis, pub schema_change: TimestampMillis, pub last_sync: TimestampMillis, } impl CollectionTimestamps { pub fn collection_changed_since_sync(&self) -> bool { self.collection_change > self.last_sync } pub fn schema_changed_since_sync(&self) -> bool { self.schema_change > self.last_sync } } impl Collection { /// This is done automatically when you call collection methods, so callers /// outside this crate should only need this if you are manually /// modifying the database. pub fn set_modified(&mut self) -> Result<()> { let stamps = self.storage.get_collection_timestamps()?; self.set_modified_time_undoable(TimestampMillis::now(), stamps.collection_change) } /// Forces the next sync in one direction. pub fn set_schema_modified(&mut self) -> Result<()> { let stamps = self.storage.get_collection_timestamps()?; self.set_schema_modified_time_undoable(TimestampMillis::now(), stamps.schema_change) } pub fn changed_since_last_backup(&self) -> Result { let stamps = self.storage.get_collection_timestamps()?; Ok(self .state .last_backup_modified .map(|last_backup| last_backup != stamps.collection_change) .unwrap_or(true)) } pub(crate) fn update_last_backup_timestamp(&mut self) -> Result<()> { self.state.last_backup_modified = Some(self.storage.get_collection_timestamps()?.collection_change); Ok(()) } } ================================================ FILE: rslib/src/collection/transact.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::ops::StateChanges; use crate::prelude::*; impl Collection { fn transact_inner(&mut self, op: Option, func: F) -> Result> where F: FnOnce(&mut Collection) -> Result, { let have_op = op.is_some(); let skip_undo_queue = op == Some(Op::SkipUndo); let autocommit = self.storage.db.is_autocommit(); self.storage.begin_rust_trx()?; self.begin_undoable_operation(op); func(self) .and_then(|output| { // any changes mean an mtime bump if !have_op || (self.current_undo_step_has_changes() && !self.undoing_or_redoing()) { self.set_modified()?; } // then commit self.storage.commit_rust_trx()?; // finalize undo let changes = if have_op { let changes = self.op_changes(); self.maybe_clear_study_queues_after_op(&changes); self.maybe_coalesce_note_undo_entry(&changes); changes } else { self.clear_study_queues(); // dummy value that will be discarded OpChanges { op: Op::SkipUndo, changes: StateChanges::default(), } }; self.end_undoable_operation(skip_undo_queue); Ok(OpOutput { output, changes }) }) // roll back on error .or_else(|err| { self.discard_undo_and_study_queues(); if autocommit { self.storage.rollback_trx()?; } else { self.storage.rollback_rust_trx()?; } Err(err) }) } /// Execute the provided closure in a transaction, rolling back if /// an error is returned. Records undo state, and returns changes. pub(crate) fn transact(&mut self, op: Op, func: F) -> Result> where F: FnOnce(&mut Collection) -> Result, { self.transact_inner(Some(op), func) } /// Execute the provided closure in a transaction, rolling back if /// an error is returned. pub(crate) fn transact_no_undo(&mut self, func: F) -> Result where F: FnOnce(&mut Collection) -> Result, { self.transact_inner(None, func).map(|out| out.output) } } ================================================ FILE: rslib/src/collection/undo.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::prelude::*; #[derive(Debug)] pub(crate) enum UndoableCollectionChange { Schema(TimestampMillis), Modified(TimestampMillis), } impl Collection { pub(crate) fn undo_collection_change( &mut self, change: UndoableCollectionChange, ) -> Result<()> { match change { UndoableCollectionChange::Schema(schema) => { let current = self.storage.get_collection_timestamps()?.schema_change; self.set_schema_modified_time_undoable(schema, current) } UndoableCollectionChange::Modified(modified) => { let current = self.storage.get_collection_timestamps()?.collection_change; self.set_modified_time_undoable(modified, current) } } } pub(super) fn set_modified_time_undoable( &mut self, modified: TimestampMillis, original: TimestampMillis, ) -> Result<()> { self.save_undo(UndoableCollectionChange::Modified(original)); self.storage.set_modified_time(modified) } pub(super) fn set_schema_modified_time_undoable( &mut self, schema: TimestampMillis, original: TimestampMillis, ) -> Result<()> { self.save_undo(UndoableCollectionChange::Schema(original)); self.storage.set_schema_modified_time(schema) } } ================================================ FILE: rslib/src/config/bool.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use strum::IntoStaticStr; use crate::prelude::*; #[derive(Debug, Clone, Copy, IntoStaticStr)] #[strum(serialize_all = "camelCase")] pub enum BoolKey { ApplyAllParentLimits, BrowserTableShowNotesMode, CardCountsSeparateInactive, CollapseCardState, CollapseDecks, CollapseFlags, CollapseNotetypes, CollapseSavedSearches, CollapseTags, CollapseToday, FutureDueShowBacklog, HideAudioPlayButtons, IgnoreAccentsInSearch, InterruptAudioWhenAnswering, NewCardsIgnoreReviewLimit, PasteImagesAsPng, PasteStripsFormatting, RenderLatex, PreviewBothSides, RestorePositionBrowser, RestorePositionReviewer, ResetCountsBrowser, ResetCountsReviewer, RandomOrderReposition, Sched2021, ShiftPositionOfExistingCards, MergeNotetypes, WithScheduling, WithDeckConfigs, Fsrs, FsrsHealthCheck, FsrsLegacyEvaluate, LoadBalancerEnabled, FsrsShortTermWithStepsEnabled, #[strum(to_string = "normalize_note_text")] NormalizeNoteText, #[strum(to_string = "dayLearnFirst")] ShowDayLearningCardsFirst, #[strum(to_string = "estTimes")] ShowIntervalsAboveAnswerButtons, #[strum(to_string = "dueCounts")] ShowRemainingDueCountsInStudy, #[strum(to_string = "addToCur")] AddingDefaultsToCurrentDeck, } impl Collection { pub fn get_config_bool(&self, key: BoolKey) -> bool { match key { // some keys default to true BoolKey::InterruptAudioWhenAnswering | BoolKey::ShowIntervalsAboveAnswerButtons | BoolKey::AddingDefaultsToCurrentDeck | BoolKey::FutureDueShowBacklog | BoolKey::ShowRemainingDueCountsInStudy | BoolKey::CardCountsSeparateInactive | BoolKey::RestorePositionBrowser | BoolKey::RestorePositionReviewer | BoolKey::LoadBalancerEnabled | BoolKey::FsrsHealthCheck | BoolKey::NormalizeNoteText => self.get_config_optional(key).unwrap_or(true), // other options default to false other => self.get_config_default(other), } } pub fn set_config_bool( &mut self, key: BoolKey, value: bool, undoable: bool, ) -> Result> { let op = if undoable { Op::UpdateConfig } else { Op::SkipUndo }; self.transact(op, |col| { col.set_config(key, &value)?; Ok(()) }) } } impl Collection { pub(crate) fn set_config_bool_inner(&mut self, key: BoolKey, value: bool) -> Result { self.set_config(key, &value) } } ================================================ FILE: rslib/src/config/deck.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use strum::IntoStaticStr; use crate::prelude::*; /// Auxiliary deck state, stored in the config table. #[derive(Debug, Clone, Copy, IntoStaticStr)] #[strum(serialize_all = "camelCase")] pub enum DeckConfigKey { LastNotetype, CustomStudyIncludeTags, CustomStudyExcludeTags, } impl DeckConfigKey { pub fn for_deck(self, did: DeckId) -> String { build_aux_deck_key(did, <&'static str>::from(self)) } } impl Collection { pub(crate) fn clear_aux_config_for_deck(&mut self, ntid: DeckId) -> Result<()> { self.remove_config_prefix(&build_aux_deck_key(ntid, "")) } pub(crate) fn get_last_notetype_for_deck(&self, id: DeckId) -> Option { let key = DeckConfigKey::LastNotetype.for_deck(id); self.get_config_optional(key.as_str()) } pub(crate) fn set_last_notetype_for_deck( &mut self, did: DeckId, ntid: NotetypeId, ) -> Result { let key = DeckConfigKey::LastNotetype.for_deck(did); self.set_config(key.as_str(), &ntid) } } fn build_aux_deck_key(deck: DeckId, key: &str) -> String { format!("_deck_{deck}_{key}") } ================================================ FILE: rslib/src/config/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod bool; mod deck; mod notetype; mod number; pub(crate) mod schema11; mod string; pub(crate) mod undo; use anki_proto::config::preferences::BackupLimits; use serde::de::DeserializeOwned; use serde::Serialize; use serde_repr::Deserialize_repr; use serde_repr::Serialize_repr; use strum::IntoStaticStr; pub use self::bool::BoolKey; pub use self::deck::DeckConfigKey; pub use self::notetype::get_aux_notetype_config_key; pub use self::number::I32ConfigKey; pub use self::string::StringKey; use crate::import_export::package::UpdateCondition; use crate::prelude::*; /// Only used when updating/undoing. #[derive(Debug)] pub(crate) struct ConfigEntry { pub key: String, pub value: Vec, pub usn: Usn, pub mtime: TimestampSecs, } impl ConfigEntry { pub(crate) fn boxed(key: &str, value: Vec, usn: Usn, mtime: TimestampSecs) -> Box { Box::new(Self { key: key.into(), value, usn, mtime, }) } } #[derive(IntoStaticStr)] #[strum(serialize_all = "camelCase")] pub(crate) enum ConfigKey { CreationOffset, FirstDayOfWeek, LocalOffset, Rollover, Backups, UpdateNotes, UpdateNotetypes, #[strum(to_string = "timeLim")] AnswerTimeLimitSecs, #[strum(to_string = "curDeck")] CurrentDeckId, #[strum(to_string = "curModel")] CurrentNotetypeId, #[strum(to_string = "lastUnburied")] LastUnburiedDay, #[strum(to_string = "collapseTime")] LearnAheadSecs, #[strum(to_string = "newSpread")] NewReviewMix, #[strum(to_string = "nextPos")] NextNewCardPosition, #[strum(to_string = "schedVer")] SchedulerVersion, CustomColorPickerPalette, } #[derive(PartialEq, Eq, Serialize_repr, Deserialize_repr, Clone, Copy, Debug)] #[repr(u8)] pub enum SchedulerVersion { V1 = 1, V2 = 2, } impl Collection { pub fn set_config_json( &mut self, key: &str, val: &T, undoable: bool, ) -> Result> { let op = if undoable { Op::UpdateConfig } else { Op::SkipUndo }; self.transact(op, |col| { col.set_config(key, val)?; Ok(()) }) } pub fn remove_config(&mut self, key: &str) -> Result> { self.transact(Op::UpdateConfig, |col| col.remove_config_inner(key)) } } impl Collection { /// Get config item, returning None if missing/invalid. pub(crate) fn get_config_optional<'a, T, K>(&self, key: K) -> Option where T: DeserializeOwned, K: Into<&'a str>, { let key = key.into(); match self.storage.get_config_value(key) { Ok(Some(val)) => Some(val), Ok(None) => None, // If the key is missing or invalid, we use the default value. Err(_) => None, } } // /// Get config item, returning default value if missing/invalid. pub(crate) fn get_config_default<'a, T, K>(&self, key: K) -> T where T: DeserializeOwned + Default, K: Into<&'a str>, { self.get_config_optional(key).unwrap_or_default() } /// True if added, or new value is different. pub(crate) fn set_config<'a, T: Serialize, K>(&mut self, key: K, val: &T) -> Result where K: Into<&'a str>, { let entry = ConfigEntry::boxed( key.into(), serde_json::to_vec(val)?, self.usn()?, TimestampSecs::now(), ); self.set_config_undoable(entry) } pub(crate) fn remove_config_inner<'a, K>(&mut self, key: K) -> Result<()> where K: Into<&'a str>, { self.remove_config_undoable(key.into()) } /// Remove all keys starting with provided prefix, which must end with '_'. pub(crate) fn remove_config_prefix(&mut self, key: &str) -> Result<()> { for (key, _val) in self.storage.get_config_prefix(key)? { self.remove_config_inner(key.as_str())?; } Ok(()) } pub(crate) fn get_creation_utc_offset(&self) -> Option { self.get_config_optional(ConfigKey::CreationOffset) } pub(crate) fn set_creation_utc_offset(&mut self, mins: Option) -> Result<()> { self.state.scheduler_info = None; if let Some(mins) = mins { self.set_config(ConfigKey::CreationOffset, &mins) .map(|_| ()) } else { self.remove_config_inner(ConfigKey::CreationOffset) } } /// In minutes west of UTC. pub fn get_configured_utc_offset(&self) -> Option { self.get_config_optional(ConfigKey::LocalOffset) } /// In minutes west of UTC. pub fn set_configured_utc_offset(&mut self, mins: i32) -> Result<()> { self.state.scheduler_info = None; self.set_config(ConfigKey::LocalOffset, &mins).map(|_| ()) } pub(crate) fn get_v2_rollover(&self) -> Option { self.get_config_optional::(ConfigKey::Rollover) .map(|r| r.min(23)) } pub(crate) fn set_v2_rollover(&mut self, hour: u32) -> Result<()> { self.state.scheduler_info = None; self.set_config(ConfigKey::Rollover, &hour).map(|_| ()) } pub(crate) fn get_next_card_position(&self) -> u32 { self.get_config_default(ConfigKey::NextNewCardPosition) } pub(crate) fn get_and_update_next_card_position(&mut self) -> Result { let pos: u32 = self .get_config_optional(ConfigKey::NextNewCardPosition) .unwrap_or_default(); self.set_config(ConfigKey::NextNewCardPosition, &pos.wrapping_add(1))?; Ok(pos) } pub(crate) fn set_next_card_position(&mut self, pos: u32) -> Result<()> { self.set_config(ConfigKey::NextNewCardPosition, &pos) .map(|_| ()) } pub(crate) fn scheduler_version(&self) -> SchedulerVersion { self.get_config_optional(ConfigKey::SchedulerVersion) .unwrap_or(SchedulerVersion::V1) } pub fn v2_enabled(&self) -> bool { self.scheduler_version() == SchedulerVersion::V2 } pub fn v3_enabled(&self) -> bool { self.scheduler_version() == SchedulerVersion::V2 && self.get_config_bool(BoolKey::Sched2021) } /// Caution: this only updates the config setting. pub(crate) fn set_scheduler_version_config_key(&mut self, ver: SchedulerVersion) -> Result<()> { self.state.scheduler_info = None; self.set_config(ConfigKey::SchedulerVersion, &ver) .map(|_| ()) } pub(crate) fn learn_ahead_secs(&self) -> u32 { self.get_config_optional(ConfigKey::LearnAheadSecs) .unwrap_or(1200) } pub(crate) fn set_learn_ahead_secs(&mut self, secs: u32) -> Result<()> { self.set_config(ConfigKey::LearnAheadSecs, &secs) .map(|_| ()) } pub(crate) fn get_new_review_mix(&self) -> NewReviewMix { match self.get_config_default::(ConfigKey::NewReviewMix) { 1 => NewReviewMix::ReviewsFirst, 2 => NewReviewMix::NewFirst, _ => NewReviewMix::Mix, } } pub(crate) fn set_new_review_mix(&mut self, mix: NewReviewMix) -> Result<()> { self.set_config(ConfigKey::NewReviewMix, &(mix as u8)) .map(|_| ()) } pub(crate) fn get_first_day_of_week(&self) -> Weekday { self.get_config_optional(ConfigKey::FirstDayOfWeek) .unwrap_or(Weekday::Sunday) } pub(crate) fn set_first_day_of_week(&mut self, weekday: Weekday) -> Result<()> { self.set_config(ConfigKey::FirstDayOfWeek, &weekday) .map(|_| ()) } pub(crate) fn get_answer_time_limit_secs(&self) -> u32 { self.get_config_optional(ConfigKey::AnswerTimeLimitSecs) .unwrap_or_default() } pub(crate) fn set_answer_time_limit_secs(&mut self, secs: u32) -> Result<()> { self.set_config(ConfigKey::AnswerTimeLimitSecs, &secs) .map(|_| ()) } pub(crate) fn get_last_unburied_day(&self) -> u32 { self.get_config_optional(ConfigKey::LastUnburiedDay) .unwrap_or_default() } pub(crate) fn set_last_unburied_day(&mut self, day: u32) -> Result<()> { self.set_config(ConfigKey::LastUnburiedDay, &day) .map(|_| ()) } pub(crate) fn get_backup_limits(&self) -> BackupLimits { self.get_config_optional(ConfigKey::Backups).unwrap_or( // 2d + 12d + 10w + 9m ≈ 1y BackupLimits { daily: 12, weekly: 10, monthly: 9, minimum_interval_mins: 30, }, ) } pub(crate) fn set_backup_limits(&mut self, limits: BackupLimits) -> Result<()> { self.set_config(ConfigKey::Backups, &limits).map(|_| ()) } pub(crate) fn get_update_notes(&self) -> UpdateCondition { self.get_config_optional(ConfigKey::UpdateNotes) .unwrap_or_default() } pub(crate) fn get_update_notetypes(&self) -> UpdateCondition { self.get_config_optional(ConfigKey::UpdateNotetypes) .unwrap_or_default() } } // 2021 scheduler moves this into deck config #[derive(Default)] pub(crate) enum NewReviewMix { #[default] Mix = 0, ReviewsFirst = 1, NewFirst = 2, } #[derive(PartialEq, Eq, Serialize_repr, Deserialize_repr, Clone, Copy)] #[repr(u8)] pub(crate) enum Weekday { Sunday = 0, Monday = 1, Friday = 5, Saturday = 6, } #[cfg(test)] mod test { use super::*; #[test] fn defaults() { let col = Collection::new(); assert_eq!(col.get_current_deck_id(), DeckId(1)); } #[test] fn get_set() { let mut col = Collection::new(); // missing key assert_eq!(col.get_config_optional::, _>("test"), None); // normal retrieval col.set_config("test", &vec![1, 2]).unwrap(); assert_eq!( col.get_config_optional::, _>("test"), Some(vec![1, 2]) ); // invalid type conversion assert_eq!(col.get_config_optional::("test"), None,); // invalid json col.storage .db .execute("update config set val=? where key='test'", [b"xx".as_ref()]) .unwrap(); assert_eq!(col.get_config_optional::("test"), None,); } } ================================================ FILE: rslib/src/config/notetype.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use strum::IntoStaticStr; use super::ConfigKey; use crate::notetype::NotetypeKind; use crate::prelude::*; /// Notetype config packed into a collection config key. This may change /// frequently, and we want to avoid the potentially expensive notetype /// write/sync. #[derive(Debug, Clone, Copy, IntoStaticStr)] #[strum(serialize_all = "camelCase")] enum NotetypeConfigKey { #[strum(to_string = "lastDeck")] LastDeckAddedTo, } impl Collection { pub fn get_aux_template_config_key( &mut self, ntid: NotetypeId, card_ordinal: usize, key: &str, ) -> Result { let nt = self.get_notetype(ntid)?.or_not_found(ntid)?; let ordinal = if matches!(nt.config.kind(), NotetypeKind::Cloze) { 0 } else { card_ordinal }; Ok(get_aux_notetype_config_key( ntid, &format!("{key}_{ordinal}"), )) } } impl NotetypeConfigKey { fn for_notetype(self, ntid: NotetypeId) -> String { get_aux_notetype_config_key(ntid, <&'static str>::from(self)) } } impl Collection { #[allow(dead_code)] pub(crate) fn get_current_notetype_id(&self) -> Option { self.get_config_optional(ConfigKey::CurrentNotetypeId) } pub(crate) fn set_current_notetype_id(&mut self, ntid: NotetypeId) -> Result<()> { self.set_config(ConfigKey::CurrentNotetypeId, &ntid) .map(|_| ()) } pub(crate) fn clear_aux_config_for_notetype(&mut self, ntid: NotetypeId) -> Result<()> { self.remove_config_prefix(&get_aux_notetype_config_key(ntid, "")) } pub(crate) fn get_last_deck_added_to_for_notetype(&self, id: NotetypeId) -> Option { let key = NotetypeConfigKey::LastDeckAddedTo.for_notetype(id); self.get_config_optional(key.as_str()) } pub(crate) fn set_last_deck_for_notetype(&mut self, id: NotetypeId, did: DeckId) -> Result<()> { let key = NotetypeConfigKey::LastDeckAddedTo.for_notetype(id); self.set_config(key.as_str(), &did).map(|_| ()) } } pub fn get_aux_notetype_config_key(ntid: NotetypeId, key: &str) -> String { format!("_nt_{ntid}_{key}") } ================================================ FILE: rslib/src/config/number.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use strum::IntoStaticStr; use crate::prelude::*; #[derive(Debug, Clone, Copy, IntoStaticStr)] #[strum(serialize_all = "camelCase")] pub enum I32ConfigKey { CsvDuplicateResolution, MatchScope, LastFsrsOptimize, } impl Collection { pub fn get_config_i32(&self, key: I32ConfigKey) -> i32 { #[allow(clippy::match_single_binding)] self.get_config_optional(key).unwrap_or(match key { _other => 0, }) } } impl Collection { pub(crate) fn set_config_i32_inner(&mut self, key: I32ConfigKey, value: i32) -> Result { self.set_config(key, &value) } } ================================================ FILE: rslib/src/config/schema11.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use serde_json::json; /// These items are expected to exist in schema 11. When adding /// new config variables, you do not need to add them here - /// just create an accessor function in one of the config/*.rs files, /// with an appropriate default for missing/invalid values instead. pub(crate) fn schema11_config_as_string(creation_offset: Option) -> String { let obj = json!({ "activeDecks": [1], "curDeck": 1, "newSpread": 0, "collapseTime": 1200, "timeLim": 0, "estTimes": true, "dueCounts": true, "curModel": null, "nextPos": 1, "sortType": "noteFld", "sortBackwards": false, "addToCur": true, "dayLearnFirst": false, "schedVer": 2, "creationOffset": creation_offset, "sched2021": true, }); serde_json::to_string(&obj).unwrap() } ================================================ FILE: rslib/src/config/string.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use strum::IntoStaticStr; use crate::prelude::*; #[derive(Debug, Clone, Copy, IntoStaticStr)] #[strum(serialize_all = "camelCase")] pub enum StringKey { SetDueBrowser, SetDueReviewer, DefaultSearchText, CardStateCustomizer, } impl Collection { pub fn get_config_string(&self, key: StringKey) -> String { let default = match key { StringKey::SetDueBrowser => "0", StringKey::SetDueReviewer => "1", _other => "", }; self.get_config_optional(key) .unwrap_or_else(|| default.to_string()) } pub fn set_config_string( &mut self, key: StringKey, val: &str, undoable: bool, ) -> Result> { let op = if undoable { Op::UpdateConfig } else { Op::SkipUndo }; self.transact(op, |col| { col.set_config_string_inner(key, val)?; Ok(()) }) } } impl Collection { pub(crate) fn set_config_string_inner(&mut self, key: StringKey, val: &str) -> Result { self.set_config(key, &val) } } ================================================ FILE: rslib/src/config/undo.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::ConfigEntry; use crate::prelude::*; #[derive(Debug)] pub(crate) enum UndoableConfigChange { Added(Box), Updated(Box), Removed(Box), } impl Collection { pub(crate) fn undo_config_change(&mut self, change: UndoableConfigChange) -> Result<()> { match change { UndoableConfigChange::Added(entry) => self.remove_config_undoable(&entry.key), UndoableConfigChange::Updated(entry) => { let current = self .storage .get_config_entry(&entry.key)? .or_invalid("config disappeared")?; self.update_config_entry_undoable(entry, current) .map(|_| ()) } UndoableConfigChange::Removed(entry) => self.add_config_entry_undoable(entry), } } /// True if added, or value changed. pub(super) fn set_config_undoable(&mut self, entry: Box) -> Result { if let Some(original) = self.storage.get_config_entry(&entry.key)? { self.update_config_entry_undoable(entry, original) } else { self.add_config_entry_undoable(entry)?; Ok(true) } } pub(super) fn remove_config_undoable(&mut self, key: &str) -> Result<()> { if let Some(current) = self.storage.get_config_entry(key)? { self.save_undo(UndoableConfigChange::Removed(current)); self.storage.remove_config(key)?; } Ok(()) } fn add_config_entry_undoable(&mut self, entry: Box) -> Result<()> { self.storage.set_config_entry(&entry)?; self.save_undo(UndoableConfigChange::Added(entry)); Ok(()) } /// True if new value differed. fn update_config_entry_undoable( &mut self, entry: Box, original: Box, ) -> Result { if entry.value != original.value { self.save_undo(UndoableConfigChange::Updated(original)); self.storage.set_config_entry(&entry)?; Ok(true) } else { Ok(false) } } } #[cfg(test)] mod test { use super::*; #[test] fn undo() -> Result<()> { let mut col = Collection::new(); // the op kind doesn't matter, we just need undo enabled let op = Op::Bury; // test key let key = BoolKey::NormalizeNoteText; // not set by default, but defaults to true assert!(col.get_config_bool(key)); // first set adds the key col.transact(op.clone(), |col| col.set_config_bool_inner(key, false))?; assert!(!col.get_config_bool(key)); // mutate it twice col.transact(op.clone(), |col| col.set_config_bool_inner(key, true))?; assert!(col.get_config_bool(key)); col.transact(op.clone(), |col| col.set_config_bool_inner(key, false))?; assert!(!col.get_config_bool(key)); // when we remove it, it goes back to its default col.transact(op, |col| col.remove_config_inner(key))?; assert!(col.get_config_bool(key)); // undo the removal col.undo()?; assert!(!col.get_config_bool(key)); // undo the mutations col.undo()?; assert!(col.get_config_bool(key)); col.undo()?; assert!(!col.get_config_bool(key)); // and undo the initial add col.undo()?; assert!(col.get_config_bool(key)); Ok(()) } } ================================================ FILE: rslib/src/dbcheck.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashSet; use std::sync::Arc; use anki_i18n::I18n; use anki_proto::notetypes::stock_notetype::OriginalStockKind; use anki_proto::notetypes::ImageOcclusionField; use itertools::Itertools; use tracing::debug; use crate::collection::Collection; use crate::config::SchedulerVersion; use crate::error::AnkiError; use crate::error::DbError; use crate::error::DbErrorKind; use crate::error::Result; use crate::notetype::all_stock_notetypes; use crate::notetype::AlreadyGeneratedCardInfo; use crate::notetype::CardGenContext; use crate::notetype::Notetype; use crate::notetype::NotetypeId; use crate::notetype::NotetypeKind; use crate::prelude::*; use crate::progress::ThrottlingProgressHandler; use crate::storage::card::CardFixStats; use crate::timestamp::TimestampMillis; use crate::timestamp::TimestampSecs; #[derive(Debug, Default, PartialEq, Eq)] pub struct CheckDatabaseOutput { card_properties_invalid: usize, card_position_too_high: usize, cards_missing_note: usize, decks_missing: usize, revlog_properties_invalid: usize, templates_missing: usize, card_ords_duplicated: usize, field_count_mismatch: usize, notetypes_recovered: usize, invalid_utf8: usize, invalid_ids: usize, card_last_review_time_empty: usize, } #[derive(Debug, Clone, Copy, Default)] pub enum DatabaseCheckProgress { #[default] Integrity, Optimize, Cards, Notes { current: usize, total: usize, }, History, } impl CheckDatabaseOutput { pub fn to_i18n_strings(&self, tr: &I18n) -> Vec { let mut probs = Vec::new(); if self.notetypes_recovered > 0 { probs.push(tr.database_check_notetypes_recovered()); } if self.card_position_too_high > 0 { probs.push(tr.database_check_new_card_high_due(self.card_position_too_high)); } if self.card_properties_invalid > 0 { probs.push(tr.database_check_card_properties(self.card_properties_invalid)); } if self.card_last_review_time_empty > 0 { probs.push( tr.database_check_card_last_review_time_empty(self.card_last_review_time_empty), ); } if self.cards_missing_note > 0 { probs.push(tr.database_check_card_missing_note(self.cards_missing_note)); } if self.decks_missing > 0 { probs.push(tr.database_check_missing_decks(self.decks_missing)); } if self.field_count_mismatch > 0 { probs.push(tr.database_check_field_count(self.field_count_mismatch)); } if self.card_ords_duplicated > 0 { probs.push(tr.database_check_duplicate_card_ords(self.card_ords_duplicated)); } if self.templates_missing > 0 { probs.push(tr.database_check_missing_templates(self.templates_missing)); } if self.revlog_properties_invalid > 0 { probs.push(tr.database_check_revlog_properties(self.revlog_properties_invalid)); } if self.invalid_utf8 > 0 { probs.push(tr.database_check_notes_with_invalid_utf8(self.invalid_utf8)); } if self.invalid_ids > 0 { probs.push(tr.database_check_fixed_invalid_ids(self.invalid_ids)); } probs.into_iter().map(Into::into).collect() } } impl Collection { /// Check the database, returning a list of problems that were fixed. pub(crate) fn check_database(&mut self) -> Result { let mut progress = self.new_progress_handler(); progress.set(DatabaseCheckProgress::Integrity)?; debug!("quick check"); if self.storage.quick_check_corrupt() { debug!("quick check failed"); return Err(AnkiError::db_error( self.tr.database_check_corrupt(), DbErrorKind::Corrupt, )); } progress.set(DatabaseCheckProgress::Optimize)?; debug!("optimize"); self.storage.optimize()?; self.transact_no_undo(|col| col.check_database_inner(progress)) } fn check_database_inner( &mut self, mut progress: ThrottlingProgressHandler, ) -> Result { let mut out = CheckDatabaseOutput::default(); // cards first, as we need to be able to read them to process notes progress.set(DatabaseCheckProgress::Cards)?; debug!("check cards"); self.check_card_properties(&mut out)?; self.check_orphaned_cards(&mut out)?; debug!("check decks"); self.check_missing_deck_ids(&mut out)?; self.check_filtered_cards(&mut out)?; debug!("check notetypes"); self.check_notetypes(&mut out, &mut progress)?; progress.set(DatabaseCheckProgress::History)?; debug!("check review log"); self.check_revlog(&mut out)?; debug!("missing decks"); self.check_missing_deck_names(&mut out)?; self.update_next_new_position()?; debug!("invalid ids"); out.invalid_ids = self.maybe_fix_invalid_ids()?; debug!("db check finished: {:#?}", out); Ok(out) } fn check_card_properties(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> { let timing = self.timing_today()?; let CardFixStats { new_cards_fixed, other_cards_fixed, last_review_time_fixed, } = self.storage.fix_card_properties( timing.days_elapsed, TimestampSecs::now(), self.usn()?, self.scheduler_version() == SchedulerVersion::V1, )?; out.card_position_too_high = new_cards_fixed; out.card_properties_invalid += other_cards_fixed; out.card_last_review_time_empty = last_review_time_fixed; // Trigger one-way sync if last_review_time was updated to avoid conflicts if last_review_time_fixed > 0 { self.set_schema_modified()?; } Ok(()) } fn check_orphaned_cards(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> { let cnt = self.storage.delete_orphaned_cards()?; if cnt > 0 { self.set_schema_modified()?; out.cards_missing_note = cnt; } Ok(()) } fn check_missing_deck_ids(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> { let usn = self.usn()?; for did in self.storage.missing_decks()? { self.recover_missing_deck(did, usn)?; out.decks_missing += 1; } Ok(()) } fn check_filtered_cards(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> { let decks = self.storage.get_decks_map()?; let mut wrong = 0; for (cid, did) in self.storage.all_filtered_cards_by_deck()? { // we expect calling code to ensure all decks already exist if let Some(deck) = decks.get(&did) { if !deck.is_filtered() { let mut card = self.storage.get_card(cid)?.unwrap(); card.original_deck_id.0 = 0; card.original_due = 0; self.storage.update_card(&card)?; wrong += 1; } } } if wrong > 0 { self.set_schema_modified()?; out.card_properties_invalid += wrong; } Ok(()) } fn check_notetypes( &mut self, out: &mut CheckDatabaseOutput, progress: &mut ThrottlingProgressHandler, ) -> Result<()> { let nids_by_notetype = self.storage.all_note_ids_by_notetype()?; let norm = self.get_config_bool(BoolKey::NormalizeNoteText); let usn = self.usn()?; let stamp_millis = TimestampMillis::now(); let stamp_secs = TimestampSecs::now(); let expanded_tags = self.storage.expanded_tags()?; self.storage.clear_all_tags()?; let total_notes = self.storage.total_notes()?; progress.set(DatabaseCheckProgress::Notes { current: 0, total: total_notes as usize, })?; for (ntid, group) in &nids_by_notetype.into_iter().chunk_by(|tup| tup.0) { debug!("check notetype: {}", ntid); let mut group = group.peekable(); let mut nt = match self.get_notetype(ntid)? { None => { let first_note = self.storage.get_note(group.peek().unwrap().1)?.unwrap(); out.notetypes_recovered += 1; self.recover_notetype(stamp_millis, first_note.fields().len(), ntid)? } Some(nt) => nt, }; self.add_missing_field_tags(Arc::make_mut(&mut nt))?; let mut genctx = None; for (_, nid) in group { progress.increment(|p| { let DatabaseCheckProgress::Notes { current, .. } = p else { unreachable!() }; current })?; let mut note = self.get_note_fixing_invalid_utf8(nid, out)?; let original = note.clone(); let cards = self.storage.existing_cards_for_note(nid)?; out.card_ords_duplicated += self.remove_duplicate_card_ordinals(&cards)?; out.templates_missing += self.remove_cards_without_template(&nt, &cards)?; // fix fields if note.fields().len() != nt.fields.len() { note.fix_field_count(&nt); note.tags.push("db-check".into()); out.field_count_mismatch += 1; } if note.mtime > stamp_secs { note.mtime = stamp_secs; } // note type ID may have changed if we created a recovery notetype note.notetype_id = nt.id; // write note, updating tags and generating missing cards let ctx = genctx.get_or_insert_with(|| { CardGenContext::new( nt.as_ref(), self.get_last_deck_added_to_for_notetype(nt.id), usn, ) }); self.update_note_inner_generating_cards( ctx, &mut note, &original, false, norm, true, )?; } } // the note rebuilding process took care of adding tags back, so we just need // to ensure to restore the collapse state self.storage.restore_expanded_tags(&expanded_tags)?; // if the collection is empty and the user has deleted all note types, ensure at // least one note type exists if self.storage.get_all_notetype_names()?.is_empty() { let mut nt = all_stock_notetypes(&self.tr).remove(0); self.add_notetype_inner(&mut nt, usn, true)?; } if out.card_ords_duplicated > 0 || out.field_count_mismatch > 0 || out.templates_missing > 0 || out.notetypes_recovered > 0 { self.set_schema_modified()?; } Ok(()) } fn get_note_fixing_invalid_utf8( &self, nid: NoteId, out: &mut CheckDatabaseOutput, ) -> Result { match self.storage.get_note(nid) { Ok(note) => Ok(note.unwrap()), Err(err) => match err { AnkiError::DbError { source: DbError { kind: DbErrorKind::Utf8, .. }, } => { // fix note then fetch again self.storage.fix_invalid_utf8_in_note(nid)?; out.invalid_utf8 += 1; Ok(self.storage.get_note(nid)?.unwrap()) } // other errors are unhandled _ => Err(err), }, } } fn remove_duplicate_card_ordinals( &mut self, cards: &[AlreadyGeneratedCardInfo], ) -> Result { let mut ords = HashSet::new(); let mut removed = 0; for card in cards { if !ords.insert(card.ord) { self.storage.remove_card(card.id)?; removed += 1; } } Ok(removed) } fn remove_cards_without_template( &mut self, nt: &Notetype, cards: &[AlreadyGeneratedCardInfo], ) -> Result { if nt.config.kind() == NotetypeKind::Cloze { return Ok(0); } let mut removed = 0; for card in cards { if card.ord as usize >= nt.templates.len() { self.storage.remove_card(card.id)?; removed += 1; } } Ok(removed) } fn recover_notetype( &mut self, stamp: TimestampMillis, field_count: usize, previous_id: NotetypeId, ) -> Result> { debug!("create recovery notetype"); let extra_cards_required = self .storage .highest_card_ordinal_for_notetype(previous_id)?; let mut basic = all_stock_notetypes(&self.tr).remove(0); let mut field = 3; while basic.fields.len() < field_count { basic.add_field(format!("{field}")); field += 1; } basic.name = format!("db-check-{stamp}-{field_count}"); let qfmt = basic.templates[0].config.q_format.clone(); let afmt = basic.templates[0].config.a_format.clone(); for n in 0..extra_cards_required { basic.add_template(format!("Card {}", n + 2), &qfmt, &afmt); } self.add_notetype(&mut basic, true)?; Ok(Arc::new(basic)) } fn check_revlog(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> { let cnt = self.storage.fix_revlog_properties()?; if cnt > 0 { self.set_schema_modified()?; out.revlog_properties_invalid = cnt; } Ok(()) } fn check_missing_deck_names(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> { let names = self.storage.get_all_deck_names()?; out.decks_missing += self.add_missing_deck_names(&names)?; Ok(()) } fn update_next_new_position(&mut self) -> Result<()> { let pos = self.storage.max_new_card_position().unwrap_or(0); self.set_next_card_position(pos) } pub(crate) fn maybe_fix_invalid_ids(&mut self) -> Result { let now = TimestampMillis::now(); let tomorrow = now.adding_secs(24 * 60 * 60).0; let num_invalid_ids = self.storage.invalid_ids(tomorrow)?; if num_invalid_ids > 0 { self.storage.fix_invalid_ids(tomorrow, now.0)?; self.set_schema_modified()?; } Ok(num_invalid_ids) } fn add_missing_field_tags(&mut self, nt: &mut Notetype) -> Result<()> { // we only try to fix I/O, as the other notetypes have been in circulation too // long, and there's too much of a risk that the user has reordered the fields // already. We could try to match on field name in the future though. let usn = self.usn()?; if let OriginalStockKind::ImageOcclusion = nt.config.original_stock_kind() { let mut changed = false; if nt.fields.len() >= 5 { for i in 0..5 { let conf = &mut nt.fields[i].config; if !conf.prevent_deletion { changed = true; conf.prevent_deletion = i != ImageOcclusionField::Comments as usize; conf.tag = Some(i as u32); } } } if changed { nt.set_modified(usn); self.add_or_update_notetype_with_existing_id_inner(nt, None, usn, true)?; } } Ok(()) } } #[cfg(test)] mod test { use super::*; use crate::decks::DeckId; use crate::search::SortMode; #[test] fn cards() -> Result<()> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); col.add_note(&mut note, DeckId(1))?; // card properties col.storage .db .execute_batch("update cards set ivl=1.5,due=2000000,odue=1.5")?; let out = col.check_database()?; assert_eq!( out, CheckDatabaseOutput { card_properties_invalid: 2, card_position_too_high: 1, ..Default::default() } ); // should be idempotent assert_eq!(col.check_database()?, Default::default()); // missing deck col.storage.db.execute_batch("update cards set did=123")?; let out = col.check_database()?; assert_eq!( out, CheckDatabaseOutput { decks_missing: 1, ..Default::default() } ); assert_eq!( col.storage .get_deck(DeckId(123))? .unwrap() .name .as_native_str(), "recovered123" ); // missing note col.storage.remove_note(note.id)?; let out = col.check_database()?; assert_eq!( out, CheckDatabaseOutput { cards_missing_note: 1, ..Default::default() } ); assert_eq!( col.storage.db_scalar::("select count(*) from cards")?, 0 ); Ok(()) } #[test] fn revlog() -> Result<()> { let mut col = Collection::new(); col.storage.db.execute_batch( " insert into revlog (id,cid,usn,ease,ivl,lastIvl,factor,time,type) values (0,0,0,0,1.5,1.5,0,0,0)", )?; let out = col.check_database()?; assert_eq!( out, CheckDatabaseOutput { revlog_properties_invalid: 1, ..Default::default() } ); assert!(col .storage .db_scalar::("select ivl = lastIvl = 1 from revlog")?); Ok(()) } #[test] fn note_card_link() -> Result<()> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); col.add_note(&mut note, DeckId(1))?; // duplicate ordinals let cid = col.search_cards("", SortMode::NoOrder)?[0]; let mut card = col.storage.get_card(cid)?.unwrap(); card.id.0 += 1; col.storage.add_card(&mut card)?; let out = col.check_database()?; assert_eq!( out, CheckDatabaseOutput { card_ords_duplicated: 1, ..Default::default() } ); assert_eq!( col.storage.db_scalar::("select count(*) from cards")?, 1 ); // missing templates let cid = col.search_cards("", SortMode::NoOrder)?[0]; let mut card = col.storage.get_card(cid)?.unwrap(); card.id.0 += 1; card.template_idx = 10; col.storage.add_card(&mut card)?; let out = col.check_database()?; assert_eq!( out, CheckDatabaseOutput { templates_missing: 1, ..Default::default() } ); assert_eq!( col.storage.db_scalar::("select count(*) from cards")?, 1 ); Ok(()) } #[test] fn note_fields() -> Result<()> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); col.add_note(&mut note, DeckId(1))?; // excess fields get joined into the last one col.storage .db .execute_batch("update notes set flds = 'a\x1fb\x1fc\x1fd'")?; let out = col.check_database()?; assert_eq!( out, CheckDatabaseOutput { field_count_mismatch: 1, ..Default::default() } ); let note = col.storage.get_note(note.id)?.unwrap(); assert_eq!(¬e.fields()[..], &["a", "b; c; d"]); // missing fields get filled with blanks col.storage .db .execute_batch("update notes set flds = 'a'")?; let out = col.check_database()?; assert_eq!( out, CheckDatabaseOutput { field_count_mismatch: 1, ..Default::default() } ); let note = col.storage.get_note(note.id)?.unwrap(); assert_eq!(¬e.fields()[..], &["a", ""]); Ok(()) } #[test] fn deck_names() -> Result<()> { let mut col = Collection::new(); let deck = col.get_or_create_normal_deck("foo::bar::baz")?; // includes default assert_eq!(col.storage.get_all_deck_names()?.len(), 4); col.storage .db .prepare("delete from decks where id != ? and id != 1")? .execute([deck.id])?; assert_eq!(col.storage.get_all_deck_names()?.len(), 2); let out = col.check_database()?; assert_eq!( out, CheckDatabaseOutput { decks_missing: 1, // only counts the immediate parent that was missing ..Default::default() } ); assert_eq!( &col.storage .get_all_deck_names()? .iter() .map(|(_, name)| name) .collect::>(), &["Default", "foo", "foo::bar", "foo::bar::baz"] ); Ok(()) } #[test] fn tags() -> Result<()> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); note.tags.push("one".into()); note.tags.push("two".into()); col.add_note(&mut note, DeckId(1))?; col.set_tag_collapsed("one", false)?; col.check_database()?; assert!(col.storage.get_tag("one")?.unwrap().expanded); assert!(!col.storage.get_tag("two")?.unwrap().expanded); Ok(()) } } ================================================ FILE: rslib/src/deckconfig/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod schema11; mod service; pub(crate) mod undo; mod update; pub use anki_proto::deck_config::deck_config::config::AnswerAction; pub use anki_proto::deck_config::deck_config::config::LeechAction; pub use anki_proto::deck_config::deck_config::config::NewCardGatherPriority; pub use anki_proto::deck_config::deck_config::config::NewCardInsertOrder; pub use anki_proto::deck_config::deck_config::config::NewCardSortOrder; pub use anki_proto::deck_config::deck_config::config::QuestionAction; pub use anki_proto::deck_config::deck_config::config::ReviewCardOrder; pub use anki_proto::deck_config::deck_config::config::ReviewMix; pub use anki_proto::deck_config::deck_config::Config as DeckConfigInner; pub use schema11::DeckConfSchema11; pub use schema11::NewCardOrderSchema11; pub use update::UpdateDeckConfigsRequest; /// Old deck config and cards table store 250% as 2500. pub(crate) const INITIAL_EASE_FACTOR_THOUSANDS: u16 = (INITIAL_EASE_FACTOR * 1000.0) as u16; use crate::define_newtype; use crate::prelude::*; use crate::scheduler::states::review::INITIAL_EASE_FACTOR; define_newtype!(DeckConfigId, i64); #[derive(Debug, PartialEq, Clone)] pub struct DeckConfig { pub id: DeckConfigId, pub name: String, pub mtime_secs: TimestampSecs, pub usn: Usn, pub inner: DeckConfigInner, } /// NOTE: this does not set the default steps const DEFAULT_DECK_CONFIG_INNER: DeckConfigInner = DeckConfigInner { learn_steps: Vec::new(), relearn_steps: Vec::new(), new_per_day: 20, reviews_per_day: 200, new_per_day_minimum: 0, initial_ease: 2.5, easy_multiplier: 1.3, hard_multiplier: 1.2, lapse_multiplier: 0.0, interval_multiplier: 1.0, maximum_review_interval: 36_500, minimum_lapse_interval: 1, graduating_interval_good: 1, graduating_interval_easy: 4, new_card_insert_order: NewCardInsertOrder::Due as i32, new_card_gather_priority: NewCardGatherPriority::Deck as i32, new_card_sort_order: NewCardSortOrder::Template as i32, review_order: ReviewCardOrder::Day as i32, new_mix: ReviewMix::MixWithReviews as i32, interday_learning_mix: ReviewMix::MixWithReviews as i32, leech_action: LeechAction::TagOnly as i32, leech_threshold: 8, disable_autoplay: false, cap_answer_time_to_secs: 60, show_timer: false, stop_timer_on_answer: false, seconds_to_show_question: 0.0, seconds_to_show_answer: 0.0, question_action: QuestionAction::ShowAnswer as i32, answer_action: AnswerAction::BuryCard as i32, wait_for_audio: true, skip_question_when_replaying_answer: false, bury_new: false, bury_reviews: false, bury_interday_learning: false, fsrs_params_4: vec![], fsrs_params_5: vec![], fsrs_params_6: vec![], desired_retention: 0.9, other: Vec::new(), historical_retention: 0.9, param_search: String::new(), ignore_revlogs_before_date: String::new(), easy_days_percentages: Vec::new(), }; impl Default for DeckConfig { fn default() -> Self { DeckConfig { id: DeckConfigId(0), name: "".to_string(), mtime_secs: Default::default(), usn: Default::default(), inner: DeckConfigInner { learn_steps: vec![1.0, 10.0], relearn_steps: vec![10.0], easy_days_percentages: vec![1.0; 7], ..DEFAULT_DECK_CONFIG_INNER }, } } } impl DeckConfig { pub(crate) fn set_modified(&mut self, usn: Usn) { self.mtime_secs = TimestampSecs::now(); self.usn = usn; } /// Retrieve the FSRS 6.0 params, falling back on 5.0 or 4.x ones. pub fn fsrs_params(&self) -> &Vec { if !self.inner.fsrs_params_6.is_empty() { &self.inner.fsrs_params_6 } else if !self.inner.fsrs_params_5.is_empty() { &self.inner.fsrs_params_5 } else { &self.inner.fsrs_params_4 } } } impl Collection { /// If fallback is true, guaranteed to return a deck config. pub fn get_deck_config( &self, dcid: DeckConfigId, fallback: bool, ) -> Result> { if let Some(conf) = self.storage.get_deck_config(dcid)? { return Ok(Some(conf)); } if fallback { if let Some(conf) = self.storage.get_deck_config(DeckConfigId(1))? { return Ok(Some(conf)); } // if even the default deck config is missing, just return the defaults Ok(Some(DeckConfig::default())) } else { Ok(None) } } } impl Collection { pub(crate) fn add_or_update_deck_config(&mut self, config: &mut DeckConfig) -> Result<()> { let usn = Some(self.usn()?); if config.id.0 == 0 { self.add_deck_config_inner(config, usn) } else { let original = self .storage .get_deck_config(config.id)? .or_not_found(config.id)?; self.update_deck_config_inner(config, original, usn) } } /// Used by the old import code; if provided id is non-zero, will add /// instead of ignoring. Does not support undo. pub(crate) fn add_or_update_deck_config_legacy( &mut self, config: &mut DeckConfig, ) -> Result<()> { let usn = self.usn()?; if config.id.0 == 0 { self.add_deck_config_inner(config, Some(usn)) } else { config.set_modified(usn); self.storage .add_or_update_deck_config_with_existing_id(config) } } /// Assigns an id and adds to DB. If usn is provided, modification time and /// usn will be updated. pub(crate) fn add_deck_config_inner( &mut self, config: &mut DeckConfig, usn: Option, ) -> Result<()> { if let Some(usn) = usn { config.set_modified(usn); } config.id.0 = TimestampMillis::now().0; self.add_deck_config_undoable(config) } /// Update an existing deck config. If usn is provided, modification time /// and usn will be updated. pub(crate) fn update_deck_config_inner( &mut self, config: &mut DeckConfig, original: DeckConfig, usn: Option, ) -> Result<()> { if config == &original { return Ok(()); } if let Some(usn) = usn { config.set_modified(usn); } self.update_deck_config_undoable(config, original) } /// Remove a deck configuration. This will force a full sync. pub(crate) fn remove_deck_config_inner(&mut self, dcid: DeckConfigId) -> Result<()> { require!(dcid.0 != 1, "can't delete default conf"); let conf = self.storage.get_deck_config(dcid)?.or_not_found(dcid)?; self.set_schema_modified()?; self.remove_deck_config_undoable(conf) } } /// There was a period of time when the deck options screen was allowing /// 0/NaN to be persisted, so we need to check the values are within /// valid bounds when reading from the DB. pub(crate) fn ensure_deck_config_values_valid(config: &mut DeckConfigInner) { let default = DEFAULT_DECK_CONFIG_INNER; ensure_u32_valid(&mut config.new_per_day, default.new_per_day, 0, 9999); ensure_u32_valid( &mut config.reviews_per_day, default.reviews_per_day, 0, 9999, ); ensure_u32_valid( &mut config.new_per_day_minimum, default.new_per_day_minimum, 0, 9999, ); ensure_f32_valid(&mut config.initial_ease, default.initial_ease, 1.31, 5.0); ensure_f32_valid( &mut config.easy_multiplier, default.easy_multiplier, 1.0, 5.0, ); ensure_f32_valid( &mut config.hard_multiplier, default.hard_multiplier, 0.5, 1.3, ); ensure_f32_valid( &mut config.lapse_multiplier, default.lapse_multiplier, 0.0, 1.0, ); ensure_f32_valid( &mut config.interval_multiplier, default.interval_multiplier, 0.5, 2.0, ); ensure_u32_valid( &mut config.maximum_review_interval, default.maximum_review_interval, 1, 36_500, ); ensure_u32_valid( &mut config.minimum_lapse_interval, default.minimum_lapse_interval, 1, 36_500, ); ensure_u32_valid( &mut config.graduating_interval_good, default.graduating_interval_good, 1, 36_500, ); ensure_u32_valid( &mut config.graduating_interval_easy, default.graduating_interval_easy, 1, 36_500, ); ensure_u32_valid( &mut config.leech_threshold, default.leech_threshold, 1, 9999, ); ensure_u32_valid( &mut config.cap_answer_time_to_secs, default.cap_answer_time_to_secs, 1, 9999, ); ensure_f32_valid( &mut config.desired_retention, default.desired_retention, 0.7, 0.99, ); ensure_f32_valid( &mut config.historical_retention, default.historical_retention, 0.7, 0.97, ) } fn ensure_f32_valid(val: &mut f32, default: f32, min: f32, max: f32) { if val.is_nan() || *val < min || *val > max { *val = default; } } fn ensure_u32_valid(val: &mut u32, default: u32, min: u32, max: u32) { if *val < min || *val > max { *val = default; } } ================================================ FILE: rslib/src/deckconfig/schema11.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use phf::phf_set; use phf::Set; use serde::Deserialize as DeTrait; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; use serde_aux::field_attributes::deserialize_number_from_string; use serde_json::Value; use serde_repr::Deserialize_repr; use serde_repr::Serialize_repr; use serde_tuple::Serialize_tuple; use super::DeckConfig; use super::DeckConfigId; use super::DeckConfigInner; use super::NewCardInsertOrder; use super::INITIAL_EASE_FACTOR_THOUSANDS; use crate::serde::default_on_invalid; use crate::timestamp::TimestampSecs; use crate::types::Usn; fn wait_for_audio_default() -> bool { true } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(rename_all = "camelCase")] pub struct DeckConfSchema11 { #[serde(deserialize_with = "deserialize_number_from_string")] pub(crate) id: DeckConfigId, #[serde(rename = "mod", deserialize_with = "deserialize_number_from_string")] pub(crate) mtime: TimestampSecs, pub(crate) name: String, pub(crate) usn: Usn, max_taken: i32, autoplay: bool, #[serde(deserialize_with = "default_on_invalid")] timer: u8, #[serde(default)] replayq: bool, #[serde(deserialize_with = "default_on_invalid")] pub(crate) new: NewConfSchema11, #[serde(deserialize_with = "default_on_invalid")] pub(crate) rev: RevConfSchema11, #[serde(deserialize_with = "default_on_invalid")] pub(crate) lapse: LapseConfSchema11, #[serde(rename = "dyn", default, deserialize_with = "default_on_invalid")] dynamic: bool, // 2021 scheduler options: these were not in schema 11, but we need to persist them // so the settings are not lost on upgrade/downgrade. #[serde(default)] new_mix: i32, #[serde(default)] new_per_day_minimum: u32, #[serde(default)] interday_learning_mix: i32, #[serde(default)] review_order: i32, #[serde(default)] new_sort_order: i32, #[serde(default)] new_gather_priority: i32, #[serde(default)] bury_interday_learning: bool, #[serde(default, rename = "fsrsWeights")] fsrs_params_4: Vec, #[serde(default)] fsrs_params_5: Vec, #[serde(default)] fsrs_params_6: Vec, #[serde(default)] desired_retention: f32, #[serde(default)] ignore_revlogs_before_date: String, #[serde(default)] easy_days_percentages: Vec, #[serde(default)] stop_timer_on_answer: bool, #[serde(default)] seconds_to_show_question: f32, #[serde(default)] seconds_to_show_answer: f32, #[serde(default)] question_action: QuestionAction, #[serde(default)] answer_action: AnswerAction, #[serde(default = "wait_for_audio_default")] wait_for_audio: bool, #[serde(default)] /// historical retention sm2_retention: f32, #[serde(default, rename = "weightSearch")] param_search: String, #[serde(flatten)] other: HashMap, } #[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Eq, Clone)] #[repr(u8)] #[derive(Default)] pub enum QuestionAction { #[default] ShowAnswer = 0, ShowReminder = 1, } #[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Eq, Clone)] #[repr(u8)] #[derive(Default)] pub enum AnswerAction { #[default] BuryCard = 0, AnswerAgain = 1, AnswerGood = 2, AnswerHard = 3, ShowReminder = 4, } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(rename_all = "camelCase")] pub struct NewConfSchema11 { #[serde(default)] bury: bool, #[serde(deserialize_with = "default_on_invalid")] delays: Vec, initial_factor: u16, #[serde(deserialize_with = "deserialize_new_intervals")] ints: NewCardIntervals, #[serde(deserialize_with = "default_on_invalid")] pub(crate) order: NewCardOrderSchema11, #[serde(deserialize_with = "default_on_invalid")] pub(crate) per_day: u32, #[serde(flatten)] other: HashMap, } #[derive(Serialize_tuple, Debug, PartialEq, Eq, Clone)] pub struct NewCardIntervals { good: u16, easy: u16, _unused: u16, } impl Default for NewCardIntervals { fn default() -> Self { Self { good: 1, easy: 4, _unused: 0, } } } /// This extra logic is required because AnkiDroid's options screen was creating /// a 2 element array instead of a 3 element one. fn deserialize_new_intervals<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let vals: Result, _> = DeTrait::deserialize(deserializer); Ok(vals .ok() .and_then(|vals| { if vals.len() >= 2 { Some(NewCardIntervals { good: vals[0], easy: vals[1], _unused: 0, }) } else { None } }) .unwrap_or_default()) } #[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Eq, Clone)] #[repr(u8)] #[derive(Default)] pub enum NewCardOrderSchema11 { Random = 0, #[default] Due = 1, } fn hard_factor_default() -> f32 { 1.2 } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(rename_all = "camelCase")] pub struct RevConfSchema11 { #[serde(default)] bury: bool, ease4: f32, ivl_fct: f32, max_ivl: u32, #[serde(deserialize_with = "default_on_invalid")] pub(crate) per_day: u32, #[serde(default = "hard_factor_default")] hard_factor: f32, #[serde(flatten)] other: HashMap, } #[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Eq, Clone)] #[repr(u8)] #[derive(Default)] pub enum LeechAction { Suspend = 0, #[default] TagOnly = 1, } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(rename_all = "camelCase")] pub struct LapseConfSchema11 { #[serde(deserialize_with = "default_on_invalid")] delays: Vec, #[serde(deserialize_with = "default_on_invalid")] leech_action: LeechAction, leech_fails: u32, min_int: u32, mult: f32, #[serde(flatten)] other: HashMap, } impl Default for RevConfSchema11 { fn default() -> Self { RevConfSchema11 { bury: false, ease4: 1.3, ivl_fct: 1.0, max_ivl: 36500, per_day: 200, hard_factor: 1.2, other: Default::default(), } } } impl Default for NewConfSchema11 { fn default() -> Self { NewConfSchema11 { bury: false, delays: vec![1.0, 10.0], initial_factor: INITIAL_EASE_FACTOR_THOUSANDS, ints: NewCardIntervals::default(), order: NewCardOrderSchema11::default(), per_day: 20, other: Default::default(), } } } impl Default for LapseConfSchema11 { fn default() -> Self { LapseConfSchema11 { delays: vec![10.0], leech_action: LeechAction::default(), leech_fails: 8, min_int: 1, mult: 0.0, other: Default::default(), } } } impl Default for DeckConfSchema11 { fn default() -> Self { DeckConfSchema11 { id: DeckConfigId(0), mtime: TimestampSecs(0), name: "Default".to_string(), usn: Usn(0), max_taken: 60, autoplay: true, timer: 0, stop_timer_on_answer: false, seconds_to_show_question: 0.0, seconds_to_show_answer: 0.0, question_action: QuestionAction::ShowAnswer, answer_action: AnswerAction::BuryCard, wait_for_audio: true, replayq: true, dynamic: false, new: Default::default(), rev: Default::default(), lapse: Default::default(), other: Default::default(), new_mix: 0, new_per_day_minimum: 0, interday_learning_mix: 0, review_order: 0, new_sort_order: 0, new_gather_priority: 0, bury_interday_learning: false, fsrs_params_4: vec![], fsrs_params_5: vec![], fsrs_params_6: vec![], desired_retention: 0.9, sm2_retention: 0.9, param_search: "".to_string(), ignore_revlogs_before_date: "".to_string(), easy_days_percentages: vec![1.0; 7], } } } // schema11 -> schema15 impl From for DeckConfig { fn from(mut c: DeckConfSchema11) -> DeckConfig { // merge any json stored in new/rev/lapse into top level if !c.new.other.is_empty() { if let Ok(val) = serde_json::to_value(c.new.other) { c.other.insert("new".into(), val); } } if !c.rev.other.is_empty() { if let Ok(val) = serde_json::to_value(c.rev.other) { c.other.insert("rev".into(), val); } } if !c.lapse.other.is_empty() { if let Ok(val) = serde_json::to_value(c.lapse.other) { c.other.insert("lapse".into(), val); } } let other_bytes = if c.other.is_empty() { vec![] } else { serde_json::to_vec(&c.other).unwrap_or_default() }; DeckConfig { id: c.id, name: c.name, mtime_secs: c.mtime, usn: c.usn, inner: DeckConfigInner { learn_steps: c.new.delays, relearn_steps: c.lapse.delays, new_per_day: c.new.per_day, reviews_per_day: c.rev.per_day, new_per_day_minimum: c.new_per_day_minimum, initial_ease: (c.new.initial_factor as f32) / 1000.0, easy_multiplier: c.rev.ease4, hard_multiplier: c.rev.hard_factor, lapse_multiplier: c.lapse.mult, interval_multiplier: c.rev.ivl_fct, maximum_review_interval: c.rev.max_ivl, minimum_lapse_interval: c.lapse.min_int, graduating_interval_good: c.new.ints.good as u32, graduating_interval_easy: c.new.ints.easy as u32, new_card_insert_order: match c.new.order { NewCardOrderSchema11::Random => NewCardInsertOrder::Random, NewCardOrderSchema11::Due => NewCardInsertOrder::Due, } as i32, new_card_gather_priority: c.new_gather_priority, new_card_sort_order: c.new_sort_order, review_order: c.review_order, new_mix: c.new_mix, interday_learning_mix: c.interday_learning_mix, leech_action: c.lapse.leech_action as i32, leech_threshold: c.lapse.leech_fails, disable_autoplay: !c.autoplay, cap_answer_time_to_secs: c.max_taken.max(0) as u32, show_timer: c.timer != 0, stop_timer_on_answer: c.stop_timer_on_answer, seconds_to_show_question: c.seconds_to_show_question, seconds_to_show_answer: c.seconds_to_show_answer, question_action: c.question_action as i32, answer_action: c.answer_action as i32, wait_for_audio: c.wait_for_audio, skip_question_when_replaying_answer: !c.replayq, bury_new: c.new.bury, bury_reviews: c.rev.bury, bury_interday_learning: c.bury_interday_learning, fsrs_params_4: c.fsrs_params_4, fsrs_params_5: c.fsrs_params_5, fsrs_params_6: c.fsrs_params_6, ignore_revlogs_before_date: c.ignore_revlogs_before_date, easy_days_percentages: c.easy_days_percentages, desired_retention: c.desired_retention, historical_retention: c.sm2_retention, param_search: c.param_search, other: other_bytes, }, } } } // latest schema -> schema 11 impl From for DeckConfSchema11 { fn from(c: DeckConfig) -> DeckConfSchema11 { // split extra json up let mut top_other: HashMap; let mut new_other = Default::default(); let mut rev_other = Default::default(); let mut lapse_other = Default::default(); if c.inner.other.is_empty() { top_other = Default::default(); } else { top_other = serde_json::from_slice(&c.inner.other).unwrap_or_default(); if let Some(new) = top_other.remove("new") { let val: HashMap = serde_json::from_value(new).unwrap_or_default(); new_other = val; new_other.retain(|k, _v| !RESERVED_DECKCONF_NEW_KEYS.contains(k)) } if let Some(rev) = top_other.remove("rev") { let val: HashMap = serde_json::from_value(rev).unwrap_or_default(); rev_other = val; rev_other.retain(|k, _v| !RESERVED_DECKCONF_REV_KEYS.contains(k)) } if let Some(lapse) = top_other.remove("lapse") { let val: HashMap = serde_json::from_value(lapse).unwrap_or_default(); lapse_other = val; lapse_other.retain(|k, _v| !RESERVED_DECKCONF_LAPSE_KEYS.contains(k)) } top_other.retain(|k, _v| !RESERVED_DECKCONF_KEYS.contains(k)); } let i = c.inner; let new_order = i.new_card_insert_order(); DeckConfSchema11 { id: c.id, mtime: c.mtime_secs, name: c.name, usn: c.usn, max_taken: i.cap_answer_time_to_secs as i32, autoplay: !i.disable_autoplay, timer: i.show_timer.into(), stop_timer_on_answer: i.stop_timer_on_answer, seconds_to_show_question: i.seconds_to_show_question, seconds_to_show_answer: i.seconds_to_show_answer, answer_action: match i.answer_action { 1 => AnswerAction::AnswerAgain, 2 => AnswerAction::AnswerGood, 3 => AnswerAction::AnswerHard, 4 => AnswerAction::ShowReminder, _ => AnswerAction::BuryCard, }, question_action: match i.question_action { 1 => QuestionAction::ShowReminder, _ => QuestionAction::ShowAnswer, }, wait_for_audio: i.wait_for_audio, replayq: !i.skip_question_when_replaying_answer, dynamic: false, new: NewConfSchema11 { bury: i.bury_new, delays: i.learn_steps, initial_factor: (i.initial_ease * 1000.0) as u16, ints: NewCardIntervals { good: i.graduating_interval_good as u16, easy: i.graduating_interval_easy as u16, _unused: 0, }, order: match new_order { NewCardInsertOrder::Random => NewCardOrderSchema11::Random, NewCardInsertOrder::Due => NewCardOrderSchema11::Due, }, per_day: i.new_per_day, other: new_other, }, rev: RevConfSchema11 { bury: i.bury_reviews, ease4: i.easy_multiplier, ivl_fct: i.interval_multiplier, max_ivl: i.maximum_review_interval, per_day: i.reviews_per_day, hard_factor: i.hard_multiplier, other: rev_other, }, lapse: LapseConfSchema11 { delays: i.relearn_steps, leech_action: match i.leech_action { 1 => LeechAction::TagOnly, _ => LeechAction::Suspend, }, leech_fails: i.leech_threshold, min_int: i.minimum_lapse_interval, mult: i.lapse_multiplier, other: lapse_other, }, other: top_other, new_mix: i.new_mix, new_per_day_minimum: i.new_per_day_minimum, interday_learning_mix: i.interday_learning_mix, review_order: i.review_order, new_sort_order: i.new_card_sort_order, new_gather_priority: i.new_card_gather_priority, bury_interday_learning: i.bury_interday_learning, fsrs_params_4: i.fsrs_params_4, fsrs_params_5: i.fsrs_params_5, fsrs_params_6: i.fsrs_params_6, desired_retention: i.desired_retention, sm2_retention: i.historical_retention, param_search: i.param_search, ignore_revlogs_before_date: i.ignore_revlogs_before_date, easy_days_percentages: i.easy_days_percentages, } } } static RESERVED_DECKCONF_KEYS: Set<&'static str> = phf_set! { "id", "newSortOrder", "replayq", "newPerDayMinimum", "usn", "autoplay", "dyn", "maxTaken", "reviewOrder", "buryInterdayLearning", "newMix", "mod", "timer", "name", "interdayLearningMix", "newGatherPriority", "fsrsWeights", "fsrsParams5", "fsrsParams6", "desiredRetention", "stopTimerOnAnswer", "secondsToShowQuestion", "secondsToShowAnswer", "questionAction", "answerAction", "waitForAudio", "sm2Retention", "weightSearch", "ignoreRevlogsBeforeDate", "easyDaysPercentages", }; static RESERVED_DECKCONF_NEW_KEYS: Set<&'static str> = phf_set! { "order", "delays", "bury", "perDay", "initialFactor", "ints" }; static RESERVED_DECKCONF_REV_KEYS: Set<&'static str> = phf_set! { "maxIvl", "hardFactor", "ease4", "ivlFct", "perDay", "bury" }; static RESERVED_DECKCONF_LAPSE_KEYS: Set<&'static str> = phf_set! { "leechFails", "mult", "leechAction", "delays", "minInt" }; #[cfg(test)] mod test { use itertools::Itertools; use serde::de::IntoDeserializer; use serde_json::json; use serde_json::Value; use super::*; use crate::prelude::*; #[test] fn all_reserved_fields_are_removed() -> Result<()> { let key_source = DeckConfSchema11::default(); let mut config = DeckConfig::default(); let empty: &[&String] = &[]; config.inner.other = serde_json::to_vec(&key_source)?; let s11 = DeckConfSchema11::from(config); assert_eq!(&s11.other.keys().collect_vec(), empty); assert_eq!(&s11.new.other.keys().collect_vec(), empty); assert_eq!(&s11.rev.other.keys().collect_vec(), empty); assert_eq!(&s11.lapse.other.keys().collect_vec(), empty); Ok(()) } #[test] fn new_intervals() { let decode = |value: Value| -> NewCardIntervals { deserialize_new_intervals(value.into_deserializer()).unwrap() }; assert_eq!( decode(json!([2, 4, 6])), NewCardIntervals { good: 2, easy: 4, _unused: 0 } ); assert_eq!( decode(json!([3, 9])), NewCardIntervals { good: 3, easy: 9, _unused: 0 } ); // invalid input will yield defaults assert_eq!( decode(json!([4])), NewCardIntervals { good: 1, easy: 4, _unused: 0 } ); assert_eq!( decode(json!([-5, 4, 3])), NewCardIntervals { good: 1, easy: 4, _unused: 0 } ); } } ================================================ FILE: rslib/src/deckconfig/service.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use anki_proto::generic; use rayon::iter::IntoParallelIterator; use rayon::iter::ParallelIterator; use crate::collection::Collection; use crate::deckconfig::DeckConfSchema11; use crate::deckconfig::DeckConfig; use crate::deckconfig::DeckConfigId; use crate::deckconfig::UpdateDeckConfigsRequest; use crate::error::Result; use crate::scheduler::fsrs::params::ignore_revlogs_before_date_to_ms; use crate::scheduler::fsrs::simulator::is_included_card; impl crate::services::DeckConfigService for Collection { fn add_or_update_deck_config_legacy( &mut self, input: generic::Json, ) -> Result { let conf: DeckConfSchema11 = serde_json::from_slice(&input.json)?; let mut conf: DeckConfig = conf.into(); self.transact_no_undo(|col| { col.add_or_update_deck_config_legacy(&mut conf)?; Ok(anki_proto::deck_config::DeckConfigId { dcid: conf.id.0 }) }) } fn all_deck_config_legacy(&mut self) -> Result { let conf: Vec = self .storage .all_deck_config()? .into_iter() .map(Into::into) .collect(); serde_json::to_vec(&conf) .map_err(Into::into) .map(Into::into) } fn get_deck_config( &mut self, input: anki_proto::deck_config::DeckConfigId, ) -> Result { Ok(Collection::get_deck_config(self, input.into(), true)? .unwrap() .into()) } fn get_deck_config_legacy( &mut self, input: anki_proto::deck_config::DeckConfigId, ) -> Result { let conf = Collection::get_deck_config(self, input.into(), true)?.unwrap(); let conf: DeckConfSchema11 = conf.into(); Ok(serde_json::to_vec(&conf)?.into()) } fn new_deck_config_legacy(&mut self) -> Result { serde_json::to_vec(&DeckConfSchema11::default()) .map_err(Into::into) .map(Into::into) } fn remove_deck_config(&mut self, input: anki_proto::deck_config::DeckConfigId) -> Result<()> { self.transact_no_undo(|col| col.remove_deck_config_inner(input.into())) } fn get_deck_configs_for_update( &mut self, input: anki_proto::decks::DeckId, ) -> Result { self.get_deck_configs_for_update(input.did.into()) } fn update_deck_configs( &mut self, input: anki_proto::deck_config::UpdateDeckConfigsRequest, ) -> Result { self.update_deck_configs(input.into()).map(Into::into) } fn get_ignored_before_count( &mut self, input: anki_proto::deck_config::GetIgnoredBeforeCountRequest, ) -> Result { let timestamp = ignore_revlogs_before_date_to_ms(&input.ignore_revlogs_before_date)?; let guard = self.search_cards_into_table( &format!("{} -is:new", input.search), crate::search::SortMode::NoOrder, )?; Ok(anki_proto::deck_config::GetIgnoredBeforeCountResponse { included: guard .col .storage .get_card_count_with_ignore_before(timestamp)?, total: guard.cards.try_into().unwrap_or(0), }) } fn get_retention_workload( &mut self, input: anki_proto::deck_config::GetRetentionWorkloadRequest, ) -> Result { let days_elapsed = self.timing_today().unwrap().days_elapsed as i32; let guard = self.search_cards_into_table(&input.search, crate::search::SortMode::NoOrder)?; let revlogs = guard .col .storage .get_revlog_entries_for_searched_cards_in_card_order()?; let mut config = guard.col.get_optimal_retention_parameters(revlogs)?; let cards = guard .col .storage .all_searched_cards()? .into_iter() .filter(is_included_card) .filter_map(|c| crate::card::Card::convert(c.clone(), days_elapsed, c.memory_state?)) .collect::>(); config.deck_size = guard.cards; let costs = (70u32..=99u32) .into_par_iter() .map(|dr| { Ok(( dr, fsrs::expected_workload_with_existing_cards( &input.w, dr as f32 / 100., &config, &cards, )?, )) }) .collect::>>()?; Ok(anki_proto::deck_config::GetRetentionWorkloadResponse { costs }) } } impl From for anki_proto::deck_config::DeckConfig { fn from(c: DeckConfig) -> Self { anki_proto::deck_config::DeckConfig { id: c.id.0, name: c.name, mtime_secs: c.mtime_secs.0, usn: c.usn.0, config: Some(c.inner), } } } impl From for UpdateDeckConfigsRequest { fn from(c: anki_proto::deck_config::UpdateDeckConfigsRequest) -> Self { let mode = c.mode(); UpdateDeckConfigsRequest { target_deck_id: c.target_deck_id.into(), configs: c.configs.into_iter().map(Into::into).collect(), removed_config_ids: c.removed_config_ids.into_iter().map(Into::into).collect(), mode, card_state_customizer: c.card_state_customizer, limits: c.limits.unwrap_or_default(), new_cards_ignore_review_limit: c.new_cards_ignore_review_limit, apply_all_parent_limits: c.apply_all_parent_limits, fsrs: c.fsrs, fsrs_reschedule: c.fsrs_reschedule, fsrs_health_check: c.fsrs_health_check, } } } impl From for DeckConfig { fn from(c: anki_proto::deck_config::DeckConfig) -> Self { DeckConfig { id: c.id.into(), name: c.name, mtime_secs: c.mtime_secs.into(), usn: c.usn.into(), inner: c.config.unwrap_or_default(), } } } impl From for DeckConfigId { fn from(dcid: anki_proto::deck_config::DeckConfigId) -> Self { DeckConfigId(dcid.dcid) } } ================================================ FILE: rslib/src/deckconfig/undo.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::prelude::*; #[derive(Debug)] pub(crate) enum UndoableDeckConfigChange { Added(Box), Updated(Box), Removed(Box), } impl Collection { pub(crate) fn undo_deck_config_change( &mut self, change: UndoableDeckConfigChange, ) -> Result<()> { match change { UndoableDeckConfigChange::Added(config) => self.remove_deck_config_undoable(*config), UndoableDeckConfigChange::Updated(config) => { let current = self .storage .get_deck_config(config.id)? .or_invalid("deck config disappeared")?; self.update_deck_config_undoable(&config, current) } UndoableDeckConfigChange::Removed(config) => self.restore_deleted_deck_config(*config), } } pub(crate) fn remove_deck_config_undoable(&mut self, config: DeckConfig) -> Result<()> { self.storage.remove_deck_conf(config.id)?; self.save_undo(UndoableDeckConfigChange::Removed(Box::new(config))); Ok(()) } pub(super) fn add_deck_config_undoable( &mut self, config: &mut DeckConfig, ) -> Result<(), AnkiError> { self.storage.add_deck_conf(config)?; self.save_undo(UndoableDeckConfigChange::Added(Box::new(config.clone()))); Ok(()) } pub(crate) fn add_deck_config_if_unique_undoable(&mut self, config: &DeckConfig) -> Result<()> { if self.storage.add_deck_conf_if_unique(config)? { self.save_undo(UndoableDeckConfigChange::Added(Box::new(config.clone()))); } Ok(()) } pub(super) fn update_deck_config_undoable( &mut self, config: &DeckConfig, original: DeckConfig, ) -> Result<()> { self.save_undo(UndoableDeckConfigChange::Updated(Box::new(original))); self.storage.update_deck_conf(config) } fn restore_deleted_deck_config(&mut self, config: DeckConfig) -> Result<()> { self.storage .add_or_update_deck_config_with_existing_id(&config)?; self.save_undo(UndoableDeckConfigChange::Added(Box::new(config))); Ok(()) } } ================================================ FILE: rslib/src/deckconfig/update.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html //! Updating configs in bulk, from the deck options screen. use std::collections::HashMap; use std::collections::HashSet; use std::iter; use anki_proto::deck_config::deck_configs_for_update::current_deck::Limits; use anki_proto::deck_config::deck_configs_for_update::ConfigWithExtra; use anki_proto::deck_config::deck_configs_for_update::CurrentDeck; use anki_proto::deck_config::UpdateDeckConfigsMode; use anki_proto::decks::deck::normal::DayLimit; use fsrs::DEFAULT_PARAMETERS; use fsrs::FSRS; use crate::config::I32ConfigKey; use crate::config::StringKey; use crate::decks::NormalDeck; use crate::prelude::*; use crate::scheduler::fsrs::memory_state::UpdateMemoryStateEntry; use crate::scheduler::fsrs::memory_state::UpdateMemoryStateRequest; use crate::scheduler::fsrs::params::ignore_revlogs_before_ms_from_config; use crate::scheduler::fsrs::params::ComputeParamsRequest; use crate::search::JoinSearches; use crate::search::Negated; use crate::search::SearchNode; use crate::search::StateKind; use crate::storage::comma_separated_ids; #[derive(Debug, Clone)] pub struct UpdateDeckConfigsRequest { pub target_deck_id: DeckId, /// Deck will be set to last provided deck config. pub configs: Vec, pub removed_config_ids: Vec, pub mode: UpdateDeckConfigsMode, pub card_state_customizer: String, pub limits: Limits, pub new_cards_ignore_review_limit: bool, pub apply_all_parent_limits: bool, pub fsrs: bool, pub fsrs_reschedule: bool, pub fsrs_health_check: bool, } impl Collection { /// Information required for the deck options screen. pub fn get_deck_configs_for_update( &mut self, deck: DeckId, ) -> Result { let mut defaults = DeckConfig::default(); defaults.inner.fsrs_params_6 = DEFAULT_PARAMETERS.into(); let last_optimize = self.get_config_i32(I32ConfigKey::LastFsrsOptimize) as u32; let days_since_last_fsrs_optimize = if last_optimize > 0 { self.timing_today()? .days_elapsed .saturating_sub(last_optimize) } else { 0 }; Ok(anki_proto::deck_config::DeckConfigsForUpdate { all_config: self.get_deck_config_with_extra_for_update()?, current_deck: Some(self.get_current_deck_for_update(deck)?), defaults: Some(defaults.into()), schema_modified: self .storage .get_collection_timestamps()? .schema_changed_since_sync(), card_state_customizer: self.get_config_string(StringKey::CardStateCustomizer), new_cards_ignore_review_limit: self.get_config_bool(BoolKey::NewCardsIgnoreReviewLimit), apply_all_parent_limits: self.get_config_bool(BoolKey::ApplyAllParentLimits), fsrs: self.get_config_bool(BoolKey::Fsrs), fsrs_health_check: self.get_config_bool(BoolKey::FsrsHealthCheck), fsrs_legacy_evaluate: self.get_config_bool(BoolKey::FsrsLegacyEvaluate), days_since_last_fsrs_optimize, }) } /// Information required for the deck options screen. pub fn update_deck_configs(&mut self, input: UpdateDeckConfigsRequest) -> Result> { self.transact(Op::UpdateDeckConfig, |col| { col.update_deck_configs_inner(input) }) } } impl Collection { fn get_deck_config_with_extra_for_update(&self) -> Result> { // grab the config and sort it let mut config = self.storage.all_deck_config()?; config.sort_unstable_by(|a, b| a.name.cmp(&b.name)); // pre-fill empty fsrs params with older params config.iter_mut().for_each(|c| { if c.inner.fsrs_params_6.is_empty() { c.inner.fsrs_params_6 = if c.inner.fsrs_params_5.is_empty() { c.inner.fsrs_params_4.clone() } else { c.inner.fsrs_params_5.clone() }; } }); // combine with use counts let counts = self.get_deck_config_use_counts()?; Ok(config .into_iter() .map(|config| ConfigWithExtra { use_count: counts.get(&config.id).cloned().unwrap_or_default() as u32, config: Some(config.into()), }) .collect()) } fn get_deck_config_use_counts(&self) -> Result> { let mut counts = HashMap::new(); for deck in self.storage.get_all_decks()? { if let Ok(normal) = deck.normal() { *counts.entry(DeckConfigId(normal.config_id)).or_default() += 1; } } Ok(counts) } fn get_current_deck_for_update(&mut self, deck: DeckId) -> Result { let deck = self.get_deck(deck)?.or_not_found(deck)?; let normal = deck.normal()?; let today = self.timing_today()?.days_elapsed; Ok(CurrentDeck { name: deck.human_name(), config_id: normal.config_id, parent_config_ids: self .parent_config_ids(&deck)? .into_iter() .map(Into::into) .collect(), limits: Some(normal_deck_to_limits(normal, today)), }) } /// Deck configs used by parent decks. fn parent_config_ids(&self, deck: &Deck) -> Result> { Ok(self .storage .parent_decks(deck)? .iter() .filter_map(|deck| { deck.normal() .ok() .map(|normal| DeckConfigId(normal.config_id)) }) .collect()) } fn update_deck_configs_inner(&mut self, mut req: UpdateDeckConfigsRequest) -> Result<()> { require!(!req.configs.is_empty(), "config not provided"); let configs_before_update = self.storage.get_deck_config_map()?; let mut configs_after_update = configs_before_update.clone(); // handle removals first for dcid in &req.removed_config_ids { self.remove_deck_config_inner(*dcid)?; configs_after_update.remove(dcid); } if req.mode == UpdateDeckConfigsMode::ComputeAllParams { self.compute_all_params(&mut req)?; } // add/update provided configs for conf in &mut req.configs { // If the user has provided empty FSRS6 params, zero out any // old params as well, so we don't fall back on them, which would // be surprising as they're not shown in the GUI. if conf.inner.fsrs_params_6.is_empty() { conf.inner.fsrs_params_5.clear(); conf.inner.fsrs_params_4.clear(); } // check the provided parameters are valid before we save them FSRS::new(Some(conf.fsrs_params()))?; self.add_or_update_deck_config(conf)?; configs_after_update.insert(conf.id, conf.clone()); } // get selected deck and possibly children let selected_deck_ids: HashSet<_> = if req.mode == UpdateDeckConfigsMode::ApplyToChildren { let deck = self .storage .get_deck(req.target_deck_id)? .or_not_found(req.target_deck_id)?; self.storage .child_decks(&deck)? .iter() .chain(iter::once(&deck)) .map(|d| d.id) .collect() } else { [req.target_deck_id].iter().cloned().collect() }; // loop through all normal decks let usn = self.usn()?; let today = self.timing_today()?.days_elapsed; let selected_config = req.configs.last().unwrap(); let mut decks_needing_memory_recompute: HashMap> = Default::default(); let fsrs_toggled = self.get_config_bool(BoolKey::Fsrs) != req.fsrs; if fsrs_toggled { self.set_config_bool_inner(BoolKey::Fsrs, req.fsrs)?; } let mut deck_desired_retention: HashMap = Default::default(); for deck in self.storage.get_all_decks()? { if let Ok(normal) = deck.normal() { let deck_id = deck.id; // previous order & params let previous_config_id = DeckConfigId(normal.config_id); let previous_config = configs_before_update.get(&previous_config_id); let previous_order = previous_config .map(|c| c.inner.new_card_insert_order()) .unwrap_or_default(); let previous_params = previous_config.map(|c| c.fsrs_params()); let previous_preset_dr = previous_config.map(|c| c.inner.desired_retention); let previous_deck_dr = normal.desired_retention; let previous_dr = previous_deck_dr.or(previous_preset_dr); let previous_easy_days = previous_config.map(|c| &c.inner.easy_days_percentages); // if a selected (sub)deck, or its old config was removed, update deck to point // to new config let (current_config_id, current_deck_dr) = if selected_deck_ids.contains(&deck.id) || !configs_after_update.contains_key(&previous_config_id) { let mut updated = deck.clone(); updated.normal_mut()?.config_id = selected_config.id.0; update_deck_limits(updated.normal_mut()?, &req.limits, today); self.update_deck_inner(&mut updated, deck, usn)?; (selected_config.id, updated.normal()?.desired_retention) } else { (previous_config_id, previous_deck_dr) }; // if new order differs, deck needs re-sorting let current_config = configs_after_update.get(¤t_config_id); let current_order = current_config .map(|c| c.inner.new_card_insert_order()) .unwrap_or_default(); if previous_order != current_order { self.sort_deck(deck_id, current_order, usn)?; } // if params differ, memory state needs to be recomputed let current_params = current_config.map(|c| c.fsrs_params()); let current_preset_dr = current_config.map(|c| c.inner.desired_retention); let current_dr = current_deck_dr.or(current_preset_dr); let current_easy_days = current_config.map(|c| &c.inner.easy_days_percentages); if fsrs_toggled || previous_params != current_params || previous_dr != current_dr || (req.fsrs_reschedule && previous_easy_days != current_easy_days) { decks_needing_memory_recompute .entry(current_config_id) .or_default() .push(deck_id); } if let Some(desired_retention) = current_deck_dr { deck_desired_retention.insert(deck_id, desired_retention); } self.adjust_remaining_steps_in_deck(deck_id, previous_config, current_config, usn)?; } } if !decks_needing_memory_recompute.is_empty() { let input: Vec = decks_needing_memory_recompute .into_iter() .map(|(conf_id, search)| { let config = configs_after_update.get(&conf_id); let params = config.and_then(|c| { if req.fsrs { Some(UpdateMemoryStateRequest { params: c.fsrs_params().clone(), preset_desired_retention: c.inner.desired_retention, max_interval: c.inner.maximum_review_interval, reschedule: req.fsrs_reschedule, historical_retention: c.inner.historical_retention, deck_desired_retention: deck_desired_retention.clone(), }) } else { None } }); Ok(UpdateMemoryStateEntry { req: params, search: SearchNode::DeckIdsWithoutChildren(comma_separated_ids(&search)), ignore_before: config .map(ignore_revlogs_before_ms_from_config) .unwrap_or(Ok(0.into()))?, }) }) .collect::>()?; self.update_memory_state(input)?; } self.set_config_string_inner(StringKey::CardStateCustomizer, &req.card_state_customizer)?; self.set_config_bool_inner( BoolKey::NewCardsIgnoreReviewLimit, req.new_cards_ignore_review_limit, )?; self.set_config_bool_inner(BoolKey::ApplyAllParentLimits, req.apply_all_parent_limits)?; self.set_config_bool_inner(BoolKey::FsrsHealthCheck, req.fsrs_health_check)?; Ok(()) } /// Adjust the remaining steps of cards in the given deck according to the /// config change. pub(crate) fn adjust_remaining_steps_in_deck( &mut self, deck: DeckId, previous_config: Option<&DeckConfig>, current_config: Option<&DeckConfig>, usn: Usn, ) -> Result<()> { if let (Some(old), Some(new)) = (previous_config, current_config) { for (search, old_steps, new_steps) in [ ( SearchBuilder::learning_cards(), &old.inner.learn_steps, &new.inner.learn_steps, ), ( SearchBuilder::relearning_cards(), &old.inner.relearn_steps, &new.inner.relearn_steps, ), ] { if old_steps == new_steps { continue; } let search = search.clone().and(SearchNode::from_deck_id(deck, false)); for mut card in self.all_cards_for_search(search)? { self.adjust_remaining_steps(&mut card, old_steps, new_steps, usn)?; } } } Ok(()) } fn compute_all_params(&mut self, req: &mut UpdateDeckConfigsRequest) -> Result<()> { require!(req.fsrs, "FSRS must be enabled"); // frontend didn't include any unmodified deck configs, so we need to fill them // in let changed_configs: HashSet<_> = req.configs.iter().map(|c| c.id).collect(); let previous_last = req.configs.pop().or_invalid("no configs provided")?; for config in self.storage.all_deck_config()? { if !changed_configs.contains(&config.id) { req.configs.push(config); } } // other parts of the code expect the currently-selected preset to come last req.configs.push(previous_last); // calculate and apply params to each preset let config_len = req.configs.len() as u32; for (idx, config) in req.configs.iter_mut().enumerate() { let search = if config.inner.param_search.trim().is_empty() { SearchNode::Preset(config.name.clone()) .and(SearchNode::State(StateKind::Suspended).negated()) .try_into_search()? .to_string() } else { config.inner.param_search.clone() }; let ignore_revlogs_before_ms = ignore_revlogs_before_ms_from_config(config)?; let num_of_relearning_steps = config.inner.relearn_steps.len(); match self.compute_params(ComputeParamsRequest { search: &search, ignore_revlogs_before_ms, current_preset: idx as u32 + 1, total_presets: config_len, current_params: config.fsrs_params(), num_of_relearning_steps, health_check: false, }) { Ok(params) => { println!("{}: {:?}", config.name, params.params); config.inner.fsrs_params_6 = params.params; } Err(AnkiError::Interrupted) => return Err(AnkiError::Interrupted), Err(err) => { println!("{}: {}", config.name, err) } } let today = self.timing_today()?.days_elapsed as i32; self.set_config_i32_inner(I32ConfigKey::LastFsrsOptimize, today)?; } Ok(()) } } fn normal_deck_to_limits(deck: &NormalDeck, today: u32) -> Limits { Limits { review: deck.review_limit, new: deck.new_limit, review_today: deck.review_limit_today.map(|limit| limit.limit), new_today: deck.new_limit_today.map(|limit| limit.limit), review_today_active: deck .review_limit_today .map(|limit| limit.today == today) .unwrap_or_default(), new_today_active: deck .new_limit_today .map(|limit| limit.today == today) .unwrap_or_default(), desired_retention: deck.desired_retention, } } fn update_deck_limits(deck: &mut NormalDeck, limits: &Limits, today: u32) { deck.review_limit = limits.review; deck.new_limit = limits.new; update_day_limit(&mut deck.review_limit_today, limits.review_today, today); update_day_limit(&mut deck.new_limit_today, limits.new_today, today); deck.desired_retention = limits.desired_retention; } fn update_day_limit(day_limit: &mut Option, new_limit: Option, today: u32) { if let Some(limit) = new_limit { day_limit.replace(DayLimit { limit, today }); } else { // if the collection was created today, the // "preserve last value" hack below won't work // clear "future" limits as well (from imports) day_limit.take_if(|limit| limit.today == 0 || limit.today > today); if let Some(limit) = day_limit { // instead of setting to None, only make sure today is in the past, // thus preserving last used value limit.today = limit.today.min(today.saturating_sub(1)); } } } #[cfg(test)] mod test { use super::*; use crate::deckconfig::NewCardInsertOrder; use crate::tests::open_test_collection_with_learning_card; use crate::tests::open_test_collection_with_relearning_card; #[test] fn updating() -> Result<()> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note1 = nt.new_note(); col.add_note(&mut note1, DeckId(1))?; let card1_id = col.storage.card_ids_of_notes(&[note1.id])?[0]; for _ in 0..9 { let mut note = nt.new_note(); col.add_note(&mut note, DeckId(1))?; } // add the keys so it doesn't trigger a change below col.set_config_string_inner(StringKey::CardStateCustomizer, "")?; col.set_config_bool_inner(BoolKey::NewCardsIgnoreReviewLimit, false)?; col.set_config_bool_inner(BoolKey::ApplyAllParentLimits, false)?; col.set_config_bool_inner(BoolKey::FsrsHealthCheck, true)?; // pretend we're in sync let stamps = col.storage.get_collection_timestamps()?; col.storage.set_last_sync(stamps.schema_change)?; let full_sync_required = |col: &mut Collection| -> bool { col.storage .get_collection_timestamps() .unwrap() .schema_changed_since_sync() }; let reset_card1_pos = |col: &mut Collection| { let mut card = col.storage.get_card(card1_id).unwrap().unwrap(); // set it out of bounds, so we can be sure it has changed card.due = 0; col.storage.update_card(&card).unwrap(); }; let card1_pos = |col: &mut Collection| col.storage.get_card(card1_id).unwrap().unwrap().due; // if nothing changed, no changes should be made let output = col.get_deck_configs_for_update(DeckId(1))?; let mut input = UpdateDeckConfigsRequest { target_deck_id: DeckId(1), configs: output .all_config .into_iter() .map(|c| c.config.unwrap().into()) .collect(), removed_config_ids: vec![], mode: UpdateDeckConfigsMode::Normal, card_state_customizer: "".to_string(), limits: Limits::default(), new_cards_ignore_review_limit: false, apply_all_parent_limits: false, fsrs: false, fsrs_reschedule: false, fsrs_health_check: true, }; assert!(!col.update_deck_configs(input.clone())?.changes.had_change()); // modifying a value should update the config, but not the deck input.configs[0].inner.new_per_day += 1; let changes = col.update_deck_configs(input.clone())?.changes.changes; assert!(!changes.deck); assert!(changes.deck_config); assert!(!changes.card); // adding a new config will update the deck as well let new_config = DeckConfig { id: DeckConfigId(0), ..input.configs[0].clone() }; input.configs.push(new_config); let changes = col.update_deck_configs(input.clone())?.changes.changes; assert!(changes.deck); assert!(changes.deck_config); assert!(!changes.card); let allocated_id = col.get_deck(DeckId(1))?.unwrap().normal()?.config_id; assert_ne!(allocated_id, 0); assert_ne!(allocated_id, 1); // changing the order will cause the cards to be re-sorted assert_eq!(card1_pos(&mut col), 1); reset_card1_pos(&mut col); assert_eq!(card1_pos(&mut col), 0); input.configs[1].inner.new_card_insert_order = NewCardInsertOrder::Random as i32; assert!(col.update_deck_configs(input.clone())?.changes.changes.card); assert_ne!(card1_pos(&mut col), 0); // removing the config will assign the selected config (default in this case), // and as default has normal sort order, that will reset the order again assert!(!full_sync_required(&mut col)); reset_card1_pos(&mut col); input.configs.remove(1); input.removed_config_ids.push(DeckConfigId(allocated_id)); col.update_deck_configs(input)?; let current_id = col.get_deck(DeckId(1))?.unwrap().normal()?.config_id; assert_eq!(current_id, 1); assert_eq!(card1_pos(&mut col), 1); // should have forced a full sync assert!(full_sync_required(&mut col)); Ok(()) } #[test] fn should_increase_remaining_learning_steps_if_unpassed_learning_step_added() { let mut col = open_test_collection_with_learning_card(); col.set_default_learn_steps(vec![1., 10., 100.]); assert_eq!(col.get_first_card().remaining_steps, 3); } #[test] fn should_keep_remaining_learning_steps_if_unpassed_relearning_step_added() { let mut col = open_test_collection_with_learning_card(); col.set_default_relearn_steps(vec![1., 10., 100.]); assert_eq!(col.get_first_card().remaining_steps, 2); } #[test] fn should_keep_remaining_learning_steps_if_passed_learning_step_added() { let mut col = open_test_collection_with_learning_card(); col.answer_good(); col.set_default_learn_steps(vec![1., 1., 10.]); assert_eq!(col.get_first_card().remaining_steps, 1); } #[test] fn should_keep_at_least_one_remaining_learning_step() { let mut col = open_test_collection_with_learning_card(); col.answer_good(); col.set_default_learn_steps(vec![1.]); assert_eq!(col.get_first_card().remaining_steps, 1); } #[test] fn should_increase_remaining_relearning_steps_if_unpassed_relearning_step_added() { let mut col = open_test_collection_with_relearning_card(); col.set_default_relearn_steps(vec![1., 10., 100.]); assert_eq!(col.get_first_card().remaining_steps, 3); } #[test] fn should_keep_remaining_relearning_steps_if_unpassed_learning_step_added() { let mut col = open_test_collection_with_relearning_card(); col.set_default_learn_steps(vec![1., 10., 100.]); assert_eq!(col.get_first_card().remaining_steps, 1); } #[test] fn should_keep_remaining_relearning_steps_if_passed_relearning_step_added() { let mut col = open_test_collection_with_relearning_card(); col.set_default_relearn_steps(vec![10., 100.]); col.answer_good(); col.set_default_relearn_steps(vec![1., 10., 100.]); assert_eq!(col.get_first_card().remaining_steps, 1); } #[test] fn should_keep_at_least_one_remaining_relearning_step() { let mut col = open_test_collection_with_relearning_card(); col.set_default_relearn_steps(vec![10., 100.]); col.answer_good(); col.set_default_relearn_steps(vec![1.]); assert_eq!(col.get_first_card().remaining_steps, 1); } } ================================================ FILE: rslib/src/decks/addupdate.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html //! Adding and updating. use super::name::immediate_parent_name; use crate::error::FilteredDeckError; use crate::prelude::*; impl Collection { /// Add a new deck. The id must be 0, as it will be automatically assigned. pub fn add_deck(&mut self, deck: &mut Deck) -> Result> { self.transact(Op::AddDeck, |col| col.add_deck_inner(deck, col.usn()?)) } pub fn update_deck(&mut self, deck: &mut Deck) -> Result> { self.transact(Op::UpdateDeck, |col| { let existing_deck = col.storage.get_deck(deck.id)?.or_not_found(deck.id)?; col.update_deck_inner(deck, existing_deck, col.usn()?) }) } /// Add or update an existing deck modified by the user. May add parents, /// or rename children as required. Prefer add_deck() or update_deck() to /// be explicit about your intentions; this function mainly exists so we /// can integrate with older Python code that behaved this way. pub fn add_or_update_deck(&mut self, deck: &mut Deck) -> Result> { if deck.id.0 == 0 { self.add_deck(deck) } else { self.update_deck(deck) } } } impl Collection { /// Rename deck if not unique. Bumps mtime and usn if /// name was changed, but otherwise leaves it the same. pub(super) fn prepare_deck_for_update(&mut self, deck: &mut Deck, usn: Usn) -> Result<()> { if deck.name.maybe_normalize() { deck.set_modified(usn); } self.ensure_deck_name_unique(deck, usn) } pub(crate) fn add_deck_inner(&mut self, deck: &mut Deck, usn: Usn) -> Result<()> { require!(deck.id.0 == 0, "deck to add must have id 0"); self.prepare_deck_for_update(deck, usn)?; deck.set_modified(usn); self.match_or_create_parents(deck, usn)?; self.add_deck_undoable(deck) } pub(crate) fn update_deck_inner( &mut self, deck: &mut Deck, original: Deck, usn: Usn, ) -> Result<()> { self.prepare_deck_for_update(deck, usn)?; if deck == &original { return Ok(()); } deck.set_modified(usn); let name_changed = original.name != deck.name; if name_changed { // match closest parent name self.match_or_create_parents(deck, usn)?; // rename children self.rename_child_decks(&original, &deck.name, usn)?; } self.update_single_deck_undoable(deck, original)?; if name_changed { // after updating, we need to ensure all grandparents exist, which may not be // the case in the parent->child case self.create_missing_parents(&deck.name, usn)?; } Ok(()) } /// Add/update a single deck when syncing/importing. Ensures name is unique /// & normalized, but does not check parents/children or update mtime /// (unless the name was changed). Caller must set up transaction. pub(crate) fn add_or_update_single_deck_with_existing_id( &mut self, deck: &mut Deck, usn: Usn, ) -> Result<()> { self.prepare_deck_for_update(deck, usn)?; self.add_or_update_deck_with_existing_id_undoable(deck) } pub(crate) fn recover_missing_deck(&mut self, did: DeckId, usn: Usn) -> Result<()> { let mut deck = Deck::new_normal(); deck.id = did; deck.name = NativeDeckName::from_native_str(format!("recovered{did}")); deck.set_modified(usn); self.add_or_update_single_deck_with_existing_id(&mut deck, usn) } /// Add a single, normal deck with the provided name for a child deck. /// Caller must have done necessarily validation on name. fn add_parent_deck(&mut self, machine_name: &str, usn: Usn) -> Result<()> { let mut deck = Deck::new_normal(); deck.name = NativeDeckName::from_native_str(machine_name); deck.set_modified(usn); self.add_deck_undoable(&mut deck) } /// If parent deck(s) exist, rewrite name to match their case. /// If they don't exist, create them. /// Returns an error if a DB operation fails, or if the first existing /// parent is a filtered deck. fn match_or_create_parents(&mut self, deck: &mut Deck, usn: Usn) -> Result<()> { let child_split: Vec<_> = deck.name.components().collect(); if let Some(parent_deck) = self.first_existing_parent(deck.name.as_native_str(), 0)? { if parent_deck.is_filtered() { return Err(FilteredDeckError::MustBeLeafNode.into()); } let parent_count = parent_deck.name.components().count(); let need_create = parent_count != child_split.len() - 1; deck.name = NativeDeckName::from_native_str(format!( "{}\x1f{}", parent_deck.name, &child_split[parent_count..].join("\x1f") )); if need_create { self.create_missing_parents(&deck.name, usn)?; } Ok(()) } else if child_split.len() == 1 { // no parents required Ok(()) } else { // no existing parents self.create_missing_parents(&deck.name, usn) } } fn create_missing_parents(&mut self, name: &NativeDeckName, usn: Usn) -> Result<()> { let mut machine_name = name.as_native_str(); while let Some(parent_name) = immediate_parent_name(machine_name) { if self.storage.get_deck_id(parent_name)?.is_none() { self.add_parent_deck(parent_name, usn)?; } machine_name = parent_name; } Ok(()) } pub(crate) fn first_existing_parent( &self, machine_name: &str, recursion_level: usize, ) -> Result> { require!(recursion_level < 11, "deck nesting level too deep"); if let Some(parent_name) = immediate_parent_name(machine_name) { if let Some(parent_did) = self.storage.get_deck_id(parent_name)? { self.storage.get_deck(parent_did) } else { self.first_existing_parent(parent_name, recursion_level + 1) } } else { Ok(None) } } } ================================================ FILE: rslib/src/decks/counts.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use crate::prelude::*; #[derive(Debug)] pub(crate) struct DueCounts { pub new: u32, pub review: u32, /// interday+intraday pub learning: u32, pub intraday_learning: u32, pub interday_learning: u32, pub total_cards: u32, } impl Deck { /// Return the studied counts if studied today. /// May be negative if user has extended limits. pub(crate) fn new_rev_counts(&self, today: u32) -> (i32, i32) { if self.common.last_day_studied == today { (self.common.new_studied, self.common.review_studied) } else { (0, 0) } } } impl Collection { /// Get due counts for decks at the given timestamp. pub(crate) fn due_counts( &mut self, days_elapsed: u32, learn_cutoff: u32, ) -> Result> { self.storage.due_counts(days_elapsed, learn_cutoff) } pub(crate) fn counts_for_deck_today( &mut self, did: DeckId, ) -> Result { let today = self.current_due_day(0)?; let mut deck = self.storage.get_deck(did)?.or_not_found(did)?; deck.reset_stats_if_day_changed(today); Ok(anki_proto::scheduler::CountsForDeckTodayResponse { new: deck.common.new_studied, review: deck.common.review_studied, }) } } ================================================ FILE: rslib/src/decks/current.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::sync::Arc; use crate::config::ConfigKey; use crate::prelude::*; impl Collection { pub fn set_current_deck(&mut self, deck: DeckId) -> Result> { self.transact(Op::SetCurrentDeck, |col| col.set_current_deck_inner(deck)) } /// Fetch the current deck, falling back to the default if the previously /// selected deck is invalid. pub fn get_current_deck(&mut self) -> Result> { if let Some(deck) = self.get_deck(self.get_current_deck_id())? { return Ok(deck); } self.get_deck(DeckId(1))?.or_not_found(DeckId(1)) } } impl Collection { /// The returned id may reference a deck that does not exist; /// prefer using get_current_deck() instead. pub(crate) fn get_current_deck_id(&self) -> DeckId { self.get_config_optional(ConfigKey::CurrentDeckId) .unwrap_or(DeckId(1)) } fn set_current_deck_inner(&mut self, deck: DeckId) -> Result<()> { if self.set_current_deck_id(deck)? { self.state.card_queues = None; } Ok(()) } fn set_current_deck_id(&mut self, did: DeckId) -> Result { self.set_config(ConfigKey::CurrentDeckId, &did) } } ================================================ FILE: rslib/src/decks/filtered.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use strum::IntoEnumIterator; use super::DeckCommon; use super::FilteredDeck; use super::FilteredSearchOrder; use super::FilteredSearchTerm; use crate::prelude::*; impl Deck { pub fn new_filtered() -> Deck { let mut filt = FilteredDeck::default(); filt.search_terms.push(FilteredSearchTerm { search: "".into(), limit: 100, order: FilteredSearchOrder::Random as i32, }); filt.search_terms.push(FilteredSearchTerm { search: "".into(), limit: 20, order: FilteredSearchOrder::Due as i32, }); filt.preview_again_secs = 60; filt.preview_hard_secs = 600; filt.reschedule = true; Deck { id: DeckId(0), name: NativeDeckName::from_native_str(""), mtime_secs: TimestampSecs(0), usn: Usn(0), common: DeckCommon { study_collapsed: true, browser_collapsed: true, ..Default::default() }, kind: DeckKind::Filtered(filt), } } pub(crate) fn is_filtered(&self) -> bool { matches!(self.kind, DeckKind::Filtered(_)) } } pub fn search_order_labels(tr: &I18n) -> Vec { FilteredSearchOrder::iter() .map(|v| search_order_label(v, tr)) .collect() } fn search_order_label(order: FilteredSearchOrder, tr: &I18n) -> String { match order { FilteredSearchOrder::OldestReviewedFirst => tr.decks_oldest_seen_first(), FilteredSearchOrder::Random => tr.decks_random(), FilteredSearchOrder::IntervalsAscending => tr.decks_increasing_intervals(), FilteredSearchOrder::IntervalsDescending => tr.decks_decreasing_intervals(), FilteredSearchOrder::Lapses => tr.decks_most_lapses(), FilteredSearchOrder::Added => tr.decks_order_added(), FilteredSearchOrder::Due => tr.decks_order_due(), FilteredSearchOrder::ReverseAdded => tr.decks_latest_added_first(), FilteredSearchOrder::RetrievabilityAscending => { tr.deck_config_sort_order_retrievability_ascending() } FilteredSearchOrder::RetrievabilityDescending => { tr.deck_config_sort_order_retrievability_descending() } FilteredSearchOrder::RelativeOverdueness => tr.decks_relative_overdueness(), } .into() } ================================================ FILE: rslib/src/decks/limits.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::iter::Peekable; use anki_proto::decks::deck::normal::DayLimit; use id_tree::InsertBehavior; use id_tree::Node; use id_tree::NodeId; use id_tree::Tree; use super::Deck; use super::NormalDeck; use crate::deckconfig::DeckConfig; use crate::deckconfig::DeckConfigId; use crate::prelude::*; #[derive(Debug, Clone, Copy)] pub(crate) enum LimitKind { Review, New, } /// The deck's review limit for today, or its regular one, if any is /// configured. pub fn current_review_limit(deck: &NormalDeck, today: u32) -> Option { review_limit_today(deck, today).or(deck.review_limit) } /// The deck's new limit for today, or its regular one, if any is /// configured. pub fn current_new_limit(deck: &NormalDeck, today: u32) -> Option { new_limit_today(deck, today).or(deck.new_limit) } /// The deck's review limit for today. pub fn review_limit_today(deck: &NormalDeck, today: u32) -> Option { deck.review_limit_today .and_then(|day_limit| limit_if_today(day_limit, today)) } /// The deck's new limit for today. pub fn new_limit_today(deck: &NormalDeck, today: u32) -> Option { deck.new_limit_today .and_then(|day_limit| limit_if_today(day_limit, today)) } pub fn limit_if_today(limit: DayLimit, today: u32) -> Option { (limit.today == today).then_some(limit.limit) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct RemainingLimits { pub(crate) review: u32, pub(crate) new: u32, pub(crate) cap_new_to_review: bool, } impl RemainingLimits { pub(crate) fn new( deck: &Deck, config: Option<&DeckConfig>, today: u32, new_cards_ignore_review_limit: bool, ) -> Self { if let Ok(normal) = deck.normal() { if let Some(config) = config { return Self::new_for_normal_deck( deck, today, new_cards_ignore_review_limit, normal, config, ); } } Self::default() } fn new_for_normal_deck( deck: &Deck, today: u32, new_cards_ignore_review_limit: bool, normal: &NormalDeck, config: &DeckConfig, ) -> RemainingLimits { Self::new_for_normal_deck_v3(deck, today, new_cards_ignore_review_limit, normal, config) } fn new_for_normal_deck_v3( deck: &Deck, today: u32, new_cards_ignore_review_limit: bool, normal: &NormalDeck, config: &DeckConfig, ) -> RemainingLimits { let mut review_limit = current_review_limit(normal, today).unwrap_or(config.inner.reviews_per_day) as i32; let mut new_limit = current_new_limit(normal, today).unwrap_or(config.inner.new_per_day) as i32; let (new_today_count, review_today_count) = deck.new_rev_counts(today); review_limit -= review_today_count; new_limit -= new_today_count; if !new_cards_ignore_review_limit { review_limit -= new_today_count; new_limit = new_limit.min(review_limit); } Self { review: review_limit.max(0) as u32, new: new_limit.max(0) as u32, cap_new_to_review: !new_cards_ignore_review_limit, } } pub(crate) fn get(&self, kind: LimitKind) -> u32 { match kind { LimitKind::Review => self.review, LimitKind::New => self.new, } } pub(crate) fn cap_to(&mut self, limits: RemainingLimits) { self.review = self.review.min(limits.review); self.new = self.new.min(limits.new); } /// True if some limit was decremented to 0. fn decrement(&mut self, kind: LimitKind) -> DecrementResult { let before = *self; match kind { LimitKind::Review => { self.review = self.review.saturating_sub(1); if self.cap_new_to_review { self.new = self.new.min(self.review); } } LimitKind::New => self.new = self.new.saturating_sub(1), } DecrementResult::new(&before, self) } } struct DecrementResult { count_reached_zero: bool, } impl DecrementResult { fn new(before: &RemainingLimits, after: &RemainingLimits) -> Self { Self { count_reached_zero: before.review > 0 && after.review == 0 || before.new > 0 && after.new == 0, } } } impl Default for RemainingLimits { fn default() -> Self { RemainingLimits { review: 9999, new: 9999, cap_new_to_review: false, } } } pub(crate) fn remaining_limits_map<'a>( decks: impl Iterator, config: &'a HashMap, today: u32, new_cards_ignore_review_limit: bool, ) -> HashMap { decks .map(|deck| { ( deck.id, RemainingLimits::new( deck, deck.config_id().and_then(|id| config.get(&id)), today, new_cards_ignore_review_limit, ), ) }) .collect() } /// Wrapper of [RemainingLimits] with some additional meta data. #[derive(Debug, Clone, Copy)] struct NodeLimits { /// absolute level in the deck hierarchy level: usize, limits: RemainingLimits, } impl NodeLimits { fn new( deck: &Deck, config: &HashMap, today: u32, new_cards_ignore_review_limit: bool, ) -> Self { Self { level: deck.name.components().count(), limits: RemainingLimits::new( deck, deck.config_id().and_then(|id| config.get(&id)), today, new_cards_ignore_review_limit, ), } } } #[derive(Debug, Clone)] pub(crate) struct LimitTreeMap { /// A tree representing the remaining limits of the active deck hierarchy. // // As long as we never (1) allow a tree without a root, (2) remove nodes, // and (3) have more than 1 tree, it's safe to unwrap on Tree::get() and // Tree::root_node_id(), even if we clone Nodes. tree: Tree, /// A map to access the tree node of a deck. map: HashMap, } impl LimitTreeMap { /// [Deck]s must be sorted by name. pub(crate) fn build( decks: &[Deck], config: &HashMap, today: u32, new_cards_ignore_review_limit: bool, ) -> Self { let root_limits = NodeLimits::new(&decks[0], config, today, new_cards_ignore_review_limit); let mut tree = Tree::new(); let root_id = tree .insert(Node::new(root_limits), InsertBehavior::AsRoot) .unwrap(); let mut map = HashMap::new(); map.insert(decks[0].id, root_id.clone()); let mut limits = Self { tree, map }; let mut remaining_decks = decks[1..].iter().peekable(); limits.add_child_nodes( root_id, &mut remaining_decks, config, today, new_cards_ignore_review_limit, ); limits } /// Recursively appends descendants to the provided parent [Node], and adds /// them to the [HashMap]. /// Given [Deck]s are assumed to arrive in depth-first order. /// The tree-from-deck-list logic is taken from /// [crate::decks::tree::add_child_nodes]. fn add_child_nodes<'d>( &mut self, parent_node_id: NodeId, remaining_decks: &mut Peekable>, config: &HashMap, today: u32, new_cards_ignore_review_limit: bool, ) { let parent = *self.tree.get(&parent_node_id).unwrap().data(); while let Some(deck) = remaining_decks.peek() { match deck.name.components().count() { l if l <= parent.level => { // next item is at a higher level break; } l if l == parent.level + 1 => { // next item is an immediate descendent of parent self.insert_child_node( deck, parent_node_id.clone(), config, today, new_cards_ignore_review_limit, ); remaining_decks.next(); } _ => { // next item is at a lower level if let Some(last_child_node_id) = self .tree .get(&parent_node_id) .unwrap() .children() .last() .cloned() { self.add_child_nodes( last_child_node_id, remaining_decks, config, today, new_cards_ignore_review_limit, ) } else { // immediate parent is missing, skip the deck until a DB check is run remaining_decks.next(); } } } } } fn insert_child_node( &mut self, child_deck: &Deck, parent_node_id: NodeId, config: &HashMap, today: u32, new_cards_ignore_review_limit: bool, ) { let mut child_limits = NodeLimits::new(child_deck, config, today, new_cards_ignore_review_limit); child_limits .limits .cap_to(self.get_node_limits(&parent_node_id)); let child_node_id = self .tree .insert( Node::new(child_limits), InsertBehavior::UnderNode(&parent_node_id), ) .unwrap(); self.map.insert(child_deck.id, child_node_id); } fn get_node_id(&self, deck_id: DeckId) -> Result<&NodeId> { self.map .get(&deck_id) .or_invalid("deck not found in limits map") } fn get_node_limits(&self, node_id: &NodeId) -> RemainingLimits { self.tree.get(node_id).unwrap().data().limits } fn get_deck_limits(&self, deck_id: DeckId) -> Result { self.get_node_id(deck_id) .map(|node_id| self.get_node_limits(node_id)) } fn get_root_limits(&self) -> RemainingLimits { self.get_node_limits(self.tree.root_node_id().unwrap()) } pub(crate) fn root_limit_reached(&self, kind: LimitKind) -> bool { self.get_root_limits().get(kind) == 0 } pub(crate) fn limit_reached(&self, deck_id: DeckId, kind: LimitKind) -> Result { Ok(self.get_deck_limits(deck_id)?.get(kind) == 0) } pub(crate) fn decrement_deck_and_parent_limits( &mut self, deck_id: DeckId, kind: LimitKind, ) -> Result<()> { let node_id = self.get_node_id(deck_id)?.clone(); self.decrement_node_and_parent_limits(&node_id, kind); Ok(()) } fn decrement_node_and_parent_limits(&mut self, node_id: &NodeId, kind: LimitKind) { let node = self.tree.get_mut(node_id).unwrap(); let parent = node.parent().cloned(); let limits = &mut node.data_mut().limits; if limits.decrement(kind).count_reached_zero { let limits = *limits; self.cap_node_and_descendants(node_id, limits); }; if let Some(parent_id) = parent { self.decrement_node_and_parent_limits(&parent_id, kind) } } fn cap_node_and_descendants(&mut self, node_id: &NodeId, limits: RemainingLimits) { let node = self.tree.get_mut(node_id).unwrap(); node.data_mut().limits.cap_to(limits); for child_id in node.children().clone() { self.cap_node_and_descendants(&child_id, limits); } } } ================================================ FILE: rslib/src/decks/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod addupdate; mod counts; mod current; pub mod filtered; pub(crate) mod limits; mod name; mod remove; mod reparent; mod schema11; mod service; mod stats; pub mod tree; pub(crate) mod undo; use std::sync::Arc; pub use anki_proto::decks::deck::filtered::search_term::Order as FilteredSearchOrder; pub use anki_proto::decks::deck::filtered::SearchTerm as FilteredSearchTerm; pub use anki_proto::decks::deck::kind_container::Kind as DeckKind; pub use anki_proto::decks::deck::normal::DayLimit as NormalDeckDayLimit; pub use anki_proto::decks::deck::Common as DeckCommon; pub use anki_proto::decks::deck::Filtered as FilteredDeck; pub use anki_proto::decks::deck::KindContainer as DeckKindContainer; pub use anki_proto::decks::deck::Normal as NormalDeck; pub use anki_proto::decks::Deck as DeckProto; pub(crate) use counts::DueCounts; pub(crate) use name::immediate_parent_name; pub use name::NativeDeckName; pub use schema11::DeckSchema11; use crate::deckconfig::DeckConfig; use crate::define_newtype; use crate::error::FilteredDeckError; use crate::markdown::render_markdown; use crate::prelude::*; use crate::text::sanitize_html_no_images; define_newtype!(DeckId, i64); impl DeckId { pub(crate) fn or(self, other: DeckId) -> Self { if self.0 == 0 { other } else { self } } } #[derive(Debug, Clone, PartialEq)] pub struct Deck { pub id: DeckId, pub name: NativeDeckName, pub mtime_secs: TimestampSecs, pub usn: Usn, pub common: DeckCommon, pub kind: DeckKind, } impl Deck { pub fn new_normal() -> Deck { Deck { id: DeckId(0), name: NativeDeckName::from_native_str(""), mtime_secs: TimestampSecs(0), usn: Usn(0), common: DeckCommon { study_collapsed: true, browser_collapsed: true, ..Default::default() }, kind: DeckKind::Normal(NormalDeck { config_id: 1, // enable in the future // markdown_description = true, ..Default::default() }), } } /// Returns deck config ID if deck is a normal deck. pub fn config_id(&self) -> Option { if let DeckKind::Normal(ref norm) = self.kind { Some(DeckConfigId(norm.config_id)) } else { None } } /// Get the effective desired retention value for a deck. /// Returns deck-specific desired retention if available, otherwise falls /// back to config default. pub fn effective_desired_retention(&self, config: &DeckConfig) -> f32 { self.normal() .ok() .and_then(|d| d.desired_retention) .unwrap_or(config.inner.desired_retention) } // used by tests at the moment #[allow(dead_code)] pub(crate) fn normal(&self) -> Result<&NormalDeck> { match &self.kind { DeckKind::Normal(normal) => Ok(normal), _ => invalid_input!("deck not normal"), } } #[allow(dead_code)] pub(crate) fn normal_mut(&mut self) -> Result<&mut NormalDeck> { match &mut self.kind { DeckKind::Normal(normal) => Ok(normal), _ => invalid_input!("deck not normal"), } } pub(crate) fn filtered(&self) -> Result<&FilteredDeck> { if let DeckKind::Filtered(filtered) = &self.kind { Ok(filtered) } else { Err(FilteredDeckError::FilteredDeckRequired.into()) } } #[allow(dead_code)] pub(crate) fn filtered_mut(&mut self) -> Result<&mut FilteredDeck> { if let DeckKind::Filtered(filtered) = &mut self.kind { Ok(filtered) } else { Err(FilteredDeckError::FilteredDeckRequired.into()) } } pub(crate) fn set_modified(&mut self, usn: Usn) { self.mtime_secs = TimestampSecs::now(); self.usn = usn; } pub fn rendered_description(&self) -> String { if let DeckKind::Normal(normal) = &self.kind { if normal.markdown_description { let description = render_markdown(&normal.description); // before allowing images, we'll need to handle relative image // links on the various platforms sanitize_html_no_images(&description) } else { String::new() } } else { String::new() } } } impl Collection { pub fn get_or_create_normal_deck(&mut self, human_name: &str) -> Result { let name = NativeDeckName::from_human_name(human_name); if let Some(did) = self.storage.get_deck_id(name.as_native_str())? { self.storage.get_deck(did).map(|opt| opt.unwrap()) } else { let mut deck = Deck::new_normal(); deck.name = name; self.add_or_update_deck(&mut deck)?; Ok(deck) } } } impl Collection { pub fn get_deck(&mut self, did: DeckId) -> Result>> { if let Some(deck) = self.state.deck_cache.get(&did) { return Ok(Some(deck.clone())); } if let Some(deck) = self.storage.get_deck(did)? { let deck = Arc::new(deck); self.state.deck_cache.insert(did, deck.clone()); Ok(Some(deck)) } else { Ok(None) } } pub(crate) fn default_deck_is_empty(&self) -> Result { self.storage.deck_is_empty(DeckId(1)) } /// Get a deck based on its human name. If you have a machine name, /// use the method in storage instead. pub fn get_deck_id(&self, human_name: &str) -> Result> { self.storage .get_deck_id(NativeDeckName::from_human_name(human_name).as_native_str()) } } #[cfg(test)] mod test { use crate::prelude::*; use crate::search::SortMode; fn sorted_names(col: &Collection) -> Vec { col.storage .get_all_deck_names() .unwrap() .into_iter() .map(|d| d.1) .collect() } #[test] fn adding_updating() -> Result<()> { let mut col = Collection::new(); let deck1 = col.get_or_create_normal_deck("foo")?; let deck2 = col.get_or_create_normal_deck("FOO")?; assert_eq!(deck1.id, deck2.id); assert_eq!(sorted_names(&col), vec!["Default", "foo"]); // missing parents should be automatically created, and case should match // existing parents let _deck3 = col.get_or_create_normal_deck("FOO::BAR::BAZ")?; assert_eq!( sorted_names(&col), vec!["Default", "foo", "foo::BAR", "foo::BAR::BAZ"] ); Ok(()) } #[test] fn renaming() -> Result<()> { let mut col = Collection::new(); let _ = col.get_or_create_normal_deck("foo::bar::baz")?; let mut top_deck = col.get_or_create_normal_deck("foo")?; top_deck.name = NativeDeckName::from_native_str("other"); col.add_or_update_deck(&mut top_deck)?; assert_eq!( sorted_names(&col), vec!["Default", "other", "other::bar", "other::bar::baz"] ); // should do the right thing in the middle of the tree as well let mut middle = col.get_or_create_normal_deck("other::bar")?; middle.name = NativeDeckName::from_native_str("quux\x1ffoo"); col.add_or_update_deck(&mut middle)?; assert_eq!( sorted_names(&col), vec!["Default", "other", "quux", "quux::foo", "quux::foo::baz"] ); // add another child let _ = col.get_or_create_normal_deck("quux::foo::baz2"); // quux::foo -> quux::foo::baz::four // means quux::foo::baz2 should be quux::foo::baz::four::baz2 // and a new quux::foo should have been created middle.name = NativeDeckName::from_native_str("quux\x1ffoo\x1fbaz\x1ffour"); col.add_or_update_deck(&mut middle)?; assert_eq!( sorted_names(&col), vec![ "Default", "other", "quux", "quux::foo", "quux::foo::baz", "quux::foo::baz::four", "quux::foo::baz::four::baz", "quux::foo::baz::four::baz2" ] ); // should handle name conflicts middle.name = NativeDeckName::from_native_str("other"); col.add_or_update_deck(&mut middle)?; assert_eq!(middle.name.as_native_str(), "other+"); // public function takes human name col.rename_deck(middle.id, "one::two")?; assert_eq!( sorted_names(&col), vec![ "Default", "one", "one::two", "one::two::baz", "one::two::baz2", "other", "quux", "quux::foo", "quux::foo::baz", ] ); Ok(()) } #[test] fn default() -> Result<()> { // deleting the default deck will remove cards, but bring the deck back // as a top level deck let mut col = Collection::new(); let mut default = col.get_or_create_normal_deck("default")?; default.name = NativeDeckName::from_native_str("one\x1ftwo"); col.add_or_update_deck(&mut default)?; // create a non-default deck confusingly named "default" let _fake_default = col.get_or_create_normal_deck("default")?; // add a card to the real default let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); col.add_note(&mut note, default.id)?; assert_ne!(col.search_cards("", SortMode::NoOrder)?, vec![]); // add a subdeck let _ = col.get_or_create_normal_deck("one::two::three")?; // delete top level let top = col.get_or_create_normal_deck("one")?; col.remove_decks_and_child_decks(&[top.id])?; // should have come back as "Default+" due to conflict assert_eq!(sorted_names(&col), vec!["default", "Default+"]); // and the cards it contained should have been removed assert_eq!(col.search_cards("", SortMode::NoOrder)?, vec![]); Ok(()) } } ================================================ FILE: rslib/src/decks/name.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use itertools::Itertools; use crate::prelude::*; use crate::text::normalize_to_nfc; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct NativeDeckName(String); impl NativeDeckName { /// Create from a '::'-separated string pub fn from_human_name(name: impl AsRef) -> Self { NativeDeckName( name.as_ref() .split("::") .map(normalized_deck_name_component) .join("\x1f"), ) } /// Return a '::'-separated string. pub fn human_name(&self) -> String { self.0.replace('\x1f', "::") } pub(crate) fn add_suffix(&mut self, suffix: &str) { self.0 += suffix } /// Create from an '\x1f'-separated string pub(crate) fn from_native_str>(name: N) -> Self { NativeDeckName(name.into()) } /// Return a reference to the inner '\x1f'-separated string. pub(crate) fn as_native_str(&self) -> &str { &self.0 } pub(crate) fn components(&self) -> std::str::Split<'_, char> { self.0.split('\x1f') } /// Normalize the name's components if necessary. True if mutation took /// place. pub(crate) fn maybe_normalize(&mut self) -> bool { let needs_normalization = self .components() .any(|comp| matches!(normalized_deck_name_component(comp), Cow::Owned(_))); if needs_normalization { self.0 = self .components() .map(normalized_deck_name_component) .join("\x1f"); } needs_normalization } /// Determine name to rename a deck to, when `self` is dropped on `target`. /// `target` being unset represents a drop at the top or bottom of the deck /// list. The returned name should be used to replace `self`. pub(crate) fn reparented_name(&self, target: Option<&NativeDeckName>) -> Option { let dragged_base = self.0.rsplit('\x1f').next().unwrap(); let dragged_root = self.components().next().unwrap(); if let Some(target) = target { let target_root = target.components().next().unwrap(); if target.0.starts_with(&self.0) && target_root == dragged_root { // foo onto foo::bar, or foo onto itself -> no-op None } else { // foo::bar onto baz -> baz::bar Some(NativeDeckName(format!("{}\x1f{}", target.0, dragged_base))) } } else { // foo::bar onto top level -> bar Some(NativeDeckName(dragged_base.into())) } } /// Replace the old parent's name with the new parent's name in self's name, /// where the old parent's name is expected to be a prefix. fn reparent(&mut self, old_parent: &NativeDeckName, new_parent: &NativeDeckName) { self.0 = std::iter::once(new_parent.as_native_str()) .chain(self.components().skip(old_parent.components().count())) .join("\x1f") } } impl std::fmt::Display for NativeDeckName { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } impl Deck { pub fn human_name(&self) -> String { self.name.human_name() } } impl Collection { pub fn get_all_normal_deck_names( &mut self, skip_default: bool, ) -> Result> { Ok(self .storage .get_all_deck_names()? .into_iter() .filter(|node| { if skip_default { node.0 != DeckId(1) } else { true } }) .filter(|(id, _name)| match self.get_deck(*id) { Ok(Some(deck)) => !deck.is_filtered(), _ => true, }) .collect()) } pub fn rename_deck(&mut self, did: DeckId, new_human_name: &str) -> Result> { self.transact(Op::RenameDeck, |col| { let existing_deck = col.storage.get_deck(did)?.or_not_found(did)?; let mut deck = existing_deck.clone(); deck.name = NativeDeckName::from_human_name(new_human_name); col.update_deck_inner(&mut deck, existing_deck, col.usn()?) }) } pub(super) fn rename_child_decks( &mut self, old: &Deck, new_name: &NativeDeckName, usn: Usn, ) -> Result<()> { let children = self.storage.child_decks(old)?; for mut child in children { let original = child.clone(); child.name.reparent(&old.name, new_name); child.set_modified(usn); self.update_single_deck_undoable(&mut child, original)?; } Ok(()) } pub(crate) fn ensure_deck_name_unique(&self, deck: &mut Deck, usn: Usn) -> Result<()> { loop { match self.storage.get_deck_id(deck.name.as_native_str())? { Some(did) if did == deck.id => break, None => break, _ => (), } deck.name.add_suffix("+"); deck.set_modified(usn); } Ok(()) } pub fn get_all_deck_names(&self, skip_default: bool) -> Result> { if skip_default { Ok(self .storage .get_all_deck_names()? .into_iter() .filter(|(id, _name)| id.0 != 1) .collect()) } else { self.storage.get_all_deck_names() } } pub fn get_deck_and_child_names(&self, did: DeckId) -> Result> { Ok(self .storage .deck_with_children(did)? .iter() .map(|deck| (deck.id, deck.name.human_name())) .collect()) } } fn invalid_char_for_deck_component(c: char) -> bool { c.is_ascii_control() } fn normalized_deck_name_component(comp: &str) -> Cow<'_, str> { let mut out = normalize_to_nfc(comp); if out.contains(invalid_char_for_deck_component) { out = out.replace(invalid_char_for_deck_component, "").into(); } let trimmed = out.trim_matches(|c: char| c.is_whitespace() || c == ':'); if trimmed.is_empty() { "blank".to_string().into() } else if trimmed.len() != out.len() { trimmed.to_string().into() } else { out } } pub(crate) fn immediate_parent_name(machine_name: &str) -> Option<&str> { machine_name.rsplit_once('\x1f').map(|t| t.0) } #[cfg(test)] mod test { use super::*; #[test] fn parent() { assert_eq!(immediate_parent_name("foo"), None); assert_eq!(immediate_parent_name("foo\x1fbar"), Some("foo")); assert_eq!( immediate_parent_name("foo\x1fbar\x1fbaz"), Some("foo\x1fbar") ); } #[test] fn from_human() { fn native_name(name: &str) -> String { NativeDeckName::from_human_name(name).0 } assert_eq!(native_name("foo"), "foo"); assert_eq!(native_name("foo::bar"), "foo\x1fbar"); assert_eq!(native_name("foo::::baz"), "foo\x1fblank\x1fbaz"); // implicitly normalize assert_eq!(native_name("fo\x1fo::ba\nr"), "foo\x1fbar"); assert_eq!(native_name("fo\u{a}o\x1fbar"), "foobar"); assert_eq!(native_name("foo:::bar"), "foo\x1fbar"); assert_eq!(native_name("foo:::bar:baz: "), "foo\x1fbar:baz"); } #[test] fn normalize() { fn normalize_res(name: &str) -> (bool, String) { let mut name = NativeDeckName::from_native_str(name); (name.maybe_normalize(), name.0) } assert_eq!(normalize_res("foo\x1fbar"), (false, "foo\x1fbar".into())); assert_eq!( normalize_res("fo\x1fo::ba\nr"), (true, "fo\x1fo::bar".into()) ); assert_eq!(normalize_res("fo\u{a}obar"), (true, "foobar".into())); } #[test] fn drag_drop() { // use custom separator to make the tests easier to read fn n(s: &str) -> NativeDeckName { NativeDeckName(s.replace(':', "\x1f")) } #[allow(clippy::unnecessary_wraps)] fn n_opt(s: &str) -> Option { Some(n(s)) } fn reparented_name(drag: &str, drop: Option<&str>) -> Option { n(drag).reparented_name(drop.map(n).as_ref()) } assert_eq!(reparented_name("drag", Some("drop")), n_opt("drop:drag")); assert_eq!(reparented_name("drag", None), n_opt("drag")); assert_eq!(reparented_name("drag:child", None), n_opt("child")); assert_eq!( reparented_name("drag:child", Some("drop:deck")), n_opt("drop:deck:child") ); assert_eq!( reparented_name("drag:child", Some("drag")), n_opt("drag:child") ); assert_eq!( reparented_name("drag:child:grandchild", Some("drag")), n_opt("drag:grandchild") ); // drops to child not supported assert_eq!(reparented_name("drag", Some("drag:child:grandchild")), None); // name doesn't change when deck dropped on itself assert_eq!(reparented_name("foo:bar", Some("foo:bar")), None); // names that are prefixes of the target are handled correctly assert_eq!(reparented_name("a", Some("ab")), n_opt("ab:a")); } } ================================================ FILE: rslib/src/decks/remove.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::prelude::*; impl Collection { pub fn remove_decks_and_child_decks(&mut self, dids: &[DeckId]) -> Result> { self.transact(Op::RemoveDeck, |col| { let mut card_count = 0; let usn = col.usn()?; for did in dids { if let Some(deck) = col.storage.get_deck(*did)? { let child_decks = col.storage.child_decks(&deck)?; // top level card_count += col.remove_single_deck(&deck, usn)?; // remove children for deck in child_decks { card_count += col.remove_single_deck(&deck, usn)?; } } } Ok(card_count) }) } pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result { let card_count = match deck.kind { DeckKind::Normal(_) => self.delete_all_cards_in_normal_deck(deck.id)?, DeckKind::Filtered(_) => { self.return_all_cards_in_filtered_deck(deck)?; 0 } }; self.clear_aux_config_for_deck(deck.id)?; if deck.id.0 == 1 { // if the default deck is included, just ensure it's reset to the default // name, as we've already removed its cards let mut modified_default = deck.clone(); modified_default.name = NativeDeckName::from_native_str(self.tr.deck_config_default_name()); self.prepare_deck_for_update(&mut modified_default, usn)?; modified_default.set_modified(usn); self.update_single_deck_undoable(&mut modified_default, deck.clone())?; } else { self.remove_deck_and_add_grave_undoable(deck.clone(), usn)?; } Ok(card_count) } } impl Collection { fn delete_all_cards_in_normal_deck(&mut self, did: DeckId) -> Result { let cids = self.storage.all_cards_in_single_deck(did)?; self.remove_cards_and_orphaned_notes(&cids)?; Ok(cids.len()) } } ================================================ FILE: rslib/src/decks/reparent.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::error::FilteredDeckError; use crate::prelude::*; impl Collection { pub fn reparent_decks( &mut self, deck_ids: &[DeckId], new_parent: Option, ) -> Result> { self.transact(Op::ReparentDeck, |col| { col.reparent_decks_inner(deck_ids, new_parent) }) } pub fn reparent_decks_inner( &mut self, deck_ids: &[DeckId], new_parent: Option, ) -> Result { let usn = self.usn()?; let target_deck; let mut target_name = None; if let Some(target) = new_parent { if let Some(target) = self.storage.get_deck(target)? { if target.is_filtered() { return Err(FilteredDeckError::MustBeLeafNode.into()); } target_deck = target; target_name = Some(&target_deck.name); } } let mut count = 0; for deck in deck_ids { if let Some(mut deck) = self.storage.get_deck(*deck)? { if let Some(new_name) = deck.name.reparented_name(target_name) { if new_name == deck.name { continue; } count += 1; let orig = deck.clone(); // this is basically update_deck_inner(), except: // - we skip the normalization in prepare_for_update() // - we skip the match_or_create_parents() step // - we skip the final create_missing_parents(), as we don't allow parent->child // renames deck.set_modified(usn); deck.name = new_name; self.ensure_deck_name_unique(&mut deck, usn)?; self.rename_child_decks(&orig, &deck.name, usn)?; self.update_single_deck_undoable(&mut deck, orig)?; } } } Ok(count) } } ================================================ FILE: rslib/src/decks/schema11.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use anki_proto::decks::deck::normal::DayLimit; use phf::phf_set; use phf::Set; use serde::Deserialize; use serde::Serialize; use serde_json::Value; use serde_tuple::Serialize_tuple; use super::DeckCommon; use super::FilteredDeck; use super::FilteredSearchTerm; use super::NormalDeck; use crate::notetype::schema11::parse_other_fields; use crate::prelude::*; use crate::serde::default_on_invalid; use crate::serde::deserialize_bool_from_anything; use crate::serde::deserialize_number_from_string; #[derive(Serialize, PartialEq, Debug, Clone)] #[serde(untagged)] pub enum DeckSchema11 { Normal(NormalDeckSchema11), Filtered(FilteredDeckSchema11), } // serde doesn't support integer/bool enum tags, so we manually pick the correct // variant mod dynfix { use serde::de; use serde::de::Deserialize; use serde::de::Deserializer; use serde_json::Map; use serde_json::Value; use super::DeckSchema11; use super::FilteredDeckSchema11; use super::NormalDeckSchema11; impl<'de> Deserialize<'de> for DeckSchema11 { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let mut map = Map::deserialize(deserializer)?; let (is_dyn, needs_fix) = map .get("dyn") .ok_or_else(|| de::Error::missing_field("dyn")) .and_then(|v| { Ok(match v { Value::Bool(b) => (*b, true), Value::Number(n) => (n.as_i64().unwrap_or(0) == 1, false), _ => { // invalid type return Err(de::Error::custom("dyn was wrong type")); } }) })?; if needs_fix { map.insert("dyn".into(), Value::Number(u8::from(is_dyn).into())); } // remove an obsolete key map.remove("return"); let rest = Value::Object(map); if is_dyn { FilteredDeckSchema11::deserialize(rest) .map(DeckSchema11::Filtered) .map_err(de::Error::custom) } else { NormalDeckSchema11::deserialize(rest) .map(DeckSchema11::Normal) .map_err(de::Error::custom) } } } } fn is_false(b: &bool) -> bool { !b } #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] pub struct DeckCommonSchema11 { #[serde(deserialize_with = "deserialize_number_from_string")] pub(crate) id: DeckId, #[serde( rename = "mod", deserialize_with = "deserialize_number_from_string", default )] pub(crate) mtime: TimestampSecs, pub(crate) name: String, pub(crate) usn: Usn, #[serde(flatten)] pub(crate) today: DeckTodaySchema11, #[serde(rename = "collapsed")] study_collapsed: bool, #[serde(default, rename = "browserCollapsed")] browser_collapsed: bool, #[serde(default)] desc: String, #[serde(default, rename = "md", skip_serializing_if = "is_false")] markdown_description: bool, #[serde(rename = "dyn")] dynamic: u8, #[serde(flatten)] other: HashMap, } #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct NormalDeckSchema11 { #[serde(flatten)] pub(crate) common: DeckCommonSchema11, #[serde(deserialize_with = "deserialize_number_from_string")] pub(crate) conf: i64, #[serde(default, deserialize_with = "default_on_invalid")] extend_new: i32, #[serde(default, deserialize_with = "default_on_invalid")] extend_rev: i32, #[serde(default, deserialize_with = "default_on_invalid")] review_limit: Option, #[serde(default, deserialize_with = "default_on_invalid")] new_limit: Option, #[serde(default, deserialize_with = "default_on_invalid")] review_limit_today: Option, #[serde(default, deserialize_with = "default_on_invalid")] new_limit_today: Option, #[serde(default, deserialize_with = "default_on_invalid")] desired_retention: Option, } #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct FilteredDeckSchema11 { #[serde(flatten)] common: DeckCommonSchema11, #[serde(deserialize_with = "deserialize_bool_from_anything")] resched: bool, terms: Vec, // unused, but older clients require its existence #[serde(default)] separate: bool, // old scheduler #[serde(default, deserialize_with = "default_on_invalid")] delays: Option>, // old scheduler #[serde(default)] preview_delay: u32, // new scheduler #[serde(default)] preview_again_secs: u32, #[serde(default)] preview_hard_secs: u32, #[serde(default)] preview_good_secs: u32, } #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Default, Clone)] pub struct DeckTodaySchema11 { #[serde(rename = "lrnToday")] pub(crate) lrn: TodayAmountSchema11, #[serde(rename = "revToday")] pub(crate) rev: TodayAmountSchema11, #[serde(rename = "newToday")] pub(crate) new: TodayAmountSchema11, #[serde(rename = "timeToday")] pub(crate) time: TodayAmountSchema11, } #[derive(Serialize_tuple, Deserialize, Debug, PartialEq, Eq, Default, Clone)] #[serde(from = "Vec")] pub struct TodayAmountSchema11 { day: i32, amount: i32, } impl From> for TodayAmountSchema11 { fn from(mut v: Vec) -> Self { let amt = v.pop().and_then(|v| v.as_i64()).unwrap_or(0); let day = v.pop().and_then(|v| v.as_i64()).unwrap_or(0); TodayAmountSchema11 { amount: amt as i32, day: day as i32, } } } #[derive(Serialize_tuple, Deserialize, Debug, PartialEq, Eq, Clone)] pub struct FilteredSearchTermSchema11 { search: String, #[serde(deserialize_with = "deserialize_number_from_string")] limit: i32, order: i32, } impl DeckSchema11 { pub fn common(&self) -> &DeckCommonSchema11 { match self { DeckSchema11::Normal(d) => &d.common, DeckSchema11::Filtered(d) => &d.common, } } pub fn id(&self) -> DeckId { self.common().id } pub fn name(&self) -> &str { &self.common().name } } impl Default for DeckSchema11 { fn default() -> Self { DeckSchema11::Normal(NormalDeckSchema11::default()) } } impl Default for NormalDeckSchema11 { fn default() -> Self { NormalDeckSchema11 { common: DeckCommonSchema11 { id: DeckId(0), mtime: TimestampSecs(0), name: "".to_string(), usn: Usn(0), study_collapsed: false, browser_collapsed: false, desc: "".to_string(), today: Default::default(), other: Default::default(), dynamic: 0, markdown_description: false, }, conf: 1, extend_new: 0, extend_rev: 0, review_limit: None, new_limit: None, review_limit_today: None, new_limit_today: None, desired_retention: None, } } } // schema 11 -> latest impl From for Deck { fn from(deck: DeckSchema11) -> Self { match deck { DeckSchema11::Normal(d) => Deck { id: d.common.id, name: NativeDeckName::from_human_name(&d.common.name), mtime_secs: d.common.mtime, usn: d.common.usn, common: (&d.common).into(), kind: DeckKind::Normal(d.into()), }, DeckSchema11::Filtered(d) => Deck { id: d.common.id, name: NativeDeckName::from_human_name(&d.common.name), mtime_secs: d.common.mtime, usn: d.common.usn, common: (&d.common).into(), kind: DeckKind::Filtered(d.into()), }, } } } impl From<&DeckCommonSchema11> for DeckCommon { fn from(common: &DeckCommonSchema11) -> Self { let other = if common.other.is_empty() { vec![] } else { serde_json::to_vec(&common.other).unwrap_or_default() }; // since we're combining the day values into a single value, // any items from an earlier day need to be reset let mut today = common.today.clone(); // study will always update 'time', but custom study may only update // 'rev' or 'new' let max_day = today.time.day.max(today.new.day).max(today.rev.day); if today.lrn.day != max_day { today.lrn.amount = 0; } if today.rev.day != max_day { today.rev.amount = 0; } if today.new.day != max_day { today.new.amount = 0; } DeckCommon { study_collapsed: common.study_collapsed, browser_collapsed: common.browser_collapsed, last_day_studied: max_day as u32, new_studied: today.new.amount, review_studied: today.rev.amount, learning_studied: today.lrn.amount, milliseconds_studied: common.today.time.amount, other, } } } impl From for NormalDeck { fn from(deck: NormalDeckSchema11) -> Self { NormalDeck { config_id: deck.conf, extend_new: deck.extend_new.max(0) as u32, extend_review: deck.extend_rev.max(0) as u32, markdown_description: deck.common.markdown_description, description: deck.common.desc, review_limit: deck.review_limit, new_limit: deck.new_limit, review_limit_today: deck.review_limit_today, new_limit_today: deck.new_limit_today, desired_retention: deck.desired_retention.map(|v| v as f32 / 100.0), } } } impl From for FilteredDeck { fn from(deck: FilteredDeckSchema11) -> Self { FilteredDeck { reschedule: deck.resched, search_terms: deck.terms.into_iter().map(Into::into).collect(), delays: deck.delays.unwrap_or_default(), preview_delay: deck.preview_delay, preview_again_secs: deck.preview_again_secs, preview_hard_secs: deck.preview_hard_secs, preview_good_secs: deck.preview_good_secs, } } } impl From for FilteredSearchTerm { fn from(term: FilteredSearchTermSchema11) -> Self { FilteredSearchTerm { search: term.search, limit: term.limit.max(0) as u32, order: term.order, } } } // latest -> schema 11 impl From for DeckSchema11 { fn from(deck: Deck) -> Self { match deck.kind { DeckKind::Normal(ref norm) => DeckSchema11::Normal(NormalDeckSchema11 { conf: norm.config_id, extend_new: norm.extend_new as i32, extend_rev: norm.extend_review as i32, review_limit: norm.review_limit, new_limit: norm.new_limit, review_limit_today: norm.review_limit_today, new_limit_today: norm.new_limit_today, desired_retention: norm.desired_retention.map(|v| (v * 100.0) as u32), common: deck.into(), }), DeckKind::Filtered(ref filt) => DeckSchema11::Filtered(FilteredDeckSchema11 { resched: filt.reschedule, terms: filt.search_terms.iter().map(|v| v.clone().into()).collect(), separate: true, delays: if filt.delays.is_empty() { None } else { Some(filt.delays.clone()) }, preview_delay: filt.preview_delay, preview_again_secs: filt.preview_again_secs, preview_hard_secs: filt.preview_hard_secs, preview_good_secs: filt.preview_good_secs, common: deck.into(), }), } } } impl From for DeckCommonSchema11 { fn from(deck: Deck) -> Self { DeckCommonSchema11 { id: deck.id, mtime: deck.mtime_secs, name: deck.human_name(), usn: deck.usn, today: (&deck).into(), study_collapsed: deck.common.study_collapsed, browser_collapsed: deck.common.browser_collapsed, dynamic: matches!(deck.kind, DeckKind::Filtered(_)).into(), markdown_description: match &deck.kind { DeckKind::Normal(n) => n.markdown_description, DeckKind::Filtered(_) => false, }, desc: match deck.kind { DeckKind::Normal(n) => n.description, DeckKind::Filtered(_) => String::new(), }, other: parse_other_fields(&deck.common.other, &RESERVED_DECK_KEYS), } } } static RESERVED_DECK_KEYS: Set<&'static str> = phf_set! { "usn", "revToday", "newLimit", "dyn", "reviewLimit", "newToday", "timeToday", "reviewLimitToday", "extendNew", "mod", "newLimitToday", "desc", "name", "lrnToday", "conf", "browserCollapsed", "extendRev", "id", "collapsed", "desiredRetention", }; impl From<&Deck> for DeckTodaySchema11 { fn from(deck: &Deck) -> Self { let day = deck.common.last_day_studied as i32; let c = &deck.common; DeckTodaySchema11 { lrn: TodayAmountSchema11 { day, amount: c.learning_studied, }, rev: TodayAmountSchema11 { day, amount: c.review_studied, }, new: TodayAmountSchema11 { day, amount: c.new_studied, }, time: TodayAmountSchema11 { day, amount: c.milliseconds_studied, }, } } } impl From for FilteredSearchTermSchema11 { fn from(term: FilteredSearchTerm) -> Self { FilteredSearchTermSchema11 { search: term.search, limit: term.limit as i32, order: term.order, } } } #[cfg(test)] mod tests { use itertools::Itertools; use super::*; #[test] fn all_reserved_fields_are_removed() -> Result<()> { let key_source = DeckSchema11::default(); let mut deck = Deck::new_normal(); deck.common.other = serde_json::to_vec(&key_source)?; let DeckSchema11::Normal(s11) = DeckSchema11::from(deck) else { panic!() }; let empty: &[&String] = &[]; assert_eq!(&s11.common.other.keys().collect_vec(), empty); Ok(()) } } ================================================ FILE: rslib/src/decks/service.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::decks::deck::kind_container::Kind as DeckKind; use anki_proto::generic; use crate::collection::Collection; use crate::decks::filtered::search_order_labels; use crate::decks::Deck; use crate::decks::DeckId; use crate::decks::DeckSchema11; use crate::decks::NativeDeckName; use crate::error; use crate::error::AnkiError; use crate::error::OrInvalid; use crate::error::OrNotFound; use crate::prelude::TimestampSecs; use crate::prelude::Usn; use crate::scheduler::filtered::FilteredDeckForUpdate; impl crate::services::DecksService for Collection { fn new_deck(&mut self) -> error::Result { Ok(Deck::new_normal().into()) } fn add_deck( &mut self, deck: anki_proto::decks::Deck, ) -> error::Result { let mut deck: Deck = deck.try_into()?; Ok(self.add_deck(&mut deck)?.map(|_| deck.id.0).into()) } fn add_deck_legacy( &mut self, input: generic::Json, ) -> error::Result { let schema11: DeckSchema11 = serde_json::from_slice(&input.json)?; let mut deck: Deck = schema11.into(); let output = self.add_deck(&mut deck)?; Ok(output.map(|_| deck.id.0).into()) } fn add_or_update_deck_legacy( &mut self, input: anki_proto::decks::AddOrUpdateDeckLegacyRequest, ) -> error::Result { let schema11: DeckSchema11 = serde_json::from_slice(&input.deck)?; let mut deck: Deck = schema11.into(); if input.preserve_usn_and_mtime { self.transact_no_undo(|col| { let usn = col.usn()?; col.add_or_update_single_deck_with_existing_id(&mut deck, usn) })?; } else { self.add_or_update_deck(&mut deck)?; } Ok(anki_proto::decks::DeckId { did: deck.id.0 }) } fn deck_tree( &mut self, input: anki_proto::decks::DeckTreeRequest, ) -> error::Result { let now = if input.now == 0 { None } else { Some(TimestampSecs(input.now)) }; self.deck_tree(now) } fn deck_tree_legacy(&mut self) -> error::Result { let tree = self.legacy_deck_tree()?; serde_json::to_vec(&tree) .map_err(Into::into) .map(Into::into) } fn get_all_decks_legacy(&mut self) -> error::Result { let decks = self.storage.get_all_decks_as_schema11()?; serde_json::to_vec(&decks) .map_err(Into::into) .map(Into::into) } fn get_deck_id_by_name( &mut self, input: generic::String, ) -> error::Result { self.get_deck_id(&input.val).and_then(|d| { d.or_not_found(input.val) .map(|d| anki_proto::decks::DeckId { did: d.0 }) }) } fn get_deck( &mut self, input: anki_proto::decks::DeckId, ) -> error::Result { let did = input.into(); Ok(self.storage.get_deck(did)?.or_not_found(did)?.into()) } fn update_deck( &mut self, input: anki_proto::decks::Deck, ) -> error::Result { let mut deck = Deck::try_from(input)?; self.update_deck(&mut deck).map(Into::into) } fn update_deck_legacy( &mut self, input: generic::Json, ) -> error::Result { let deck: DeckSchema11 = serde_json::from_slice(&input.json)?; let mut deck = deck.into(); self.update_deck(&mut deck).map(Into::into) } fn get_deck_legacy( &mut self, input: anki_proto::decks::DeckId, ) -> error::Result { let did = input.into(); let deck: DeckSchema11 = self.storage.get_deck(did)?.or_not_found(did)?.into(); serde_json::to_vec(&deck) .map_err(Into::into) .map(Into::into) } fn get_deck_names( &mut self, input: anki_proto::decks::GetDeckNamesRequest, ) -> error::Result { let skip_default = input.skip_empty_default && self.default_deck_is_empty()?; let names = if input.include_filtered { self.get_all_deck_names(skip_default)? } else { self.get_all_normal_deck_names(skip_default)? }; Ok(deck_names_to_proto(names)) } fn get_deck_and_child_names( &mut self, input: anki_proto::decks::DeckId, ) -> error::Result { Collection::get_deck_and_child_names(self, input.did.into()).map(deck_names_to_proto) } fn new_deck_legacy(&mut self, input: generic::Bool) -> error::Result { let deck = if input.val { Deck::new_filtered() } else { Deck::new_normal() }; let schema11: DeckSchema11 = deck.into(); serde_json::to_vec(&schema11) .map_err(Into::into) .map(Into::into) } fn remove_decks( &mut self, input: anki_proto::decks::DeckIds, ) -> error::Result { self.remove_decks_and_child_decks(&input.dids.into_iter().map(DeckId).collect::>()) .map(Into::into) } fn reparent_decks( &mut self, input: anki_proto::decks::ReparentDecksRequest, ) -> error::Result { let deck_ids: Vec<_> = input.deck_ids.into_iter().map(Into::into).collect(); let new_parent = if input.new_parent == 0 { None } else { Some(input.new_parent.into()) }; self.reparent_decks(&deck_ids, new_parent).map(Into::into) } fn rename_deck( &mut self, input: anki_proto::decks::RenameDeckRequest, ) -> error::Result { self.rename_deck(input.deck_id.into(), &input.new_name) .map(Into::into) } fn get_or_create_filtered_deck( &mut self, input: anki_proto::decks::DeckId, ) -> error::Result { self.get_or_create_filtered_deck(input.into()) .map(Into::into) } fn add_or_update_filtered_deck( &mut self, input: anki_proto::decks::FilteredDeckForUpdate, ) -> error::Result { self.add_or_update_filtered_deck(input.into()) .map(|out| out.map(i64::from)) .map(Into::into) } fn filtered_deck_order_labels(&mut self) -> error::Result { Ok(search_order_labels(&self.tr).into()) } fn set_deck_collapsed( &mut self, input: anki_proto::decks::SetDeckCollapsedRequest, ) -> error::Result { self.set_deck_collapsed(input.deck_id.into(), input.collapsed, input.scope()) .map(Into::into) } fn set_current_deck( &mut self, input: anki_proto::decks::DeckId, ) -> error::Result { self.set_current_deck(input.did.into()).map(Into::into) } fn get_current_deck(&mut self) -> error::Result { self.get_current_deck().map(|deck| (*deck).clone().into()) } } impl From for DeckId { fn from(did: anki_proto::decks::DeckId) -> Self { DeckId(did.did) } } impl From for anki_proto::decks::DeckId { fn from(did: DeckId) -> Self { anki_proto::decks::DeckId { did: did.0 } } } impl From for anki_proto::decks::FilteredDeckForUpdate { fn from(deck: FilteredDeckForUpdate) -> Self { anki_proto::decks::FilteredDeckForUpdate { id: deck.id.into(), name: deck.human_name, config: Some(deck.config), allow_empty: deck.allow_empty, } } } impl From for FilteredDeckForUpdate { fn from(deck: anki_proto::decks::FilteredDeckForUpdate) -> Self { FilteredDeckForUpdate { id: deck.id.into(), human_name: deck.name, config: deck.config.unwrap_or_default(), allow_empty: deck.allow_empty, } } } impl From for anki_proto::decks::Deck { fn from(d: Deck) -> Self { anki_proto::decks::Deck { id: d.id.0, name: d.name.human_name(), mtime_secs: d.mtime_secs.0, usn: d.usn.0, common: Some(d.common), kind: Some(kind_from_inline(d.kind)), } } } impl TryFrom for Deck { type Error = AnkiError; fn try_from(d: anki_proto::decks::Deck) -> error::Result { Ok(Deck { id: DeckId(d.id), name: NativeDeckName::from_human_name(&d.name), mtime_secs: TimestampSecs(d.mtime_secs), usn: Usn(d.usn), common: d.common.unwrap_or_default(), kind: kind_to_inline(d.kind.or_invalid("missing kind")?), }) } } fn kind_to_inline(kind: anki_proto::decks::deck::Kind) -> DeckKind { match kind { anki_proto::decks::deck::Kind::Normal(normal) => DeckKind::Normal(normal), anki_proto::decks::deck::Kind::Filtered(filtered) => DeckKind::Filtered(filtered), } } fn kind_from_inline(k: DeckKind) -> anki_proto::decks::deck::Kind { match k { DeckKind::Normal(n) => anki_proto::decks::deck::Kind::Normal(n), DeckKind::Filtered(f) => anki_proto::decks::deck::Kind::Filtered(f), } } fn deck_name_to_proto((id, name): (DeckId, String)) -> anki_proto::decks::DeckNameId { anki_proto::decks::DeckNameId { id: id.0, name } } fn deck_names_to_proto(names: Vec<(DeckId, String)>) -> anki_proto::decks::DeckNames { anki_proto::decks::DeckNames { entries: names.into_iter().map(deck_name_to_proto).collect(), } } ================================================ FILE: rslib/src/decks/stats.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::DeckCommon; use crate::prelude::*; impl Deck { pub(super) fn reset_stats_if_day_changed(&mut self, today: u32) { let c = &mut self.common; if c.last_day_studied != today { c.new_studied = 0; c.learning_studied = 0; c.review_studied = 0; c.milliseconds_studied = 0; c.last_day_studied = today; } } } impl Collection { /// Apply input delta to deck, and its parents. /// Caller should ensure transaction. pub(crate) fn update_deck_stats( &mut self, today: u32, usn: Usn, input: anki_proto::scheduler::UpdateStatsRequest, ) -> Result<()> { let did = input.deck_id.into(); let mutator = |c: &mut DeckCommon| { c.new_studied += input.new_delta; c.review_studied += input.review_delta; c.milliseconds_studied += input.millisecond_delta; }; if let Some(mut deck) = self.storage.get_deck(did)? { self.update_deck_stats_single(today, usn, &mut deck, mutator)?; for mut deck in self.storage.parent_decks(&deck)? { self.update_deck_stats_single(today, usn, &mut deck, mutator)?; } } Ok(()) } /// Modify the deck's limits by adjusting the 'done today' count. /// Positive values increase the limit, negative value decrease it. /// If global parent limits are enabled, the deck's parents are adjusted as /// well. /// Caller should ensure a transaction. pub(crate) fn extend_limits( &mut self, today: u32, usn: Usn, did: DeckId, new_delta: i32, review_delta: i32, ) -> Result<()> { let mutator = |c: &mut DeckCommon| { c.new_studied -= new_delta; c.review_studied -= review_delta; }; if let Some(mut deck) = self.storage.get_deck(did)? { self.update_deck_stats_single(today, usn, &mut deck, mutator)?; if self.get_config_bool(BoolKey::ApplyAllParentLimits) { for mut parent in self.storage.parent_decks(&deck)? { self.update_deck_stats_single(today, usn, &mut parent, mutator)?; } } } Ok(()) } } impl Collection { fn update_deck_stats_single( &mut self, today: u32, usn: Usn, deck: &mut Deck, mutator: F, ) -> Result<()> where F: FnOnce(&mut DeckCommon), { let original = deck.clone(); deck.reset_stats_if_day_changed(today); mutator(&mut deck.common); deck.set_modified(usn); self.update_single_deck_undoable(deck, original) } } ================================================ FILE: rslib/src/decks/tree.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::collections::HashSet; use std::iter::Peekable; use std::ops::AddAssign; pub use anki_proto::decks::set_deck_collapsed_request::Scope as DeckCollapseScope; use anki_proto::decks::DeckTreeNode; use serde_tuple::Serialize_tuple; use unicase::UniCase; use super::limits::remaining_limits_map; use super::limits::RemainingLimits; use super::DueCounts; use crate::ops::OpOutput; use crate::prelude::*; use crate::undo::Op; fn deck_names_to_tree(names: impl Iterator) -> DeckTreeNode { let mut top = DeckTreeNode::default(); let mut it = names.peekable(); add_child_nodes(&mut it, &mut top); top } fn add_child_nodes( names: &mut Peekable>, parent: &mut DeckTreeNode, ) { while let Some((id, name)) = names.peek() { let split_name: Vec<_> = name.split("::").collect(); // protobuf refuses to decode messages with 100+ levels of nesting, and // broken collections with such nesting have been found in the wild let capped_len = split_name.len().min(99) as u32; match capped_len { l if l <= parent.level => { // next item is at a higher level return; } l if l == parent.level + 1 => { // next item is an immediate descendent of parent parent.children.push(DeckTreeNode { deck_id: id.0, name: (*split_name.last().unwrap()).into(), children: vec![], level: parent.level + 1, ..Default::default() }); names.next(); } _ => { // next item is at a lower level if let Some(last_child) = parent.children.last_mut() { add_child_nodes(names, last_child) } else { // immediate parent is missing, skip the deck until a DB check is run names.next(); } } } } } fn add_collapsed_and_filtered( node: &mut DeckTreeNode, decks: &HashMap, browser: bool, ) { if let Some(deck) = decks.get(&DeckId(node.deck_id)) { node.collapsed = if browser { deck.common.browser_collapsed } else { deck.common.study_collapsed }; node.filtered = deck.is_filtered(); } for child in &mut node.children { add_collapsed_and_filtered(child, decks, browser); } } fn add_counts(node: &mut DeckTreeNode, counts: &HashMap) { if let Some(counts) = counts.get(&DeckId(node.deck_id)) { node.new_count = counts.new; node.review_count = counts.review; node.learn_count = counts.learning; node.intraday_learning = counts.intraday_learning; node.interday_learning_uncapped = counts.interday_learning; node.new_uncapped = counts.new; node.review_uncapped = counts.review; node.total_in_deck = counts.total_cards; } for child in &mut node.children { add_counts(child, counts); } } /// A temporary container used during count summation and limit application. #[derive(Default, Clone)] struct NodeCountsV3 { new: u32, review: u32, intraday_learning: u32, interday_learning: u32, total: u32, } impl NodeCountsV3 { fn capped(&self, remaining: &RemainingLimits) -> Self { let mut capped = self.clone(); // apply review limit to interday learning capped.interday_learning = capped.interday_learning.min(remaining.review); let mut remaining_reviews = remaining.review.saturating_sub(capped.interday_learning); // any remaining review limit is applied to reviews capped.review = capped.review.min(remaining_reviews); capped.new = capped.new.min(remaining.new); if remaining.cap_new_to_review { remaining_reviews = remaining_reviews.saturating_sub(capped.review); capped.new = capped.new.min(remaining_reviews); } capped } } impl AddAssign for NodeCountsV3 { fn add_assign(&mut self, rhs: Self) { self.new += rhs.new; self.review += rhs.review; self.intraday_learning += rhs.intraday_learning; self.interday_learning += rhs.interday_learning; self.total += rhs.total; } } /// Adjust new, review and learning counts based on the daily limits. /// As part of this process, the separate interday and intraday learning /// counts are combined after the limits have been applied. fn sum_counts_and_apply_limits_v3( node: &mut DeckTreeNode, limits: &HashMap, mut parent_limits: Option, ) -> NodeCountsV3 { let mut remaining = limits .get(&DeckId(node.deck_id)) .copied() .unwrap_or_default(); if let Some(parent_remaining) = parent_limits { remaining.cap_to(parent_remaining); parent_limits.replace(remaining); } // initialize with this node's values let mut this_node_uncapped = NodeCountsV3 { new: node.new_count, review: node.review_count, intraday_learning: node.intraday_learning, interday_learning: node.interday_learning_uncapped, total: node.total_in_deck, }; let mut total_including_children = node.total_in_deck; // add capped child counts / uncapped total for child in &mut node.children { this_node_uncapped += sum_counts_and_apply_limits_v3(child, limits, parent_limits); total_including_children += child.total_including_children; } let this_node_capped = this_node_uncapped.capped(&remaining); node.new_count = this_node_capped.new; node.review_count = this_node_capped.review; node.learn_count = this_node_capped.intraday_learning + this_node_capped.interday_learning; node.total_including_children = total_including_children; this_node_capped } fn hide_default_deck(node: &mut DeckTreeNode) { for (idx, child) in node.children.iter().enumerate() { // we can hide the default if it has no children if child.deck_id == 1 && child.children.is_empty() { if child.level == 1 && node.children.len() == 1 { // can't remove if there are no other decks } else { // safe to remove _ = node.children.remove(idx); } return; } } } /// Locate provided deck in tree, and return it. pub fn get_deck_in_tree(tree: DeckTreeNode, deck_id: DeckId) -> Option { if tree.deck_id == deck_id.0 { return Some(tree); } for child in tree.children { if let Some(node) = get_deck_in_tree(child, deck_id) { return Some(node); } } None } pub(crate) fn sum_deck_tree_node( node: &DeckTreeNode, map: fn(&DeckTreeNode) -> T, ) -> T { let mut output = map(node); for child in &node.children { output += sum_deck_tree_node(child, map) } output } #[derive(Serialize_tuple)] pub(crate) struct LegacyDueCounts { name: String, deck_id: i64, review: u32, learn: u32, new: u32, children: Vec, } impl From for LegacyDueCounts { fn from(n: DeckTreeNode) -> Self { LegacyDueCounts { name: n.name, deck_id: n.deck_id, review: n.review_count, learn: n.learn_count, new: n.new_count, children: n.children.into_iter().map(From::from).collect(), } } } impl Collection { /// Get the deck tree. /// - If `timestamp` is provided, due counts for the provided timestamp will /// be populated. /// - Buried cards from previous days will be unburied if necessary. Because /// this does not happen for future stamps, future due numbers may not be /// accurate. pub fn deck_tree(&mut self, timestamp: Option) -> Result { let names = self.storage.get_all_deck_names()?; let mut tree = deck_names_to_tree(names.into_iter()); let decks_map = self.storage.get_decks_map()?; add_collapsed_and_filtered(&mut tree, &decks_map, timestamp.is_none()); if self.default_deck_is_empty()? { hide_default_deck(&mut tree); } if let Some(timestamp) = timestamp { // cards buried on previous days need to be unburied for the current // day's counts to be accurate let timing_today = self.timing_today()?; self.unbury_if_day_rolled_over(timing_today)?; let timing_at_stamp = self.timing_for_timestamp(timestamp)?; let days_elapsed = timing_at_stamp.days_elapsed; let learn_cutoff = (timestamp.0 as u32) + self.learn_ahead_secs(); let new_cards_ignore_review_limit = self.get_config_bool(BoolKey::NewCardsIgnoreReviewLimit); let parent_limits = self .get_config_bool(BoolKey::ApplyAllParentLimits) .then(Default::default); let counts = self.due_counts(days_elapsed, learn_cutoff)?; let dconf = self.storage.get_deck_config_map()?; add_counts(&mut tree, &counts); let limits = remaining_limits_map( decks_map.values(), &dconf, days_elapsed, new_cards_ignore_review_limit, ); sum_counts_and_apply_limits_v3(&mut tree, &limits, parent_limits); } Ok(tree) } pub fn current_deck_tree(&mut self) -> Result> { let target = self.get_current_deck_id(); let tree = self.deck_tree(Some(TimestampSecs::now()))?; Ok(get_deck_in_tree(tree, target)) } pub fn set_deck_collapsed( &mut self, did: DeckId, collapsed: bool, scope: DeckCollapseScope, ) -> Result> { self.transact(Op::SkipUndo, |col| { if let Some(mut deck) = col.storage.get_deck(did)? { let original = deck.clone(); let c = &mut deck.common; match scope { DeckCollapseScope::Reviewer => c.study_collapsed = collapsed, DeckCollapseScope::Browser => c.browser_collapsed = collapsed, }; col.update_deck_inner(&mut deck, original, col.usn()?)?; } Ok(()) }) } } impl Collection { pub(crate) fn legacy_deck_tree(&mut self) -> Result { let tree = self.deck_tree(Some(TimestampSecs::now()))?; Ok(LegacyDueCounts::from(tree)) } pub(crate) fn add_missing_deck_names(&mut self, names: &[(DeckId, String)]) -> Result { let mut parents = HashSet::new(); let mut missing = 0; for (_id, name) in names { parents.insert(UniCase::new(name.as_str())); if let Some((immediate_parent, _)) = name.rsplit_once("::") { let immediate_parent_uni = UniCase::new(immediate_parent); if !parents.contains(&immediate_parent_uni) { self.get_or_create_normal_deck(immediate_parent)?; parents.insert(immediate_parent_uni); missing += 1; } } } Ok(missing) } } #[cfg(test)] mod test { use super::*; use crate::deckconfig::DeckConfigId; use crate::error::Result; #[test] fn wellformed() -> Result<()> { let mut col = Collection::new(); col.get_or_create_normal_deck("1")?; col.get_or_create_normal_deck("2")?; col.get_or_create_normal_deck("2::a")?; col.get_or_create_normal_deck("2::b")?; col.get_or_create_normal_deck("2::c")?; col.get_or_create_normal_deck("2::c::A")?; col.get_or_create_normal_deck("3")?; let tree = col.deck_tree(None)?; assert_eq!(tree.children.len(), 3); assert_eq!(tree.children[1].name, "2"); assert_eq!(tree.children[1].children[0].name, "a"); assert_eq!(tree.children[1].children[2].name, "c"); assert_eq!(tree.children[1].children[2].children[0].name, "A"); Ok(()) } #[test] fn malformed() -> Result<()> { let mut col = Collection::new(); col.get_or_create_normal_deck("1")?; col.get_or_create_normal_deck("2::3::4")?; // remove the top parent and middle parent col.storage.remove_deck(col.get_deck_id("2")?.unwrap())?; col.storage.remove_deck(col.get_deck_id("2::3")?.unwrap())?; let tree = col.deck_tree(None)?; assert_eq!(tree.children.len(), 1); Ok(()) } #[test] fn counts() -> Result<()> { let mut col = Collection::new(); let mut parent_deck = col.get_or_create_normal_deck("Default")?; let mut child_deck = col.get_or_create_normal_deck("Default::one")?; // add some new cards let nt = col.get_notetype_by_name("Cloze")?.unwrap(); let mut note = nt.new_note(); note.set_field(0, "{{c1::}} {{c2::}} {{c3::}} {{c4::}}")?; col.add_note(&mut note, child_deck.id)?; let tree = col.deck_tree(Some(TimestampSecs::now()))?; assert_eq!(tree.children[0].new_count, 4); assert_eq!(tree.children[0].children[0].new_count, 4); // simulate answering a card child_deck.common.new_studied = 1; col.add_or_update_deck(&mut child_deck)?; parent_deck.common.new_studied = 1; col.add_or_update_deck(&mut parent_deck)?; // with the default limit of 20, there should still be 4 due let tree = col.deck_tree(Some(TimestampSecs::now()))?; assert_eq!(tree.children[0].new_count, 4); assert_eq!(tree.children[0].children[0].new_count, 4); // set the limit to 4, which should mean 3 are left let mut conf = col.get_deck_config(DeckConfigId(1), false)?.unwrap(); conf.inner.new_per_day = 4; col.add_or_update_deck_config(&mut conf)?; let tree = col.deck_tree(Some(TimestampSecs::now()))?; assert_eq!(tree.children[0].new_count, 3); assert_eq!(tree.children[0].children[0].new_count, 3); Ok(()) } #[test] fn nested_counts_v3() -> Result<()> { fn create_deck_with_new_limit(col: &mut Collection, name: &str, new_limit: u32) -> Deck { let mut deck = col.get_or_create_normal_deck(name).unwrap(); let mut conf = DeckConfig::default(); conf.inner.new_per_day = new_limit; col.add_or_update_deck_config(&mut conf).unwrap(); deck.normal_mut().unwrap().config_id = conf.id.0; col.add_or_update_deck(&mut deck).unwrap(); deck } let mut col = Collection::new(); let parent_deck = create_deck_with_new_limit(&mut col, "Default", 8); let child_deck = create_deck_with_new_limit(&mut col, "Default::child", 4); let grandchild_1 = create_deck_with_new_limit(&mut col, "Default::child::grandchild_1", 2); let grandchild_2 = create_deck_with_new_limit(&mut col, "Default::child::grandchild_2", 1); // add 2 new cards to each deck let nt = col.get_notetype_by_name("Cloze")?.unwrap(); let mut note = nt.new_note(); note.set_field(0, "{{c1::}} {{c2::}}")?; col.add_note(&mut note, parent_deck.id)?; note.id.0 = 0; col.add_note(&mut note, child_deck.id)?; note.id.0 = 0; col.add_note(&mut note, grandchild_1.id)?; note.id.0 = 0; col.add_note(&mut note, grandchild_2.id)?; let parent = &col.deck_tree(Some(TimestampSecs::now()))?.children[0]; // grandchildren: own cards, limited by own new limits assert_eq!(parent.children[0].children[0].new_count, 2); assert_eq!(parent.children[0].children[1].new_count, 1); // child: cards from self and children, limited by own new limit assert_eq!(parent.children[0].new_count, 4); // parent: cards from self and all subdecks, all limits in the hierarchy are // respected assert_eq!(parent.new_count, 6); assert_eq!(parent.total_including_children, 8); assert_eq!(parent.total_in_deck, 2); Ok(()) } } ================================================ FILE: rslib/src/decks/undo.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::prelude::*; #[derive(Debug)] pub(crate) enum UndoableDeckChange { Added(Box), Updated(Box), Removed(Box), GraveAdded(Box<(DeckId, Usn)>), GraveRemoved(Box<(DeckId, Usn)>), } impl Collection { pub(crate) fn undo_deck_change(&mut self, change: UndoableDeckChange) -> Result<()> { match change { UndoableDeckChange::Added(deck) => self.remove_deck_undoable(*deck), UndoableDeckChange::Updated(mut deck) => { let current = self .storage .get_deck(deck.id)? .or_invalid("deck disappeared")?; self.update_single_deck_undoable(&mut deck, current) } UndoableDeckChange::Removed(deck) => self.restore_deleted_deck(*deck), UndoableDeckChange::GraveAdded(e) => self.remove_deck_grave(e.0, e.1), UndoableDeckChange::GraveRemoved(e) => self.add_deck_grave_undoable(e.0, e.1), } } pub(crate) fn remove_deck_and_add_grave_undoable( &mut self, deck: Deck, usn: Usn, ) -> Result<()> { self.state.deck_cache.clear(); self.add_deck_grave_undoable(deck.id, usn)?; self.storage.remove_deck(deck.id)?; self.save_undo(UndoableDeckChange::Removed(Box::new(deck))); Ok(()) } } impl Collection { pub(super) fn add_deck_undoable(&mut self, deck: &mut Deck) -> Result<(), AnkiError> { self.storage.add_deck(deck)?; self.save_undo(UndoableDeckChange::Added(Box::new(deck.clone()))); Ok(()) } pub(super) fn add_or_update_deck_with_existing_id_undoable( &mut self, deck: &mut Deck, ) -> Result<(), AnkiError> { self.state.deck_cache.clear(); self.storage.add_or_update_deck_with_existing_id(deck)?; self.save_undo(UndoableDeckChange::Added(Box::new(deck.clone()))); Ok(()) } /// Update an individual, existing deck. Caller is responsible for ensuring /// deck is normalized, matches parents, is not a duplicate name, and /// bumping mtime. Clears deck cache. pub(super) fn update_single_deck_undoable( &mut self, deck: &mut Deck, original: Deck, ) -> Result<()> { self.state.deck_cache.clear(); self.save_undo(UndoableDeckChange::Updated(Box::new(original))); self.storage.update_deck(deck) } fn restore_deleted_deck(&mut self, deck: Deck) -> Result<()> { self.storage.add_or_update_deck_with_existing_id(&deck)?; self.save_undo(UndoableDeckChange::Added(Box::new(deck))); Ok(()) } fn remove_deck_undoable(&mut self, deck: Deck) -> Result<()> { self.state.deck_cache.clear(); self.storage.remove_deck(deck.id)?; self.save_undo(UndoableDeckChange::Removed(Box::new(deck))); Ok(()) } fn add_deck_grave_undoable(&mut self, did: DeckId, usn: Usn) -> Result<()> { self.save_undo(UndoableDeckChange::GraveAdded(Box::new((did, usn)))); self.storage.add_deck_grave(did, usn) } fn remove_deck_grave(&mut self, did: DeckId, usn: Usn) -> Result<()> { self.save_undo(UndoableDeckChange::GraveRemoved(Box::new((did, usn)))); self.storage.remove_deck_grave(did) } } ================================================ FILE: rslib/src/error/db.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::str::Utf8Error; use anki_i18n::I18n; use rusqlite::types::FromSqlError; use rusqlite::Error; use snafu::Snafu; use super::AnkiError; #[derive(Debug, PartialEq, Eq, Snafu)] #[snafu(display("{kind:?}: {info}"))] pub struct DbError { pub info: String, pub kind: DbErrorKind, } #[derive(Debug, PartialEq, Eq)] pub enum DbErrorKind { FileTooNew, FileTooOld, MissingEntity, Corrupt, Locked, Utf8, Other, } impl AnkiError { pub(crate) fn db_error(info: impl Into, kind: DbErrorKind) -> Self { AnkiError::DbError { source: DbError { info: info.into(), kind, }, } } } impl From for AnkiError { fn from(err: Error) -> Self { if let Error::SqliteFailure(error, Some(reason)) = &err { if error.code == rusqlite::ErrorCode::DatabaseBusy { return AnkiError::DbError { source: DbError { info: "".to_string(), kind: DbErrorKind::Locked, }, }; } if reason.contains("regex parse error") { return AnkiError::InvalidRegex { info: reason.to_owned(), }; } } else if let Error::FromSqlConversionFailure(_, _, err) = &err { if let Some(_err) = err.downcast_ref::() { return AnkiError::DbError { source: DbError { info: "".to_string(), kind: DbErrorKind::Utf8, }, }; } } AnkiError::DbError { source: DbError { info: format!("{err:?}"), kind: DbErrorKind::Other, }, } } } impl From for AnkiError { fn from(err: FromSqlError) -> Self { if let FromSqlError::Other(ref err) = err { if let Some(_err) = err.downcast_ref::() { return AnkiError::DbError { source: DbError { info: "".to_string(), kind: DbErrorKind::Utf8, }, }; } } AnkiError::DbError { source: DbError { info: format!("{err:?}"), kind: DbErrorKind::Other, }, } } } impl DbError { pub fn message(&self, _tr: &I18n) -> String { match self.kind { DbErrorKind::Corrupt => self.info.clone(), // fixme: i18n DbErrorKind::Locked => "Anki already open, or media currently syncing.".into(), _ => format!("{self:?}"), } } } ================================================ FILE: rslib/src/error/filtered.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_i18n::I18n; use snafu::Snafu; #[derive(Debug, PartialEq, Eq, Snafu)] pub enum FilteredDeckError { MustBeLeafNode, CanNotMoveCardsInto, SearchReturnedNoCards, FilteredDeckRequired, } impl FilteredDeckError { pub fn message(&self, tr: &I18n) -> String { match self { FilteredDeckError::MustBeLeafNode => tr.errors_filtered_parent_deck(), FilteredDeckError::CanNotMoveCardsInto => { tr.browsing_cards_cant_be_manually_moved_into() } FilteredDeckError::SearchReturnedNoCards => tr.decks_filtered_deck_search_empty(), FilteredDeckError::FilteredDeckRequired => tr.errors_filtered_deck_required(), } .into() } } #[derive(Debug, PartialEq, Eq, Snafu)] pub enum CustomStudyError { NoMatchingCards, ExistingDeck, } impl CustomStudyError { pub fn message(&self, tr: &I18n) -> String { match self { Self::NoMatchingCards => tr.custom_study_no_cards_matched_the_criteria_you(), Self::ExistingDeck => tr.custom_study_must_rename_deck(), } .into() } } ================================================ FILE: rslib/src/error/invalid_input.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use snafu::Backtrace; use snafu::OptionExt; use snafu::ResultExt; use snafu::Snafu; use crate::prelude::*; /// General-purpose error for unexpected [Err]s, [None]s, and other /// violated constraints. #[derive(Debug, Snafu)] #[snafu(visibility(pub), display("{message}"), whatever)] pub struct InvalidInputError { pub message: String, #[snafu(source(from(Box, Some)))] pub source: Option>, pub backtrace: Option, } impl InvalidInputError { pub fn message(&self) -> String { self.message.clone() } pub fn context(&self) -> String { if let Some(source) = &self.source { format!("{source}") } else { String::new() } } } impl PartialEq for InvalidInputError { fn eq(&self, other: &Self) -> bool { self.message == other.message } } impl Eq for InvalidInputError {} /// Allows generating [AnkiError::InvalidInput] from [None] and the /// typical [Err]. pub trait OrInvalid { type Value; fn or_invalid(self, message: impl Into) -> Result; } impl OrInvalid for Option { type Value = T; fn or_invalid(self, message: impl Into) -> Result { self.whatever_context::<_, InvalidInputError>(message) .map_err(Into::into) } } impl OrInvalid for Result { type Value = T; fn or_invalid(self, message: impl Into) -> Result { self.whatever_context::<_, InvalidInputError>(message) .map_err(Into::into) } } /// Returns an [AnkiError::InvalidInput] with the provided format string and an /// optional underlying error. #[macro_export] macro_rules! invalid_input { ($fmt:literal$(, $($arg:expr),* $(,)?)?) => { return core::result::Result::Err({ $crate::error::AnkiError::InvalidInput { source: snafu::FromString::without_source( format!($fmt$(, $($arg),*)*), ) }}) }; ($source:expr, $fmt:literal$(, $($arg:expr),* $(,)?)?) => { return core::result::Result::Err({ $crate::error::AnkiError::InvalidInput { source: snafu::FromString::with_source( core::convert::Into::into($source), format!($fmt$(, $($arg),*)*), ) }}) }; } /// Returns an [AnkiError::InvalidInput] unless the condition is true. #[macro_export] macro_rules! require { ($condition:expr, $fmt:literal$(, $($arg:expr),* $(,)?)?) => { if !$condition { return core::result::Result::Err({ $crate::error::AnkiError::InvalidInput { source: snafu::FromString::without_source( format!($fmt$(, $($arg),*)*), ) }}); } }; } ================================================ FILE: rslib/src/error/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod db; mod filtered; mod invalid_input; pub(crate) mod network; mod not_found; mod search; #[cfg(windows)] pub mod windows; use anki_i18n::I18n; use anki_io::FileIoError; use anki_io::FileOp; pub use db::DbError; pub use db::DbErrorKind; pub use filtered::CustomStudyError; pub use filtered::FilteredDeckError; pub use network::NetworkError; pub use network::NetworkErrorKind; pub use network::SyncError; pub use network::SyncErrorKind; pub use search::ParseError; pub use search::SearchErrorKind; use snafu::Snafu; pub use self::invalid_input::InvalidInputError; pub use self::invalid_input::OrInvalid; pub use self::not_found::NotFoundError; pub use self::not_found::OrNotFound; use crate::import_export::ImportError; use crate::links::HelpPage; pub type Result = std::result::Result; #[derive(Debug, PartialEq, Snafu)] pub enum AnkiError { #[snafu(context(false))] InvalidInput { source: InvalidInputError, }, TemplateError { info: String, }, #[snafu(context(false))] CardTypeError { source: CardTypeError, }, #[snafu(context(false))] FileIoError { source: FileIoError, }, #[snafu(context(false))] DbError { source: DbError, }, #[snafu(context(false))] NetworkError { source: NetworkError, }, #[snafu(context(false))] SyncError { source: SyncError, }, JsonError { info: String, }, ProtoError { info: String, }, ParseNumError, Interrupted, CollectionNotOpen, CollectionAlreadyOpen, #[snafu(context(false))] NotFound { source: NotFoundError, }, /// Indicates an absent card or note, but (unlike [AnkiError::NotFound]) in /// a non-critical context like the browser table, where deleted ids are /// deliberately not removed. Deleted, Existing, #[snafu(context(false))] FilteredDeckError { source: FilteredDeckError, }, #[snafu(context(false))] SearchError { source: SearchErrorKind, }, InvalidRegex { info: String, }, UndoEmpty, MultipleNotetypesSelected, DatabaseCheckRequired, MediaCheckRequired, #[snafu(context(false))] CustomStudyError { source: CustomStudyError, }, #[snafu(context(false))] ImportError { source: ImportError, }, InvalidId, #[cfg(windows)] #[snafu(context(false))] WindowsError { source: windows::WindowsError, }, InvalidMethodIndex, InvalidServiceIndex, FsrsParamsInvalid, /// Returned by fsrs-rs; may happen even if 400+ reviews FsrsInsufficientData, /// Generated by our backend if count < 400 FsrsInsufficientReviews { count: usize, }, FsrsUnableToDetermineDesiredRetention, SchedulerUpgradeRequired, InvalidCertificateFormat, } // error helpers impl AnkiError { pub fn message(&self, tr: &I18n) -> String { match self { AnkiError::SyncError { source } => source.message(tr), AnkiError::NetworkError { source } => source.message(tr), AnkiError::TemplateError { info: source } => { // already localized source.into() } AnkiError::CardTypeError { source } => { let header = tr.card_templates_invalid_template_number(source.ordinal + 1, &source.notetype); let details = match &source.source { CardTypeErrorDetails::TemplateParseError => tr.card_templates_see_preview(), CardTypeErrorDetails::NoSuchField { field } => { tr.card_templates_field_not_found(field) } CardTypeErrorDetails::NoFrontField => tr.card_templates_no_front_field(), CardTypeErrorDetails::Duplicate { index } => { tr.card_templates_identical_front(index + 1) } CardTypeErrorDetails::MissingCloze => tr.card_templates_missing_cloze(), }; format!("{header}
{details}") } AnkiError::DbError { source } => source.message(tr), AnkiError::SearchError { source } => source.message(tr), AnkiError::ParseNumError => tr.errors_parse_number_fail().into(), AnkiError::FilteredDeckError { source } => source.message(tr), AnkiError::InvalidRegex { info: source } => format!("
{source}
"), AnkiError::MultipleNotetypesSelected => tr.errors_multiple_notetypes_selected().into(), AnkiError::DatabaseCheckRequired => tr.errors_please_check_database().into(), AnkiError::MediaCheckRequired => tr.errors_please_check_media().into(), AnkiError::CustomStudyError { source } => source.message(tr), AnkiError::ImportError { source } => source.message(tr), AnkiError::Deleted => tr.browsing_row_deleted().into(), AnkiError::InvalidId => tr.errors_please_check_database().into(), AnkiError::JsonError { .. } | AnkiError::ProtoError { .. } | AnkiError::Interrupted | AnkiError::CollectionNotOpen | AnkiError::CollectionAlreadyOpen | AnkiError::Existing | AnkiError::InvalidServiceIndex | AnkiError::InvalidMethodIndex | AnkiError::UndoEmpty | AnkiError::InvalidCertificateFormat => format!("{self:?}"), AnkiError::FileIoError { source } => source.message(), AnkiError::InvalidInput { source } => source.message(), AnkiError::NotFound { source } => source.message(tr), AnkiError::FsrsInsufficientData => tr.deck_config_not_enough_history().into(), AnkiError::FsrsInsufficientReviews { count } => { tr.deck_config_must_have_400_reviews(*count).into() } AnkiError::FsrsParamsInvalid => tr.deck_config_invalid_parameters().into(), AnkiError::SchedulerUpgradeRequired => { tr.scheduling_update_required().replace("V2", "v3") } #[cfg(windows)] AnkiError::WindowsError { source } => format!("{source:?}"), AnkiError::FsrsUnableToDetermineDesiredRetention => tr .deck_config_unable_to_determine_desired_retention() .into(), } } pub fn help_page(&self) -> Option { match self { Self::CardTypeError { source: CardTypeError { source, .. }, } => Some(match source { CardTypeErrorDetails::TemplateParseError => HelpPage::CardTypeTemplateError, CardTypeErrorDetails::NoSuchField { field: _ } => HelpPage::CardTypeTemplateError, CardTypeErrorDetails::Duplicate { .. } => HelpPage::CardTypeDuplicate, CardTypeErrorDetails::NoFrontField => HelpPage::CardTypeNoFrontField, CardTypeErrorDetails::MissingCloze => HelpPage::CardTypeMissingCloze, }), _ => None, } } pub fn context(&self) -> String { match self { Self::InvalidInput { source } => source.context(), Self::NotFound { source } => source.context(), _ => String::new(), } } pub fn backtrace(&self) -> String { match self { Self::InvalidInput { source } => { if let Some(bt) = snafu::ErrorCompat::backtrace(source) { return format!("{bt}"); } } Self::NotFound { source } => { if let Some(bt) = snafu::ErrorCompat::backtrace(source) { return format!("{bt}"); } } _ => (), } String::new() } } #[derive(Debug, PartialEq, Eq)] pub enum TemplateError { NoClosingBrackets(String), ConditionalNotClosed(String), ConditionalNotOpen { closed: String, currently_open: Option, }, FieldNotFound { filters: String, field: String, }, NoSuchConditional(String), } impl From for AnkiError { fn from(err: serde_json::Error) -> Self { AnkiError::JsonError { info: err.to_string(), } } } impl From for AnkiError { fn from(err: prost::EncodeError) -> Self { AnkiError::ProtoError { info: err.to_string(), } } } impl From for AnkiError { fn from(err: prost::DecodeError) -> Self { AnkiError::ProtoError { info: err.to_string(), } } } impl From for AnkiError { fn from(e: tempfile::PathPersistError) -> Self { FileIoError::from(e).into() } } impl From for AnkiError { fn from(e: tempfile::PersistError) -> Self { FileIoError::from(e).into() } } impl From for AnkiError { fn from(err: regex::Error) -> Self { AnkiError::InvalidRegex { info: err.to_string(), } } } // stopgap; implicit mapping should be phased out in favor of manual // context attachment impl From for AnkiError { fn from(source: std::io::Error) -> Self { FileIoError { path: std::path::PathBuf::new(), op: FileOp::Unknown, source, } .into() } } #[derive(Debug, PartialEq, Eq, Snafu)] #[snafu(visibility(pub))] pub struct CardTypeError { pub notetype: String, pub ordinal: usize, pub source: CardTypeErrorDetails, } #[derive(Debug, PartialEq, Eq, Snafu)] #[snafu(visibility(pub))] pub enum CardTypeErrorDetails { TemplateParseError, Duplicate { index: usize }, NoFrontField, NoSuchField { field: String }, MissingCloze, } ================================================ FILE: rslib/src/error/network.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_i18n::I18n; use reqwest::StatusCode; use snafu::Snafu; use super::AnkiError; use crate::sync::collection::sanity::SanityCheckCounts; use crate::sync::error::HttpError; #[derive(Debug, PartialEq, Eq, Snafu)] #[snafu(visibility(pub(crate)))] pub struct NetworkError { pub info: String, pub kind: NetworkErrorKind, } #[derive(Debug, PartialEq, Eq)] pub enum NetworkErrorKind { Offline, Timeout, ProxyAuth, Other, } #[derive(Debug, PartialEq, Eq, Snafu)] #[snafu(display("{kind:?}: {info}"))] pub struct SyncError { pub info: String, pub kind: SyncErrorKind, } #[derive(Debug, PartialEq, Eq)] pub enum SyncErrorKind { Conflict, ServerError, ClientTooOld, AuthFailed, ServerMessage, ClockIncorrect, Other, ResyncRequired, DatabaseCheckRequired, SyncNotStarted, UploadTooLarge, SanityCheckFailed { client: Option, server: Option, }, } impl AnkiError { pub(crate) fn sync_error(info: impl Into, kind: SyncErrorKind) -> Self { AnkiError::SyncError { source: SyncError { info: info.into(), kind, }, } } pub(crate) fn server_message>(msg: S) -> AnkiError { AnkiError::sync_error(msg, SyncErrorKind::ServerMessage) } } impl From<&reqwest::Error> for AnkiError { fn from(err: &reqwest::Error) -> Self { let url = err.url().map(|url| url.as_str()).unwrap_or(""); let str_err = format!("{err}"); // strip url from error to avoid exposing keys let info = str_err.replace(url, ""); if err.is_timeout() { AnkiError::NetworkError { source: NetworkError { info, kind: NetworkErrorKind::Timeout, }, } } else if err.is_status() { error_for_status_code(info, err.status().unwrap()) } else { guess_reqwest_error(info) } } } impl From for AnkiError { fn from(err: reqwest::Error) -> Self { (&err).into() } } fn error_for_status_code(info: String, code: StatusCode) -> AnkiError { use reqwest::StatusCode as S; match code { S::PROXY_AUTHENTICATION_REQUIRED => AnkiError::NetworkError { source: NetworkError { info, kind: NetworkErrorKind::ProxyAuth, }, }, S::CONFLICT => AnkiError::SyncError { source: SyncError { info, kind: SyncErrorKind::Conflict, }, }, S::FORBIDDEN => AnkiError::SyncError { source: SyncError { info, kind: SyncErrorKind::AuthFailed, }, }, S::NOT_IMPLEMENTED => AnkiError::SyncError { source: SyncError { info, kind: SyncErrorKind::ClientTooOld, }, }, S::INTERNAL_SERVER_ERROR | S::BAD_GATEWAY | S::GATEWAY_TIMEOUT | S::SERVICE_UNAVAILABLE => { AnkiError::SyncError { source: SyncError { info, kind: SyncErrorKind::ServerError, }, } } S::BAD_REQUEST => AnkiError::SyncError { source: SyncError { info, kind: SyncErrorKind::DatabaseCheckRequired, }, }, _ => AnkiError::NetworkError { source: NetworkError { info, kind: NetworkErrorKind::Other, }, }, } } fn guess_reqwest_error(mut info: String) -> AnkiError { if info.contains("dns error: cancelled") { return AnkiError::Interrupted; } let kind = if info.contains("unreachable") || info.contains("dns") { NetworkErrorKind::Offline } else if info.contains("timed out") { NetworkErrorKind::Timeout } else { if info.contains("invalid type") { info = format!( "{} {} {}\n\n{}", "Please force a one-way sync in the Preferences screen to bring your devices into sync.", "Then, please use the Check Database feature, and sync to your other devices.", "If problems persist, please post on the support forum.", info, ); } NetworkErrorKind::Other }; AnkiError::NetworkError { source: NetworkError { info, kind }, } } impl From for AnkiError { fn from(err: zip::result::ZipError) -> Self { AnkiError::sync_error(err.to_string(), SyncErrorKind::Other) } } impl SyncError { pub fn message(&self, tr: &I18n) -> String { match self.kind { SyncErrorKind::ServerMessage => self.info.clone().into(), SyncErrorKind::Other => self.info.clone().into(), SyncErrorKind::Conflict => tr.sync_conflict(), SyncErrorKind::ServerError => tr.sync_server_error(), SyncErrorKind::ClientTooOld => tr.sync_client_too_old(), SyncErrorKind::AuthFailed => tr.sync_wrong_pass(), SyncErrorKind::ResyncRequired => tr.sync_resync_required(), SyncErrorKind::ClockIncorrect => tr.sync_clock_off(), SyncErrorKind::DatabaseCheckRequired | SyncErrorKind::SanityCheckFailed { .. } => { tr.sync_sanity_check_failed() } SyncErrorKind::SyncNotStarted => "sync not started".into(), SyncErrorKind::UploadTooLarge => tr.sync_upload_too_large(&self.info), } .into() } } impl NetworkError { pub fn message(&self, tr: &I18n) -> String { let summary = match self.kind { NetworkErrorKind::Offline => tr.network_offline(), NetworkErrorKind::Timeout => tr.network_timeout(), NetworkErrorKind::ProxyAuth => tr.network_proxy_auth(), NetworkErrorKind::Other => tr.network_other(), }; let details = tr.network_details(self.info.as_str()); format!("{summary}\n\n{details}") } } // This needs rethinking; we should be attaching error context as errors are // encountered instead of trying to determine the problem later. impl From for AnkiError { fn from(err: HttpError) -> Self { if let Some(reqwest_error) = err .source .as_ref() .and_then(|source| source.downcast_ref::()) { reqwest_error.into() } else if err.code == StatusCode::REQUEST_TIMEOUT { NetworkError { info: String::new(), kind: NetworkErrorKind::Timeout, } .into() } else { AnkiError::sync_error(format!("{err:?}"), SyncErrorKind::Other) } } } ================================================ FILE: rslib/src/error/not_found.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::any; use std::fmt; use convert_case::Case; use convert_case::Casing; use snafu::Backtrace; use snafu::OptionExt; use snafu::Snafu; use crate::prelude::*; /// Something was unexpectedly missing from the database. #[derive(Debug, Snafu)] #[snafu(visibility(pub))] pub struct NotFoundError { pub type_name: String, pub identifier: String, pub backtrace: Option, } impl NotFoundError { pub fn message(&self, tr: &I18n) -> String { format!( "{} No such {}: '{}'", tr.errors_inconsistent_db_state(), self.type_name, self.identifier ) } pub fn context(&self) -> String { format!("No such {}: '{}'", self.type_name, self.identifier) } } impl PartialEq for NotFoundError { fn eq(&self, other: &Self) -> bool { self.type_name == other.type_name && self.identifier == other.identifier } } impl Eq for NotFoundError {} /// Allows generating [AnkiError::NotFound] from [None]. pub trait OrNotFound { type Value; fn or_not_found(self, identifier: impl fmt::Display) -> Result; } impl OrNotFound for Option { type Value = T; fn or_not_found(self, identifier: impl fmt::Display) -> Result { self.with_context(|| NotFoundSnafu { type_name: unqualified_lowercase_type_name::(), identifier: format!("{identifier}"), }) .map_err(Into::into) } } fn unqualified_lowercase_type_name() -> String { any::type_name::() .split("::") .last() .unwrap_or_default() .to_case(Case::Lower) } #[cfg(test)] mod test { use super::*; #[test] fn test_unqualified_lowercase_type_name() { assert_eq!(unqualified_lowercase_type_name::(), "card id"); } } ================================================ FILE: rslib/src/error/search.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::num::ParseIntError; use anki_i18n::I18n; use nom::error::ErrorKind as NomErrorKind; use nom::error::ParseError as NomParseError; use snafu::Snafu; use super::AnkiError; #[derive(Debug, PartialEq, Eq)] pub enum ParseError<'a> { Anki(&'a str, SearchErrorKind), Nom(&'a str, NomErrorKind), } #[derive(Debug, PartialEq, Eq, Snafu)] pub enum SearchErrorKind { MisplacedAnd, MisplacedOr, EmptyGroup, UnopenedGroup, UnclosedGroup, EmptyQuote, UnclosedQuote, MissingKey, UnknownEscape { provided: String }, InvalidState { provided: String }, InvalidFlag, InvalidPropProperty { provided: String }, InvalidPropOperator { provided: String }, InvalidNumber { provided: String, context: String }, InvalidWholeNumber { provided: String, context: String }, InvalidPositiveWholeNumber { provided: String, context: String }, InvalidNegativeWholeNumber { provided: String, context: String }, InvalidAnswerButton { provided: String, context: String }, Other { info: Option }, } impl From> for AnkiError { fn from(err: ParseError) -> Self { match err { ParseError::Anki(_, kind) => AnkiError::SearchError { source: kind }, ParseError::Nom(_, _) => AnkiError::SearchError { source: SearchErrorKind::Other { info: None }, }, } } } impl From>> for AnkiError { fn from(err: nom::Err>) -> Self { match err { nom::Err::Error(e) => e.into(), nom::Err::Failure(e) => e.into(), nom::Err::Incomplete(_) => AnkiError::SearchError { source: SearchErrorKind::Other { info: None }, }, } } } impl<'a> NomParseError<&'a str> for ParseError<'a> { fn from_error_kind(input: &'a str, kind: NomErrorKind) -> Self { ParseError::Nom(input, kind) } fn append(_: &str, _: NomErrorKind, other: Self) -> Self { other } } impl From for AnkiError { fn from(_err: ParseIntError) -> Self { AnkiError::ParseNumError } } impl SearchErrorKind { pub fn message(&self, tr: &I18n) -> String { let reason = match self { SearchErrorKind::MisplacedAnd => tr.search_misplaced_and(), SearchErrorKind::MisplacedOr => tr.search_misplaced_or(), SearchErrorKind::EmptyGroup => tr.search_empty_group(), SearchErrorKind::UnopenedGroup => tr.search_unopened_group(), SearchErrorKind::UnclosedGroup => tr.search_unclosed_group(), SearchErrorKind::EmptyQuote => tr.search_empty_quote(), SearchErrorKind::UnclosedQuote => tr.search_unclosed_quote(), SearchErrorKind::MissingKey => tr.search_missing_key(), SearchErrorKind::UnknownEscape { provided } => { tr.search_unknown_escape(provided.replace('`', "'")) } SearchErrorKind::InvalidState { provided } => { tr.search_invalid_argument("is:", provided.replace('`', "'")) } SearchErrorKind::InvalidFlag => tr.search_invalid_flag_2(), SearchErrorKind::InvalidPropProperty { provided } => { tr.search_invalid_argument("prop:", provided.replace('`', "'")) } SearchErrorKind::InvalidPropOperator { provided } => { tr.search_invalid_prop_operator(provided.as_str()) } SearchErrorKind::Other { info: Some(info) } => info.into(), SearchErrorKind::Other { info: None } => tr.search_invalid_other(), SearchErrorKind::InvalidNumber { provided, context } => { tr.search_invalid_number(context.replace('`', "'"), provided.replace('`', "'")) } SearchErrorKind::InvalidWholeNumber { provided, context } => tr .search_invalid_whole_number(context.replace('`', "'"), provided.replace('`', "'")), SearchErrorKind::InvalidPositiveWholeNumber { provided, context } => tr .search_invalid_positive_whole_number( context.replace('`', "'"), provided.replace('`', "'"), ), SearchErrorKind::InvalidNegativeWholeNumber { provided, context } => tr .search_invalid_negative_whole_number( context.replace('`', "'"), provided.replace('`', "'"), ), SearchErrorKind::InvalidAnswerButton { provided, context } => tr .search_invalid_answer_button( context.replace('`', "'"), provided.replace('`', "'"), ), }; tr.search_invalid_search(reason).into() } } ================================================ FILE: rslib/src/error/windows.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use snafu::Snafu; use super::AnkiError; #[derive(Debug, PartialEq, Snafu)] #[snafu(visibility(pub))] pub struct WindowsError { details: WindowsErrorDetails, source: windows::core::Error, } #[derive(Debug, PartialEq)] pub enum WindowsErrorDetails { SettingVoice(windows::Media::SpeechSynthesis::VoiceInformation), SettingRate(f32), Synthesizing, Other, } impl From for AnkiError { fn from(source: windows::core::Error) -> Self { AnkiError::WindowsError { source: WindowsError { source, details: WindowsErrorDetails::Other, }, } } } ================================================ FILE: rslib/src/findreplace.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use regex::Regex; use crate::collection::Collection; use crate::error::Result; use crate::notes::NoteId; use crate::notes::TransformNoteOutput; use crate::prelude::*; use crate::text::normalize_to_nfc; pub struct FindReplaceContext { nids: Vec, search: Regex, replacement: String, field_name: Option, } enum FieldForNotetype { Any, Index(usize), None, } impl FindReplaceContext { pub fn new( nids: Vec, search_re: &str, repl: impl Into, field_name: Option, ) -> Result { Ok(FindReplaceContext { nids, search: Regex::new(search_re)?, replacement: repl.into(), field_name, }) } fn replace_text<'a>(&self, text: &'a str) -> Cow<'a, str> { self.search.replace_all(text, self.replacement.as_str()) } } impl Collection { pub fn find_and_replace( &mut self, nids: Vec, search_re: &str, repl: &str, field_name: Option, ) -> Result> { self.transact(Op::FindAndReplace, |col| { let norm = col.get_config_bool(BoolKey::NormalizeNoteText); let search = if norm { normalize_to_nfc(search_re) } else { search_re.into() }; let ctx = FindReplaceContext::new(nids, &search, repl, field_name)?; col.find_and_replace_inner(ctx) }) } fn find_and_replace_inner(&mut self, ctx: FindReplaceContext) -> Result { let mut last_ntid = None; let mut field_for_notetype = FieldForNotetype::None; self.transform_notes(&ctx.nids, |note, nt| { if last_ntid != Some(nt.id) { field_for_notetype = match ctx.field_name.as_ref() { None => FieldForNotetype::Any, Some(name) => match nt.get_field_ord(name) { None => FieldForNotetype::None, Some(ord) => FieldForNotetype::Index(ord), }, }; last_ntid = Some(nt.id); } let mut changed = false; match field_for_notetype { FieldForNotetype::Any => { for txt in note.fields_mut() { if let Cow::Owned(otxt) = ctx.replace_text(txt) { changed = true; *txt = otxt; } } } FieldForNotetype::Index(ord) => { if let Some(txt) = note.fields_mut().get_mut(ord) { if let Cow::Owned(otxt) = ctx.replace_text(txt) { changed = true; *txt = otxt; } } } FieldForNotetype::None => (), } Ok(TransformNoteOutput { changed, generate_cards: true, mark_modified: true, update_tags: false, }) }) } } #[cfg(test)] mod test { use super::*; use crate::decks::DeckId; #[test] fn findreplace() -> Result<()> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); note.set_field(0, "one aaa")?; note.set_field(1, "two aaa")?; col.add_note(&mut note, DeckId(1))?; let nt = col.get_notetype_by_name("Cloze")?.unwrap(); let mut note2 = nt.new_note(); note2.set_field(0, "three aaa")?; col.add_note(&mut note2, DeckId(1))?; let nids = col.search_notes_unordered("")?; let out = col.find_and_replace(nids.clone(), "(?i)AAA", "BBB", None)?; assert_eq!(out.output, 2); let note = col.storage.get_note(note.id)?.unwrap(); // but the update should be limited to the specified field when it was available assert_eq!(¬e.fields()[..], &["one BBB", "two BBB"]); let note2 = col.storage.get_note(note2.id)?.unwrap(); assert_eq!(¬e2.fields()[..], &["three BBB", ""]); assert_eq!( col.storage.field_names_for_notes(&nids)?, vec![ "Back".to_string(), "Back Extra".into(), "Front".into(), "Text".into() ] ); let out = col.find_and_replace(nids, "BBB", "ccc", Some("Front".into()))?; // 1, because notes without the specified field should be skipped assert_eq!(out.output, 1); let note = col.storage.get_note(note.id)?.unwrap(); // the update should be limited to the specified field when it was available assert_eq!(¬e.fields()[..], &["one ccc", "two BBB"]); Ok(()) } } ================================================ FILE: rslib/src/i18n/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub(crate) mod service; ================================================ FILE: rslib/src/i18n/service.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use anki_i18n::I18n; use anki_proto::generic; use anki_proto::generic::Json; use anki_proto::i18n::format_timespan_request::Context; use anki_proto::i18n::FormatTimespanRequest; use anki_proto::i18n::I18nResourcesRequest; use anki_proto::i18n::TranslateStringRequest; use fluent_bundle::FluentArgs; use fluent_bundle::FluentValue; use crate::collection::Collection; use crate::error; use crate::scheduler::timespan::answer_button_time; use crate::scheduler::timespan::time_span; impl crate::services::I18nService for Collection { fn translate_string( &mut self, input: TranslateStringRequest, ) -> error::Result { translate_string(&self.tr, input) } fn format_timespan(&mut self, input: FormatTimespanRequest) -> error::Result { format_timespan(&self.tr, input) } fn i18n_resources(&mut self, input: I18nResourcesRequest) -> error::Result { i18n_resources(&self.tr, input) } } pub(crate) fn translate_string( tr: &I18n, input: TranslateStringRequest, ) -> error::Result { let args = build_fluent_args(input.args); Ok(tr .translate_via_index( input.module_index as usize, input.message_index as usize, args, ) .into()) } pub(crate) fn format_timespan( tr: &I18n, input: FormatTimespanRequest, ) -> error::Result { Ok(match input.context() { Context::Precise => time_span(input.seconds, tr, true), Context::Intervals => time_span(input.seconds, tr, false), Context::AnswerButtons => answer_button_time(input.seconds, tr), } .into()) } pub(crate) fn i18n_resources( tr: &I18n, input: I18nResourcesRequest, ) -> error::Result { serde_json::to_vec(&tr.resources_for_js(&input.modules)) .map(Into::into) .map_err(Into::into) } fn build_fluent_args( input: HashMap, ) -> FluentArgs<'static> { let mut args = FluentArgs::new(); for (key, val) in input { args.set(key, translate_arg_to_fluent_val(&val)); } args } fn translate_arg_to_fluent_val(arg: &anki_proto::i18n::TranslateArgValue) -> FluentValue<'static> { use anki_proto::i18n::translate_arg_value::Value as V; match &arg.value { Some(val) => match val { V::Str(s) => FluentValue::String(s.to_owned().into()), V::Number(f) => FluentValue::Number(f.into()), }, None => FluentValue::String("".into()), } } ================================================ FILE: rslib/src/image_occlusion/imagedata.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::path::Path; use std::path::PathBuf; use anki_io::metadata; use anki_io::read_file; use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusionNote; use anki_proto::image_occlusion::get_image_occlusion_note_response::Value; use anki_proto::image_occlusion::AddImageOcclusionNoteRequest; use anki_proto::image_occlusion::GetImageForOcclusionResponse; use anki_proto::image_occlusion::GetImageOcclusionNoteResponse; use anki_proto::image_occlusion::ImageOcclusionFieldIndexes; use anki_proto::notetypes::ImageOcclusionField; use regex::Regex; use crate::cloze::parse_image_occlusions; use crate::media::MediaManager; use crate::prelude::*; impl Collection { pub fn get_image_for_occlusion(&mut self, path: &str) -> Result { let mut metadata = GetImageForOcclusionResponse { ..Default::default() }; metadata.data = read_file(path)?; Ok(metadata) } pub fn add_image_occlusion_note( &mut self, req: AddImageOcclusionNoteRequest, ) -> Result> { // image file let image_bytes = read_file(&req.image_path)?; let image_filename = Path::new(&req.image_path) .file_name() .or_not_found("expected filename")? .to_str() .unwrap() .to_string(); let mgr = MediaManager::new(&self.media_folder, &self.media_db)?; let actual_image_name_after_adding = mgr.add_file(&image_filename, &image_bytes)?; let image_tag = format!(r#""#, &actual_image_name_after_adding); let current_deck = self.get_current_deck()?; let notetype_id: NotetypeId = req.notetype_id.into(); self.transact(Op::ImageOcclusion, |col| { let nt = if notetype_id.0 == 0 { // when testing via .html page, use first available notetype col.add_image_occlusion_notetype_inner()?; col.get_first_io_notetype()? .or_invalid("expected an i/o notetype to exist")? } else { col.get_io_notetype_by_id(notetype_id)? }; let mut note = nt.new_note(); let idxs = nt.get_io_field_indexes()?; note.set_field(idxs.occlusions as usize, req.occlusions)?; note.set_field(idxs.image as usize, image_tag)?; note.set_field(idxs.header as usize, req.header)?; note.set_field(idxs.back_extra as usize, req.back_extra)?; note.tags = req.tags; col.add_note_inner(&mut note, current_deck.id)?; Ok(()) }) } pub fn get_image_occlusion_note( &mut self, note_id: NoteId, ) -> Result { let value = match self.get_image_occlusion_note_inner(note_id) { Ok(note) => Value::Note(note), Err(err) => Value::Error(format!("{err:?}")), }; Ok(GetImageOcclusionNoteResponse { value: Some(value) }) } pub fn get_image_occlusion_note_inner( &mut self, note_id: NoteId, ) -> Result { let note = self.storage.get_note(note_id)?.or_not_found(note_id)?; let mut cloze_note = ImageOcclusionNote::default(); let fields = note.fields(); let nt = self .get_notetype(note.notetype_id)? .or_not_found(note.notetype_id)?; let idxs = nt.get_io_field_indexes()?; cloze_note.occlusions = parse_image_occlusions(fields[idxs.occlusions as usize].as_str()); cloze_note.occlude_inactive = cloze_note.occlusions.iter().any(|oc| { oc.shapes.iter().any(|sh| { sh.properties .iter() .find(|p| p.name == "oi") .is_some_and(|p| p.value == "1") }) }); cloze_note.header.clone_from(&fields[idxs.header as usize]); cloze_note .back_extra .clone_from(&fields[idxs.back_extra as usize]); cloze_note.image_data = "".into(); cloze_note.tags.clone_from(¬e.tags); let image_file_name = &fields[idxs.image as usize]; let src = self .extract_img_src(image_file_name) .unwrap_or_else(|| "".to_owned()); let final_path = self.media_folder.join(src); if self.is_image_file(&final_path)? { cloze_note.image_data = read_file(&final_path)?; cloze_note.image_file_name = final_path .file_name() .or_not_found("expected filename")? .to_str() .unwrap() .to_string(); } Ok(cloze_note) } pub fn update_image_occlusion_note( &mut self, note_id: NoteId, occlusions: &str, header: &str, back_extra: &str, tags: Vec, ) -> Result> { let mut note = self.storage.get_note(note_id)?.or_not_found(note_id)?; self.transact(Op::ImageOcclusion, |col| { let nt = col .get_notetype(note.notetype_id)? .or_not_found(note.notetype_id)?; let idxs = nt.get_io_field_indexes()?; note.set_field(idxs.occlusions as usize, occlusions)?; note.set_field(idxs.header as usize, header)?; note.set_field(idxs.back_extra as usize, back_extra)?; note.tags = tags; col.update_note_inner(&mut note)?; Ok(()) }) } fn extract_img_src(&mut self, html: &str) -> Option { let re = Regex::new(r#"]*src\s*=\s*"([^"]+)"#).unwrap(); re.captures(html).map(|cap| cap[1].to_owned()) } fn is_image_file(&mut self, path: &PathBuf) -> Result { let file_path = Path::new(&path); let supported_extensions = ["jpg", "jpeg", "png", "gif", "svg", "webp", "ico", "avif"]; if file_path.exists() { let meta = metadata(file_path)?; if meta.is_file() { if let Some(ext_osstr) = file_path.extension() { if let Some(ext_str) = ext_osstr.to_str() { if supported_extensions.contains(&ext_str.to_lowercase().as_str()) { return Ok(true); } } } } } Ok(false) } } impl Notetype { pub(crate) fn get_io_field_indexes(&self) -> Result { get_field_indexes_by_tag(self).or_else(|_| { if self.fields.len() < 4 { return Err(AnkiError::DatabaseCheckRequired); } Ok(ImageOcclusionFieldIndexes { occlusions: 0, image: 1, header: 2, back_extra: 3, }) }) } } fn get_field_indexes_by_tag(nt: &Notetype) -> Result { Ok(ImageOcclusionFieldIndexes { occlusions: get_field_index(nt, ImageOcclusionField::Occlusions)?, image: get_field_index(nt, ImageOcclusionField::Image)?, header: get_field_index(nt, ImageOcclusionField::Header)?, back_extra: get_field_index(nt, ImageOcclusionField::BackExtra)?, }) } fn get_field_index(nt: &Notetype, field: ImageOcclusionField) -> Result { nt.fields .iter() .enumerate() .find(|(_idx, f)| f.config.tag == Some(field as u32)) .map(|(idx, _)| idx as u32) .ok_or(AnkiError::DatabaseCheckRequired) } ================================================ FILE: rslib/src/image_occlusion/imageocclusion.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::fmt::Write; use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusionProperty; use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusionShape; use htmlescape::encode_attribute; use nom::bytes::complete::escaped; use nom::bytes::complete::is_not; use nom::bytes::complete::tag; use nom::character::complete::char; use nom::error::ErrorKind; use nom::sequence::preceded; use nom::sequence::separated_pair; use nom::Parser; fn unescape(text: &str) -> String { text.replace("\\:", ":") } pub fn parse_image_cloze(text: &str) -> Option { if let Some((shape, _)) = text.split_once(':') { let mut properties = vec![]; let mut remaining = &text[shape.len()..]; while let Ok((rem, (name, value))) = separated_pair::<_, _, _, (_, ErrorKind), _, _, _>( preceded(tag(":"), is_not("=")), tag("="), escaped(is_not("\\:"), '\\', char(':')), ) .parse(remaining) { remaining = rem; let value = unescape(value); properties.push(ImageOcclusionProperty { name: name.to_string(), value, }) } return Some(ImageOcclusionShape { shape: shape.to_string(), properties, }); } None } // convert text like // rect:left=.2325:top=.3261:width=.202:height=.0975 // to something like // result = "data-shape="rect" data-left="399.01" data-top="99.52" // data-width="167.09" data-height="33.78" pub fn get_image_cloze_data(text: &str) -> String { let mut result = String::new(); if let Some(occlusion) = parse_image_cloze(text) { if !occlusion.shape.is_empty() && matches!( occlusion.shape.as_str(), "rect" | "ellipse" | "polygon" | "text" ) { result.push_str(&format!("data-shape=\"{}\" ", occlusion.shape)); } for property in occlusion.properties { match property.name.as_str() { "left" | "top" | "angle" | "fill" => { if !property.value.is_empty() { result.push_str(&format!("data-{}=\"{}\" ", property.name, property.value)); } } "width" => { if !is_empty_or_zero(&property.value) { result.push_str(&format!("data-width=\"{}\" ", property.value)); } } "height" => { if !is_empty_or_zero(&property.value) { result.push_str(&format!("data-height=\"{}\" ", property.value)); } } "rx" => { if !is_empty_or_zero(&property.value) { result.push_str(&format!("data-rx=\"{}\" ", property.value)); } } "ry" => { if !is_empty_or_zero(&property.value) { result.push_str(&format!("data-ry=\"{}\" ", property.value)); } } "points" => { if !property.value.is_empty() { let mut point_str = String::new(); for point_pair in property.value.split(' ') { let Some((x, y)) = point_pair.split_once(',') else { continue; }; write!(&mut point_str, "{x},{y} ").unwrap(); } // remove the trailing space point_str.pop(); if !point_str.is_empty() { result.push_str(&format!("data-points=\"{point_str}\" ")); } } } "oi" => { if !property.value.is_empty() { result.push_str(&format!("data-occludeInactive=\"{}\" ", property.value)); } } "text" => { if !property.value.is_empty() { result.push_str(&format!( "data-text=\"{}\" ", encode_attribute(&property.value) )); } } "scale" => { if !is_empty_or_zero(&property.value) { result.push_str(&format!("data-scale=\"{}\" ", property.value)); } } "fs" => { if !property.value.is_empty() { result.push_str(&format!("data-font-size=\"{}\" ", property.value)); } } _ => {} } } } result } fn is_empty_or_zero(text: &str) -> bool { text.is_empty() || text == "0" } //---------------------------------------- // Tests //---------------------------------------- #[test] fn test_get_image_cloze_data() { assert_eq!( get_image_cloze_data("rect:left=10:top=20:width=30:height=10"), format!( r#"data-shape="rect" data-left="10" data-top="20" data-width="30" data-height="10" "#, ) ); assert_eq!( get_image_cloze_data("ellipse:left=15:top=20:width=10:height=20:rx=10:ry=5"), r#"data-shape="ellipse" data-left="15" data-top="20" data-width="10" data-height="20" data-rx="10" data-ry="5" "#, ); assert_eq!( get_image_cloze_data("polygon:points=0,0 10,10 20,0"), r#"data-shape="polygon" data-points="0,0 10,10 20,0" "#, ); assert_eq!( get_image_cloze_data("text:text=foo\\:bar:left=10"), r#"data-shape="text" data-text="foo:bar" data-left="10" "#, ); } ================================================ FILE: rslib/src/image_occlusion/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub mod imagedata; pub mod imageocclusion; pub(crate) mod notetype; mod service; ================================================ FILE: rslib/src/image_occlusion/notetype.css ================================================ #image-occlusion-canvas { --inactive-shape-color: #ffeba2; --active-shape-color: #ff8e8e; --inactive-shape-border: 1px #212121; --active-shape-border: 1px #212121; --highlight-shape-color: #ff8e8e00; --highlight-shape-border: 1px #ff8e8e; } .card { font-family: arial; font-size: 20px; text-align: center; color: black; background-color: white; } ================================================ FILE: rslib/src/image_occlusion/notetype.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::sync::Arc; use anki_proto::notetypes::stock_notetype::OriginalStockKind; use anki_proto::notetypes::ImageOcclusionField; use crate::notetype::stock::empty_stock; use crate::notetype::Notetype; use crate::notetype::NotetypeKind; use crate::prelude::*; impl Collection { pub fn add_image_occlusion_notetype(&mut self) -> Result> { self.transact(Op::UpdateNotetype, |col| { col.add_image_occlusion_notetype_inner() }) } pub fn add_image_occlusion_notetype_inner(&mut self) -> Result<()> { if self.get_first_io_notetype()?.is_none() { let mut nt = image_occlusion_notetype(&self.tr); let usn = self.usn()?; nt.set_modified(usn); let current_id = self.get_current_notetype_id(); self.add_notetype_inner(&mut nt, usn, false)?; if let Some(current_id) = current_id { // preserve previous default self.set_current_notetype_id(current_id)?; } } Ok(()) } /// Returns the I/O notetype with the provided id, checking to make sure it /// is valid. pub(crate) fn get_io_notetype_by_id( &mut self, notetype_id: NotetypeId, ) -> Result> { let nt = self.get_notetype(notetype_id)?.or_not_found(notetype_id)?; io_notetype_if_valid(nt) } pub(crate) fn get_first_io_notetype(&mut self) -> Result>> { for nt in self.get_all_notetypes()? { if nt.config.original_stock_kind() == OriginalStockKind::ImageOcclusion { if let Ok(nt) = io_notetype_if_valid(nt) { return Ok(Some(nt)); } } } Ok(None) } } pub(crate) fn image_occlusion_notetype(tr: &I18n) -> Notetype { const IMAGE_CLOZE_CSS: &str = include_str!("notetype.css"); let mut nt = empty_stock( NotetypeKind::Cloze, OriginalStockKind::ImageOcclusion, tr.notetypes_image_occlusion_name(), ); nt.config.css = IMAGE_CLOZE_CSS.to_string(); let occlusion = tr.notetypes_occlusion(); let mut config = nt.add_field(occlusion.as_ref()); config.tag = Some(ImageOcclusionField::Occlusions as u32); config.prevent_deletion = true; let image = tr.notetypes_image(); config = nt.add_field(image.as_ref()); config.tag = Some(ImageOcclusionField::Image as u32); config.prevent_deletion = true; let header = tr.notetypes_header(); config = nt.add_field(header.as_ref()); config.tag = Some(ImageOcclusionField::Header as u32); config.prevent_deletion = true; let back_extra = tr.notetypes_back_extra_field(); config = nt.add_field(back_extra.as_ref()); config.tag = Some(ImageOcclusionField::BackExtra as u32); config.prevent_deletion = true; let comments = tr.notetypes_comments_field(); config = nt.add_field(comments.as_ref()); config.tag = Some(ImageOcclusionField::Comments as u32); config.prevent_deletion = false; let err_loading = tr.notetypes_error_loading_image_occlusion(); let qfmt = format!( r#"{{{{#{header}}}}}
{{{{{header}}}}}
{{{{/{header}}}}}
{{{{cloze:{occlusion}}}}}
{{{{{image}}}}}
"# ); let toggle_masks = tr.notetypes_toggle_masks(); let afmt = format!( r#"{qfmt}
{{{{#{back_extra}}}}}
{{{{{back_extra}}}}}
{{{{/{back_extra}}}}} "#, ); nt.add_template(nt.name.clone(), qfmt, afmt); nt } fn io_notetype_if_valid(nt: Arc) -> Result> { if nt.config.original_stock_kind() != OriginalStockKind::ImageOcclusion { invalid_input!("Not an image occlusion notetype"); } if nt.fields.len() < 4 { return Err(AnkiError::TemplateError { info: "IO notetype must have 4+ fields".to_string(), }); } Ok(nt) } ================================================ FILE: rslib/src/image_occlusion/service.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::image_occlusion::AddImageOcclusionNoteRequest; use anki_proto::image_occlusion::GetImageForOcclusionRequest; use anki_proto::image_occlusion::GetImageForOcclusionResponse; use anki_proto::image_occlusion::GetImageOcclusionFieldsRequest; use anki_proto::image_occlusion::GetImageOcclusionFieldsResponse; use anki_proto::image_occlusion::GetImageOcclusionNoteRequest; use anki_proto::image_occlusion::GetImageOcclusionNoteResponse; use anki_proto::image_occlusion::UpdateImageOcclusionNoteRequest; use crate::collection::Collection; use crate::error::Result; use crate::prelude::*; impl crate::services::ImageOcclusionService for Collection { fn get_image_for_occlusion( &mut self, input: GetImageForOcclusionRequest, ) -> Result { self.get_image_for_occlusion(&input.path) } fn add_image_occlusion_note( &mut self, input: AddImageOcclusionNoteRequest, ) -> Result { self.add_image_occlusion_note(input).map(Into::into) } fn get_image_occlusion_note( &mut self, input: GetImageOcclusionNoteRequest, ) -> Result { self.get_image_occlusion_note(input.note_id.into()) } fn update_image_occlusion_note( &mut self, input: UpdateImageOcclusionNoteRequest, ) -> Result { self.update_image_occlusion_note( input.note_id.into(), &input.occlusions, &input.header, &input.back_extra, input.tags, ) .map(Into::into) } fn add_image_occlusion_notetype(&mut self) -> Result { self.add_image_occlusion_notetype().map(Into::into) } fn get_image_occlusion_fields( &mut self, input: GetImageOcclusionFieldsRequest, ) -> Result { let ntid = NotetypeId::from(input.notetype_id); let nt = self.get_notetype(ntid)?.or_not_found(ntid)?; Ok(GetImageOcclusionFieldsResponse { fields: Some(nt.get_io_field_indexes()?), }) } } ================================================ FILE: rslib/src/import_export/gather.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::collections::HashSet; use anki_io::filename_is_safe; use itertools::Itertools; use super::ExportProgress; use crate::decks::immediate_parent_name; use crate::decks::NormalDeck; use crate::latex::extract_latex; use crate::prelude::*; use crate::progress::ThrottlingProgressHandler; use crate::revlog::RevlogEntry; use crate::search::CardTableGuard; use crate::search::NoteTableGuard; use crate::text::extract_media_refs; #[derive(Debug, Default)] pub(super) struct ExchangeData { pub(super) decks: Vec, pub(super) notes: Vec, pub(super) cards: Vec, pub(super) notetypes: Vec, pub(super) revlog: Vec, pub(super) deck_configs: Vec, pub(super) media_filenames: HashSet, pub(super) days_elapsed: u32, pub(super) creation_utc_offset: Option, } impl ExchangeData { pub(super) fn gather_data( &mut self, col: &mut Collection, search: impl TryIntoSearch, with_scheduling: bool, with_deck_configs: bool, ) -> Result<()> { self.days_elapsed = col.timing_today()?.days_elapsed; self.creation_utc_offset = col.get_creation_utc_offset(); let (notes, guard) = col.gather_notes(search)?; self.notes = notes; let (cards, guard) = guard.col.gather_cards()?; self.cards = cards; self.decks = guard.col.gather_decks(with_scheduling, !with_scheduling)?; self.notetypes = guard.col.gather_notetypes()?; let allow_filtered = self.enables_filtered_decks(); if with_scheduling { self.revlog = guard.col.gather_revlog()?; if !allow_filtered { self.restore_cards_from_filtered_decks(); } } else { self.reset_cards_and_notes(guard.col); }; if with_deck_configs { self.deck_configs = guard.col.gather_deck_configs(&self.decks)?; } self.reset_decks(!with_deck_configs, !with_scheduling, allow_filtered); self.check_ids() } pub(super) fn gather_media_names( &mut self, progress: &mut ThrottlingProgressHandler, ) -> Result<()> { let mut inserter = |name: String| { if filename_is_safe(&name) { self.media_filenames.insert(name); } }; let mut progress = progress.incrementor(ExportProgress::Notes); let svg_getter = svg_getter(&self.notetypes); for note in self.notes.iter() { progress.increment()?; gather_media_names_from_note(note, &mut inserter, &svg_getter); } for notetype in self.notetypes.iter() { notetype.gather_media_names(&mut inserter); } Ok(()) } fn reset_cards_and_notes(&mut self, col: &Collection) { self.remove_system_tags(); self.reset_cards(col); } fn remove_system_tags(&mut self) { const SYSTEM_TAGS: [&str; 2] = ["marked", "leech"]; for note in self.notes.iter_mut() { note.tags = std::mem::take(&mut note.tags) .into_iter() .filter(|tag| !SYSTEM_TAGS.iter().any(|s| tag.eq_ignore_ascii_case(s))) .collect(); } } fn reset_decks( &mut self, reset_config_ids: bool, reset_study_info: bool, allow_filtered: bool, ) { for deck in self.decks.iter_mut() { if reset_study_info { deck.common = Default::default(); } match &mut deck.kind { DeckKind::Normal(normal) => { if reset_config_ids { normal.config_id = 1; } if reset_study_info { normal.extend_new = 0; normal.extend_review = 0; normal.review_limit = None; normal.review_limit_today = None; normal.new_limit = None; normal.new_limit_today = None; } } DeckKind::Filtered(_) if reset_study_info || !allow_filtered => { deck.kind = DeckKind::Normal(NormalDeck { config_id: 1, ..Default::default() }) } DeckKind::Filtered(_) => (), } } } /// Because the legacy exporter relied on the importer handling filtered /// decks by converting them into regular ones, there are two scenarios to /// watch out for: /// 1. If exported without scheduling, cards have been reset, but their deck /// ids may point to filtered decks. /// 2. If exported with scheduling, cards have not been reset, but their /// original deck ids may point to missing decks. fn enables_filtered_decks(&self) -> bool { self.cards .iter() .all(|c| self.card_and_its_deck_are_normal(c) || self.original_deck_exists(c)) } fn card_and_its_deck_are_normal(&self, card: &Card) -> bool { card.original_deck_id.0 == 0 && self .decks .iter() .find(|d| d.id == card.deck_id) .map(|d| !d.is_filtered()) .unwrap_or_default() } fn original_deck_exists(&self, card: &Card) -> bool { card.original_deck_id.0 == 1 || self.decks.iter().any(|d| d.id == card.original_deck_id) } fn reset_cards(&mut self, col: &Collection) { let mut position = col.get_next_card_position(); for card in self.cards.iter_mut() { // schedule_as_new() removes cards from filtered decks, but we want to // leave cards in their current deck, which gets converted to a regular one let deck_id = card.deck_id; if card.schedule_as_new(position, true, true) { position += 1; } card.flags = 0; card.deck_id = deck_id; } } fn restore_cards_from_filtered_decks(&mut self) { for card in self.cards.iter_mut() { if card.is_filtered() { // instead of moving between decks, the deck is converted to a regular one card.original_deck_id = card.deck_id; card.remove_from_filtered_deck_restoring_queue(); } } } fn check_ids(&self) -> Result<()> { let tomorrow = TimestampMillis::now().adding_secs(86_400).0; if self .cards .iter() .map(|card| card.id.0) .chain(self.notes.iter().map(|note| note.id.0)) .chain(self.revlog.iter().map(|entry| entry.id.0)) .any(|timestamp| timestamp > tomorrow) { Err(AnkiError::InvalidId) } else { Ok(()) } } } fn gather_media_names_from_note( note: &Note, inserter: &mut impl FnMut(String), svg_getter: &impl Fn(NotetypeId) -> bool, ) { for field in note.fields() { for media_ref in extract_media_refs(field) { inserter(media_ref.fname_decoded.to_string()); } for latex in extract_latex(field, svg_getter(note.notetype_id)).1 { inserter(latex.fname); } } } fn svg_getter(notetypes: &[Notetype]) -> impl Fn(NotetypeId) -> bool { let svg_map: HashMap = notetypes .iter() .map(|nt| (nt.id, nt.config.latex_svg)) .collect(); move |nt_id| svg_map.get(&nt_id).copied().unwrap_or_default() } impl Collection { fn gather_notes( &mut self, search: impl TryIntoSearch, ) -> Result<(Vec, NoteTableGuard<'_>)> { let guard = self.search_notes_into_table(search)?; guard .col .storage .all_searched_notes() .map(|notes| (notes, guard)) } fn gather_cards(&mut self) -> Result<(Vec, CardTableGuard<'_>)> { let guard = self.search_cards_of_notes_into_table()?; guard .col .storage .all_searched_cards() .map(|cards| (cards, guard)) } /// If with_original, also gather all original decks of cards in filtered /// decks, so they don't have to be converted to regular decks on import. /// If skip_default, skip exporting the default deck to avoid /// changing the importing client's defaults. fn gather_decks(&mut self, with_original: bool, skip_default: bool) -> Result> { let decks = if with_original { self.storage.get_decks_and_original_for_search_cards() } else { self.storage.get_decks_for_search_cards() }?; let parents = self.get_parent_decks(&decks)?; Ok(decks .into_iter() .chain(parents) .filter(|deck| !(skip_default && deck.id.0 == 1)) .collect()) } fn get_parent_decks(&mut self, decks: &[Deck]) -> Result> { let mut parent_names: HashSet = decks .iter() .map(|deck| deck.name.as_native_str().to_owned()) .collect(); let mut parents = Vec::new(); for deck in decks { self.add_parent_decks(deck.name.as_native_str(), &mut parent_names, &mut parents)?; } Ok(parents) } fn add_parent_decks( &mut self, name: &str, parent_names: &mut HashSet, parents: &mut Vec, ) -> Result<()> { if let Some(parent_name) = immediate_parent_name(name) { if parent_names.insert(parent_name.to_owned()) { if let Some(parent) = self.storage.get_deck_by_name(parent_name)? { parents.push(parent); self.add_parent_decks(parent_name, parent_names, parents)?; } } } Ok(()) } fn gather_notetypes(&mut self) -> Result> { self.storage.get_notetypes_for_search_notes() } fn gather_revlog(&mut self) -> Result> { self.storage.get_revlog_entries_for_searched_cards() } fn gather_deck_configs(&mut self, decks: &[Deck]) -> Result> { decks .iter() .filter_map(|deck| deck.config_id()) .unique() .map(|config_id| { self.storage .get_deck_config(config_id)? .or_not_found(config_id) }) .collect() } } #[cfg(test)] mod test { use super::*; use crate::search::SearchNode; #[test] fn should_gather_valid_notes() { let mut data = ExchangeData::default(); let mut col = Collection::new(); let note = NoteAdder::basic(&mut col).add(&mut col); data.gather_data(&mut col, SearchNode::WholeCollection, true, true) .unwrap(); assert_eq!(data.notes, [note]); } #[test] fn should_err_if_note_has_invalid_id() { let mut data = ExchangeData::default(); let mut col = Collection::new(); let now_micros = TimestampMillis::now().0 * 1000; let mut note = NoteAdder::basic(&mut col).add(&mut col); note.id = NoteId(now_micros); col.add_note_only_with_id_undoable(&mut note).unwrap(); assert!(data .gather_data(&mut col, SearchNode::WholeCollection, true, true) .is_err()); } } ================================================ FILE: rslib/src/import_export/insert.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::gather::ExchangeData; use crate::prelude::*; use crate::revlog::RevlogEntry; impl Collection { pub(super) fn insert_data(&mut self, data: &ExchangeData) -> Result<()> { self.transact_no_undo(|col| { col.insert_decks(&data.decks)?; col.insert_notes(&data.notes)?; col.insert_cards(&data.cards)?; col.insert_notetypes(&data.notetypes)?; col.insert_revlog(&data.revlog)?; col.insert_deck_configs(&data.deck_configs) }) } fn insert_decks(&self, decks: &[Deck]) -> Result<()> { for deck in decks { self.storage.add_or_update_deck_with_existing_id(deck)?; } Ok(()) } fn insert_notes(&self, notes: &[Note]) -> Result<()> { for note in notes { self.storage.add_or_update_note(note)?; } Ok(()) } fn insert_cards(&self, cards: &[Card]) -> Result<()> { for card in cards { self.storage.add_or_update_card(card)?; } Ok(()) } fn insert_notetypes(&self, notetypes: &[Notetype]) -> Result<()> { for notetype in notetypes { self.storage .add_or_update_notetype_with_existing_id(notetype)?; } Ok(()) } fn insert_revlog(&self, revlog: &[RevlogEntry]) -> Result<()> { for entry in revlog { self.storage.add_revlog_entry(entry, false)?; } Ok(()) } fn insert_deck_configs(&self, configs: &[DeckConfig]) -> Result<()> { for config in configs { self.storage .add_or_update_deck_config_with_existing_id(config)?; } Ok(()) } } ================================================ FILE: rslib/src/import_export/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod gather; mod insert; pub mod package; mod service; pub mod text; pub use anki_proto::import_export::import_response::Log as NoteLog; pub use anki_proto::import_export::import_response::Note as LogNote; use snafu::Snafu; use crate::prelude::*; use crate::text::newlines_to_spaces; use crate::text::strip_html_preserving_media_filenames; use crate::text::truncate_to_char_boundary; use crate::text::CowMapping; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ImportProgress { #[default] Extracting, File, Gathering, Media(usize), MediaCheck(usize), Notes(usize), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ExportProgress { #[default] File, Gathering, Notes(usize), Cards(usize), Media(usize), } impl Note { pub(crate) fn into_log_note(self) -> LogNote { LogNote { id: Some(anki_proto::notes::NoteId { nid: self.id.0 }), fields: self .into_fields() .into_iter() .map(|field| { let mut reduced = strip_html_preserving_media_filenames(&field) .map_cow(newlines_to_spaces) .get_owned() .unwrap_or(field); truncate_to_char_boundary(&mut reduced, 80); reduced }) .collect(), } } } #[derive(Debug, PartialEq, Eq, Clone, Snafu)] pub enum ImportError { Corrupt, TooNew, MediaImportFailed { info: String, }, NoFieldColumn, EmptyFile, /// Two notetypes could not be merged because one was a regular one and the /// other one a cloze notetype. NotetypeKindMergeConflict, } impl ImportError { pub(crate) fn message(&self, tr: &I18n) -> String { match self { ImportError::Corrupt => tr.importing_the_provided_file_is_not_a(), ImportError::TooNew => tr.errors_collection_too_new(), ImportError::MediaImportFailed { info } => { tr.importing_failed_to_import_media_file(info) } ImportError::NoFieldColumn => tr.importing_file_must_contain_field_column(), ImportError::EmptyFile => tr.importing_file_empty(), ImportError::NotetypeKindMergeConflict => { tr.importing_cannot_merge_notetypes_of_different_kinds() } } .into() } } ================================================ FILE: rslib/src/import_export/package/apkg/export.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; use anki_io::atomic_rename; use anki_io::new_tempfile; use anki_io::new_tempfile_in_parent_of; use super::super::meta::MetaExt; use crate::collection::CollectionBuilder; use crate::import_export::gather::ExchangeData; use crate::import_export::package::colpkg::export::export_collection; use crate::import_export::package::media::MediaIter; use crate::import_export::package::ExportAnkiPackageOptions; use crate::import_export::package::Meta; use crate::import_export::ExportProgress; use crate::prelude::*; use crate::progress::ThrottlingProgressHandler; impl Collection { /// Returns number of exported notes. pub fn export_apkg( &mut self, out_path: impl AsRef, options: ExportAnkiPackageOptions, search: impl TryIntoSearch, media_fn: Option) -> MediaIter>>, ) -> Result { let mut progress = self.new_progress_handler(); let temp_apkg = new_tempfile_in_parent_of(out_path.as_ref())?; let mut temp_col = new_tempfile()?; let temp_col_path = temp_col .path() .to_str() .or_invalid("non-unicode filename")?; let meta = if options.legacy { Meta::new_legacy() } else { Meta::new() }; let data = self.export_into_collection_file(&meta, temp_col_path, options, search, &mut progress)?; progress.set(ExportProgress::File)?; let media = if let Some(media_fn) = media_fn { media_fn(data.media_filenames) } else { MediaIter::from_file_list(data.media_filenames, self.media_folder.clone()) }; let col_size = temp_col.as_file().metadata()?.len() as usize; export_collection( meta, temp_apkg.path(), &mut temp_col, col_size, media, &self.tr, &mut progress, )?; atomic_rename(temp_apkg, out_path.as_ref(), true)?; Ok(data.notes.len()) } fn export_into_collection_file( &mut self, meta: &Meta, path: &str, options: ExportAnkiPackageOptions, search: impl TryIntoSearch, progress: &mut ThrottlingProgressHandler, ) -> Result { let mut data = ExchangeData::default(); progress.set(ExportProgress::Gathering)?; data.gather_data( self, search, options.with_scheduling, options.with_deck_configs, )?; if options.with_media { data.gather_media_names(progress)?; } let mut temp_col = Collection::new_minimal(path)?; progress.set(ExportProgress::File)?; temp_col.insert_data(&data)?; temp_col.set_creation_stamp(self.storage.creation_stamp()?)?; temp_col.set_creation_utc_offset(data.creation_utc_offset)?; temp_col.close(Some(meta.schema_version()))?; Ok(data) } fn new_minimal(path: impl Into) -> Result { let col = CollectionBuilder::new(path).build()?; col.storage.db.execute_batch("DELETE FROM notetypes")?; Ok(col) } } ================================================ FILE: rslib/src/import_export/package/apkg/import/cards.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::collections::HashSet; use std::mem; use super::Context; use super::TemplateMap; use crate::card::CardQueue; use crate::card::CardType; use crate::config::SchedulerVersion; use crate::prelude::*; use crate::revlog::RevlogEntry; type CardAsNidAndOrd = (NoteId, u16); struct CardContext<'a> { target_col: &'a mut Collection, usn: Usn, imported_notes: &'a HashMap, notetype_map: &'a HashMap, remapped_templates: &'a HashMap, remapped_decks: &'a HashMap, /// The number of days the source collection is ahead of the target /// collection collection_delta: i32, scheduler_version: SchedulerVersion, existing_cards: HashSet, existing_card_ids: HashSet, imported_cards: HashMap, } impl<'c> CardContext<'c> { fn new<'a: 'c>( usn: Usn, days_elapsed: u32, target_col: &'a mut Collection, imported_notes: &'a HashMap, notetype_map: &'a HashMap, remapped_templates: &'a HashMap, imported_decks: &'a HashMap, ) -> Result { let existing_cards = target_col.storage.all_cards_as_nid_and_ord()?; let collection_delta = target_col.collection_delta(days_elapsed)?; let scheduler_version = target_col.scheduler_info()?.version; let existing_card_ids = target_col.storage.get_all_card_ids()?; Ok(Self { target_col, usn, imported_notes, notetype_map, remapped_templates, remapped_decks: imported_decks, existing_cards, collection_delta, scheduler_version, existing_card_ids, imported_cards: HashMap::new(), }) } } impl Collection { /// How much `days_elapsed` is ahead of this collection. fn collection_delta(&mut self, days_elapsed: u32) -> Result { Ok(days_elapsed as i32 - self.timing_today()?.days_elapsed as i32) } } impl Context<'_> { pub(super) fn import_cards_and_revlog( &mut self, imported_notes: &HashMap, notetype_map: &HashMap, remapped_templates: &HashMap, imported_decks: &HashMap, ) -> Result<()> { let mut ctx = CardContext::new( self.usn, self.data.days_elapsed, self.target_col, imported_notes, notetype_map, remapped_templates, imported_decks, )?; if ctx.scheduler_version == SchedulerVersion::V1 { return Err(AnkiError::SchedulerUpgradeRequired); } ctx.import_cards(mem::take(&mut self.data.cards))?; ctx.import_revlog(mem::take(&mut self.data.revlog)) } } impl CardContext<'_> { fn import_cards(&mut self, mut cards: Vec) -> Result<()> { for card in &mut cards { if self.map_to_imported_note(card) && !self.card_ordinal_already_exists(card) { self.add_card(card)?; } // TODO: could update existing card } Ok(()) } fn import_revlog(&mut self, revlog: Vec) -> Result<()> { for mut entry in revlog { if let Some(cid) = self.imported_cards.get(&entry.cid) { entry.cid = *cid; entry.usn = self.usn; self.target_col.add_revlog_entry_if_unique_undoable(entry)?; } } Ok(()) } fn map_to_imported_note(&self, card: &mut Card) -> bool { if let Some(nid) = self.imported_notes.get(&card.note_id) { card.note_id = *nid; true } else { false } } fn card_ordinal_already_exists(&self, card: &Card) -> bool { self.existing_cards .contains(&(card.note_id, card.template_idx)) } fn add_card(&mut self, card: &mut Card) -> Result<()> { card.usn = self.usn; self.remap_deck_ids(card); self.remap_template_index(card); card.shift_collection_relative_dates(self.collection_delta); let old_id = self.uniquify_card_id(card); self.target_col.add_card_if_unique_undoable(card)?; self.existing_card_ids.insert(card.id); self.imported_cards.insert(old_id, card.id); Ok(()) } fn uniquify_card_id(&mut self, card: &mut Card) -> CardId { let original = card.id; while self.existing_card_ids.contains(&card.id) { card.id.0 += 999; } original } fn remap_deck_ids(&self, card: &mut Card) { if let Some(did) = self.remapped_decks.get(&card.deck_id) { card.deck_id = *did; } if let Some(did) = self.remapped_decks.get(&card.original_deck_id) { card.original_deck_id = *did; } } fn remap_template_index(&self, card: &mut Card) { card.template_idx = self .notetype_map .get(&card.note_id) .and_then(|ntid| self.remapped_templates.get(ntid)) .and_then(|map| map.get(&card.template_idx)) .copied() .unwrap_or(card.template_idx); } } impl Card { /// `delta` is the number days the card's source collection is ahead of the /// target collection. fn shift_collection_relative_dates(&mut self, delta: i32) { if self.due_in_days_since_collection_creation() { self.due -= delta; } if self.original_due_in_days_since_collection_creation() && self.original_due != 0 { self.original_due -= delta; } } fn due_in_days_since_collection_creation(&self) -> bool { matches!(self.queue, CardQueue::Review | CardQueue::DayLearn) || self.ctype == CardType::Review } fn original_due_in_days_since_collection_creation(&self) -> bool { self.ctype == CardType::Review } } ================================================ FILE: rslib/src/import_export/package/apkg/import/decks.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::mem; use super::Context; use crate::decks::NormalDeck; use crate::decks::NormalDeckDayLimit; use crate::prelude::*; struct DeckContext<'d> { target_col: &'d mut Collection, usn: Usn, renamed_parents: Vec<(String, String)>, imported_decks: HashMap, unique_suffix: String, source_col_today: u32, } impl<'d> DeckContext<'d> { fn new<'a: 'd>(target_col: &'a mut Collection, usn: Usn, source_col_today: u32) -> Self { Self { target_col, usn, renamed_parents: Vec::new(), imported_decks: HashMap::new(), unique_suffix: TimestampSecs::now().to_string(), source_col_today, } } } impl Context<'_> { pub(super) fn import_decks_and_configs(&mut self) -> Result> { let mut ctx = DeckContext::new(self.target_col, self.usn, self.data.days_elapsed); ctx.import_deck_configs(mem::take(&mut self.data.deck_configs))?; ctx.import_decks(mem::take(&mut self.data.decks))?; Ok(ctx.imported_decks) } } impl DeckContext<'_> { fn import_deck_configs(&mut self, mut configs: Vec) -> Result<()> { for config in &mut configs { config.usn = self.usn; self.target_col.add_deck_config_if_unique_undoable(config)?; } Ok(()) } fn import_decks(&mut self, mut decks: Vec) -> Result<()> { // ensure parents are seen before children decks.sort_unstable_by_key(|deck| deck.level()); for deck in &mut decks { self.maybe_reparent(deck); self.maybe_correct_day_limits(deck)?; self.import_deck(deck)?; } Ok(()) } fn import_deck(&mut self, deck: &mut Deck) -> Result<()> { if let Some(original) = self.get_deck_by_name(deck)? { if original.is_same_kind(deck) { return self.update_deck(deck, original); } else { self.uniquify_name(deck); } } self.ensure_valid_first_existing_parent(deck)?; self.add_deck(deck) } fn maybe_reparent(&self, deck: &mut Deck) { if let Some(new_name) = self.reparented_name(deck.name.as_native_str()) { deck.name = NativeDeckName::from_native_str(new_name); } } fn reparented_name(&self, name: &str) -> Option { self.renamed_parents .iter() .find_map(|(old_parent, new_parent)| { name.starts_with(old_parent) .then(|| name.replacen(old_parent, new_parent, 1)) }) } fn maybe_correct_day_limits(&mut self, deck: &mut Deck) -> Result<()> { if let Ok(normal) = deck.normal_mut() { let target_col_today = self.target_col.timing_today()?.days_elapsed; let op = |mut limit: NormalDeckDayLimit| { if limit.today == self.source_col_today { // imported deck has an active today limit, map it to target col limit.today = target_col_today; Some(limit) } else if target_col_today > 0 { // imported deck's today limit was not active limit.today = limit.today.min(target_col_today - 1); Some(limit) } else { // edge case where target collection is new (day 0), clear saved limit None } }; normal.new_limit_today = normal.new_limit_today.and_then(op); normal.review_limit_today = normal.review_limit_today.and_then(op); } Ok(()) } fn get_deck_by_name(&mut self, deck: &Deck) -> Result> { self.target_col .storage .get_deck_by_name(deck.name.as_native_str()) } fn uniquify_name(&mut self, deck: &mut Deck) { let old_parent = format!("{}\x1f", deck.name.as_native_str()); deck.uniquify_name(&self.unique_suffix); let new_parent = format!("{}\x1f", deck.name.as_native_str()); self.renamed_parents.push((old_parent, new_parent)); } fn add_deck(&mut self, deck: &mut Deck) -> Result<()> { let old_id = mem::take(&mut deck.id); self.target_col.add_deck_inner(deck, self.usn)?; self.imported_decks.insert(old_id, deck.id); Ok(()) } /// Caller must ensure decks are of the same kind. fn update_deck(&mut self, deck: &Deck, original: Deck) -> Result<()> { let mut new_deck = original.clone(); if let (Ok(new), Ok(old)) = (new_deck.normal_mut(), deck.normal()) { update_normal_with_other(new, old); } else if let (Ok(new), Ok(old)) = (new_deck.filtered_mut(), deck.filtered()) { *new = old.clone(); } else { invalid_input!("decks have different kinds"); } self.imported_decks.insert(deck.id, new_deck.id); self.target_col .update_deck_inner(&mut new_deck, original, self.usn) } fn ensure_valid_first_existing_parent(&mut self, deck: &mut Deck) -> Result<()> { if let Some(ancestor) = self .target_col .first_existing_parent(deck.name.as_native_str(), 0)? { if ancestor.is_filtered() { self.add_unique_default_deck(ancestor.name.as_native_str())?; self.maybe_reparent(deck); } } Ok(()) } fn add_unique_default_deck(&mut self, name: &str) -> Result<()> { let mut deck = Deck::new_normal(); deck.name = NativeDeckName::from_native_str(name); self.uniquify_name(&mut deck); self.target_col.add_deck_inner(&mut deck, self.usn) } } impl Deck { fn uniquify_name(&mut self, suffix: &str) { let new_name = format!("{} {}", self.name.as_native_str(), suffix); self.name = NativeDeckName::from_native_str(new_name); } fn level(&self) -> usize { self.name.components().count() } fn is_same_kind(&self, other: &Self) -> bool { self.is_filtered() == other.is_filtered() } } fn update_normal_with_other(normal: &mut NormalDeck, other: &NormalDeck) { if !other.description.is_empty() { normal.markdown_description = other.markdown_description; normal.description.clone_from(&other.description); } if other.config_id != 1 { normal.config_id = other.config_id; } normal.review_limit = other.review_limit.or(normal.review_limit); normal.new_limit = other.new_limit.or(normal.new_limit); normal.review_limit_today = other.review_limit_today.or(normal.review_limit_today); normal.new_limit_today = other.new_limit_today.or(normal.new_limit_today); } #[cfg(test)] mod test { use std::collections::HashSet; use super::*; #[test] fn parents() { let mut col = Collection::new(); DeckAdder::new("filtered").filtered(true).add(&mut col); DeckAdder::new("PARENT").add(&mut col); let mut ctx = DeckContext::new(&mut col, Usn(1), 0); ctx.unique_suffix = "★".to_string(); let imports = vec![ DeckAdder::new("unknown parent::child").deck(), DeckAdder::new("filtered::child").deck(), DeckAdder::new("parent::child").deck(), DeckAdder::new("NEW PARENT::child").deck(), DeckAdder::new("new parent").deck(), ]; ctx.import_decks(imports).unwrap(); let existing_decks: HashSet<_> = ctx .target_col .get_all_deck_names(true) .unwrap() .into_iter() .map(|(_, name)| name) .collect(); // missing parents get created assert!(existing_decks.contains("unknown parent")); // ... and uniquified if their existing counterparts are filtered assert!(existing_decks.contains("filtered ★")); assert!(existing_decks.contains("filtered ★::child")); // the case of existing parents is matched assert!(existing_decks.contains("PARENT::child")); // the case of imported parents is matched, regardless of pass order assert!(existing_decks.contains("new parent::child")); } #[test] fn day_limits_should_carry_over_correctly() { let mut col = Collection::new(); let importing_col_today = col.timing_today().unwrap().days_elapsed; let exporting_col_today = importing_col_today + 100; let deck_name = "blah"; let mut exported_deck = DeckAdder::new(deck_name).filtered(false).deck(); let normal = exported_deck.normal_mut().unwrap(); normal.new_limit_today = Some(NormalDeckDayLimit { limit: 123, today: exporting_col_today, }); normal.review_limit_today = Some(NormalDeckDayLimit { limit: 456, today: exporting_col_today - 100, }); let mut ctx = DeckContext::new(&mut col, Usn(1), exporting_col_today); ctx.import_decks(vec![exported_deck]).unwrap(); let imported_deck_id = ctx.target_col.get_deck_id(deck_name).unwrap().unwrap(); let imported_deck = ctx.target_col.get_deck(imported_deck_id).unwrap().unwrap(); let imported_deck = imported_deck.normal().unwrap(); // active day limit should carry over regardless of collection age assert!( matches!(imported_deck.new_limit_today, Some(NormalDeckDayLimit { limit: 123, today }) if today == importing_col_today) ); // target_col's today is 0, expect the day limit to be cleared assert!(imported_deck.review_limit_today.is_none()) } } ================================================ FILE: rslib/src/import_export/package/apkg/import/media.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::fs::File; use std::mem; use anki_io::FileIoSnafu; use anki_io::FileOp; use zip::ZipArchive; use super::super::super::meta::MetaExt; use super::Context; use crate::import_export::package::media::extract_media_entries; use crate::import_export::package::media::MediaCopier; use crate::import_export::package::media::SafeMediaEntry; use crate::import_export::ImportProgress; use crate::media::files::add_hash_suffix_to_file_stem; use crate::media::files::sha1_of_reader; use crate::media::Checksums; use crate::prelude::*; use crate::progress::ThrottlingProgressHandler; /// Map of source media files, that do not already exist in the target. #[derive(Debug, Default)] pub(super) struct MediaUseMap { /// original, normalized filename → (refererenced on import material, /// entry with possibly remapped filename) checked: HashMap, /// Static files (latex, underscored). Usage is not tracked, and if the name /// already exists in the target, it is skipped regardless of content /// equality. unchecked: Vec, } impl Context<'_> { pub(super) fn prepare_media(&mut self) -> Result { let media_entries = extract_media_entries(&self.meta, &mut self.archive)?; if media_entries.is_empty() { return Ok(MediaUseMap::default()); } let db_progress_fn = self.progress.media_db_fn(ImportProgress::MediaCheck)?; let existing_sha1s = self .media_manager .all_checksums_after_checking(db_progress_fn)?; prepare_media( media_entries, &mut self.archive, &existing_sha1s, &mut self.progress, ) } pub(super) fn copy_media(&mut self, media_map: &mut MediaUseMap) -> Result<()> { let mut incrementor = self.progress.incrementor(ImportProgress::Media); let mut copier = MediaCopier::new(false); self.media_manager.transact(|_db| { for entry in media_map.used_entries() { incrementor.increment()?; entry.copy_and_ensure_sha1_set( &mut self.archive, &self.target_col.media_folder, &mut copier, self.meta.zstd_compressed(), )?; self.media_manager .add_entry(&entry.name, entry.sha1.unwrap())?; } Ok(()) }) } } fn prepare_media( media_entries: Vec, archive: &mut ZipArchive, existing_sha1s: &Checksums, progress: &mut ThrottlingProgressHandler, ) -> Result { let mut media_map = MediaUseMap::default(); let mut incrementor = progress.incrementor(ImportProgress::MediaCheck); for mut entry in media_entries { incrementor.increment()?; if entry.is_static() { if !existing_sha1s.contains_key(&entry.name) { media_map.unchecked.push(entry); } } else if let Some(other_sha1) = existing_sha1s.get(&entry.name) { entry.ensure_sha1_set(archive)?; if entry.sha1.unwrap() != *other_sha1 { let original_name = entry.uniquify_name(); media_map.add_checked(original_name, entry); } } else { media_map.add_checked(entry.name.clone(), entry); } } Ok(media_map) } impl MediaUseMap { pub(super) fn add_checked(&mut self, filename: impl Into, entry: SafeMediaEntry) { self.checked.insert(filename.into(), (false, entry)); } pub(super) fn use_entry(&mut self, filename: &str) -> Option<&SafeMediaEntry> { self.checked.get_mut(filename).map(|(used, entry)| { *used = true; &*entry }) } pub(super) fn used_entries(&mut self) -> impl Iterator { self.checked .values_mut() .filter_map(|(used, entry)| used.then(|| entry)) .chain(self.unchecked.iter_mut()) } } impl SafeMediaEntry { fn ensure_sha1_set(&mut self, archive: &mut ZipArchive) -> Result<()> { if self.sha1.is_none() { let mut reader = self.fetch_file(archive)?; self.sha1 = Some(sha1_of_reader(&mut reader).context(FileIoSnafu { path: &self.name, op: FileOp::Read, })?); } Ok(()) } /// Requires sha1 to be set. Returns old file name. fn uniquify_name(&mut self) -> String { let new_name = add_hash_suffix_to_file_stem(&self.name, &self.sha1.expect("sha1 not set")); mem::replace(&mut self.name, new_name) } fn is_static(&self) -> bool { self.name.starts_with('_') || self.name.starts_with("latex-") } } ================================================ FILE: rslib/src/import_export/package/apkg/import/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod cards; mod decks; mod media; mod notes; use std::fs::File; use std::path::Path; use anki_io::new_tempfile; use anki_io::open_file; use anki_io::FileIoSnafu; use anki_io::FileOp; pub(crate) use notes::NoteMeta; use rusqlite::OptionalExtension; use tempfile::NamedTempFile; use zip::ZipArchive; use super::super::meta::MetaExt; use crate::collection::CollectionBuilder; use crate::config::ConfigKey; use crate::import_export::gather::ExchangeData; use crate::import_export::package::ImportAnkiPackageOptions; use crate::import_export::package::Meta; use crate::import_export::package::UpdateCondition; use crate::import_export::ImportProgress; use crate::import_export::NoteLog; use crate::media::MediaManager; use crate::prelude::*; use crate::progress::ThrottlingProgressHandler; use crate::search::SearchNode; /// A map of old to new template indices for a given notetype. type TemplateMap = std::collections::HashMap; struct Context<'a> { target_col: &'a mut Collection, merge_notetypes: bool, update_notes: UpdateCondition, update_notetypes: UpdateCondition, media_manager: MediaManager, archive: ZipArchive, meta: Meta, data: ExchangeData, usn: Usn, progress: ThrottlingProgressHandler, } impl Collection { pub fn import_apkg( &mut self, path: impl AsRef, options: ImportAnkiPackageOptions, ) -> Result> { let file = open_file(path)?; let archive = ZipArchive::new(file)?; let progress = self.new_progress_handler(); self.transact(Op::Import, |col| { col.set_config(BoolKey::MergeNotetypes, &options.merge_notetypes)?; col.set_config(BoolKey::WithScheduling, &options.with_scheduling)?; col.set_config(BoolKey::WithDeckConfigs, &options.with_deck_configs)?; col.set_config(ConfigKey::UpdateNotes, &options.update_notes())?; col.set_config(ConfigKey::UpdateNotetypes, &options.update_notetypes())?; let mut ctx = Context::new(archive, col, options, progress)?; ctx.import() }) } } impl<'a> Context<'a> { fn new( mut archive: ZipArchive, target_col: &'a mut Collection, options: ImportAnkiPackageOptions, mut progress: ThrottlingProgressHandler, ) -> Result { let media_manager = target_col.media()?; let meta = Meta::from_archive(&mut archive)?; let data = ExchangeData::gather_from_archive( &mut archive, &meta, SearchNode::WholeCollection, &mut progress, options.with_scheduling, options.with_deck_configs, )?; let usn = target_col.usn()?; Ok(Self { target_col, merge_notetypes: options.merge_notetypes, update_notes: options.update_notes(), update_notetypes: options.update_notetypes(), media_manager, archive, meta, data, usn, progress, }) } fn import(&mut self) -> Result { let notetypes = self .data .notes .iter() .map(|n| (n.id, n.notetype_id)) .collect(); let mut media_map = self.prepare_media()?; let note_imports = self.import_notes_and_notetypes(&mut media_map)?; let imported_decks = self.import_decks_and_configs()?; self.import_cards_and_revlog( ¬e_imports.id_map, ¬etypes, ¬e_imports.remapped_templates, &imported_decks, )?; self.copy_media(&mut media_map)?; Ok(note_imports.log) } } impl ExchangeData { fn gather_from_archive( archive: &mut ZipArchive, meta: &Meta, search: impl TryIntoSearch, progress: &mut ThrottlingProgressHandler, with_scheduling: bool, with_deck_configs: bool, ) -> Result { let tempfile = collection_to_tempfile(meta, archive)?; let mut col = CollectionBuilder::new(tempfile.path()).build()?; col.maybe_fix_invalid_ids()?; col.maybe_upgrade_scheduler()?; progress.set(ImportProgress::Gathering)?; let mut data = ExchangeData::default(); data.gather_data(&mut col, search, with_scheduling, with_deck_configs)?; Ok(data) } } fn collection_to_tempfile(meta: &Meta, archive: &mut ZipArchive) -> Result { let mut zip_file = archive.by_name(meta.collection_filename())?; let mut tempfile = new_tempfile()?; meta.copy(&mut zip_file, &mut tempfile) .with_context(|_| FileIoSnafu { path: tempfile.path(), op: FileOp::copy(zip_file.name()), })?; Ok(tempfile) } impl Collection { fn maybe_upgrade_scheduler(&mut self) -> Result<()> { if self.scheduling_included()? { self.upgrade_to_v2_scheduler()?; } Ok(()) } fn scheduling_included(&mut self) -> Result { const SQL: &str = "SELECT 1 FROM cards WHERE queue != 0"; Ok(self .storage .db .query_row(SQL, [], |_| Ok(())) .optional()? .is_some()) } } ================================================ FILE: rslib/src/import_export/package/apkg/import/notes.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; use std::mem; use std::sync::Arc; use super::media::MediaUseMap; use super::Context; use super::TemplateMap; use crate::import_export::package::media::safe_normalized_file_name; use crate::import_export::package::UpdateCondition; use crate::import_export::ImportError; use crate::import_export::ImportProgress; use crate::import_export::NoteLog; use crate::notes::UpdateNoteInnerWithoutCardsArgs; use crate::notetype::ChangeNotetypeInput; use crate::prelude::*; use crate::progress::ThrottlingProgressHandler; use crate::text::replace_media_refs; #[derive(Debug)] struct NoteContext<'a> { target_col: &'a mut Collection, usn: Usn, normalize_notes: bool, remapped_notetypes: HashMap, remapped_fields: HashMap>>, target_ids: HashSet, target_notetypes: Vec>, media_map: &'a mut MediaUseMap, merge_notetypes: bool, update_notes: UpdateCondition, update_notetypes: UpdateCondition, imports: NoteImports, // notetypes that have been merged into others and may now possibly be deleted merged_notetypes: HashSet, } #[derive(Debug, Default)] pub(super) struct NoteImports { pub(super) id_map: HashMap, pub(super) remapped_templates: HashMap, /// All notes from the source collection as [Vec]s of their fields, and /// grouped by import result kind. pub(super) log: NoteLog, } impl NoteImports { fn log_new(&mut self, note: Note, source_id: NoteId) { self.id_map.insert(source_id, note.id); self.log.new.push(note.into_log_note()); } fn log_updated(&mut self, note: Note, source_id: NoteId) { self.id_map.insert(source_id, note.id); self.log.updated.push(note.into_log_note()); } fn log_duplicate(&mut self, mut note: Note, target_id: NoteId) { self.id_map.insert(note.id, target_id); // id is for looking up note in *target* collection note.id = target_id; self.log.duplicate.push(note.into_log_note()); } fn log_conflicting(&mut self, note: Note) { self.log.conflicting.push(note.into_log_note()); } } #[derive(Debug, Clone, Copy)] pub(crate) struct NoteMeta { id: NoteId, mtime: TimestampSecs, notetype_id: NotetypeId, } impl NoteMeta { pub(crate) fn new(id: NoteId, mtime: TimestampSecs, notetype_id: NotetypeId) -> Self { Self { id, mtime, notetype_id, } } } impl Context<'_> { pub(super) fn import_notes_and_notetypes( &mut self, media_map: &mut MediaUseMap, ) -> Result { let mut ctx = NoteContext::new( self.usn, self.target_col, media_map, self.merge_notetypes, self.update_notes, self.update_notetypes, )?; ctx.import_notetypes(mem::take(&mut self.data.notetypes))?; ctx.import_notes(mem::take(&mut self.data.notes), &mut self.progress)?; Ok(ctx.imports) } } impl<'n> NoteContext<'n> { fn new<'a: 'n>( usn: Usn, target_col: &'a mut Collection, media_map: &'a mut MediaUseMap, merge_notetypes: bool, update_notes: UpdateCondition, update_notetypes: UpdateCondition, ) -> Result { let normalize_notes = target_col.get_config_bool(BoolKey::NormalizeNoteText); let target_ids = target_col.storage.get_all_note_ids()?; let target_notetypes = target_col.get_all_notetypes()?; Ok(Self { target_col, usn, normalize_notes, remapped_notetypes: HashMap::new(), remapped_fields: HashMap::new(), target_ids, target_notetypes, imports: NoteImports::default(), merge_notetypes, update_notes, update_notetypes, media_map, merged_notetypes: HashSet::new(), }) } fn import_notetypes(&mut self, mut notetypes: Vec) -> Result<()> { for notetype in &mut notetypes { notetype.config.original_id.replace(notetype.id.0); if let Some(nt) = self.get_target_notetype(notetype.id) { let existing = nt.as_ref().clone(); if self.merge_notetypes { self.update_or_merge_notetype(notetype, existing)?; } else { self.update_or_duplicate_notetype(notetype, existing)?; } } else { self.add_notetype(notetype)?; } } Ok(()) } fn get_target_notetype(&self, ntid: NotetypeId) -> Option<&Arc> { self.target_notetypes.iter().find(|nt| nt.id == ntid) } fn update_or_duplicate_notetype( &mut self, incoming: &mut Notetype, mut existing: Notetype, ) -> Result<()> { if !existing.equal_schema(incoming) { if let Some(nt) = self.get_previously_duplicated_notetype(incoming) { existing = nt; self.remapped_notetypes.insert(incoming.id, existing.id); incoming.id = existing.id; } else { return self.add_notetype_with_remapped_id(incoming); } } if should_update( self.update_notetypes, existing.mtime_secs, incoming.mtime_secs, ) { self.update_notetype(incoming, existing, false)?; } Ok(()) } /// Try to find a notetype with matching original id and schema. fn get_previously_duplicated_notetype(&self, original: &Notetype) -> Option { self.target_notetypes .iter() .find(|nt| { nt.id != original.id && nt.config.original_id == Some(original.id.0) && nt.equal_schema(original) }) .map(|nt| nt.as_ref().clone()) } fn should_update_notetype(&self, existing: &Notetype, incoming: &Notetype) -> bool { match self.update_notetypes { UpdateCondition::IfNewer => existing.mtime_secs < incoming.mtime_secs, UpdateCondition::Always => true, UpdateCondition::Never => false, } } fn add_notetype(&mut self, notetype: &mut Notetype) -> Result<()> { notetype.prepare_for_update(None, true)?; self.target_col .ensure_notetype_name_unique(notetype, self.usn)?; notetype.usn = self.usn; self.target_col .add_notetype_with_unique_id_undoable(notetype) } fn update_notetype( &mut self, notetype: &mut Notetype, original: Notetype, modified: bool, ) -> Result<()> { if modified { notetype.set_modified(self.usn); notetype.prepare_for_update(Some(&original), true)?; } else { notetype.usn = self.usn; } self.target_col .add_or_update_notetype_with_existing_id_inner(notetype, Some(original), self.usn, true) } fn update_or_merge_notetype( &mut self, incoming: &mut Notetype, mut existing: Notetype, ) -> Result<()> { if existing.is_cloze() != incoming.is_cloze() { return Err(ImportError::NotetypeKindMergeConflict.into()); } let original_existing = existing.clone(); // get and merge duplicated notetypes from previous no-merge imports let mut siblings = self.get_sibling_notetypes(existing.id); existing.merge_all(&siblings); incoming.merge(&existing); existing.merge(incoming); self.record_remapped_ords(incoming); let new_incoming = if self.should_update_notetype(&existing, incoming) { // ords must be existing's as they are used to remap note fields and card // template indices incoming.copy_ords(&existing); incoming } else { &mut existing }; self.update_notetype(new_incoming, original_existing, true)?; self.merge_sibling_notetypes(new_incoming, &mut siblings) } /// Get notetypes with different id, but matching original id. fn get_sibling_notetypes(&mut self, original_id: NotetypeId) -> Vec { self.target_notetypes .iter() .filter(|nt| nt.id != original_id && nt.config.original_id == Some(original_id.0)) .map(|nt| nt.as_ref().clone()) .collect() } /// Removes the sibling notetypes, changing their notes' notetype to /// `original`. This assumes `siblings` have already been merged into /// `original`. fn merge_sibling_notetypes( &mut self, original: &Notetype, siblings: &mut [Notetype], ) -> Result<()> { for nt in siblings { nt.merge(original); let note_ids = self.target_col.search_notes_unordered(nt.id)?; self.target_col .change_notetype_of_notes_inner(ChangeNotetypeInput { current_schema: self.target_col_schema_change()?, note_ids, old_notetype_name: nt.name.clone(), old_notetype_id: nt.id, new_notetype_id: original.id, new_fields: nt.field_ords_vec(), new_templates: Some(nt.template_ords_vec()), })?; self.merged_notetypes.insert(nt.id); } Ok(()) } fn target_col_schema_change(&self) -> Result { self.target_col .storage .get_collection_timestamps() .map(|ts| ts.schema_change) } /// Maintain map of ord changes in order to remap incoming note fields and /// cards. If called multiple times with the same notetype maps will be /// chained. fn record_remapped_ords(&mut self, incoming: &Notetype) { self.remapped_fields .entry(incoming.id) .and_modify(|old| { *old = combine_field_ords_maps(old, incoming.field_ords()); }) .or_insert(incoming.field_ords().collect()); self.imports .remapped_templates .entry(incoming.id) .and_modify(|old_map| { combine_template_ords_maps(old_map, incoming); }) .or_insert( incoming .template_ords() .enumerate() .filter_map(|(new, old)| old.map(|ord| (ord as u16, new as u16))) .collect(), ); } fn add_notetype_with_remapped_id(&mut self, notetype: &mut Notetype) -> Result<()> { let old_id = mem::take(&mut notetype.id); notetype.usn = self.usn; self.target_col .add_notetype_inner(notetype, self.usn, true)?; self.remapped_notetypes.insert(old_id, notetype.id); Ok(()) } fn import_notes( &mut self, notes: Vec, progress: &mut ThrottlingProgressHandler, ) -> Result<()> { let existing_guids = self.target_col.storage.note_guid_map()?; if self.merge_notetypes { self.resolve_notetype_conflicts(¬es, &existing_guids)?; } let mut incrementor = progress.incrementor(ImportProgress::Notes); self.imports.log.found_notes = notes.len() as u32; for mut note in notes { incrementor.increment()?; self.remap_notetype_and_fields(&mut note); if let Some(existing_note) = existing_guids.get(¬e.guid) { self.maybe_update_existing_note(*existing_note, note)?; } else { self.add_note(note)?; } } self.delete_merged_unused_notetypes() } fn resolve_notetype_conflicts( &mut self, incoming_notes: &[Note], existing_guids: &HashMap, ) -> Result<()> { for ((existing_ntid, incoming_ntid), note_ids) in notetype_conflicts(incoming_notes, existing_guids) { let original_existing = self .target_col .storage .get_notetype(existing_ntid)? .or_not_found(existing_ntid)?; let mut incoming = self .target_col .storage .get_notetype(incoming_ntid)? .or_not_found(incoming_ntid)?; if original_existing.is_cloze() != incoming.is_cloze() { return Err(ImportError::NotetypeKindMergeConflict.into()); } let mut existing = original_existing.clone(); existing.merge(&incoming); incoming.merge(&existing); self.record_remapped_ords(&incoming); let old_notetype_name = existing.name.clone(); let new_fields = existing.field_ords_vec(); let new_templates = Some(existing.template_ords_vec()); incoming.copy_ords(&existing); self.update_notetype(&mut incoming, original_existing, true)?; self.target_col .change_notetype_of_notes_inner(ChangeNotetypeInput { current_schema: self.target_col_schema_change()?, note_ids, old_notetype_name, old_notetype_id: existing_ntid, new_notetype_id: incoming_ntid, new_fields, new_templates, })?; self.merged_notetypes.insert(existing_ntid); } Ok(()) } fn remap_notetype_and_fields(&mut self, note: &mut Note) { if let Some(new_ords) = self.remapped_fields.get(¬e.notetype_id) { note.reorder_fields(new_ords); } if let Some(remapped_ntid) = self.remapped_notetypes.get(¬e.notetype_id) { note.notetype_id = *remapped_ntid; } } fn maybe_update_existing_note(&mut self, existing: NoteMeta, incoming: Note) -> Result<()> { if !self.merge_notetypes && incoming.notetype_id != existing.notetype_id { // notetype of existing note has changed, or notetype of incoming note has been // remapped due to a schema conflict self.imports.log_conflicting(incoming); } else if should_update(self.update_notes, existing.mtime, incoming.mtime) { self.update_note(incoming, existing.id)?; } else { // TODO: might still want to update merged in fields self.imports.log_duplicate(incoming, existing.id); } Ok(()) } fn add_note(&mut self, mut note: Note) -> Result<()> { self.munge_media(&mut note)?; self.target_col.canonify_note_tags(&mut note, self.usn)?; let notetype = self.get_expected_notetype(note.notetype_id)?; note.prepare_for_update(¬etype, self.normalize_notes)?; note.usn = self.usn; let old_id = self.uniquify_note_id(&mut note); self.target_col.add_note_only_with_id_undoable(&mut note)?; self.target_ids.insert(note.id); self.imports.log_new(note, old_id); Ok(()) } fn uniquify_note_id(&mut self, note: &mut Note) -> NoteId { let original = note.id; while self.target_ids.contains(¬e.id) { note.id.0 += 999; } original } fn get_expected_notetype(&mut self, ntid: NotetypeId) -> Result> { self.target_col.get_notetype(ntid)?.or_not_found(ntid) } fn get_expected_note(&mut self, nid: NoteId) -> Result { self.target_col.storage.get_note(nid)?.or_not_found(nid) } fn update_note(&mut self, mut note: Note, target_id: NoteId) -> Result<()> { let source_id = note.id; note.id = target_id; self.munge_media(&mut note)?; let original = self.get_expected_note(note.id)?; let notetype = self.get_expected_notetype(note.notetype_id)?; // Preserve the incoming note's mtime to allow imports of successive exports let incoming_mtime = note.mtime; self.target_col .update_note_inner_without_cards_using_mtime( UpdateNoteInnerWithoutCardsArgs { note: &mut note, original: &original, notetype: ¬etype, usn: self.usn, mark_note_modified: true, normalize_text: self.normalize_notes, update_tags: true, }, Some(incoming_mtime), )?; self.imports.log_updated(note, source_id); Ok(()) } fn munge_media(&mut self, note: &mut Note) -> Result<()> { for field in note.fields_mut() { if let Some(new_field) = self.replace_media_refs(field) { *field = new_field; }; } Ok(()) } fn replace_media_refs(&mut self, field: &mut str) -> Option { replace_media_refs(field, |name| { if let Ok(normalized) = safe_normalized_file_name(name) { if let Some(entry) = self.media_map.use_entry(&normalized) { if entry.name != name { // name is not normalized, and/or remapped return Some(entry.name.clone()); } } else if let Cow::Owned(s) = normalized { // no entry; might be a reference to an existing file, so ensure normalization return Some(s); } } None }) } fn delete_merged_unused_notetypes(&mut self) -> Result<()> { for &ntid in self .merged_notetypes .difference(&self.target_col.storage.used_notetypes()?) { self.target_col.remove_notetype_inner(ntid)?; } Ok(()) } } fn should_update( cond: UpdateCondition, existing_mtime: TimestampSecs, incoming_mtime: TimestampSecs, ) -> bool { match cond { UpdateCondition::IfNewer => existing_mtime < incoming_mtime, UpdateCondition::Always => existing_mtime != incoming_mtime, UpdateCondition::Never => false, } } fn combine_field_ords_maps( old: &[Option], new: impl Iterator>, ) -> Vec> { new.map(|new_field| { new_field.and_then(|old_field| old.get(old_field as usize).copied().flatten()) }) .collect() } fn combine_template_ords_maps(old_map: &mut HashMap, new: &Notetype) { for to in old_map.values_mut() { *to = new .template_ords() .enumerate() .find_map(|(new_to, new_from)| (new_from == Some(*to as u32)).then_some(new_to as u16)) .unwrap_or(*to); } } /// Target ids of notes with conflicting notetypes, with keys /// `(target note's notetype, incoming note's notetype)`. fn notetype_conflicts( incoming_notes: &[Note], existing_guids: &HashMap, ) -> HashMap<(NotetypeId, NotetypeId), Vec> { let mut conflicts: HashMap<(NotetypeId, NotetypeId), Vec> = HashMap::default(); for note in incoming_notes { if let Some(meta) = existing_guids.get(¬e.guid) { if meta.notetype_id != note.notetype_id { conflicts .entry((meta.notetype_id, note.notetype_id)) .or_default() .push(meta.id); } }; } conflicts } impl Notetype { pub(crate) fn field_ords(&self) -> impl Iterator> + '_ { self.fields.iter().map(|f| f.ord) } pub(crate) fn template_ords(&self) -> impl Iterator> + '_ { self.templates.iter().map(|t| t.ord) } fn field_ords_vec(&self) -> Vec> { self.field_ords() .map(|opt| opt.map(|u| u as usize)) .collect() } fn template_ords_vec(&self) -> Vec> { self.template_ords() .map(|opt| opt.map(|u| u as usize)) .collect() } fn equal_schema(&self, other: &Self) -> bool { self.fields.len() == other.fields.len() && self.templates.len() == other.templates.len() && self .fields .iter() .zip(other.fields.iter()) .all(|(f1, f2)| f1.is_match(f2)) && self .templates .iter() .zip(other.templates.iter()) .all(|(t1, t2)| t1.is_match(t2)) } fn copy_ords(&mut self, other: &Self) { for (field, other_ord) in self.fields.iter_mut().zip(other.field_ords()) { field.ord = other_ord; } for (template, other_ord) in self.templates.iter_mut().zip(other.template_ords()) { template.ord = other_ord; } } } #[cfg(test)] mod test { use anki_proto::import_export::ExportAnkiPackageOptions; use anki_proto::import_export::ImportAnkiPackageOptions; use tempfile::TempDir; use super::*; use crate::collection::CollectionBuilder; use crate::import_export::package::media::SafeMediaEntry; use crate::notetype::CardTemplate; use crate::notetype::NoteField; #[derive(Default)] struct ImportBuilder { notes: Vec, notetypes: Vec, remapped_notetypes: HashMap, media_map: MediaUseMap, merge_notetypes: bool, } impl ImportBuilder { fn new() -> Self { Self::default() } fn note(mut self, note: Note) -> Self { self.notes.push(note); self } fn notetype(mut self, notetype: Notetype) -> Self { self.notetypes.push(notetype); self } fn remap_notetype(mut self, from: NotetypeId, to: NotetypeId) -> Self { self.remapped_notetypes.insert(from, to); self } fn merge_notetypes(mut self, yes: bool) -> Self { self.merge_notetypes = yes; self } fn import(self, col: &mut Collection) -> NoteContext<'_> { let mut progress_handler = col.new_progress_handler(); let media_map = Box::leak(Box::new(self.media_map)); let mut ctx = NoteContext::new( Usn(1), col, media_map, self.merge_notetypes, UpdateCondition::IfNewer, UpdateCondition::IfNewer, ) .unwrap(); ctx.import_notetypes(self.notetypes).unwrap(); ctx.remapped_notetypes.extend(self.remapped_notetypes); ctx.import_notes(self.notes, &mut progress_handler).unwrap(); ctx } } /// Assert that exactly one [Note] is logged, and that with the given state /// and fields. macro_rules! assert_note_logged { ($log:expr, $state:ident, $fields:expr) => { assert_eq!($log.$state.pop().unwrap().fields, $fields); assert!($log.new.is_empty()); assert!($log.updated.is_empty()); assert!($log.duplicate.is_empty()); assert!($log.conflicting.is_empty()); }; } impl Collection { fn note_id_for_guid(&self, guid: &str) -> NoteId { self.storage .db .query_row("SELECT id FROM notes WHERE guid = ?", [guid], |r| r.get(0)) .unwrap() } } impl Notetype { pub(crate) fn field_names(&self) -> impl Iterator { self.fields.iter().map(|f| &f.name) } pub(crate) fn template_names(&self) -> impl Iterator { self.templates.iter().map(|t| &t.name) } } #[test] fn should_add_note_with_new_id_if_guid_is_unique_and_id_is_not() { let mut col = Collection::new(); let mut note = NoteAdder::basic(&mut col).add(&mut col); note.guid = "other".to_string(); let original_id = note.id; let mut ctx = ImportBuilder::new().note(note).import(&mut col); assert_note_logged!(ctx.imports.log, new, &["", ""]); assert_ne!(col.note_id_for_guid("other"), original_id); } #[test] fn should_skip_note_if_guid_already_exists_with_newer_mtime() { let mut col = Collection::new(); let mut note = NoteAdder::basic(&mut col).add(&mut col); note.mtime.0 -= 1; note.fields_mut()[0] = "outdated".to_string(); let mut ctx = ImportBuilder::new().note(note).import(&mut col); assert_note_logged!(ctx.imports.log, duplicate, &["outdated", ""]); assert_eq!(col.get_all_notes()[0].fields()[0], ""); } #[test] fn should_update_note_if_guid_already_exists_with_different_id() { let mut col = Collection::new(); let mut note = NoteAdder::basic(&mut col).add(&mut col); note.id.0 = 42; note.mtime.0 += 1; note.fields_mut()[0] = "updated".to_string(); let mut ctx = ImportBuilder::new().note(note).import(&mut col); assert_note_logged!(ctx.imports.log, updated, &["updated", ""]); assert_eq!(col.get_all_notes()[0].fields()[0], "updated"); } #[test] fn should_ignore_note_if_guid_already_exists_with_different_notetype() { let mut col = Collection::new(); let mut note = NoteAdder::basic(&mut col).add(&mut col); note.notetype_id.0 = 42; note.mtime.0 += 1; note.fields_mut()[0] = "updated".to_string(); let mut ctx = ImportBuilder::new().note(note).import(&mut col); assert_note_logged!(ctx.imports.log, conflicting, &["updated", ""]); assert_eq!(col.get_all_notes()[0].fields()[0], ""); } #[test] fn should_add_note_with_remapped_notetype_if_in_notetype_map() { let mut col = Collection::new(); let basic_ntid = col.get_notetype_by_name("basic").unwrap().unwrap().id; let mut note = NoteAdder::basic(&mut col).note(); note.notetype_id.0 = 123; let mut ctx = ImportBuilder::new() .note(note) .remap_notetype(NotetypeId(123), basic_ntid) .import(&mut col); assert_note_logged!(ctx.imports.log, new, &["", ""]); assert_eq!(col.get_all_notes()[0].notetype_id, basic_ntid); } #[test] fn should_ignore_note_if_guid_already_exists_and_notetype_is_remapped() { let mut col = Collection::new(); let basic_ntid = col.get_notetype_by_name("basic").unwrap().unwrap().id; let mut note = NoteAdder::basic(&mut col).add(&mut col); note.mtime.0 += 1; note.fields_mut()[0] = "updated".to_string(); let mut ctx = ImportBuilder::new() .note(note) .remap_notetype(basic_ntid, NotetypeId(123)) .import(&mut col); assert_note_logged!(ctx.imports.log, conflicting, &["updated", ""]); assert_eq!(col.get_all_notes()[0].fields()[0], ""); } #[test] fn should_add_note_with_remapped_media_reference_in_field_if_in_media_map() { let mut col = Collection::new(); let mut note = NoteAdder::basic(&mut col).note(); note.fields_mut()[0] = "".to_string(); let mut builder = ImportBuilder::new(); let entry = SafeMediaEntry::from_legacy(("0", "bar.jpg".to_string())).unwrap(); builder.media_map.add_checked("foo.jpg", entry); let mut ctx = builder.note(note).import(&mut col); assert_note_logged!(ctx.imports.log, new, &[" bar.jpg ", ""]); assert_eq!(col.get_all_notes()[0].fields()[0], ""); } #[test] fn should_import_new_notetype() { let mut col = Collection::new(); let mut new_basic = crate::notetype::stock::basic(&col.tr); new_basic.id.0 = 123; ImportBuilder::new().notetype(new_basic).import(&mut col); assert!(col.storage.get_notetype(NotetypeId(123)).unwrap().is_some()); } #[test] fn should_update_existing_notetype_with_older_mtime_and_matching_schema() { let mut col = Collection::new(); let mut basic = col.basic_notetype(); basic.mtime_secs.0 += 1; basic.name = String::from("new"); ImportBuilder::new().notetype(basic).import(&mut col); assert!(col.get_notetype_by_name("new").unwrap().is_some()); } #[test] fn should_not_update_existing_notetype_with_newer_mtime_and_matching_schema() { let mut col = Collection::new(); let mut basic = col.basic_notetype(); basic.mtime_secs.0 -= 1; basic.name = String::from("new"); ImportBuilder::new().notetype(basic).import(&mut col); assert!(col.get_notetype_by_name("new").unwrap().is_none()); } #[test] fn should_rename_field_with_matching_id_without_schema_change() { let mut col = Collection::new(); let mut to_import = col.basic_notetype(); to_import.fields[0].name = String::from("renamed"); to_import.mtime_secs.0 += 1; ImportBuilder::new().notetype(to_import).import(&mut col); assert_eq!(col.basic_notetype().fields[0].name, "renamed"); } #[test] fn should_add_remapped_notetype_if_schema_has_changed_and_reuse_it_subsequently() { let mut col = Collection::new(); let mut to_import = col.basic_notetype(); to_import.fields[0].name = String::from("new field"); // clear id or schemas would still match to_import.fields[0].config.id.take(); // schema mismatch => notetype should be imported with new id let ctx = ImportBuilder::new() .notetype(to_import.clone()) .import(&mut col); let remapped_id = *ctx.remapped_notetypes.values().next().unwrap(); assert_eq!(col.basic_notetype().fields[0].name, "Front"); let remapped = col.storage.get_notetype(remapped_id).unwrap().unwrap(); assert_eq!(remapped.fields[0].name, "new field"); // notetype with matching schema and original id exists => should be reused to_import.name = String::from("new name"); to_import.mtime_secs.0 = remapped.mtime_secs.0 + 1; let ctx_2 = ImportBuilder::new().notetype(to_import).import(&mut col); let remapped_id_2 = *ctx_2.remapped_notetypes.values().next().unwrap(); assert_eq!(remapped_id, remapped_id_2); let updated = col.storage.get_notetype(remapped_id).unwrap().unwrap(); assert_eq!(updated.name, "new name"); } #[test] fn should_merge_notetype_fields() { let mut col = Collection::new(); let mut to_import = col.basic_notetype(); to_import.mtime_secs.0 += 1; to_import.fields.remove(0); to_import.fields[0].name = String::from("renamed"); to_import.fields[0].ord.replace(0); to_import.fields.push(NoteField::new("new")); to_import.fields[1].ord.replace(1); let fields = ImportBuilder::new() .notetype(to_import.clone()) .merge_notetypes(true) .import(&mut col) .remapped_fields; // Front field is preserved and new field added assert!(col .basic_notetype() .field_names() .eq(["Front", "renamed", "new"])); // extra field must be inserted into incoming notes assert_eq!( fields.get(&to_import.id).unwrap(), &[None, Some(0), Some(1)] ); } #[test] fn should_merge_notetype_templates() { let mut col = Collection::new(); let mut to_import = col.basic_rev_notetype(); to_import.mtime_secs.0 += 1; to_import.templates.remove(0); to_import.templates[0].name = String::from("renamed"); to_import.templates[0].ord.replace(0); to_import.templates.push(CardTemplate::new("new", "", "")); to_import.templates[1].ord.replace(1); let templates = ImportBuilder::new() .notetype(to_import.clone()) .merge_notetypes(true) .import(&mut col) .imports .remapped_templates; // Card 1 is preserved and new template added assert!(col .basic_rev_notetype() .template_names() .eq(["Card 1", "renamed", "new"])); // templates must be shifted accordingly let map = templates.get(&to_import.id).unwrap(); assert_eq!(map.get(&0), Some(&1)); assert_eq!(map.get(&1), Some(&2)); } #[test] fn should_merge_notetype_duplicates_from_previous_imports() { let mut col = Collection::new(); let mut incoming = col.basic_notetype(); incoming.fields.push(NoteField::new("new incoming")); // simulate a notetype duplicated during previous import let mut remapped = col.basic_notetype(); remapped.config.original_id.replace(incoming.id.0); // ... which was modified and has notes remapped.fields.push(NoteField::new("new remapped")); remapped.id.0 = 0; col.add_notetype_inner(&mut remapped, Usn(0), true).unwrap(); let mut note = Note::new(&remapped); *note.fields_mut() = vec![ String::from("front"), String::from("back"), String::from("new"), ]; col.add_note(&mut note, DeckId(1)).unwrap(); let ntid = incoming.id; ImportBuilder::new() .notetype(incoming) .merge_notetypes(true) .import(&mut col); // both notetypes should have been merged into it assert!(col.get_notetype(ntid).unwrap().unwrap().field_names().eq([ "Front", "Back", "new remapped", "new incoming", ])); assert!(col.get_all_notes()[0] .fields() .iter() .eq(["front", "back", "new", ""])) } #[test] fn reimport_with_merge_enabled_should_handle_duplicates() -> Result<()> { // import from src to dst let mut src = Collection::new(); NoteAdder::basic(&mut src) .fields(&["foo", "bar"]) .add(&mut src); let temp_dir = TempDir::new()?; let path = temp_dir.path().join("foo.apkg"); src.export_apkg(&path, ExportAnkiPackageOptions::default(), "", None)?; let mut dst = CollectionBuilder::new(temp_dir.path().join("dst.anki2")) .with_desktop_media_paths() .build()?; dst.import_apkg(&path, ImportAnkiPackageOptions::default())?; // add a field to src let mut nt = src.basic_notetype(); nt.fields.push(NoteField::new("new incoming")); src.update_notetype(&mut nt, false)?; // add a new note using the updated notetype NoteAdder::basic(&mut src) .fields(&["baz", "bar", "foo"]) .add(&mut src); // importing again with merge disabled will fail for the exisitng note, // but the new one will be added with an extra notetype assert_eq!(dst.storage.get_all_notetype_names().unwrap().len(), 7); src.export_apkg(&path, ExportAnkiPackageOptions::default(), "", None)?; assert_eq!( dst.import_apkg(&path, ImportAnkiPackageOptions::default())? .output .conflicting .len(), 1 ); assert_eq!(dst.storage.get_all_notetype_names().unwrap().len(), 8); // if enabling merge, it should succeed and remove the empty notetype, remapping // its note src.export_apkg(&path, ExportAnkiPackageOptions::default(), "", None)?; assert_eq!( dst.import_apkg( &path, ImportAnkiPackageOptions { merge_notetypes: true, ..Default::default() } )? .output .conflicting .len(), 0 ); assert_eq!(dst.storage.get_all_notetype_names().unwrap().len(), 7); Ok(()) } #[test] fn should_merge_conflicting_notetype_even_without_original_id() { let mut col = Collection::new(); // incoming notetype with a new field let mut incoming_notetype = col.basic_notetype(); incoming_notetype.fields.push(NoteField { ord: Some(2), ..NoteField::new("new incoming") }); // existing notetype with a different new field and id let mut existing_notetype = col.basic_notetype(); existing_notetype .fields .push(NoteField::new("new existing")); existing_notetype.id.0 = 0; col.add_notetype_inner(&mut existing_notetype, Usn(0), true) .unwrap(); // incoming conflicts with existing note, e.g. because it was remapped during a // previous import (which wasn't recording the origninal id of the notetype yet) let mut note = NoteAdder::new(&existing_notetype) .fields(&["front", "back", "new existing"]) .add(&mut col); note.fields_mut()[2] = String::from("new incoming"); note.notetype_id = incoming_notetype.id; note.mtime.0 += 1; let ntid = incoming_notetype.id; ImportBuilder::new() .note(note) .notetype(incoming_notetype) .merge_notetypes(true) .import(&mut col); // notetypes should have been merged assert!(col.get_notetype(ntid).unwrap().unwrap().field_names().eq([ "Front", "Back", "new incoming", "new existing" ])); // merged, now unused notetype should have been deleted assert!(col.get_notetype(existing_notetype.id).unwrap().is_none()); assert!(col.get_all_notes()[0] .fields() .iter() .eq(["front", "back", "new incoming", "",])) } #[test] fn should_combine_field_ords_maps() { // (A, B) -> (C, B, A) let old = [None, Some(1), Some(0)]; // (C, B, A)-> (D, A, B, C) let new = [None, Some(2), Some(1), Some(0)].into_iter(); // (A, B) -> (D, A, B, C) let expected = [None, Some(0), Some(1), None]; assert!(combine_field_ords_maps(&old, new).eq(&expected)); } } ================================================ FILE: rslib/src/import_export/package/apkg/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod export; mod import; mod tests; pub(crate) use import::NoteMeta; ================================================ FILE: rslib/src/import_export/package/apkg/tests.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html #![cfg(test)] use std::collections::HashSet; use std::fs::File; use std::io::Write; use anki_io::read_file; use anki_proto::import_export::ImportAnkiPackageOptions; use crate::import_export::package::ExportAnkiPackageOptions; use crate::media::files::sha1_of_data; use crate::media::MediaManager; use crate::prelude::*; use crate::search::SearchNode; use crate::tests::open_fs_test_collection; const SAMPLE_JPG: &str = "sample.jpg"; const SAMPLE_MP3: &str = "sample.mp3"; const SAMPLE_JS: &str = "_sample.js"; const JPG_DATA: &[u8] = b"1"; const MP3_DATA: &[u8] = b"2"; const JS_DATA: &[u8] = b"3"; const EXISTING_MP3_DATA: &[u8] = b"4"; #[test] fn roundtrip() { roundtrip_inner(true); roundtrip_inner(false); } fn roundtrip_inner(legacy: bool) { let (mut src_col, src_tempdir) = open_fs_test_collection("src"); let (mut target_col, _target_tempdir) = open_fs_test_collection("target"); let apkg_path = src_tempdir.path().join("test.apkg"); let (main_deck, sibling_deck) = src_col.add_sample_decks(); let notetype = src_col.add_sample_notetype(); let note = src_col.add_sample_note(&main_deck, &sibling_deck, ¬etype); src_col.add_sample_media(); target_col.add_conflicting_media(); src_col .export_apkg( &apkg_path, ExportAnkiPackageOptions { with_scheduling: true, with_deck_configs: true, with_media: true, legacy, }, SearchNode::from_deck_name("parent::sample"), None, ) .unwrap(); target_col .import_apkg(&apkg_path, ImportAnkiPackageOptions::default()) .unwrap(); target_col.assert_decks(); target_col.assert_notetype(¬etype); target_col.assert_note_and_media(¬e); target_col.undo().unwrap(); target_col.assert_empty(); } impl Collection { fn add_sample_decks(&mut self) -> (Deck, Deck) { let sample = self.add_named_deck("parent\x1fsample"); self.add_named_deck("parent\x1fsample\x1fchild"); let siblings = self.add_named_deck("siblings"); (sample, siblings) } fn add_named_deck(&mut self, name: &str) -> Deck { let mut deck = Deck::new_normal(); deck.name = NativeDeckName::from_native_str(name); self.add_deck(&mut deck).unwrap(); deck } fn add_sample_notetype(&mut self) -> Notetype { let mut nt = Notetype { name: "sample".into(), ..Default::default() }; nt.add_field("sample"); nt.add_template("sample1", "{{sample}}", ""); nt.add_template("sample2", "{{sample}}2", ""); self.add_notetype(&mut nt, true).unwrap(); nt } fn add_sample_note( &mut self, main_deck: &Deck, sibling_decks: &Deck, notetype: &Notetype, ) -> Note { let mut sample = notetype.new_note(); sample.fields_mut()[0] = format!(" [sound:{SAMPLE_MP3}]"); sample.tags = vec!["sample".into()]; self.add_note(&mut sample, main_deck.id).unwrap(); let card = self .storage .get_card_by_ordinal(sample.id, 1) .unwrap() .unwrap(); self.set_deck(&[card.id], sibling_decks.id).unwrap(); sample } fn add_sample_media(&self) { self.add_media(&[ (SAMPLE_JPG, JPG_DATA), (SAMPLE_MP3, MP3_DATA), (SAMPLE_JS, JS_DATA), ]); } fn add_conflicting_media(&mut self) { let mut file = File::create(self.media_folder.join(SAMPLE_MP3)).unwrap(); file.write_all(EXISTING_MP3_DATA).unwrap(); } fn assert_decks(&mut self) { let existing_decks: HashSet<_> = self .get_all_deck_names(true) .unwrap() .into_iter() .map(|(_, name)| name) .collect(); for deck in ["parent", "parent::sample", "siblings"] { assert!(existing_decks.contains(deck)); } assert!(!existing_decks.contains("parent::sample::child")); } fn assert_notetype(&mut self, notetype: &Notetype) { assert!(self.get_notetype(notetype.id).unwrap().is_some()); } fn assert_note_and_media(&mut self, note: &Note) { let sha1 = sha1_of_data(MP3_DATA); let new_mp3_name = format!("sample-{}.mp3", hex::encode(sha1)); let csums = MediaManager::new(&self.media_folder, &self.media_db) .unwrap() .all_checksums_as_is(); for (fname, orig_data) in [ (SAMPLE_JPG, JPG_DATA), (SAMPLE_MP3, EXISTING_MP3_DATA), (new_mp3_name.as_str(), MP3_DATA), (SAMPLE_JS, JS_DATA), ] { // data should have been copied correctly assert_eq!(read_file(self.media_folder.join(fname)).unwrap(), orig_data); // and checksums in media db should be valid assert_eq!(*csums.get(fname).unwrap(), sha1_of_data(orig_data)); } let imported_note = self.storage.get_note(note.id).unwrap().unwrap(); assert!(imported_note.fields()[0].contains(&new_mp3_name)); } fn assert_empty(&self) { assert!(self.get_all_deck_names(true).unwrap().is_empty()); assert!(self.storage.get_all_note_ids().unwrap().is_empty()); assert!(self.storage.get_all_card_ids().unwrap().is_empty()); assert!(self.storage.all_tags().unwrap().is_empty()); } } ================================================ FILE: rslib/src/import_export/package/colpkg/export.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::fs::File; use std::io; use std::io::Read; use std::io::Write; use std::path::Path; use std::path::PathBuf; use anki_io::atomic_rename; use anki_io::new_tempfile; use anki_io::new_tempfile_in_parent_of; use anki_io::open_file; use prost::Message; use tempfile::NamedTempFile; use zip::write::FileOptions; use zip::CompressionMethod; use zip::ZipWriter; use zstd::stream::raw::Encoder as RawEncoder; use zstd::stream::zio; use zstd::Encoder; use super::super::meta::MetaExt; use super::super::meta::VersionExt; use super::super::MediaEntries; use super::super::MediaEntry; use super::super::Meta; use super::super::Version; use crate::collection::CollectionBuilder; use crate::import_export::package::media::new_media_entry; use crate::import_export::package::media::MediaCopier; use crate::import_export::package::media::MediaIter; use crate::import_export::ExportProgress; use crate::prelude::*; use crate::progress::ThrottlingProgressHandler; use crate::storage::SchemaVersion; /// Enable multithreaded compression if over this size. For smaller files, /// multithreading makes things slower, and in initial tests, the crossover /// point was somewhere between 1MB and 10MB on a many-core system. const MULTITHREAD_MIN_BYTES: usize = 10 * 1024 * 1024; impl Collection { pub fn export_colpkg( self, out_path: impl AsRef, include_media: bool, legacy: bool, ) -> Result<()> { let mut progress = self.new_progress_handler(); let colpkg_name = out_path.as_ref(); let temp_colpkg = new_tempfile_in_parent_of(colpkg_name)?; let src_path = self.col_path.clone(); let src_media_folder = if include_media { Some(self.media_folder.clone()) } else { None }; let tr = self.tr.clone(); self.close(Some(if legacy { SchemaVersion::V11 } else { SchemaVersion::V18 }))?; export_collection_file( temp_colpkg.path(), &src_path, src_media_folder, legacy, &tr, &mut progress, )?; atomic_rename(temp_colpkg, colpkg_name, true)?; Ok(()) } } fn export_collection_file( out_path: impl AsRef, col_path: impl AsRef, media_dir: Option, legacy: bool, tr: &I18n, progress: &mut ThrottlingProgressHandler, ) -> Result<()> { let meta = if legacy { Meta::new_legacy() } else { Meta::new() }; let mut col_file = open_file(col_path)?; let col_size = col_file.metadata()?.len() as usize; let media = if let Some(path) = media_dir { MediaIter::from_folder(&path)? } else { MediaIter::empty() }; export_collection(meta, out_path, &mut col_file, col_size, media, tr, progress) } /// Write copied collection data without any media. pub(crate) fn export_colpkg_from_data( out_path: impl AsRef, mut col_data: &[u8], tr: &I18n, ) -> Result<()> { let col_size = col_data.len(); let mut progress = ThrottlingProgressHandler::new(Default::default()); export_collection( Meta::new(), out_path, &mut col_data, col_size, MediaIter::empty(), tr, &mut progress, ) } pub(crate) fn export_collection( meta: Meta, out_path: impl AsRef, col: &mut impl Read, col_size: usize, media: MediaIter, tr: &I18n, progress: &mut ThrottlingProgressHandler, ) -> Result<()> { let out_file = File::create(&out_path)?; let mut zip = ZipWriter::new(out_file); zip.start_file("meta", file_options_stored())?; let mut meta_bytes = vec![]; meta.encode(&mut meta_bytes)?; zip.write_all(&meta_bytes)?; write_collection(&meta, &mut zip, col, col_size)?; write_dummy_collection(&mut zip, tr)?; write_media(&meta, &mut zip, media, progress)?; zip.finish()?; Ok(()) } fn file_options_stored() -> FileOptions<'static, ()> { FileOptions::<'static, ()>::default().compression_method(CompressionMethod::Stored) } fn file_options_default() -> FileOptions<'static, ()> { FileOptions::<'static, ()>::default() } fn write_collection( meta: &Meta, zip: &mut ZipWriter, col: &mut impl Read, size: usize, ) -> Result<()> { if meta.zstd_compressed() { zip.start_file(meta.collection_filename(), file_options_stored())?; zstd_copy(col, zip, size)?; } else { zip.start_file(meta.collection_filename(), file_options_default())?; io::copy(col, zip)?; } Ok(()) } fn write_dummy_collection(zip: &mut ZipWriter, tr: &I18n) -> Result<()> { let mut tempfile = create_dummy_collection_file(tr)?; zip.start_file( Version::Legacy1.collection_filename(), file_options_stored(), )?; io::copy(&mut tempfile, zip)?; Ok(()) } fn create_dummy_collection_file(tr: &I18n) -> Result { let tempfile = new_tempfile()?; let mut dummy_col = CollectionBuilder::new(tempfile.path()).build()?; dummy_col.add_dummy_note(tr)?; dummy_col .storage .db .execute_batch("pragma page_size=512; pragma journal_mode=delete; vacuum;")?; dummy_col.close(Some(SchemaVersion::V11))?; Ok(tempfile) } impl Collection { fn add_dummy_note(&mut self, tr: &I18n) -> Result<()> { let notetype = self.get_notetype_by_name("basic")?.unwrap(); let mut note = notetype.new_note(); note.set_field(0, tr.exporting_colpkg_too_new())?; self.add_note(&mut note, DeckId(1))?; Ok(()) } } /// Copy contents of reader into writer, compressing as we copy. fn zstd_copy(reader: &mut impl Read, writer: &mut impl Write, size: usize) -> Result<()> { let mut encoder = Encoder::new(writer, 0)?; if size > MULTITHREAD_MIN_BYTES { encoder.multithread(num_cpus::get() as u32)?; } io::copy(reader, &mut encoder)?; encoder.finish()?; Ok(()) } fn write_media( meta: &Meta, zip: &mut ZipWriter, media: MediaIter, progress: &mut ThrottlingProgressHandler, ) -> Result<()> { let mut media_entries = vec![]; write_media_files(meta, zip, media, &mut media_entries, progress)?; write_media_map(meta, media_entries, zip)?; Ok(()) } fn write_media_map( meta: &Meta, media_entries: Vec, zip: &mut ZipWriter, ) -> Result<()> { zip.start_file("media", file_options_stored())?; let encoded_bytes = if meta.media_list_is_hashmap() { let map: HashMap = media_entries .iter() .enumerate() .map(|(k, entry)| (k.to_string(), entry.name.as_str())) .collect(); serde_json::to_vec(&map)? } else { let mut buf = vec![]; MediaEntries { entries: media_entries, } .encode(&mut buf)?; buf }; let size = encoded_bytes.len(); let mut cursor = io::Cursor::new(encoded_bytes); if meta.zstd_compressed() { zstd_copy(&mut cursor, zip, size)?; } else { io::copy(&mut cursor, zip)?; } Ok(()) } fn write_media_files( meta: &Meta, zip: &mut ZipWriter, media: MediaIter, media_entries: &mut Vec, progress: &mut ThrottlingProgressHandler, ) -> Result<()> { let mut copier = MediaCopier::new(meta.zstd_compressed()); let mut incrementor = progress.incrementor(ExportProgress::Media); for (index, res) in media.0.enumerate() { incrementor.increment()?; let mut entry = res?; zip.start_file(index.to_string(), file_options_stored())?; let (size, sha1) = copier.copy(&mut entry.data, zip)?; media_entries.push(new_media_entry(entry.nfc_filename, size, sha1)); } Ok(()) } pub(crate) enum MaybeEncodedWriter<'a, W: Write> { Stored(&'a mut W), Encoded(zio::Writer<&'a mut W, RawEncoder<'static>>), } impl<'a, W: Write> MaybeEncodedWriter<'a, W> { pub fn new(writer: &'a mut W, encoder: Option>) -> Self { if let Some(encoder) = encoder { Self::Encoded(zio::Writer::new(writer, encoder)) } else { Self::Stored(writer) } } pub fn write(&mut self, buf: &[u8]) -> Result<()> { match self { Self::Stored(writer) => writer.write_all(buf)?, Self::Encoded(writer) => writer.write_all(buf)?, }; Ok(()) } pub fn finish(self) -> Result>> { Ok(match self { Self::Stored(_) => None, Self::Encoded(mut writer) => { writer.finish()?; Some(writer.into_inner().1) } }) } } #[cfg(test)] mod test { use super::*; use crate::media::files::sha1_of_data; #[test] fn media_file_writing() { let bytes = b"foo"; let bytes_hash = sha1_of_data(b"foo"); for meta in [Meta::new_legacy(), Meta::new()] { let mut writer = MediaCopier::new(meta.zstd_compressed()); let mut buf = Vec::new(); let (size, hash) = writer.copy(&mut bytes.as_slice(), &mut buf).unwrap(); if meta.zstd_compressed() { buf = zstd::decode_all(buf.as_slice()).unwrap(); } assert_eq!(buf, bytes); assert_eq!(size, bytes.len()); assert_eq!(hash, bytes_hash); } } } ================================================ FILE: rslib/src/import_export/package/colpkg/import.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::fs::File; use std::io; use std::io::Write; use std::path::Path; use std::path::PathBuf; use anki_io::atomic_rename; use anki_io::create_dir_all; use anki_io::new_tempfile_in_parent_of; use anki_io::open_file; use anki_io::FileIoSnafu; use anki_io::FileOp; use zip::read::ZipFile; use zip::ZipArchive; use zstd::stream::copy_decode; use super::super::meta::MetaExt; use crate::collection::CollectionBuilder; use crate::import_export::package::media::extract_media_entries; use crate::import_export::package::media::SafeMediaEntry; use crate::import_export::package::Meta; use crate::import_export::ImportError; use crate::import_export::ImportProgress; use crate::media::MediaManager; use crate::prelude::*; use crate::progress::ThrottlingProgressHandler; pub fn import_colpkg( colpkg_path: &str, target_col_path: &str, target_media_folder: &Path, media_db: &Path, mut progress: ThrottlingProgressHandler, ) -> Result<()> { let col_path = PathBuf::from(target_col_path); let mut tempfile = new_tempfile_in_parent_of(&col_path)?; let backup_file = open_file(colpkg_path)?; let mut archive = ZipArchive::new(backup_file)?; let meta = Meta::from_archive(&mut archive)?; copy_collection(&mut archive, &mut tempfile, &meta)?; progress.set(ImportProgress::File)?; check_collection_and_mod_schema(tempfile.path())?; progress.set(ImportProgress::File)?; restore_media( &meta, &mut progress, &mut archive, target_media_folder, media_db, )?; atomic_rename(tempfile, &col_path, true)?; Ok(()) } fn check_collection_and_mod_schema(col_path: &Path) -> Result<()> { CollectionBuilder::new(col_path) .build() .ok() .and_then(|mut col| { col.set_schema_modified().ok()?; col.set_modified().ok()?; col.storage .db .pragma_query_value(None, "integrity_check", |row| row.get::<_, String>(0)) .ok() }) .and_then(|s| (s == "ok").then_some(())) .ok_or(AnkiError::ImportError { source: ImportError::Corrupt, }) } fn restore_media( meta: &Meta, progress: &mut ThrottlingProgressHandler, archive: &mut ZipArchive, media_folder: &Path, media_db: &Path, ) -> Result<()> { let media_entries = extract_media_entries(meta, archive)?; if media_entries.is_empty() { return Ok(()); } create_dir_all(media_folder)?; let media_manager = MediaManager::new(media_folder, media_db)?; let mut media_comparer = MediaComparer::new(meta, progress, &media_manager)?; let mut incrementor = progress.incrementor(ImportProgress::Media); for mut entry in media_entries { incrementor.increment()?; maybe_restore_media_file(meta, media_folder, archive, &mut entry, &mut media_comparer)?; } Ok(()) } fn maybe_restore_media_file( meta: &Meta, media_folder: &Path, archive: &mut ZipArchive, entry: &mut SafeMediaEntry, media_comparer: &mut MediaComparer, ) -> Result<()> { let file_path = entry.file_path(media_folder); let mut zip_file = entry.fetch_file(archive)?; if meta.media_list_is_hashmap() { entry.size = zip_file.size() as u32; } let already_exists = media_comparer.entry_is_equal_to(entry, &file_path)?; if !already_exists { restore_media_file(meta, &mut zip_file, &file_path)?; }; Ok(()) } fn restore_media_file(meta: &Meta, zip_file: &mut ZipFile, path: &Path) -> Result<()> { let mut tempfile = new_tempfile_in_parent_of(path)?; meta.copy(zip_file, &mut tempfile) .with_context(|_| FileIoSnafu { path: tempfile.path(), op: FileOp::copy(zip_file.name()), })?; atomic_rename(tempfile, path, false)?; Ok(()) } fn copy_collection( archive: &mut ZipArchive, writer: &mut impl Write, meta: &Meta, ) -> Result<()> { let mut file = archive .by_name(meta.collection_filename()) .map_err(|_| AnkiError::ImportError { source: ImportError::Corrupt, })?; if !meta.zstd_compressed() { io::copy(&mut file, writer)?; } else { copy_decode(file, writer)?; } Ok(()) } type GetChecksumFn<'a> = dyn FnMut(&str) -> Result> + 'a; struct MediaComparer<'a>(Option>>); impl<'a> MediaComparer<'a> { fn new( meta: &Meta, progress: &mut ThrottlingProgressHandler, media_manager: &'a MediaManager, ) -> Result { Ok(Self(if meta.media_list_is_hashmap() { None } else { let mut db_progress_fn = progress.media_db_fn(ImportProgress::MediaCheck)?; media_manager.register_changes(&mut db_progress_fn)?; Some(Box::new(media_manager.checksum_getter())) })) } fn entry_is_equal_to(&mut self, entry: &SafeMediaEntry, other_path: &Path) -> Result { if let Some(ref mut get_checksum) = self.0 { Ok(entry.has_checksum_equal_to(get_checksum)?) } else { Ok(entry.has_size_equal_to(other_path)) } } } ================================================ FILE: rslib/src/import_export/package/colpkg/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub(super) mod export; pub(super) mod import; mod tests; ================================================ FILE: rslib/src/import_export/package/colpkg/tests.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html #![cfg(test)] use std::path::Path; use anki_io::create_dir_all; use anki_io::read_file; use tempfile::tempdir; use crate::collection::CollectionBuilder; use crate::import_export::package::import_colpkg; use crate::media::MediaManager; use crate::prelude::*; fn collection_with_media(dir: &Path, name: &str) -> Result { let name = format!("{name}_src"); // add collection with sentinel note let mut col = CollectionBuilder::new(dir.join(format!("{name}.anki2"))) .with_desktop_media_paths() .build()?; let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); col.add_note(&mut note, DeckId(1))?; // add sample media let mgr = col.media()?; mgr.add_file("1", b"1")?; mgr.add_file("2", b"2")?; mgr.add_file("3", b"3")?; Ok(col) } #[test] fn roundtrip() -> Result<()> { let _dir = tempdir()?; let dir = _dir.path(); for (legacy, name) in [(true, "legacy"), (false, "v3")] { // export to a file let col = collection_with_media(dir, name)?; let colpkg_name = dir.join(format!("{name}.colpkg")); let progress = col.new_progress_handler(); col.export_colpkg(&colpkg_name, true, legacy)?; // import into a new collection let anki2_name = dir .join(format!("{name}.anki2")) .to_string_lossy() .into_owned(); let import_media_dir = dir.join(format!("{name}.media")); create_dir_all(&import_media_dir)?; let import_media_db = dir.join(format!("{name}.mdb")); MediaManager::new(&import_media_dir, &import_media_db)?; import_colpkg( &colpkg_name.to_string_lossy(), &anki2_name, &import_media_dir, &import_media_db, progress, )?; // confirm collection imported let col = CollectionBuilder::new(&anki2_name).build()?; assert_eq!( col.storage.db_scalar::("select count() from notes")?, 1 ); // confirm media imported correctly assert_eq!(read_file(import_media_dir.join("1"))?, b"1"); assert_eq!(read_file(import_media_dir.join("2"))?, b"2"); assert_eq!(read_file(import_media_dir.join("3"))?, b"3"); } Ok(()) } /// Files with an invalid encoding should prevent export, except /// on Apple platforms where the encoding is transparently changed. #[test] #[cfg(not(target_vendor = "apple"))] fn normalization_check_on_export() -> Result<()> { use anki_io::write_file; let _dir = tempdir()?; let dir = _dir.path(); let col = collection_with_media(dir, "normalize")?; let colpkg_name = dir.join("normalize.colpkg"); // manually write a file in the wrong encoding. write_file(col.media_folder.join("ぱぱ.jpg"), "nfd encoding")?; assert_eq!( col.export_colpkg(&colpkg_name, true, false,).unwrap_err(), AnkiError::MediaCheckRequired ); // file should have been cleaned up assert!(!colpkg_name.exists()); Ok(()) } ================================================ FILE: rslib/src/import_export/package/media.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::collections::HashMap; use std::ffi::OsString; use std::fs; use std::fs::File; use std::io; use std::io::Read; use std::io::Write; use std::path::Path; use std::path::PathBuf; use anki_io::atomic_rename; use anki_io::filename_is_safe; use anki_io::new_tempfile_in; use anki_io::read_dir_files; use anki_io::FileIoError; use anki_io::FileOp; use prost::Message; use sha1::Digest; use sha1::Sha1; use zip::read::ZipFile; use zip::result::ZipError; use zip::ZipArchive; use zstd::stream::copy_decode; use zstd::stream::raw::Encoder as RawEncoder; use super::meta::MetaExt; use super::MediaEntries; use super::MediaEntry; use super::Meta; use crate::error::InvalidInputError; use crate::import_export::package::colpkg::export::MaybeEncodedWriter; use crate::import_export::ImportError; use crate::media::files::filename_if_normalized; use crate::media::files::normalize_filename; use crate::prelude::*; /// Like [MediaEntry], but with a safe filename and set zip filename. #[derive(Debug)] pub(super) struct SafeMediaEntry { pub(super) name: String, pub(super) size: u32, pub(super) sha1: Option, pub(super) index: usize, } pub(super) fn new_media_entry( name: impl Into, size: impl TryInto, sha1: impl Into>, ) -> MediaEntry { MediaEntry { name: name.into(), size: size.try_into().unwrap_or_default(), sha1: sha1.into(), legacy_zip_filename: None, } } impl SafeMediaEntry { pub(super) fn from_entry(enumerated: (usize, MediaEntry)) -> Result { let (index, entry) = enumerated; if let Ok(sha1) = entry.sha1.try_into() { if !matches!(safe_normalized_file_name(&entry.name)?, Cow::Owned(_)) { return Ok(Self { name: entry.name, size: entry.size, sha1: Some(sha1), index, }); } } Err(AnkiError::ImportError { source: ImportError::Corrupt, }) } pub(super) fn from_legacy(legacy_entry: (&str, String)) -> Result { let zip_filename: usize = legacy_entry.0.parse()?; let name = match safe_normalized_file_name(&legacy_entry.1)? { Cow::Owned(new_name) => new_name, Cow::Borrowed(_) => legacy_entry.1, }; Ok(Self { name, size: 0, sha1: None, index: zip_filename, }) } pub(super) fn file_path(&self, media_folder: &Path) -> PathBuf { media_folder.join(&self.name) } pub(super) fn fetch_file<'a>( &self, archive: &'a mut ZipArchive, ) -> Result> { match archive.by_name(&self.index.to_string()) { Ok(file) => Ok(file), Err(err) => invalid_input!(err, "{} missing from archive", self.index), } } pub(super) fn has_checksum_equal_to( &self, get_checksum: &mut impl FnMut(&str) -> Result>, ) -> Result { get_checksum(&self.name) .map(|opt| opt.is_some_and(|sha1| sha1 == self.sha1.expect("sha1 not set"))) } pub(super) fn has_size_equal_to(&self, other_path: &Path) -> bool { fs::metadata(other_path).is_ok_and(|metadata| metadata.len() == self.size as u64) } /// Copy the archived file to the target folder, setting its hash if /// necessary. pub(super) fn copy_and_ensure_sha1_set( &mut self, archive: &mut ZipArchive, target_folder: &Path, copier: &mut MediaCopier, compressed: bool, ) -> Result<()> { let mut file = self.fetch_file(archive)?; let mut tempfile = new_tempfile_in(target_folder)?; if compressed { copy_decode(&mut file, &mut tempfile)? } else { let (_, sha1) = copier.copy(&mut file, &mut tempfile)?; self.sha1 = Some(sha1); } atomic_rename(tempfile, &self.file_path(target_folder), false)?; Ok(()) } } pub(super) fn extract_media_entries( meta: &Meta, archive: &mut ZipArchive, ) -> Result> { let media_list_data = get_media_list_data(archive, meta)?; if meta.media_list_is_hashmap() { let map: HashMap<&str, String> = serde_json::from_slice(&media_list_data)?; map.into_iter().map(SafeMediaEntry::from_legacy).collect() } else { decode_safe_entries(&media_list_data) } } pub(super) fn safe_normalized_file_name(name: &str) -> Result> { if !filename_is_safe(name) { Err(AnkiError::ImportError { source: ImportError::Corrupt, }) } else { Ok(normalize_filename(name)) } } fn get_media_list_data(archive: &mut ZipArchive, meta: &Meta) -> Result> { let mut file = match archive.by_name("media") { Ok(file) => file, Err(ZipError::FileNotFound) => { // Older AnkiDroid versions wrote colpkg files without a media map return Ok(b"{}".to_vec()); } err => err?, }; let mut buf = Vec::new(); if meta.zstd_compressed() { copy_decode(file, &mut buf)?; } else { io::copy(&mut file, &mut buf)?; } Ok(buf) } pub(super) fn decode_safe_entries(buf: &[u8]) -> Result> { let entries: MediaEntries = Message::decode(buf)?; entries .entries .into_iter() .enumerate() .map(SafeMediaEntry::from_entry) .collect() } pub struct MediaIterEntry { pub nfc_filename: String, pub data: Box, } #[derive(Debug)] pub enum MediaIterError { InvalidFilename { filename: OsString, }, IoError { filename: String, source: io::Error, }, Other { source: Box, }, } impl TryFrom<&Path> for MediaIterEntry { type Error = MediaIterError; fn try_from(value: &Path) -> std::result::Result { let nfc_filename: String = value .file_name() .and_then(|s| s.to_str()) .and_then(filename_if_normalized) .ok_or_else(|| MediaIterError::InvalidFilename { filename: value.as_os_str().to_owned(), })? .into(); let file = File::open(value).map_err(|err| MediaIterError::IoError { filename: nfc_filename.clone(), source: err, })?; Ok(MediaIterEntry { nfc_filename, data: Box::new(file) as _, }) } } impl From for AnkiError { fn from(err: MediaIterError) -> Self { match err { MediaIterError::InvalidFilename { .. } => AnkiError::MediaCheckRequired, MediaIterError::IoError { filename, source } => FileIoError { path: filename.into(), op: FileOp::Read, source, } .into(), MediaIterError::Other { source } => InvalidInputError { message: "".to_string(), source: Some(source), backtrace: None, } .into(), } } } pub struct MediaIter(pub Box>>); impl MediaIter { pub fn new(iter: I) -> Self where I: Iterator> + 'static, { Self(Box::new(iter)) } /// Iterator over all files in the given path, without traversing /// subfolders. pub fn from_folder(path: &Path) -> Result { let path2 = path.to_owned(); Ok(Self::new(read_dir_files(path)?.map(move |res| match res { Ok(entry) => MediaIterEntry::try_from(entry.path().as_path()), Err(err) => Err(MediaIterError::IoError { filename: path2.to_string_lossy().into(), source: err, }), }))) } /// Iterator over all given files in the given folder. /// Missing files are silently ignored. pub fn from_file_list( list: impl IntoIterator + 'static, folder: PathBuf, ) -> Self { Self::new( list.into_iter() .map(move |file| folder.join(file)) .filter(|path| path.exists()) .map(|path| MediaIterEntry::try_from(path.as_path())), ) } pub fn empty() -> Self { Self::new([].into_iter()) } } /// Copies and hashes while optionally encoding. /// If compressing, the encoder is reused to optimize for repeated calls. pub(crate) struct MediaCopier { encoding: bool, encoder: Option>, buf: [u8; 64 * 1024], } impl MediaCopier { pub(crate) fn new(encoding: bool) -> Self { Self { encoding, encoder: None, buf: [0; 64 * 1024], } } fn encoder(&mut self) -> Option> { self.encoding.then(|| { self.encoder .take() .unwrap_or_else(|| RawEncoder::with_dictionary(0, &[]).unwrap()) }) } /// Returns size and sha1 hash of the copied data. pub(crate) fn copy( &mut self, reader: &mut impl Read, writer: &mut impl Write, ) -> Result<(usize, Sha1Hash)> { let mut size = 0; let mut hasher = Sha1::new(); self.buf = [0; 64 * 1024]; let mut wrapped_writer = MaybeEncodedWriter::new(writer, self.encoder()); loop { let count = match reader.read(&mut self.buf) { Ok(0) => break, Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, result => result?, }; size += count; hasher.update(&self.buf[..count]); wrapped_writer.write(&self.buf[..count])?; } self.encoder = wrapped_writer.finish()?; Ok((size, hasher.finalize().into())) } } #[cfg(test)] mod test { use super::*; #[test] fn normalization() { // legacy entries get normalized on deserialisation let entry = SafeMediaEntry::from_legacy(("1", "con".to_owned())).unwrap(); assert_eq!(entry.name, "con_"); // new-style entries should have been normalized on export let mut entries = Vec::new(); MediaEntries { entries: vec![new_media_entry("con", 0, Vec::new())], } .encode(&mut entries) .unwrap(); assert!(decode_safe_entries(&entries).is_err()); } } ================================================ FILE: rslib/src/import_export/package/meta.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::fs::File; use std::io; use std::io::Read; pub(super) use anki_proto::import_export::package_metadata::Version; pub(super) use anki_proto::import_export::PackageMetadata as Meta; use prost::Message; use zip::ZipArchive; use zstd::stream::copy_decode; use crate::import_export::ImportError; use crate::prelude::*; use crate::storage::SchemaVersion; pub(super) trait VersionExt { fn collection_filename(&self) -> &'static str; fn schema_version(&self) -> SchemaVersion; } impl VersionExt for Version { fn collection_filename(&self) -> &'static str { match self { Version::Unknown => unreachable!(), Version::Legacy1 => "collection.anki2", Version::Legacy2 => "collection.anki21", Version::Latest => "collection.anki21b", } } /// Latest schema version that is supported by all clients supporting /// this package version. fn schema_version(&self) -> SchemaVersion { match self { Version::Unknown => unreachable!(), Version::Legacy1 | Version::Legacy2 => SchemaVersion::V11, Version::Latest => SchemaVersion::V18, } } } pub(in crate::import_export) trait MetaExt: Sized { fn new() -> Self; fn new_legacy() -> Self; fn from_archive(archive: &mut ZipArchive) -> Result; fn collection_filename(&self) -> &'static str; fn schema_version(&self) -> SchemaVersion; fn zstd_compressed(&self) -> bool; fn media_list_is_hashmap(&self) -> bool; fn is_legacy(&self) -> bool; fn copy(&self, reader: &mut impl Read, writer: &mut impl io::Write) -> io::Result<()>; } impl MetaExt for Meta { fn new() -> Self { Self { version: Version::Latest as i32, } } fn new_legacy() -> Self { Self { version: Version::Legacy2 as i32, } } /// Extracts meta data from an archive and checks if its version is /// supported. fn from_archive(archive: &mut ZipArchive) -> Result { let meta_bytes = archive.by_name("meta").ok().and_then(|mut meta_file| { let mut buf = vec![]; meta_file.read_to_end(&mut buf).ok()?; Some(buf) }); let meta = if let Some(bytes) = meta_bytes { let meta: Meta = Message::decode(&*bytes)?; if meta.version() == Version::Unknown { return Err(AnkiError::ImportError { source: ImportError::TooNew, }); } meta } else { Meta { version: if archive.by_name("collection.anki21").is_ok() { Version::Legacy2 } else { Version::Legacy1 } as i32, } }; Ok(meta) } fn collection_filename(&self) -> &'static str { self.version().collection_filename() } /// Latest schema version that is supported by all clients supporting /// this package version. fn schema_version(&self) -> SchemaVersion { self.version().schema_version() } fn zstd_compressed(&self) -> bool { !self.is_legacy() } fn media_list_is_hashmap(&self) -> bool { self.is_legacy() } fn is_legacy(&self) -> bool { matches!(self.version(), Version::Legacy1 | Version::Legacy2) } fn copy(&self, reader: &mut impl Read, writer: &mut impl io::Write) -> io::Result<()> { if self.zstd_compressed() { copy_decode(reader, writer) } else { io::copy(reader, writer).map(|_| ()) } } } ================================================ FILE: rslib/src/import_export/package/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod apkg; mod colpkg; mod media; mod meta; use anki_proto::import_export::media_entries::MediaEntry; pub use anki_proto::import_export::ExportAnkiPackageOptions; pub use anki_proto::import_export::ImportAnkiPackageOptions; pub use anki_proto::import_export::ImportAnkiPackageUpdateCondition as UpdateCondition; use anki_proto::import_export::MediaEntries; pub(crate) use apkg::NoteMeta; pub(crate) use colpkg::export::export_colpkg_from_data; pub use colpkg::import::import_colpkg; pub use media::MediaIter; pub use media::MediaIterEntry; pub use media::MediaIterError; use meta::Meta; use meta::Version; ================================================ FILE: rslib/src/import_export/service.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::generic; use anki_proto::import_export::import_response::Log as NoteLog; use anki_proto::import_export::ExportLimit; use crate::prelude::*; use crate::search::SearchNode; impl crate::services::ImportExportService for Collection { fn import_anki_package( &mut self, input: anki_proto::import_export::ImportAnkiPackageRequest, ) -> Result { self.import_apkg(&input.package_path, input.options.unwrap_or_default()) .map(Into::into) } fn get_import_anki_package_presets( &mut self, ) -> Result { Ok(anki_proto::import_export::ImportAnkiPackageOptions { merge_notetypes: self.get_config_bool(BoolKey::MergeNotetypes), with_scheduling: self.get_config_bool(BoolKey::WithScheduling), with_deck_configs: self.get_config_bool(BoolKey::WithDeckConfigs), update_notes: self.get_update_notes() as i32, update_notetypes: self.get_update_notetypes() as i32, }) } fn export_anki_package( &mut self, input: anki_proto::import_export::ExportAnkiPackageRequest, ) -> Result { self.export_apkg( &input.out_path, input.options.unwrap_or_default(), input.limit.unwrap_or_default(), None, ) .map(Into::into) } fn get_csv_metadata( &mut self, input: anki_proto::import_export::CsvMetadataRequest, ) -> Result { let delimiter = input.delimiter.is_some().then(|| input.delimiter()); self.get_csv_metadata( &input.path, delimiter, input.notetype_id.map(Into::into), input.deck_id.map(Into::into), input.is_html, ) } fn import_csv( &mut self, input: anki_proto::import_export::ImportCsvRequest, ) -> Result { self.import_csv(&input.path, input.metadata.unwrap_or_default()) .map(Into::into) } fn export_note_csv( &mut self, input: anki_proto::import_export::ExportNoteCsvRequest, ) -> Result { self.export_note_csv(input).map(Into::into) } fn export_card_csv( &mut self, input: anki_proto::import_export::ExportCardCsvRequest, ) -> Result { self.export_card_csv( &input.out_path, SearchNode::from(input.limit.unwrap_or_default()), input.with_html, ) .map(Into::into) } fn import_json_file( &mut self, input: generic::String, ) -> Result { self.import_json_file(&input.val).map(Into::into) } fn import_json_string( &mut self, input: generic::String, ) -> Result { self.import_json_string(&input.val).map(Into::into) } } impl From> for anki_proto::import_export::ImportResponse { fn from(output: OpOutput) -> Self { Self { changes: Some(output.changes.into()), log: Some(output.output), } } } impl From for SearchNode { fn from(export_limit: ExportLimit) -> Self { use anki_proto::import_export::export_limit::Limit; let limit = export_limit .limit .unwrap_or(Limit::WholeCollection(generic::Empty {})); match limit { Limit::WholeCollection(_) => Self::WholeCollection, Limit::DeckId(did) => Self::from_deck_id(did, true), Limit::NoteIds(nids) => Self::from_note_ids(nids.note_ids), Limit::CardIds(cids) => Self::from_card_ids(cids.cids), } } } ================================================ FILE: rslib/src/import_export/text/csv/export.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::collections::HashMap; use std::fs::File; use std::io::Write; use std::sync::Arc; use std::sync::LazyLock; use anki_proto::import_export::ExportNoteCsvRequest; use itertools::Itertools; use regex::Regex; use super::metadata::Delimiter; use crate::import_export::text::csv::metadata::DelimeterExt; use crate::import_export::ExportProgress; use crate::notetype::RenderCardOutput; use crate::prelude::*; use crate::search::SearchNode; use crate::search::SortMode; use crate::template::RenderedNode; use crate::text::html_to_text_line; use crate::text::CowMapping; const DELIMITER: Delimiter = Delimiter::Tab; impl Collection { pub fn export_card_csv( &mut self, path: &str, search: impl TryIntoSearch, with_html: bool, ) -> Result { let mut progress = self.new_progress_handler::(); let mut incrementor = progress.incrementor(ExportProgress::Cards); let mut writer = file_writer_with_header(path, with_html)?; let mut cards = self.search_cards(search, SortMode::NoOrder)?; cards.sort_unstable(); for &card in &cards { incrementor.increment()?; writer .write_record(self.card_record(card, with_html)?) .or_invalid("invalid csv")?; } writer.flush()?; Ok(cards.len()) } pub fn export_note_csv(&mut self, mut request: ExportNoteCsvRequest) -> Result { let mut progress = self.new_progress_handler::(); let mut incrementor = progress.incrementor(ExportProgress::Notes); let guard = self.search_notes_into_table(Into::::into(&mut request))?; let ctx = NoteContext::new(&request, guard.col)?; let mut writer = note_file_writer_with_header(&request.out_path, &ctx)?; guard.col.storage.for_each_note_in_search(|note| { incrementor.increment()?; writer .write_record(ctx.record(¬e)) .or_invalid("invalid csv")?; Ok(()) })?; writer.flush()?; Ok(incrementor.count()) } fn card_record(&mut self, card: CardId, with_html: bool) -> Result<[String; 2]> { let RenderCardOutput { qnodes, anodes, .. } = self.render_existing_card(card, false, false)?; Ok([ rendered_nodes_to_record_field(&qnodes, with_html, false), rendered_nodes_to_record_field(&anodes, with_html, true), ]) } } fn file_writer_with_header(path: &str, with_html: bool) -> Result> { let mut file = File::create(path)?; write_file_header(&mut file, with_html)?; Ok(csv::WriterBuilder::new() .delimiter(DELIMITER.byte()) .comment(Some(b'#')) .from_writer(file)) } fn write_file_header(writer: &mut impl Write, with_html: bool) -> Result<()> { writeln!(writer, "#separator:{}", DELIMITER.name())?; writeln!(writer, "#html:{with_html}")?; Ok(()) } fn note_file_writer_with_header(path: &str, ctx: &NoteContext) -> Result> { let mut file = File::create(path)?; write_note_file_header(&mut file, ctx)?; Ok(csv::WriterBuilder::new() .delimiter(DELIMITER.byte()) .comment(Some(b'#')) .from_writer(file)) } fn write_note_file_header(writer: &mut impl Write, ctx: &NoteContext) -> Result<()> { write_file_header(writer, ctx.with_html)?; write_column_header(ctx, writer) } fn write_column_header(ctx: &NoteContext, writer: &mut impl Write) -> Result<()> { for (name, column) in [ ("guid", ctx.guid_column()), ("notetype", ctx.notetype_column()), ("deck", ctx.deck_column()), ("tags", ctx.tags_column()), ] { if let Some(index) = column { writeln!(writer, "#{name} column:{index}")?; } } Ok(()) } fn rendered_nodes_to_record_field( nodes: &[RenderedNode], with_html: bool, answer_side: bool, ) -> String { let text = rendered_nodes_to_str(nodes); let mut text = strip_redundant_sections(&text); if answer_side { text = text.map_cow(strip_answer_side_question); } if !with_html { text = text.map_cow(|t| html_to_text_line(t, false)); } text.into() } fn rendered_nodes_to_str(nodes: &[RenderedNode]) -> String { nodes .iter() .map(|node| match node { RenderedNode::Text { text } => text, RenderedNode::Replacement { current_text, .. } => current_text, }) .join("") } fn field_to_record_field(field: &str, with_html: bool) -> Cow<'_, str> { let mut text = strip_redundant_sections(field); if !with_html { text = text.map_cow(|t| html_to_text_line(t, false)); } text } fn strip_redundant_sections(text: &str) -> Cow<'_, str> { static RE: LazyLock = LazyLock::new(|| { Regex::new( r"(?isx) # style elements | \[\[type:[^]]+\]\] # type replacements ", ) .unwrap() }); RE.replace_all(text.as_ref(), "") } fn strip_answer_side_question(text: &str) -> Cow<'_, str> { static RE: LazyLock = LazyLock::new(|| Regex::new(r"(?is)^.*
\n*").unwrap()); RE.replace_all(text.as_ref(), "") } struct NoteContext { with_html: bool, with_tags: bool, with_deck: bool, with_notetype: bool, with_guid: bool, notetypes: HashMap>, deck_ids: HashMap, deck_names: HashMap, field_columns: usize, } impl NoteContext { /// Caller must have searched notes into table. fn new(request: &ExportNoteCsvRequest, col: &mut Collection) -> Result { let notetypes = col.get_all_notetypes_of_search_notes()?; let field_columns = notetypes .values() .map(|nt| nt.fields.len()) .max() .unwrap_or_default(); let deck_ids = col.storage.all_decks_of_search_notes()?; let deck_names = HashMap::from_iter(col.storage.get_all_deck_names()?); Ok(Self { with_html: request.with_html, with_tags: request.with_tags, with_deck: request.with_deck, with_notetype: request.with_notetype, with_guid: request.with_guid, notetypes, field_columns, deck_ids, deck_names, }) } fn guid_column(&self) -> Option { self.with_guid.then_some(1) } fn notetype_column(&self) -> Option { self.with_notetype .then(|| 1 + self.guid_column().unwrap_or_default()) } fn deck_column(&self) -> Option { self.with_deck.then(|| { 1 + self .notetype_column() .or_else(|| self.guid_column()) .unwrap_or_default() }) } fn tags_column(&self) -> Option { self.with_tags.then(|| { 1 + self .deck_column() .or_else(|| self.notetype_column()) .or_else(|| self.guid_column()) .unwrap_or_default() + self.field_columns }) } fn record<'c, 's: 'c, 'n: 'c>(&'s self, note: &'n Note) -> impl Iterator> { self.with_guid .then(|| Cow::from(note.guid.as_bytes())) .into_iter() .chain(self.notetype_name(note)) .chain(self.deck_name(note)) .chain(self.note_fields(note)) .chain(self.tags(note)) } fn notetype_name(&self, note: &Note) -> Option> { self.with_notetype.then(|| { self.notetypes .get(¬e.notetype_id) .map_or(Cow::from(vec![]), |nt| Cow::from(nt.name.as_bytes())) }) } fn deck_name(&self, note: &Note) -> Option> { self.with_deck.then(|| { self.deck_ids .get(¬e.id) .and_then(|did| self.deck_names.get(did)) .map_or(Cow::from(vec![]), |name| Cow::from(name.as_bytes())) }) } fn tags(&self, note: &Note) -> Option> { self.with_tags .then(|| Cow::from(note.tags.join(" ").into_bytes())) } fn note_fields<'n>(&self, note: &'n Note) -> impl Iterator> { let with_html = self.with_html; note.fields() .iter() .map(move |f| field_to_record_field(f, with_html)) .pad_using(self.field_columns, |_| Cow::from("")) .map(|cow| match cow { Cow::Borrowed(s) => Cow::from(s.as_bytes()), Cow::Owned(s) => Cow::from(s.into_bytes()), }) } } impl From<&mut ExportNoteCsvRequest> for SearchNode { fn from(req: &mut ExportNoteCsvRequest) -> Self { SearchNode::from(req.limit.take().unwrap_or_default()) } } ================================================ FILE: rslib/src/import_export/text/csv/import.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashSet; use std::io::BufRead; use std::io::BufReader; use std::io::Read; use std::io::Seek; use std::io::SeekFrom; use anki_io::open_file; use crate::import_export::text::csv::metadata::CsvDeck; use crate::import_export::text::csv::metadata::CsvMetadata; use crate::import_export::text::csv::metadata::CsvMetadataHelpers; use crate::import_export::text::csv::metadata::CsvNotetype; use crate::import_export::text::csv::metadata::DelimeterExt; use crate::import_export::text::csv::metadata::Delimiter; use crate::import_export::text::ForeignData; use crate::import_export::text::ForeignNote; use crate::import_export::text::NameOrId; use crate::import_export::NoteLog; use crate::prelude::*; use crate::text::strip_utf8_bom; impl Collection { pub fn import_csv(&mut self, path: &str, metadata: CsvMetadata) -> Result> { let progress = self.new_progress_handler(); let file = open_file(path)?; let mut ctx = ColumnContext::new(&metadata)?; let notes = ctx.deserialize_csv(file, metadata.delimiter())?; let mut data = ForeignData::from(metadata); data.notes = notes; data.import(self, progress) } } impl From for ForeignData { fn from(metadata: CsvMetadata) -> Self { ForeignData { dupe_resolution: metadata.dupe_resolution(), match_scope: metadata.match_scope(), default_deck: metadata.deck().map(|d| d.name_or_id()).unwrap_or_default(), default_notetype: metadata .notetype() .map(|nt| nt.name_or_id()) .unwrap_or_default(), global_tags: metadata.global_tags, updated_tags: metadata.updated_tags, ..Default::default() } } } trait CsvDeckExt { fn name_or_id(&self) -> NameOrId; fn column(&self) -> Option; } impl CsvDeckExt for CsvDeck { fn name_or_id(&self) -> NameOrId { match self { Self::DeckId(did) => NameOrId::Id(*did), Self::DeckColumn(_) => NameOrId::default(), Self::DeckName(name) => NameOrId::Name(name.into()), } } fn column(&self) -> Option { match self { Self::DeckId(_) => None, Self::DeckColumn(column) => Some(*column as usize), Self::DeckName(_) => None, } } } trait CsvNotetypeExt { fn name_or_id(&self) -> NameOrId; fn column(&self) -> Option; } impl CsvNotetypeExt for CsvNotetype { fn name_or_id(&self) -> NameOrId { match self { Self::GlobalNotetype(nt) => NameOrId::Id(nt.id), Self::NotetypeColumn(_) => NameOrId::default(), } } fn column(&self) -> Option { match self { Self::GlobalNotetype(_) => None, Self::NotetypeColumn(column) => Some(*column as usize), } } } /// Column indices for the fields of a notetype. pub(super) type FieldSourceColumns = Vec>; // Column indices are 1-based. struct ColumnContext { tags_column: Option, guid_column: Option, deck_column: Option, notetype_column: Option, /// Source column indices for the fields of a notetype field_source_columns: FieldSourceColumns, /// Metadata column indices (1-based) meta_columns: HashSet, /// How fields are converted to strings. Used for escaping HTML if /// appropriate. stringify: fn(&str) -> String, } impl ColumnContext { fn new(metadata: &CsvMetadata) -> Result { Ok(Self { tags_column: (metadata.tags_column > 0).then_some(metadata.tags_column as usize), guid_column: (metadata.guid_column > 0).then_some(metadata.guid_column as usize), deck_column: metadata.deck()?.column(), notetype_column: metadata.notetype()?.column(), field_source_columns: metadata.field_source_columns()?, meta_columns: metadata.meta_columns(), stringify: stringify_fn(metadata.is_html), }) } fn deserialize_csv( &mut self, reader: impl Read + Seek, delimiter: Delimiter, ) -> Result> { let mut csv_reader = build_csv_reader(reader, delimiter)?; self.deserialize_csv_reader(&mut csv_reader) } fn deserialize_csv_reader( &mut self, reader: &mut csv::Reader, ) -> Result> { reader .records() .map(|res| { res.or_invalid("invalid csv") .map(|record| self.foreign_note_from_record(&record)) }) .collect() } fn foreign_note_from_record(&self, record: &csv::StringRecord) -> ForeignNote { ForeignNote { notetype: name_or_id_from_record_column(self.notetype_column, record), fields: self.gather_note_fields(record), tags: self.gather_tags(record), deck: name_or_id_from_record_column(self.deck_column, record), guid: str_from_record_column(self.guid_column, record), ..Default::default() } } fn gather_tags(&self, record: &csv::StringRecord) -> Option> { self.tags_column.and_then(|i| record.get(i - 1)).map(|s| { s.split_whitespace() .filter(|s| !s.is_empty()) .map(ToString::to_string) .collect() }) } fn gather_note_fields(&self, record: &csv::StringRecord) -> Vec> { let op = |i| record.get(i - 1).map(self.stringify); if !self.field_source_columns.is_empty() { self.field_source_columns .iter() .map(|opt| opt.and_then(op)) .collect() } else { // notetype column provided, assume all non-metadata columns are notetype fields (1..=record.len()) .filter(|i| !self.meta_columns.contains(i)) .map(op) .collect() } } } fn str_from_record_column(column: Option, record: &csv::StringRecord) -> String { column .and_then(|i| record.get(i - 1)) .unwrap_or_default() .to_string() } fn name_or_id_from_record_column(column: Option, record: &csv::StringRecord) -> NameOrId { NameOrId::parse(column.and_then(|i| record.get(i - 1)).unwrap_or_default()) } pub(super) fn build_csv_reader( mut reader: impl Read + Seek, delimiter: Delimiter, ) -> Result> { remove_tags_line_from_reader(&mut reader)?; Ok(csv::ReaderBuilder::new() .has_headers(false) .flexible(true) .comment(Some(b'#')) .delimiter(delimiter.byte()) .from_reader(reader)) } fn stringify_fn(is_html: bool) -> fn(&str) -> String { if is_html { ToString::to_string } else { |s| htmlescape::encode_minimal(s).replace('\n', "
") } } /// If the reader's first line starts with "tags:", which is allowed for /// historic reasons, seek to the second line. fn remove_tags_line_from_reader(reader: &mut (impl Read + Seek)) -> Result<()> { let mut buf_reader = BufReader::new(reader); let mut first_line = String::new(); buf_reader.read_line(&mut first_line)?; let offset = if strip_utf8_bom(&first_line).starts_with("tags:") { first_line.len() } else { 0 }; buf_reader .into_inner() .seek(SeekFrom::Start(offset as u64))?; Ok(()) } #[cfg(test)] mod test { use std::io::Cursor; use anki_proto::import_export::csv_metadata::MappedNotetype; use super::super::metadata::test::CsvMetadataTestExt; use super::*; macro_rules! import { ($metadata:expr, $csv:expr) => {{ let reader = Cursor::new($csv); let delimiter = $metadata.delimiter(); let mut ctx = ColumnContext::new(&$metadata).unwrap(); ctx.deserialize_csv(reader, delimiter).unwrap() }}; } macro_rules! assert_imported_fields { ($metadata:expr, $csv:expr, $expected:expr) => { let notes = import!(&$metadata, $csv); let fields: Vec<_> = notes.into_iter().map(|note| note.fields).collect(); assert_eq!(fields.len(), $expected.len()); for (note_fields, note_expected) in fields.iter().zip($expected.iter()) { assert_field_eq!(note_fields, note_expected); } }; } macro_rules! assert_field_eq { ($fields:expr, $expected:expr) => { assert_eq!($fields.len(), $expected.len()); for (field, expected) in $fields.iter().zip($expected.iter()) { assert_eq!(&field.as_ref().map(String::as_str), expected); } }; } #[test] fn should_allow_missing_columns() { let metadata = CsvMetadata::defaults_for_testing(); assert_imported_fields!(metadata, "foo\n", [[Some("foo"), None]]); } #[test] fn should_respect_custom_delimiter() { let mut metadata = CsvMetadata::defaults_for_testing(); metadata.set_delimiter(Delimiter::Pipe); assert_imported_fields!( metadata, "fr,ont|ba,ck\n", [[Some("fr,ont"), Some("ba,ck")]] ); } #[test] fn should_ignore_first_line_starting_with_tags() { let metadata = CsvMetadata::defaults_for_testing(); assert_imported_fields!( metadata, "tags:foo\nfront,back\n", [[Some("front"), Some("back")]] ); } #[test] fn should_respect_column_remapping() { let mut metadata = CsvMetadata::defaults_for_testing(); metadata .notetype .replace(CsvNotetype::GlobalNotetype(MappedNotetype { id: 1, field_columns: vec![3, 1], })); assert_imported_fields!( metadata, "front,foo,back\n", [[Some("back"), Some("front")]] ); } #[test] fn should_ignore_lines_starting_with_number_sign() { let metadata = CsvMetadata::defaults_for_testing(); assert_imported_fields!( metadata, "#foo\nfront,back\n#bar\n", [[Some("front"), Some("back")]] ); } #[test] fn should_escape_html_entities_if_csv_is_html() { let mut metadata = CsvMetadata::defaults_for_testing(); assert_imported_fields!(metadata, "
\n", [[Some("<hr>"), None]]); metadata.is_html = true; assert_imported_fields!(metadata, "
\n", [[Some("
"), None]]); } #[test] fn should_parse_tag_column() { let mut metadata = CsvMetadata::defaults_for_testing(); metadata.tags_column = 3; let notes = import!(metadata, "front,back,foo bar\n"); assert_eq!(notes[0].tags.as_ref().unwrap(), &["foo", "bar"]); } #[test] fn should_parse_deck_column() { let mut metadata = CsvMetadata::defaults_for_testing(); metadata.deck.replace(CsvDeck::DeckColumn(1)); let notes = import!(metadata, "front,back\n"); assert_eq!(notes[0].deck, NameOrId::Name(String::from("front"))); } #[test] fn should_parse_notetype_column() { let mut metadata = CsvMetadata::defaults_for_testing(); metadata.notetype.replace(CsvNotetype::NotetypeColumn(1)); metadata.column_labels.push("".to_string()); let notes = import!(metadata, "Basic,front,back\nCloze,foo,bar\n"); assert_field_eq!(notes[0].fields, [Some("front"), Some("back")]); assert_eq!(notes[0].notetype, NameOrId::Name(String::from("Basic"))); assert_field_eq!(notes[1].fields, [Some("foo"), Some("bar")]); assert_eq!(notes[1].notetype, NameOrId::Name(String::from("Cloze"))); } #[test] fn should_ignore_bom() { let metadata = CsvMetadata::defaults_for_testing(); assert_imported_fields!(metadata, "\u{feff}foo,bar\n", [[Some("foo"), Some("bar")]]); assert!(import!(metadata, "\u{feff}#foo\n").is_empty()); assert!(import!(metadata, "\u{feff}#html:true\n").is_empty()); assert!(import!(metadata, "\u{feff}tags:foo\n").is_empty()); } } ================================================ FILE: rslib/src/import_export/text/csv/metadata.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::collections::HashSet; use std::io::BufRead; use std::io::BufReader; use std::io::Cursor; use std::io::Read; use std::io::Seek; use std::io::SeekFrom; use anki_io::read_to_string; pub use anki_proto::import_export::csv_metadata::Deck as CsvDeck; pub use anki_proto::import_export::csv_metadata::Delimiter; pub use anki_proto::import_export::csv_metadata::DupeResolution; pub use anki_proto::import_export::csv_metadata::MappedNotetype; pub use anki_proto::import_export::csv_metadata::MatchScope; pub use anki_proto::import_export::csv_metadata::Notetype as CsvNotetype; pub use anki_proto::import_export::CsvMetadata; use itertools::Itertools; use strum::IntoEnumIterator; use super::import::build_csv_reader; use crate::config::I32ConfigKey; use crate::import_export::text::csv::import::FieldSourceColumns; use crate::import_export::text::NameOrId; use crate::import_export::ImportError; use crate::notetype::NoteField; use crate::prelude::*; use crate::text::html_to_text_line; use crate::text::is_html; use crate::text::strip_utf8_bom; /// The maximum number of preview rows. const PREVIEW_LENGTH: usize = 5; /// The maximum number of characters per preview field. const PREVIEW_FIELD_LENGTH: usize = 80; impl Collection { pub fn get_csv_metadata( &mut self, path: &str, delimiter: Option, notetype_id: Option, deck_id: Option, is_html: Option, ) -> Result { let text = read_to_string(path)?; let mut reader = Cursor::new(text); let meta = self.get_reader_metadata(&mut reader, delimiter, notetype_id, deck_id, is_html)?; if meta.preview.is_empty() { return Err(ImportError::EmptyFile.into()); } Ok(meta) } fn get_reader_metadata( &mut self, mut reader: impl Read + Seek, delimiter: Option, notetype_id: Option, deck_id: Option, is_html: Option, ) -> Result { let mut metadata = CsvMetadata::from_config(self); let meta_len = self.parse_meta_lines(&mut reader, &mut metadata)? as u64; maybe_set_fallback_delimiter(delimiter, &mut metadata, &mut reader, meta_len)?; let records = collect_preview_records(&mut metadata, reader)?; maybe_set_fallback_is_html(&mut metadata, &records, is_html)?; set_preview(&mut metadata, &records)?; maybe_set_fallback_columns(&mut metadata)?; self.maybe_set_notetype_and_deck(&mut metadata, notetype_id, deck_id)?; self.maybe_init_notetype_map(&mut metadata)?; Ok(metadata) } /// Parses the meta head of the file and returns the total of meta bytes. fn parse_meta_lines(&mut self, reader: impl Read, metadata: &mut CsvMetadata) -> Result { let mut meta_len = 0; let mut reader = BufReader::new(reader); let mut line = String::new(); let mut line_len = reader.read_line(&mut line)?; if self.parse_first_line(&line, metadata) { meta_len += line_len; line.clear(); line_len = reader.read_line(&mut line)?; while self.parse_line(&line, metadata) { meta_len += line_len; line.clear(); line_len = reader.read_line(&mut line)?; } } Ok(meta_len) } /// True if the line is a meta line, i.e. a comment, or starting with /// 'tags:'. fn parse_first_line(&mut self, line: &str, metadata: &mut CsvMetadata) -> bool { let line = strip_utf8_bom(line); if let Some(tags) = line.strip_prefix("tags:") { metadata.global_tags = collect_tags(tags); true } else { self.parse_line(line, metadata) } } /// True if the line is a comment. fn parse_line(&mut self, line: &str, metadata: &mut CsvMetadata) -> bool { if let Some(l) = line.strip_prefix('#') { if let Some((key, value)) = l.split_once(':') { self.parse_meta_value(key, strip_line_ending(value), metadata); } true } else { false } } fn parse_meta_value(&mut self, key: &str, value: &str, metadata: &mut CsvMetadata) { // trim potential delimiters past the first char* if // metadata line was mistakenly exported as a record // *to allow cases like #separator:, // ASSUMPTION: delimiters are not ascii-alphanumeric let trimmed_value = value .char_indices() .nth(1) .and_then(|(i, _)| { value[i..] // SAFETY: char_indices are on char boundaries .find(|c| !char::is_ascii_alphanumeric(&c)) .map(|j| value.split_at(i + j).0) }) .unwrap_or(value); match key.trim().to_ascii_lowercase().as_str() { "separator" => { if let Some(delimiter) = delimiter_from_value(trimmed_value) { metadata.delimiter = delimiter as i32; metadata.force_delimiter = true; } } "html" => { if let Ok(is_html) = trimmed_value.to_lowercase().parse() { metadata.is_html = is_html; metadata.force_is_html = true; } } // freeform values cannot be trimmed thus without knowing the exact delimiter "tags" => metadata.global_tags = collect_tags(value), "columns" => { if let Ok(columns) = parse_columns(value, metadata.delimiter()) { metadata.column_labels = columns; } } "notetype" => { if let Ok(Some(nt)) = self.notetype_by_name_or_id(&NameOrId::parse(value)) { metadata.notetype = Some(new_global_csv_notetype(nt.id)); } } "deck" => { if let Ok(Some(did)) = self.deck_id_by_name_or_id(&NameOrId::parse(value)) { metadata.deck = Some(CsvDeck::DeckId(did.0)); } else if !value.is_empty() { metadata.deck = Some(CsvDeck::DeckName(value.to_string())); } } "notetype column" => { if let Ok(n) = trimmed_value.trim().parse() { metadata.notetype = Some(CsvNotetype::NotetypeColumn(n)); } } "deck column" => { if let Ok(n) = trimmed_value.trim().parse() { metadata.deck = Some(CsvDeck::DeckColumn(n)); } } "tags column" => { if let Ok(n) = trimmed_value.trim().parse() { metadata.tags_column = n; } } "guid column" => { if let Ok(n) = trimmed_value.trim().parse() { metadata.guid_column = n; } } "match scope" => { if let Some(scope) = MatchScope::from_text(value) { metadata.match_scope = scope as i32; } } "if matches" => { if let Some(resolution) = DupeResolution::from_text(value) { metadata.dupe_resolution = resolution as i32; } } _ => (), } } /// Ensure notetype and deck are set. /// /// - When the UI is first loaded, both notetype and deck arguments will be /// None. /// - When the UI refreshes due to user changes, the currently-selected deck /// and notetype will be provided. /// - Metadata may already have deck and notetype set, if those directives /// were present in the file to import. In the UI refresh case, we /// override them with the current UI values, so that the user can adjust /// the deck/notetype if they wish. /// - In the initial load case, if notetype/deck were not specified in file, /// we apply the defaults from defaults_for_adding(). pub(crate) fn maybe_set_notetype_and_deck( &mut self, metadata: &mut anki_proto::import_export::CsvMetadata, notetype_id: Option, deck_id: Option, ) -> Result<()> { let defaults = self.defaults_for_adding(DeckId(0))?; if metadata.notetype.is_none() || notetype_id.is_some() { metadata.notetype = Some(new_global_csv_notetype( notetype_id.unwrap_or(defaults.notetype_id), )); } if metadata.deck.is_none() || deck_id.is_some() { metadata.deck = Some(CsvDeck::DeckId(deck_id.unwrap_or(defaults.deck_id).0)); } Ok(()) } fn maybe_init_notetype_map(&mut self, metadata: &mut CsvMetadata) -> Result<()> { let meta_columns = metadata.meta_columns(); if let Some(CsvNotetype::GlobalNotetype(ref mut global)) = metadata.notetype { let notetype = self .get_notetype(NotetypeId(global.id))? .or_not_found(NotetypeId(global.id))?; global.field_columns = vec![0; notetype.fields.len()]; global.field_columns[0] = 1; let column_len = metadata.column_labels.len(); if metadata.column_labels.iter().all(String::is_empty) { map_field_columns_by_index(&mut global.field_columns, column_len, &meta_columns); } else { map_field_columns_by_name( &mut global.field_columns, &metadata.column_labels, &meta_columns, ¬etype.fields, ); } ensure_first_field_is_mapped(&mut global.field_columns, column_len, &meta_columns)?; maybe_set_tags_column(metadata, &meta_columns); } Ok(()) } } pub(super) trait CsvMetadataHelpers { fn from_config(col: &Collection) -> Self; fn deck(&self) -> Result<&CsvDeck>; fn notetype(&self) -> Result<&CsvNotetype>; fn field_source_columns(&self) -> Result; fn meta_columns(&self) -> HashSet; } impl CsvMetadataHelpers for CsvMetadata { /// Defaults with config values filled in. fn from_config(col: &Collection) -> Self { Self { dupe_resolution: DupeResolution::from_config(col) as i32, match_scope: MatchScope::from_config(col) as i32, ..Default::default() } } fn deck(&self) -> Result<&CsvDeck> { self.deck.as_ref().or_invalid("deck oneof not set") } fn notetype(&self) -> Result<&CsvNotetype> { self.notetype.as_ref().or_invalid("notetype oneof not set") } fn field_source_columns(&self) -> Result { Ok(match self.notetype()? { CsvNotetype::GlobalNotetype(global) => global .field_columns .iter() .map(|&i| (i > 0).then_some(i as usize)) .collect(), CsvNotetype::NotetypeColumn(_) => { // each row's notetype could have varying number of fields vec![] } }) } fn meta_columns(&self) -> HashSet { let mut columns = HashSet::new(); if let Some(CsvDeck::DeckColumn(deck_column)) = self.deck { columns.insert(deck_column as usize); } if let Some(CsvNotetype::NotetypeColumn(notetype_column)) = self.notetype { columns.insert(notetype_column as usize); } if self.tags_column > 0 { columns.insert(self.tags_column as usize); } if self.guid_column > 0 { columns.insert(self.guid_column as usize); } columns } } pub(super) trait DupeResolutionExt: Sized { fn from_config(col: &Collection) -> Self; fn from_text(text: &str) -> Option; } impl DupeResolutionExt for DupeResolution { fn from_config(col: &Collection) -> Self { Self::try_from(col.get_config_i32(I32ConfigKey::CsvDuplicateResolution)).unwrap_or_default() } fn from_text(text: &str) -> Option { match text { "update current" => Some(Self::Update), "keep current" => Some(Self::Preserve), "keep both" => Some(Self::Duplicate), _ => None, } } } pub(super) trait MatchScopeExt: Sized { fn from_config(col: &Collection) -> Self; fn from_text(text: &str) -> Option; } impl MatchScopeExt for MatchScope { fn from_config(col: &Collection) -> Self { Self::try_from(col.get_config_i32(I32ConfigKey::MatchScope)).unwrap_or_default() } fn from_text(text: &str) -> Option { match text { "notetype" => Some(Self::Notetype), "notetype + deck" => Some(Self::NotetypeAndDeck), _ => None, } } } fn parse_columns(line: &str, delimiter: Delimiter) -> Result> { map_single_record(line, delimiter, |record| { record.iter().map(ToString::to_string).collect() }) } fn collect_preview_records( metadata: &mut CsvMetadata, mut reader: impl Read + Seek, ) -> Result> { reader.rewind()?; let mut csv_reader = build_csv_reader(reader, metadata.delimiter())?; csv_reader .records() .take(PREVIEW_LENGTH) .collect::>() .or_invalid("invalid csv") } fn set_preview(metadata: &mut CsvMetadata, records: &[csv::StringRecord]) -> Result<()> { let mut min_len = 1; metadata.preview = records .iter() .enumerate() .map(|(idx, record)| { let row = build_preview_row(min_len, record, metadata.is_html); if idx == 0 { min_len = row.vals.len(); } row }) .collect(); Ok(()) } fn build_preview_row( min_len: usize, record: &csv::StringRecord, strip_html: bool, ) -> anki_proto::generic::StringList { anki_proto::generic::StringList { vals: record .iter() .pad_using(min_len, |_| "") .map(|field| { if strip_html { html_to_text_line(field, true) .chars() .take(PREVIEW_FIELD_LENGTH) .collect() } else { field.chars().take(PREVIEW_FIELD_LENGTH).collect() } }) .collect(), } } pub(super) fn collect_tags(txt: &str) -> Vec { txt.split_whitespace() .filter(|s| !s.is_empty()) .map(ToString::to_string) .collect() } fn map_field_columns_by_index( field_columns: &mut [u32], column_len: usize, meta_columns: &HashSet, ) { let mut field_columns = field_columns.iter_mut(); for index in 1..column_len + 1 { if !meta_columns.contains(&index) { if let Some(field_column) = field_columns.next() { *field_column = index as u32; } else { break; } } } } fn map_field_columns_by_name( field_columns: &mut [u32], column_labels: &[String], meta_columns: &HashSet, note_fields: &[NoteField], ) { let columns: HashMap<&str, usize> = HashMap::from_iter( column_labels .iter() .enumerate() .map(|(idx, s)| (s.as_str(), idx + 1)) .filter(|(_, idx)| !meta_columns.contains(idx)), ); for (column, field) in field_columns.iter_mut().zip(note_fields) { if let Some(index) = columns.get(field.name.as_str()) { *column = *index as u32; } } } fn ensure_first_field_is_mapped( field_columns: &mut [u32], column_len: usize, meta_columns: &HashSet, ) -> Result<()> { if field_columns[0] == 0 { field_columns[0] = (1..column_len + 1) .find(|i| !meta_columns.contains(i)) .ok_or(AnkiError::ImportError { source: ImportError::NoFieldColumn, })? as u32; } Ok(()) } fn maybe_set_fallback_columns(metadata: &mut CsvMetadata) -> Result<()> { if metadata.column_labels.is_empty() { metadata.column_labels = vec![String::new(); metadata.preview.first().map_or(0, |row| row.vals.len())]; } Ok(()) } fn maybe_set_fallback_is_html( metadata: &mut CsvMetadata, records: &[csv::StringRecord], is_html_option: Option, ) -> Result<()> { if let Some(is_html) = is_html_option { metadata.is_html = is_html; } else if !metadata.force_is_html { metadata.is_html = records.iter().flat_map(|record| record.iter()).any(is_html); } Ok(()) } fn maybe_set_fallback_delimiter( delimiter: Option, metadata: &mut CsvMetadata, mut reader: impl Read + Seek, meta_len: u64, ) -> Result<()> { if let Some(delim) = delimiter { metadata.set_delimiter(delim); } else if !metadata.force_delimiter { reader.seek(SeekFrom::Start(meta_len))?; metadata.set_delimiter(delimiter_from_reader(reader)?); } Ok(()) } fn maybe_set_tags_column(metadata: &mut CsvMetadata, meta_columns: &HashSet) { if metadata.tags_column == 0 { if let Some(CsvNotetype::GlobalNotetype(ref global)) = metadata.notetype { let max_field = global.field_columns.iter().max().copied().unwrap_or(0); for idx in (max_field + 1) as usize..=metadata.column_labels.len() { if !meta_columns.contains(&idx) { metadata.tags_column = idx as u32; break; } } } } } fn delimiter_from_value(value: &str) -> Option { let normed = value.to_ascii_lowercase(); Delimiter::iter().find(|&delimiter| { normed.trim() == delimiter.name() || normed.as_bytes() == [delimiter.byte()] }) } fn delimiter_from_reader(mut reader: impl Read) -> Result { let mut buf = [0; 8 * 1024]; let _ = reader.read(&mut buf)?; // TODO: use smarter heuristic for delimiter in Delimiter::iter() { if buf.contains(&delimiter.byte()) { return Ok(delimiter); } } Ok(Delimiter::Space) } fn map_single_record( line: &str, delimiter: Delimiter, op: impl FnOnce(&csv::StringRecord) -> T, ) -> Result { csv::ReaderBuilder::new() .delimiter(delimiter.byte()) .from_reader(line.as_bytes()) .headers() .map_err(|_| AnkiError::ImportError { source: ImportError::Corrupt, }) .map(op) } fn strip_line_ending(line: &str) -> &str { line.strip_suffix("\r\n") .unwrap_or_else(|| line.strip_suffix('\n').unwrap_or(line)) } pub(super) trait DelimeterExt { fn byte(self) -> u8; fn name(self) -> &'static str; } impl DelimeterExt for Delimiter { fn byte(self) -> u8 { match self { Delimiter::Comma => b',', Delimiter::Semicolon => b';', Delimiter::Tab => b'\t', Delimiter::Space => b' ', Delimiter::Pipe => b'|', Delimiter::Colon => b':', } } fn name(self) -> &'static str { match self { Delimiter::Comma => "comma", Delimiter::Semicolon => "semicolon", Delimiter::Tab => "tab", Delimiter::Space => "space", Delimiter::Pipe => "pipe", Delimiter::Colon => "colon", } } } fn new_global_csv_notetype(id: NotetypeId) -> CsvNotetype { CsvNotetype::GlobalNotetype(MappedNotetype { id: id.0, field_columns: Vec::new(), }) } impl NameOrId { pub fn parse(s: &str) -> Self { if let Ok(id) = s.parse() { Self::Id(id) } else { Self::Name(s.to_string()) } } } #[cfg(test)] pub(in crate::import_export) mod test { use std::io::Cursor; use super::*; macro_rules! metadata { ($col:expr,$csv:expr) => { metadata!($col, $csv, None) }; ($col:expr,$csv:expr, $delim:expr) => { $col.get_reader_metadata(Cursor::new($csv.as_bytes()), $delim, None, None, None) .unwrap() }; } pub trait CsvMetadataTestExt { fn defaults_for_testing() -> Self; fn unwrap_deck_id(&self) -> i64; fn unwrap_deck_name(&self) -> &str; fn unwrap_notetype_id(&self) -> i64; fn unwrap_notetype_map(&self) -> &[u32]; } impl CsvMetadataTestExt for CsvMetadata { fn defaults_for_testing() -> Self { Self { delimiter: Delimiter::Comma as i32, force_delimiter: false, is_html: false, force_is_html: false, tags_column: 0, guid_column: 0, global_tags: Vec::new(), updated_tags: Vec::new(), column_labels: vec!["".to_string(); 2], deck: Some(CsvDeck::DeckId(1)), notetype: Some(CsvNotetype::GlobalNotetype(MappedNotetype { id: 1, field_columns: vec![1, 2], })), preview: Vec::new(), dupe_resolution: 0, match_scope: 0, } } fn unwrap_deck_id(&self) -> i64 { match self.deck { Some(CsvDeck::DeckId(did)) => did, _ => panic!("no deck id"), } } fn unwrap_deck_name(&self) -> &str { match &self.deck { Some(CsvDeck::DeckName(name)) => name, _ => panic!("no deck name"), } } fn unwrap_notetype_id(&self) -> i64 { match self.notetype { Some(CsvNotetype::GlobalNotetype(ref nt)) => nt.id, _ => panic!("no notetype id"), } } fn unwrap_notetype_map(&self) -> &[u32] { match &self.notetype { Some(CsvNotetype::GlobalNotetype(nt)) => &nt.field_columns, _ => panic!("no notetype map"), } } } #[test] fn should_detect_deck_by_name_or_id() { let mut col = Collection::new(); let deck_id = col.get_or_create_normal_deck("my deck").unwrap().id.0; assert_eq!(metadata!(col, "#deck:my deck\n").unwrap_deck_id(), deck_id); assert_eq!( metadata!(col, format!("#deck:{deck_id}\n")).unwrap_deck_id(), deck_id ); // unknown deck assert_eq!(metadata!(col, "#deck:foo\n").unwrap_deck_name(), "foo"); assert_eq!(metadata!(col, "#deck:1234\n").unwrap_deck_name(), "1234"); // fallback assert_eq!(metadata!(col, "#deck:\n").unwrap_deck_id(), 1); assert_eq!(metadata!(col, "\n").unwrap_deck_id(), 1); } #[test] fn should_detect_notetype_by_name_or_id() { let mut col = Collection::new(); let basic_id = col.get_notetype_by_name("Basic").unwrap().unwrap().id.0; assert_eq!( metadata!(col, "#notetype:Basic\n").unwrap_notetype_id(), basic_id ); assert_eq!( metadata!(col, &format!("#notetype:{basic_id}\n")).unwrap_notetype_id(), basic_id ); } #[test] fn should_fallback_to_parsing_deck_ids_as_deck_names() { let mut col = Collection::new(); let numeric_deck_id = col.get_or_create_normal_deck("123456789").unwrap().id.0; let numeric_deck_2_id = col .get_or_create_normal_deck(&numeric_deck_id.to_string()) .unwrap() .id .0; assert_eq!( metadata!(col, "#deck:123456789\n").unwrap_deck_id(), numeric_deck_id ); // parsed as id first, fallback to name after assert_eq!( metadata!(col, format!("#deck:{numeric_deck_id}\n")).unwrap_deck_id(), numeric_deck_id ); assert_eq!( metadata!(col, format!("#deck:{numeric_deck_2_id}\n")).unwrap_deck_id(), numeric_deck_2_id ); assert_eq!( metadata!(col, format!("#deck:1234\n")).unwrap_deck_name(), "1234" ); } #[test] fn should_detect_valid_delimiters() { let mut col = Collection::new(); assert_eq!( metadata!(col, "#separator:comma\n").delimiter(), Delimiter::Comma ); assert_eq!( metadata!(col, "#separator:\t\n").delimiter(), Delimiter::Tab ); // fallback assert_eq!( metadata!(col, "#separator:foo\n").delimiter(), Delimiter::Space ); assert_eq!( metadata!(col, "#separator:♥\n").delimiter(), Delimiter::Space ); // pick up from first line assert_eq!(metadata!(col, "foo\tbar\n").delimiter(), Delimiter::Tab); // override with provided assert_eq!( metadata!(col, "#separator: \nfoo\tbar\n", Some(Delimiter::Pipe)).delimiter(), Delimiter::Pipe ); } #[test] fn should_enforce_valid_html_flag() { let mut col = Collection::new(); let meta = metadata!(col, "#html:true\n"); assert!(meta.is_html); assert!(meta.force_is_html); let meta = metadata!(col, "#html:FALSE\n"); assert!(!meta.is_html); assert!(meta.force_is_html); assert!(!metadata!(col, "#html:maybe\n").force_is_html); } #[test] fn should_set_missing_html_flag_by_first_line() { let mut col = Collection::new(); let meta = metadata!(col, "
\n"); assert!(meta.is_html); assert!(!meta.force_is_html); // HTML check is field-, not row-based assert!(!metadata!(col, "\n").is_html); assert!(!metadata!(col, "#html:false\n
\n").is_html); } #[test] fn should_detect_old_and_new_style_tags() { let mut col = Collection::new(); assert_eq!(metadata!(col, "tags:foo bar\n").global_tags, ["foo", "bar"]); assert_eq!( metadata!(col, "#tags:foo bar\n").global_tags, ["foo", "bar"] ); // only in head assert_eq!( metadata!(col, "#\n#tags:foo bar\n").global_tags, ["foo", "bar"] ); assert_eq!(metadata!(col, "\n#tags:foo bar\n").global_tags, [""; 0]); // only on very first line assert_eq!(metadata!(col, "#\ntags:foo bar\n").global_tags, [""; 0]); } #[test] fn should_detect_column_number_and_names() { let mut col = Collection::new(); // detect from line assert_eq!(metadata!(col, "foo;bar\n").column_labels.len(), 2); // detect encoded assert_eq!( metadata!(col, "#separator:,\nfoo;bar\n") .column_labels .len(), 1 ); assert_eq!( metadata!(col, "#separator:|\nfoo|bar\n") .column_labels .len(), 2 ); // override assert_eq!( metadata!(col, "#separator:;\nfoo;bar\n", Some(Delimiter::Pipe)) .column_labels .len(), 1 ); // custom names assert_eq!( metadata!(col, "#columns:one\ttwo\n").column_labels, ["one", "two"] ); assert_eq!( metadata!(col, "#separator:|\n#columns:one|two\n").column_labels, ["one", "two"] ); } #[test] fn should_detect_column_number_despite_escaped_line_breaks() { let mut col = Collection::new(); assert_eq!( metadata!(col, "\"foo|\nbar\"\tfoo\tbar\n") .column_labels .len(), 3 ); } #[test] fn should_map_default_notetype_fields_by_index_if_no_column_names() { let mut col = Collection::new(); let meta = metadata!(col, "#deck column:1\nfoo,bar,baz\n"); assert_eq!(meta.unwrap_notetype_map(), &[2, 3]); } #[test] fn should_map_default_notetype_fields_by_given_column_names() { let mut col = Collection::new(); let meta = metadata!(col, "#columns:Back\tFront\nfoo,bar,baz\n"); assert_eq!(meta.unwrap_notetype_map(), &[2, 1]); } #[test] fn should_gather_first_lines_into_preview() { let mut col = Collection::new(); let meta = metadata!(col, "#separator: \nfoo bar\nbaz
\n"); assert_eq!(meta.preview[0].vals, ["foo", "bar"]); // html is stripped assert_eq!(meta.preview[1].vals, ["baz", ""]); } #[test] fn should_parse_first_first_line_despite_bom() { let mut col = Collection::new(); assert_eq!( metadata!(col, "\u{feff}#separator:tab\n").delimiter(), Delimiter::Tab ); assert_eq!(metadata!(col, "\u{feff}tags:foo\n").global_tags, ["foo"]); } #[test] fn should_not_set_tags_column_if_all_are_field_columns() { let meta_columns = Default::default(); let mut metadata = CsvMetadata::defaults_for_testing(); maybe_set_tags_column(&mut metadata, &meta_columns); assert_eq!(metadata.tags_column, 0); } #[test] fn should_set_tags_column_to_next_unused_column() { let mut meta_columns = HashSet::default(); meta_columns.insert(3); let mut metadata = CsvMetadata::defaults_for_testing(); metadata.column_labels.push(String::new()); metadata.column_labels.push(String::new()); maybe_set_tags_column(&mut metadata, &meta_columns); assert_eq!(metadata.tags_column, 4); } #[test] fn should_allow_non_freeform_metadata_lines_to_be_suffixed_by_delimiters() { let mut col = Collection::new(); let metadata = metadata!( col, r#" #separator:Pipe,,,,,,, #html:true||||| #tags:foo bar::世界,,, #guid column:8 #tags column:123abc "# .trim() ); assert_eq!(metadata.delimiter(), Delimiter::Pipe); assert!(metadata.is_html); assert_eq!(metadata.guid_column, 8); // tags is freeform, potential delimiters aren't trimmed assert_eq!(metadata.global_tags, ["foo", "bar::世界,,,"]); // ascii alphanumerics aren't trimmed away assert_eq!(metadata.tags_column, 0); assert_eq!( metadata!(col, "#separator:\t|,:\n").delimiter(), Delimiter::Tab ); } } ================================================ FILE: rslib/src/import_export/text/csv/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod export; mod import; pub mod metadata; ================================================ FILE: rslib/src/import_export/text/import.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; use unicase::UniCase; use super::NameOrId; use crate::card::CardQueue; use crate::card::CardType; use crate::config::I32ConfigKey; use crate::import_export::text::DupeResolution; use crate::import_export::text::ForeignCard; use crate::import_export::text::ForeignData; use crate::import_export::text::ForeignNote; use crate::import_export::text::ForeignNotetype; use crate::import_export::text::ForeignTemplate; use crate::import_export::text::MatchScope; use crate::import_export::ImportProgress; use crate::import_export::NoteLog; use crate::notes::field_checksum; use crate::notes::normalize_field; use crate::notetype::CardGenContext; use crate::notetype::CardTemplate; use crate::notetype::NoteField; use crate::prelude::*; use crate::progress::ThrottlingProgressHandler; use crate::scheduler::timing::SchedTimingToday; use crate::text::strip_html_preserving_media_filenames; impl ForeignData { pub fn import( self, col: &mut Collection, mut progress: ThrottlingProgressHandler, ) -> Result> { progress.set(ImportProgress::File)?; col.transact(Op::Import, |col| { self.update_config(col)?; let mut ctx = Context::new(&self, col)?; ctx.import_foreign_notetypes(self.notetypes)?; ctx.import_foreign_notes( self.notes, &self.global_tags, &self.updated_tags, &mut progress, ) }) } fn update_config(&self, col: &mut Collection) -> Result<()> { col.set_config_i32_inner( I32ConfigKey::CsvDuplicateResolution, self.dupe_resolution as i32, )?; col.set_config_i32_inner(I32ConfigKey::MatchScope, self.match_scope as i32)?; Ok(()) } } fn new_note_log(dupe_resolution: DupeResolution, found_notes: u32) -> NoteLog { NoteLog { dupe_resolution: dupe_resolution as i32, found_notes, ..Default::default() } } struct Context<'a> { col: &'a mut Collection, /// Contains the optional default notetype with the default key. notetypes: HashMap>>, deck_ids: DeckIdsByNameOrId, usn: Usn, normalize_notes: bool, timing: SchedTimingToday, dupe_resolution: DupeResolution, card_gen_ctxs: HashMap<(NotetypeId, DeckId), CardGenContext>>, existing_checksums: ExistingChecksums, existing_guids: HashMap, } struct DeckIdsByNameOrId { ids: HashSet, names: HashMap, DeckId>, default: Option, } /// Notes in the collection indexed by notetype, checksum and optionally deck. /// With deck, a note will be included in as many entries as its cards /// have different original decks. #[derive(Debug)] enum ExistingChecksums { ByNotetype(HashMap<(NotetypeId, u32), Vec>), ByNotetypeAndDeck(HashMap<(NotetypeId, u32, DeckId), Vec>), } impl ExistingChecksums { fn new(col: &mut Collection, match_scope: MatchScope) -> Result { match match_scope { MatchScope::Notetype => col .storage .all_notes_by_type_and_checksum() .map(Self::ByNotetype), MatchScope::NotetypeAndDeck => col .storage .all_notes_by_type_checksum_and_deck() .map(Self::ByNotetypeAndDeck), } } fn get(&self, notetype: NotetypeId, checksum: u32, deck: DeckId) -> Option<&Vec> { match self { Self::ByNotetype(map) => map.get(&(notetype, checksum)), Self::ByNotetypeAndDeck(map) => map.get(&(notetype, checksum, deck)), } } } struct NoteContext<'a> { note: ForeignNote, dupes: Vec, notetype: Arc, deck_id: DeckId, global_tags: &'a [String], updated_tags: &'a [String], } struct Duplicate { note: Note, identical: bool, first_field_match: bool, } impl Duplicate { fn new(dupe: Note, original: &ForeignNote, first_field_match: bool) -> Self { let identical = original.equal_fields_and_tags(&dupe); Self { note: dupe, identical, first_field_match, } } } impl DeckIdsByNameOrId { fn new(col: &mut Collection, default: &NameOrId, usn: Usn) -> Result { let names: HashMap, DeckId> = col .get_all_normal_deck_names(false)? .into_iter() .map(|(id, name)| (UniCase::new(name), id)) .collect(); let ids = names.values().copied().collect(); let mut new = Self { ids, names, default: None, }; new.default = new.get(default); if new.default.is_none() && *default != NameOrId::default() { let mut deck = Deck::new_normal(); deck.name = NativeDeckName::from_human_name(default.to_string()); col.add_deck_inner(&mut deck, usn)?; new.insert(deck.id, deck.human_name()); new.default = Some(deck.id); } Ok(new) } fn get(&self, name_or_id: &NameOrId) -> Option { match name_or_id { _ if *name_or_id == NameOrId::default() => self.default, NameOrId::Id(id) => self .ids .get(&DeckId(*id)) // try treating it as a numeric deck name .or_else(|| self.names.get(&UniCase::new(id.to_string()))) .copied(), NameOrId::Name(name) => self.names.get(&UniCase::new(name.to_string())).copied(), } } fn insert(&mut self, deck_id: DeckId, name: String) { self.ids.insert(deck_id); self.names.insert(UniCase::new(name), deck_id); } } impl<'a> Context<'a> { fn new(data: &ForeignData, col: &'a mut Collection) -> Result { let usn = col.usn()?; let normalize_notes = col.get_config_bool(BoolKey::NormalizeNoteText); let timing = col.timing_today()?; let mut notetypes = HashMap::new(); notetypes.insert( NameOrId::default(), col.notetype_by_name_or_id(&data.default_notetype)?, ); let deck_ids = DeckIdsByNameOrId::new(col, &data.default_deck, usn)?; let existing_checksums = ExistingChecksums::new(col, data.match_scope)?; let existing_guids = col.storage.all_notes_by_guid()?; Ok(Self { col, usn, normalize_notes, timing, dupe_resolution: data.dupe_resolution, notetypes, deck_ids, card_gen_ctxs: HashMap::new(), existing_checksums, existing_guids, }) } fn import_foreign_notetypes(&mut self, notetypes: Vec) -> Result<()> { for foreign in notetypes { let mut notetype = foreign.into_native(); notetype.usn = self.usn; self.col .add_notetype_inner(&mut notetype, self.usn, false)?; } Ok(()) } fn notetype_for_note(&mut self, note: &ForeignNote) -> Result>> { Ok(if let Some(nt) = self.notetypes.get(¬e.notetype) { nt.clone() } else { let nt = self.col.notetype_by_name_or_id(¬e.notetype)?; self.notetypes.insert(note.notetype.clone(), nt.clone()); nt }) } fn import_foreign_notes( &mut self, notes: Vec, global_tags: &[String], updated_tags: &[String], progress: &mut ThrottlingProgressHandler, ) -> Result { let mut incrementor = progress.incrementor(ImportProgress::Notes); let mut log = new_note_log(self.dupe_resolution, notes.len() as u32); for foreign in notes { incrementor.increment()?; if foreign.first_field_is_the_empty_string() { log.empty_first_field.push(foreign.into_log_note()); continue; } if let Some(notetype) = self.notetype_for_note(&foreign)? { if let Some(deck_id) = self.get_or_create_deck_id(&foreign.deck)? { let ctx = self.build_note_context( foreign, notetype, deck_id, global_tags, updated_tags, )?; self.import_note(ctx, &mut log)?; } else { log.missing_deck.push(foreign.into_log_note()); } } else { log.missing_notetype.push(foreign.into_log_note()); } } Ok(log) } fn get_or_create_deck_id(&mut self, deck: &NameOrId) -> Result> { Ok(if let Some(did) = self.deck_ids.get(deck) { Some(did) } else if let NameOrId::Name(name) = deck { let mut deck = Deck::new_normal(); deck.name = NativeDeckName::from_human_name(name); self.col.add_deck_inner(&mut deck, self.usn)?; self.deck_ids.insert(deck.id, deck.human_name()); if name.is_empty() { self.deck_ids.default = Some(deck.id); } Some(deck.id) } else { None }) } fn build_note_context<'tags>( &mut self, mut note: ForeignNote, notetype: Arc, deck_id: DeckId, global_tags: &'tags [String], updated_tags: &'tags [String], ) -> Result> { self.prepare_foreign_note(&mut note)?; let dupes = self.find_duplicates(¬etype, ¬e, deck_id)?; Ok(NoteContext { note, dupes, notetype, deck_id, global_tags, updated_tags, }) } fn prepare_foreign_note(&mut self, note: &mut ForeignNote) -> Result<()> { note.normalize_fields(self.normalize_notes); self.col.canonify_foreign_tags(note, self.usn) } fn find_duplicates( &self, notetype: &Notetype, note: &ForeignNote, deck_id: DeckId, ) -> Result> { if note.guid.is_empty() { if let Some(nids) = note .checksum() .and_then(|csum| self.existing_checksums.get(notetype.id, csum, deck_id)) { return self.get_first_field_dupes(note, nids); } } else if let Some(nid) = self.existing_guids.get(¬e.guid) { return self.get_guid_dupe(*nid, note).map(|dupe| vec![dupe]); } Ok(Vec::new()) } fn get_guid_dupe(&self, nid: NoteId, original: &ForeignNote) -> Result { self.col .storage .get_note(nid)? .or_not_found(nid) .map(|dupe| Duplicate::new(dupe, original, false)) } fn get_first_field_dupes(&self, note: &ForeignNote, nids: &[NoteId]) -> Result> { Ok(self .col .get_full_duplicates(note, nids)? .into_iter() .map(|dupe| Duplicate::new(dupe, note, true)) .collect()) } fn import_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> { match self.dupe_resolution { _ if ctx.dupes.is_empty() => self.add_note(ctx, log)?, DupeResolution::Duplicate if ctx.is_guid_dupe() => log .duplicate .push(ctx.dupes.into_iter().next().unwrap().note.into_log_note()), DupeResolution::Duplicate if !ctx.has_first_field() => { log.empty_first_field.push(ctx.note.into_log_note()) } DupeResolution::Duplicate => self.add_note(ctx, log)?, DupeResolution::Update => self.update_with_note(ctx, log)?, DupeResolution::Preserve => log .first_field_match .push(ctx.dupes.into_iter().next().unwrap().note.into_log_note()), } Ok(()) } fn add_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> { let mut note = Note::new(&ctx.notetype); let mut cards = ctx .note .into_native(&mut note, ctx.deck_id, &self.timing, ctx.global_tags); self.prepare_note(&mut note, &ctx.notetype)?; self.col.add_note_only_undoable(&mut note)?; self.add_cards(&mut cards, ¬e, ctx.deck_id, ctx.notetype)?; if ctx.dupes.is_empty() { log.new.push(note.into_log_note()); } else { log.first_field_match.push(note.into_log_note()); } Ok(()) } fn add_cards( &mut self, cards: &mut [Card], note: &Note, deck_id: DeckId, notetype: Arc, ) -> Result<()> { self.import_cards(cards, note.id)?; self.generate_missing_cards(notetype, deck_id, note) } fn update_with_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> { let mut update_result = DuplicateUpdateResult::None; for dupe in ctx.dupes { if dupe.note.notetype_id != ctx.notetype.id { update_result.update(DuplicateUpdateResult::Conflicting(dupe)); continue; } let mut note = dupe.note.clone(); let mut cards = ctx.note.clone().into_native( &mut note, ctx.deck_id, &self.timing, ctx.global_tags.iter().chain(ctx.updated_tags.iter()), ); if dupe.identical { update_result.update(DuplicateUpdateResult::Identical(dupe)); } else { self.prepare_note(&mut note, &ctx.notetype)?; self.col.update_note_undoable(¬e, &dupe.note)?; update_result.update(DuplicateUpdateResult::Update(dupe)); } self.add_cards(&mut cards, ¬e, ctx.deck_id, ctx.notetype.clone())?; } update_result.log(log); Ok(()) } fn prepare_note(&mut self, note: &mut Note, notetype: &Notetype) -> Result<()> { note.prepare_for_update(notetype, self.normalize_notes)?; self.col.canonify_note_tags(note, self.usn)?; note.set_modified(self.usn); Ok(()) } fn import_cards(&mut self, cards: &mut [Card], note_id: NoteId) -> Result<()> { for card in cards { card.note_id = note_id; self.col.add_card(card)?; } Ok(()) } fn generate_missing_cards( &mut self, notetype: Arc, deck_id: DeckId, note: &Note, ) -> Result<()> { let card_gen_context = self .card_gen_ctxs .entry((notetype.id, deck_id)) .or_insert_with(|| CardGenContext::new(notetype, Some(deck_id), self.usn)); self.col .generate_cards_for_existing_note(card_gen_context, note) } } /// Helper enum to decide which result to log if multiple duplicates were found /// for a single incoming note. enum DuplicateUpdateResult { None, Conflicting(Duplicate), Identical(Duplicate), Update(Duplicate), } impl DuplicateUpdateResult { fn priority(&self) -> u8 { match self { DuplicateUpdateResult::None => 0, DuplicateUpdateResult::Conflicting(_) => 1, DuplicateUpdateResult::Identical(_) => 2, DuplicateUpdateResult::Update(_) => 3, } } fn update(&mut self, new: Self) { if self.priority() < new.priority() { *self = new; } } fn log(self, log: &mut NoteLog) { match self { DuplicateUpdateResult::None => (), DuplicateUpdateResult::Conflicting(dupe) => { log.conflicting.push(dupe.note.into_log_note()) } DuplicateUpdateResult::Identical(dupe) => log.duplicate.push(dupe.note.into_log_note()), DuplicateUpdateResult::Update(dupe) if dupe.first_field_match => { log.first_field_match.push(dupe.note.into_log_note()) } DuplicateUpdateResult::Update(dupe) => log.updated.push(dupe.note.into_log_note()), } } } impl NoteContext<'_> { fn is_guid_dupe(&self) -> bool { self.dupes .first() .is_some_and(|d| d.note.guid == self.note.guid) } fn has_first_field(&self) -> bool { self.note.first_field_is_unempty() } } impl Note { fn first_field_stripped(&self) -> Cow<'_, str> { strip_html_preserving_media_filenames(&self.fields()[0]) } } impl Collection { pub(super) fn deck_id_by_name_or_id(&mut self, deck: &NameOrId) -> Result> { match deck { NameOrId::Id(id) => Ok({ match self.get_deck(DeckId(*id))?.map(|d| d.id) { did @ Some(_) => did, // try treating it as a numeric deck name _ => self.get_deck_id(&id.to_string())?, } }), NameOrId::Name(name) => self.get_deck_id(name), } } pub(super) fn notetype_by_name_or_id( &mut self, notetype: &NameOrId, ) -> Result>> { match notetype { NameOrId::Id(id) => Ok({ match self.get_notetype(NotetypeId(*id))? { nt @ Some(_) => nt, // try treating it as a numeric notetype name _ => self.get_notetype_by_name(&id.to_string())?, } }), NameOrId::Name(name) => self.get_notetype_by_name(name), } } fn canonify_foreign_tags(&mut self, note: &mut ForeignNote, usn: Usn) -> Result<()> { if let Some(tags) = note.tags.take() { note.tags .replace(self.canonify_tags_without_registering(tags, usn)?); } Ok(()) } fn get_full_duplicates(&self, note: &ForeignNote, dupe_ids: &[NoteId]) -> Result> { let first_field = note.first_field_stripped().or_invalid("no first field")?; dupe_ids .iter() .filter_map(|&dupe_id| self.storage.get_note(dupe_id).transpose()) .filter(|res| match res { Ok(dupe) => dupe.first_field_stripped() == first_field, Err(_) => true, }) .collect() } } impl ForeignNote { /// Updates a native note with the foreign data and returns its new cards. fn into_native<'tags>( self, note: &mut Note, deck_id: DeckId, timing: &SchedTimingToday, extra_tags: impl IntoIterator, ) -> Vec { // TODO: Handle new and learning cards if !self.guid.is_empty() { note.guid = self.guid; } if let Some(tags) = self.tags { note.tags = tags; } note.tags.extend(extra_tags.into_iter().cloned()); note.fields_mut() .iter_mut() .zip(self.fields) .for_each(|(field, new)| { if let Some(s) = new { *field = s; } }); self.cards .into_iter() .enumerate() .map(|(idx, c)| c.into_native(NoteId(0), idx as u16, deck_id, timing)) .collect() } fn first_field_is_the_empty_string(&self) -> bool { matches!(self.fields.first(), Some(Some(s)) if s.is_empty()) } fn first_field_is_unempty(&self) -> bool { matches!(self.fields.first(), Some(Some(s)) if !s.is_empty()) } fn normalize_fields(&mut self, normalize_text: bool) { for field in self.fields.iter_mut().flatten() { normalize_field(field, normalize_text); } } /// Expects normalized form. fn equal_fields_and_tags(&self, other: &Note) -> bool { self.tags.as_ref().map_or(true, |tags| *tags == other.tags) && self .fields .iter() .zip(other.fields()) .all(|(opt, field)| opt.as_ref().map(|s| s == field).unwrap_or(true)) } fn first_field_stripped(&self) -> Option> { self.fields .first() .and_then(|s| s.as_ref()) .map(|field| strip_html_preserving_media_filenames(field.as_str())) } /// If the first field is set, returns its checksum. Field is expected to be /// normalized. fn checksum(&self) -> Option { self.first_field_stripped() .map(|field| field_checksum(&field)) } } impl ForeignCard { fn into_native( self, note_id: NoteId, template_idx: u16, deck_id: DeckId, timing: &SchedTimingToday, ) -> Card { Card { note_id, template_idx, deck_id, due: self.native_due(timing), interval: self.interval, ease_factor: (self.ease_factor * 1000.).round() as u16, reps: self.reps, lapses: self.lapses, ctype: CardType::Review, queue: CardQueue::Review, ..Default::default() } } fn native_due(self, timing: &SchedTimingToday) -> i32 { let day_start = timing.next_day_at.0 - 86_400; let due_delta = (self.due - day_start) / 86_400; due_delta as i32 + timing.days_elapsed as i32 } } impl ForeignNotetype { fn into_native(self) -> Notetype { Notetype { name: self.name, fields: self.fields.into_iter().map(NoteField::new).collect(), templates: self .templates .into_iter() .map(ForeignTemplate::into_native) .collect(), config: if self.is_cloze { Notetype::new_cloze_config() } else { Notetype::new_config() }, ..Notetype::default() } } } impl ForeignTemplate { fn into_native(self) -> CardTemplate { CardTemplate::new(self.name, self.qfmt, self.afmt) } } #[cfg(test)] mod test { use super::*; use crate::tests::DeckAdder; use crate::tests::NoteAdder; impl ForeignData { fn with_defaults() -> Self { Self { default_notetype: NameOrId::Name("Basic".to_string()), default_deck: NameOrId::Id(1), ..Default::default() } } fn add_note(&mut self, fields: &[&str]) { self.notes.push(ForeignNote { fields: fields.iter().map(ToString::to_string).map(Some).collect(), ..Default::default() }); } } #[test] fn should_always_add_note_if_dupe_mode_is_add() { let mut col = Collection::new(); let mut data = ForeignData::with_defaults(); data.add_note(&["same", "old"]); data.dupe_resolution = DupeResolution::Duplicate; let progress = col.new_progress_handler(); data.clone().import(&mut col, progress).unwrap(); let progress = col.new_progress_handler(); data.import(&mut col, progress).unwrap(); assert_eq!(col.storage.notes_table_len(), 2); } #[test] fn should_add_or_ignore_note_if_dupe_mode_is_ignore() { let mut col = Collection::new(); let mut data = ForeignData::with_defaults(); data.add_note(&["same", "old"]); data.dupe_resolution = DupeResolution::Preserve; let progress = col.new_progress_handler(); data.clone().import(&mut col, progress).unwrap(); assert_eq!(col.storage.notes_table_len(), 1); data.notes[0].fields[1].replace("new".to_string()); let progress = col.new_progress_handler(); data.import(&mut col, progress).unwrap(); let notes = col.storage.get_all_notes(); assert_eq!(notes.len(), 1); assert_eq!(notes[0].fields()[1], "old"); } #[test] fn should_update_or_add_note_if_dupe_mode_is_update() { let mut col = Collection::new(); let mut data = ForeignData::with_defaults(); data.add_note(&["same", "old"]); data.dupe_resolution = DupeResolution::Update; let progress = col.new_progress_handler(); data.clone().import(&mut col, progress).unwrap(); assert_eq!(col.storage.notes_table_len(), 1); data.notes[0].fields[1].replace("new".to_string()); let progress = col.new_progress_handler(); data.import(&mut col, progress).unwrap(); assert_eq!(col.storage.get_all_notes()[0].fields()[1], "new"); } #[test] fn should_keep_old_field_content_if_no_new_one_is_supplied() { let mut col = Collection::new(); let mut data = ForeignData::with_defaults(); data.add_note(&["same", "unchanged"]); data.add_note(&["same", "unchanged"]); data.dupe_resolution = DupeResolution::Update; let progress = col.new_progress_handler(); data.clone().import(&mut col, progress).unwrap(); assert_eq!(col.storage.notes_table_len(), 2); data.notes[0].fields[1] = None; data.notes[1].fields.pop(); let progress = col.new_progress_handler(); data.import(&mut col, progress).unwrap(); let notes = col.storage.get_all_notes(); assert_eq!(notes[0].fields(), &["same", "unchanged"]); assert_eq!(notes[0].fields(), &["same", "unchanged"]); } #[test] fn should_recognize_normalized_duplicate_only_if_normalization_is_enabled() { let mut col = Collection::new(); NoteAdder::basic(&mut col) .fields(&["神", "old"]) .add(&mut col); let mut data = ForeignData::with_defaults(); data.dupe_resolution = DupeResolution::Update; data.add_note(&["神", "new"]); let progress = col.new_progress_handler(); data.clone().import(&mut col, progress).unwrap(); assert_eq!(col.storage.get_all_notes()[0].fields(), &["神", "new"]); col.set_config_bool(BoolKey::NormalizeNoteText, false, false) .unwrap(); let progress = col.new_progress_handler(); data.import(&mut col, progress).unwrap(); let notes = col.storage.get_all_notes(); assert_eq!(notes[0].fields(), &["神", "new"]); assert_eq!(notes[1].fields(), &["神", "new"]); } #[test] fn should_add_global_tags() { let mut col = Collection::new(); let mut data = ForeignData::with_defaults(); data.add_note(&["foo"]); data.notes[0].tags.replace(vec![String::from("bar")]); data.global_tags = vec![String::from("baz")]; let progress = col.new_progress_handler(); data.import(&mut col, progress).unwrap(); assert_eq!(col.storage.get_all_notes()[0].tags, ["bar", "baz"]); } #[test] fn should_match_note_with_same_guid() { let mut col = Collection::new(); let mut data = ForeignData::with_defaults(); data.add_note(&["foo"]); data.notes[0].tags.replace(vec![String::from("bar")]); data.global_tags = vec![String::from("baz")]; let progress = col.new_progress_handler(); data.import(&mut col, progress).unwrap(); assert_eq!(col.storage.get_all_notes()[0].tags, ["bar", "baz"]); } #[test] fn should_only_update_duplicates_in_same_deck_if_limit_is_enabled() { let mut col = Collection::new(); let other_deck_id = DeckAdder::new("other").add(&mut col).id; NoteAdder::basic(&mut col) .fields(&["foo", "old"]) .add(&mut col); NoteAdder::basic(&mut col) .fields(&["foo", "old"]) .deck(other_deck_id) .add(&mut col); let mut data = ForeignData::with_defaults(); data.match_scope = MatchScope::NotetypeAndDeck; data.add_note(&["foo", "new"]); let progress = col.new_progress_handler(); data.import(&mut col, progress).unwrap(); let notes = col.storage.get_all_notes(); // same deck, should be updated assert_eq!(notes[0].fields()[1], "new"); // other deck, should be unchanged assert_eq!(notes[1].fields()[1], "old"); } } ================================================ FILE: rslib/src/import_export/text/json.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_io::read_file; use crate::import_export::text::ForeignData; use crate::import_export::NoteLog; use crate::prelude::*; impl Collection { pub fn import_json_file(&mut self, path: &str) -> Result> { let progress = self.new_progress_handler(); let slice = read_file(path)?; let data: ForeignData = serde_json::from_slice(&slice)?; data.import(self, progress) } pub fn import_json_string(&mut self, json: &str) -> Result> { let progress = self.new_progress_handler(); let data: ForeignData = serde_json::from_str(json)?; data.import(self, progress) } } ================================================ FILE: rslib/src/import_export/text/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub mod csv; mod import; mod json; use anki_proto::import_export::csv_metadata::DupeResolution; use anki_proto::import_export::csv_metadata::MatchScope; use serde::Deserialize; use serde::Serialize; use super::LogNote; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct ForeignData { dupe_resolution: DupeResolution, match_scope: MatchScope, default_deck: NameOrId, default_notetype: NameOrId, notes: Vec, notetypes: Vec, global_tags: Vec, updated_tags: Vec, } #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] #[serde(default)] pub struct ForeignNote { guid: String, fields: Vec>, tags: Option>, notetype: NameOrId, deck: NameOrId, cards: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)] #[serde(default)] pub struct ForeignCard { /// Seconds-based timestamp pub due: i64, /// In days pub interval: u32, pub ease_factor: f32, pub reps: u32, pub lapses: u32, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ForeignNotetype { name: String, fields: Vec, templates: Vec, #[serde(default)] is_cloze: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ForeignTemplate { name: String, qfmt: String, afmt: String, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(untagged)] pub enum NameOrId { Id(i64), Name(String), } impl Default for NameOrId { fn default() -> Self { NameOrId::Name(String::new()) } } impl From for NameOrId { fn from(s: String) -> Self { Self::Name(s) } } impl std::fmt::Display for NameOrId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { NameOrId::Id(did) => write!(f, "{did}"), NameOrId::Name(name) => write!(f, "{name}"), } } } impl ForeignNote { pub(crate) fn into_log_note(self) -> LogNote { LogNote { id: None, fields: self .fields .into_iter() .map(Option::unwrap_or_default) .collect(), } } } ================================================ FILE: rslib/src/latex.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::sync::LazyLock; use regex::Captures; use regex::Regex; use crate::cloze::expand_clozes_to_reveal_latex; use crate::media::files::sha1_of_data; use crate::text::strip_html; pub(crate) static LATEX: LazyLock = LazyLock::new(|| { Regex::new( r"(?xsi) \[latex\](.+?)\[/latex\] # 1 - standard latex | \[\$\](.+?)\[/\$\] # 2 - inline math | \[\$\$\](.+?)\[/\$\$\] # 3 - math environment ", ) .unwrap() }); static LATEX_NEWLINES: LazyLock = LazyLock::new(|| { Regex::new( r#"(?xi) |
"#, ) .unwrap() }); pub(crate) fn contains_latex(text: &str) -> bool { LATEX.is_match(text) } #[derive(Debug, PartialEq, Eq)] pub struct ExtractedLatex { pub fname: String, pub latex: String, } /// Expand any cloze deletions, then extract LaTeX. pub(crate) fn extract_latex_expanding_clozes( text: &str, svg: bool, ) -> (Cow<'_, str>, Vec) { if text.contains("{{c") { let expanded = expand_clozes_to_reveal_latex(text); let (text, extracts) = extract_latex(&expanded, svg); (text.into_owned().into(), extracts) } else { extract_latex(text, svg) } } /// Extract LaTeX from the provided text. /// Expects cloze deletions to already be expanded. pub fn extract_latex(text: &str, svg: bool) -> (Cow<'_, str>, Vec) { let mut extracted = vec![]; let new_text = LATEX.replace_all(text, |caps: &Captures| { let latex = match (caps.get(1), caps.get(2), caps.get(3)) { (Some(m), _, _) => m.as_str().into(), (_, Some(m), _) => format!("${}$", m.as_str()), (_, _, Some(m)) => format!(r"\begin{{displaymath}}{}\end{{displaymath}}", m.as_str()), _ => unreachable!(), }; let latex_text = strip_html_for_latex(&latex); let fname = fname_for_latex(&latex_text, svg); let img_link = image_link_for_fname(&latex_text, &fname); extracted.push(ExtractedLatex { fname, latex: latex_text.into(), }); img_link }); (new_text, extracted) } fn strip_html_for_latex(html: &str) -> Cow<'_, str> { let mut out: Cow = html.into(); if let Cow::Owned(o) = LATEX_NEWLINES.replace_all(html, "\n") { out = o.into(); } if let Cow::Owned(o) = strip_html(out.as_ref()) { out = o.into(); } out } fn fname_for_latex(latex: &str, svg: bool) -> String { let ext = if svg { "svg" } else { "png" }; let csum = hex::encode(sha1_of_data(latex.as_bytes())); format!("latex-{csum}.{ext}") } fn image_link_for_fname(src: &str, fname: &str) -> String { format!( "\"{}\"", htmlescape::encode_attribute(src), fname ) } #[cfg(test)] mod test { use crate::latex::extract_latex; use crate::latex::ExtractedLatex; #[test] fn latex() { let fname = "latex-ef30b3f4141c33a5bf7044b0d1961d3399c05d50.png"; assert_eq!( extract_latex("a[latex]one
and
two[/latex]b", false), ( format!("a\"one
and
two\"b").into(), vec![ExtractedLatex { fname: fname.into(), latex: "one\nand\ntwo".into() }] ) ); assert_eq!( extract_latex("[$]hello  world[/$]", true).1, vec![ExtractedLatex { fname: "latex-060219fbf3ddb74306abddaf4504276ad793b029.svg".to_string(), latex: "$hello world$".to_string() }] ); assert_eq!( extract_latex("[$$]math & stuff[/$$]", false).1, vec![ExtractedLatex { fname: "latex-8899f3f849ffdef6e4e9f2f34a923a1f608ebc07.png".to_string(), latex: r"\begin{displaymath}math & stuff\end{displaymath}".to_string() }] ); } } ================================================ FILE: rslib/src/lib.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html #![deny(unused_must_use)] pub mod adding; pub(crate) mod ankidroid; pub mod ankihub; pub mod backend; pub mod browser_table; pub mod card; pub mod card_rendering; pub mod cloze; pub mod collection; pub mod config; pub mod dbcheck; pub mod deckconfig; pub mod decks; pub mod error; pub mod findreplace; pub mod i18n; pub mod image_occlusion; pub mod import_export; pub mod latex; pub mod links; pub mod log; mod markdown; pub mod media; pub mod notes; pub mod notetype; pub mod ops; mod preferences; pub mod prelude; mod progress; pub mod revlog; pub mod scheduler; pub mod search; pub mod serde; pub mod services; mod stats; pub mod storage; pub mod sync; pub mod tags; pub mod template; pub mod template_filters; pub(crate) mod tests; pub mod text; pub mod timestamp; mod typeanswer; pub mod types; pub mod undo; pub mod version; use std::env; use std::sync::LazyLock; pub(crate) static PYTHON_UNIT_TESTS: LazyLock = LazyLock::new(|| env::var("ANKI_TEST_MODE").is_ok()); ================================================ FILE: rslib/src/links.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub use anki_proto::links::help_page_link_request::HelpPage; use crate::collection::Collection; use crate::error; static HELP_SITE: &str = "https://docs.ankiweb.net/"; pub fn help_page_to_link(page: HelpPage) -> String { format!("{}{}", HELP_SITE, help_page_link_suffix(page)) } pub fn help_page_link_suffix(page: HelpPage) -> &'static str { match page { HelpPage::NoteType => "getting-started.html#note-types", HelpPage::Browsing => "browsing.html", HelpPage::BrowsingFindAndReplace => "browsing.html#find-and-replace", HelpPage::BrowsingNotesMenu => "browsing.html#notes", HelpPage::KeyboardShortcuts => "studying.html#keyboard-shortcuts", HelpPage::Editing => "editing.html", HelpPage::AddingCardAndNote => "editing.html#adding-cards-and-notes", HelpPage::AddingANoteType => "editing.html#adding-a-note-type", HelpPage::Latex => "math.html#latex", HelpPage::Preferences => "preferences.html", HelpPage::Index => "", HelpPage::Templates => "templates/intro.html", HelpPage::FilteredDeck => "filtered-decks.html", HelpPage::Importing => "importing/intro.html", HelpPage::CustomizingFields => "editing.html#customizing-fields", HelpPage::DeckOptions => "deck-options.html", HelpPage::EditingFeatures => "editing.html#editing-features", HelpPage::FullScreenIssue => "platform/windows/display-issues.html#full-screen", HelpPage::CardTypeTemplateError => "templates/errors.html#template-syntax-error", HelpPage::CardTypeDuplicate => "templates/errors.html#identical-front-sides", HelpPage::CardTypeNoFrontField => { "templates/errors.html#no-field-replacement-on-front-side" } HelpPage::CardTypeMissingCloze => "templates/errors.html#no-cloze-filter-on-cloze-notetype", HelpPage::Troubleshooting => "troubleshooting.html", } } impl crate::services::LinksService for Collection { fn help_page_link( &mut self, input: anki_proto::links::HelpPageLinkRequest, ) -> error::Result { Ok(help_page_to_link(HelpPage::try_from(input.page).unwrap_or(HelpPage::Index)).into()) } } ================================================ FILE: rslib/src/log.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::fs; use std::fs::OpenOptions; use std::io; use once_cell::sync::OnceCell; use tracing::subscriber::set_global_default; use tracing_appender::non_blocking::NonBlocking; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::fmt; use tracing_subscriber::fmt::Layer; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::EnvFilter; use crate::prelude::*; const LOG_ROTATE_BYTES: u64 = 50 * 1024 * 1024; /// Enable logging to the console, and optionally also to a file. pub fn set_global_logger(path: Option<&str>) -> Result<()> { if std::env::var("BURN_LOG").is_ok() { return Ok(()); } static ONCE: OnceCell<()> = OnceCell::new(); ONCE.get_or_try_init(|| -> Result<()> { let file_writer = if let Some(path) = path { Some(Layer::new().with_writer(get_appender(path)?)) } else { None }; let subscriber = tracing_subscriber::registry() .with(fmt::layer().with_target(false)) .with(file_writer) .with(EnvFilter::from_default_env()); set_global_default(subscriber).or_invalid("global subscriber already set")?; Ok(()) })?; Ok(()) } /// Holding on to this guard does not actually ensure the log file will be fully /// written, as statics do not implement Drop. static APPENDER_GUARD: OnceCell = OnceCell::new(); fn get_appender(path: &str) -> Result { maybe_rotate_log(path)?; let file = OpenOptions::new().create(true).append(true).open(path)?; let (appender, guard) = tracing_appender::non_blocking(file); if APPENDER_GUARD.set(guard).is_err() { invalid_input!("log file should be set only once"); } Ok(appender) } fn maybe_rotate_log(path: &str) -> io::Result<()> { let current_bytes = match fs::metadata(path) { Ok(meta) => meta.len(), Err(e) => { if e.kind() == io::ErrorKind::NotFound { 0 } else { return Err(e); } } }; if current_bytes < LOG_ROTATE_BYTES { return Ok(()); } let path2 = format!("{path}.1"); let path3 = format!("{path}.2"); // if a rotated file already exists, rename it if let Err(e) = fs::rename(&path2, path3) { if e.kind() != io::ErrorKind::NotFound { return Err(e); } } // and rotate the primary log fs::rename(path, path2) } ================================================ FILE: rslib/src/markdown.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use pulldown_cmark::html; use pulldown_cmark::Parser; pub(crate) fn render_markdown(markdown: &str) -> String { let mut buf = String::with_capacity(markdown.len()); let parser = Parser::new(markdown); html::push_html(&mut buf, parser); buf } ================================================ FILE: rslib/src/media/check.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; use std::fs; use std::io; use std::sync::LazyLock; use anki_i18n::without_unicode_isolation; use anki_io::write_file; use data_encoding::BASE64; use regex::Regex; use tracing::debug; use tracing::info; use crate::error::DbErrorKind; use crate::latex::extract_latex_expanding_clozes; use crate::media::files::data_for_file; use crate::media::files::filename_if_normalized; use crate::media::files::normalize_nfc_filename; use crate::media::files::sha1_of_data; use crate::media::files::trash_folder; use crate::media::MediaManager; use crate::prelude::*; use crate::progress::ThrottlingProgressHandler; use crate::sync::media::progress::MediaCheckProgress; use crate::sync::media::MAX_INDIVIDUAL_MEDIA_FILE_SIZE; use crate::text::extract_media_refs; use crate::text::normalize_to_nfc; use crate::text::CowMapping; use crate::text::MediaRef; use crate::text::REMOTE_FILENAME; #[derive(Debug, PartialEq, Eq, Clone)] pub struct MediaCheckOutput { pub unused: Vec, pub missing: Vec, pub missing_media_notes: Vec, pub renamed: HashMap, pub dirs: Vec, pub oversize: Vec, pub trash_count: u64, pub trash_bytes: u64, pub inlined_image_count: u64, } #[derive(Debug, PartialEq, Eq, Default)] struct MediaFolderCheck { files: Vec, renamed: HashMap, dirs: Vec, oversize: Vec, } impl Collection { pub fn media_checker(&mut self) -> Result> { MediaChecker::new(self) } } pub struct MediaChecker<'a> { col: &'a mut Collection, media: MediaManager, progress: ThrottlingProgressHandler, inlined_image_count: u64, } impl MediaChecker<'_> { pub(crate) fn new(col: &mut Collection) -> Result> { Ok(MediaChecker { media: col.media()?, progress: col.new_progress_handler(), col, inlined_image_count: 0, }) } pub fn check(&mut self) -> Result { let folder_check = self.check_media_folder()?; let references = self.check_media_references(&folder_check.renamed)?; let unused_and_missing = UnusedAndMissingFiles::new(folder_check.files, references); let (trash_count, trash_bytes) = self.files_in_trash()?; Ok(MediaCheckOutput { unused: unused_and_missing.unused, missing: unused_and_missing.missing, missing_media_notes: unused_and_missing.missing_media_notes, renamed: folder_check.renamed, dirs: folder_check.dirs, oversize: folder_check.oversize, trash_count, trash_bytes, inlined_image_count: self.inlined_image_count, }) } pub fn summarize_output(&self, output: &mut MediaCheckOutput) -> String { let mut buf = String::new(); let tr = &self.col.tr; // top summary area if output.trash_count > 0 { let megs = (output.trash_bytes as f32) / 1024.0 / 1024.0; buf += &tr.media_check_trash_count(output.trash_count, megs); buf.push('\n'); } buf += &tr.media_check_missing_count(output.missing.len()); buf.push('\n'); buf += &tr.media_check_unused_count(output.unused.len()); buf.push('\n'); if output.inlined_image_count > 0 { buf += &tr.media_check_extracted_count(output.inlined_image_count); buf.push('\n'); } if !output.renamed.is_empty() { buf += &tr.media_check_renamed_count(output.renamed.len()); buf.push('\n'); } if !output.oversize.is_empty() { buf += &tr.media_check_oversize_count(output.oversize.len()); buf.push('\n'); } if !output.dirs.is_empty() { buf += &tr.media_check_subfolder_count(output.dirs.len()); buf.push('\n'); } buf.push('\n'); if !output.renamed.is_empty() { buf += &tr.media_check_renamed_header(); buf.push('\n'); for (old, new) in &output.renamed { buf += &without_unicode_isolation( &tr.media_check_renamed_file(old.as_str(), new.as_str()), ); buf.push('\n'); } buf.push('\n') } if !output.oversize.is_empty() { output.oversize.sort(); buf += &tr.media_check_oversize_header(); buf.push('\n'); for fname in &output.oversize { buf += &without_unicode_isolation(&tr.media_check_oversize_file(fname.as_str())); buf.push('\n'); } buf.push('\n') } if !output.dirs.is_empty() { output.dirs.sort(); buf += &tr.media_check_subfolder_header(); buf.push('\n'); for fname in &output.dirs { buf += &without_unicode_isolation(&tr.media_check_subfolder_file(fname.as_str())); buf.push('\n'); } buf.push('\n') } if !output.missing.is_empty() { output.missing.sort(); buf += &tr.media_check_missing_header(); buf.push('\n'); for fname in &output.missing { buf += &without_unicode_isolation(&tr.media_check_missing_file(fname.as_str())); buf.push('\n'); } buf.push('\n') } if !output.unused.is_empty() { output.unused.sort(); buf += &tr.media_check_unused_header(); buf.push('\n'); for fname in &output.unused { buf += &without_unicode_isolation(&tr.media_check_unused_file(fname.as_str())); buf.push('\n'); } } buf } fn increment_progress(&mut self) -> Result<()> { self.progress.increment(|p| &mut p.checked) } /// Check all the files in the media folder. /// /// - Renames files with invalid names /// - Notes folders/oversized files /// - Gathers a list of all files fn check_media_folder(&mut self) -> Result { let mut out = MediaFolderCheck::default(); for dentry in self.media.media_folder.read_dir()? { let dentry = dentry?; self.increment_progress()?; // if the filename is not valid unicode, skip it let fname_os = dentry.file_name(); let disk_fname = match fname_os.to_str() { Some(s) => s, None => continue, }; if fname_os == ".DS_Store" { continue; } // skip folders if dentry.file_type()?.is_dir() { out.dirs.push(disk_fname.to_string()); continue; } // ignore large files and zero byte files let metadata = dentry.metadata()?; if metadata.len() > MAX_INDIVIDUAL_MEDIA_FILE_SIZE as u64 { out.oversize.push(disk_fname.to_string()); continue; } if metadata.len() == 0 { continue; } if let Some(norm_name) = filename_if_normalized(disk_fname) { out.files.push(norm_name.into_owned()); } else { match data_for_file(&self.media.media_folder, disk_fname)? { Some(data) => { let norm_name = self.normalize_file(disk_fname, data)?; out.renamed .insert(disk_fname.to_string(), norm_name.to_string()); out.files.push(norm_name.into_owned()); } None => { // file not found, caused by the file being removed at this exact instant, // or the path being larger than MAXPATH on Windows continue; } }; } } Ok(out) } /// Write file data to normalized location, moving old file to trash. fn normalize_file<'a>(&mut self, disk_fname: &'a str, data: Vec) -> Result> { // add a copy of the file using the correct name let fname = self.media.add_file(disk_fname, &data)?; debug!(from = disk_fname, to = &fname.as_ref(), "renamed"); assert_ne!(fname.as_ref(), disk_fname); // remove the original file let path = &self.media.media_folder.join(disk_fname); fs::remove_file(path)?; Ok(fname) } /// Returns the count and total size of the files in the trash folder fn files_in_trash(&mut self) -> Result<(u64, u64)> { let trash = trash_folder(&self.media.media_folder)?; let mut total_files = 0; let mut total_bytes = 0; for dentry in trash.read_dir()? { let dentry = dentry?; self.increment_progress()?; if dentry.file_name() == ".DS_Store" { continue; } let meta = dentry.metadata()?; total_files += 1; total_bytes += meta.len(); } Ok((total_files, total_bytes)) } pub fn empty_trash(&mut self) -> Result<()> { let trash = trash_folder(&self.media.media_folder)?; for dentry in trash.read_dir()? { let dentry = dentry?; self.increment_progress()?; fs::remove_file(dentry.path())?; } Ok(()) } pub fn restore_trash(&mut self) -> Result<()> { let trash = trash_folder(&self.media.media_folder)?; for dentry in trash.read_dir()? { let dentry = dentry?; self.increment_progress()?; let orig_path = self.media.media_folder.join(dentry.file_name()); // if the original filename doesn't exist, we can just rename if let Err(e) = fs::metadata(&orig_path) { if e.kind() == io::ErrorKind::NotFound { fs::rename(dentry.path(), &orig_path)?; } else { return Err(e.into()); } } else { // ensure we don't overwrite different data let fname_os = dentry.file_name(); let fname = fname_os.to_string_lossy(); if let Some(data) = data_for_file(&trash, fname.as_ref())? { let _new_fname = self.media.add_file(fname.as_ref(), &data)?; } else { debug!(?fname, "file disappeared while restoring trash"); } fs::remove_file(dentry.path())?; } } Ok(()) } /// Find all media references in notes, fixing as necessary. fn check_media_references( &mut self, renamed: &HashMap, ) -> Result>> { let mut referenced_files = HashMap::new(); let notetypes = self.col.get_all_notetypes()?; let mut collection_modified = false; let nids = self.col.search_notes_unordered("")?; let usn = self.col.usn()?; for nid in nids { self.increment_progress()?; let mut note = self.col.storage.get_note(nid)?.unwrap(); let nt = notetypes .iter() .find(|nt| nt.id == note.notetype_id) .ok_or_else(|| { AnkiError::db_error("missing note type", DbErrorKind::MissingEntity) })?; let mut tracker = |fname| { referenced_files .entry(fname) .or_insert_with(Vec::new) .push(nid) }; if self.fix_and_extract_media_refs(&mut note, &mut tracker, renamed)? { // note was modified, needs saving note.prepare_for_update(nt, false)?; note.set_modified(usn); self.col.storage.update_note(¬e)?; collection_modified = true; } // extract latex extract_latex_refs(¬e, &mut tracker, nt.config.latex_svg); } if collection_modified { // fixme: need to refactor to use new transaction handling? // self.ctx.storage.commit_trx()?; } Ok(referenced_files) } /// Returns true if note was modified. fn fix_and_extract_media_refs( &mut self, note: &mut Note, mut tracker: impl FnMut(String), renamed: &HashMap, ) -> Result { let mut updated = false; for idx in 0..note.fields().len() { let field = self.normalize_and_maybe_rename_files(¬e.fields()[idx], renamed, &mut tracker)?; if let Cow::Owned(field) = field { // field was modified, need to save note.set_field(idx, field)?; updated = true; } } Ok(updated) } /// Convert any filenames that are not in NFC form into NFC, /// and update any files that were renamed on disk. fn normalize_and_maybe_rename_files<'a>( &mut self, field: &'a str, renamed: &HashMap, mut tracker: impl FnMut(String), ) -> Result> { let refs = extract_media_refs(field); let mut field: Cow = field.into(); for media_ref in refs { if REMOTE_FILENAME.is_match(media_ref.fname) { // skip remote references continue; } let mut fname = self.maybe_extract_inline_image(&media_ref.fname_decoded)?; // normalize fname into NFC fname = fname.map_cow(normalize_to_nfc); // and look it up to see if it's been renamed if let Some(new_name) = renamed.get(fname.as_ref()) { fname = new_name.to_owned().into(); } // if the filename was in NFC and was not renamed as part of the // media check, it may have already been renamed during a previous // sync. If that's the case and the renamed version exists on disk, // we'll need to update the field to match it. It may be possible // to remove this check in the future once we can be sure all media // files stored on AnkiWeb are in normalized form. if matches!(fname, Cow::Borrowed(_)) { if let Cow::Owned(normname) = normalize_nfc_filename(fname.as_ref().into()) { let path = self.media.media_folder.join(&normname); if path.exists() { fname = normname.into(); } } } // update the field if the filename was modified if let Cow::Owned(ref new_name) = fname { field = rename_media_ref_in_field(field.as_ref(), &media_ref, new_name).into(); } // and mark this filename as having been referenced tracker(fname.into_owned()); } Ok(field) } fn maybe_extract_inline_image<'a>(&mut self, fname_decoded: &'a str) -> Result> { static BASE64_IMG: LazyLock = LazyLock::new(|| { Regex::new("(?i)^data:image/(jpg|jpeg|png|gif|webp|avif);base64,(.+)$").unwrap() }); let Some(caps) = BASE64_IMG.captures(fname_decoded) else { return Ok(fname_decoded.into()); }; let (_all, [ext, data]) = caps.extract(); let data = data.trim(); let data = match BASE64.decode(data.as_bytes()) { Ok(data) => data, Err(err) => { info!("invalid base64: {}", err); return Ok(fname_decoded.into()); } }; let checksum = hex::encode(sha1_of_data(&data)); let external_fname = format!("paste-{checksum}.{ext}"); write_file(self.media.media_folder.join(&external_fname), data)?; self.inlined_image_count += 1; Ok(external_fname.into()) } } fn rename_media_ref_in_field(field: &str, media_ref: &MediaRef, new_name: &str) -> String { let new_name = if matches!(media_ref.fname_decoded, Cow::Owned(_)) { // filename had quoted characters like & - need to re-encode htmlescape::encode_minimal(new_name) } else { new_name.into() }; let updated_tag = media_ref.full_ref.replace(media_ref.fname, &new_name); field.replace(media_ref.full_ref, &updated_tag) } struct UnusedAndMissingFiles { unused: Vec, missing: Vec, missing_media_notes: Vec, } impl UnusedAndMissingFiles { fn new(files: Vec, mut references: HashMap>) -> Self { let mut unused = vec![]; for file in files { if !file.starts_with('_') && !references.contains_key(&file) { unused.push(file); } else { references.remove(&file); } } let mut missing = Vec::new(); let mut notes = HashSet::new(); for (fname, nids) in references { missing.push(fname); notes.extend(nids); } Self { unused, missing, missing_media_notes: notes.into_iter().collect(), } } } fn extract_latex_refs(note: &Note, mut tracker: impl FnMut(String), svg: bool) { for field in note.fields() { let (_, extracted) = extract_latex_expanding_clozes(field, svg); for e in extracted { tracker(e.fname); } } } #[cfg(test)] pub(crate) mod test { pub(crate) const MEDIACHECK_ANKI2: &[u8] = include_bytes!("../../tests/support/mediacheck.anki2"); use std::collections::HashMap; use std::path::Path; use anki_io::create_dir; use anki_io::read_to_string; use anki_io::write_file; use anki_io::write_file_and_flush; use tempfile::tempdir; use tempfile::TempDir; use super::*; use crate::collection::CollectionBuilder; use crate::sync::media::MAX_MEDIA_FILENAME_LENGTH; use crate::tests::NoteAdder; fn common_setup() -> Result<(TempDir, MediaManager, Collection)> { let dir = tempdir()?; let media_folder = dir.path().join("media"); create_dir(&media_folder)?; let media_db = dir.path().join("media.db"); let col_path = dir.path().join("col.anki2"); write_file(&col_path, MEDIACHECK_ANKI2)?; let mgr = MediaManager::new(&media_folder, media_db.clone())?; let col = CollectionBuilder::new(col_path) .set_media_paths(media_folder, media_db) .build()?; Ok((dir, mgr, col)) } #[test] fn media_check() -> Result<()> { let (_dir, mgr, mut col) = common_setup()?; // add some test files write_file(mgr.media_folder.join("zerobytes"), "")?; create_dir(mgr.media_folder.join("folder"))?; write_file(mgr.media_folder.join("normal.jpg"), "normal")?; write_file(mgr.media_folder.join("foo[.jpg"), "foo")?; write_file(mgr.media_folder.join("_under.jpg"), "foo")?; write_file(mgr.media_folder.join("unused.jpg"), "foo")?; write_file(mgr.media_folder.join(".DS_Store"), ".DS_Store")?; let (output, report) = { let mut checker = col.media_checker()?; let output = checker.check()?; let summary = checker.summarize_output(&mut output.clone()); (output, summary) }; assert_eq!( output, MediaCheckOutput { unused: vec!["unused.jpg".into()], missing: vec!["ぱぱ.jpg".into()], missing_media_notes: vec![NoteId(1581236461568)], renamed: vec![("foo[.jpg".into(), "foo.jpg".into())] .into_iter() .collect(), dirs: vec!["folder".to_string()], oversize: vec![], trash_count: 0, trash_bytes: 0, inlined_image_count: 0, } ); assert!(fs::metadata(mgr.media_folder.join("foo[.jpg")).is_err()); assert!(fs::metadata(mgr.media_folder.join("foo.jpg")).is_ok()); assert_eq!( report, "Missing files: 1 Unused files: 1 Renamed files: 1 Subfolders: 1 Some files have been renamed for compatibility: Renamed: foo[.jpg -> foo.jpg Folders inside the media folder are not supported. Folder: folder The following files are referenced by cards, but were not found in the media folder: Missing: ぱぱ.jpg The following files were found in the media folder, but do not appear to be used on any cards: Unused: unused.jpg " ); Ok(()) } fn files_in_dir(dir: &Path) -> Vec { let mut files = fs::read_dir(dir) .unwrap() .map(|dentry| { let dentry = dentry.unwrap(); Ok(dentry.file_name().to_string_lossy().to_string()) }) .collect::>>() .unwrap(); files.sort(); files } #[test] fn trash_handling() -> Result<()> { let (_dir, mgr, mut col) = common_setup()?; let trash_folder = trash_folder(&mgr.media_folder)?; write_file(trash_folder.join("test.jpg"), "test")?; let mut checker = col.media_checker()?; checker.restore_trash()?; // file should have been moved to media folder assert_eq!(files_in_dir(&trash_folder), Vec::::new()); assert_eq!( files_in_dir(&mgr.media_folder), vec!["test.jpg".to_string()] ); // if we repeat the process, restoring should do the same thing if the contents // are equal write_file(trash_folder.join("test.jpg"), "test")?; let mut checker = col.media_checker()?; checker.restore_trash()?; assert_eq!(files_in_dir(&trash_folder), Vec::::new()); assert_eq!( files_in_dir(&mgr.media_folder), vec!["test.jpg".to_string()] ); // but rename if required write_file(trash_folder.join("test.jpg"), "test2")?; let mut checker = col.media_checker()?; checker.restore_trash()?; assert_eq!(files_in_dir(&trash_folder), Vec::::new()); assert_eq!( files_in_dir(&mgr.media_folder), vec![ "test-109f4b3c50d7b0df729d299bc6f8e9ef9066971f.jpg".to_string(), "test.jpg".into() ] ); Ok(()) } #[test] fn unicode_normalization() -> Result<()> { let (_dir, mgr, mut col) = common_setup()?; write_file_and_flush(mgr.media_folder.join("ぱぱ.jpg"), "nfd encoding")?; let mut output = { let mut checker = col.media_checker()?; checker.check() }?; output.missing.sort(); if cfg!(target_vendor = "apple") { // on a Mac, the file should not have been renamed, but the returned name // should be in NFC format assert_eq!( output, MediaCheckOutput { unused: vec![], missing: vec!["foo[.jpg".into(), "normal.jpg".into()], missing_media_notes: vec![NoteId(1581236386334)], renamed: Default::default(), dirs: vec![], oversize: vec![], trash_count: 0, trash_bytes: 0, inlined_image_count: 0, } ); assert!(fs::metadata(mgr.media_folder.join("ぱぱ.jpg")).is_ok()); } else { // on other platforms, the file should have been renamed to NFC assert_eq!( output, MediaCheckOutput { unused: vec![], missing: vec!["foo[.jpg".into(), "normal.jpg".into()], missing_media_notes: vec![NoteId(1581236386334)], renamed: vec![("ぱぱ.jpg".into(), "ぱぱ.jpg".into())] .into_iter() .collect(), dirs: vec![], oversize: vec![], trash_count: 0, trash_bytes: 0, inlined_image_count: 0, } ); assert!(fs::metadata(mgr.media_folder.join("ぱぱ.jpg")).is_err()); assert!(fs::metadata(mgr.media_folder.join("ぱぱ.jpg")).is_ok()); } Ok(()) } fn normalize_and_maybe_rename_files_helper( checker: &mut MediaChecker, field: &str, ) -> HashSet { let mut seen = HashSet::new(); checker .normalize_and_maybe_rename_files(field, &HashMap::new(), |fname| { seen.insert(fname); }) .unwrap(); seen } #[test] fn html_encoding() -> Result<()> { let (_dir, _mgr, mut col) = common_setup()?; let mut checker = col.media_checker()?; let mut field = "[sound:a & b.mp3]"; let seen = normalize_and_maybe_rename_files_helper(&mut checker, field); assert!(seen.contains("a & b.mp3")); field = r#""#; let seen = normalize_and_maybe_rename_files_helper(&mut checker, field); assert!(seen.contains("a&b.jpg")); field = r#""#; let seen = normalize_and_maybe_rename_files_helper(&mut checker, field); assert!(seen.contains("a&b.jpg")); Ok(()) } #[test] fn inlined_images() -> Result<()> { let (_dir, mgr, mut col) = common_setup()?; NoteAdder::basic(&mut col) // b'foo' .fields(&["foo", ""]) .add(&mut col); let mut checker = col.media_checker()?; let output = checker.check()?; assert_eq!(output.inlined_image_count, 1); assert_eq!( &read_to_string( mgr.media_folder .join("paste-0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33.jpg") )?, "foo" ); Ok(()) } #[test] fn html_chevron_in_non_source_attribute() -> Result<()> { let (_dir, _mgr, mut col) = common_setup()?; let mut checker = col.media_checker()?; let field = "\"alt\" src=\"foo.jpg\">"; let seen = normalize_and_maybe_rename_files_helper(&mut checker, field); assert!(seen.contains("foo.jpg")); let field = ">a>l>t>"; let seen = normalize_and_maybe_rename_files_helper(&mut checker, field); assert!(seen.contains("bar.jpg")); let field = "\"alt>\""; let seen = normalize_and_maybe_rename_files_helper(&mut checker, field); assert!(seen.contains("double-in-single.jpg")); let field = "alt src='illegal.jpg'>"; let seen = normalize_and_maybe_rename_files_helper(&mut checker, field); assert!(!seen.contains("illegal.jpg")); Ok(()) } #[test] fn multiple_images() -> Result<()> { let (_dir, _mgr, mut col) = common_setup()?; let mut checker = col.media_checker()?; let field = "foobar"; let seen = normalize_and_maybe_rename_files_helper(&mut checker, field); assert!(seen.contains("foo-ss.jpg")); assert!(seen.contains("bar-ss.jpg")); let field = "\"foo\"\"bar\""; let seen = normalize_and_maybe_rename_files_helper(&mut checker, field); assert!(seen.contains("foo-dd.jpg")); assert!(seen.contains("bar-dd.jpg")); let field = "foo\"bar\""; let seen = normalize_and_maybe_rename_files_helper(&mut checker, field); assert!(seen.contains("foo-sd.jpg")); assert!(seen.contains("bar-sd.jpg")); Ok(()) } #[test] fn source_tags() -> Result<()> { let (_dir, _mgr, mut col) = common_setup()?; let mut checker = col.media_checker()?; let field = ""; let seen = normalize_and_maybe_rename_files_helper(&mut checker, field); assert!(seen.contains("foo-ss.mp3")); assert!(seen.contains("bar-ss.ogg")); let field = r#" fancy jif "#; let seen = normalize_and_maybe_rename_files_helper(&mut checker, field); assert!(seen.contains("foo-dd.webp")); assert!(seen.contains("bar-dd.gif")); Ok(()) } #[test] fn long_filename_rename_not_reported_as_unused() -> Result<()> { let (_dir, mgr, mut col) = common_setup()?; let long_filename = format!("{}.mp3", "a".repeat(MAX_MEDIA_FILENAME_LENGTH + 1)); NoteAdder::basic(&mut col) .fields(&["test", &format!("[sound:{}]", long_filename)]) .add(&mut col); write_file(mgr.media_folder.join(&long_filename), "audio data")?; let output = { let mut checker = col.media_checker()?; checker.check()? }; assert!(output.renamed.contains_key(&long_filename)); let new_filename = output.renamed.get(&long_filename).unwrap(); assert!(new_filename.len() <= MAX_MEDIA_FILENAME_LENGTH); assert!(!output.unused.contains(new_filename)); assert!(!output.missing.contains(new_filename)); Ok(()) } } ================================================ FILE: rslib/src/media/files.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::fs; use std::fs::FileTimes; use std::io; use std::io::Read; use std::path::Path; use std::path::PathBuf; use std::sync::LazyLock; use std::time; use anki_io::create_dir; use anki_io::open_file; use anki_io::set_file_times; use anki_io::write_file; use anki_io::FileIoError; use anki_io::FileIoSnafu; use anki_io::FileOp; use regex::Regex; use sha1::Digest; use sha1::Sha1; use tracing::debug; use unic_ucd_category::GeneralCategory; use unicode_normalization::is_nfc; use unicode_normalization::UnicodeNormalization; use crate::prelude::*; use crate::sync::media::MAX_MEDIA_FILENAME_LENGTH; static WINDOWS_DEVICE_NAME: LazyLock = LazyLock::new(|| { Regex::new( r"(?xi) # starting with one of the following names ^ ( CON | PRN | AUX | NUL | COM[1-9] | LPT[1-9] ) # either followed by a dot, or no extension ( \. | $ ) ", ) .unwrap() }); static WINDOWS_TRAILING_CHAR: LazyLock = LazyLock::new(|| { Regex::new( r"(?x) # filenames can't end with a space or period ( \x20 | \. ) $ ", ) .unwrap() }); pub(crate) static NONSYNCABLE_FILENAME: LazyLock = LazyLock::new(|| { Regex::new( r#"(?xi) ^ (:? thumbs.db | .ds_store ) $ "#, ) .unwrap() }); /// True if character may cause problems on one or more platforms. fn disallowed_char(char: char) -> bool { match char { '[' | ']' | '<' | '>' | ':' | '"' | '/' | '?' | '*' | '^' | '\\' | '|' => true, c if c.is_ascii_control() => true, // Macs do not allow invalid Unicode characters like 05F8 to be in a filename. c if GeneralCategory::of(c) == GeneralCategory::Unassigned => true, _ => false, } } fn nonbreaking_space(char: char) -> bool { char == '\u{a0}' } /// Adjust filename into the format Anki expects. /// /// - The filename is normalized to NFC. /// - Any problem characters are removed. /// - Windows device names like CON and PRN have '_' appended /// - The filename is limited to 120 bytes. pub(crate) fn normalize_filename(fname: &str) -> Cow<'_, str> { let mut output = Cow::Borrowed(fname); if !is_nfc(output.as_ref()) { output = output.chars().nfc().collect::().into(); } normalize_nfc_filename(output) } /// See normalize_filename(). This function expects NFC-normalized input. pub(crate) fn normalize_nfc_filename(mut fname: Cow<'_, str>) -> Cow<'_, str> { if fname.contains(disallowed_char) { fname = fname.replace(disallowed_char, "").into() } // convert nonbreaking spaces to regular ones, as the filename extraction // code treats nonbreaking spaces as regular ones if fname.contains(nonbreaking_space) { fname = fname.replace(nonbreaking_space, " ").into() } if let Cow::Owned(o) = WINDOWS_DEVICE_NAME.replace_all(fname.as_ref(), "${1}_${2}") { fname = o.into(); } if WINDOWS_TRAILING_CHAR.is_match(fname.as_ref()) { fname = format!("{}_", fname.as_ref()).into(); } if let Cow::Owned(o) = truncate_filename(fname.as_ref(), MAX_MEDIA_FILENAME_LENGTH) { fname = o.into(); } fname } /// Return the filename in NFC form if the filename is valid. /// /// Returns None if the filename is not normalized /// (NFD, invalid chars, etc) /// /// On Apple devices, the filename may be stored on disk in NFD encoding, /// but can be accessed as NFC. On these devices, if the filename /// is otherwise valid, the filename is returned as NFC. #[allow(clippy::collapsible_else_if)] pub(crate) fn filename_if_normalized(fname: &str) -> Option> { if cfg!(target_vendor = "apple") { if !is_nfc(fname) { let as_nfc = fname.chars().nfc().collect::(); if let Cow::Borrowed(_) = normalize_nfc_filename(as_nfc.as_str().into()) { Some(as_nfc.into()) } else { None } } else { if let Cow::Borrowed(_) = normalize_nfc_filename(fname.into()) { Some(fname.into()) } else { None } } } else { if let Cow::Borrowed(_) = normalize_filename(fname) { Some(fname.into()) } else { None } } } /// Write desired_name into folder, renaming if existing file has different /// content. Returns the used filename. pub fn add_data_to_folder_uniquely<'a, P>( folder: P, desired_name: &'a str, data: &[u8], sha1: Sha1Hash, ) -> Result, FileIoError> where P: AsRef, { // force lowercase to account for case-insensitive filesystems // but not within normalize_filename, for existing media refs let normalized_name: Cow<_> = normalize_filename(desired_name).to_lowercase().into(); let mut target_path = folder.as_ref().join(normalized_name.as_ref()); let existing_file_hash = existing_file_sha1(&target_path)?; if existing_file_hash.is_none() { // no file with that name exists yet write_file(&target_path, data)?; return Ok(normalized_name); } if existing_file_hash.unwrap() == sha1 { // existing file has same checksum, nothing to do return Ok(normalized_name); } // give it a unique name based on its hash let hashed_name = add_hash_suffix_to_file_stem(normalized_name.as_ref(), &sha1); target_path.set_file_name(&hashed_name); write_file(&target_path, data)?; Ok(hashed_name.into()) } /// Convert foo.jpg into foo-abcde12345679.jpg pub(crate) fn add_hash_suffix_to_file_stem(fname: &str, hash: &Sha1Hash) -> String { // when appending a hash to make unique, it will be 40 bytes plus the hyphen. let max_len = MAX_MEDIA_FILENAME_LENGTH - 40 - 1; let (stem, ext) = split_and_truncate_filename(fname, max_len); format!("{}-{}.{}", stem, hex::encode(hash), ext) } /// If filename is longer than max_bytes, truncate it. fn truncate_filename(fname: &str, max_bytes: usize) -> Cow<'_, str> { if fname.len() <= max_bytes { return Cow::Borrowed(fname); } let (stem, ext) = split_and_truncate_filename(fname, max_bytes); let mut new_name = if ext.is_empty() { stem.to_string() } else { format!("{stem}.{ext}") }; // make sure we don't break Windows by ending with a space or dot if WINDOWS_TRAILING_CHAR.is_match(&new_name) { new_name.push('_'); } new_name.into() } /// Split filename into stem and extension, and trim both so the /// resulting filename would be under max_bytes. /// Returns (stem, extension) fn split_and_truncate_filename(fname: &str, max_bytes: usize) -> (&str, &str) { // the code assumes max_bytes will be at least 11 debug_assert!(max_bytes > 10); let mut iter = fname.rsplitn(2, '.'); let mut ext = iter.next().unwrap(); let mut stem = if let Some(s) = iter.next() { s } else { // no extension, so ext holds the full filename let ext_tmp = ext; ext = ""; ext_tmp }; // cap extension to 10 bytes so stem_len can't be negative ext = truncated_to_char_boundary(ext, 10); // cap stem, allowing for the . and a trailing _ let stem_len = max_bytes - ext.len() - 2; stem = truncated_to_char_boundary(stem, stem_len); (stem, ext) } /// Return a substring on a valid UTF8 boundary. /// Based on a function in the Rust stdlib. fn truncated_to_char_boundary(s: &str, mut max: usize) -> &str { if max >= s.len() { s } else { while !s.is_char_boundary(max) { max -= 1; } &s[..max] } } /// Return the SHA1 of a file if it exists, or None. fn existing_file_sha1(path: &Path) -> Result, FileIoError> { match sha1_of_file(path) { Ok(o) => Ok(Some(o)), Err(e) if e.is_not_found() => Ok(None), Err(e) => Err(e), } } /// Return the SHA1 of a file, failing if it doesn't exist. pub(crate) fn sha1_of_file(path: &Path) -> Result { let mut file = open_file(path)?; sha1_of_reader(&mut file).context(FileIoSnafu { path, op: FileOp::Read, }) } /// Return the SHA1 of a stream. pub(crate) fn sha1_of_reader(reader: &mut impl Read) -> io::Result { let mut hasher = Sha1::new(); let mut buf = [0; 64 * 1024]; loop { match reader.read(&mut buf) { Ok(0) => break, Ok(n) => hasher.update(&buf[0..n]), Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, Err(e) => return Err(e), }; } Ok(hasher.finalize().into()) } /// Return the SHA1 of provided data. pub(crate) fn sha1_of_data(data: &[u8]) -> Sha1Hash { let mut hasher = Sha1::new(); hasher.update(data); hasher.finalize().into() } pub(crate) fn mtime_as_i64>(path: P) -> io::Result { Ok(path .as_ref() .metadata()? .modified()? .duration_since(time::UNIX_EPOCH) .unwrap() .as_millis() as i64) } pub fn remove_files(media_folder: &Path, files: &[S]) -> Result<()> where S: AsRef + std::fmt::Debug, { if files.is_empty() { return Ok(()); } let trash_folder = trash_folder(media_folder)?; for file in files { let src_path = media_folder.join(file.as_ref()); let dst_path = trash_folder.join(file.as_ref()); // if the file doesn't exist, nothing to do if let Err(e) = fs::metadata(&src_path) { if e.kind() == io::ErrorKind::NotFound { return Ok(()); } else { return Err(e.into()); } } // move file to trash, clobbering any existing file with the same name fs::rename(&src_path, &dst_path)?; // mark it as modified, so we can expire it in the future let secs = time::SystemTime::now(); let times = FileTimes::new().set_accessed(secs).set_modified(secs); if let Err(err) = set_file_times(&dst_path, times) { // The libc utimes() call fails on (some? all?) Android devices. Since we don't // do automatic expiry yet, we can safely ignore the error. if !cfg!(target_os = "android") { return Err(err.into()); } } } Ok(()) } pub(super) fn trash_folder(media_folder: &Path) -> Result { let trash_folder = media_folder.with_file_name("media.trash"); match create_dir(&trash_folder) { Ok(()) => Ok(trash_folder), Err(e) => { if e.source.kind() == io::ErrorKind::AlreadyExists { Ok(trash_folder) } else { Err(e.into()) } } } } pub struct AddedFile { pub fname: String, pub sha1: Sha1Hash, pub mtime: i64, pub renamed_from: Option, } /// Add a file received from AnkiWeb into the media folder. /// /// Because AnkiWeb did not previously enforce file name limits and invalid /// characters, we'll need to rename the file if it is not valid. pub(crate) fn add_file_from_ankiweb( media_folder: &Path, fname: &str, data: &[u8], ) -> Result { let sha1 = sha1_of_data(data); let normalized = normalize_filename(fname); // if the filename is already valid, we can write the file directly let (renamed_from, path) = if let Cow::Borrowed(_) = normalized { let path = media_folder.join(normalized.as_ref()); debug!(fname = normalized.as_ref(), "write"); write_file(&path, data)?; (None, path) } else { // ankiweb sent us a non-normalized filename, so we'll rename it let new_name = add_data_to_folder_uniquely(media_folder, fname, data, sha1)?; debug!( fname, rename_to = new_name.as_ref(), "non-normalized filename received" ); ( Some(fname.to_string()), media_folder.join(new_name.as_ref()), ) }; let mtime = mtime_as_i64(path)?; Ok(AddedFile { fname: normalized.to_string(), sha1, mtime, renamed_from, }) } pub(crate) fn data_for_file(media_folder: &Path, fname: &str) -> Result>> { let mut file = match open_file(media_folder.join(fname)) { Err(e) if e.is_not_found() => return Ok(None), res => res?, }; let mut buf = vec![]; file.read_to_end(&mut buf)?; Ok(Some(buf)) } #[cfg(test)] mod test { use std::borrow::Cow; use tempfile::tempdir; use crate::media::files::add_data_to_folder_uniquely; use crate::media::files::add_hash_suffix_to_file_stem; use crate::media::files::normalize_filename; use crate::media::files::remove_files; use crate::media::files::sha1_of_data; use crate::media::files::truncate_filename; use crate::sync::media::MAX_MEDIA_FILENAME_LENGTH; #[test] fn normalize() { assert_eq!(normalize_filename("foo.jpg"), Cow::Borrowed("foo.jpg")); assert_eq!( normalize_filename("con.jpg[]><:\"/?*^\\|\0\r\n").as_ref(), "con_.jpg" ); assert_eq!(normalize_filename("test.").as_ref(), "test._"); assert_eq!(normalize_filename("test ").as_ref(), "test _"); let expected_stem_len = MAX_MEDIA_FILENAME_LENGTH - ".jpg".len() - 1; assert_eq!( normalize_filename(&format!( "{}.jpg", "x".repeat(MAX_MEDIA_FILENAME_LENGTH * 2) )), "x".repeat(expected_stem_len) + ".jpg" ); } #[test] fn add_hash_suffix() { let hash = sha1_of_data(b"hello"); assert_eq!( add_hash_suffix_to_file_stem("test.jpg", &hash).as_str(), "test-aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d.jpg" ); } #[test] fn adding_removing() { let dir = tempdir().unwrap(); let dpath = dir.path(); // no existing file case let h1 = sha1_of_data(b"hello"); assert_eq!( add_data_to_folder_uniquely(dpath, "test.mp3", b"hello", h1).unwrap(), "test.mp3" ); // same contents case assert_eq!( add_data_to_folder_uniquely(dpath, "test.mp3", b"hello", h1).unwrap(), "test.mp3" ); // different contents, filenames differ only by case let h2 = sha1_of_data(b"hello1"); assert_eq!( add_data_to_folder_uniquely(dpath, "Test.mp3", b"hello1", h2).unwrap(), "test-88fdd585121a4ccb3d1540527aee53a77c77abb8.mp3" ); // same contents, filenames differ only by case assert_eq!( add_data_to_folder_uniquely(dpath, "test.mp3", b"hello1", h2).unwrap(), "test-88fdd585121a4ccb3d1540527aee53a77c77abb8.mp3" ); let mut written_files = std::fs::read_dir(dpath) .unwrap() .map(|d| d.unwrap().file_name().to_string_lossy().into_owned()) .collect::>(); written_files.sort(); assert_eq!( written_files, vec![ "test-88fdd585121a4ccb3d1540527aee53a77c77abb8.mp3", "test.mp3", ] ); // remove remove_files(dpath, written_files.as_slice()).unwrap(); } #[test] fn truncation() { let one_less = "x".repeat(MAX_MEDIA_FILENAME_LENGTH - 1); assert_eq!( truncate_filename(&one_less, MAX_MEDIA_FILENAME_LENGTH), Cow::Borrowed(&one_less) ); let equal = "x".repeat(MAX_MEDIA_FILENAME_LENGTH); assert_eq!( truncate_filename(&equal, MAX_MEDIA_FILENAME_LENGTH), Cow::Borrowed(&equal) ); let equal = format!("{}.jpg", "x".repeat(MAX_MEDIA_FILENAME_LENGTH - 4)); assert_eq!( truncate_filename(&equal, MAX_MEDIA_FILENAME_LENGTH), Cow::Borrowed(&equal) ); let one_more = "x".repeat(MAX_MEDIA_FILENAME_LENGTH + 1); assert_eq!( truncate_filename(&one_more, MAX_MEDIA_FILENAME_LENGTH), Cow::::Owned("x".repeat(MAX_MEDIA_FILENAME_LENGTH - 2)) ); assert_eq!( truncate_filename( &" ".repeat(MAX_MEDIA_FILENAME_LENGTH + 1), MAX_MEDIA_FILENAME_LENGTH ), Cow::::Owned(format!("{}_", " ".repeat(MAX_MEDIA_FILENAME_LENGTH - 2))) ); } } ================================================ FILE: rslib/src/media/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub mod check; pub mod files; mod service; use std::borrow::Cow; use std::path::Path; use std::path::PathBuf; use anki_io::create_dir_all; use reqwest::Client; use crate::media::files::add_data_to_folder_uniquely; use crate::media::files::mtime_as_i64; use crate::media::files::remove_files; use crate::media::files::sha1_of_data; use crate::prelude::*; use crate::progress::ThrottlingProgressHandler; use crate::sync::http_client::HttpSyncClient; use crate::sync::login::SyncAuth; use crate::sync::media::database::client::changetracker::ChangeTracker; pub use crate::sync::media::database::client::Checksums; use crate::sync::media::database::client::MediaDatabase; use crate::sync::media::database::client::MediaEntry; use crate::sync::media::progress::MediaSyncProgress; use crate::sync::media::syncer::MediaSyncer; pub type Sha1Hash = [u8; 20]; impl Collection { pub fn media(&self) -> Result { MediaManager::new(&self.media_folder, &self.media_db) } } pub struct MediaManager { pub(crate) db: MediaDatabase, pub(crate) media_folder: PathBuf, } impl MediaManager { pub fn new(media_folder: P, media_db: P2) -> Result where P: Into, P2: AsRef, { let media_folder = media_folder.into(); if media_folder.as_os_str().is_empty() { invalid_input!("attempted media operation without media folder set"); } create_dir_all(&media_folder)?; Ok(MediaManager { db: MediaDatabase::new(media_db.as_ref())?, media_folder, }) } /// Add a file to the media folder. /// /// If a file with differing contents already exists, a hash will be /// appended to the name. /// /// Also notes the file in the media database. pub fn add_file<'a>(&self, desired_name: &'a str, data: &[u8]) -> Result> { let data_hash = sha1_of_data(data); self.transact(|db| { let chosen_fname = add_data_to_folder_uniquely(&self.media_folder, desired_name, data, data_hash)?; let file_mtime = mtime_as_i64(self.media_folder.join(chosen_fname.as_ref()))?; let existing_entry = db.get_entry(&chosen_fname)?; let new_sha1 = Some(data_hash); let entry_update_required = existing_entry.map(|e| e.sha1 != new_sha1).unwrap_or(true); if entry_update_required { db.set_entry(&MediaEntry { fname: chosen_fname.to_string(), sha1: new_sha1, mtime: file_mtime, sync_required: true, })?; } Ok(chosen_fname) }) } pub fn remove_files(&self, filenames: &[S]) -> Result<()> where S: AsRef + std::fmt::Debug, { self.transact(|db| { remove_files(&self.media_folder, filenames)?; for fname in filenames { if let Some(mut entry) = db.get_entry(fname.as_ref())? { entry.sha1 = None; entry.mtime = 0; entry.sync_required = true; db.set_entry(&entry)?; } } Ok(()) }) } /// Opens a transaction and manages folder mtime, so user should perform not /// only db ops, but also all file ops inside the closure. pub(crate) fn transact(&self, func: impl FnOnce(&MediaDatabase) -> Result) -> Result { let start_folder_mtime = mtime_as_i64(&self.media_folder)?; self.db.transact(|db| { let out = func(db)?; let mut meta = db.get_meta()?; if meta.folder_mtime == start_folder_mtime { // if media db was in sync with folder prior to this add, // we can keep it in sync meta.folder_mtime = mtime_as_i64(&self.media_folder)?; db.set_meta(&meta)?; } else { // otherwise, leave it alone so that other pending changes // get picked up later } Ok(out) }) } /// Set entry for a newly added file. Caller must ensure transaction. pub(crate) fn add_entry(&self, fname: impl Into, sha1: [u8; 20]) -> Result<()> { let fname = fname.into(); let mtime = mtime_as_i64(self.media_folder.join(&fname))?; self.db.set_entry(&MediaEntry { fname, mtime, sha1: Some(sha1), sync_required: true, }) } /// Sync media. pub async fn sync_media( self, progress: ThrottlingProgressHandler, auth: SyncAuth, client: Client, server_usn: Option, ) -> Result<()> { let client = HttpSyncClient::new(auth, client); let mut syncer = MediaSyncer::new(self, progress, client)?; syncer.sync(server_usn).await } pub fn all_checksums_after_checking( &self, progress: impl FnMut(usize) -> bool, ) -> Result { ChangeTracker::new(&self.media_folder, progress).register_changes(&self.db)?; self.db.all_registered_checksums() } pub fn checksum_getter(&self) -> impl FnMut(&str) -> Result> + '_ { |fname: &str| { self.db .get_entry(fname) .map(|opt| opt.and_then(|entry| entry.sha1)) } } pub fn register_changes(&self, progress: &mut impl FnMut(usize) -> bool) -> Result<()> { ChangeTracker::new(&self.media_folder, progress).register_changes(&self.db) } /// All checksums without registering changes first. #[cfg(test)] pub(crate) fn all_checksums_as_is(&self) -> Checksums { self.db.all_registered_checksums().unwrap() } } ================================================ FILE: rslib/src/media/service.rs ================================================ use std::collections::HashSet; // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::generic; use anki_proto::media::AddMediaFileRequest; use anki_proto::media::CheckMediaResponse; use anki_proto::media::TrashMediaFilesRequest; use crate::collection::Collection; use crate::error; use crate::error::OrNotFound; use crate::notes::service::to_i64s; use crate::notetype::NotetypeId; impl crate::services::MediaService for Collection { fn check_media(&mut self) -> error::Result { self.transact_no_undo(|col| { let mut checker = col.media_checker()?; let mut output = checker.check()?; let mut report = checker.summarize_output(&mut output); col.report_media_field_referencing_templates(&mut report)?; Ok(CheckMediaResponse { unused: output.unused, missing: output.missing, missing_media_notes: to_i64s(output.missing_media_notes), report, have_trash: output.trash_count > 0, }) }) } fn add_media_file(&mut self, input: AddMediaFileRequest) -> error::Result { Ok(self .media()? .add_file(&input.desired_name, &input.data)? .to_string() .into()) } fn trash_media_files(&mut self, input: TrashMediaFilesRequest) -> error::Result<()> { self.media()?.remove_files(&input.fnames) } fn empty_trash(&mut self) -> error::Result<()> { self.media_checker()?.empty_trash() } fn restore_trash(&mut self) -> error::Result<()> { self.media_checker()?.restore_trash() } fn extract_static_media_files( &mut self, ntid: anki_proto::notetypes::NotetypeId, ) -> error::Result { let ntid = NotetypeId::from(ntid); let notetype = self.storage.get_notetype(ntid)?.or_not_found(ntid)?; let mut files: HashSet = HashSet::new(); let mut inserter = |name: String| { files.insert(name); }; notetype.gather_media_names(&mut inserter); Ok(files.into_iter().collect::>().into()) } } ================================================ FILE: rslib/src/notes/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub(crate) mod service; pub(crate) mod undo; use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; use anki_proto::notes::note_fields_check_response::State as NoteFieldsState; use itertools::Itertools; use sha1::Digest; use sha1::Sha1; use crate::cloze::contains_cloze; use crate::define_newtype; use crate::error; use crate::error::AnkiError; use crate::error::OrInvalid; use crate::notetype::CardGenContext; use crate::notetype::NoteField; use crate::ops::StateChanges; use crate::prelude::*; use crate::template::field_is_empty; use crate::text::ensure_string_in_nfc; use crate::text::normalize_to_nfc; use crate::text::strip_html_preserving_media_filenames; define_newtype!(NoteId, i64); #[derive(Default)] pub(crate) struct TransformNoteOutput { pub changed: bool, pub generate_cards: bool, pub mark_modified: bool, pub update_tags: bool, } #[derive(Debug, PartialEq, Eq, Clone)] pub struct Note { pub id: NoteId, pub guid: String, pub notetype_id: NotetypeId, pub mtime: TimestampSecs, pub usn: Usn, pub tags: Vec, fields: Vec, pub(crate) sort_field: Option, pub(crate) checksum: Option, } impl Note { pub fn fields(&self) -> &Vec { &self.fields } pub fn into_fields(self) -> Vec { self.fields } pub fn set_field(&mut self, idx: usize, text: impl Into) -> Result<()> { require!(idx < self.fields.len(), "field idx out of range"); self.fields[idx] = text.into(); self.mark_dirty(); Ok(()) } } #[derive(Debug, Clone)] pub struct AddNoteRequest { pub note: Note, pub deck_id: DeckId, } impl TryFrom for AddNoteRequest { type Error = AnkiError; fn try_from(request: anki_proto::notes::AddNoteRequest) -> error::Result { Ok(Self { note: request.note.or_invalid("no note provided")?.into(), deck_id: DeckId(request.deck_id), }) } } impl Collection { pub fn add_note(&mut self, note: &mut Note, did: DeckId) -> Result> { self.transact(Op::AddNote, |col| col.add_note_inner(note, did)) } pub fn add_notes(&mut self, requests: &mut [AddNoteRequest]) -> Result> { self.transact(Op::AddNote, |col| { for request in requests { col.add_note_inner(&mut request.note, request.deck_id)?; } Ok(()) }) } /// Remove provided notes, and any cards that use them. pub fn remove_notes(&mut self, nids: &[NoteId]) -> Result> { let usn = self.usn()?; self.transact(Op::RemoveNote, |col| col.remove_notes_inner(nids, usn)) } /// Update cards and field cache after notes modified externally. /// If gencards is false, skip card generation. pub fn after_note_updates( &mut self, nids: &[NoteId], generate_cards: bool, mark_notes_modified: bool, ) -> Result> { self.transact(Op::UpdateNote, |col| { col.after_note_updates_inner(nids, generate_cards, mark_notes_modified) }) } } /// Information required for updating tags while leaving note content alone. /// Tags are stored in their DB form, separated by spaces. #[derive(Debug, PartialEq, Eq, Clone)] pub(crate) struct NoteTags { pub id: NoteId, pub mtime: TimestampSecs, pub usn: Usn, pub tags: String, } impl NoteTags { pub(crate) fn set_modified(&mut self, usn: Usn) { self.mtime = TimestampSecs::now(); self.usn = usn; } } impl Note { pub fn new(notetype: &Notetype) -> Self { Note { id: NoteId(0), guid: base91_u64(), notetype_id: notetype.id, mtime: TimestampSecs(0), usn: Usn(0), tags: vec![], fields: vec!["".to_string(); notetype.fields.len()], sort_field: None, checksum: None, } } #[allow(clippy::too_many_arguments)] pub(crate) fn new_from_storage( id: NoteId, guid: String, notetype_id: NotetypeId, mtime: TimestampSecs, usn: Usn, tags: Vec, fields: Vec, sort_field: Option, checksum: Option, ) -> Self { Self { id, guid, notetype_id, mtime, usn, tags, fields, sort_field, checksum, } } pub fn fields_mut(&mut self) -> &mut Vec { self.mark_dirty(); &mut self.fields } // Ensure we get an error if caller forgets to call prepare_for_update(). fn mark_dirty(&mut self) { self.sort_field = None; self.checksum = None; } /// Prepare note for saving to the database. Does not mark it as modified. pub(crate) fn prepare_for_update(&mut self, nt: &Notetype, normalize_text: bool) -> Result<()> { assert_eq!(nt.id, self.notetype_id); let notetype_field_count = nt.fields.len().max(1); require!( notetype_field_count == self.fields.len(), "note has {} fields, expected {notetype_field_count}", self.fields.len() ); for field in self.fields_mut() { normalize_field(field, normalize_text); } let field1_nohtml = strip_html_preserving_media_filenames(&self.fields()[0]); let checksum = field_checksum(field1_nohtml.as_ref()); let sort_field = if nt.config.sort_field_idx == 0 { field1_nohtml } else { strip_html_preserving_media_filenames( self.fields .get(nt.config.sort_field_idx as usize) .map(AsRef::as_ref) .unwrap_or(""), ) }; self.sort_field = Some(sort_field.into()); self.checksum = Some(checksum); Ok(()) } #[inline] pub(crate) fn set_modified_with_mtime(&mut self, usn: Usn, mtime: TimestampSecs) { self.mtime = mtime; self.usn = usn; } pub(crate) fn set_modified(&mut self, usn: Usn) { self.set_modified_with_mtime(usn, TimestampSecs::now()) } pub(crate) fn nonempty_fields<'a>(&self, fields: &'a [NoteField]) -> HashSet<&'a str> { self.fields .iter() .enumerate() .filter_map(|(ord, s)| { if field_is_empty(s) { None } else { fields.get(ord).map(|f| f.name.as_str()) } }) .collect() } pub(crate) fn fields_map<'a>( &'a self, fields: &'a [NoteField], ) -> HashMap<&'a str, Cow<'a, str>> { self.fields .iter() .enumerate() .map(|(ord, field_content)| { ( fields.get(ord).map(|f| f.name.as_str()).unwrap_or(""), field_content.as_str().into(), ) }) .collect() } /// Pad or merge fields to match note type. pub(crate) fn fix_field_count(&mut self, nt: &Notetype) { while self.fields.len() < nt.fields.len() { self.fields.push("".into()) } while self.fields.len() > nt.fields.len() && self.fields.len() > 1 { let last = self.fields.pop().unwrap(); self.fields .last_mut() .unwrap() .push_str(&format!("; {last}")); } } } /// Remove invalid characters and optionally ensure nfc normalization. pub(crate) fn normalize_field(field: &mut String, normalize_text: bool) { if field.contains(invalid_char_for_field) { *field = field.replace(invalid_char_for_field, ""); } if normalize_text { ensure_string_in_nfc(field); } } impl From for anki_proto::notes::Note { fn from(n: Note) -> Self { anki_proto::notes::Note { id: n.id.0, guid: n.guid, notetype_id: n.notetype_id.0, mtime_secs: n.mtime.0 as u32, usn: n.usn.0, tags: n.tags, fields: n.fields, } } } impl From for Note { fn from(n: anki_proto::notes::Note) -> Self { Note { id: NoteId(n.id), guid: n.guid, notetype_id: NotetypeId(n.notetype_id), mtime: TimestampSecs(n.mtime_secs as i64), usn: Usn(n.usn), tags: n.tags, fields: n.fields, sort_field: None, checksum: None, } } } /// Text must be passed to strip_html_preserving_media_filenames() by /// caller prior to passing in here. pub(crate) fn field_checksum(text: &str) -> u32 { let mut hash = Sha1::new(); hash.update(text); let digest = hash.finalize(); u32::from_be_bytes(digest[..4].try_into().unwrap()) } pub(crate) fn base91_u64() -> String { anki_base91(rand::random()) } fn anki_base91(n: u64) -> String { to_base_n( n, b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\ 0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~", ) } pub fn to_base_n(mut n: u64, table: &[u8]) -> String { let mut buf = String::new(); while n > 0 { let tablelen = table.len() as u64; let (q, r) = (n / tablelen, n % tablelen); buf.push(table[r as usize] as char); n = q; } buf.chars().rev().collect() } fn invalid_char_for_field(c: char) -> bool { c.is_ascii_control() && c != '\n' && c != '\t' } /// Used when calling [Collection::update_note_inner_without_cards] and /// [Collection::update_note_inner_without_cards_using_mtime] pub(crate) struct UpdateNoteInnerWithoutCardsArgs<'a> { pub(crate) note: &'a mut Note, pub(crate) original: &'a Note, pub(crate) notetype: &'a Notetype, pub(crate) usn: Usn, pub(crate) mark_note_modified: bool, pub(crate) normalize_text: bool, pub(crate) update_tags: bool, } impl Collection { pub(crate) fn canonify_note_tags(&mut self, note: &mut Note, usn: Usn) -> Result<()> { if !note.tags.is_empty() { let tags = std::mem::take(&mut note.tags); note.tags = self.canonify_tags(tags, usn)?.0; } Ok(()) } pub(crate) fn add_note_inner(&mut self, note: &mut Note, did: DeckId) -> Result { let nt = self .get_notetype(note.notetype_id)? .or_invalid("missing note type")?; let last_deck = self.get_last_deck_added_to_for_notetype(note.notetype_id); let ctx = CardGenContext::new(nt.as_ref(), last_deck, self.usn()?); let normalize_text = self.get_config_bool(BoolKey::NormalizeNoteText); self.canonify_note_tags(note, ctx.usn)?; note.prepare_for_update(ctx.notetype, normalize_text)?; note.set_modified(ctx.usn); self.add_note_only_undoable(note)?; let count = self.generate_cards_for_new_note(&ctx, note, did)?; self.set_last_deck_for_notetype(note.notetype_id, did)?; self.set_last_notetype_for_deck(did, note.notetype_id)?; self.set_current_notetype_id(note.notetype_id)?; Ok(count) } pub fn update_note(&mut self, note: &mut Note) -> Result> { self.transact(Op::UpdateNote, |col| col.update_note_inner(note)) } pub(crate) fn update_notes_maybe_undoable( &mut self, notes: Vec, undoable: bool, ) -> Result> { if undoable { self.transact(Op::UpdateNote, |col| { for mut note in notes { col.update_note_inner(&mut note)?; } Ok(()) }) } else { self.transact_no_undo(|col| { for mut note in notes { col.update_note_inner(&mut note)?; } Ok(OpOutput { output: (), changes: OpChanges { op: Op::UpdateNote, changes: StateChanges { note: true, tag: true, card: true, ..Default::default() }, }, }) }) } } pub(crate) fn update_note_inner(&mut self, note: &mut Note) -> Result<()> { let mut existing_note = self.storage.get_note(note.id)?.or_not_found(note.id)?; if !note_differs_from_db(&mut existing_note, note) { // nothing to do return Ok(()); } let nt = self .get_notetype(note.notetype_id)? .or_invalid("missing note type")?; let last_deck = self.get_last_deck_added_to_for_notetype(note.notetype_id); let ctx = CardGenContext::new(nt.as_ref(), last_deck, self.usn()?); let norm = self.get_config_bool(BoolKey::NormalizeNoteText); self.update_note_inner_generating_cards(&ctx, note, &existing_note, true, norm, true)?; Ok(()) } pub(crate) fn update_note_inner_generating_cards( &mut self, ctx: &CardGenContext<&Notetype>, note: &mut Note, original: &Note, mark_note_modified: bool, normalize_text: bool, update_tags: bool, ) -> Result<()> { self.update_note_inner_without_cards(UpdateNoteInnerWithoutCardsArgs { note, original, notetype: ctx.notetype, usn: ctx.usn, mark_note_modified, normalize_text, update_tags, })?; self.generate_cards_for_existing_note(ctx, note) } #[inline] pub(crate) fn update_note_inner_without_cards_using_mtime( &mut self, UpdateNoteInnerWithoutCardsArgs { note, original, notetype, usn, mark_note_modified, normalize_text, update_tags, }: UpdateNoteInnerWithoutCardsArgs, mtime: Option, ) -> Result<()> { if update_tags { self.canonify_note_tags(note, usn)?; } note.prepare_for_update(notetype, normalize_text)?; if mark_note_modified { if let Some(mtime) = mtime { note.set_modified_with_mtime(usn, mtime); } else { note.set_modified(usn); } } self.update_note_undoable(note, original) } pub(crate) fn update_note_inner_without_cards( &mut self, args: UpdateNoteInnerWithoutCardsArgs<'_>, ) -> Result<()> { self.update_note_inner_without_cards_using_mtime(args, None) } pub(crate) fn remove_notes_inner(&mut self, nids: &[NoteId], usn: Usn) -> Result { let mut card_count = 0; for nid in nids { let nid = *nid; if let Some(_existing_note) = self.storage.get_note(nid)? { for card in self.storage.all_cards_of_note(nid)? { card_count += 1; self.remove_card_and_add_grave_undoable(card, usn)?; } self.remove_note_only_undoable(nid, usn)?; } } Ok(card_count) } fn after_note_updates_inner( &mut self, nids: &[NoteId], generate_cards: bool, mark_notes_modified: bool, ) -> Result { self.transform_notes(nids, |_note, _nt| { Ok(TransformNoteOutput { changed: true, generate_cards, mark_modified: mark_notes_modified, update_tags: true, }) }) } pub(crate) fn transform_notes( &mut self, nids: &[NoteId], mut transformer: F, ) -> Result where F: FnMut(&mut Note, &Notetype) -> Result, { let nids_by_notetype = self.storage.note_ids_by_notetype(nids)?; let norm = self.get_config_bool(BoolKey::NormalizeNoteText); let mut changed_notes = 0; let usn = self.usn()?; for (ntid, group) in &nids_by_notetype.into_iter().chunk_by(|tup| tup.0) { let nt = self.get_notetype(ntid)?.or_invalid("missing note type")?; let mut genctx = None; for (_, nid) in group { // grab the note and transform it let mut note = self.storage.get_note(nid)?.unwrap(); let original = note.clone(); let out = transformer(&mut note, &nt)?; if !out.changed { continue; } if out.generate_cards { let ctx = genctx.get_or_insert_with(|| { CardGenContext::new( nt.as_ref(), self.get_last_deck_added_to_for_notetype(nt.id), usn, ) }); self.update_note_inner_generating_cards( ctx, &mut note, &original, out.mark_modified, norm, out.update_tags, )?; } else { self.update_note_inner_without_cards(UpdateNoteInnerWithoutCardsArgs { note: &mut note, original: &original, notetype: &nt, usn, mark_note_modified: out.mark_modified, normalize_text: norm, update_tags: out.update_tags, })?; } changed_notes += 1; } } Ok(changed_notes) } /// Check if there is a cloze in a non-cloze field. Then check if the /// note's first field is empty. For cloze notetypes, check whether there /// is a cloze at all. Finally, check if the first field is a duplicate. pub fn note_fields_check(&mut self, note: &Note) -> Result { Ok({ let cloze_state = self.field_cloze_check(note)?; if cloze_state == NoteFieldsState::FieldNotCloze { NoteFieldsState::FieldNotCloze } else if let Some(text) = note.fields.first() { let field1 = if self.get_config_bool(BoolKey::NormalizeNoteText) { normalize_to_nfc(text) } else { text.into() }; let stripped = strip_html_preserving_media_filenames(&field1); if stripped.trim().is_empty() { NoteFieldsState::Empty } else if cloze_state != NoteFieldsState::Normal { cloze_state } else if self.is_duplicate(&stripped, note)? { NoteFieldsState::Duplicate } else { NoteFieldsState::Normal } } else { NoteFieldsState::Empty } }) } fn is_duplicate(&self, first_field: &str, note: &Note) -> Result { let csum = field_checksum(first_field); Ok(self .storage .note_fields_by_checksum(note.notetype_id, csum)? .into_iter() .any(|(nid, field)| { nid != note.id && strip_html_preserving_media_filenames(&field) == first_field })) } fn field_cloze_check(&mut self, note: &Note) -> Result { let notetype = self .get_notetype(note.notetype_id)? .or_not_found(note.notetype_id)?; let cloze_fields = notetype.cloze_fields(); let mut has_cloze = false; let extraneous_cloze = note.fields.iter().enumerate().find_map(|(i, field)| { if notetype.is_cloze() { if contains_cloze(field) { if cloze_fields.contains(&i) { has_cloze = true; None } else { Some(NoteFieldsState::FieldNotCloze) } } else { None } } else if contains_cloze(field) { Some(NoteFieldsState::NotetypeNotCloze) } else { None } }); Ok(if let Some(state) = extraneous_cloze { state } else if notetype.is_cloze() && !has_cloze { NoteFieldsState::MissingCloze } else { NoteFieldsState::Normal }) } } /// The existing note pulled from the DB will have sfld and csum set, but the /// note we receive from the frontend won't. Temporarily zero them out and /// compare, then restore them again. /// Also set mtime to existing, since the frontend may have a stale mtime, and /// we'll bump it as we save in any case. fn note_differs_from_db(existing_note: &mut Note, note: &mut Note) -> bool { let sort_field = existing_note.sort_field.take(); let checksum = existing_note.checksum.take(); note.mtime = existing_note.mtime; let notes_differ = existing_note != note; existing_note.sort_field = sort_field; existing_note.checksum = checksum; notes_differ } #[cfg(test)] mod test { use super::anki_base91; use super::field_checksum; use crate::config::BoolKey; use crate::decks::DeckId; use crate::error::Result; use crate::prelude::*; use crate::search::SortMode; #[test] fn test_base91() { // match the python implementation for now assert_eq!(anki_base91(0), ""); assert_eq!(anki_base91(1), "b"); assert_eq!(anki_base91(u64::MAX), "Rj&Z5m[>Zp"); assert_eq!(anki_base91(1234567890), "saAKk"); } #[test] fn test_field_checksum() { assert_eq!(field_checksum("test"), 2840236005); assert_eq!(field_checksum("今日"), 1464653051); } #[test] fn adding_cards() -> Result<()> { let mut col = Collection::new(); let nt = col .get_notetype_by_name("basic (and reversed card)")? .unwrap(); let mut note = nt.new_note(); // if no cards are generated, 1 card is added col.add_note(&mut note, DeckId(1)).unwrap(); let existing = col.storage.existing_cards_for_note(note.id)?; assert_eq!(existing.len(), 1); assert_eq!(existing[0].ord, 0); // nothing changes if the first field is filled note.fields[0] = "test".into(); col.update_note(&mut note).unwrap(); let existing = col.storage.existing_cards_for_note(note.id)?; assert_eq!(existing.len(), 1); assert_eq!(existing[0].ord, 0); // second field causes another card to be generated note.fields[1] = "test".into(); col.update_note(&mut note).unwrap(); let existing = col.storage.existing_cards_for_note(note.id)?; assert_eq!(existing.len(), 2); assert_eq!(existing[1].ord, 1); // cloze cards also generate card 0 if no clozes are found let nt = col.get_notetype_by_name("cloze")?.unwrap(); let mut note = nt.new_note(); col.add_note(&mut note, DeckId(1)).unwrap(); let existing = col.storage.existing_cards_for_note(note.id)?; assert_eq!(existing.len(), 1); assert_eq!(existing[0].ord, 0); assert_eq!(existing[0].original_deck_id, DeckId(1)); // and generate cards for any cloze deletions note.fields[0] = "{{c1::foo}} {{c2::bar}} {{c3::baz}} {{c0::quux}} {{c501::over}}".into(); col.update_note(&mut note)?; let existing = col.storage.existing_cards_for_note(note.id)?; let mut ords = existing.iter().map(|a| a.ord).collect::>(); ords.sort_unstable(); assert_eq!(ords, vec![0, 1, 2, 499]); Ok(()) } #[test] fn normalization() -> Result<()> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); note.fields[0] = "\u{fa47}".into(); col.add_note(&mut note, DeckId(1))?; assert_eq!(note.fields[0], "\u{6f22}"); // non-normalized searches should be converted assert_eq!(col.search_cards("\u{fa47}", SortMode::NoOrder)?.len(), 1); assert_eq!( col.search_cards("front:\u{fa47}", SortMode::NoOrder)?.len(), 1 ); let cids = col.search_cards("", SortMode::NoOrder)?; col.remove_cards_and_orphaned_notes(&cids)?; // if normalization turned off, note text is entered as-is let mut note = nt.new_note(); note.fields[0] = "\u{fa47}".into(); col.set_config(BoolKey::NormalizeNoteText, &false).unwrap(); col.add_note(&mut note, DeckId(1))?; assert_eq!(note.fields[0], "\u{fa47}"); // normalized searches won't match assert_eq!(col.search_cards("\u{6f22}", SortMode::NoOrder)?.len(), 0); // but original characters will assert_eq!(col.search_cards("\u{fa47}", SortMode::NoOrder)?.len(), 1); Ok(()) } #[test] fn undo() -> Result<()> { let mut col = Collection::new(); let nt = col .get_notetype_by_name("basic (and reversed card)")? .unwrap(); let assert_initial = |col: &mut Collection| -> Result<()> { assert_eq!(col.search_notes_unordered("")?.len(), 0); assert_eq!(col.search_cards("", SortMode::NoOrder)?.len(), 0); assert_eq!( col.storage.db_scalar::("select count() from graves")?, 0 ); assert!(col.get_next_card()?.is_none()); Ok(()) }; let assert_after_add = |col: &mut Collection| -> Result<()> { assert_eq!(col.search_notes_unordered("")?.len(), 1); assert_eq!(col.search_cards("", SortMode::NoOrder)?.len(), 2); assert_eq!( col.storage.db_scalar::("select count() from graves")?, 0 ); assert!(col.get_next_card()?.is_some()); Ok(()) }; assert_initial(&mut col)?; let mut note = nt.new_note(); note.set_field(0, "a")?; note.set_field(1, "b")?; col.add_note(&mut note, DeckId(1)).unwrap(); assert_after_add(&mut col)?; col.undo()?; assert_initial(&mut col)?; col.redo()?; assert_after_add(&mut col)?; col.undo()?; assert_initial(&mut col)?; let assert_after_remove = |col: &mut Collection| -> Result<()> { assert_eq!(col.search_notes_unordered("")?.len(), 0); assert_eq!(col.search_cards("", SortMode::NoOrder)?.len(), 0); // 1 note + 2 cards assert_eq!( col.storage.db_scalar::("select count() from graves")?, 3 ); assert!(col.get_next_card()?.is_none()); Ok(()) }; col.redo()?; assert_after_add(&mut col)?; let nids = col.search_notes_unordered("")?; col.remove_notes(&nids)?; assert_after_remove(&mut col)?; col.undo()?; assert_after_add(&mut col)?; col.redo()?; assert_after_remove(&mut col)?; Ok(()) } } ================================================ FILE: rslib/src/notes/service.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::cloze::cloze_number_in_fields; use crate::collection::Collection; use crate::decks::DeckId; use crate::error; use crate::error::AnkiError; use crate::error::OrInvalid; use crate::error::OrNotFound; use crate::notes::AddNoteRequest; use crate::notes::Note; use crate::notes::NoteId; use crate::prelude::IntoNewtypeVec; pub(crate) fn to_i64s(ids: Vec) -> Vec { ids.into_iter().map(Into::into).collect() } impl crate::services::NotesService for Collection { fn new_note( &mut self, input: anki_proto::notetypes::NotetypeId, ) -> error::Result { let ntid = input.into(); let nt = self.get_notetype(ntid)?.or_not_found(ntid)?; Ok(nt.new_note().into()) } fn add_note( &mut self, input: anki_proto::notes::AddNoteRequest, ) -> error::Result { let mut note: Note = input.note.or_invalid("no note provided")?.into(); let changes = self.add_note(&mut note, DeckId(input.deck_id))?; Ok(anki_proto::notes::AddNoteResponse { note_id: note.id.0, changes: Some(changes.into()), }) } fn add_notes( &mut self, input: anki_proto::notes::AddNotesRequest, ) -> error::Result { let mut requests = input .requests .into_iter() .map(TryInto::try_into) .collect::, AnkiError>>()?; let changes = self.add_notes(&mut requests)?; Ok(anki_proto::notes::AddNotesResponse { nids: requests.iter().map(|r| r.note.id.0).collect(), changes: Some(changes.into()), }) } fn defaults_for_adding( &mut self, input: anki_proto::notes::DefaultsForAddingRequest, ) -> error::Result { let home_deck: DeckId = input.home_deck_of_current_review_card.into(); self.defaults_for_adding(home_deck).map(Into::into) } fn default_deck_for_notetype( &mut self, input: anki_proto::notetypes::NotetypeId, ) -> error::Result { Ok(self .default_deck_for_notetype(input.into())? .unwrap_or(DeckId(0)) .into()) } fn update_notes( &mut self, input: anki_proto::notes::UpdateNotesRequest, ) -> error::Result { let notes = input .notes .into_iter() .map(Into::into) .collect::>(); self.update_notes_maybe_undoable(notes, !input.skip_undo_entry) .map(Into::into) } fn get_note( &mut self, input: anki_proto::notes::NoteId, ) -> error::Result { let nid = input.into(); self.storage .get_note(nid)? .or_not_found(nid) .map(Into::into) } fn remove_notes( &mut self, input: anki_proto::notes::RemoveNotesRequest, ) -> error::Result { if !input.note_ids.is_empty() { self.remove_notes( &input .note_ids .into_iter() .map(Into::into) .collect::>(), ) } else { let nids = self.storage.note_ids_of_cards( &input .card_ids .into_iter() .map(Into::into) .collect::>(), )?; self.remove_notes(&nids.into_iter().collect::>()) } .map(Into::into) } fn cloze_numbers_in_note( &mut self, note: anki_proto::notes::Note, ) -> error::Result { let set = cloze_number_in_fields(note.fields); Ok(anki_proto::notes::ClozeNumbersInNoteResponse { numbers: set.into_iter().map(|n| n as u32).collect(), }) } fn after_note_updates( &mut self, input: anki_proto::notes::AfterNoteUpdatesRequest, ) -> error::Result { self.after_note_updates( &to_note_ids(input.nids), input.generate_cards, input.mark_notes_modified, ) .map(Into::into) } fn field_names_for_notes( &mut self, input: anki_proto::notes::FieldNamesForNotesRequest, ) -> error::Result { let nids: Vec<_> = input.nids.into_iter().map(NoteId).collect(); self.storage .field_names_for_notes(&nids) .map(|fields| anki_proto::notes::FieldNamesForNotesResponse { fields }) } fn note_fields_check( &mut self, input: anki_proto::notes::Note, ) -> error::Result { let note: Note = input.into(); self.note_fields_check(¬e) .map(|r| anki_proto::notes::NoteFieldsCheckResponse { state: r as i32 }) } fn cards_of_note( &mut self, input: anki_proto::notes::NoteId, ) -> error::Result { self.storage .all_card_ids_of_note_in_template_order(NoteId(input.nid)) .map(|v| anki_proto::cards::CardIds { cids: v.into_iter().map(Into::into).collect(), }) } fn get_single_notetype_of_notes( &mut self, input: anki_proto::notes::NoteIds, ) -> error::Result { self.get_single_notetype_of_notes(&input.note_ids.into_newtype(NoteId)) .map(Into::into) } } pub(crate) fn to_note_ids(ids: Vec) -> Vec { ids.into_iter().map(NoteId).collect() } impl From for NoteId { fn from(nid: anki_proto::notes::NoteId) -> Self { NoteId(nid.nid) } } impl From for anki_proto::notes::NoteId { fn from(nid: NoteId) -> Self { anki_proto::notes::NoteId { nid: nid.0 } } } ================================================ FILE: rslib/src/notes/undo.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::NoteTags; use crate::collection::undo::UndoableCollectionChange; use crate::prelude::*; use crate::undo::UndoableChange; #[derive(Debug)] pub(crate) enum UndoableNoteChange { Added(Box), Updated(Box), Removed(Box), GraveAdded(Box<(NoteId, Usn)>), GraveRemoved(Box<(NoteId, Usn)>), TagsUpdated(Box), } impl Collection { pub(crate) fn undo_note_change(&mut self, change: UndoableNoteChange) -> Result<()> { match change { UndoableNoteChange::Added(note) => self.remove_note_without_grave(*note), UndoableNoteChange::Updated(note) => { let current = self .storage .get_note(note.id)? .or_invalid("note disappeared")?; self.update_note_undoable(¬e, ¤t) } UndoableNoteChange::Removed(note) => self.restore_deleted_note(*note), UndoableNoteChange::GraveAdded(e) => self.remove_note_grave(e.0, e.1), UndoableNoteChange::GraveRemoved(e) => self.add_note_grave(e.0, e.1), UndoableNoteChange::TagsUpdated(note_tags) => { let current = self .storage .get_note_tags_by_id(note_tags.id)? .or_invalid("note disappeared")?; self.update_note_tags_undoable(¬e_tags, current) } } } /// Saves in the undo queue, and commits to DB. /// No validation, card generation or normalization is done. pub(crate) fn update_note_undoable(&mut self, note: &Note, original: &Note) -> Result<()> { self.save_undo(UndoableNoteChange::Updated(Box::new(original.clone()))); self.storage.update_note(note)?; Ok(()) } /// Remove a note. Cards must already have been deleted. pub(crate) fn remove_note_only_undoable(&mut self, nid: NoteId, usn: Usn) -> Result<()> { if let Some(note) = self.storage.get_note(nid)? { self.save_undo(UndoableNoteChange::Removed(Box::new(note))); self.storage.remove_note(nid)?; self.add_note_grave(nid, usn)?; } Ok(()) } /// If note is edited multiple times in quick succession, avoid creating /// extra undo entries. pub(crate) fn maybe_coalesce_note_undo_entry(&mut self, changes: &OpChanges) { if changes.op != Op::UpdateNote { return; } let Some(previous_op) = self.previous_undo_op() else { return; }; if previous_op.kind != Op::UpdateNote { return; } let Some(current_op) = self.current_undo_op() else { return; }; if let ( [UndoableChange::Note(UndoableNoteChange::Updated(previous)), UndoableChange::Collection(UndoableCollectionChange::Modified(_))], [UndoableChange::Note(UndoableNoteChange::Updated(current)), UndoableChange::Collection(UndoableCollectionChange::Modified(_))], ) = (&previous_op.changes[..], ¤t_op.changes[..]) { if previous.id == current.id && previous_op.timestamp.elapsed_secs() < 60 { self.clear_last_op(); } } } /// Add a note, not adding any cards. pub(crate) fn add_note_only_undoable(&mut self, note: &mut Note) -> Result<(), AnkiError> { self.storage.add_note(note)?; self.save_undo(UndoableNoteChange::Added(Box::new(note.clone()))); Ok(()) } /// Add a note, not adding any cards. Caller guarantees id is unique. pub(crate) fn add_note_only_with_id_undoable(&mut self, note: &mut Note) -> Result<()> { require!(self.storage.add_note_if_unique(note)?, "note id existed"); self.save_undo(UndoableNoteChange::Added(Box::new(note.clone()))); Ok(()) } pub(crate) fn update_note_tags_undoable( &mut self, tags: &NoteTags, original: NoteTags, ) -> Result<()> { self.save_undo(UndoableNoteChange::TagsUpdated(Box::new(original))); self.storage.update_note_tags(tags) } fn remove_note_without_grave(&mut self, note: Note) -> Result<()> { self.storage.remove_note(note.id)?; self.save_undo(UndoableNoteChange::Removed(Box::new(note))); Ok(()) } fn restore_deleted_note(&mut self, note: Note) -> Result<()> { self.storage.add_or_update_note(¬e)?; self.save_undo(UndoableNoteChange::Added(Box::new(note))); Ok(()) } fn add_note_grave(&mut self, nid: NoteId, usn: Usn) -> Result<()> { self.save_undo(UndoableNoteChange::GraveAdded(Box::new((nid, usn)))); self.storage.add_note_grave(nid, usn) } fn remove_note_grave(&mut self, nid: NoteId, usn: Usn) -> Result<()> { self.save_undo(UndoableNoteChange::GraveRemoved(Box::new((nid, usn)))); self.storage.remove_note_grave(nid) } } ================================================ FILE: rslib/src/notetype/cardgen.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::collections::HashSet; use std::ops::Deref; use itertools::Itertools; use rand::rngs::StdRng; use rand::Rng; use rand::SeedableRng; use super::Notetype; use crate::cloze::cloze_number_in_fields; use crate::notetype::NotetypeKind; use crate::prelude::*; use crate::template::ParsedTemplate; /// Info about an existing card required when generating new cards #[derive(Debug, PartialEq, Eq)] pub(crate) struct AlreadyGeneratedCardInfo { pub id: CardId, pub nid: NoteId, pub ord: u32, pub original_deck_id: DeckId, pub position_if_new: Option, } #[derive(Debug)] pub(crate) struct CardToGenerate { pub ord: u32, pub did: Option, pub due: Option, } /// Info required to determine whether a particular card ordinal should exist, /// and which deck it should be placed in. pub(crate) struct SingleCardGenContext { template: Option, target_deck_id: Option, } /// Info required to determine which cards should be generated when note /// added/updated, and where they should be placed. pub(crate) struct CardGenContext> { pub usn: Usn, pub notetype: N, /// The last deck that was added to with this note type pub last_deck: Option, cards: Vec, } // store for data that needs to be looked up multiple times #[derive(Default)] pub(crate) struct CardGenCache { next_position: Option, deck_configs: HashMap, } impl> CardGenContext { pub(crate) fn new(nt: N, last_deck: Option, usn: Usn) -> CardGenContext { let cards = nt .templates .iter() .map(|tmpl| SingleCardGenContext { template: tmpl.parsed_question(), target_deck_id: tmpl.target_deck_id(), }) .collect(); CardGenContext { usn, last_deck, notetype: nt, cards, } } /// If template[ord] generates a non-empty question given nonempty_fields, /// return the provided deck id, or an overridden one. If question is /// empty, return None. fn is_nonempty(&self, card_ord: usize, nonempty_fields: &HashSet<&str>) -> bool { let card = &self.cards[card_ord]; let template = match card.template { Some(ref template) => template, None => { // template failed to parse; card can not be generated return false; } }; template.renders_with_fields(nonempty_fields) } /// Returns the cards that need to be generated for the provided note. pub(crate) fn new_cards_required( &self, note: &Note, existing: &[AlreadyGeneratedCardInfo], ensure_not_empty: bool, ) -> Vec { let extracted = extract_data_from_existing_cards(existing); let cards = match self.notetype.config.kind() { NotetypeKind::Normal => self.new_cards_required_normal(note, &extracted), NotetypeKind::Cloze => self.new_cards_required_cloze(note, &extracted), }; if extracted.existing_ords.is_empty() && cards.is_empty() && ensure_not_empty { // if there are no existing cards and no cards will be generated, // we add card 0 to ensure the note always has at least one card vec![CardToGenerate { ord: 0, did: extracted.deck_id, due: extracted.due, }] } else { cards } } fn new_cards_required_normal( &self, note: &Note, extracted: &ExtractedCardInfo, ) -> Vec { let mut nonempty_fields = note.nonempty_fields(&self.notetype.fields); // Include Tags as a nonempty field when note has tags to render {{#Tags}} if !note.tags.is_empty() { nonempty_fields.insert("Tags"); } self.cards .iter() .enumerate() .filter_map(|(ord, card)| { if !extracted.existing_ords.contains(&(ord as u32)) && self.is_nonempty(ord, &nonempty_fields) { Some(CardToGenerate { ord: ord as u32, did: card.target_deck_id.or(extracted.deck_id), due: extracted.due, }) } else { None } }) .collect() } fn new_cards_required_cloze( &self, note: &Note, extracted: &ExtractedCardInfo, ) -> Vec { // gather all cloze numbers let set = cloze_number_in_fields(note.fields()); set.into_iter() .filter_map(|cloze_ord| { let card_ord = cloze_ord.saturating_sub(1).min(499); if extracted.existing_ords.contains(&(card_ord as u32)) { None } else { Some(CardToGenerate { ord: card_ord as u32, did: extracted.deck_id, due: extracted.due, }) } }) .collect() } } // this could be reworked in the future to avoid the extra vec allocation pub(super) fn group_generated_cards_by_note( items: Vec, ) -> Vec<(NoteId, Vec)> { let mut out = vec![]; for (key, group) in &items.into_iter().chunk_by(|c| c.nid) { out.push((key, group.collect())); } out } #[derive(Debug, PartialEq, Eq, Default)] pub(crate) struct ExtractedCardInfo { // if set, the due position new cards should be given pub due: Option, // if set, the deck all current cards are in pub deck_id: Option, pub existing_ords: HashSet, } pub(crate) fn extract_data_from_existing_cards( cards: &[AlreadyGeneratedCardInfo], ) -> ExtractedCardInfo { let mut due = None; let mut deck_ids = HashSet::new(); for card in cards { if due.is_none() && card.position_if_new.is_some() { due = card.position_if_new; } deck_ids.insert(card.original_deck_id); } let existing_ords: HashSet<_> = cards.iter().map(|c| c.ord).collect(); ExtractedCardInfo { due, deck_id: if deck_ids.len() == 1 { deck_ids.into_iter().next() } else { None }, existing_ords, } } impl Collection { pub(crate) fn generate_cards_for_new_note( &mut self, ctx: &CardGenContext>, note: &Note, target_deck_id: DeckId, ) -> Result { self.generate_cards_for_note( ctx, note, &[], Some(target_deck_id), &mut Default::default(), ) } pub(crate) fn generate_cards_for_existing_note( &mut self, ctx: &CardGenContext>, note: &Note, ) -> Result<()> { let existing = self.storage.existing_cards_for_note(note.id)?; self.generate_cards_for_note(ctx, note, &existing, ctx.last_deck, &mut Default::default())?; Ok(()) } fn generate_cards_for_note( &mut self, ctx: &CardGenContext>, note: &Note, existing: &[AlreadyGeneratedCardInfo], target_deck_id: Option, cache: &mut CardGenCache, ) -> Result { let cards = ctx.new_cards_required(note, existing, true); if cards.is_empty() { return Ok(0); } self.add_generated_cards(note.id, &cards, target_deck_id, cache)?; Ok(cards.len()) } pub(crate) fn generate_cards_for_notetype( &mut self, ctx: &CardGenContext>, ) -> Result<()> { let existing_cards = self.storage.existing_cards_for_notetype(ctx.notetype.id)?; let by_note = group_generated_cards_by_note(existing_cards); let mut cache = CardGenCache::default(); for (nid, existing_cards) in by_note { if ctx.notetype.config.kind() == NotetypeKind::Normal && existing_cards.len() == ctx.notetype.templates.len() { // in a normal note type, if card count matches template count, we don't need // to load the note contents to know if all cards have been generated continue; } cache.next_position = None; let note = self.storage.get_note(nid)?.unwrap(); self.generate_cards_for_note(ctx, ¬e, &existing_cards, None, &mut cache)?; } Ok(()) } pub(crate) fn add_generated_cards( &mut self, nid: NoteId, cards: &[CardToGenerate], target_deck_id: Option, cache: &mut CardGenCache, ) -> Result<()> { for c in cards { let (did, dcid) = self.deck_for_adding(c.did.or(target_deck_id))?; let due = if let Some(due) = c.due { // use existing due number if provided due } else { self.due_for_deck(did, dcid, cache)? }; let mut card = Card::new(nid, c.ord as u16, did, due as i32); self.add_card(&mut card)?; } Ok(()) } // not sure if entry() can be used due to get_deck_config() returning a result #[allow(clippy::map_entry)] fn due_for_deck( &mut self, did: DeckId, dcid: DeckConfigId, cache: &mut CardGenCache, ) -> Result { if !cache.deck_configs.contains_key(&did) { let conf = self.get_deck_config(dcid, true)?.unwrap(); cache.deck_configs.insert(did, conf); } // set if not yet set if cache.next_position.is_none() { cache.next_position = Some(self.get_and_update_next_card_position().unwrap_or(0)); } let next_pos = cache.next_position.unwrap(); match cache .deck_configs .get(&did) .unwrap() .inner .new_card_insert_order() { crate::deckconfig::NewCardInsertOrder::Random => Ok(random_position(next_pos)), crate::deckconfig::NewCardInsertOrder::Due => Ok(next_pos), } } /// If deck ID does not exist or points to a filtered deck, fall back on /// default. fn deck_for_adding(&mut self, did: Option) -> Result<(DeckId, DeckConfigId)> { if let Some(did) = did { if let Some(deck) = self.deck_conf_if_normal(did)? { return Ok(deck); } } self.default_deck_conf() } fn default_deck_conf(&mut self) -> Result<(DeckId, DeckConfigId)> { // currently hard-coded to 1, we could create this as needed in the future self.deck_conf_if_normal(DeckId(1))? .or_invalid("invalid default deck") } /// If deck exists and and is a normal deck, return its ID and config fn deck_conf_if_normal(&mut self, did: DeckId) -> Result> { Ok(self .get_deck(did)? .and_then(|d| d.config_id().map(|conf_id| (did, conf_id)))) } } fn random_position(highest_position: u32) -> u32 { let mut rng = StdRng::seed_from_u64(highest_position as u64); rng.random_range(1..highest_position.max(1000)) } #[cfg(test)] mod test { use super::*; use crate::collection::CollectionBuilder; #[test] fn random() { // predictable output and a minimum range of 1000 assert_eq!(random_position(5), 180); assert_eq!(random_position(500), 13); assert_eq!(random_position(5001), 3731); } /// Tests if a basic template generates one card if the Front field has /// content inside #[test] fn new_cards_required_normal_basic() { // create a new temporary collection let mut col = CollectionBuilder::default().build().unwrap(); let note_type = col.get_notetype_by_name("Basic").unwrap().unwrap(); // create a new note of the basic type let mut note = note_type.new_note(); // create a new context for the card generation let context = CardGenContext::new(note_type, None, Usn(-1)); // set the front field of the note to "Hello World" note.set_field(0, "Hello World").unwrap(); let cards = context.new_cards_required(¬e, &[], true); assert_eq!(cards.len(), 1); assert_eq!(cards[0].ord, 0); } /// Tests if a cloze note with a single deletion generates one card #[test] fn new_cards_required_cloze_basic() { let mut col = CollectionBuilder::default().build().unwrap(); let note_type = col.get_notetype_by_name("Cloze").unwrap().unwrap(); let mut note = note_type.new_note(); let context = CardGenContext::new(note_type, None, Usn(-1)); note.set_field(0, "Hello {{c1::World}}").unwrap(); let cards = context.new_cards_required(¬e, &[], true); assert_eq!(cards.len(), 1); assert_eq!(cards[0].ord, 0); } /// Tests if multiple cloze deletions generate multiple cards #[test] fn new_cards_required_cloze_multi() { let mut col = CollectionBuilder::default().build().unwrap(); let note_type = col.get_notetype_by_name("Cloze").unwrap().unwrap(); let mut note = note_type.new_note(); let context = CardGenContext::new(note_type, None, Usn(-1)); note.set_field(0, "{{c1::Rome}} is in {{c2::Italy}}") .unwrap(); let cards = context.new_cards_required(¬e, &[], true); assert_eq!(cards.len(), 2); // using a HashSet to check ordinals without assuming order since cloze // cards can return in any order let ords: HashSet = cards.iter().map(|c| c.ord).collect(); assert!(ords.contains(&0)); assert!(ords.contains(&1)); } /// Tests if the {{#Tags}} conditional generates a card if note has tags #[test] fn new_cards_required_normal_tags_conditional() { let mut col = CollectionBuilder::default().build().unwrap(); // cloning the inner Notetype so we can modify the template let arc_note_type = col.get_notetype_by_name("Basic").unwrap().unwrap(); let mut note_type = (*arc_note_type).clone(); note_type.templates[0].config.q_format = "{{#Tags}}{{Front}}{{/Tags}}".to_string(); let mut note = note_type.new_note(); let context = CardGenContext::new(¬e_type, None, Usn(-1)); note.set_field(0, "Hello").unwrap(); note.tags = vec!["vocabolary".to_string(), "english".to_string()]; let cards = context.new_cards_required(¬e, &[], true); assert_eq!(cards.len(), 1); assert_eq!(cards[0].ord, 0); } /// Tests if the {{#Tags}} conditional does not render when the note has no /// tags #[test] fn new_cards_required_normal_tags_empty() { let mut col = CollectionBuilder::default().build().unwrap(); // cloning the inner Notetype so we can modify the template let arc_note_type = col.get_notetype_by_name("Basic").unwrap().unwrap(); let mut note_type = (*arc_note_type).clone(); note_type.templates[0].config.q_format = "{{#Tags}}{{Front}}{{/Tags}}".to_string(); let mut note = note_type.new_note(); let context = CardGenContext::new(¬e_type, None, Usn(-1)); note.set_field(0, "Hello").unwrap(); note.tags = vec![]; let cards = context.new_cards_required(¬e, &[], true); assert_eq!(cards.len(), 1); assert_eq!(cards[0].ord, 0); } /// Tests if card generation skips ordinals that already exist(duplication) #[test] fn new_cards_required_skip_existing_cards() { let mut col = CollectionBuilder::default().build().unwrap(); let note_type = col .get_notetype_by_name("Basic (and reversed card)") .unwrap() .unwrap(); let mut note = note_type.new_note(); let context = CardGenContext::new(note_type, None, Usn(-1)); note.set_field(0, "Cat").unwrap(); note.set_field(1, "Neko").unwrap(); // simulating that card 0 already exists in the database let existing = vec![AlreadyGeneratedCardInfo { id: CardId(1), nid: NoteId(100), ord: 0, original_deck_id: DeckId(1), position_if_new: None, }]; let cards = context.new_cards_required(¬e, &existing, true); assert_eq!(cards.len(), 1); assert_eq!(cards[0].ord, 1); } } ================================================ FILE: rslib/src/notetype/checks.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::fmt::Write; use std::ops::Deref; use std::sync::LazyLock; use anki_i18n::without_unicode_isolation; use regex::Captures; use regex::Match; use regex::Regex; use super::CardTemplate; use crate::latex::LATEX; use crate::prelude::*; use crate::text::HTML_MEDIA_TAGS; use crate::text::SOUND_TAG; #[derive(Debug, PartialEq, Eq)] struct Template<'a> { notetype: &'a str, card_type: &'a str, front: bool, } static FIELD_REPLACEMENT: LazyLock = LazyLock::new(|| Regex::new(r"\{\{.+\}\}").unwrap()); impl Collection { pub fn report_media_field_referencing_templates(&mut self, buf: &mut String) -> Result<()> { let notetypes = self.get_all_notetypes()?; let templates = media_field_referencing_templates(notetypes.iter().map(Deref::deref)); write_template_report(buf, &templates, &self.tr); Ok(()) } } fn media_field_referencing_templates<'a>( notetypes: impl Iterator, ) -> Vec> { notetypes .flat_map(|notetype| { notetype.templates.iter().flat_map(|card_type| { card_type .sides() .into_iter() .filter(|&(format, _front)| references_media_field(format)) .map(|(_format, front)| Template::new(¬etype.name, &card_type.name, front)) }) }) .collect() } fn references_media_field(format: &str) -> bool { for regex in [&*HTML_MEDIA_TAGS, &*SOUND_TAG, &*LATEX] { if regex .captures_iter(format) .any(captures_contain_field_replacement) { return true; } } false } fn captures_contain_field_replacement(caps: Captures) -> bool { caps.iter() .skip(1) .any(|opt| opt.is_some_and(match_contains_field_replacement)) } fn match_contains_field_replacement(m: Match) -> bool { FIELD_REPLACEMENT.is_match(m.as_str()) } fn write_template_report(buf: &mut String, templates: &[Template], tr: &I18n) { if templates.is_empty() { return; } writeln!( buf, "\n{}", &tr.media_check_template_references_field_header() ) .unwrap(); for template in templates { writeln!(buf, "{}", template.as_str(tr)).unwrap(); } } impl<'a> Template<'a> { fn new(notetype: &'a str, card_type: &'a str, front: bool) -> Self { Template { notetype, card_type, front, } } fn as_str(&self, tr: &I18n) -> String { without_unicode_isolation(&tr.media_check_notetype_template( self.notetype, self.card_type, self.side_name(tr), )) } fn side_name<'tr>(&self, tr: &'tr I18n) -> Cow<'tr, str> { if self.front { tr.card_templates_front_template() } else { tr.card_templates_back_template() } } } impl CardTemplate { fn sides(&self) -> [(&str, bool); 2] { [ (&self.config.q_format, true), (&self.config.a_format, false), ] } } #[cfg(test)] mod test { use std::iter::once; use super::*; #[test] fn should_report_media_field_referencing_template() { let notetype = "foo"; let card_type = "bar"; let mut nt = Notetype { name: notetype.into(), ..Default::default() }; nt.add_field("baz"); nt.add_template(card_type, "", ""); let templates = media_field_referencing_templates(once(&nt)); let expected = Template { notetype, card_type, front: false, }; assert_eq!(templates, &[expected]); } } ================================================ FILE: rslib/src/notetype/cloze_styling.css ================================================ .cloze { font-weight: bold; color: blue; } .nightMode .cloze { color: lightblue; } ================================================ FILE: rslib/src/notetype/emptycards.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashSet; use std::fmt::Write; use super::cardgen::group_generated_cards_by_note; use super::CardGenContext; use super::Notetype; use super::NotetypeId; use super::NotetypeKind; use crate::card::CardId; use crate::collection::Collection; use crate::error::Result; use crate::notes::NoteId; pub struct EmptyCardsForNote { pub nid: NoteId, // (ordinal, card id) pub empty: Vec<(u32, CardId)>, pub current_count: usize, } impl Collection { fn empty_cards_for_notetype(&self, nt: &Notetype) -> Result> { let last_deck = self.get_last_deck_added_to_for_notetype(nt.id); let ctx = CardGenContext::new(nt, last_deck, self.usn()?); let existing_cards = self.storage.existing_cards_for_notetype(nt.id)?; let by_note = group_generated_cards_by_note(existing_cards); let mut out = Vec::with_capacity(by_note.len()); for (nid, existing) in by_note { let note = self.storage.get_note(nid)?.unwrap(); let cards = ctx.new_cards_required(¬e, &[], false); let nonempty_ords: HashSet<_> = cards.into_iter().map(|c| c.ord).collect(); let current_count = existing.len(); let empty: Vec<_> = existing .into_iter() .filter_map(|e| { if !nonempty_ords.contains(&e.ord) { Some((e.ord, e.id)) } else { None } }) .collect(); if !empty.is_empty() { out.push(EmptyCardsForNote { nid, empty, current_count, }) } } Ok(out) } pub fn empty_cards(&mut self) -> Result)>> { self.storage .get_all_notetype_names()? .into_iter() .map(|(id, _name)| { let nt = self.get_notetype(id)?.unwrap(); self.empty_cards_for_notetype(&nt).map(|v| (id, v)) }) .collect() } /// Create a report on empty cards. Mutates the provided data to sort /// ordinals. pub fn empty_cards_report( &mut self, empty: &mut [(NotetypeId, Vec)], ) -> Result { let nts = self.get_all_notetypes()?; let mut buf = String::new(); for (ntid, notes) in empty { if !notes.is_empty() { let nt = nts.iter().find(|nt| nt.id == *ntid).unwrap(); write!( buf, "
{}
    ", self.tr.empty_cards_for_note_type(nt.name.clone()) ) .unwrap(); for note in notes { note.empty.sort_unstable(); let templates = match nt.config.kind() { // "Front, Back" NotetypeKind::Normal => note .empty .iter() .map(|(ord, _)| { nt.templates .get(*ord as usize) .map(|t| t.name.clone()) .unwrap_or_else(|| format!("Card {}", *ord + 1)) }) .collect::>() .join(", "), // "Cloze 1, 3" NotetypeKind::Cloze => format!( "{} {}", self.tr.notetypes_cloze_name(), note.empty .iter() .map(|(ord, _)| (ord + 1).to_string()) .collect::>() .join(", ") ), }; let class = if note.current_count == note.empty.len() { "allempty" } else { "" }; write!( buf, "
  1. [anki:nid:{}] {}
  2. ", class, note.nid, self.tr.empty_cards_count_line( note.empty.len(), note.current_count, templates ) ) .unwrap(); } buf.push_str("
"); } } Ok(buf) } } ================================================ FILE: rslib/src/notetype/fields.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::NoteFieldConfig; use super::NoteFieldProto; use crate::prelude::*; #[derive(Debug, PartialEq, Clone)] pub struct NoteField { pub ord: Option, pub name: String, pub config: NoteFieldConfig, } impl From for NoteFieldProto { fn from(f: NoteField) -> Self { NoteFieldProto { ord: f.ord.map(Into::into), name: f.name, config: Some(f.config), } } } impl From for NoteField { fn from(f: NoteFieldProto) -> Self { NoteField { ord: f.ord.map(|n| n.val), name: f.name, config: f.config.unwrap_or_default(), } } } impl NoteField { pub fn new(name: impl Into) -> Self { NoteField { ord: None, name: name.into(), config: NoteFieldConfig { id: Some(rand::random()), sticky: false, rtl: false, plain_text: false, font_name: "Arial".into(), font_size: 20, description: "".into(), collapsed: false, exclude_from_search: false, tag: None, prevent_deletion: false, other: vec![], }, } } /// Fix the name of the field if it's valid. Otherwise explain why it's not. pub(crate) fn fix_name(&mut self) -> Result<()> { require!(!self.name.is_empty(), "Empty field name"); let bad_chars = |c| c == ':' || c == '{' || c == '}' || c == '"'; if self.name.contains(bad_chars) { self.name = self.name.replace(bad_chars, ""); } // and leading/trailing whitespace and special chars let bad_start_chars = |c: char| c == '#' || c == '/' || c == '^' || c.is_whitespace(); let trimmed = self.name.trim().trim_start_matches(bad_start_chars); require!(!trimmed.is_empty(), "Field name: {}", self.name); if trimmed.len() != self.name.len() { self.name = trimmed.into(); } Ok(()) } } #[cfg(test)] mod test { use super::*; #[test] fn name() { let mut field = NoteField::new(" # /^ t:e{s\"t} field name #/^ "); assert_eq!(field.fix_name(), Ok(())); assert_eq!(&field.name, "test field name #/^"); } } ================================================ FILE: rslib/src/notetype/header.tex ================================================ \documentclass[12pt]{article} \special{papersize=3in,5in} \usepackage[utf8]{inputenc} \usepackage{amssymb,amsmath} \pagestyle{empty} \setlength{\parindent}{0in} \begin{document} ================================================ FILE: rslib/src/notetype/merge.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::CardTemplate; use crate::notetype::NoteField; use crate::prelude::*; impl Notetype { /// Inserts not yet existing fields ands templates from `other`. pub(crate) fn merge(&mut self, other: &Self) { self.merge_fields(other); if !self.is_cloze() { self.merge_templates(other); } } pub(crate) fn merge_all<'a>(&mut self, others: impl IntoIterator) { for other in others { self.merge(other); } } /// Inserts not yet existing fields from `other`. fn merge_fields(&mut self, other: &Self) { for (index, field) in other.fields.iter().enumerate() { match self.find_field(field) { Some(i) if i == index => (), Some(i) => self.fields.swap(i, index), None => { let mut missing = field.clone(); missing.ord.take(); self.fields.insert(index, missing); } } } } fn find_field(&self, like: &NoteField) -> Option { self.fields .iter() .enumerate() .find_map(|(i, f)| f.is_match(like).then_some(i)) } /// Inserts not yet existing templates from `other`. fn merge_templates(&mut self, other: &Self) { for (index, template) in other.templates.iter().enumerate() { match self.find_template(template) { Some(i) if i == index => (), Some(i) => self.templates.swap(i, index), None => { let mut missing = template.clone(); missing.ord.take(); self.templates.insert(index, missing); } } } } fn find_template(&self, like: &CardTemplate) -> Option { self.templates .iter() .enumerate() .find_map(|(i, t)| t.is_match(like).then_some(i)) } } impl NoteField { /// True if both ids are identical, but not [None], or at least one id is /// [None] and the names are identical. pub(crate) fn is_match(&self, other: &Self) -> bool { if let (Some(id), Some(other_id)) = (self.config.id, other.config.id) { id == other_id } else { self.name == other.name } } } impl CardTemplate { /// True if both ids are identical, but not [None], or at least one id is /// [None] and the names are identical. pub(crate) fn is_match(&self, other: &Self) -> bool { if let (Some(id), Some(other_id)) = (self.config.id, other.config.id) { id == other_id } else { self.name == other.name } } } #[cfg(test)] mod test { use itertools::assert_equal; use super::*; use crate::notetype::stock; impl Notetype { fn field_ids(&self) -> impl Iterator> + '_ { self.fields.iter().map(|field| field.config.id) } fn template_ids(&self) -> impl Iterator> + '_ { self.templates.iter().map(|template| template.config.id) } } #[test] fn merge_new_fields() { let mut basic = stock::basic(&I18n::template_only()); let mut other = basic.clone(); other.add_field("with id"); other.add_field("without id"); other.fields[3].config.id.take(); basic.merge(&other); assert_equal(basic.field_ids(), other.field_ids()); assert_equal(basic.field_names(), other.field_names()); } #[test] fn skip_merging_field_with_existing_id() { let mut basic = stock::basic(&I18n::template_only()); let mut other = basic.clone(); other.fields[1].name = String::from("renamed"); basic.merge(&other); assert_equal(basic.field_ids(), other.field_ids()); assert_equal(basic.field_names(), ["Front", "Back"].iter()); } #[test] fn align_field_order() { let mut basic = stock::basic(&I18n::template_only()); let mut other = basic.clone(); other.fields.swap(0, 1); basic.merge(&other); assert_equal(basic.field_ids(), other.field_ids()); assert_equal(basic.field_names(), other.field_names()); } #[test] fn merge_new_templates() { let mut basic = stock::basic(&I18n::template_only()); let mut other = basic.clone(); other.add_template("with id", "", ""); other.add_template("without id", "", ""); other.templates[2].config.id.take(); basic.merge(&other); assert_equal(basic.template_ids(), other.template_ids()); assert_equal(basic.template_names(), other.template_names()); } #[test] fn skip_merging_template_with_existing_id() { let mut basic = stock::basic(&I18n::template_only()); let mut other = basic.clone(); other.templates[0].name = String::from("renamed"); basic.merge(&other); assert_equal(basic.template_ids(), other.template_ids()); assert_equal(basic.template_names(), std::iter::once("Card 1")); } #[test] fn align_template_order() { let mut basic_rev = stock::basic_forward_reverse(&I18n::template_only()); let mut other = basic_rev.clone(); other.templates.swap(0, 1); basic_rev.merge(&other); assert_equal(basic_rev.template_ids(), other.template_ids()); assert_equal(basic_rev.template_names(), other.template_names()); } } ================================================ FILE: rslib/src/notetype/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod cardgen; mod checks; mod emptycards; mod fields; mod merge; mod notetypechange; mod render; mod restore; pub(crate) mod schema11; mod schemachange; mod service; pub(crate) mod stock; mod templates; pub(crate) mod undo; use std::collections::HashMap; use std::collections::HashSet; use std::iter::FromIterator; use std::sync::Arc; use std::sync::LazyLock; pub use anki_proto::notetypes::notetype::config::card_requirement::Kind as CardRequirementKind; pub use anki_proto::notetypes::notetype::config::CardRequirement; pub use anki_proto::notetypes::notetype::config::Kind as NotetypeKind; pub use anki_proto::notetypes::notetype::field::Config as NoteFieldConfig; pub use anki_proto::notetypes::notetype::template::Config as CardTemplateConfig; pub use anki_proto::notetypes::notetype::Config as NotetypeConfig; pub use anki_proto::notetypes::notetype::Field as NoteFieldProto; pub use anki_proto::notetypes::notetype::Template as CardTemplateProto; pub use anki_proto::notetypes::Notetype as NotetypeProto; pub(crate) use cardgen::AlreadyGeneratedCardInfo; pub(crate) use cardgen::CardGenContext; pub use fields::NoteField; pub use notetypechange::ChangeNotetypeInput; pub use notetypechange::NotetypeChangeInfo; use regex::Regex; pub(crate) use render::RenderCardOutput; pub use schema11::CardTemplateSchema11; pub use schema11::NoteFieldSchema11; pub use schema11::NotetypeSchema11; pub use stock::all_stock_notetypes; pub use templates::CardTemplate; use unicase::UniCase; use crate::define_newtype; use crate::error::CardTypeError; use crate::error::CardTypeErrorDetails; use crate::error::CardTypeSnafu; use crate::error::MissingClozeSnafu; use crate::prelude::*; use crate::search::JoinSearches; use crate::search::Node; use crate::search::SearchNode; use crate::storage::comma_separated_ids; use crate::template::FieldRequirements; use crate::template::ParsedTemplate; use crate::text::ensure_string_in_nfc; use crate::text::extract_underscored_css_imports; use crate::text::extract_underscored_references; define_newtype!(NotetypeId, i64); pub(crate) const DEFAULT_CSS: &str = include_str!("styling.css"); pub(crate) const DEFAULT_CLOZE_CSS: &str = include_str!("cloze_styling.css"); pub(crate) const DEFAULT_LATEX_HEADER: &str = include_str!("header.tex"); pub(crate) const DEFAULT_LATEX_FOOTER: &str = r"\end{document}"; /// New entries must be handled in render.rs/add_special_fields(). static SPECIAL_FIELDS: LazyLock> = LazyLock::new(|| { HashSet::from_iter(vec![ "FrontSide", "Card", "CardFlag", "Deck", "Subdeck", "Tags", "Type", "CardID", ]) }); #[derive(Debug, PartialEq, Clone)] pub struct Notetype { pub id: NotetypeId, pub name: String, pub mtime_secs: TimestampSecs, pub usn: Usn, pub fields: Vec, pub templates: Vec, pub config: NotetypeConfig, } impl Default for Notetype { fn default() -> Self { Notetype { id: NotetypeId(0), name: "".into(), mtime_secs: TimestampSecs(0), usn: Usn(0), fields: vec![], templates: vec![], config: Notetype::new_config(), } } } impl Notetype { pub(crate) fn new_config() -> NotetypeConfig { NotetypeConfig { css: DEFAULT_CSS.into(), latex_pre: DEFAULT_LATEX_HEADER.into(), latex_post: DEFAULT_LATEX_FOOTER.into(), ..Default::default() } } pub(crate) fn new_cloze_config() -> NotetypeConfig { let mut config = Self::new_config(); config.css += DEFAULT_CLOZE_CSS; config.kind = NotetypeKind::Cloze as i32; config } } impl Notetype { pub fn new_note(&self) -> Note { Note::new(self) } /// Return the template for the given card ordinal. Cloze notetypes /// always return the first and only template. pub fn get_template(&self, card_ord: u16) -> Result<&CardTemplate> { let template = if self.config.kind() == NotetypeKind::Cloze { self.templates.first() } else { self.templates.get(card_ord as usize) }; template.or_not_found(card_ord) } } impl Collection { /// Add a new notetype, and allocate it an ID. pub fn add_notetype( &mut self, notetype: &mut Notetype, skip_checks: bool, ) -> Result> { self.transact(Op::AddNotetype, |col| { let usn = col.usn()?; notetype.set_modified(usn); col.add_notetype_inner(notetype, usn, skip_checks) }) } /// Saves changes to a note type. This will force a full sync if templates /// or fields have been added/removed/reordered. /// /// This does not assign ordinals to the provided notetype, so if you wish /// to make use of template_idx, the notetype must be fetched again. pub fn update_notetype( &mut self, notetype: &mut Notetype, skip_checks: bool, ) -> Result> { self.transact(Op::UpdateNotetype, |col| { let original = col .storage .get_notetype(notetype.id)? .or_not_found(notetype.id)?; let usn = col.usn()?; notetype.set_modified(usn); col.add_or_update_notetype_with_existing_id_inner( notetype, Some(original), usn, skip_checks, ) }) } /// Used to support the current importing code; does not mark notetype as /// modified, and does not support undo. pub fn add_or_update_notetype_with_existing_id( &mut self, notetype: &mut Notetype, skip_checks: bool, ) -> Result<()> { self.transact_no_undo(|col| { let usn = col.usn()?; let existing = col.storage.get_notetype(notetype.id)?; col.add_or_update_notetype_with_existing_id_inner(notetype, existing, usn, skip_checks) }) } pub fn get_notetype_by_name(&mut self, name: &str) -> Result>> { if let Some(ntid) = self.storage.get_notetype_id(name)? { self.get_notetype(ntid) } else { Ok(None) } } pub fn get_notetype(&mut self, ntid: NotetypeId) -> Result>> { if let Some(nt) = self.state.notetype_cache.get(&ntid) { return Ok(Some(nt.clone())); } if let Some(nt) = self.storage.get_notetype(ntid)? { let nt = Arc::new(nt); self.state.notetype_cache.insert(ntid, nt.clone()); Ok(Some(nt)) } else { Ok(None) } } pub fn get_all_notetypes(&mut self) -> Result>> { self.storage .get_all_notetype_ids()? .into_iter() .filter_map(|ntid| self.get_notetype(ntid).transpose()) .collect() } pub fn get_all_notetypes_of_search_notes( &mut self, ) -> Result>> { self.storage .all_notetypes_of_search_notes()? .into_iter() .map(|ntid| { self.get_notetype(ntid) .transpose() .unwrap() .map(|nt| (ntid, nt)) }) .collect() } pub fn remove_notetype(&mut self, ntid: NotetypeId) -> Result> { self.transact(Op::RemoveNotetype, |col| col.remove_notetype_inner(ntid)) } /// Return the notetype used by `note_ids`, or an error if not exactly 1 /// notetype is in use. pub fn get_single_notetype_of_notes(&mut self, note_ids: &[NoteId]) -> Result { require!(!note_ids.is_empty(), "no note id provided"); let nids_node: Node = SearchNode::NoteIds(comma_separated_ids(note_ids)).into(); let note1 = self .storage .get_note(*note_ids.first().unwrap())? .or_not_found(note_ids[0])?; if self .search_notes_unordered(note1.notetype_id.and(nids_node))? .len() != note_ids.len() { Err(AnkiError::MultipleNotetypesSelected) } else { Ok(note1.notetype_id) } } } impl Notetype { pub(crate) fn ensure_names_unique(&mut self) { let mut names = HashSet::new(); for t in &mut self.templates { loop { let name = UniCase::new(t.name.clone()); if !names.contains(&name) { names.insert(name); break; } t.name.push('+'); } } names.clear(); for t in &mut self.fields { loop { let name = UniCase::new(t.name.clone()); if !names.contains(&name) { names.insert(name); break; } t.name.push('+'); } } } pub(crate) fn set_modified(&mut self, usn: Usn) { self.mtime_secs = TimestampSecs::now(); self.usn = usn; } fn updated_requirements( &self, parsed: &[(Option, Option)], ) -> Vec { let field_map: HashMap<&str, u16> = self .fields .iter() .enumerate() .map(|(idx, field)| (field.name.as_str(), idx as u16)) .collect(); parsed .iter() .enumerate() .map(|(ord, (qtmpl, _atmpl))| { if let Some(tmpl) = qtmpl { let mut req = match tmpl.requirements(&field_map) { FieldRequirements::Any(ords) => CardRequirement { card_ord: ord as u32, kind: CardRequirementKind::Any as i32, field_ords: ords.into_iter().map(|n| n as u32).collect(), }, FieldRequirements::All(ords) => CardRequirement { card_ord: ord as u32, kind: CardRequirementKind::All as i32, field_ords: ords.into_iter().map(|n| n as u32).collect(), }, FieldRequirements::None => CardRequirement { card_ord: ord as u32, kind: CardRequirementKind::None as i32, field_ords: vec![], }, }; req.field_ords.sort_unstable(); req } else { // template parsing failures make card unsatisfiable CardRequirement { card_ord: ord as u32, kind: CardRequirementKind::None as i32, field_ords: vec![], } } }) .collect() } /// Adjust sort index to match repositioned fields. fn reposition_sort_idx(&mut self) { self.config.sort_field_idx = self .fields .iter() .enumerate() .find_map(|(idx, f)| { if f.ord == Some(self.config.sort_field_idx) { Some(idx as u32) } else { None } }) .unwrap_or_else(|| { // provided ordinal not on any existing field; cap to bounds self.config .sort_field_idx .clamp(0, (self.fields.len() - 1) as u32) }); } fn ensure_template_fronts_unique(&self) -> Result<(), CardTypeError> { static CARD_TAG: LazyLock = LazyLock::new(|| Regex::new(r"\{\{\s*Card\s*\}\}").unwrap()); let mut map = HashMap::new(); for (index, card) in self.templates.iter().enumerate() { if let Some(old_index) = map.insert(&card.config.q_format, index) { if !CARD_TAG.is_match(&card.config.q_format) { return Err(CardTypeError { notetype: self.name.clone(), ordinal: index, source: CardTypeErrorDetails::Duplicate { index: old_index }, }); } } } Ok(()) } /// Ensure no templates are None, every front template contains at least one /// field, and all used field names belong to a field of this notetype. fn ensure_valid_parsed_templates( &self, templates: &[(Option, Option)], ) -> Result<(), CardTypeError> { for (ordinal, sides) in templates.iter().enumerate() { self.ensure_valid_parsed_card_templates(sides) .context(CardTypeSnafu { notetype: &self.name, ordinal, })?; } Ok(()) } fn ensure_valid_parsed_card_templates( &self, sides: &(Option, Option), ) -> Result<(), CardTypeErrorDetails> { if let (Some(q), Some(a)) = sides { let q_fields = q.all_referenced_field_names(); if q_fields.is_empty() { return Err(CardTypeErrorDetails::NoFrontField); } if let Some(unknown_field) = self.first_unknown_field_name(q_fields.union(&a.all_referenced_field_names())) { return Err(CardTypeErrorDetails::NoSuchField { field: unknown_field.to_string(), }); } Ok(()) } else { Err(CardTypeErrorDetails::TemplateParseError) } } /// Return the first non-empty name in names that does not denote a special /// field or a field of this notetype. fn first_unknown_field_name(&self, names: T) -> Option where T: IntoIterator, I: AsRef, { names.into_iter().find(|name| { // The empty field name is allowed as it may be used by add-ons. !name.as_ref().is_empty() && !SPECIAL_FIELDS.contains(&name.as_ref()) && self.fields.iter().all(|field| field.name != name.as_ref()) }) } fn ensure_cloze_if_cloze_notetype( &self, parsed_templates: &[(Option, Option)], ) -> Result<(), CardTypeError> { if self.is_cloze() && missing_cloze_filter(parsed_templates) { MissingClozeSnafu.fail().context(CardTypeSnafu { notetype: &self.name, ordinal: 0usize, }) } else { Ok(()) } } pub(crate) fn normalize_names(&mut self) { ensure_string_in_nfc(&mut self.name); for f in &mut self.fields { ensure_string_in_nfc(&mut f.name); } for t in &mut self.templates { ensure_string_in_nfc(&mut t.name); } } pub(crate) fn add_field>(&mut self, name: S) -> &mut NoteFieldConfig { self.fields.push(NoteField::new(name)); self.fields.last_mut().map(|f| &mut f.config).unwrap() } pub(crate) fn add_template(&mut self, name: S1, qfmt: S2, afmt: S3) where S1: Into, S2: Into, S3: Into, { self.templates.push(CardTemplate::new(name, qfmt, afmt)); } pub(crate) fn prepare_for_update( &mut self, existing: Option<&Notetype>, skip_checks: bool, ) -> Result<()> { require!(!self.fields.is_empty(), "1 field required"); require!(!self.templates.is_empty(), "1 template required"); let bad_chars = |c| c == '"'; if self.name.contains(bad_chars) { self.name = self.name.replace(bad_chars, ""); } require!(!self.name.is_empty(), "Empty notetype name"); self.normalize_names(); self.fix_field_names()?; self.fix_template_names()?; self.ensure_names_unique(); self.reposition_sort_idx(); let mut parsed_templates = self.parsed_templates(); let mut parsed_browser_templates = self.parsed_browser_templates(); let reqs = self.updated_requirements(&parsed_templates); // handle renamed+deleted fields if let Some(existing) = existing { let fields = self.renamed_and_removed_fields(existing); if !fields.is_empty() { self.update_templates_for_renamed_and_removed_fields( fields, &mut parsed_templates, &mut parsed_browser_templates, ); } } self.config.reqs = reqs; if !skip_checks { self.check_templates(parsed_templates)?; } Ok(()) } fn check_templates( &self, parsed_templates: Vec<(Option, Option)>, ) -> Result<()> { self.ensure_template_fronts_unique() .and(self.ensure_valid_parsed_templates(&parsed_templates)) .and(self.ensure_cloze_if_cloze_notetype(&parsed_templates))?; Ok(()) } fn renamed_and_removed_fields(&self, current: &Notetype) -> HashMap> { let mut remaining_ords = HashSet::new(); // gather renames let mut map: HashMap> = self .fields .iter() .filter_map(|field| { if let Some(existing_ord) = field.ord { remaining_ords.insert(existing_ord); if let Some(existing_field) = current.fields.get(existing_ord as usize) { if existing_field.name != field.name { return Some((existing_field.name.clone(), Some(field.name.clone()))); } } } None }) .collect(); // and add any fields that have been removed for (idx, field) in current.fields.iter().enumerate() { if !remaining_ords.contains(&(idx as u32)) { map.insert(field.name.clone(), None); } } map } /// Update templates to reflect field deletions and renames. /// Any templates that failed to parse will be ignored. fn update_templates_for_renamed_and_removed_fields( &mut self, fields: HashMap>, parsed: &mut [(Option, Option)], parsed_browser: &mut [(Option, Option)], ) { let first_remaining_field_name = &self.fields.first().unwrap().name; let is_cloze = self.is_cloze(); let q_update_fields = |q_opt: &mut Option, template_target: &mut String| { if let Some(q) = q_opt { q.rename_and_remove_fields(&fields); if !q.contains_field_replacement() || is_cloze && !q.contains_cloze_replacement() { q.add_missing_field_replacement(first_remaining_field_name, is_cloze); } *template_target = q.template_to_string(); } }; let a_update_fields = |a_opt: &mut Option, template_target: &mut String| { if let Some(a) = a_opt { a.rename_and_remove_fields(&fields); if is_cloze && !a.contains_cloze_replacement() { a.add_missing_field_replacement(first_remaining_field_name, is_cloze); } *template_target = a.template_to_string(); } }; // Update main templates for (idx, (q_opt, a_opt)) in parsed.iter_mut().enumerate() { q_update_fields(q_opt, &mut self.templates[idx].config.q_format); a_update_fields(a_opt, &mut self.templates[idx].config.a_format); } // Update browser templates, if they exist for (idx, (q_browser_opt, a_browser_opt)) in parsed_browser.iter_mut().enumerate() { q_update_fields( q_browser_opt, &mut self.templates[idx].config.q_format_browser, ); a_update_fields( a_browser_opt, &mut self.templates[idx].config.a_format_browser, ); } } fn parsed_templates(&self) -> Vec<(Option, Option)> { self.templates .iter() .map(|t| (t.parsed_question(), t.parsed_answer())) .collect() } fn parsed_browser_templates(&self) -> Vec<(Option, Option)> { self.templates .iter() .map(|t| { ( t.parsed_question_format_for_browser(), t.parsed_answer_format_for_browser(), ) }) .collect() } fn fix_field_names(&mut self) -> Result<()> { self.fields.iter_mut().try_for_each(NoteField::fix_name) } fn fix_template_names(&mut self) -> Result<()> { self.templates .iter_mut() .try_for_each(CardTemplate::fix_name) } /// Find the field index of the provided field name. pub(crate) fn get_field_ord(&self, field_name: &str) -> Option { let field_name = UniCase::new(field_name); self.fields .iter() .enumerate() .filter_map(|(idx, f)| { if UniCase::new(&f.name) == field_name { Some(idx) } else { None } }) .next() } pub(crate) fn is_cloze(&self) -> bool { matches!(self.config.kind(), NotetypeKind::Cloze) } /// Return all clozable fields. A field is clozable when it belongs to a /// cloze notetype and a 'cloze' filter is applied to it in the /// template. pub(crate) fn cloze_fields(&self) -> HashSet { if !self.is_cloze() { HashSet::new() } else if let Some((Some(front), _)) = self.parsed_templates().first() { front .all_referenced_cloze_field_names() .iter() .filter_map(|name| self.get_field_ord(name)) .collect() } else { HashSet::new() } } pub(crate) fn gather_media_names(&self, inserter: &mut impl FnMut(String)) { for name in extract_underscored_css_imports(&self.config.css) { inserter(name.to_string()); } for template in &self.templates { for template_side in [&template.config.q_format, &template.config.a_format] { for name in extract_underscored_references(template_side) { inserter(name.to_string()); } } } } } /// True if the slice is empty or either template of the first tuple doesn't /// have a cloze field. fn missing_cloze_filter( parsed_templates: &[(Option, Option)], ) -> bool { parsed_templates .first() .map_or(true, |t| !has_cloze(&t.0) || !has_cloze(&t.1)) } /// True if the template is non-empty and has a cloze field. fn has_cloze(template: &Option) -> bool { template .as_ref() .is_some_and(|t| !t.all_referenced_cloze_field_names().is_empty()) } impl From for NotetypeProto { fn from(nt: Notetype) -> Self { NotetypeProto { id: nt.id.0, name: nt.name, mtime_secs: nt.mtime_secs.0, usn: nt.usn.0, config: Some(nt.config), fields: nt.fields.into_iter().map(Into::into).collect(), templates: nt.templates.into_iter().map(Into::into).collect(), } } } impl Collection { pub(crate) fn ensure_notetype_name_unique( &self, notetype: &mut Notetype, usn: Usn, ) -> Result<()> { loop { match self.storage.get_notetype_id(¬etype.name)? { Some(id) if id == notetype.id => { break; } None => break, _ => (), } notetype.name += "+"; notetype.set_modified(usn); } Ok(()) } /// Caller must set notetype as modified if appropriate. pub(crate) fn add_notetype_inner( &mut self, notetype: &mut Notetype, usn: Usn, skip_checks: bool, ) -> Result<()> { notetype.prepare_for_update(None, skip_checks)?; self.ensure_notetype_name_unique(notetype, usn)?; self.add_notetype_undoable(notetype)?; self.set_current_notetype_id(notetype.id) } /// - Caller must set notetype as modified if appropriate. /// - This only supports undo when an existing notetype is passed in. pub(crate) fn add_or_update_notetype_with_existing_id_inner( &mut self, notetype: &mut Notetype, original: Option, usn: Usn, skip_checks: bool, ) -> Result<()> { let normalize = self.get_config_bool(BoolKey::NormalizeNoteText); notetype.prepare_for_update(original.as_ref(), skip_checks)?; self.ensure_notetype_name_unique(notetype, usn)?; if let Some(original) = original { self.update_notes_for_changed_fields( notetype, original.fields.len(), original.config.sort_field_idx, normalize, )?; self.update_cards_for_changed_templates(notetype, &original.templates)?; self.update_notetype_undoable(notetype, original)?; } else { // adding with existing id for old undo code, bypass undo self.state.notetype_cache.remove(¬etype.id); self.storage .add_or_update_notetype_with_existing_id(notetype)?; } Ok(()) } pub(crate) fn remove_notetype_inner(&mut self, ntid: NotetypeId) -> Result<()> { let notetype = if let Some(notetype) = self.storage.get_notetype(ntid)? { notetype } else { // already removed return Ok(()); }; // remove associated cards/notes let usn = self.usn()?; let note_ids = self.search_notes_unordered(ntid)?; self.remove_notes_inner(¬e_ids, usn)?; // remove notetype self.set_schema_modified()?; self.state.notetype_cache.remove(&ntid); self.clear_aux_config_for_notetype(ntid)?; self.remove_notetype_only_undoable(notetype)?; // update last-used notetype let all = self.storage.get_all_notetype_names()?; if all.is_empty() { let mut nt = all_stock_notetypes(&self.tr).remove(0); self.add_notetype_inner(&mut nt, self.usn()?, true)?; self.set_current_notetype_id(nt.id) } else { self.set_current_notetype_id(all[0].0) } } } // Tests //--------------------------------------- #[cfg(test)] mod test { use super::*; #[test] fn update_templates_after_removing_crucial_fields() { // Normal Test (all front fields removed) let mut nt_norm = Notetype::default(); nt_norm.add_field("baz"); // Fields "foo" and "bar" were removed nt_norm.fields[0].ord = Some(2); nt_norm.add_template("Card 1", "front {{foo}}", "back {{bar}}"); nt_norm.templates[0].ord = Some(0); let mut parsed = nt_norm.parsed_templates(); let mut parsed_browser = nt_norm.parsed_browser_templates(); let mut field_map: HashMap> = HashMap::new(); field_map.insert("foo".to_owned(), None); field_map.insert("bar".to_owned(), None); nt_norm.update_templates_for_renamed_and_removed_fields( field_map, &mut parsed, &mut parsed_browser, ); assert_eq!(nt_norm.templates[0].config.q_format, "front {{baz}}"); assert_eq!(nt_norm.templates[0].config.a_format, "back "); // Cloze Test 1/2 (front and back cloze fields removed) let mut nt_cloze = Notetype { config: Notetype::new_cloze_config(), ..Default::default() }; nt_cloze.add_field("baz"); // Fields "foo" and "bar" were removed nt_cloze.fields[0].ord = Some(2); nt_cloze.add_template("Card 1", "front {{cloze:foo}}", "back {{cloze:bar}}"); nt_cloze.templates[0].ord = Some(0); let mut parsed = nt_cloze.parsed_templates(); let mut field_map: HashMap> = HashMap::new(); field_map.insert("foo".to_owned(), None); field_map.insert("bar".to_owned(), None); nt_cloze.update_templates_for_renamed_and_removed_fields( field_map, &mut parsed, &mut parsed_browser, ); assert_eq!(nt_cloze.templates[0].config.q_format, "front {{cloze:baz}}"); assert_eq!(nt_cloze.templates[0].config.a_format, "back {{cloze:baz}}"); // Cloze Test 2/2 (only back cloze field is removed) let mut nt_cloze = Notetype { config: Notetype::new_cloze_config(), ..Default::default() }; nt_cloze.add_field("foo"); nt_cloze.fields[0].ord = Some(0); nt_cloze.add_field("baz"); nt_cloze.fields[1].ord = Some(2); // ^ only field "bar" was removed nt_cloze.add_template("Card 1", "front {{cloze:foo}}", "back {{cloze:bar}}"); nt_cloze.templates[0].ord = Some(0); let mut parsed = nt_cloze.parsed_templates(); let mut field_map: HashMap> = HashMap::new(); field_map.insert("bar".to_owned(), None); nt_cloze.update_templates_for_renamed_and_removed_fields( field_map, &mut parsed, &mut parsed_browser, ); assert_eq!(nt_cloze.templates[0].config.q_format, "front {{cloze:foo}}"); assert_eq!(nt_cloze.templates[0].config.a_format, "back {{cloze:foo}}"); } } ================================================ FILE: rslib/src/notetype/notetypechange.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html //! Updates to notes/cards when a note is moved to a different notetype. use std::collections::HashMap; use std::collections::HashSet; use super::CardGenContext; use super::Notetype; use super::NotetypeKind; use crate::prelude::*; use crate::search::JoinSearches; use crate::search::Node; use crate::search::SearchNode; use crate::search::TemplateKind; use crate::storage::comma_separated_ids; #[derive(Debug)] pub struct ChangeNotetypeInput { pub current_schema: TimestampMillis, pub note_ids: Vec, pub old_notetype_name: String, pub old_notetype_id: NotetypeId, pub new_notetype_id: NotetypeId, pub new_fields: Vec>, pub new_templates: Option>>, } #[derive(Debug)] pub struct NotetypeChangeInfo { pub input: ChangeNotetypeInput, pub old_notetype_name: String, pub old_field_names: Vec, pub old_template_names: Vec, pub new_field_names: Vec, pub new_template_names: Vec, } #[derive(Debug, PartialEq, Eq)] pub struct TemplateMap { pub removed: Vec, pub remapped: HashMap, } impl TemplateMap { fn new(new_templates: Vec>, old_template_count: usize) -> Self { let mut seen: HashSet = HashSet::new(); let remapped: HashMap<_, _> = new_templates .iter() .enumerate() .filter_map(|(new_idx, old_idx)| { if let Some(old_idx) = *old_idx { seen.insert(old_idx); if old_idx != new_idx { return Some((old_idx, new_idx)); } } None }) .collect(); let removed: Vec<_> = (0..old_template_count) .filter(|idx| !seen.contains(idx)) .collect(); TemplateMap { removed, remapped } } } impl Collection { pub fn notetype_change_info( &mut self, old_notetype_id: NotetypeId, new_notetype_id: NotetypeId, ) -> Result { let old_notetype = self .get_notetype(old_notetype_id)? .or_not_found(old_notetype_id)?; let new_notetype = self .get_notetype(new_notetype_id)? .or_not_found(new_notetype_id)?; let current_schema = self.storage.get_collection_timestamps()?.schema_change; let old_notetype_name = &old_notetype.name; let new_fields = default_field_map(&old_notetype, &new_notetype); let new_templates = default_template_map(&old_notetype, &new_notetype); Ok(NotetypeChangeInfo { input: ChangeNotetypeInput { current_schema, note_ids: vec![], old_notetype_name: old_notetype_name.clone(), old_notetype_id, new_notetype_id, new_fields, new_templates, }, old_notetype_name: old_notetype_name.clone(), old_field_names: old_notetype.fields.iter().map(|f| f.name.clone()).collect(), old_template_names: old_notetype .templates .iter() .map(|f| f.name.clone()) .collect(), new_field_names: new_notetype.fields.iter().map(|f| f.name.clone()).collect(), new_template_names: new_notetype .templates .iter() .map(|f| f.name.clone()) .collect(), }) } pub fn change_notetype_of_notes(&mut self, input: ChangeNotetypeInput) -> Result> { self.transact(Op::ChangeNotetype, |col| { col.change_notetype_of_notes_inner(input) }) } } fn default_template_map( current_notetype: &Notetype, new_notetype: &Notetype, ) -> Option>> { if current_notetype.config.kind() == NotetypeKind::Cloze || new_notetype.config.kind() == NotetypeKind::Cloze { // clozes can't be remapped None } else { // name -> (ordinal, is_used) let mut existing_templates: HashMap<&str, (usize, bool)> = current_notetype .templates .iter() .map(|template| { ( template.name.as_str(), (template.ord.unwrap() as usize, false), ) }) .collect(); // match by name let mut new_templates: Vec<_> = new_notetype .templates .iter() .map(|template| { existing_templates .get_mut(template.name.as_str()) .map(|(idx, used)| { *used = true; *idx }) }) .collect(); // fill in gaps with any unused templates let mut remaining_templates: Vec<_> = existing_templates .values() .filter_map(|(idx, used)| if !used { Some(idx) } else { None }) .collect(); remaining_templates.sort_unstable(); new_templates .iter_mut() .filter(|o| o.is_none()) .zip(remaining_templates) .for_each(|(template, old_idx)| *template = Some(*old_idx)); Some(new_templates) } } fn default_field_map(current_notetype: &Notetype, new_notetype: &Notetype) -> Vec> { // name -> (ordinal, is_used) let mut existing_fields: HashMap<&str, (usize, bool)> = current_notetype .fields .iter() .map(|field| (field.name.as_str(), (field.ord.unwrap() as usize, false))) .collect(); // match by name let mut new_fields: Vec<_> = new_notetype .fields .iter() .map(|field| { existing_fields .get_mut(field.name.as_str()) .map(|(idx, used)| { *used = true; *idx }) }) .collect(); // fill in gaps with any unused fields let mut remaining_fields: Vec<_> = existing_fields .values() .filter_map(|(idx, used)| if !used { Some(idx) } else { None }) .collect(); remaining_fields.sort_unstable(); new_fields .iter_mut() .filter(|o| o.is_none()) .zip(remaining_fields) .for_each(|(field, old_idx)| *field = Some(*old_idx)); new_fields } impl Collection { pub(crate) fn change_notetype_of_notes_inner( &mut self, input: ChangeNotetypeInput, ) -> Result<()> { require!( input.current_schema == self.storage.get_collection_timestamps()?.schema_change, "schema changed" ); let usn = self.usn()?; self.set_schema_modified()?; if let Some(new_templates) = input.new_templates { let old_notetype = self .get_notetype(input.old_notetype_id)? .or_not_found(input.old_notetype_id)?; self.update_cards_for_new_notetype( &input.note_ids, old_notetype.templates.len(), new_templates, usn, )?; } else { self.maybe_remove_cards_with_missing_template( &input.note_ids, input.new_notetype_id, usn, )?; } self.update_notes_for_new_notetype_and_generate_cards( &input.note_ids, &input.new_fields, input.new_notetype_id, usn, )?; Ok(()) } /// Rewrite notes to match new notetype, and assigns new notetype id. /// /// `new_fields` should be the length of the new notetype's fields, and is a /// list of the previous field index each field should be mapped to. If /// None, the field is left empty. fn update_notes_for_new_notetype_and_generate_cards( &mut self, note_ids: &[NoteId], new_fields: &[Option], new_notetype_id: NotetypeId, usn: Usn, ) -> Result<()> { let notetype = self .get_notetype(new_notetype_id)? .or_not_found(new_notetype_id)?; let last_deck = self.get_last_deck_added_to_for_notetype(notetype.id); let ctx = CardGenContext::new(notetype.as_ref(), last_deck, usn); for nid in note_ids { let mut note = self.storage.get_note(*nid)?.or_not_found(nid)?; let original = note.clone(); remap_fields(note.fields_mut(), new_fields); note.notetype_id = new_notetype_id; self.update_note_inner_generating_cards( &ctx, &mut note, &original, true, false, false, )?; } Ok(()) } fn update_cards_for_new_notetype( &mut self, note_ids: &[NoteId], old_template_count: usize, new_templates: Vec>, usn: Usn, ) -> Result<()> { let nids: Node = SearchNode::NoteIds(comma_separated_ids(note_ids)).into(); let map = TemplateMap::new(new_templates, old_template_count); self.remove_unmapped_cards(&map, nids.clone(), usn)?; self.rewrite_remapped_cards(&map, nids, usn)?; Ok(()) } fn remove_unmapped_cards( &mut self, map: &TemplateMap, nids: Node, usn: Usn, ) -> Result<(), AnkiError> { if !map.removed.is_empty() { let ords = SearchBuilder::any(map.removed.iter().map(|o| TemplateKind::Ordinal(*o as u16))); for card in self.all_cards_for_search(nids.and(ords))? { self.remove_card_and_add_grave_undoable(card, usn)?; } } Ok(()) } fn rewrite_remapped_cards( &mut self, map: &TemplateMap, nids: Node, usn: Usn, ) -> Result<(), AnkiError> { if !map.remapped.is_empty() { let ords = SearchBuilder::any( map.remapped .keys() .map(|o| TemplateKind::Ordinal(*o as u16)), ); for mut card in self.all_cards_for_search(nids.and(ords))? { let original = card.clone(); card.template_idx = *map.remapped.get(&(card.template_idx as usize)).unwrap() as u16; self.update_card_inner(&mut card, original, usn)?; } } Ok(()) } /// If provided notetype is a normal notetype, remove any card ordinals that /// don't have a template associated with them. While recent Anki versions /// should be able to handle this case, it can cause crashes on older /// clients. fn maybe_remove_cards_with_missing_template( &mut self, note_ids: &[NoteId], notetype_id: NotetypeId, usn: Usn, ) -> Result<()> { let notetype = self.get_notetype(notetype_id)?.or_not_found(notetype_id)?; if notetype.config.kind() == NotetypeKind::Normal { // cloze -> normal change requires clean up for card in self .storage .all_cards_of_notes_above_ordinal(note_ids, notetype.templates.len() - 1)? { self.remove_card_and_add_grave_undoable(card, usn)?; } } Ok(()) } } /// Rewrite the field list from a note to match a new notetype's fields. fn remap_fields(fields: &mut Vec, new_fields: &[Option]) { *fields = new_fields .iter() .map(|field| { if let Some(idx) = *field { // clone required as same field can be mapped multiple times fields.get(idx).map(ToString::to_string).unwrap_or_default() } else { String::new() } }) .collect(); } #[cfg(test)] mod test { use super::*; use crate::error::Result; #[test] fn field_map() -> Result<()> { let mut col = Collection::new(); let mut basic = col .storage .get_notetype(col.get_current_notetype_id().unwrap())? .unwrap(); // no matching field names; fields are assigned in order let cloze = col.get_notetype_by_name("Cloze")?.unwrap().as_ref().clone(); assert_eq!(&default_field_map(&basic, &cloze), &[Some(0), Some(1)]); basic.add_field("idx2"); basic.add_field("idx3"); basic.add_field("Text"); // 4 basic.add_field("idx5"); // re-fetch to get ordinals col.update_notetype(&mut basic, false)?; let basic = col.get_notetype(basic.id)?.unwrap(); // if names match, assignments are out of order; unmatched entries // are filled sequentially assert_eq!(&default_field_map(&basic, &cloze), &[Some(4), Some(0)]); // unmatched entries are filled sequentially until exhausted assert_eq!( &default_field_map(&cloze, &basic), &[ // front Some(1), // back None, // idx2 None, // idx3 None, // text Some(0), // idx5 None, ] ); Ok(()) } #[test] fn template_map() { let new_templates = vec![None, Some(0)]; assert_eq!( TemplateMap::new(new_templates.clone(), 1), TemplateMap { removed: vec![], remapped: vec![(0, 1)].into_iter().collect() } ); assert_eq!( TemplateMap::new(new_templates, 2), TemplateMap { removed: vec![1], remapped: vec![(0, 1)].into_iter().collect() } ); } #[test] fn basic() -> Result<()> { let mut col = Collection::new(); let basic = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = basic.new_note(); note.set_field(0, "1")?; note.set_field(1, "2")?; col.add_note(&mut note, DeckId(1))?; let basic2 = col .get_notetype_by_name("Basic (and reversed card)")? .unwrap(); let first_card = col.storage.all_cards_of_note(note.id)?[0].clone(); assert_eq!(first_card.template_idx, 0); // switch the existing card to ordinal 2 let input = ChangeNotetypeInput { note_ids: vec![note.id], new_templates: Some(vec![None, Some(0)]), ..col.notetype_change_info(basic.id, basic2.id)?.input }; col.change_notetype_of_notes(input)?; // cards arrive in creation order, so the existing card will come first let cards = col.storage.all_cards_of_note(note.id)?; assert_eq!(cards[0].id, first_card.id); assert_eq!(cards[0].template_idx, 1); // a new forward card should also have been generated assert_eq!(cards[1].template_idx, 0); assert_ne!(cards[1].id, first_card.id); Ok(()) } #[test] fn field_count_change() -> Result<()> { let mut col = Collection::new(); let basic = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = basic.new_note(); note.set_field(0, "1")?; note.set_field(1, "2")?; col.add_note(&mut note, DeckId(1))?; let basic2 = col .get_notetype_by_name("Basic (optional reversed card)")? .unwrap(); let input = ChangeNotetypeInput { note_ids: vec![note.id], ..col.notetype_change_info(basic.id, basic2.id)?.input }; col.change_notetype_of_notes(input)?; Ok(()) } #[test] fn cloze() -> Result<()> { let mut col = Collection::new(); let basic = col .get_notetype_by_name("Basic (and reversed card)")? .unwrap(); let mut note = basic.new_note(); note.set_field(0, "1")?; note.set_field(1, "2")?; col.add_note(&mut note, DeckId(1))?; let cloze = col.get_notetype_by_name("Cloze")?.unwrap(); // changing to cloze should leave all the existing cards alone let input = ChangeNotetypeInput { note_ids: vec![note.id], ..col.notetype_change_info(basic.id, cloze.id)?.input }; col.change_notetype_of_notes(input)?; let cards = col.storage.all_cards_of_note(note.id)?; assert_eq!(cards.len(), 2); // and back again should also work let input = ChangeNotetypeInput { note_ids: vec![note.id], ..col.notetype_change_info(cloze.id, basic.id)?.input }; col.change_notetype_of_notes(input)?; let cards = col.storage.all_cards_of_note(note.id)?; assert_eq!(cards.len(), 2); // but any cards above the available templates should be removed when converting // from cloze->normal let input = ChangeNotetypeInput { note_ids: vec![note.id], ..col.notetype_change_info(basic.id, cloze.id)?.input }; col.change_notetype_of_notes(input)?; let basic1 = col.get_notetype_by_name("Basic")?.unwrap(); let input = ChangeNotetypeInput { note_ids: vec![note.id], ..col.notetype_change_info(cloze.id, basic1.id)?.input }; col.change_notetype_of_notes(input)?; let cards = col.storage.all_cards_of_note(note.id)?; assert_eq!(cards.len(), 1); Ok(()) } } ================================================ FILE: rslib/src/notetype/render.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::collections::HashMap; use super::CardTemplate; use super::Notetype; use super::NotetypeKind; use crate::prelude::*; use crate::template::field_is_empty; use crate::template::render_card; use crate::template::ParsedTemplate; use crate::template::RenderCardRequest; use crate::template::RenderedNode; #[derive(Debug)] pub struct RenderCardOutput { pub qnodes: Vec, pub anodes: Vec, pub css: String, pub latex_svg: bool, pub is_empty: bool, } impl RenderCardOutput { /// The question text. This is only valid to call when partial_render=false. pub fn question(&self) -> Cow<'_, str> { match self.qnodes.as_slice() { [RenderedNode::Text { text }] => text.into(), _ => "not fully rendered".into(), } } /// The answer text. This is only valid to call when partial_render=false. pub fn answer(&self) -> Cow<'_, str> { match self.anodes.as_slice() { [RenderedNode::Text { text }] => text.into(), _ => "not fully rendered".into(), } } } impl Collection { /// Render an existing card saved in the database. pub fn render_existing_card( &mut self, cid: CardId, browser: bool, partial_render: bool, ) -> Result { let card = self.storage.get_card(cid)?.or_invalid("no such card")?; let note = self .storage .get_note(card.note_id)? .or_invalid("no such note")?; let nt = self .get_notetype(note.notetype_id)? .or_invalid("no such notetype")?; let template = match nt.config.kind() { NotetypeKind::Normal => nt.templates.get(card.template_idx as usize), NotetypeKind::Cloze => nt.templates.first(), } .or_invalid("missing template")?; self.render_card(¬e, &card, &nt, template, browser, partial_render) } /// Render a card that may not yet have been added. /// The provided ordinal will be used if the template has not yet been /// saved. If fill_empty is set, note will be mutated. pub fn render_uncommitted_card( &mut self, note: &mut Note, template: &CardTemplate, card_ord: u16, fill_empty: bool, partial_render: bool, ) -> Result { let card = self.existing_or_synthesized_card(note.id, template.ord, card_ord)?; let nt = self .get_notetype(note.notetype_id)? .or_invalid("no such notetype")?; if fill_empty { fill_empty_fields(note, &template.config.q_format, &nt, &self.tr); } self.render_card(note, &card, &nt, template, false, partial_render) } fn existing_or_synthesized_card( &self, nid: NoteId, template_ord: Option, card_ord: u16, ) -> Result { // fetch existing card if let Some(ord) = template_ord { if let Some(card) = self.storage.get_card_by_ordinal(nid, ord as u16)? { return Ok(card); } } // no existing card; synthesize one Ok(Card { template_idx: card_ord, ..Default::default() }) } pub fn render_card( &mut self, note: &Note, card: &Card, nt: &Notetype, template: &CardTemplate, browser: bool, partial_render: bool, ) -> Result { let mut field_map = note.fields_map(&nt.fields); self.add_special_fields(&mut field_map, note, card, nt, template)?; // due to lifetime restrictions we need to add card number here let card_num = format!("c{}", card.template_idx + 1); field_map.entry(&card_num).or_insert_with(|| "1".into()); let (qfmt, afmt) = if browser { ( template.question_format_for_browser(), template.answer_format_for_browser(), ) } else { ( template.config.q_format.as_str(), template.config.a_format.as_str(), ) }; let response = render_card(RenderCardRequest { qfmt, afmt, field_map: &field_map, card_ord: card.template_idx, is_cloze: nt.is_cloze(), browser, tr: &self.tr, partial_render, })?; Ok(RenderCardOutput { qnodes: response.qnodes, anodes: response.anodes, css: nt.config.css.clone(), latex_svg: nt.config.latex_svg, is_empty: response.is_empty, }) } /// Add special fields if they don't clobber note fields. /// The fields supported here must coincide with SPECIAL_FIELDS in /// notetype/mod.rs, apart from FrontSide which is handled by Python. fn add_special_fields( &mut self, map: &mut HashMap<&str, Cow>, note: &Note, card: &Card, nt: &Notetype, template: &CardTemplate, ) -> Result<()> { let tags = note.tags.join(" "); map.entry("Tags").or_insert_with(|| tags.into()); map.entry("Type").or_insert_with(|| nt.name.clone().into()); let deck_name: Cow = self .get_deck(card.original_deck_id.or(card.deck_id))? .map(|d| d.human_name().into()) .unwrap_or_else(|| "(Deck)".into()); let subdeck_name = deck_name.rsplit("::").next().unwrap(); map.entry("Subdeck") .or_insert_with(|| subdeck_name.to_string().into()); map.entry("Deck") .or_insert_with(|| deck_name.to_string().into()); map.entry("CardFlag") .or_insert_with(|| flag_name(card.flags).into()); map.entry("Card") .or_insert_with(|| template.name.clone().into()); map.entry("CardID") .or_insert_with(|| card.id.to_string().into()); Ok(()) } } fn flag_name(n: u8) -> String { format!("flag{n}") } fn fill_empty_fields(note: &mut Note, qfmt: &str, nt: &Notetype, tr: &I18n) { if let Ok(tmpl) = ParsedTemplate::from_text(qfmt) { let cloze_fields = tmpl.all_referenced_cloze_field_names(); for (val, field) in note.fields_mut().iter_mut().zip(nt.fields.iter()) { if field_is_empty(val) { if cloze_fields.contains(&field.name.as_str()) { *val = tr.card_templates_sample_cloze().into(); } else { *val = format!("({})", field.name); } } } } } #[cfg(test)] mod test { use super::*; use crate::collection::CollectionBuilder; use crate::notetype::SPECIAL_FIELDS; #[test] fn can_render_fully() -> Result<()> { let mut col = CollectionBuilder::default().build()?; let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = Note::new(&nt); note.set_field(0, "front")?; note.set_field(1, "back")?; let out: RenderCardOutput = col.render_uncommitted_card(&mut note, &nt.templates[0], 0, false, false)?; assert_eq!(&out.question(), "front"); assert_eq!(&out.answer(), "front\n\n
\n\nback"); // should work even if unknown filters are encountered let mut tmpl = nt.templates[0].clone(); tmpl.config.q_format = "{{some_filter:Front}}{{another_filter:}}".into(); let out = col.render_uncommitted_card(&mut note, &nt.templates[0], 0, false, false)?; assert_eq!(&out.question(), "front"); Ok(()) } #[test] fn special_fields_complete() -> Result<()> { let mut col = CollectionBuilder::default().build()?; let mut map = HashMap::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let note = Note::new(&nt); let card = Card::new(0.into(), 0.try_into().unwrap(), 0.into(), 0); let tmpl = nt.templates[0].clone(); col.add_special_fields(&mut map, ¬e, &card, &nt, &tmpl)?; assert!(map.iter().all(|val| SPECIAL_FIELDS.contains(val.0))); Ok(()) } } ================================================ FILE: rslib/src/notetype/restore.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::notetypes::stock_notetype::Kind; use anki_proto::notetypes::stock_notetype::OriginalStockKind; use crate::notetype::stock::get_original_stock_notetype; use crate::notetype::stock::StockKind; use crate::prelude::*; impl Collection { /// If force_kind is not Unknown, it will be used in preference to the kind /// stored in the notetype. If Unknown, and the kind stored in the /// notetype is also Unknown, an error will be returned. pub(crate) fn restore_notetype_to_stock( &mut self, notetype_id: NotetypeId, force_kind: Option, ) -> Result> { let mut nt = self .storage .get_notetype(notetype_id)? .or_not_found(notetype_id)?; let stock_kind = match (nt.config.original_stock_kind(), force_kind) { (_, Some(force_kind)) => match force_kind { Kind::Basic => OriginalStockKind::Basic, Kind::BasicAndReversed => OriginalStockKind::BasicAndReversed, Kind::BasicOptionalReversed => OriginalStockKind::BasicOptionalReversed, Kind::BasicTyping => OriginalStockKind::BasicTyping, Kind::Cloze => OriginalStockKind::Cloze, Kind::ImageOcclusion => OriginalStockKind::ImageOcclusion, }, (stock, _) => stock, }; if stock_kind == OriginalStockKind::Unknown { invalid_input!("unknown original notetype kind"); } let mut stock_nt = get_original_stock_notetype(stock_kind, &self.tr)?; for (idx, item) in stock_nt.templates.iter_mut().enumerate() { item.ord = Some(idx as u32); } nt.templates = stock_nt.templates; for (idx, item) in stock_nt.fields.iter_mut().enumerate() { item.ord = Some(idx as u32); } nt.fields = stock_nt.fields; nt.config.css = stock_nt.config.css; if force_kind.is_some() { nt.config.original_stock_kind = stock_kind as i32; nt.config.kind = stock_nt.config.kind; } self.update_notetype(&mut nt, false) } } #[cfg(test)] mod test { use super::*; #[test] fn adding_and_removing_fields_and_templates() -> Result<()> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let note = NoteAdder::basic(&mut col) .fields(&["front", "back"]) .add(&mut col); col.restore_notetype_to_stock(nt.id, Some(StockKind::BasicOptionalReversed))?; let note = col.storage.get_note(note.id)?.unwrap(); assert_eq!(note.fields(), &["front", "back", ""]); assert_eq!( col.storage.db_scalar::("select count(*) from cards")?, 1 ); col.restore_notetype_to_stock(nt.id, Some(StockKind::BasicAndReversed))?; let note = col.storage.get_note(note.id)?.unwrap(); assert_eq!(note.fields(), &["front", "back"]); assert_eq!( col.storage.db_scalar::("select count(*) from cards")?, 2 ); col.restore_notetype_to_stock(nt.id, Some(StockKind::Cloze))?; let note = col.storage.get_note(note.id)?.unwrap(); assert_eq!(note.fields(), &["front", "back"]); assert_eq!( col.storage.db_scalar::("select count(*) from cards")?, 1 ); Ok(()) } } ================================================ FILE: rslib/src/notetype/schema11.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use phf::phf_set; use phf::Set; use serde::Deserialize; use serde::Serialize; use serde_json::Value; use serde_repr::Deserialize_repr; use serde_repr::Serialize_repr; use serde_tuple::Serialize_tuple; use super::CardRequirementKind; use super::NotetypeId; use crate::decks::DeckId; use crate::notetype::CardRequirement; use crate::notetype::CardTemplate; use crate::notetype::CardTemplateConfig; use crate::notetype::NoteField; use crate::notetype::NoteFieldConfig; use crate::notetype::Notetype; use crate::notetype::NotetypeConfig; use crate::serde::default_on_invalid; use crate::serde::deserialize_bool_from_anything; use crate::serde::deserialize_number_from_string; use crate::serde::is_default; use crate::timestamp::TimestampSecs; use crate::types::Usn; #[derive(Serialize_repr, Deserialize_repr, PartialEq, Eq, Debug, Clone)] #[repr(u8)] pub enum NotetypeKind { Standard = 0, Cloze = 1, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct NotetypeSchema11 { #[serde(deserialize_with = "deserialize_number_from_string")] pub(crate) id: NotetypeId, pub(crate) name: String, #[serde(rename = "type")] pub(crate) kind: NotetypeKind, #[serde(rename = "mod")] pub(crate) mtime: TimestampSecs, pub(crate) usn: Usn, pub(crate) sortf: u16, #[serde(deserialize_with = "default_on_invalid")] pub(crate) did: Option, pub(crate) tmpls: Vec, pub(crate) flds: Vec, #[serde(deserialize_with = "default_on_invalid")] pub(crate) css: String, #[serde(default)] pub(crate) latex_pre: String, #[serde(default)] pub(crate) latex_post: String, #[serde(default, deserialize_with = "default_on_invalid")] pub latexsvg: bool, #[serde(default, deserialize_with = "default_on_invalid")] pub(crate) req: CardRequirementsSchema11, #[serde(default, skip_serializing_if = "is_default")] pub(crate) original_stock_kind: i32, #[serde(default, skip_serializing_if = "is_default")] pub(crate) original_id: Option, #[serde(flatten)] pub(crate) other: HashMap, } #[derive(Serialize, Deserialize, Debug, Default, Clone)] pub(crate) struct CardRequirementsSchema11(pub(crate) Vec); #[derive(Serialize_tuple, Deserialize, Debug, Clone)] pub(crate) struct CardRequirementSchema11 { pub(crate) card_ord: u16, pub(crate) kind: FieldRequirementKindSchema11, pub(crate) field_ords: Vec, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "lowercase")] pub enum FieldRequirementKindSchema11 { Any, All, None, } impl NotetypeSchema11 { pub fn latex_uses_svg(&self) -> bool { self.latexsvg } } impl From for Notetype { fn from(nt: NotetypeSchema11) -> Self { Notetype { id: nt.id, name: nt.name, mtime_secs: nt.mtime, usn: nt.usn, config: NotetypeConfig { kind: nt.kind as i32, sort_field_idx: nt.sortf as u32, css: nt.css, target_deck_id_unused: nt.did.unwrap_or(DeckId(0)).0, latex_pre: nt.latex_pre, latex_post: nt.latex_post, latex_svg: nt.latexsvg, reqs: nt.req.0.into_iter().map(Into::into).collect(), original_stock_kind: nt.original_stock_kind, original_id: nt.original_id, other: other_to_bytes(&nt.other), }, fields: nt.flds.into_iter().map(Into::into).collect(), templates: nt.tmpls.into_iter().map(Into::into).collect(), } } } fn other_to_bytes(other: &HashMap) -> Vec { if other.is_empty() { vec![] } else { serde_json::to_vec(other).unwrap_or_else(|e| { // theoretically should never happen println!("serialization failed for {other:?}: {e}"); vec![] }) } } pub(crate) fn parse_other_fields( bytes: &[u8], reserved: &Set<&'static str>, ) -> HashMap { if bytes.is_empty() { Default::default() } else { let mut map: HashMap = serde_json::from_slice(bytes).unwrap_or_else(|e| { println!("deserialization failed for other: {e}"); Default::default() }); map.retain(|k, _v| !reserved.contains(k)); map } } impl From for NotetypeSchema11 { fn from(p: Notetype) -> Self { let c = p.config; NotetypeSchema11 { id: p.id, name: p.name, kind: if c.kind == 1 { NotetypeKind::Cloze } else { NotetypeKind::Standard }, mtime: p.mtime_secs, usn: p.usn, sortf: c.sort_field_idx as u16, did: if c.target_deck_id_unused == 0 { None } else { Some(DeckId(c.target_deck_id_unused)) }, tmpls: p.templates.into_iter().map(Into::into).collect(), flds: p.fields.into_iter().map(Into::into).collect(), css: c.css, latex_pre: c.latex_pre, latex_post: c.latex_post, latexsvg: c.latex_svg, req: CardRequirementsSchema11(c.reqs.into_iter().map(Into::into).collect()), original_stock_kind: c.original_stock_kind, original_id: c.original_id, other: parse_other_fields(&c.other, &RESERVED_NOTETYPE_KEYS), } } } static RESERVED_NOTETYPE_KEYS: Set<&'static str> = phf_set! { "latexPost", "flds", "css", "originalStockKind", "originalId", "id", "usn", "mod", "req", "latexPre", "name", "did", "tmpls", "type", "sortf", "latexsvg" }; impl From for CardRequirement { fn from(r: CardRequirementSchema11) -> Self { CardRequirement { card_ord: r.card_ord as u32, kind: match r.kind { FieldRequirementKindSchema11::Any => CardRequirementKind::Any, FieldRequirementKindSchema11::All => CardRequirementKind::All, FieldRequirementKindSchema11::None => CardRequirementKind::None, } as i32, field_ords: r.field_ords.into_iter().map(|n| n as u32).collect(), } } } impl From for CardRequirementSchema11 { fn from(p: CardRequirement) -> Self { CardRequirementSchema11 { card_ord: p.card_ord as u16, kind: match p.kind() { CardRequirementKind::Any => FieldRequirementKindSchema11::Any, CardRequirementKind::All => FieldRequirementKindSchema11::All, CardRequirementKind::None => FieldRequirementKindSchema11::None, }, field_ords: p.field_ords.into_iter().map(|n| n as u16).collect(), } } } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct NoteFieldSchema11 { pub(crate) name: String, pub(crate) ord: Option, #[serde(deserialize_with = "deserialize_bool_from_anything")] pub(crate) sticky: bool, #[serde(deserialize_with = "deserialize_bool_from_anything")] pub(crate) rtl: bool, pub(crate) font: String, pub(crate) size: u16, // These were not in schema 11, but need to be listed here so that the setting is not lost // on downgrade/upgrade. #[serde(default, deserialize_with = "default_on_invalid")] pub(crate) description: String, #[serde(default, deserialize_with = "default_on_invalid")] pub(crate) plain_text: bool, #[serde(default, deserialize_with = "default_on_invalid")] pub(crate) collapsed: bool, #[serde(default, deserialize_with = "default_on_invalid")] pub(crate) exclude_from_search: bool, #[serde(default, deserialize_with = "default_on_invalid")] pub(crate) id: Option, #[serde(default, deserialize_with = "default_on_invalid")] pub(crate) tag: Option, #[serde(default, deserialize_with = "default_on_invalid")] pub(crate) prevent_deletion: bool, #[serde(flatten)] pub(crate) other: HashMap, } impl Default for NoteFieldSchema11 { fn default() -> Self { Self { name: String::new(), ord: None, sticky: false, rtl: false, plain_text: false, font: "Arial".to_string(), size: 20, description: String::new(), collapsed: false, exclude_from_search: false, id: None, tag: None, prevent_deletion: false, other: Default::default(), } } } impl From for NoteField { fn from(f: NoteFieldSchema11) -> Self { NoteField { ord: f.ord.map(|o| o as u32), name: f.name, config: NoteFieldConfig { sticky: f.sticky, rtl: f.rtl, plain_text: f.plain_text, font_name: f.font, font_size: f.size as u32, description: f.description, collapsed: f.collapsed, exclude_from_search: f.exclude_from_search, id: f.id, tag: f.tag, prevent_deletion: f.prevent_deletion, other: other_to_bytes(&f.other), }, } } } impl From for NoteFieldSchema11 { fn from(p: NoteField) -> Self { let conf = p.config; NoteFieldSchema11 { name: p.name, ord: p.ord.map(|o| o as u16), sticky: conf.sticky, rtl: conf.rtl, plain_text: conf.plain_text, font: conf.font_name, size: conf.font_size as u16, description: conf.description, collapsed: conf.collapsed, exclude_from_search: conf.exclude_from_search, id: conf.id, tag: conf.tag, prevent_deletion: conf.prevent_deletion, other: parse_other_fields(&conf.other, &RESERVED_FIELD_KEYS), } } } static RESERVED_FIELD_KEYS: Set<&'static str> = phf_set! { "name", "ord", "sticky", "rtl", "plainText", "font", "size", "collapsed", "description", "excludeFromSearch", "id", "tag", "preventDeletion", }; #[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct CardTemplateSchema11 { pub(crate) name: String, pub(crate) ord: Option, pub(crate) qfmt: String, #[serde(default)] pub(crate) afmt: String, #[serde(default)] pub(crate) bqfmt: String, #[serde(default)] pub(crate) bafmt: String, #[serde(deserialize_with = "default_on_invalid", default)] pub(crate) did: Option, #[serde(default, deserialize_with = "default_on_invalid")] pub(crate) bfont: String, #[serde(default, deserialize_with = "default_on_invalid")] pub(crate) bsize: u8, #[serde(default, deserialize_with = "default_on_invalid")] pub(crate) id: Option, #[serde(flatten)] pub(crate) other: HashMap, } impl From for CardTemplate { fn from(t: CardTemplateSchema11) -> Self { CardTemplate { ord: t.ord.map(|t| t as u32), name: t.name, mtime_secs: TimestampSecs(0), usn: Usn(0), config: CardTemplateConfig { q_format: t.qfmt, a_format: t.afmt, q_format_browser: t.bqfmt, a_format_browser: t.bafmt, target_deck_id: t.did.unwrap_or(DeckId(0)).0, browser_font_name: t.bfont, browser_font_size: t.bsize as u32, id: t.id, other: other_to_bytes(&t.other), }, } } } impl From for CardTemplateSchema11 { fn from(p: CardTemplate) -> Self { let conf = p.config; CardTemplateSchema11 { name: p.name, ord: p.ord.map(|o| o as u16), qfmt: conf.q_format, afmt: conf.a_format, bqfmt: conf.q_format_browser, bafmt: conf.a_format_browser, did: if conf.target_deck_id > 0 { Some(DeckId(conf.target_deck_id)) } else { None }, bfont: conf.browser_font_name, bsize: conf.browser_font_size as u8, id: conf.id, other: parse_other_fields(&conf.other, &RESERVED_TEMPLATE_KEYS), } } } static RESERVED_TEMPLATE_KEYS: Set<&'static str> = phf_set! { "name", "ord", "did", "afmt", "bafmt", "qfmt", "bqfmt", "bfont", "bsize", "id", }; #[cfg(test)] mod tests { use itertools::Itertools; use super::*; use crate::notetype::stock::basic; use crate::prelude::*; #[test] fn all_reserved_fields_are_removed() -> Result<()> { let mut nt = basic(&I18n::template_only()); let key_source = NotetypeSchema11::from(nt.clone()); nt.config.other = serde_json::to_vec(&key_source)?; nt.fields[0].config.other = serde_json::to_vec(&key_source.flds[0])?; nt.templates[0].config.other = serde_json::to_vec(&key_source.tmpls[0])?; let s11 = NotetypeSchema11::from(nt); let empty: &[&String] = &[]; assert_eq!(&s11.other.keys().collect_vec(), empty); assert_eq!(&s11.flds[0].other.keys().collect_vec(), empty); assert_eq!(&s11.tmpls[0].other.keys().collect_vec(), empty); Ok(()) } } ================================================ FILE: rslib/src/notetype/schemachange.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html //! Updates to notes/cards when the structure of a notetype is changed. use std::collections::HashMap; use std::mem; use super::CardGenContext; use super::CardTemplate; use super::Notetype; use crate::notes::UpdateNoteInnerWithoutCardsArgs; use crate::prelude::*; use crate::search::JoinSearches; use crate::search::TemplateKind; /// True if any ordinals added, removed or reordered. fn ords_changed(ords: &[Option], previous_len: usize) -> bool { ords.len() != previous_len || ords .iter() .enumerate() .any(|(idx, &ord)| ord != Some(idx as u32)) } #[derive(Default, PartialEq, Eq, Debug)] struct TemplateOrdChanges { added: Vec, removed: Vec, // map of old->new moved: HashMap, } impl TemplateOrdChanges { fn new(ords: Vec>, previous_len: u32) -> Self { let mut changes = TemplateOrdChanges::default(); let mut removed: Vec<_> = (0..previous_len).map(|v| Some(v as u16)).collect(); for (idx, old_ord) in ords.into_iter().enumerate() { if let Some(old_ord) = old_ord { if let Some(entry) = removed.get_mut(old_ord as usize) { // guard required to ensure we don't panic if invalid high ordinal received *entry = None; } if old_ord == idx as u32 { // no action } else { changes.moved.insert(old_ord as u16, idx as u16); } } else { changes.added.push(idx as u32); } } changes.removed = removed.into_iter().flatten().collect(); changes } fn is_empty(&self) -> bool { *self == Self::default() } } impl Collection { /// Rewrite notes to match the updated field schema. /// Caller must create transaction. pub(crate) fn update_notes_for_changed_fields( &mut self, nt: &Notetype, previous_field_count: usize, previous_sort_idx: u32, normalize_text: bool, ) -> Result<()> { let usn = self.usn()?; let ords: Vec<_> = nt.fields.iter().map(|f| f.ord).collect(); if !ords_changed(&ords, previous_field_count) { if nt.config.sort_field_idx != previous_sort_idx { // only need to update sort field self.set_schema_modified()?; let nids = self.search_notes_unordered(nt.id)?; for nid in nids { let mut note = self.storage.get_note(nid)?.unwrap(); let original = note.clone(); self.update_note_inner_without_cards(UpdateNoteInnerWithoutCardsArgs { note: &mut note, original: &original, notetype: nt, usn, mark_note_modified: true, normalize_text, update_tags: false, })?; } } else { // nothing to do } return Ok(()); } // fields have changed self.set_schema_modified()?; let nids = self.search_notes_unordered(nt.id)?; let usn = self.usn()?; for nid in nids { let mut note = self.storage.get_note(nid)?.unwrap(); let original = note.clone(); note.reorder_fields(&ords); self.update_note_inner_without_cards(UpdateNoteInnerWithoutCardsArgs { note: &mut note, original: &original, notetype: nt, usn, mark_note_modified: true, normalize_text, update_tags: false, })?; } Ok(()) } /// Update cards after card templates added, removed or reordered. /// Does not remove cards where the template still exists but creates an /// empty card. Caller must create transaction. pub(crate) fn update_cards_for_changed_templates( &mut self, nt: &Notetype, old_templates: &[CardTemplate], ) -> Result<()> { let usn = self.usn()?; let ords: Vec<_> = nt.templates.iter().map(|f| f.ord).collect(); let changes = TemplateOrdChanges::new(ords, old_templates.len() as u32); if !changes.is_empty() { self.set_schema_modified()?; } // remove any cards where the template was deleted if !changes.removed.is_empty() { let ords = SearchBuilder::any(changes.removed.iter().cloned().map(TemplateKind::Ordinal)); for card in self.all_cards_for_search(nt.id.and(ords))? { self.remove_card_and_add_grave_undoable(card, usn)?; } } // update ordinals for cards with a repositioned template if !changes.moved.is_empty() { let ords = SearchBuilder::any(changes.moved.keys().cloned().map(TemplateKind::Ordinal)); for mut card in self.all_cards_for_search(nt.id.and(ords))? { let original = card.clone(); card.template_idx = *changes.moved.get(&card.template_idx).unwrap(); self.update_card_inner(&mut card, original, usn)?; } } if should_generate_cards(&changes, nt, old_templates) { let last_deck = self.get_last_deck_added_to_for_notetype(nt.id); let ctx = CardGenContext::new(nt, last_deck, usn); self.generate_cards_for_notetype(&ctx)?; } Ok(()) } } fn should_generate_cards( changes: &TemplateOrdChanges, nt: &Notetype, old_templates: &[CardTemplate], ) -> bool { // must regenerate if any front side has changed, but also in the (unlikely) // case that a template has been replaced by one with an identical front !(changes.added.is_empty() && nt.template_fronts_are_identical(old_templates)) } impl Notetype { fn template_fronts_are_identical(&self, other_templates: &[CardTemplate]) -> bool { self.templates .iter() .map(|t| &t.config.q_format) .eq(other_templates.iter().map(|t| &t.config.q_format)) } } impl Note { pub(crate) fn reorder_fields(&mut self, new_ords: &[Option]) { *self.fields_mut() = new_ords .iter() .map(|ord| { ord.and_then(|idx| self.fields_mut().get_mut(idx as usize)) .map(mem::take) .unwrap_or_default() }) .collect(); } } #[cfg(test)] mod test { use super::*; use crate::search::SortMode; #[test] fn ord_changes() { assert!(!ords_changed(&[Some(0), Some(1)], 2)); assert!(ords_changed(&[Some(0), Some(1)], 1)); assert!(ords_changed(&[Some(1), Some(0)], 2)); assert!(ords_changed(&[None, Some(1)], 2)); assert!(ords_changed(&[Some(0), Some(1), None], 2)); } #[test] fn template_changes() { assert_eq!( TemplateOrdChanges::new(vec![Some(0), Some(1)], 2), TemplateOrdChanges::default(), ); assert_eq!( TemplateOrdChanges::new(vec![Some(0), Some(1)], 3), TemplateOrdChanges { removed: vec![2], ..Default::default() } ); assert_eq!( TemplateOrdChanges::new(vec![Some(1)], 2), TemplateOrdChanges { removed: vec![0], moved: vec![(1, 0)].into_iter().collect(), ..Default::default() } ); assert_eq!( TemplateOrdChanges::new(vec![Some(0), None], 1), TemplateOrdChanges { added: vec![1], ..Default::default() } ); assert_eq!( TemplateOrdChanges::new(vec![Some(2), None, Some(0)], 2), TemplateOrdChanges { added: vec![1], moved: vec![(2, 0), (0, 2)].into_iter().collect(), removed: vec![1], } ); assert_eq!( TemplateOrdChanges::new(vec![None, Some(2), None, Some(4)], 5), TemplateOrdChanges { added: vec![0, 2], moved: vec![(2, 1), (4, 3)].into_iter().collect(), removed: vec![0, 1, 3], } ); } #[test] fn fields() -> Result<()> { let mut col = Collection::new(); let mut nt = col .storage .get_notetype(col.get_current_notetype_id().unwrap())? .unwrap(); let mut note = nt.new_note(); assert_eq!(note.fields().len(), 2); note.set_field(0, "one")?; note.set_field(1, "two")?; col.add_note(&mut note, DeckId(1))?; nt.add_field("three"); col.update_notetype(&mut nt, false)?; let note = col.storage.get_note(note.id)?.unwrap(); assert_eq!(note.fields(), &["one".to_string(), "two".into(), "".into()]); nt.fields.remove(1); col.update_notetype(&mut nt, false)?; let note = col.storage.get_note(note.id)?.unwrap(); assert_eq!(note.fields(), &["one".to_string(), "".into()]); Ok(()) } #[test] fn field_renaming_and_deleting() -> Result<()> { let mut col = Collection::new(); let mut nt = col .storage .get_notetype(col.get_current_notetype_id().unwrap())? .unwrap(); nt.templates[0].config.q_format += "\n{{#Front}}{{some:Front}}{{Back}}{{/Front}}"; nt.fields[0].name = "Test".into(); col.update_notetype(&mut nt, false)?; assert_eq!( &nt.templates[0].config.q_format, "{{Test}}\n{{#Test}}{{some:Test}}{{Back}}{{/Test}}" ); nt.fields.remove(0); col.update_notetype(&mut nt, false)?; assert_eq!(&nt.templates[0].config.q_format, "\n{{Back}}"); Ok(()) } #[test] fn cards() -> Result<()> { let mut col = Collection::new(); let mut nt = col .storage .get_notetype(col.get_current_notetype_id().unwrap())? .unwrap(); let mut note = nt.new_note(); assert_eq!(note.fields().len(), 2); note.set_field(0, "one")?; note.set_field(1, "two")?; col.add_note(&mut note, DeckId(1))?; assert_eq!( col.search_cards(note.id, SortMode::NoOrder).unwrap().len(), 1 ); // add an extra card template nt.add_template("card 2", "{{Front}}2", ""); col.update_notetype(&mut nt, false)?; assert_eq!( col.search_cards(note.id, SortMode::NoOrder).unwrap().len(), 2 ); Ok(()) } } ================================================ FILE: rslib/src/notetype/service.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::generic; use anki_proto::notetypes::stock_notetype::Kind as StockKind; use crate::collection::Collection; use crate::config::get_aux_notetype_config_key; use crate::error; use crate::error::OrInvalid; use crate::error::OrNotFound; use crate::notes::NoteId; use crate::notetype::stock::get_stock_notetype; use crate::notetype::ChangeNotetypeInput; use crate::notetype::Notetype; use crate::notetype::NotetypeChangeInfo; use crate::notetype::NotetypeId; use crate::notetype::NotetypeSchema11; use crate::prelude::IntoNewtypeVec; impl crate::services::NotetypesService for Collection { fn add_notetype( &mut self, input: anki_proto::notetypes::Notetype, ) -> error::Result { let mut notetype: Notetype = input.into(); Ok(self .add_notetype(&mut notetype, false)? .map(|_| notetype.id.0) .into()) } fn update_notetype( &mut self, input: anki_proto::notetypes::Notetype, ) -> error::Result { let mut notetype: Notetype = input.into(); self.update_notetype(&mut notetype, false).map(Into::into) } fn add_notetype_legacy( &mut self, input: generic::Json, ) -> error::Result { let legacy: NotetypeSchema11 = serde_json::from_slice(&input.json)?; let mut notetype: Notetype = legacy.into(); Ok(self .add_notetype(&mut notetype, false)? .map(|_| notetype.id.0) .into()) } fn update_notetype_legacy( &mut self, input: anki_proto::notetypes::UpdateNotetypeLegacyRequest, ) -> error::Result { let legacy: NotetypeSchema11 = serde_json::from_slice(&input.json)?; let mut notetype: Notetype = legacy.into(); self.update_notetype(&mut notetype, input.skip_checks) .map(Into::into) } fn add_or_update_notetype( &mut self, input: anki_proto::notetypes::AddOrUpdateNotetypeRequest, ) -> error::Result { let legacy: NotetypeSchema11 = serde_json::from_slice(&input.json)?; let mut nt: Notetype = legacy.into(); if !input.preserve_usn_and_mtime { nt.set_modified(self.usn()?); } if nt.id.0 == 0 { self.add_notetype(&mut nt, input.skip_checks)?; } else if !input.preserve_usn_and_mtime { self.update_notetype(&mut nt, input.skip_checks)?; } else { self.add_or_update_notetype_with_existing_id(&mut nt, input.skip_checks)?; } Ok(anki_proto::notetypes::NotetypeId { ntid: nt.id.0 }) } fn get_stock_notetype_legacy( &mut self, input: anki_proto::notetypes::StockNotetype, ) -> error::Result { let nt = get_stock_notetype(input.kind(), &self.tr); let schema11: NotetypeSchema11 = nt.into(); serde_json::to_vec(&schema11) .map_err(Into::into) .map(Into::into) } fn get_notetype( &mut self, input: anki_proto::notetypes::NotetypeId, ) -> error::Result { let ntid = input.into(); self.storage .get_notetype(ntid)? .or_not_found(ntid) .map(Into::into) } fn get_notetype_legacy( &mut self, input: anki_proto::notetypes::NotetypeId, ) -> error::Result { let ntid = input.into(); let schema11: NotetypeSchema11 = self.storage.get_notetype(ntid)?.or_not_found(ntid)?.into(); Ok(serde_json::to_vec(&schema11)?.into()) } fn get_notetype_names(&mut self) -> error::Result { let entries: Vec<_> = self .storage .get_all_notetype_names()? .into_iter() .map(|(id, name)| anki_proto::notetypes::NotetypeNameId { id: id.0, name }) .collect(); Ok(anki_proto::notetypes::NotetypeNames { entries }) } fn get_notetype_names_and_counts( &mut self, ) -> error::Result { let entries: Vec<_> = self .storage .get_notetype_use_counts()? .into_iter() .map( |(id, name, use_count)| anki_proto::notetypes::NotetypeNameIdUseCount { id: id.0, name, use_count, }, ) .collect(); Ok(anki_proto::notetypes::NotetypeUseCounts { entries }) } fn get_notetype_id_by_name( &mut self, input: generic::String, ) -> error::Result { self.storage .get_notetype_id(&input.val) .and_then(|nt| nt.or_not_found(input.val)) .map(|ntid| anki_proto::notetypes::NotetypeId { ntid: ntid.0 }) } fn remove_notetype( &mut self, input: anki_proto::notetypes::NotetypeId, ) -> error::Result { self.remove_notetype(input.into()).map(Into::into) } fn get_aux_notetype_config_key( &mut self, input: anki_proto::notetypes::GetAuxConfigKeyRequest, ) -> error::Result { Ok(get_aux_notetype_config_key(input.id.into(), &input.key).into()) } fn get_aux_template_config_key( &mut self, input: anki_proto::notetypes::GetAuxTemplateConfigKeyRequest, ) -> error::Result { self.get_aux_template_config_key( input.notetype_id.into(), input.card_ordinal as usize, &input.key, ) .map(Into::into) } fn get_change_notetype_info( &mut self, input: anki_proto::notetypes::GetChangeNotetypeInfoRequest, ) -> error::Result { self.notetype_change_info(input.old_notetype_id.into(), input.new_notetype_id.into()) .map(Into::into) } fn change_notetype( &mut self, input: anki_proto::notetypes::ChangeNotetypeRequest, ) -> error::Result { self.change_notetype_of_notes(input.into()).map(Into::into) } fn get_field_names( &mut self, input: anki_proto::notetypes::NotetypeId, ) -> error::Result { self.storage.get_field_names(input.into()).map(Into::into) } fn restore_notetype_to_stock( &mut self, input: anki_proto::notetypes::RestoreNotetypeToStockRequest, ) -> error::Result { let force_kind = input.force_kind.and_then(|s| StockKind::try_from(s).ok()); self.restore_notetype_to_stock( input.notetype_id.or_invalid("missing notetype id")?.into(), force_kind, ) .map(Into::into) } fn get_cloze_field_ords( &mut self, input: anki_proto::notetypes::NotetypeId, ) -> error::Result { Ok(anki_proto::notetypes::GetClozeFieldOrdsResponse { ords: self .get_notetype(input.into())? .unwrap() .cloze_fields() .iter() .map(|ord| (*ord) as u32) .collect(), }) } } impl From for Notetype { fn from(n: anki_proto::notetypes::Notetype) -> Self { Notetype { id: n.id.into(), name: n.name, mtime_secs: n.mtime_secs.into(), usn: n.usn.into(), fields: n.fields.into_iter().map(Into::into).collect(), templates: n.templates.into_iter().map(Into::into).collect(), config: n.config.unwrap_or_default(), } } } impl From for anki_proto::notetypes::ChangeNotetypeInfo { fn from(i: NotetypeChangeInfo) -> Self { anki_proto::notetypes::ChangeNotetypeInfo { old_notetype_name: i.old_notetype_name, old_field_names: i.old_field_names, old_template_names: i.old_template_names, new_field_names: i.new_field_names, new_template_names: i.new_template_names, input: Some(i.input.into()), } } } impl From for ChangeNotetypeInput { fn from(i: anki_proto::notetypes::ChangeNotetypeRequest) -> Self { ChangeNotetypeInput { current_schema: i.current_schema.into(), note_ids: i.note_ids.into_newtype(NoteId), old_notetype_name: i.old_notetype_name, old_notetype_id: i.old_notetype_id.into(), new_notetype_id: i.new_notetype_id.into(), new_fields: i .new_fields .into_iter() .map(|v| if v == -1 { None } else { Some(v as usize) }) .collect(), new_templates: { let v: Vec<_> = i .new_templates .into_iter() .map(|v| if v == -1 { None } else { Some(v as usize) }) .collect(); if v.is_empty() { None } else { Some(v) } }, } } } impl From for anki_proto::notetypes::ChangeNotetypeRequest { fn from(i: ChangeNotetypeInput) -> Self { anki_proto::notetypes::ChangeNotetypeRequest { current_schema: i.current_schema.into(), note_ids: i.note_ids.into_iter().map(Into::into).collect(), old_notetype_name: i.old_notetype_name, old_notetype_id: i.old_notetype_id.into(), new_notetype_id: i.new_notetype_id.into(), new_fields: i .new_fields .into_iter() .map(|idx| idx.map(|v| v as i32).unwrap_or(-1)) .collect(), is_cloze: i.new_templates.is_none(), new_templates: i .new_templates .unwrap_or_default() .into_iter() .map(|idx| idx.map(|v| v as i32).unwrap_or(-1)) .collect(), } } } impl From for NotetypeId { fn from(ntid: anki_proto::notetypes::NotetypeId) -> Self { NotetypeId(ntid.ntid) } } impl From for anki_proto::notetypes::NotetypeId { fn from(ntid: NotetypeId) -> Self { anki_proto::notetypes::NotetypeId { ntid: ntid.0 } } } ================================================ FILE: rslib/src/notetype/stock.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_i18n::I18n; use anki_proto::notetypes::notetype::config::Kind as NotetypeKind; use anki_proto::notetypes::stock_notetype::Kind; pub(crate) use anki_proto::notetypes::stock_notetype::Kind as StockKind; use anki_proto::notetypes::stock_notetype::OriginalStockKind; use anki_proto::notetypes::ClozeField; use super::NotetypeConfig; use crate::config::ConfigEntry; use crate::config::ConfigKey; use crate::error::Result; use crate::image_occlusion::notetype::image_occlusion_notetype; use crate::invalid_input; use crate::notetype::Notetype; use crate::storage::SqliteStorage; use crate::timestamp::TimestampSecs; impl SqliteStorage { pub(crate) fn add_stock_notetypes(&self, tr: &I18n) -> Result<()> { for (idx, mut nt) in all_stock_notetypes(tr).into_iter().enumerate() { nt.prepare_for_update(None, true)?; self.add_notetype(&mut nt)?; if idx == 0 { self.set_config_entry(&ConfigEntry::boxed( ConfigKey::CurrentNotetypeId.into(), serde_json::to_vec(&nt.id)?, self.usn(false)?, TimestampSecs::now(), ))?; } } Ok(()) } } // If changing this, make sure to update StockNotetype enum. Other parts of the // code expect the order here to be the same as the enum. pub fn all_stock_notetypes(tr: &I18n) -> Vec { vec![ basic(tr), basic_forward_reverse(tr), basic_optional_reverse(tr), basic_typing(tr), cloze(tr), image_occlusion_notetype(tr), ] } /// returns {{name}} fn fieldref>(name: S) -> String { format!("{{{{{}}}}}", name.as_ref()) } /// Create an empty notetype with a given name and stock kind. pub(crate) fn empty_stock( nt_kind: NotetypeKind, original_stock_kind: OriginalStockKind, name: impl Into, ) -> Notetype { Notetype { name: name.into(), config: NotetypeConfig { kind: nt_kind as i32, original_stock_kind: original_stock_kind as i32, ..if nt_kind == NotetypeKind::Cloze { Notetype::new_cloze_config() } else { Notetype::new_config() } }, ..Default::default() } } pub(crate) fn get_stock_notetype(kind: StockKind, tr: &I18n) -> Notetype { match kind { Kind::Basic => basic(tr), Kind::BasicAndReversed => basic_forward_reverse(tr), Kind::BasicOptionalReversed => basic_optional_reverse(tr), Kind::BasicTyping => basic_typing(tr), Kind::Cloze => cloze(tr), Kind::ImageOcclusion => image_occlusion_notetype(tr), } } pub(crate) fn get_original_stock_notetype(kind: OriginalStockKind, tr: &I18n) -> Result { Ok(match kind { OriginalStockKind::Unknown => invalid_input!("original stock kind not provided"), OriginalStockKind::Basic => basic(tr), OriginalStockKind::BasicAndReversed => basic_forward_reverse(tr), OriginalStockKind::BasicOptionalReversed => basic_optional_reverse(tr), OriginalStockKind::BasicTyping => basic_typing(tr), OriginalStockKind::Cloze => cloze(tr), OriginalStockKind::ImageOcclusion => image_occlusion_notetype(tr), }) } pub(crate) fn basic(tr: &I18n) -> Notetype { let mut nt = empty_stock( NotetypeKind::Normal, OriginalStockKind::Basic, tr.notetypes_basic_name(), ); let front = tr.notetypes_front_field(); let back = tr.notetypes_back_field(); nt.add_field(front.as_ref()); nt.add_field(back.as_ref()); nt.add_template( tr.notetypes_card_1_name(), fieldref(front), format!( "{}\n\n
\n\n{}", fieldref("FrontSide"), fieldref(back), ), ); nt } pub(crate) fn basic_typing(tr: &I18n) -> Notetype { let mut nt = basic(tr); nt.config.original_stock_kind = OriginalStockKind::BasicTyping as i32; nt.name = tr.notetypes_basic_type_answer_name().into(); let front = tr.notetypes_front_field(); let back = tr.notetypes_back_field(); let tmpl = &mut nt.templates[0].config; tmpl.q_format = format!("{}\n\n{{{{type:{}}}}}", fieldref(front.as_ref()), back); tmpl.a_format = format!( "{}\n\n
\n\n{{{{type:{}}}}}", fieldref(front), back ); nt } pub(crate) fn basic_forward_reverse(tr: &I18n) -> Notetype { let mut nt = basic(tr); nt.config.original_stock_kind = OriginalStockKind::BasicAndReversed as i32; nt.name = tr.notetypes_basic_reversed_name().into(); let front = tr.notetypes_front_field(); let back = tr.notetypes_back_field(); nt.add_template( tr.notetypes_card_2_name(), fieldref(back), format!( "{}\n\n
\n\n{}", fieldref("FrontSide"), fieldref(front), ), ); nt } pub(crate) fn basic_optional_reverse(tr: &I18n) -> Notetype { let mut nt = basic_forward_reverse(tr); nt.config.original_stock_kind = OriginalStockKind::BasicOptionalReversed as i32; nt.name = tr.notetypes_basic_optional_reversed_name().into(); let addrev = tr.notetypes_add_reverse_field(); nt.add_field(addrev.as_ref()); let tmpl = &mut nt.templates[1].config; tmpl.q_format = format!("{{{{#{}}}}}{}{{{{/{}}}}}", addrev, tmpl.q_format, addrev); nt } pub(crate) fn cloze(tr: &I18n) -> Notetype { let mut nt = empty_stock( NotetypeKind::Cloze, OriginalStockKind::Cloze, tr.notetypes_cloze_name(), ); let text = tr.notetypes_text_field(); let mut config = nt.add_field(text.as_ref()); config.tag = Some(ClozeField::Text as u32); config.prevent_deletion = true; let back_extra = tr.notetypes_back_extra_field(); config = nt.add_field(back_extra.as_ref()); config.tag = Some(ClozeField::BackExtra as u32); let qfmt = format!("{{{{cloze:{text}}}}}"); let afmt = format!("{qfmt}
\n{{{{{back_extra}}}}}"); nt.add_template(nt.name.clone(), qfmt, afmt); nt } ================================================ FILE: rslib/src/notetype/styling.css ================================================ .card { font-family: arial; font-size: 20px; line-height: 1.5; text-align: center; color: black; background-color: white; } ================================================ FILE: rslib/src/notetype/templates.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::CardTemplateConfig; use super::CardTemplateProto; use crate::prelude::*; use crate::template::ParsedTemplate; #[derive(Debug, PartialEq, Clone)] pub struct CardTemplate { pub ord: Option, pub mtime_secs: TimestampSecs, pub usn: Usn, pub name: String, pub config: CardTemplateConfig, } impl CardTemplate { pub(crate) fn parsed_question(&self) -> Option { ParsedTemplate::from_text(&self.config.q_format).ok() } pub(crate) fn parsed_answer(&self) -> Option { ParsedTemplate::from_text(&self.config.a_format).ok() } pub(crate) fn parsed_question_format_for_browser(&self) -> Option { ParsedTemplate::from_text(&self.config.q_format_browser).ok() } pub(crate) fn parsed_answer_format_for_browser(&self) -> Option { ParsedTemplate::from_text(&self.config.a_format_browser).ok() } pub(crate) fn question_format_for_browser(&self) -> &str { if !self.config.q_format_browser.is_empty() { &self.config.q_format_browser } else { &self.config.q_format } } pub(crate) fn answer_format_for_browser(&self) -> &str { if !self.config.a_format_browser.is_empty() { &self.config.a_format_browser } else { &self.config.a_format } } pub(crate) fn target_deck_id(&self) -> Option { if self.config.target_deck_id > 0 { Some(DeckId(self.config.target_deck_id)) } else { None } } } impl From for CardTemplateProto { fn from(t: CardTemplate) -> Self { CardTemplateProto { ord: t.ord.map(Into::into), mtime_secs: t.mtime_secs.0, usn: t.usn.0, name: t.name, config: Some(t.config), } } } impl From for CardTemplate { fn from(t: CardTemplateProto) -> Self { CardTemplate { ord: t.ord.map(|n| n.val), mtime_secs: t.mtime_secs.into(), usn: t.usn.into(), name: t.name, config: t.config.unwrap_or_default(), } } } impl CardTemplate { pub fn new(name: S1, qfmt: S2, afmt: S3) -> Self where S1: Into, S2: Into, S3: Into, { CardTemplate { ord: None, name: name.into(), mtime_secs: TimestampSecs(0), usn: Usn(0), config: CardTemplateConfig { id: Some(rand::random()), q_format: qfmt.into(), a_format: afmt.into(), q_format_browser: "".into(), a_format_browser: "".into(), target_deck_id: 0, browser_font_name: "".into(), browser_font_size: 0, other: vec![], }, } } /// Return whether the name is valid. Remove quote characters if it leads to /// a valid name. pub(crate) fn fix_name(&mut self) -> Result<()> { let bad_chars = |c| c == '"'; require!(!self.name.is_empty(), "Empty template name"); let trimmed = self.name.replace(bad_chars, ""); require!(!trimmed.is_empty(), "Template name contains only quotes"); if self.name.len() != trimmed.len() { self.name = trimmed; } Ok(()) } } ================================================ FILE: rslib/src/notetype/undo.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::prelude::*; #[derive(Debug)] pub(crate) enum UndoableNotetypeChange { Added(Box), Updated(Box), Removed(Box), } impl Collection { pub(crate) fn undo_notetype_change(&mut self, change: UndoableNotetypeChange) -> Result<()> { match change { UndoableNotetypeChange::Added(nt) => self.remove_notetype_only_undoable(*nt), UndoableNotetypeChange::Updated(nt) => { let current = self .storage .get_notetype(nt.id)? .or_invalid("notetype disappeared")?; self.update_notetype_undoable(&nt, current) } UndoableNotetypeChange::Removed(nt) => self.restore_deleted_notetype(*nt), } } pub(crate) fn remove_notetype_only_undoable(&mut self, notetype: Notetype) -> Result<()> { self.state.notetype_cache.remove(¬etype.id); self.storage.remove_notetype(notetype.id)?; self.save_undo(UndoableNotetypeChange::Removed(Box::new(notetype))); Ok(()) } pub(super) fn add_notetype_undoable( &mut self, notetype: &mut Notetype, ) -> Result<(), AnkiError> { self.storage.add_notetype(notetype)?; self.save_undo(UndoableNotetypeChange::Added(Box::new(notetype.clone()))); Ok(()) } /// Caller must ensure [NotetypeId] is unique. pub(crate) fn add_notetype_with_unique_id_undoable( &mut self, notetype: &Notetype, ) -> Result<()> { self.storage .add_or_update_notetype_with_existing_id(notetype)?; self.save_undo(UndoableNotetypeChange::Added(Box::new(notetype.clone()))); Ok(()) } pub(super) fn update_notetype_undoable( &mut self, notetype: &Notetype, original: Notetype, ) -> Result<()> { self.state.notetype_cache.remove(¬etype.id); self.save_undo(UndoableNotetypeChange::Updated(Box::new(original))); self.storage .add_or_update_notetype_with_existing_id(notetype) } fn restore_deleted_notetype(&mut self, notetype: Notetype) -> Result<()> { self.storage .add_or_update_notetype_with_existing_id(¬etype)?; self.save_undo(UndoableNotetypeChange::Added(Box::new(notetype))); Ok(()) } } ================================================ FILE: rslib/src/ops.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::prelude::*; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Op { Custom(String), AddDeck, AddNote, AddNotetype, AnswerCard, BuildFilteredDeck, Bury, ChangeNotetype, ClearUnusedTags, CreateCustomStudy, EmptyCards, EmptyFilteredDeck, FindAndReplace, ImageOcclusion, Import, RebuildFilteredDeck, RemoveDeck, RemoveNote, RemoveNotetype, RemoveTag, RenameDeck, ReparentDeck, RenameTag, ReparentTag, ScheduleAsNew, SetCardDeck, SetDueDate, GradeNow, SetFlag, SortCards, Suspend, ToggleLoadBalancer, UnburyUnsuspend, UpdateCard, UpdateConfig, UpdateDeck, UpdateDeckConfig, UpdateNote, UpdatePreferences, UpdateTag, UpdateNotetype, SetCurrentDeck, /// Does not register changes in undo queue, but does not clear the current /// queue either. SkipUndo, } impl Op { pub fn describe(&self, tr: &I18n) -> String { match self { Op::AddDeck => tr.actions_add_deck(), Op::AddNote => tr.actions_add_note(), Op::AnswerCard => tr.actions_answer_card(), Op::Bury => tr.studying_bury(), Op::CreateCustomStudy => tr.actions_custom_study(), Op::EmptyCards => tr.actions_empty_cards(), Op::Import => tr.actions_import(), Op::RemoveDeck => tr.decks_delete_deck(), Op::RemoveNote => tr.studying_delete_note(), Op::RenameDeck => tr.actions_rename_deck(), Op::ScheduleAsNew => tr.actions_forget_card(), Op::SetDueDate => tr.actions_set_due_date(), Op::ToggleLoadBalancer => tr.actions_toggle_load_balancer(), Op::GradeNow => tr.actions_grade_now(), Op::Suspend => tr.studying_suspend(), Op::UnburyUnsuspend => tr.actions_unbury_unsuspend(), Op::UpdateCard => tr.actions_update_card(), Op::UpdateDeck => tr.actions_update_deck(), Op::UpdateNote => tr.actions_update_note(), Op::UpdatePreferences => tr.preferences_preferences(), Op::UpdateTag => tr.actions_update_tag(), Op::SetCardDeck => tr.browsing_change_deck(), Op::SetFlag => tr.actions_set_flag(), Op::FindAndReplace => tr.browsing_find_and_replace(), Op::ClearUnusedTags => tr.browsing_clear_unused_tags(), Op::SortCards => tr.actions_reposition(), Op::RenameTag => tr.actions_rename_tag(), Op::RemoveTag => tr.actions_remove_tag(), Op::ReparentTag => tr.actions_rename_tag(), Op::ReparentDeck => tr.actions_rename_deck(), Op::BuildFilteredDeck => tr.actions_build_filtered_deck(), Op::RebuildFilteredDeck => tr.actions_build_filtered_deck(), Op::EmptyFilteredDeck => tr.studying_empty(), Op::SetCurrentDeck => tr.browsing_select_deck(), Op::UpdateDeckConfig => tr.deck_config_title(), Op::AddNotetype => tr.actions_add_notetype(), Op::RemoveNotetype => tr.actions_remove_notetype(), Op::UpdateNotetype => tr.actions_update_notetype(), Op::UpdateConfig => tr.actions_update_config(), Op::Custom(name) => name.into(), Op::ChangeNotetype => tr.browsing_change_notetype(), Op::SkipUndo => return "".to_string(), Op::ImageOcclusion => tr.notetypes_image_occlusion_name(), } .into() } } #[derive(Debug, PartialEq, Eq, Default, Clone, Copy)] pub struct StateChanges { pub card: bool, pub note: bool, pub deck: bool, pub tag: bool, pub notetype: bool, pub config: bool, pub deck_config: bool, pub mtime: bool, } #[derive(Debug, PartialEq, Eq, Clone)] pub struct OpChanges { pub op: Op, pub changes: StateChanges, } #[derive(Debug, PartialEq, Eq)] pub struct OpOutput { pub output: T, pub changes: OpChanges, } impl OpOutput { pub(crate) fn map(self, func: F) -> OpOutput where F: FnOnce(T) -> N, { OpOutput { output: func(self.output), changes: self.changes, } } } impl OpChanges { #[cfg(test)] pub fn had_change(&self) -> bool { let c = &self.changes; c.card || c.config || c.deck || c.deck_config || c.note || c.notetype || c.tag || c.mtime } // These routines should return true even if the GUI may have // special handling for an action, since we need to do the right // thing when undoing, and if multiple windows of the same type are // open. pub fn requires_browser_table_redraw(&self) -> bool { let c = &self.changes; c.card || c.notetype || c.config || (c.note && self.op != Op::AddNote) || c.deck } pub fn requires_browser_sidebar_redraw(&self) -> bool { let c = &self.changes; c.tag || c.deck || c.notetype || c.config } pub fn requires_note_text_redraw(&self) -> bool { let c = &self.changes; c.note || c.notetype } pub fn requires_study_queue_rebuild(&self) -> bool { let c = &self.changes; (c.card && self.op != Op::SetFlag) || c.deck || (c.config && matches!( self.op, Op::SetCurrentDeck | Op::UpdatePreferences | Op::UpdateDeckConfig | Op::ToggleLoadBalancer )) || c.deck_config } } ================================================ FILE: rslib/src/preferences.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::config::preferences::scheduling::NewReviewMix as NewRevMixPB; use anki_proto::config::preferences::Editing; use anki_proto::config::preferences::Reviewing; use anki_proto::config::preferences::Scheduling; use anki_proto::config::Preferences; use crate::collection::Collection; use crate::config::BoolKey; use crate::config::StringKey; use crate::error::Result; use crate::prelude::*; use crate::scheduler::timing::local_minutes_west_for_stamp; impl Collection { pub fn get_preferences(&self) -> Result { Ok(Preferences { scheduling: Some(self.get_scheduling_preferences()?), reviewing: Some(self.get_reviewing_preferences()?), editing: Some(self.get_editing_preferences()?), backups: Some(self.get_backup_limits()), }) } pub fn set_preferences(&mut self, prefs: Preferences) -> Result> { self.transact(Op::UpdatePreferences, |col| { col.set_preferences_inner(prefs) }) } fn set_preferences_inner(&mut self, prefs: Preferences) -> Result<()> { if let Some(sched) = prefs.scheduling { self.set_scheduling_preferences(sched)?; } if let Some(reviewing) = prefs.reviewing { self.set_reviewing_preferences(reviewing)?; } if let Some(editing) = prefs.editing { self.set_editing_preferences(editing)?; } if let Some(backups) = prefs.backups { self.set_backup_limits(backups)?; } Ok(()) } pub fn get_scheduling_preferences(&self) -> Result { Ok(Scheduling { rollover: self.rollover_for_current_scheduler()? as u32, learn_ahead_secs: self.learn_ahead_secs(), new_review_mix: match self.get_new_review_mix() { crate::config::NewReviewMix::Mix => NewRevMixPB::Distribute, crate::config::NewReviewMix::ReviewsFirst => NewRevMixPB::ReviewsFirst, crate::config::NewReviewMix::NewFirst => NewRevMixPB::NewFirst, } as i32, new_timezone: self.get_creation_utc_offset().is_some(), day_learn_first: self.get_config_bool(BoolKey::ShowDayLearningCardsFirst), }) } pub(crate) fn set_scheduling_preferences(&mut self, settings: Scheduling) -> Result<()> { let s = settings; self.set_config_bool_inner(BoolKey::ShowDayLearningCardsFirst, s.day_learn_first)?; self.set_learn_ahead_secs(s.learn_ahead_secs)?; self.set_new_review_mix(match s.new_review_mix() { NewRevMixPB::Distribute => crate::config::NewReviewMix::Mix, NewRevMixPB::NewFirst => crate::config::NewReviewMix::NewFirst, NewRevMixPB::ReviewsFirst => crate::config::NewReviewMix::ReviewsFirst, })?; let created = self.storage.creation_stamp()?; if self.rollover_for_current_scheduler()? != s.rollover as u8 { self.set_rollover_for_current_scheduler(s.rollover as u8)?; } if s.new_timezone { if self.get_creation_utc_offset().is_none() { self.set_creation_utc_offset(Some(local_minutes_west_for_stamp(created)?))?; } } else { self.set_creation_utc_offset(None)?; } Ok(()) } pub fn get_reviewing_preferences(&self) -> Result { Ok(Reviewing { hide_audio_play_buttons: self.get_config_bool(BoolKey::HideAudioPlayButtons), interrupt_audio_when_answering: self .get_config_bool(BoolKey::InterruptAudioWhenAnswering), show_remaining_due_counts: self.get_config_bool(BoolKey::ShowRemainingDueCountsInStudy), show_intervals_on_buttons: self .get_config_bool(BoolKey::ShowIntervalsAboveAnswerButtons), time_limit_secs: self.get_answer_time_limit_secs(), load_balancer_enabled: self.get_config_bool(BoolKey::LoadBalancerEnabled), fsrs_short_term_with_steps_enabled: self .get_config_bool(BoolKey::FsrsShortTermWithStepsEnabled), }) } pub(crate) fn set_reviewing_preferences(&mut self, settings: Reviewing) -> Result<()> { let s = settings; self.set_config_bool_inner(BoolKey::HideAudioPlayButtons, s.hide_audio_play_buttons)?; self.set_config_bool_inner( BoolKey::InterruptAudioWhenAnswering, s.interrupt_audio_when_answering, )?; self.set_config_bool_inner( BoolKey::ShowRemainingDueCountsInStudy, s.show_remaining_due_counts, )?; self.set_config_bool_inner( BoolKey::ShowIntervalsAboveAnswerButtons, s.show_intervals_on_buttons, )?; self.set_answer_time_limit_secs(s.time_limit_secs)?; self.set_config_bool_inner(BoolKey::LoadBalancerEnabled, s.load_balancer_enabled)?; self.set_config_bool_inner( BoolKey::FsrsShortTermWithStepsEnabled, s.fsrs_short_term_with_steps_enabled, )?; Ok(()) } pub fn get_editing_preferences(&self) -> Result { Ok(Editing { adding_defaults_to_current_deck: self .get_config_bool(BoolKey::AddingDefaultsToCurrentDeck), paste_images_as_png: self.get_config_bool(BoolKey::PasteImagesAsPng), paste_strips_formatting: self.get_config_bool(BoolKey::PasteStripsFormatting), default_search_text: self.get_config_string(StringKey::DefaultSearchText), ignore_accents_in_search: self.get_config_bool(BoolKey::IgnoreAccentsInSearch), render_latex: self.get_config_bool(BoolKey::RenderLatex), }) } pub(crate) fn set_editing_preferences(&mut self, settings: Editing) -> Result<()> { let s = settings; self.set_config_bool_inner( BoolKey::AddingDefaultsToCurrentDeck, s.adding_defaults_to_current_deck, )?; self.set_config_bool_inner(BoolKey::PasteImagesAsPng, s.paste_images_as_png)?; self.set_config_bool_inner(BoolKey::PasteStripsFormatting, s.paste_strips_formatting)?; self.set_config_string_inner(StringKey::DefaultSearchText, &s.default_search_text)?; self.set_config_bool_inner(BoolKey::IgnoreAccentsInSearch, s.ignore_accents_in_search)?; self.set_config_bool_inner(BoolKey::RenderLatex, s.render_latex)?; Ok(()) } } ================================================ FILE: rslib/src/prelude.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub use anki_i18n::I18n; pub use snafu::ResultExt; pub use crate::card::Card; pub use crate::card::CardId; pub use crate::collection::Collection; pub use crate::config::BoolKey; pub use crate::deckconfig::DeckConfig; pub use crate::deckconfig::DeckConfigId; pub use crate::decks::Deck; pub use crate::decks::DeckId; pub use crate::decks::DeckKind; pub use crate::decks::NativeDeckName; pub use crate::error::AnkiError; pub use crate::error::OrInvalid; pub use crate::error::OrNotFound; pub use crate::error::Result; pub use crate::invalid_input; pub use crate::media::Sha1Hash; pub use crate::notes::Note; pub use crate::notes::NoteId; pub use crate::notetype::Notetype; pub use crate::notetype::NotetypeId; pub use crate::ops::Op; pub use crate::ops::OpChanges; pub use crate::ops::OpOutput; pub use crate::require; pub use crate::revlog::RevlogId; pub use crate::search::SearchBuilder; pub use crate::search::TryIntoSearch; #[cfg(test)] pub(crate) use crate::tests::*; pub use crate::timestamp::TimestampMillis; pub use crate::timestamp::TimestampSecs; pub(crate) use crate::types::IntoNewtypeVec; pub use crate::types::Usn; ================================================ FILE: rslib/src/progress.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::marker::PhantomData; use std::sync::Arc; use std::sync::Mutex; use anki_i18n::I18n; use anki_proto::collection::progress::Value; use crate::dbcheck::DatabaseCheckProgress; use crate::error::AnkiError; use crate::error::Result; use crate::import_export::ExportProgress; use crate::import_export::ImportProgress; use crate::prelude::Collection; use crate::scheduler::fsrs::memory_state::ComputeMemoryProgress; use crate::scheduler::fsrs::params::ComputeParamsProgress; use crate::scheduler::fsrs::retention::ComputeRetentionProgress; use crate::sync::collection::normal::NormalSyncProgress; use crate::sync::collection::progress::FullSyncProgress; use crate::sync::collection::progress::SyncStage; use crate::sync::media::progress::MediaCheckProgress; use crate::sync::media::progress::MediaSyncProgress; /// Stores progress state that can be updated cheaply, and will update a /// Mutex-protected copy that other threads can check, if more than 0.1 /// secs has elapsed since the previous update. /// If another thread has set the `want_abort` flag on the shared state, /// then the next non-throttled update will fail with [AnkiError::Interrupted]. /// Automatically updates the shared state on creation, with the default /// value for the type. #[derive(Debug, Default)] pub struct ThrottlingProgressHandler + Default> { pub(crate) state: P, shared_state: Arc>, last_shared_update: coarsetime::Instant, } impl + Default + Clone> ThrottlingProgressHandler

{ pub(crate) fn new(shared_state: Arc>) -> Self { let initial = P::default(); { let mut guard = shared_state.lock().unwrap(); guard.last_progress = Some(initial.clone().into()); guard.want_abort = false; } Self { shared_state, state: initial, ..Default::default() } } /// Overwrite the currently-stored state. This does not throttle, and should /// be used when you want to ensure the UI state gets updated, and /// ensure that the abort flag is checked between expensive steps. pub(crate) fn set(&mut self, progress: P) -> Result<()> { self.update(false, |state| *state = progress) } /// Mutate the currently-stored state, and maybe update shared state. pub(crate) fn update(&mut self, throttle: bool, mutator: impl FnOnce(&mut P)) -> Result<()> { mutator(&mut self.state); let now = coarsetime::Instant::now(); if throttle && now.duration_since(self.last_shared_update).as_f64() < 0.1 { return Ok(()); } self.last_shared_update = now; let mut guard = self.shared_state.lock().unwrap(); guard.last_progress.replace(self.state.clone().into()); if std::mem::take(&mut guard.want_abort) { Err(AnkiError::Interrupted) } else { Ok(()) } } /// Check the abort flag, and trigger a UI update if it was throttled. pub(crate) fn check_cancelled(&mut self) -> Result<()> { self.set(self.state.clone()) } /// An alternative to incrementor() below, that can be used across function /// calls easily, as it continues from the previous state. pub(crate) fn increment(&mut self, accessor: impl Fn(&mut P) -> &mut usize) -> Result<()> { let field = accessor(&mut self.state); *field += 1; if *field % 17 == 0 { self.update(true, |_| ())?; } Ok(()) } /// Returns an [Incrementor] with an `increment()` function for use in /// loops. pub(crate) fn incrementor<'inc, 'progress: 'inc, 'map: 'inc>( &'progress mut self, mut count_map: impl 'map + FnMut(usize) -> P, ) -> Incrementor<'inc, impl FnMut(usize) -> Result<()> + 'inc> { Incrementor::new(move |u| self.update(true, |p| *p = count_map(u))) } /// Stopgap for returning a progress fn compliant with the media code. pub(crate) fn media_db_fn( &mut self, count_map: impl 'static + Fn(usize) -> P, ) -> Result bool + '_> where P: Into, { Ok(move |count| self.update(true, |p| *p = count_map(count)).is_ok()) } } #[derive(Default, Debug)] pub struct ProgressState { pub want_abort: bool, pub last_progress: Option, } impl ProgressState { pub fn reset(&mut self) { self.want_abort = false; self.last_progress = None; } } #[derive(Clone, Copy, Debug)] pub enum Progress { MediaSync(MediaSyncProgress), MediaCheck(MediaCheckProgress), FullSync(FullSyncProgress), NormalSync(NormalSyncProgress), DatabaseCheck(DatabaseCheckProgress), Import(ImportProgress), Export(ExportProgress), ComputeParams(ComputeParamsProgress), ComputeRetention(ComputeRetentionProgress), ComputeMemory(ComputeMemoryProgress), } pub(crate) fn progress_to_proto( progress: Option, tr: &I18n, ) -> anki_proto::collection::Progress { let progress = if let Some(progress) = progress { match progress { Progress::MediaSync(p) => Value::MediaSync(media_sync_progress(p, tr)), Progress::MediaCheck(n) => Value::MediaCheck(tr.media_check_checked(n.checked).into()), Progress::FullSync(p) => Value::FullSync(anki_proto::collection::progress::FullSync { transferred: p.transferred_bytes as u32, total: p.total_bytes as u32, }), Progress::NormalSync(p) => { let stage = match p.stage { SyncStage::Connecting => tr.sync_syncing(), SyncStage::Syncing => tr.sync_syncing(), SyncStage::Finalizing => tr.sync_checking(), } .to_string(); let added = tr .sync_added_updated_count(p.local_update, p.remote_update) .into(); let removed = tr .sync_media_removed_count(p.local_remove, p.remote_remove) .into(); Value::NormalSync(anki_proto::collection::progress::NormalSync { stage, added, removed, }) } Progress::DatabaseCheck(p) => { let mut stage_total = 0; let mut stage_current = 0; let stage = match p { DatabaseCheckProgress::Integrity => tr.database_check_checking_integrity(), DatabaseCheckProgress::Optimize => tr.database_check_rebuilding(), DatabaseCheckProgress::Cards => tr.database_check_checking_cards(), DatabaseCheckProgress::Notes { current, total } => { stage_total = total; stage_current = current; tr.database_check_checking_notes() } DatabaseCheckProgress::History => tr.database_check_checking_history(), } .to_string(); Value::DatabaseCheck(anki_proto::collection::progress::DatabaseCheck { stage, stage_total: stage_total as u32, stage_current: stage_current as u32, }) } Progress::Import(progress) => Value::Importing( match progress { ImportProgress::File => tr.importing_importing_file(), ImportProgress::Media(n) => tr.importing_processed_media_file(n), ImportProgress::MediaCheck(n) => tr.media_check_checked(n), ImportProgress::Notes(n) => tr.importing_processed_notes(n), ImportProgress::Extracting => tr.importing_extracting(), ImportProgress::Gathering => tr.importing_gathering(), } .into(), ), Progress::Export(progress) => Value::Exporting( match progress { ExportProgress::File => tr.exporting_exporting_file(), ExportProgress::Media(n) => tr.exporting_processed_media_files(n), ExportProgress::Notes(n) => tr.importing_processed_notes(n), ExportProgress::Cards(n) => tr.importing_processed_cards(n), ExportProgress::Gathering => tr.importing_gathering(), } .into(), ), Progress::ComputeParams(progress) => { Value::ComputeParams(anki_proto::collection::ComputeParamsProgress { current: progress.current_iteration, total: progress.total_iterations, reviews: progress.reviews, current_preset: progress.current_preset, total_presets: progress.total_presets, }) } Progress::ComputeRetention(progress) => { Value::ComputeRetention(anki_proto::collection::ComputeRetentionProgress { current: progress.current, total: progress.total, }) } Progress::ComputeMemory(progress) => { Value::ComputeMemory(anki_proto::collection::ComputeMemoryProgress { current_cards: progress.current_cards, total_cards: progress.total_cards, label: tr .deck_config_updating_cards(progress.current_cards, progress.total_cards) .into(), }) } } } else { Value::None(anki_proto::generic::Empty {}) }; anki_proto::collection::Progress { value: Some(progress), } } fn media_sync_progress(p: MediaSyncProgress, tr: &I18n) -> anki_proto::sync::MediaSyncProgress { anki_proto::sync::MediaSyncProgress { checked: tr.sync_media_checked_count(p.checked).into(), added: tr .sync_media_added_count(p.uploaded_files, p.downloaded_files) .into(), removed: tr .sync_media_removed_count(p.uploaded_deletions, p.downloaded_deletions) .into(), } } impl From for Progress { fn from(p: FullSyncProgress) -> Self { Progress::FullSync(p) } } impl From for Progress { fn from(p: MediaSyncProgress) -> Self { Progress::MediaSync(p) } } impl From for Progress { fn from(p: MediaCheckProgress) -> Self { Progress::MediaCheck(p) } } impl From for Progress { fn from(p: NormalSyncProgress) -> Self { Progress::NormalSync(p) } } impl From for Progress { fn from(p: DatabaseCheckProgress) -> Self { Progress::DatabaseCheck(p) } } impl From for Progress { fn from(p: ImportProgress) -> Self { Progress::Import(p) } } impl From for Progress { fn from(p: ExportProgress) -> Self { Progress::Export(p) } } impl From for Progress { fn from(p: ComputeParamsProgress) -> Self { Progress::ComputeParams(p) } } impl From for Progress { fn from(p: ComputeRetentionProgress) -> Self { Progress::ComputeRetention(p) } } impl From for Progress { fn from(p: ComputeMemoryProgress) -> Self { Progress::ComputeMemory(p) } } impl Collection { pub fn new_progress_handler + Default + Clone>( &self, ) -> ThrottlingProgressHandler

{ ThrottlingProgressHandler::new(self.state.progress.clone()) } pub(crate) fn clear_progress(&mut self) { self.state.progress.lock().unwrap().reset(); } } pub(crate) struct Incrementor<'f, F: 'f + FnMut(usize) -> Result<()>> { update_fn: F, count: usize, update_interval: usize, _phantom: PhantomData<&'f ()>, } impl<'f, F: 'f + FnMut(usize) -> Result<()>> Incrementor<'f, F> { fn new(update_fn: F) -> Self { Self { update_fn, count: 0, update_interval: 17, _phantom: PhantomData, } } /// Increments the progress counter, periodically triggering an update. /// Returns [AnkiError::Interrupted] if the operation should be cancelled. pub(crate) fn increment(&mut self) -> Result<()> { self.count += 1; if self.count % self.update_interval != 0 { return Ok(()); } (self.update_fn)(self.count) } pub(crate) fn count(&self) -> usize { self.count } } ================================================ FILE: rslib/src/revlog/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub(crate) mod undo; use num_enum::TryFromPrimitive; use serde::Deserialize; use serde_repr::Deserialize_repr; use serde_repr::Serialize_repr; use serde_tuple::Serialize_tuple; use crate::define_newtype; use crate::prelude::*; use crate::serde::default_on_invalid; use crate::serde::deserialize_int_from_number; define_newtype!(RevlogId, i64); impl RevlogId { pub fn new() -> Self { RevlogId(TimestampMillis::now().0) } pub fn as_secs(self) -> TimestampSecs { TimestampSecs(self.0 / 1000) } } impl From for RevlogId { fn from(m: TimestampMillis) -> Self { RevlogId(m.0) } } #[derive(Serialize_tuple, Deserialize, Debug, Default, PartialEq, Eq, Clone)] pub struct RevlogEntry { pub id: RevlogId, pub cid: CardId, pub usn: Usn, /// - In the V1 scheduler, 3 represents easy in the learning case. /// - 0 represents manual rescheduling. #[serde(rename = "ease")] pub button_chosen: u8, /// Positive values are in days, negative values in seconds. #[serde(rename = "ivl", deserialize_with = "deserialize_int_from_number")] pub interval: i32, /// Positive values are in days, negative values in seconds. #[serde(rename = "lastIvl", deserialize_with = "deserialize_int_from_number")] pub last_interval: i32, /// Card's ease after answering, stored as 10x the %, eg 2500 represents /// 250%. When FSRS is active, difficulty is normalized to 100-1100 range, /// so a 0 difficulty can be distinguished from SM-2 learning. #[serde(rename = "factor", deserialize_with = "deserialize_int_from_number")] pub ease_factor: u32, /// Amount of milliseconds taken to answer the card. #[serde(rename = "time", deserialize_with = "deserialize_int_from_number")] pub taken_millis: u32, #[serde(rename = "type", default, deserialize_with = "default_on_invalid")] pub review_kind: RevlogReviewKind, } #[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Eq, TryFromPrimitive, Clone, Copy)] #[repr(u8)] #[derive(Default)] pub enum RevlogReviewKind { #[default] Learning = 0, Review = 1, Relearning = 2, /// Old Anki versions called this "Cram" or "Early". It's assigned when /// reviewing cards before they're due, or when rescheduling is /// disabled. Filtered = 3, Manual = 4, Rescheduled = 5, } impl RevlogEntry { pub(crate) fn interval_secs(&self) -> u32 { u32::try_from(if self.interval > 0 { self.interval.saturating_mul(86_400) } else { self.interval.saturating_mul(-1) }) .unwrap() } pub(crate) fn last_interval_secs(&self) -> u32 { u32::try_from(if self.last_interval > 0 { self.last_interval.saturating_mul(86_400) } else { self.last_interval.saturating_mul(-1) }) .unwrap() } /// Returns true if this entry represents a reset operation. /// These entries are created when a card is reset using /// [`Collection::reschedule_cards_as_new`]. /// The 0 value of `ease_factor` differentiates it /// from entry created by [`Collection::set_due_date`] that has /// `RevlogReviewKind::Manual` but non-zero `ease_factor`. pub(crate) fn is_reset(&self) -> bool { self.review_kind == RevlogReviewKind::Manual && self.ease_factor == 0 } /// Returns true if this entry represents a cramming operation. /// These entries are created when a card is reviewed in a /// filtered deck with "Reschedule cards based on my answers /// in this deck" disabled. /// [`crate::scheduler::answering::CardStateUpdater::apply_preview_state`]. /// The 0 value of `ease_factor` distinguishes it from the entry /// created when a card is reviewed before its due date in a /// filtered deck with reschedule enabled or using Grade Now. pub(crate) fn is_cramming(&self) -> bool { self.review_kind == RevlogReviewKind::Filtered && self.ease_factor == 0 } pub(crate) fn has_rating(&self) -> bool { self.button_chosen > 0 } /// Returns true if the review entry is not manually rescheduled and not /// cramming. Used to filter out entries that shouldn't be considered /// for statistics and scheduling. pub(crate) fn has_rating_and_affects_scheduling(&self) -> bool { // not rescheduled/set due date/reset self.has_rating() // not cramming && !self.is_cramming() } } impl Collection { // set due date or reset pub(crate) fn log_manually_scheduled_review( &mut self, card: &Card, original_interval: u32, usn: Usn, ) -> Result<()> { self.log_scheduled_review(card, original_interval, usn, RevlogReviewKind::Manual) } // reschedule cards on change pub(crate) fn log_rescheduled_review( &mut self, card: &Card, original_interval: u32, usn: Usn, ) -> Result<()> { self.log_scheduled_review(card, original_interval, usn, RevlogReviewKind::Rescheduled) } fn log_scheduled_review( &mut self, card: &Card, original_interval: u32, usn: Usn, review_kind: RevlogReviewKind, ) -> Result<()> { let ease_factor = u32::from( card.memory_state .map(|s| (s.difficulty_shifted() * 1000.) as u16) .unwrap_or(card.ease_factor), ); let entry = RevlogEntry { id: RevlogId::new(), cid: card.id, usn, button_chosen: 0, interval: i32::try_from(card.interval).unwrap_or(i32::MAX), last_interval: i32::try_from(original_interval).unwrap_or(i32::MAX), ease_factor, taken_millis: 0, review_kind, }; self.add_revlog_entry_undoable(entry)?; Ok(()) } } ================================================ FILE: rslib/src/revlog/undo.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::RevlogEntry; use crate::prelude::*; #[derive(Debug)] pub(crate) enum UndoableRevlogChange { Added(Box), Removed(Box), } impl Collection { pub(crate) fn undo_revlog_change(&mut self, change: UndoableRevlogChange) -> Result<()> { match change { UndoableRevlogChange::Added(revlog) => { self.storage.remove_revlog_entry(revlog.id)?; self.save_undo(UndoableRevlogChange::Removed(revlog)); Ok(()) } UndoableRevlogChange::Removed(revlog) => { self.storage.add_revlog_entry(&revlog, false)?; self.save_undo(UndoableRevlogChange::Added(revlog)); Ok(()) } } } /// Add the provided revlog entry, modifying the ID if it is not unique. pub(crate) fn add_revlog_entry_undoable(&mut self, mut entry: RevlogEntry) -> Result { entry.id = self.storage.add_revlog_entry(&entry, true)?.unwrap(); let id = entry.id; self.save_undo(UndoableRevlogChange::Added(Box::new(entry))); Ok(id) } /// Add the provided revlog entry, if its ID is unique. pub(crate) fn add_revlog_entry_if_unique_undoable(&mut self, entry: RevlogEntry) -> Result<()> { if self.storage.add_revlog_entry(&entry, false)?.is_some() { self.save_undo(UndoableRevlogChange::Added(Box::new(entry))); } Ok(()) } } ================================================ FILE: rslib/src/scheduler/answering/current.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::get_fuzz_seed_for_id_and_reps; use super::CardStateUpdater; use crate::card::CardQueue; use crate::card::CardType; use crate::decks::DeckKind; use crate::scheduler::states::CardState; use crate::scheduler::states::LearnState; use crate::scheduler::states::NewState; use crate::scheduler::states::NormalState; use crate::scheduler::states::PreviewState; use crate::scheduler::states::RelearnState; use crate::scheduler::states::ReschedulingFilterState; use crate::scheduler::states::ReviewState; impl CardStateUpdater { pub(crate) fn current_card_state(&self) -> CardState { let due = match &self.deck.kind { DeckKind::Normal(_) => { // if not in a filtered deck, ensure due time is not before today, // which avoids tripping up test_nextIvl() in the Python tests if matches!(self.card.ctype, CardType::Review) { self.card.due.min(self.timing.days_elapsed as i32) } else { self.card.due } } DeckKind::Filtered(_) => { if self.card.original_due != 0 { self.card.original_due } else { self.card.due } } }; let normal_state = self.normal_study_state(due); match &self.deck.kind { // normal decks have normal state DeckKind::Normal(_) => normal_state.into(), // filtered decks wrap the normal state DeckKind::Filtered(filtered) => { if filtered.reschedule { ReschedulingFilterState { original_state: normal_state, } .into() } else { PreviewState { scheduled_secs: filtered.preview_again_secs, finished: false, } .into() } } } } fn normal_study_state(&self, due: i32) -> NormalState { let interval = self.card.interval; let lapses = self.card.lapses; let ease_factor = self.card.ease_factor(); let remaining_steps = self.card.remaining_steps(); let memory_state = self.card.memory_state; let elapsed_secs = |last_ivl: u32| { match self.card.queue { CardQueue::Learn => { // Decrease reps by 1 to get correct seed for fuzz. // If the fuzz calculation changes, this will break. let last_ivl_with_fuzz = self.learning_ivl_with_fuzz( get_fuzz_seed_for_id_and_reps(self.card.id, self.card.reps.wrapping_sub(1)), last_ivl, ); let last_answered_time = due as i64 - last_ivl_with_fuzz as i64; (self.now.0 - last_answered_time) as u32 } CardQueue::DayLearn => { let days_since_col_creation = self.timing.days_elapsed as i32; // Need .max(1) for same day learning cards pushed to the next day. // 86_400 is the number of seconds in a day. let last_ivl_as_days = (last_ivl / 86_400).max(1) as i32; let elapsed_days = days_since_col_creation - due + last_ivl_as_days; (elapsed_days * 86_400) as u32 } _ => 0, // Not used for other card queues. } }; match self.card.ctype { CardType::New => NormalState::New(NewState { position: due.max(0) as u32, }), CardType::Learn => { let last_ivl = self.learn_steps().current_delay_secs(remaining_steps); LearnState { scheduled_secs: last_ivl, remaining_steps, elapsed_secs: elapsed_secs(last_ivl), memory_state, } } .into(), CardType::Review => ReviewState { scheduled_days: interval, elapsed_days: ((interval as i32) - (due - self.timing.days_elapsed as i32)).max(0) as u32, ease_factor, lapses, leeched: false, memory_state, } .into(), CardType::Relearn => { let last_ivl = self.relearn_steps().current_delay_secs(remaining_steps); RelearnState { learning: LearnState { scheduled_secs: last_ivl, elapsed_secs: elapsed_secs(last_ivl), remaining_steps, memory_state, }, review: ReviewState { scheduled_days: interval, elapsed_days: interval, ease_factor, lapses, leeched: false, memory_state, }, } } .into(), } } } ================================================ FILE: rslib/src/scheduler/answering/learning.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use rand::prelude::*; use rand::rngs::StdRng; use super::CardStateUpdater; use super::RevlogEntryPartial; use crate::card::CardQueue; use crate::card::CardType; use crate::prelude::*; use crate::scheduler::states::CardState; use crate::scheduler::states::IntervalKind; use crate::scheduler::states::LearnState; use crate::scheduler::states::NewState; impl CardStateUpdater { pub(super) fn apply_new_state( &mut self, current: CardState, next: NewState, ) -> RevlogEntryPartial { self.card.ctype = CardType::New; self.card.queue = CardQueue::New; self.card.due = next.position as i32; self.card.original_position = None; self.card.memory_state = None; RevlogEntryPartial::new( current, next.into(), self.card .memory_state .map(|d| d.difficulty_shifted()) .unwrap_or_default(), self.secs_until_rollover(), ) } pub(super) fn apply_learning_state( &mut self, current: CardState, next: LearnState, ) -> RevlogEntryPartial { self.card.remaining_steps = next.remaining_steps; self.card.ctype = CardType::Learn; if let Some(position) = current.new_position() { self.card.original_position = Some(position) } self.card.memory_state = next.memory_state; let interval = next .interval_kind() .maybe_as_days(self.secs_until_rollover()); match interval { IntervalKind::InSecs(secs) => { self.card.queue = CardQueue::Learn; self.card.due = self.fuzzed_next_learning_timestamp(secs); } IntervalKind::InDays(days) => { self.card.queue = CardQueue::DayLearn; self.card.due = (self.timing.days_elapsed + days) as i32; } } RevlogEntryPartial::new( current, next.into(), self.card .memory_state .map(|d| d.difficulty_shifted()) .unwrap_or_default(), self.secs_until_rollover(), ) } /// Adds secs + fuzz to current time pub(super) fn fuzzed_next_learning_timestamp(&self, secs: u32) -> i32 { TimestampSecs::now().0 as i32 + self.learning_ivl_with_fuzz(self.fuzz_seed, secs) as i32 } /// Add up to 25% increase to seconds, but no more than 5 minutes. pub(super) fn learning_ivl_with_fuzz(&self, input_seed: Option, secs: u32) -> u32 { if let Some(seed) = input_seed { let mut rng = StdRng::seed_from_u64(seed); let upper_exclusive = secs + ((secs as f32) * 0.25).min(300.0).floor() as u32; if secs >= upper_exclusive { secs } else { rng.random_range(secs..upper_exclusive) } } else { secs } } } ================================================ FILE: rslib/src/scheduler/answering/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod current; mod learning; mod preview; mod relearning; mod review; mod revlog; use fsrs::NextStates; use fsrs::FSRS; use rand::prelude::*; use rand::rngs::StdRng; use revlog::RevlogEntryPartial; use super::fsrs::params::ignore_revlogs_before_ms_from_config; use super::queue::BuryMode; use super::states::load_balancer::LoadBalancerContext; use super::states::steps::LearningSteps; use super::states::CardState; use super::states::FilteredState; use super::states::NormalState; use super::states::SchedulingStates; use super::states::StateContext; use super::timespan::answer_button_time_collapsible; use super::timing::SchedTimingToday; use crate::card::CardQueue; use crate::card::CardType; use crate::config::BoolKey; use crate::deckconfig::DeckConfig; use crate::deckconfig::LeechAction; use crate::decks::Deck; use crate::prelude::*; use crate::scheduler::fsrs::memory_state::fsrs_item_for_memory_state; use crate::scheduler::fsrs::memory_state::get_decay_from_params; use crate::scheduler::states::PreviewState; use crate::search::SearchNode; #[derive(Copy, Clone)] pub enum Rating { Again, Hard, Good, Easy, } pub struct CardAnswer { pub card_id: CardId, pub current_state: CardState, pub new_state: CardState, pub rating: Rating, pub answered_at: TimestampMillis, pub milliseconds_taken: u32, pub custom_data: Option, pub from_queue: bool, } impl CardAnswer { fn cap_answer_secs(&mut self, max_secs: u32) { self.milliseconds_taken = self.milliseconds_taken.min(max_secs * 1000); } } /// Holds the information required to determine a given card's /// current state, and to apply a state change to it. struct CardStateUpdater { card: Card, deck: Deck, config: DeckConfig, timing: SchedTimingToday, now: TimestampSecs, fuzz_seed: Option, /// Set if FSRS is enabled. fsrs_next_states: Option, /// Set if FSRS is enabled. desired_retention: Option, fsrs_short_term_with_steps: bool, fsrs_allow_short_term: bool, } impl CardStateUpdater { /// Returns information required when transitioning from one card state to /// another with `next_states()`. This separate structure decouples the /// state handling code from the rest of the Anki codebase. pub(crate) fn state_context<'a>( &'a self, load_balancer_ctx: Option>, ) -> StateContext<'a> { StateContext { fuzz_factor: get_fuzz_factor(self.fuzz_seed), steps: self.learn_steps(), graduating_interval_good: self.config.inner.graduating_interval_good, graduating_interval_easy: self.config.inner.graduating_interval_easy, initial_ease_factor: self.config.inner.initial_ease, hard_multiplier: self.config.inner.hard_multiplier, easy_multiplier: self.config.inner.easy_multiplier, interval_multiplier: self.config.inner.interval_multiplier, maximum_review_interval: self.config.inner.maximum_review_interval, leech_threshold: self.config.inner.leech_threshold, load_balancer_ctx: load_balancer_ctx .map(|load_balancer_ctx| load_balancer_ctx.set_fuzz_seed(self.fuzz_seed)), relearn_steps: self.relearn_steps(), lapse_multiplier: self.config.inner.lapse_multiplier, minimum_lapse_interval: self.config.inner.minimum_lapse_interval, in_filtered_deck: self.deck.is_filtered(), preview_delays: if let DeckKind::Filtered(deck) = &self.deck.kind { PreviewDelays { again: deck.preview_again_secs, hard: deck.preview_hard_secs, good: deck.preview_good_secs, } } else { Default::default() }, fsrs_next_states: self.fsrs_next_states.clone(), fsrs_short_term_with_steps_enabled: self.fsrs_short_term_with_steps, fsrs_allow_short_term: self.fsrs_allow_short_term, } } fn learn_steps(&self) -> LearningSteps<'_> { LearningSteps::new(&self.config.inner.learn_steps) } fn relearn_steps(&self) -> LearningSteps<'_> { LearningSteps::new(&self.config.inner.relearn_steps) } fn secs_until_rollover(&self) -> u32 { self.timing.next_day_at.elapsed_secs_since(self.now) as u32 } fn into_card(self) -> Card { self.card } fn apply_study_state( &mut self, current: CardState, next: CardState, ) -> Result { let revlog = match next { CardState::Normal(normal) => { // transitioning from filtered state? if let CardState::Filtered(filtered) = ¤t { match filtered { FilteredState::Preview(_) => { invalid_input!("should set finished=true, not return different state") } FilteredState::Rescheduling(_) => { // card needs to be removed from normal filtered deck, then scheduled // normally self.card.remove_from_filtered_deck_before_reschedule(); } } } // apply normal scheduling self.apply_normal_study_state(current, normal) } CardState::Filtered(filtered) => { self.ensure_filtered()?; match filtered { FilteredState::Preview(next) => self.apply_preview_state(current, next), FilteredState::Rescheduling(next) => { let revlog = self.apply_normal_study_state(current, next.original_state); self.card.original_due = self.card.due; revlog } } } }; Ok(revlog) } fn apply_normal_study_state( &mut self, current: CardState, next: NormalState, ) -> RevlogEntryPartial { self.card.reps += 1; self.card.desired_retention = self.desired_retention; let revlog = match next { NormalState::New(next) => self.apply_new_state(current, next), NormalState::Learning(next) => self.apply_learning_state(current, next), NormalState::Review(next) => self.apply_review_state(current, next), NormalState::Relearning(next) => self.apply_relearning_state(current, next), }; if next.leeched() && self.config.inner.leech_action() == LeechAction::Suspend { self.card.queue = CardQueue::Suspended; } revlog } fn ensure_filtered(&self) -> Result<()> { require!( self.card.original_deck_id.0 != 0, "card answering can't transition into filtered state", ); Ok(()) } } #[derive(Debug, Default)] pub(crate) struct PreviewDelays { pub again: u32, pub hard: u32, pub good: u32, } impl Rating { fn as_number(self) -> u8 { match self { Rating::Again => 1, Rating::Hard => 2, Rating::Good => 3, Rating::Easy => 4, } } } impl Collection { /// Return the next states that will be applied for each answer button. pub fn get_scheduling_states(&mut self, cid: CardId) -> Result { let card = self.storage.get_card(cid)?.or_not_found(cid)?; let note_id = card.note_id; let ctx = self.card_state_updater(card)?; let current = ctx.current_card_state(); let load_balancer_ctx = if let Some(load_balancer) = self .state .card_queues .as_ref() .and_then(|card_queues| card_queues.load_balancer.as_ref()) { // Only get_deck_config when load balancer is enabled if let Some(deck_config_id) = ctx.deck.config_id() { let note_id = self .get_deck_config(deck_config_id, false)? .map(|deck_config| deck_config.inner.bury_reviews) .unwrap_or(false) .then_some(note_id); Some(load_balancer.review_context(note_id, deck_config_id)) } else { None } } else { None }; let state_ctx = ctx.state_context(load_balancer_ctx); Ok(current.next_states(&state_ctx)) } /// Describe the next intervals, to display on the answer buttons. pub fn describe_next_states(&mut self, choices: &SchedulingStates) -> Result> { let collapse_time = self.learn_ahead_secs(); let now = TimestampSecs::now(); let timing = self.timing_for_timestamp(now)?; let secs_until_rollover = timing.next_day_at.elapsed_secs_since(now).max(0) as u32; Ok(vec![ answer_button_time_collapsible( choices .again .interval_kind() .maybe_as_days(secs_until_rollover) .as_seconds(), collapse_time, &self.tr, ), answer_button_time_collapsible( choices .hard .interval_kind() .maybe_as_days(secs_until_rollover) .as_seconds(), collapse_time, &self.tr, ), answer_button_time_collapsible( choices .good .interval_kind() .maybe_as_days(secs_until_rollover) .as_seconds(), collapse_time, &self.tr, ), answer_button_time_collapsible( choices .easy .interval_kind() .maybe_as_days(secs_until_rollover) .as_seconds(), collapse_time, &self.tr, ), ]) } /// Answer card, writing its new state to the database. /// Provided [CardAnswer] has its answer time capped to deck preset. pub fn answer_card(&mut self, answer: &mut CardAnswer) -> Result> { self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer)) } pub(crate) fn answer_card_inner(&mut self, answer: &mut CardAnswer) -> Result<()> { let card = self .storage .get_card(answer.card_id)? .or_not_found(answer.card_id)?; let original = card.clone(); let usn = self.usn()?; let mut updater = self.card_state_updater(card)?; answer.cap_answer_secs(updater.config.inner.cap_answer_time_to_secs); let current_state = updater.current_card_state(); // If the states aren't equal, it's probably because some time has passed. // Try to fix this by setting elapsed_secs equal. self.set_elapsed_secs_equal(¤t_state, &mut answer.current_state); require!( current_state == answer.current_state, "card was modified: {current_state:#?} {:#?}", answer.current_state, ); let revlog_partial = updater.apply_study_state(current_state, answer.new_state)?; self.add_partial_revlog(revlog_partial, usn, answer)?; self.update_deck_stats_from_answer(usn, answer, &updater, original.queue)?; self.maybe_bury_siblings(&original, &updater.config)?; let timing = updater.timing; let deckconfig_id = updater.deck.config_id(); let mut card = updater.into_card(); if !matches!( answer.current_state, CardState::Filtered(FilteredState::Preview(_)) ) { card.last_review_time = Some(answer.answered_at.as_secs()); } if let Some(data) = answer.custom_data.take() { card.custom_data = data; card.validate_custom_data()?; } self.update_card_inner(&mut card, original, usn)?; if answer.new_state.leeched() { self.add_leech_tag(card.note_id)?; } if card.queue == CardQueue::Review { if let Some(load_balancer) = self .state .card_queues .as_mut() .and_then(|card_queues| card_queues.load_balancer.as_mut()) { if let Some(deckconfig_id) = deckconfig_id { load_balancer.add_card(card.id, card.note_id, deckconfig_id, card.interval) } } } // Handle queue updates based on from_queue flag if answer.from_queue { self.update_queues_after_answering_card( &card, timing, matches!( answer.new_state, CardState::Filtered(FilteredState::Preview(PreviewState { finished: true, .. })) ), )?; } Ok(()) } fn maybe_bury_siblings(&mut self, card: &Card, config: &DeckConfig) -> Result<()> { let bury_mode = BuryMode::from_deck_config(config); if bury_mode.any_burying() { self.bury_siblings(card, card.note_id, bury_mode)?; } Ok(()) } fn add_partial_revlog( &mut self, partial: RevlogEntryPartial, usn: Usn, answer: &CardAnswer, ) -> Result<()> { let revlog = partial.into_revlog_entry( usn, answer.card_id, answer.rating.as_number(), answer.answered_at, answer.milliseconds_taken, ); self.add_revlog_entry_undoable(revlog)?; Ok(()) } fn update_deck_stats_from_answer( &mut self, usn: Usn, answer: &CardAnswer, updater: &CardStateUpdater, from_queue: CardQueue, ) -> Result<()> { let mut new_delta = 0; let mut review_delta = 0; match from_queue { CardQueue::New => new_delta += 1, CardQueue::Review | CardQueue::DayLearn => review_delta += 1, _ => {} } self.update_deck_stats( updater.timing.days_elapsed, usn, anki_proto::scheduler::UpdateStatsRequest { deck_id: updater.deck.id.0, new_delta, review_delta, millisecond_delta: answer.milliseconds_taken as i32, }, ) } fn card_state_updater(&mut self, mut card: Card) -> Result { let timing = self.timing_today()?; let deck = self .storage .get_deck(card.deck_id)? .or_not_found(card.deck_id)?; let home_deck = if card.original_deck_id.0 == 0 { &deck } else { &self .storage .get_deck(card.original_deck_id)? .or_not_found(card.original_deck_id)? }; let config = self .storage .get_deck_config(home_deck.config_id().or_invalid("home deck is filtered")?)? .unwrap_or_default(); let desired_retention = home_deck.effective_desired_retention(&config); let fsrs_enabled = self.get_config_bool(BoolKey::Fsrs); let fsrs_next_states = if fsrs_enabled { let params = config.fsrs_params(); let fsrs = FSRS::new(Some(params))?; card.decay = Some(get_decay_from_params(params)); if card.memory_state.is_none() && card.ctype != CardType::New { // Card has been moved or imported into an FSRS deck after params were set, // and will need its initial memory state to be calculated based on review // history. let revlog = self.revlog_for_srs(SearchNode::CardIds(card.id.to_string()))?; let item = fsrs_item_for_memory_state( &fsrs, revlog, timing.next_day_at, config.inner.historical_retention, ignore_revlogs_before_ms_from_config(&config)?, )?; card.set_memory_state(&fsrs, item, config.inner.historical_retention)?; } let days_elapsed = if let Some(last_review_time) = card.last_review_time { timing.next_day_at.elapsed_days_since(last_review_time) as u32 } else { self.storage .time_of_last_review(card.id)? .map(|ts| timing.next_day_at.elapsed_days_since(ts)) .unwrap_or_default() as u32 }; Some(fsrs.next_states( card.memory_state.map(Into::into), desired_retention, days_elapsed, )?) } else { None }; let desired_retention = fsrs_enabled.then_some(desired_retention); let fsrs_short_term_with_steps = self.get_config_bool(BoolKey::FsrsShortTermWithStepsEnabled); let fsrs_allow_short_term = if fsrs_enabled { let params = config.fsrs_params(); if params.len() >= 19 { params[17] > 0.0 && params[18] > 0.0 } else if params.is_empty() { // fallback to true when using default params true } else { false } } else { false }; Ok(CardStateUpdater { fuzz_seed: get_fuzz_seed(&card, false), card, deck, config, timing, now: TimestampSecs::now(), fsrs_next_states, desired_retention, fsrs_short_term_with_steps, fsrs_allow_short_term, }) } pub(crate) fn home_deck_config( &self, config_id: Option, home_deck_id: DeckId, ) -> Result { let config_id = if let Some(config_id) = config_id { config_id } else { let home_deck = self .storage .get_deck(home_deck_id)? .or_not_found(home_deck_id)?; home_deck.config_id().or_invalid("home deck is filtered")? }; Ok(self.storage.get_deck_config(config_id)?.unwrap_or_default()) } fn add_leech_tag(&mut self, nid: NoteId) -> Result<()> { self.add_tags_to_notes_inner(&[nid], "leech")?; Ok(()) } /// Update the elapsed time of the answer state to match the current state. /// /// Since the state calculation takes the current time into account, the /// elapsed_secs will probably be different for the two states. This is fine /// for elapsed_secs, but we set the two values equal to easily compare /// the other values of the two states. fn set_elapsed_secs_equal(&self, current_state: &CardState, answer_state: &mut CardState) { if let (Some(current_state), Some(answer_state)) = ( match current_state { CardState::Normal(normal_state) => Some(normal_state), CardState::Filtered(FilteredState::Rescheduling(resched_filter_state)) => { Some(&resched_filter_state.original_state) } _ => None, }, match answer_state { CardState::Normal(normal_state) => Some(normal_state), CardState::Filtered(FilteredState::Rescheduling(resched_filter_state)) => { Some(&mut resched_filter_state.original_state) } _ => None, }, ) { match (current_state, answer_state) { (NormalState::Learning(answer), NormalState::Learning(current)) => { current.elapsed_secs = answer.elapsed_secs; } (NormalState::Relearning(answer), NormalState::Relearning(current)) => { current.learning.elapsed_secs = answer.learning.elapsed_secs; } _ => {} // Other states don't use elapsed_secs. } } } } #[cfg(test)] pub mod test_helpers { use super::*; pub struct PostAnswerState { pub card_id: CardId, pub new_state: CardState, } impl Collection { pub(crate) fn answer_again(&mut self) -> PostAnswerState { self.answer(|states| states.again, Rating::Again).unwrap() } #[allow(dead_code)] pub(crate) fn answer_hard(&mut self) -> PostAnswerState { self.answer(|states| states.hard, Rating::Hard).unwrap() } pub(crate) fn answer_good(&mut self) -> PostAnswerState { self.answer(|states| states.good, Rating::Good).unwrap() } pub(crate) fn answer_easy(&mut self) -> PostAnswerState { self.answer(|states| states.easy, Rating::Easy).unwrap() } fn answer(&mut self, get_state: F, rating: Rating) -> Result where F: FnOnce(&SchedulingStates) -> CardState, { let queued = self.get_next_card()?.unwrap(); let new_state = get_state(&queued.states); self.answer_card(&mut CardAnswer { card_id: queued.card.id, current_state: queued.states.current, new_state, rating, answered_at: TimestampMillis::now(), milliseconds_taken: 0, custom_data: None, from_queue: true, })?; Ok(PostAnswerState { card_id: queued.card.id, new_state, }) } } } impl Card { /// If for_reschedule is true, we use card.reps - 1 to match the previous /// review. pub(crate) fn get_fuzz_factor(&self, for_reschedule: bool) -> Option { get_fuzz_factor(get_fuzz_seed(self, for_reschedule)) } } /// Return a consistent seed for a given card at a given number of reps. /// If for_reschedule is true, we use card.reps - 1 to match the previous /// review. pub(crate) fn get_fuzz_seed(card: &Card, for_reschedule: bool) -> Option { let reps = if for_reschedule { card.reps.saturating_sub(1) } else { card.reps }; get_fuzz_seed_for_id_and_reps(card.id, reps) } /// If in test environment, disable fuzzing. fn get_fuzz_seed_for_id_and_reps(card_id: CardId, card_reps: u32) -> Option { if *crate::PYTHON_UNIT_TESTS || cfg!(test) { None } else { Some((card_id.0 as u64).wrapping_add(card_reps as u64)) } } /// Return a fuzz factor from the range `0.0..1.0`, using the provided seed. /// None if seed is None. fn get_fuzz_factor(seed: Option) -> Option { seed.map(|s| StdRng::seed_from_u64(s).random_range(0.0..1.0)) } #[cfg(test)] pub(crate) mod test { use super::*; use crate::card::CardType; use crate::deckconfig::ReviewMix; use crate::search::SortMode; fn current_state(col: &mut Collection, card_id: CardId) -> CardState { col.get_scheduling_states(card_id).unwrap().current } // Test that deck-specific desired retention is used when available #[test] fn deck_specific_desired_retention() -> Result<()> { let mut col = Collection::new(); // Enable FSRS col.set_config_bool(BoolKey::Fsrs, true, false)?; // Create a deck with specific desired retention let deck_id = DeckId(1); let deck = col.get_deck(deck_id)?.unwrap(); let mut deck_clone = (*deck).clone(); deck_clone.normal_mut().unwrap().desired_retention = Some(0.85); col.update_deck(&mut deck_clone)?; // Create a card in this deck let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); col.add_note(&mut note, deck_id)?; // Get the card using search_cards let cards = col.search_cards(note.id, SortMode::NoOrder)?; let card = col.storage.get_card(cards[0])?.unwrap(); // Test that the card state updater uses deck-specific desired retention let updater = col.card_state_updater(card)?; // Print debug information println!("FSRS enabled: {}", col.get_config_bool(BoolKey::Fsrs)); println!("Desired retention: {:?}", updater.desired_retention); // Verify that the desired retention is from the deck, not the config assert_eq!(updater.desired_retention, Some(0.85)); Ok(()) } // make sure the 'current' state for a card matches the // state we applied to it #[test] fn state_application() -> Result<()> { let mut col = Collection::new(); if col.timing_today()?.near_cutoff() { return Ok(()); } let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); col.add_note(&mut note, DeckId(1))?; // new->learning let post_answer = col.answer_again(); let mut current = current_state(&mut col, post_answer.card_id); col.set_elapsed_secs_equal(&post_answer.new_state, &mut current); assert_eq!(post_answer.new_state, current); let card = col.storage.get_card(post_answer.card_id)?.unwrap(); assert_eq!(card.queue, CardQueue::Learn); assert_eq!(card.remaining_steps, 2); // learning step col.storage.db.execute_batch("update cards set due=0")?; col.clear_study_queues(); let post_answer = col.answer_good(); let mut current = current_state(&mut col, post_answer.card_id); col.set_elapsed_secs_equal(&post_answer.new_state, &mut current); assert_eq!(post_answer.new_state, current); let card = col.storage.get_card(post_answer.card_id)?.unwrap(); assert_eq!(card.queue, CardQueue::Learn); assert_eq!(card.remaining_steps, 1); // graduation col.storage.db.execute_batch("update cards set due=0")?; col.clear_study_queues(); let mut post_answer = col.answer_good(); // compensate for shifting the due date if let CardState::Normal(NormalState::Review(state)) = &mut post_answer.new_state { state.elapsed_days = 1; }; let mut current = current_state(&mut col, post_answer.card_id); col.set_elapsed_secs_equal(&post_answer.new_state, &mut current); assert_eq!(post_answer.new_state, current); let card = col.storage.get_card(post_answer.card_id)?.unwrap(); assert_eq!(card.queue, CardQueue::Review); assert_eq!(card.interval, 1); assert_eq!(card.remaining_steps, 0); // answering a review card again; easy boost col.storage.db.execute_batch("update cards set due=0")?; col.clear_study_queues(); let mut post_answer = col.answer_easy(); if let CardState::Normal(NormalState::Review(state)) = &mut post_answer.new_state { state.elapsed_days = 4; }; let mut current = current_state(&mut col, post_answer.card_id); col.set_elapsed_secs_equal(&post_answer.new_state, &mut current); assert_eq!(post_answer.new_state, current); let card = col.storage.get_card(post_answer.card_id)?.unwrap(); assert_eq!(card.queue, CardQueue::Review); assert_eq!(card.interval, 4); assert_eq!(card.ease_factor, 2650); // lapsing it col.storage.db.execute_batch("update cards set due=0")?; col.clear_study_queues(); let mut post_answer = col.answer_again(); if let CardState::Normal(NormalState::Relearning(state)) = &mut post_answer.new_state { state.review.elapsed_days = 1; }; let mut current = current_state(&mut col, post_answer.card_id); col.set_elapsed_secs_equal(&post_answer.new_state, &mut current); assert_eq!(post_answer.new_state, current); let card = col.storage.get_card(post_answer.card_id)?.unwrap(); assert_eq!(card.queue, CardQueue::Learn); assert_eq!(card.ctype, CardType::Relearn); assert_eq!(card.interval, 1); assert_eq!(card.ease_factor, 2450); assert_eq!(card.lapses, 1); // failed in relearning col.storage.db.execute_batch("update cards set due=0")?; col.clear_study_queues(); let mut post_answer = col.answer_again(); if let CardState::Normal(NormalState::Relearning(state)) = &mut post_answer.new_state { state.review.elapsed_days = 1; }; let mut current = current_state(&mut col, post_answer.card_id); col.set_elapsed_secs_equal(&post_answer.new_state, &mut current); assert_eq!(post_answer.new_state, current); let card = col.storage.get_card(post_answer.card_id)?.unwrap(); assert_eq!(card.queue, CardQueue::Learn); assert_eq!(card.lapses, 1); // re-graduating col.storage.db.execute_batch("update cards set due=0")?; col.clear_study_queues(); let mut post_answer = col.answer_good(); if let CardState::Normal(NormalState::Review(state)) = &mut post_answer.new_state { state.elapsed_days = 1; }; let mut current = current_state(&mut col, post_answer.card_id); col.set_elapsed_secs_equal(&post_answer.new_state, &mut current); assert_eq!(post_answer.new_state, current); let card = col.storage.get_card(post_answer.card_id)?.unwrap(); assert_eq!(card.queue, CardQueue::Review); assert_eq!(card.interval, 1); Ok(()) } pub(crate) fn v3_test_collection(cards: usize) -> Result<(Collection, Vec)> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); for _ in 0..cards { let mut note = Note::new(&nt); col.add_note(&mut note, DeckId(1))?; } let cids = col.search_cards("", SortMode::NoOrder)?; Ok((col, cids)) } macro_rules! assert_counts { ($col:ident, $new:expr, $learn:expr, $review:expr) => {{ let tree = $col.deck_tree(Some(TimestampSecs::now())).unwrap(); assert_eq!(tree.new_count, $new); assert_eq!(tree.learn_count, $learn); assert_eq!(tree.review_count, $review); let queued = $col.get_queued_cards(1, false).unwrap(); assert_eq!(queued.new_count, $new); assert_eq!(queued.learning_count, $learn); assert_eq!(queued.review_count, $review); }}; } // FIXME: This fails between 3:50-4:00 GMT #[test] fn new_limited_by_reviews() -> Result<()> { let (mut col, cids) = v3_test_collection(4)?; col.set_due_date(&cids[0..2], "0", None)?; // set a limit of 3 reviews, which should give us 2 reviews and 1 new card let mut conf = col.get_deck_config(DeckConfigId(1), false)?.unwrap(); conf.inner.reviews_per_day = 3; conf.inner.set_new_mix(ReviewMix::BeforeReviews); col.storage.update_deck_conf(&conf)?; assert_counts!(col, 1, 0, 2); // first card is the new card col.answer_good(); assert_counts!(col, 0, 1, 2); // then the two reviews col.answer_good(); assert_counts!(col, 0, 1, 1); col.answer_good(); assert_counts!(col, 0, 1, 0); // after the final 10 minute step, the queues should be empty col.answer_good(); assert_counts!(col, 0, 0, 0); Ok(()) } #[test] fn elapsed_secs() -> Result<()> { let mut col = Collection::new(); let mut conf = col.get_deck_config(DeckConfigId(1), false)?.unwrap(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); // Need to set col age for interday learning test, arbitrary col.storage .db .execute_batch("update col set crt=1686045847")?; // Fails when near cutoff since it assumes inter- and intraday learning if col.timing_today()?.near_cutoff() { return Ok(()); } col.add_note(&mut note, DeckId(1))?; // 5942.7 minutes for just over four days conf.inner.learn_steps = vec![1.0, 10.5, 15.0, 20.0, 5942.7]; col.storage.update_deck_conf(&conf)?; // Intraday learning, review same day let expected_elapsed_secs = 662; let post_answer = col.answer_good(); let card = col.storage.get_card(post_answer.card_id)?.unwrap(); let shift_due_time = card.due - expected_elapsed_secs; assert_elapsed_secs_approx_equal( &mut col, shift_due_time, post_answer, expected_elapsed_secs, )?; // Intraday learning, learn ahead let expected_elapsed_secs = 212; let post_answer = col.answer_good(); let card = col.storage.get_card(post_answer.card_id)?.unwrap(); let shift_due_time = card.due - expected_elapsed_secs; assert_elapsed_secs_approx_equal( &mut col, shift_due_time, post_answer, expected_elapsed_secs, )?; // Intraday learning, review two (and some) days later let expected_elapsed_secs = 184092; let post_answer = col.answer_good(); let card = col.storage.get_card(post_answer.card_id)?.unwrap(); let shift_due_time = card.due - expected_elapsed_secs; assert_elapsed_secs_approx_equal( &mut col, shift_due_time, post_answer, expected_elapsed_secs, )?; // Interday learning four (and some) days, review three days late let expected_elapsed_secs = 7 * 86_400; let post_answer = col.answer_good(); let now = TimestampSecs::now(); let timing = col.timing_for_timestamp(now)?; let col_age = timing.days_elapsed as i32; let shift_due_time = col_age - 3; // Three days late assert_elapsed_secs_approx_equal( &mut col, shift_due_time, post_answer, expected_elapsed_secs, )?; Ok(()) } fn assert_elapsed_secs_approx_equal( col: &mut Collection, shift_due_time: i32, post_answer: test_helpers::PostAnswerState, expected_elapsed_secs: i32, ) -> Result<()> { // Change due time to fake card answer_time, // works since answer_time is calculated as due - last_ivl let update_due_string = format!("update cards set due={shift_due_time}"); col.storage.db.execute_batch(&update_due_string)?; col.clear_study_queues(); let current_card_state = current_state(col, post_answer.card_id); let state = match current_card_state { CardState::Normal(NormalState::Learning(state)) => state, _ => panic!("State is not Normal: {current_card_state:?}"), }; let elapsed_secs = state.elapsed_secs as i32; // Give a 1 second leeway when the test runs on the off chance // that the test runs as a second rolls over. assert!( (elapsed_secs - expected_elapsed_secs).abs() <= 1, "elapsed_secs: {elapsed_secs} != expected_elapsed_secs: {expected_elapsed_secs}" ); Ok(()) } } ================================================ FILE: rslib/src/scheduler/answering/preview.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::CardStateUpdater; use super::RevlogEntryPartial; use crate::card::CardQueue; use crate::scheduler::states::CardState; use crate::scheduler::states::IntervalKind; use crate::scheduler::states::PreviewState; impl CardStateUpdater { pub(super) fn apply_preview_state( &mut self, current: CardState, next: PreviewState, ) -> RevlogEntryPartial { let revlog = RevlogEntryPartial::new(current, next.into(), 0.0, self.secs_until_rollover()); if next.finished { self.card.remove_from_filtered_deck_restoring_queue(); return revlog; } self.card.queue = CardQueue::PreviewRepeat; let interval = next.interval_kind(); match interval { IntervalKind::InSecs(secs) => { self.card.due = self.fuzzed_next_learning_timestamp(secs); } IntervalKind::InDays(_days) => { // unsupported } } revlog } } #[cfg(test)] mod test { use super::*; use crate::card::CardType; use crate::prelude::*; use crate::scheduler::answering::CardAnswer; use crate::scheduler::answering::Rating; use crate::scheduler::states::CardState; use crate::scheduler::states::FilteredState; use crate::timestamp::TimestampMillis; #[test] fn preview() -> Result<()> { let mut col = Collection::new(); let mut c = Card { deck_id: DeckId(1), ctype: CardType::Learn, queue: CardQueue::DayLearn, remaining_steps: 2, due: 123, ..Default::default() }; col.add_card(&mut c)?; // pull the card into a preview deck let mut filtered_deck = Deck::new_filtered(); filtered_deck.filtered_mut()?.reschedule = false; col.add_or_update_deck(&mut filtered_deck)?; assert_eq!(col.rebuild_filtered_deck(filtered_deck.id)?.output, 1); let next = col.get_scheduling_states(c.id)?; assert!(matches!( next.current, CardState::Filtered(FilteredState::Preview(_)) )); // the exit state should have a 0 second interval, which will show up as (end) assert!(matches!( next.easy, CardState::Filtered(FilteredState::Preview(PreviewState { scheduled_secs: 0, finished: true })) )); assert!(matches!( next.good, CardState::Filtered(FilteredState::Preview(PreviewState { scheduled_secs: 0, finished: true })) )); // use Again on the preview col.answer_card(&mut CardAnswer { card_id: c.id, current_state: next.current, new_state: next.again, rating: Rating::Again, answered_at: TimestampMillis::now(), milliseconds_taken: 0, custom_data: None, from_queue: true, })?; c = col.storage.get_card(c.id)?.unwrap(); assert_eq!(c.queue, CardQueue::PreviewRepeat); // hard let next = col.get_scheduling_states(c.id)?; col.answer_card(&mut CardAnswer { card_id: c.id, current_state: next.current, new_state: next.hard, rating: Rating::Hard, answered_at: TimestampMillis::now(), milliseconds_taken: 0, custom_data: None, from_queue: true, })?; c = col.storage.get_card(c.id)?.unwrap(); assert_eq!(c.queue, CardQueue::PreviewRepeat); // and then it should return to its old state once good or easy selected, // with the default filtered config let next = col.get_scheduling_states(c.id)?; col.answer_card(&mut CardAnswer { card_id: c.id, current_state: next.current, new_state: next.good, rating: Rating::Good, answered_at: TimestampMillis::now(), milliseconds_taken: 0, custom_data: None, from_queue: true, })?; c = col.storage.get_card(c.id)?.unwrap(); assert_eq!(c.queue, CardQueue::DayLearn); assert_eq!(c.due, 123); Ok(()) } } ================================================ FILE: rslib/src/scheduler/answering/relearning.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::CardStateUpdater; use super::RevlogEntryPartial; use crate::card::CardQueue; use crate::card::CardType; use crate::scheduler::states::CardState; use crate::scheduler::states::IntervalKind; use crate::scheduler::states::RelearnState; impl CardStateUpdater { pub(super) fn apply_relearning_state( &mut self, current: CardState, next: RelearnState, ) -> RevlogEntryPartial { self.card.interval = next.review.scheduled_days; self.card.remaining_steps = next.learning.remaining_steps; self.card.ctype = CardType::Relearn; self.card.lapses = next.review.lapses; self.card.ease_factor = (next.review.ease_factor * 1000.0).round() as u16; if let Some(position) = current.new_position() { self.card.original_position = Some(position) } self.card.memory_state = next.learning.memory_state; let interval = next .interval_kind() .maybe_as_days(self.secs_until_rollover()); match interval { IntervalKind::InSecs(secs) => { self.card.queue = CardQueue::Learn; self.card.due = self.fuzzed_next_learning_timestamp(secs); } IntervalKind::InDays(days) => { self.card.queue = CardQueue::DayLearn; self.card.due = (self.timing.days_elapsed + days) as i32; } } RevlogEntryPartial::new( current, next.into(), self.card .memory_state .map(|d| d.difficulty_shifted()) .unwrap_or(next.review.ease_factor), self.secs_until_rollover(), ) } } ================================================ FILE: rslib/src/scheduler/answering/review.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::CardStateUpdater; use super::RevlogEntryPartial; use crate::card::CardQueue; use crate::card::CardType; use crate::scheduler::states::CardState; use crate::scheduler::states::ReviewState; impl CardStateUpdater { pub(super) fn apply_review_state( &mut self, current: CardState, next: ReviewState, ) -> RevlogEntryPartial { self.card.queue = CardQueue::Review; self.card.ctype = CardType::Review; self.card.interval = next.scheduled_days; self.card.due = (self.timing.days_elapsed + next.scheduled_days) as i32; self.card.ease_factor = (next.ease_factor * 1000.0).round() as u16; self.card.lapses = next.lapses; self.card.remaining_steps = 0; if let Some(position) = current.new_position() { self.card.original_position = Some(position) } self.card.memory_state = next.memory_state; RevlogEntryPartial::new( current, next.into(), self.card .memory_state .map(|d| d.difficulty_shifted()) .unwrap_or(next.ease_factor), self.secs_until_rollover(), ) } } ================================================ FILE: rslib/src/scheduler/answering/revlog.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::prelude::*; use crate::revlog::RevlogEntry; use crate::revlog::RevlogReviewKind; use crate::scheduler::states::CardState; use crate::scheduler::states::IntervalKind; pub struct RevlogEntryPartial { interval: IntervalKind, last_interval: IntervalKind, ease_factor: f32, review_kind: RevlogReviewKind, } impl RevlogEntryPartial { pub(super) fn new( current: CardState, next: CardState, ease_factor: f32, secs_until_rollover: u32, ) -> Self { let next_interval = next.interval_kind().maybe_as_days(secs_until_rollover); let current_interval = current.interval_kind().maybe_as_days(secs_until_rollover); RevlogEntryPartial { interval: next_interval, last_interval: current_interval, ease_factor, review_kind: current.revlog_kind(), } } pub(super) fn into_revlog_entry( self, usn: Usn, cid: CardId, button_chosen: u8, answered_at: TimestampMillis, taken_millis: u32, ) -> RevlogEntry { RevlogEntry { id: answered_at.into(), cid, usn, button_chosen, interval: self.interval.as_revlog_interval(), last_interval: self.last_interval.as_revlog_interval(), ease_factor: (self.ease_factor * 1000.0).round() as u32, taken_millis, review_kind: self.review_kind, } } } ================================================ FILE: rslib/src/scheduler/bury_and_suspend.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::scheduler::bury_or_suspend_cards_request::Mode as BuryOrSuspendMode; use anki_proto::scheduler::unbury_deck_request::Mode as UnburyDeckMode; use super::queue::BuryMode; use super::timing::SchedTimingToday; use crate::card::CardQueue; use crate::config::SchedulerVersion; use crate::prelude::*; use crate::search::JoinSearches; use crate::search::SearchNode; use crate::search::StateKind; impl Card { /// True if card was buried/suspended prior to the call. pub(crate) fn restore_queue_after_bury_or_suspend(&mut self) -> bool { if !matches!( self.queue, CardQueue::Suspended | CardQueue::SchedBuried | CardQueue::UserBuried ) { false } else { self.restore_queue_from_type(); true } } } impl Collection { pub(crate) fn unbury_if_day_rolled_over(&mut self, timing: SchedTimingToday) -> Result<()> { let last_unburied = self.get_last_unburied_day(); let today = timing.days_elapsed; if last_unburied < today || (today + 7) < last_unburied { self.unbury_on_day_rollover(today)?; } Ok(()) } /// Unbury cards from the previous day. /// Done automatically, and does not mark the cards as modified. pub(crate) fn unbury_on_day_rollover(&mut self, today: u32) -> Result<()> { self.for_each_card_in_search(StateKind::Buried, |col, mut card| { card.restore_queue_after_bury_or_suspend(); col.storage.update_card(&card) })?; self.set_last_unburied_day(today) } /// Unsuspend/unbury cards. Marks the cards as modified. fn unsuspend_or_unbury_searched_cards(&mut self, cards: Vec) -> Result<()> { let usn = self.usn()?; for original in cards { let mut card = original.clone(); if card.restore_queue_after_bury_or_suspend() { self.update_card_inner(&mut card, original, usn)?; } } Ok(()) } pub fn unbury_or_unsuspend_cards(&mut self, cids: &[CardId]) -> Result> { self.transact(Op::UnburyUnsuspend, |col| { let cards = col.all_cards_for_ids(cids, false)?; col.unsuspend_or_unbury_searched_cards(cards) }) } pub fn unbury_deck(&mut self, deck_id: DeckId, mode: UnburyDeckMode) -> Result> { let state = match mode { UnburyDeckMode::All => StateKind::Buried, UnburyDeckMode::UserOnly => StateKind::UserBuried, UnburyDeckMode::SchedOnly => StateKind::SchedBuried, }; self.transact(Op::UnburyUnsuspend, |col| { let cards = col.all_cards_for_search(SearchNode::DeckIdWithChildren(deck_id).and(state))?; col.unsuspend_or_unbury_searched_cards(cards) }) } /// Marks the cards as modified. fn bury_or_suspend_cards_inner( &mut self, cards: Vec, mode: BuryOrSuspendMode, ) -> Result { let mut count = 0; let usn = self.usn()?; let sched = self.scheduler_version(); if sched == SchedulerVersion::V1 { return Err(AnkiError::SchedulerUpgradeRequired); } let desired_queue = match mode { BuryOrSuspendMode::Suspend => CardQueue::Suspended, BuryOrSuspendMode::BurySched => CardQueue::SchedBuried, BuryOrSuspendMode::BuryUser => CardQueue::UserBuried, }; for original in cards { let mut card = original.clone(); if card.queue != desired_queue { // do not bury suspended cards as that would unsuspend them if card.queue != CardQueue::Suspended { card.queue = desired_queue; count += 1; self.update_card_inner(&mut card, original, usn)?; } } } Ok(count) } pub fn bury_or_suspend_cards( &mut self, cids: &[CardId], mode: BuryOrSuspendMode, ) -> Result> { let op = match mode { BuryOrSuspendMode::Suspend => Op::Suspend, BuryOrSuspendMode::BurySched | BuryOrSuspendMode::BuryUser => Op::Bury, }; self.transact(op, |col| { let cards = col.all_cards_for_ids(cids, false)?; col.bury_or_suspend_cards_inner(cards, mode) }) } pub(crate) fn bury_siblings( &mut self, card: &Card, nid: NoteId, mut bury_mode: BuryMode, ) -> Result { bury_mode.exclude_earlier_gathered_queues(card.queue); let cards = self .storage .all_siblings_for_bury(card.id, nid, bury_mode)?; self.bury_or_suspend_cards_inner(cards, BuryOrSuspendMode::BurySched) } } impl BuryMode { /// Disables burying for queues gathered before `queue`. fn exclude_earlier_gathered_queues(&mut self, queue: CardQueue) { self.bury_interday_learning &= queue.gather_ord() <= CardQueue::DayLearn.gather_ord(); self.bury_reviews &= queue.gather_ord() <= CardQueue::Review.gather_ord(); } } impl CardQueue { fn gather_ord(self) -> u8 { match self { CardQueue::Learn | CardQueue::PreviewRepeat => 0, CardQueue::DayLearn => 1, CardQueue::Review => 2, CardQueue::New => 3, // not gathered CardQueue::Suspended | CardQueue::SchedBuried | CardQueue::UserBuried => u8::MAX, } } } #[cfg(test)] mod test { use crate::card::Card; use crate::card::CardQueue; use crate::collection::Collection; use crate::search::SortMode; use crate::search::StateKind; #[test] fn unbury() { let mut col = Collection::new(); let mut card = Card { queue: CardQueue::UserBuried, ..Default::default() }; col.add_card(&mut card).unwrap(); let assert_count = |col: &mut Collection, cnt| { assert_eq!( col.search_cards(StateKind::Buried, SortMode::NoOrder) .unwrap() .len(), cnt ); }; assert_count(&mut col, 1); // day 0, last unburied 0, so no change let timing = col.timing_today().unwrap(); col.unbury_if_day_rolled_over(timing).unwrap(); assert_count(&mut col, 1); // move creation time back and it should succeed let mut stamp = col.storage.creation_stamp().unwrap(); stamp.0 -= 86_400; col.set_creation_stamp(stamp).unwrap(); let timing = col.timing_today().unwrap(); col.unbury_if_day_rolled_over(timing).unwrap(); assert_count(&mut col, 0); } } ================================================ FILE: rslib/src/scheduler/congrats.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::prelude::*; #[derive(Debug)] pub(crate) struct CongratsInfo { pub learn_count: u32, pub next_learn_due: u32, pub review_remaining: bool, pub new_remaining: bool, pub have_sched_buried: bool, pub have_user_buried: bool, } impl Collection { pub fn congrats_info(&mut self) -> Result { let deck = self.get_current_deck()?; let today = self.timing_today()?.days_elapsed; let info = self.storage.congrats_info(&deck, today)?; let is_filtered_deck = deck.is_filtered(); let deck_description = deck.rendered_description(); let secs_until_next_learn = if info.next_learn_due == 0 { // signal to the frontend that no learning cards are due later 86_400 } else { ((info.next_learn_due as i64) - self.learn_ahead_secs() as i64 - TimestampSecs::now().0) .max(60) as u32 }; Ok(anki_proto::scheduler::CongratsInfoResponse { learn_remaining: info.learn_count, review_remaining: info.review_remaining, new_remaining: info.new_remaining, have_sched_buried: info.have_sched_buried, have_user_buried: info.have_user_buried, is_filtered_deck, secs_until_next_learn, bridge_commands_supported: true, deck_description, }) } } #[cfg(test)] mod test { use super::*; #[test] fn empty() { let mut col = Collection::new(); let info = col.congrats_info().unwrap(); assert_eq!( info, anki_proto::scheduler::CongratsInfoResponse { learn_remaining: 0, review_remaining: false, new_remaining: false, have_sched_buried: false, have_user_buried: false, is_filtered_deck: false, secs_until_next_learn: 86_400, bridge_commands_supported: true, deck_description: "".to_string() } ) } } ================================================ FILE: rslib/src/scheduler/filtered/card.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::DeckFilterContext; use crate::card::CardQueue; use crate::card::CardType; use crate::prelude::*; use crate::scheduler::timing::is_unix_epoch_timestamp; impl Card { pub(crate) fn restore_queue_from_type(&mut self) { self.queue = match self.ctype { CardType::Learn | CardType::Relearn => { if is_unix_epoch_timestamp(self.due) { // unix timestamp CardQueue::Learn } else { // day number CardQueue::DayLearn } } CardType::New => CardQueue::New, CardType::Review => CardQueue::Review, } } pub(crate) fn move_into_filtered_deck(&mut self, ctx: &DeckFilterContext, position: i32) { // filtered and v1 learning cards are excluded, so odue should be guaranteed to // be zero if self.original_due != 0 { println!("bug: odue was set"); return; } self.original_deck_id = self.deck_id; self.deck_id = ctx.target_deck; self.original_due = self.due; // if rescheduling is disabled, all cards go in the review queue if !ctx.config.reschedule { self.queue = CardQueue::Review; } if self.due > 0 { self.due = position; } } /// Restores to the original deck and clears original_due. /// This does not update the queue or type, so should only be used as /// part of an operation that adjusts those separately. pub(crate) fn remove_from_filtered_deck_before_reschedule(&mut self) { if self.original_deck_id.0 != 0 { self.deck_id = self.original_deck_id; self.original_deck_id.0 = 0; self.original_due = 0; } } pub(crate) fn original_or_current_deck_id(&self) -> DeckId { self.original_deck_id.or(self.deck_id) } pub(crate) fn remove_from_filtered_deck_restoring_queue(&mut self) { if self.original_deck_id.0 == 0 { // not in a filtered deck return; } self.deck_id = self.original_deck_id; self.original_deck_id.0 = 0; if self.original_due != 0 { self.due = self.original_due; } if (self.queue as i8) >= 0 { self.restore_queue_from_type(); } self.original_due = 0; } pub(crate) fn is_filtered(&self) -> bool { self.original_deck_id.0 > 0 } } ================================================ FILE: rslib/src/scheduler/filtered/custom_study.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashSet; use anki_proto::scheduler::custom_study_request::cram::CramKind; use anki_proto::scheduler::custom_study_request::Cram; use anki_proto::scheduler::custom_study_request::Value as CustomStudyValue; use super::FilteredDeckForUpdate; use crate::config::DeckConfigKey; use crate::decks::tree::get_deck_in_tree; use crate::decks::tree::sum_deck_tree_node; use crate::decks::FilteredDeck; use crate::decks::FilteredSearchOrder; use crate::decks::FilteredSearchTerm; use crate::error::CustomStudyError; use crate::error::FilteredDeckError; use crate::prelude::*; use crate::search::JoinSearches; use crate::search::Negated; use crate::search::PropertyKind; use crate::search::RatingKind; use crate::search::SearchNode; use crate::search::StateKind; impl Collection { pub fn custom_study( &mut self, input: anki_proto::scheduler::CustomStudyRequest, ) -> Result> { self.transact(Op::CreateCustomStudy, |col| col.custom_study_inner(input)) } pub fn custom_study_defaults( &mut self, deck_id: DeckId, ) -> Result { // daily counts let deck = self.get_deck(deck_id)?.or_not_found(deck_id)?; let normal = deck.normal()?; let extend_new = normal.extend_new; let extend_review = normal.extend_review; let subtree = get_deck_in_tree(self.deck_tree(Some(TimestampSecs::now()))?, deck_id) .or_not_found(deck_id)?; let available_new_including_children = sum_deck_tree_node(&subtree, |node| node.new_uncapped); let available_review_including_children = sum_deck_tree_node(&subtree, |node| node.review_uncapped); let ( available_new, available_new_in_children, available_review, available_review_in_children, ) = ( subtree.new_uncapped, available_new_including_children - subtree.new_uncapped, subtree.review_uncapped, available_review_including_children - subtree.review_uncapped, ); // tags let include_tags: HashSet = self.get_config_default( DeckConfigKey::CustomStudyIncludeTags .for_deck(deck_id) .as_str(), ); let exclude_tags: HashSet = self.get_config_default( DeckConfigKey::CustomStudyExcludeTags .for_deck(deck_id) .as_str(), ); let mut all_tags: Vec<_> = self.all_tags_in_deck(deck_id)?.into_iter().collect(); all_tags.sort_unstable(); let tags: Vec = all_tags .into_iter() .map(|tag| { let tag = tag.into_inner(); anki_proto::scheduler::custom_study_defaults_response::Tag { include: include_tags.contains(&tag), exclude: exclude_tags.contains(&tag), name: tag, } }) .collect(); Ok(anki_proto::scheduler::CustomStudyDefaultsResponse { tags, extend_new, extend_review, available_new, available_review, available_new_in_children, available_review_in_children, }) } } impl Collection { fn custom_study_inner( &mut self, input: anki_proto::scheduler::CustomStudyRequest, ) -> Result<()> { let mut deck = self .storage .get_deck(input.deck_id.into())? .or_not_found(input.deck_id)?; match input.value.or_invalid("missing oneof value")? { CustomStudyValue::NewLimitDelta(delta) => { let today = self.current_due_day(0)?; self.extend_limits(today, self.usn()?, deck.id, delta, 0)?; if delta > 0 { deck = self.storage.get_deck(deck.id)?.or_not_found(deck.id)?; let original = deck.clone(); deck.normal_mut()?.extend_new = delta as u32; self.update_deck_inner(&mut deck, original, self.usn()?)?; } Ok(()) } CustomStudyValue::ReviewLimitDelta(delta) => { let today = self.current_due_day(0)?; self.extend_limits(today, self.usn()?, deck.id, 0, delta)?; if delta > 0 { deck = self.storage.get_deck(deck.id)?.or_not_found(deck.id)?; let original = deck.clone(); deck.normal_mut()?.extend_review = delta as u32; self.update_deck_inner(&mut deck, original, self.usn()?)?; } Ok(()) } CustomStudyValue::ForgotDays(days) => { self.create_custom_study_deck(forgot_config(deck.human_name(), days)) } CustomStudyValue::ReviewAheadDays(days) => { self.create_custom_study_deck(ahead_config(deck.human_name(), days)) } CustomStudyValue::PreviewDays(days) => { self.create_custom_study_deck(preview_config(deck.human_name(), days)) } CustomStudyValue::Cram(cram) => { self.create_custom_study_deck(cram_config(deck.human_name(), &cram)?)?; self.set_config( DeckConfigKey::CustomStudyIncludeTags .for_deck(deck.id) .as_str(), &cram.tags_to_include, )?; self.set_config( DeckConfigKey::CustomStudyExcludeTags .for_deck(deck.id) .as_str(), &cram.tags_to_exclude, )?; Ok(()) } } } /// Reuse existing one or create new one if missing. /// Guaranteed to be a filtered deck. fn create_custom_study_deck(&mut self, config: FilteredDeck) -> Result<()> { let mut id = DeckId(0); let human_name = self.tr.custom_study_custom_study_session().to_string(); if let Some(did) = self.get_deck_id(&human_name)? { if !self.get_deck(did)?.or_not_found(did)?.is_filtered() { return Err(CustomStudyError::ExistingDeck.into()); } id = did; } let deck = FilteredDeckForUpdate { id, human_name, config, allow_empty: false, }; self.add_or_update_filtered_deck_inner(deck) .map(|_| ()) .map_err(|err| { if matches!( err, AnkiError::FilteredDeckError { source: FilteredDeckError::SearchReturnedNoCards } ) { CustomStudyError::NoMatchingCards.into() } else { err } }) } } fn custom_study_config( reschedule: bool, search: String, order: FilteredSearchOrder, limit: Option, ) -> FilteredDeck { FilteredDeck { reschedule, search_terms: vec![FilteredSearchTerm { search, limit: limit.unwrap_or(99_999), order: order as i32, }], delays: vec![], preview_delay: 10, preview_again_secs: 60, preview_hard_secs: 600, preview_good_secs: 0, } } fn forgot_config(deck_name: String, days: u32) -> FilteredDeck { let search = SearchNode::Rated { days, ease: RatingKind::AnswerButton(1), } .and(SearchNode::from_deck_name(&deck_name)) .write(); custom_study_config(false, search, FilteredSearchOrder::Random, None) } fn ahead_config(deck_name: String, days: u32) -> FilteredDeck { let search = SearchNode::Property { operator: "<=".to_string(), kind: PropertyKind::Due(days as i32), } .and(SearchNode::from_deck_name(&deck_name)) .write(); custom_study_config(true, search, FilteredSearchOrder::Due, None) } fn preview_config(deck_name: String, days: u32) -> FilteredDeck { let search = StateKind::New .and_flat(SearchNode::AddedInDays(days)) .and_flat(SearchNode::from_deck_name(&deck_name)) .write(); custom_study_config(false, search, FilteredSearchOrder::Added, None) } fn cram_config(deck_name: String, cram: &Cram) -> Result { let (reschedule, nodes, order) = match cram.kind() { CramKind::New => ( true, SearchBuilder::from(StateKind::New), FilteredSearchOrder::Added, ), CramKind::Due => ( true, SearchBuilder::from(StateKind::Due), FilteredSearchOrder::Due, ), CramKind::Review => ( true, SearchBuilder::from(StateKind::New.negated()), FilteredSearchOrder::Random, ), CramKind::All => (false, SearchBuilder::new(), FilteredSearchOrder::Random), }; let search = nodes .and(SearchNode::from_deck_name(&deck_name)) .and_flat(tags_to_nodes(&cram.tags_to_include, &cram.tags_to_exclude)) .write(); Ok(custom_study_config( reschedule, search, order, Some(cram.card_limit), )) } fn tags_to_nodes(tags_to_include: &[String], tags_to_exclude: &[String]) -> SearchBuilder { let include_nodes = SearchBuilder::any( tags_to_include .iter() .map(|tag| SearchNode::from_tag_name(tag)), ); let exclude_nodes = SearchBuilder::all( tags_to_exclude .iter() .map(|tag| SearchNode::from_tag_name(tag).negated()), ); include_nodes.and(exclude_nodes) } #[cfg(test)] mod test { use anki_proto::scheduler::custom_study_request::cram::CramKind; use anki_proto::scheduler::custom_study_request::Cram; use anki_proto::scheduler::custom_study_request::Value; use anki_proto::scheduler::CustomStudyRequest; use super::*; #[test] fn tag_remembering() -> Result<()> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); note.tags .extend_from_slice(&["3".to_string(), "1".to_string(), "2::two".to_string()]); col.add_note(&mut note, DeckId(1))?; let mut note = nt.new_note(); note.tags .extend_from_slice(&["1".to_string(), "2::two".to_string()]); col.add_note(&mut note, DeckId(1))?; fn get_defaults(col: &mut Collection) -> Result> { Ok(col .custom_study_defaults(DeckId(1))? .tags .into_iter() .map(|tag| { ( // cheekily leak the string so we have a static ref for comparison &*Box::leak(tag.name.into_boxed_str()), tag.include, tag.exclude, ) }) .collect()) } // nothing should be included/excluded by default assert_eq!( &get_defaults(&mut col)?, &[ ("1", false, false), ("2::two", false, false), ("3", false, false) ] ); // if filtered deck creation fails, inclusions/exclusions don't change let mut cram = Cram { kind: CramKind::All as i32, card_limit: 0, tags_to_include: vec!["2::two".to_string()], tags_to_exclude: vec!["3".to_string()], }; assert_eq!( col.custom_study(CustomStudyRequest { deck_id: 1, value: Some(Value::Cram(cram.clone())), }), Err(AnkiError::CustomStudyError { source: CustomStudyError::NoMatchingCards }) ); assert_eq!( &get_defaults(&mut col)?, &[ ("1", false, false), ("2::two", false, false), ("3", false, false) ] ); // a successful build should update tags cram.card_limit = 100; col.custom_study(CustomStudyRequest { deck_id: 1, value: Some(Value::Cram(cram)), })?; assert_eq!( &get_defaults(&mut col)?, &[ ("1", false, false), ("2::two", true, false), ("3", false, true) ] ); Ok(()) } #[test] fn sql_grouping() -> Result<()> { let mut deck = preview_config("d".into(), 1); assert_eq!(&deck.search_terms[0].search, "is:new added:1 deck:d"); let cram = Cram { tags_to_include: vec!["1".into(), "2".into()], tags_to_exclude: vec!["3".into(), "4".into()], ..Default::default() }; deck = cram_config("d".into(), &cram)?; assert_eq!( &deck.search_terms[0].search, "is:due deck:d (tag:1 OR tag:2) (-tag:3 -tag:4)" ); Ok(()) } } ================================================ FILE: rslib/src/scheduler/filtered/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod card; mod custom_study; use crate::config::ConfigKey; use crate::config::SchedulerVersion; use crate::decks::FilteredDeck; use crate::decks::FilteredSearchTerm; use crate::error::FilteredDeckError; use crate::prelude::*; use crate::scheduler::timing::SchedTimingToday; use crate::search::writer::deck_search; use crate::search::writer::normalize_search; use crate::search::SortMode; use crate::storage::card::filtered::order_and_limit_for_search; /// Contains the parts of a filtered deck required for modifying its settings in /// the UI. pub struct FilteredDeckForUpdate { pub id: DeckId, pub human_name: String, pub config: FilteredDeck, pub allow_empty: bool, } pub(crate) struct DeckFilterContext<'a> { pub target_deck: DeckId, pub config: &'a FilteredDeck, pub usn: Usn, pub timing: SchedTimingToday, } impl Collection { /// Get an existing filtered deck, or create a new one if `deck_id` is 0. /// The new deck will not be added to the DB. pub fn get_or_create_filtered_deck( &mut self, deck_id: DeckId, ) -> Result { let deck = if deck_id.0 == 0 { self.new_filtered_deck_for_adding()? } else { self.storage.get_deck(deck_id)?.or_not_found(deck_id)? }; deck.try_into() } /// If the provided `deck_id` is 0, add provided deck to the DB, and rebuild /// it. If the searches are invalid or do not match anything, adding is /// aborted. If an existing deck is provided, it will be updated. /// Invalid searches or an empty match will abort the update. /// Returns the deck_id, which will have changed if the id was 0. pub fn add_or_update_filtered_deck( &mut self, deck: FilteredDeckForUpdate, ) -> Result> { self.transact(Op::BuildFilteredDeck, |col| { col.add_or_update_filtered_deck_inner(deck) }) } pub fn empty_filtered_deck(&mut self, did: DeckId) -> Result> { self.transact(Op::EmptyFilteredDeck, |col| { let deck = col.get_deck(did)?.or_not_found(did)?; col.return_all_cards_in_filtered_deck(&deck) }) } // Unlike the old Python code, this also marks the cards as modified. pub fn rebuild_filtered_deck(&mut self, did: DeckId) -> Result> { self.transact(Op::RebuildFilteredDeck, |col| { let deck = col.get_deck(did)?.or_not_found(did)?; col.rebuild_filtered_deck_inner(&deck, col.usn()?) }) } } impl Collection { pub(crate) fn return_all_cards_in_filtered_deck(&mut self, deck: &Deck) -> Result<()> { if !deck.is_filtered() { return Err(FilteredDeckError::FilteredDeckRequired.into()); } let cids = self.storage.all_cards_in_single_deck(deck.id)?; self.return_cards_to_home_deck(&cids) } // Unlike the old Python code, this also marks the cards as modified. fn return_cards_to_home_deck(&mut self, cids: &[CardId]) -> Result<()> { let usn = self.usn()?; for cid in cids { if let Some(mut card) = self.storage.get_card(*cid)? { let original = card.clone(); card.remove_from_filtered_deck_restoring_queue(); self.update_card_inner(&mut card, original, usn)?; } } Ok(()) } fn build_filtered_deck(&mut self, ctx: DeckFilterContext) -> Result { let start = -100_000; let mut position = start; let fsrs = self.get_config_bool(BoolKey::Fsrs); for term in ctx.config.search_terms.iter().take(2) { position = self.move_cards_matching_term(&ctx, term, position, fsrs)?; } Ok((position - start) as usize) } /// Move matching cards into filtered deck. /// Returns the new starting position. fn move_cards_matching_term( &mut self, ctx: &DeckFilterContext, term: &FilteredSearchTerm, mut position: i32, fsrs: bool, ) -> Result { let search = format!( "{} -is:suspended -is:buried -deck:filtered", if term.search.trim().is_empty() { "".to_string() } else { format!("({})", term.search) } ); let order = order_and_limit_for_search(term, ctx.timing, fsrs); for mut card in self.all_cards_for_search_in_order(&search, SortMode::Custom(order))? { let original = card.clone(); card.move_into_filtered_deck(ctx, position); self.update_card_inner(&mut card, original, ctx.usn)?; position += 1; } Ok(position) } fn get_next_filtered_deck_name(&self) -> NativeDeckName { NativeDeckName::from_native_str(format!( "Filtered Deck {}", TimestampSecs::now().time_string() )) } fn add_or_update_filtered_deck_inner( &mut self, mut update: FilteredDeckForUpdate, ) -> Result { let usn = self.usn()?; let allow_empty = update.allow_empty; // check the searches are valid, and normalize them for term in &mut update.config.search_terms { term.search = normalize_search(&term.search)? } // add or update the deck let mut deck: Deck; if update.id.0 == 0 { deck = Deck::new_filtered(); apply_update_to_filtered_deck(&mut deck, update); self.add_deck_inner(&mut deck, usn)?; } else { let original = self.storage.get_deck(update.id)?.or_not_found(update.id)?; deck = original.clone(); apply_update_to_filtered_deck(&mut deck, update); self.update_deck_inner(&mut deck, original, usn)?; } // rebuild it let count = self.rebuild_filtered_deck_inner(&deck, usn)?; // if it failed to match any cards, we revert the changes if count == 0 && !allow_empty { Err(FilteredDeckError::SearchReturnedNoCards.into()) } else { // update current deck and return id self.set_config(ConfigKey::CurrentDeckId, &deck.id)?; Ok(deck.id) } } fn rebuild_filtered_deck_inner(&mut self, deck: &Deck, usn: Usn) -> Result { if self.scheduler_version() == SchedulerVersion::V1 { return Err(AnkiError::SchedulerUpgradeRequired); } let config = deck.filtered()?; let timing = self.timing_today()?; let ctx = DeckFilterContext { target_deck: deck.id, config, usn, timing, }; self.return_all_cards_in_filtered_deck(deck)?; self.build_filtered_deck(ctx) } fn new_filtered_deck_for_adding(&mut self) -> Result { let mut deck = Deck { name: self.get_next_filtered_deck_name(), ..Deck::new_filtered() }; if let Some(current) = self.get_deck(self.get_current_deck_id())? { if !current.is_filtered() && current.id.0 != 0 { // start with a search based on the selected deck name let search = deck_search(¤t.human_name()); let term1 = deck .filtered_mut() .unwrap() .search_terms .get_mut(0) .unwrap(); term1.search = format!("{search} is:due"); let term2 = deck .filtered_mut() .unwrap() .search_terms .get_mut(1) .unwrap(); term2.search = format!("{search} is:new"); } } Ok(deck) } } impl TryFrom for FilteredDeckForUpdate { type Error = AnkiError; fn try_from(value: Deck) -> Result { let human_name = value.human_name(); match value.kind { DeckKind::Filtered(filtered) => Ok(FilteredDeckForUpdate { id: value.id, human_name, config: filtered, allow_empty: false, }), _ => invalid_input!("not filtered"), } } } fn apply_update_to_filtered_deck(deck: &mut Deck, update: FilteredDeckForUpdate) { deck.id = update.id; deck.name = NativeDeckName::from_human_name(&update.human_name); deck.kind = DeckKind::Filtered(update.config); } ================================================ FILE: rslib/src/scheduler/fsrs/error.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use fsrs::FSRSError; use crate::error::AnkiError; use crate::error::InvalidInputError; impl From for AnkiError { fn from(err: FSRSError) -> Self { match err { FSRSError::NotEnoughData => AnkiError::FsrsInsufficientData, FSRSError::OptimalNotFound => AnkiError::FsrsUnableToDetermineDesiredRetention, FSRSError::Interrupted => AnkiError::Interrupted, FSRSError::InvalidParameters => AnkiError::FsrsParamsInvalid, FSRSError::InvalidInput => AnkiError::FsrsParamsInvalid, FSRSError::InvalidDeckSize => AnkiError::InvalidInput { source: InvalidInputError { message: "no cards to simulate".to_string(), source: None, backtrace: None, }, }, } } } ================================================ FILE: rslib/src/scheduler/fsrs/memory_state.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use anki_proto::scheduler::ComputeMemoryStateResponse; use fsrs::FSRSItem; use fsrs::MemoryState; use fsrs::FSRS; use fsrs::FSRS5_DEFAULT_DECAY; use fsrs::FSRS6_DEFAULT_DECAY; use itertools::Either; use itertools::Itertools; use super::params::ignore_revlogs_before_ms_from_config; use super::rescheduler::Rescheduler; use crate::card::CardQueue; use crate::card::CardType; use crate::prelude::*; use crate::revlog::RevlogEntry; use crate::scheduler::answering::get_fuzz_seed; use crate::scheduler::fsrs::params::reviews_for_fsrs; use crate::scheduler::fsrs::params::Params; use crate::scheduler::states::fuzz::with_review_fuzz; use crate::search::Negated; use crate::search::SearchNode; use crate::search::StateKind; #[derive(Debug, Clone, Copy, Default)] pub struct ComputeMemoryProgress { pub current_cards: u32, pub total_cards: u32, } /// Helper function to determine the appropriate decay value based on FSRS /// parameters pub(crate) fn get_decay_from_params(params: &[f32]) -> f32 { if params.is_empty() { FSRS6_DEFAULT_DECAY // default decay for FSRS-6 } else if params.len() < 21 { FSRS5_DEFAULT_DECAY // default decay for FSRS-4.5 and FSRS-5 } else { params[20] } } #[derive(Debug)] pub(crate) struct UpdateMemoryStateRequest { pub params: Params, pub preset_desired_retention: f32, pub historical_retention: f32, pub max_interval: u32, pub reschedule: bool, pub deck_desired_retention: HashMap, } pub(crate) struct UpdateMemoryStateEntry { pub req: Option, pub search: SearchNode, pub ignore_before: TimestampMillis, } trait ChunkIntoVecs { fn chunk_into_vecs(&mut self, chunk_size: usize) -> impl Iterator>; } impl ChunkIntoVecs for Vec { fn chunk_into_vecs(&mut self, chunk_size: usize) -> impl Iterator> { std::iter::from_fn(move || { (!self.is_empty()).then(|| self.drain(..chunk_size.min(self.len())).collect()) }) } } impl Collection { /// For each provided set of params, locate cards with the provided search, /// and update their memory state. /// Should be called inside a transaction. /// If Params are None, it means the user disabled FSRS, and the existing /// memory state should be removed. pub(crate) fn update_memory_state( &mut self, entries: Vec, ) -> Result<()> { let timing = self.timing_today()?; let usn = self.usn()?; for UpdateMemoryStateEntry { req, search, ignore_before, } in entries { let search = SearchBuilder::all([search.into(), SearchNode::State(StateKind::New).negated()]); let revlog = self.revlog_for_srs(search)?; let Some(req) = &req else { let items = fsrs_items_for_memory_states( &FSRS::new(Some(&[]))?, revlog, timing.next_day_at, 0.9, ignore_before, )?; let on_updated_card = self.create_progress_closure(items.len())?; // clear FSRS data if FSRS is disabled self.clear_fsrs_data_for_cards( items.into_iter().map(|(card_id, _)| card_id), usn, on_updated_card, )?; continue; }; let fsrs = FSRS::new(Some(&req.params[..]))?; let last_revlog_info = req.reschedule.then(|| get_last_revlog_info(&revlog)); let items = fsrs_items_for_memory_states( &fsrs, revlog, timing.next_day_at, req.historical_retention, ignore_before, )?; let mut on_updated_card = self.create_progress_closure(items.len())?; let (items, cards_without_items): (Vec<(CardId, FsrsItemForMemoryState)>, Vec) = items.into_iter().partition_map(|(card_id, item)| { if let Some(item) = item { Either::Left((card_id, item)) } else { Either::Right(card_id) } }); let decay = get_decay_from_params(&req.params); // Store decay and desired retention in the card so that add-ons, card info, // stats and browser search/sorts don't need to access the deck config. // Unlike memory states, scheduler doesn't use decay and dr stored in the card. let set_decay_and_desired_retention = move |card: &mut Card| { let deck_id = card.original_or_current_deck_id(); let desired_retention = *req .deck_desired_retention .get(&deck_id) .unwrap_or(&req.preset_desired_retention); card.desired_retention = Some(desired_retention); card.decay = Some(decay); }; self.update_memory_state_for_itemless_cards( cards_without_items, set_decay_and_desired_retention, usn, &mut on_updated_card, )?; let mut rescheduler = if req.reschedule && self.get_config_bool(BoolKey::LoadBalancerEnabled) { Some(Rescheduler::new(self)?) } else { None }; let reschedule = move |card: &mut Card, collection: &mut Self, fsrs: &FSRS| -> Result<()> { // we are rescheduling let Some(last_revlog_info) = &last_revlog_info else { return Ok(()); }; // we have a last review time for the card let Some(last_info) = last_revlog_info.get(&card.id) else { return Ok(()); }; let Some(last_review) = &last_info.last_reviewed_at else { return Ok(()); }; // the card isn't in (re)learning or suspended if !(card.ctype == CardType::Review && card.queue != CardQueue::Suspended) { return Ok(()); }; let deck = collection .get_deck(card.original_or_current_deck_id())? .or_not_found(card.original_or_current_deck_id())?; let deckconfig_id = deck.config_id().unwrap(); // reschedule it let days_elapsed = timing.next_day_at.elapsed_days_since(*last_review) as i32; let original_interval = card.interval; let min_interval = |interval: u32| { let previous_interval = last_info.previous_interval.unwrap_or(0); if interval > previous_interval { // interval grew; don't allow fuzzed interval to // be less than previous+1 previous_interval + 1 } else { // interval shrunk; don't restrict negative fuzz 0 } .max(1) }; let interval = fsrs.next_interval( Some( card.memory_state .expect("We set it before this function is called") .stability, ), card.desired_retention .expect("We set it before this function is called"), 0, ); card.interval = rescheduler .as_mut() .and_then(|r| { r.find_interval( interval, min_interval(interval as u32), req.max_interval, days_elapsed as u32, deckconfig_id, get_fuzz_seed(card, true), ) }) .unwrap_or_else(|| { with_review_fuzz( card.get_fuzz_factor(true), interval, min_interval(interval as u32), req.max_interval, ) }); let due = if card.original_due != 0 { &mut card.original_due } else { &mut card.due }; let new_due = (timing.days_elapsed as i32) - days_elapsed + card.interval as i32; if let Some(rescheduler) = &mut rescheduler { rescheduler.update_due_cnt_per_day(*due, new_due, deckconfig_id); } *due = new_due; // Add a rescheduled revlog entry collection.log_rescheduled_review(card, original_interval, usn)?; Ok(()) }; self.update_memory_state_for_cards_with_items( items, &fsrs, set_decay_and_desired_retention, reschedule, usn, on_updated_card, )?; } Ok(()) } fn create_progress_closure(&self, item_count: usize) -> Result Result<()>> { let mut progress = self.new_progress_handler::(); progress.update(false, |s| { s.total_cards = item_count as u32; s.current_cards = 1; })?; let on_updated_card = move || progress.update(true, |p| p.current_cards += 1); Ok(on_updated_card) } fn clear_fsrs_data_for_cards( &mut self, cards: impl Iterator, usn: Usn, mut on_updated_card: impl FnMut() -> Result<()>, ) -> Result<()> { for card_id in cards { let mut card = self.storage.get_card(card_id)?.or_not_found(card_id)?; let original = card.clone(); card.clear_fsrs_data(); self.update_card_inner(&mut card, original, usn)?; on_updated_card()? } Ok(()) } fn update_memory_state_for_itemless_cards( &mut self, cards: Vec, mut set_decay_and_desired_retention: impl FnMut(&mut Card), usn: Usn, mut on_updated_card: impl FnMut() -> Result<()>, ) -> Result<()> { for card_id in cards { let mut card = self.storage.get_card(card_id)?.or_not_found(card_id)?; let original = card.clone(); set_decay_and_desired_retention(&mut card); card.memory_state = None; self.update_card_inner(&mut card, original, usn)?; on_updated_card()?; } Ok(()) } fn update_memory_state_for_cards_with_items( &mut self, items: Vec<(CardId, FsrsItemForMemoryState)>, fsrs: &FSRS, mut set_decay_and_desired_retention: impl FnMut(&mut Card), mut maybe_reschedule_card: impl FnMut(&mut Card, &mut Self, &FSRS) -> Result<()>, usn: Usn, mut on_updated_card: impl FnMut() -> Result<()>, ) -> Result<()> { const FSRS_BATCH_SIZE: usize = 1000; let mut to_update = Vec::new(); let mut fsrs_items = Vec::new(); let mut starting_states = Vec::new(); for (card_id, item) in items.into_iter() { to_update.push(card_id); fsrs_items.push(item.item); starting_states.push(item.starting_state); } // fsrs.memory_state_batch is O(nm) where n is the number of cards and m is the // max review count between all items. Therefore we want to pass batches // to fsrs.memory_state_batch where the review count is relatively even. let mut p = permutation::sort_unstable_by_key(&fsrs_items, |item| item.reviews.len()); p.apply_slice_in_place(&mut to_update); p.apply_slice_in_place(&mut fsrs_items); p.apply_slice_in_place(&mut starting_states); for ((to_update, fsrs_items), starting_states) in to_update .chunk_into_vecs(FSRS_BATCH_SIZE) .zip_eq(fsrs_items.chunk_into_vecs(FSRS_BATCH_SIZE)) .zip_eq(starting_states.chunk_into_vecs(FSRS_BATCH_SIZE)) { let memory_states = fsrs.memory_state_batch(fsrs_items, starting_states)?; for (card_id, memory_state) in to_update.into_iter().zip_eq(memory_states) { let mut card = self.storage.get_card(card_id)?.or_not_found(card_id)?; let original = card.clone(); set_decay_and_desired_retention(&mut card); card.memory_state = Some(memory_state.into()); maybe_reschedule_card(&mut card, self, fsrs)?; self.update_card_inner(&mut card, original, usn)?; on_updated_card()?; } } Ok(()) } pub fn compute_memory_state(&mut self, card_id: CardId) -> Result { let mut card = self.storage.get_card(card_id)?.or_not_found(card_id)?; let deck_id = card.original_deck_id.or(card.deck_id); let deck = self.get_deck(deck_id)?.or_not_found(card.deck_id)?; let conf_id = DeckConfigId(deck.normal()?.config_id); let config = self .storage .get_deck_config(conf_id)? .or_not_found(conf_id)?; // Get deck-specific desired retention if available, otherwise use config // default let desired_retention = deck.effective_desired_retention(&config); let historical_retention = config.inner.historical_retention; let params = config.fsrs_params(); let decay = get_decay_from_params(params); let fsrs = FSRS::new(Some(params))?; let revlog = self.revlog_for_srs(SearchNode::CardIds(card.id.to_string()))?; let item = fsrs_item_for_memory_state( &fsrs, revlog, self.timing_today()?.next_day_at, historical_retention, ignore_revlogs_before_ms_from_config(&config)?, )?; if item.is_some() { card.set_memory_state(&fsrs, item, historical_retention)?; Ok(ComputeMemoryStateResponse { state: card.memory_state.map(Into::into), desired_retention, decay, }) } else { Ok(ComputeMemoryStateResponse { state: None, desired_retention, decay, }) } } } impl Card { pub(crate) fn set_memory_state( &mut self, fsrs: &FSRS, item: Option, historical_retention: f32, ) -> Result<()> { let memory_state = if let Some(i) = item { Some(fsrs.memory_state(i.item, i.starting_state)?) } else if self.ctype == CardType::New || self.interval == 0 { None } else { // no valid revlog entries; infer state from current card state Some(fsrs.memory_state_from_sm2( self.ease_factor(), self.interval as f32, historical_retention, )?) }; self.memory_state = memory_state.map(Into::into); Ok(()) } } #[derive(Debug, Clone)] pub(crate) struct FsrsItemForMemoryState { pub item: FSRSItem, /// When revlogs have been truncated, this stores the initial state at first /// review pub starting_state: Option, pub filtered_revlogs: Vec, } /// Like [fsrs_item_for_memory_state], but for updating multiple cards at once. pub(crate) fn fsrs_items_for_memory_states( fsrs: &FSRS, revlogs: Vec, next_day_at: TimestampSecs, historical_retention: f32, ignore_revlogs_before: TimestampMillis, ) -> Result)>> { revlogs .into_iter() .chunk_by(|r| r.cid) .into_iter() .map(|(card_id, group)| { Ok(( card_id, fsrs_item_for_memory_state( fsrs, group.collect(), next_day_at, historical_retention, ignore_revlogs_before, )?, )) }) .collect() } pub(crate) struct LastRevlogInfo { /// Used to determine the actual elapsed time between the last time the user /// reviewed the card and now, so that we can determine an accurate period /// when the card has subsequently been rescheduled to a different day. pub(crate) last_reviewed_at: Option, /// The interval before the latest review. Used to prevent fuzz from going /// backwards when rescheduling the card pub(crate) previous_interval: Option, } /// Return a map of cards to info about last review. pub(crate) fn get_last_revlog_info(revlogs: &[RevlogEntry]) -> HashMap { let mut out = HashMap::new(); revlogs .iter() .chunk_by(|r| r.cid) .into_iter() .for_each(|(card_id, group)| { let mut last_reviewed_at = None; let mut previous_interval = None; for e in group.into_iter() { if e.has_rating_and_affects_scheduling() { last_reviewed_at = Some(e.id.as_secs()); previous_interval = if e.last_interval >= 0 && e.button_chosen > 1 { Some(e.last_interval as u32) } else { None }; } else if e.is_reset() { last_reviewed_at = None; previous_interval = None; } } out.insert( card_id, LastRevlogInfo { last_reviewed_at, previous_interval, }, ); }); out } /// When calculating memory state, only the last FSRSItem is required. If the /// revlog is non-empty and no learning steps have been detected (indicative of /// a truncated revlog), we return the starting state inferred from the first /// revlog entry, so that the first review is not treated as if started from /// scratch. pub(crate) fn fsrs_item_for_memory_state( fsrs: &FSRS, entries: Vec, next_day_at: TimestampSecs, historical_retention: f32, ignore_revlogs_before: TimestampMillis, ) -> Result> { struct FirstReview { interval: f32, ease_factor: f32, } if let Some(mut output) = reviews_for_fsrs(entries, next_day_at, false, ignore_revlogs_before) { let mut item = output.fsrs_items.pop().unwrap().1; if output.revlogs_complete { Ok(Some(FsrsItemForMemoryState { item, starting_state: None, filtered_revlogs: output.filtered_revlogs, })) } else if let Some(first_user_grade) = output.filtered_revlogs.first() { // the revlog has been truncated, but not fully let first_review = FirstReview { interval: first_user_grade.interval.max(1) as f32, ease_factor: if first_user_grade.ease_factor == 0 { 2500 } else { first_user_grade.ease_factor } as f32 / 1000.0, }; let mut starting_state = fsrs.memory_state_from_sm2( first_review.ease_factor, first_review.interval, historical_retention, )?; // if the ease factor is less than 1.1, the revlog entry is generated by FSRS if first_review.ease_factor <= 1.1 { starting_state.difficulty = (first_review.ease_factor - 0.1) * 9.0 + 1.0; } // remove the first review because it has been converted to the starting state item.reviews.remove(0); Ok(Some(FsrsItemForMemoryState { item, starting_state: Some(starting_state), filtered_revlogs: output.filtered_revlogs, })) } else { // only manual and rescheduled revlogs; treat like empty Ok(None) } } else { // no revlogs (new card or caused by ignore_revlogs_before or deleted revlogs) Ok(None) } } #[cfg(test)] mod tests { use fsrs::MemoryState; use super::*; use crate::card::FsrsMemoryState; use crate::revlog::RevlogReviewKind; use crate::scheduler::fsrs::params::tests::convert; use crate::scheduler::fsrs::params::tests::revlog; /// Floating point precision can vary between platforms, and each FSRS /// update tends to result in small changes to these numbers, so we /// round them. fn assert_int_eq(actual: Option, expected: Option) { let actual = actual.unwrap(); let expected = expected.unwrap(); assert_eq!(actual.stability.round(), expected.stability.round()); assert_eq!(actual.difficulty.round(), expected.difficulty.round()); } #[test] fn bypassed_learning_is_handled() -> Result<()> { // cards without any learning steps due to truncated history still have memory // state calculated let fsrs = FSRS::new(Some(&[])).unwrap(); let item = fsrs_item_for_memory_state( &fsrs, vec![ RevlogEntry { ease_factor: 2500, interval: 100, ..revlog(RevlogReviewKind::Review, 99) }, revlog(RevlogReviewKind::Review, 0), ], TimestampSecs::now(), 0.9, 0.into(), )? .unwrap(); assert_int_eq( item.starting_state.map(Into::into), Some(FsrsMemoryState { stability: 100.0, difficulty: 5.003576, }), ); let mut card = Card { reps: 1, ..Default::default() }; card.set_memory_state(&fsrs, Some(item), 0.9)?; assert_int_eq( card.memory_state, Some(FsrsMemoryState { stability: 248.9251, difficulty: 4.9938006, }), ); // cards with a single review-type entry also get memory states from revlog // rather than card states let item = fsrs_item_for_memory_state( &fsrs, vec![RevlogEntry { ease_factor: 2500, interval: 100, ..revlog(RevlogReviewKind::Review, 100) }], TimestampSecs::now(), 0.9, 0.into(), )? .unwrap(); assert!(item.item.reviews.is_empty()); card.set_memory_state(&fsrs, Some(item), 0.9)?; assert_int_eq( card.memory_state, Some(FsrsMemoryState { stability: 100.0, difficulty: 5.003576, }), ); Ok(()) } #[test] fn zero_history_is_handled() -> Result<()> { // when the history is empty, no items are produced assert_eq!(convert(&[], false), None); // but memory state should still be inferred, by using the card's current state let mut card = Card { ctype: CardType::Review, interval: 100, ease_factor: 1300, reps: 1, ..Default::default() }; card.set_memory_state(&FSRS::new(Some(&[])).unwrap(), None, 0.9)?; assert_int_eq( card.memory_state, Some( MemoryState { stability: 99.999954, difficulty: 9.979899, } .into(), ), ); Ok(()) } mod update_memory_state { use super::*; #[test] fn no_req_clears_fsrs_data() -> Result<()> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note1 = nt.new_note(); col.add_note(&mut note1, DeckId(1))?; let mut card = col .storage .all_cards_of_note(note1.id)? .into_iter() .next() .unwrap(); let card_id = card.id; // Make the card not new card.ctype = CardType::Review; card.interval = 1; // Set FSRS parameters card.memory_state = Some(FsrsMemoryState { stability: 1.0, difficulty: 1.0, }); card.desired_retention = Some(0.123); card.decay = Some(0.456); col.storage.update_card(&card)?; // Add a revlog entry so the card is found within update_memory_state let mut rev = revlog(RevlogReviewKind::Review, 1); rev.cid = card_id; col.storage.add_revlog_entry(&rev, false)?; let entry = UpdateMemoryStateEntry { req: None, search: SearchNode::WholeCollection, ignore_before: TimestampMillis(0), }; col.transact(Op::UpdateDeckConfig, |col| { col.update_memory_state(vec![entry]).unwrap(); Ok(()) }) .unwrap(); let card = col.storage.get_card(card_id)?.unwrap(); assert_eq!(card.memory_state, None); assert_eq!(card.desired_retention, None); assert_eq!(card.decay, None); Ok(()) } } } ================================================ FILE: rslib/src/scheduler/fsrs/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod error; pub mod memory_state; pub mod params; pub mod rescheduler; pub mod retention; pub mod simulator; pub mod try_collect; ================================================ FILE: rslib/src/scheduler/fsrs/params.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::iter; use std::path::Path; use std::thread; use std::time::Duration; use anki_io::write_file; use anki_proto::scheduler::ComputeFsrsParamsResponse; use anki_proto::stats::revlog_entry; use anki_proto::stats::Dataset; use anki_proto::stats::DeckEntry; use chrono::NaiveDate; use chrono::NaiveTime; use fsrs::CombinedProgressState; use fsrs::ComputeParametersInput; use fsrs::FSRSItem; use fsrs::FSRSReview; use fsrs::MemoryState; use fsrs::ModelEvaluation; use fsrs::FSRS; use itertools::Itertools; use prost::Message; use crate::decks::immediate_parent_name; use crate::prelude::*; use crate::revlog::RevlogEntry; use crate::revlog::RevlogReviewKind; use crate::search::Node; use crate::search::SearchNode; use crate::search::SortMode; pub(crate) type Params = Vec; pub(crate) fn ignore_revlogs_before_date_to_ms( ignore_revlogs_before_date: &String, ) -> Result { Ok(match ignore_revlogs_before_date { s if s.is_empty() => 0, s => NaiveDate::parse_from_str(s.as_str(), "%Y-%m-%d") .or_else(|err| invalid_input!(err, "Error parsing date: {s}"))? .and_time(NaiveTime::from_hms_milli_opt(0, 0, 0, 0).unwrap()) .and_utc() .timestamp_millis(), } .into()) } pub(crate) fn ignore_revlogs_before_ms_from_config(config: &DeckConfig) -> Result { ignore_revlogs_before_date_to_ms(&config.inner.ignore_revlogs_before_date) } pub struct ComputeParamsRequest<'t> { pub search: &'t str, pub ignore_revlogs_before_ms: TimestampMillis, pub current_preset: u32, pub total_presets: u32, pub current_params: &'t Params, pub num_of_relearning_steps: usize, pub health_check: bool, } /// r: retention fn log_loss_adjustment(r: f32) -> f32 { 0.623 * (4. * r * (1. - r)).powf(0.738) } /// r: retention /// /// c: review count fn rmse_adjustment(r: f32, c: u32) -> f32 { 0.0135 / (r.powf(0.504) - 1.14) + 0.176 / ((c as f32 / 1000.).powf(0.825) + 2.22) + 0.101 } impl Collection { /// Note this does not return an error if there are less than 400 items - /// the caller should instead check the fsrs_items count in the return /// value. pub fn compute_params( &mut self, request: ComputeParamsRequest, ) -> Result { let ComputeParamsRequest { search, ignore_revlogs_before_ms: ignore_revlogs_before, current_preset, total_presets, current_params, num_of_relearning_steps, health_check, } = request; self.clear_progress(); let timing = self.timing_today()?; let revlogs = self.revlog_for_srs(search)?; let (items, review_count) = fsrs_items_for_training(revlogs.clone(), timing.next_day_at, ignore_revlogs_before); let fsrs_items = items.len() as u32; if fsrs_items == 0 { return Ok(ComputeFsrsParamsResponse { params: current_params.to_vec(), fsrs_items, health_check_passed: None, }); } // adapt the progress handler to our built-in progress handling let create_progress_thread = || -> Result<_> { let mut anki_progress = self.new_progress_handler::(); anki_progress.update(false, |p| { p.current_preset = current_preset; p.total_presets = total_presets; })?; let progress = CombinedProgressState::new_shared(); let progress2 = progress.clone(); let progress_thread = thread::spawn(move || { let mut finished = false; while !finished { thread::sleep(Duration::from_millis(100)); let mut guard = progress.lock().unwrap(); if let Err(_err) = anki_progress.update(false, |s| { s.total_iterations = guard.total() as u32; s.current_iteration = guard.current() as u32; s.reviews = review_count as u32; finished = guard.finished(); }) { guard.want_abort = true; return; } } }); Ok((progress2, progress_thread)) }; let (progress, progress_thread) = create_progress_thread()?; let fsrs = FSRS::new(None)?; let input = ComputeParametersInput { train_set: items.clone(), progress: Some(progress.clone()), enable_short_term: true, num_relearning_steps: Some(num_of_relearning_steps), }; let mut params = fsrs.compute_parameters(input.clone())?; progress_thread.join().ok(); if let Ok(current_fsrs) = FSRS::new(Some(current_params)) { let current_log_loss = current_fsrs.evaluate(items.clone(), |_| true)?.log_loss; let optimized_fsrs = FSRS::new(Some(¶ms))?; let optimized_log_loss = optimized_fsrs.evaluate(items.clone(), |_| true)?.log_loss; if current_log_loss <= optimized_log_loss { if num_of_relearning_steps <= 1 { params = current_params.to_vec(); } else { let memory_state = MemoryState { stability: 1.0, difficulty: 1.0, }; let s_fail = current_fsrs.next_states(Some(memory_state), 0.9, 2)?.again; let mut s_short_term = s_fail.memory; for _ in 0..num_of_relearning_steps { s_short_term = current_fsrs .next_states(Some(s_short_term), 0.9, 0)? .good .memory; } if s_short_term.stability < memory_state.stability { params = current_params.to_vec(); } } } } let health_check_passed = if health_check && input.train_set.len() > 300 { let fsrs = FSRS::new(None)?; fsrs.evaluate_with_time_series_splits(input, |_| true) .ok() .map(|eval| { let r = items.iter().fold(0, |p, item| { p + (item .reviews .last() .map(|reviews| reviews.rating) .unwrap_or(0) > 1) as u32 }) as f32 / fsrs_items as f32; let adjusted_log_loss = eval.log_loss / log_loss_adjustment(r); let adjusted_rmse = eval.rmse_bins / rmse_adjustment(r, fsrs_items); adjusted_log_loss <= 1.11 || adjusted_rmse <= 1.53 }) } else { None }; Ok(ComputeFsrsParamsResponse { params, fsrs_items, health_check_passed, }) } pub(crate) fn revlog_for_srs( &mut self, search: impl TryIntoSearch, ) -> Result> { let search = search.try_into_search()?; // a whole-collection search can match revlog entries of deleted cards, too if let Node::Group(nodes) = &search { if let &[Node::Search(SearchNode::WholeCollection)] = &nodes[..] { return self.storage.get_all_revlog_entries_in_card_order(); } } self.search_cards_into_table(search, SortMode::NoOrder)? .col .storage .get_revlog_entries_for_searched_cards_in_card_order() } /// Used for exporting revlogs for algorithm research. pub fn export_dataset(&mut self, min_entries: usize, target_path: &Path) -> Result<()> { let revlog_entries = self.storage.get_revlog_entries_for_export_dataset()?; if revlog_entries.len() < min_entries { return Err(AnkiError::FsrsInsufficientData); } let revlogs = revlog_entries .into_iter() .map(revlog_entry_to_proto) .collect_vec(); let cards = self.storage.get_all_card_entries()?; let decks_map = self.storage.get_decks_map()?; let deck_name_to_id: HashMap = decks_map .into_iter() .map(|(id, deck)| (deck.name.to_string(), id)) .collect(); let decks = self .storage .get_all_decks()? .into_iter() .filter_map(|deck| { if let Some(preset_id) = deck.config_id().map(|id| id.0) { let parent_id = immediate_parent_name(&deck.name.to_string()) .and_then(|parent_name| deck_name_to_id.get(parent_name)) .map(|id| id.0) .unwrap_or(0); Some(DeckEntry { id: deck.id.0, parent_id, preset_id, }) } else { None } }) .collect_vec(); let next_day_at = self.timing_today()?.next_day_at.0; let dataset = Dataset { revlogs, cards, decks, next_day_at, }; let data = dataset.encode_to_vec(); write_file(target_path, data)?; Ok(()) } pub fn evaluate_params( &mut self, search: &str, ignore_revlogs_before: TimestampMillis, num_of_relearning_steps: usize, ) -> Result { let timing = self.timing_today()?; let revlogs = self.revlog_for_srs(search)?; let (items, review_count) = fsrs_items_for_training(revlogs, timing.next_day_at, ignore_revlogs_before); let mut anki_progress = self.new_progress_handler::(); anki_progress.state.reviews = review_count as u32; let fsrs = FSRS::new(None)?; let input = ComputeParametersInput { train_set: items.clone(), progress: None, enable_short_term: true, num_relearning_steps: Some(num_of_relearning_steps), }; Ok(fsrs.evaluate_with_time_series_splits(input, |ip| { anki_progress .update(false, |p| { p.total_iterations = ip.total as u32; p.current_iteration = ip.current as u32; }) .is_ok() })?) } pub fn evaluate_params_legacy( &mut self, params: &Params, search: &str, ignore_revlogs_before: TimestampMillis, ) -> Result { let timing = self.timing_today()?; let mut anki_progress = self.new_progress_handler::(); let guard = self.search_cards_into_table(search, SortMode::NoOrder)?; let revlogs: Vec = guard .col .storage .get_revlog_entries_for_searched_cards_in_card_order()?; let (items, review_count) = fsrs_items_for_training(revlogs, timing.next_day_at, ignore_revlogs_before); anki_progress.state.reviews = review_count as u32; let fsrs = FSRS::new(Some(params))?; Ok(fsrs.evaluate(items, |ip| { anki_progress .update(false, |p| { p.total_iterations = ip.total as u32; p.current_iteration = ip.current as u32; }) .is_ok() })?) } } #[derive(Default, Clone, Copy, Debug)] pub struct ComputeParamsProgress { pub current_iteration: u32, pub total_iterations: u32, pub reviews: u32, /// Only used in 'compute all params' case pub current_preset: u32, /// Only used in 'compute all params' case pub total_presets: u32, } /// Convert a series of revlog entries sorted by card id into FSRS items. fn fsrs_items_for_training( revlogs: Vec, next_day_at: TimestampSecs, review_revlogs_before: TimestampMillis, ) -> (Vec, usize) { let mut review_count: usize = 0; let mut revlogs = revlogs .into_iter() .chunk_by(|r| r.cid) .into_iter() .filter_map(|(_cid, entries)| { reviews_for_fsrs(entries.collect(), next_day_at, true, review_revlogs_before) }) .flat_map(|i| { review_count += i.filtered_revlogs.len(); i.fsrs_items }) .collect_vec(); // Sort by RevlogId revlogs.sort_by_key(|(revlog_id, _)| revlog_id.0); // Extract only the FSRSItems after sorting let revlogs = revlogs.into_iter().map(|(_, item)| item).collect_vec(); (revlogs, review_count) } pub(crate) struct ReviewsForFsrs { /// The revlog entries that remain after filtering (e.g. excluding /// review entries prior to a card being reset). pub filtered_revlogs: Vec, /// FSRS items derived from the filtered revlogs. pub fsrs_items: Vec<(RevlogId, FSRSItem)>, /// True if there is enough history to derive memory state from history /// alone. If false, memory state will be derived from SM2. pub revlogs_complete: bool, } /// Filter out unwanted revlog entries, then create a series of FSRS items for /// training/memory state calculation. /// /// Filtering consists of removing revlog entries before the supplied timestamp, /// and removing items such as reviews that happened prior to a card being reset /// to new. pub(crate) fn reviews_for_fsrs( mut entries: Vec, next_day_at: TimestampSecs, training: bool, ignore_revlogs_before: TimestampMillis, ) -> Option { let mut first_of_last_learn_entries = None; let mut first_user_grade_idx = None; let mut revlogs_complete = false; // Working backwards from the latest review... for (index, entry) in entries.iter().enumerate().rev() { if entry.is_cramming() { continue; } // For incomplete review histories, initial memory state is based on the first // user-graded review after the cutoff date with interval >= 1d. let within_cutoff = entry.id.0 > ignore_revlogs_before.0; let user_graded = entry.has_rating(); let interday = entry.interval >= 1 || entry.interval <= -86400; if user_graded && within_cutoff && interday { first_user_grade_idx = Some(index); } if user_graded && entry.review_kind == RevlogReviewKind::Learning { first_of_last_learn_entries = Some(index); revlogs_complete = true; } else if entry.is_reset() { // Ignore entries prior to a `Reset` if a learning step has come after, // but consider revlogs complete. if first_of_last_learn_entries.is_some() { revlogs_complete = true; break; // Ignore entries prior to a `Reset` if the user has graded a card // after the reset. } else if first_user_grade_idx.is_some() { revlogs_complete = false; break; // User has not graded the card since it was reset, so all history // filtered out. } else { return None; } // Previous versions of Anki didn't add a revlog entry when the card was // reset. } else if first_of_last_learn_entries.is_some() { break; } } if training { // While training, ignore the entire card if the first learning step of the last // group of learning steps is before the ignore_revlogs_before date if let Some(idx) = first_of_last_learn_entries { if entries[idx].id.0 < ignore_revlogs_before.0 { return None; } } } else { // While reviewing, if the first learning step is before the ignore date, // we ignore it, and will fall back on SM2 info and the last user grade below. if let Some(idx) = first_of_last_learn_entries { if entries[idx].id.0 < ignore_revlogs_before.0 && idx < entries.len() - 1 { revlogs_complete = false; first_of_last_learn_entries = None; } } } if let Some(idx) = first_of_last_learn_entries { // start from the learning step if idx > 0 { entries.drain(..idx); } } else if training { // when training, we ignore cards that don't have any learning steps return None; } else if let Some(idx) = first_user_grade_idx { // if there are no learning entries, but the user has reviewed the card, // we ignore all entries before the first grade if idx > 0 { entries.drain(..idx); } } else { // if no valid user grades were found, ignore the card. return None; } // Filter out unwanted entries entries.retain(|entry| entry.has_rating_and_affects_scheduling()); // Compute delta_t for each entry let delta_ts = iter::once(0) .chain(entries.iter().tuple_windows().map(|(previous, current)| { previous.days_elapsed(next_day_at) - current.days_elapsed(next_day_at) })) .collect_vec(); let items = if training { // Convert the remaining entries into separate FSRSItems, where each item // contains all reviews done until then. let mut items = Vec::with_capacity(entries.len()); let mut current_reviews = Vec::with_capacity(entries.len()); for (idx, (entry, &delta_t)) in entries.iter().zip(delta_ts.iter()).enumerate() { current_reviews.push(FSRSReview { rating: entry.button_chosen as u32, delta_t, }); if idx >= 1 && delta_t > 0 { items.push(( entry.id, FSRSItem { reviews: current_reviews.clone(), }, )); } } items } else { // When not training, we only need the final FSRS item, which represents // the complete history of the card. This avoids expensive clones in a loop. let reviews = entries .iter() .zip(delta_ts.iter()) .map(|(entry, &delta_t)| FSRSReview { rating: entry.button_chosen as u32, delta_t, }) .collect(); let last_entry = entries.last().unwrap(); vec![(last_entry.id, FSRSItem { reviews })] }; if items.is_empty() { None } else { Some(ReviewsForFsrs { fsrs_items: items, revlogs_complete, filtered_revlogs: entries, }) } } impl RevlogEntry { fn days_elapsed(&self, next_day_at: TimestampSecs) -> u32 { (next_day_at.elapsed_secs_since(self.id.as_secs()) / 86_400).max(0) as u32 } } fn revlog_entry_to_proto(e: RevlogEntry) -> anki_proto::stats::RevlogEntry { anki_proto::stats::RevlogEntry { id: e.id.0, cid: e.cid.0, usn: 0, button_chosen: e.button_chosen as u32, interval: e.interval, last_interval: e.last_interval, ease_factor: e.ease_factor, taken_millis: e.taken_millis, review_kind: match e.review_kind { RevlogReviewKind::Learning => revlog_entry::ReviewKind::Learning, RevlogReviewKind::Review => revlog_entry::ReviewKind::Review, RevlogReviewKind::Relearning => revlog_entry::ReviewKind::Relearning, RevlogReviewKind::Filtered => revlog_entry::ReviewKind::Filtered, RevlogReviewKind::Manual => revlog_entry::ReviewKind::Manual, RevlogReviewKind::Rescheduled => revlog_entry::ReviewKind::Rescheduled, } as i32, } } #[cfg(test)] pub(crate) mod tests { use super::*; const NEXT_DAY_AT: TimestampSecs = TimestampSecs(86400 * 1000); fn days_ago_ms(days_ago: i64) -> TimestampMillis { ((NEXT_DAY_AT.0 - days_ago * 86400) * 1000).into() } pub(crate) fn revlog(review_kind: RevlogReviewKind, days_ago: i64) -> RevlogEntry { let button_chosen = match review_kind { RevlogReviewKind::Manual | RevlogReviewKind::Rescheduled => 0, _ => 3, }; RevlogEntry { review_kind, id: days_ago_ms(days_ago).into(), button_chosen, interval: 1, ..Default::default() } } pub(crate) fn review(delta_t: u32) -> FSRSReview { FSRSReview { rating: 3, delta_t } } pub(crate) fn convert_ignore_before( revlog: &[RevlogEntry], training: bool, ignore_before: TimestampMillis, ) -> Option> { reviews_for_fsrs(revlog.to_vec(), NEXT_DAY_AT, training, ignore_before) .map(|i| i.fsrs_items.into_iter().map(|(_, item)| item).collect_vec()) } pub(crate) fn convert(revlog: &[RevlogEntry], training: bool) -> Option> { convert_ignore_before(revlog, training, 0.into()) } #[macro_export] macro_rules! fsrs_items { ($($reviews:expr),*) => { Some(vec![ $( FSRSItem { reviews: $reviews.to_vec() } ),* ]) }; } #[test] fn delta_t_is_correct() -> Result<()> { assert_eq!( convert( &[ revlog(RevlogReviewKind::Learning, 1), revlog(RevlogReviewKind::Review, 0) ], true, ), fsrs_items!([review(0), review(1)]) ); assert_eq!( convert( &[ revlog(RevlogReviewKind::Learning, 15), revlog(RevlogReviewKind::Learning, 13), revlog(RevlogReviewKind::Review, 10), revlog(RevlogReviewKind::Review, 5) ], true, ), fsrs_items!( [review(0), review(2)], [review(0), review(2), review(3)], [review(0), review(2), review(3), review(5)] ) ); assert_eq!( convert( &[ revlog(RevlogReviewKind::Learning, 15), revlog(RevlogReviewKind::Learning, 13), ], true, ), fsrs_items!([review(0), review(2),]) ); Ok(()) } #[test] fn cram_is_filtered() { assert_eq!( convert( &[ revlog(RevlogReviewKind::Learning, 10), revlog(RevlogReviewKind::Review, 9), revlog(RevlogReviewKind::Filtered, 7), revlog(RevlogReviewKind::Review, 4), ], true, ), fsrs_items!([review(0), review(1)], [review(0), review(1), review(5)]) ); } #[test] fn set_due_date_is_filtered() { assert_eq!( convert( &[ revlog(RevlogReviewKind::Learning, 10), revlog(RevlogReviewKind::Review, 9), RevlogEntry { ease_factor: 100, ..revlog(RevlogReviewKind::Manual, 7) }, revlog(RevlogReviewKind::Review, 4), ], true, ), fsrs_items!([review(0), review(1)], [review(0), review(1), review(5)]) ); } #[test] fn card_reset_drops_all_previous_history() { // If Reset comes in between two Learn entries, only the ones after the Reset // are used. assert_eq!( convert( &[ revlog(RevlogReviewKind::Learning, 10), RevlogEntry { ease_factor: 0, ..revlog(RevlogReviewKind::Manual, 7) }, revlog(RevlogReviewKind::Learning, 4), revlog(RevlogReviewKind::Review, 0), ], true, ), fsrs_items!([review(0), review(4)]) ); // Return None if Reset is the last entry or is followed by only manual entries. assert_eq!( convert( &[ revlog(RevlogReviewKind::Learning, 10), revlog(RevlogReviewKind::Review, 9), RevlogEntry { ease_factor: 0, ..revlog(RevlogReviewKind::Manual, 7) }, RevlogEntry { ease_factor: 100, ..revlog(RevlogReviewKind::Manual, 7) }, ], false, ), None, ); // If non-learning user-graded entries are found after Reset, return None during // training but return the remaining entries during memory state calculation. assert_eq!( convert( &[ revlog(RevlogReviewKind::Learning, 10), revlog(RevlogReviewKind::Review, 9), RevlogEntry { ease_factor: 0, ..revlog(RevlogReviewKind::Manual, 7) }, revlog(RevlogReviewKind::Review, 1), revlog(RevlogReviewKind::Relearning, 0), ], true, ), None, ); assert_eq!( convert( &[ revlog(RevlogReviewKind::Review, 9), RevlogEntry { ease_factor: 0, ..revlog(RevlogReviewKind::Manual, 7) }, revlog(RevlogReviewKind::Review, 1), revlog(RevlogReviewKind::Relearning, 0), ], false, ), fsrs_items!([review(0), review(1)]) ); } #[test] fn single_learning_step_skipped_when_training() { assert_eq!( convert(&[revlog(RevlogReviewKind::Learning, 1),], true), None, ); assert_eq!( convert(&[revlog(RevlogReviewKind::Learning, 1),], false), fsrs_items!([review(0)]) ); } #[test] fn ignores_cards_before_ignore_before_date_when_training() { let revlogs = &[ revlog(RevlogReviewKind::Learning, 10), revlog(RevlogReviewKind::Learning, 8), ]; // | = Ignore before // L = learning step // L L | assert_eq!(convert_ignore_before(revlogs, true, days_ago_ms(7)), None); // L | L assert_eq!(convert_ignore_before(revlogs, true, days_ago_ms(9)), None); // L (|L) (exact same millisecond) assert_eq!( convert_ignore_before(revlogs, true, days_ago_ms(10)), convert(revlogs, true) ); // | L L assert_eq!( convert_ignore_before(revlogs, true, days_ago_ms(11)), convert(revlogs, true) ); } #[test] fn partially_ignored_learning_steps_terminate_training() { let revlogs = &[ revlog(RevlogReviewKind::Learning, 10), revlog(RevlogReviewKind::Learning, 8), revlog(RevlogReviewKind::Review, 6), ]; // | = Ignore before // L = learning step // L | L R assert_eq!(convert_ignore_before(revlogs, true, days_ago_ms(9)), None); } #[test] fn skip_initial_relearning_steps() { let revlogs = &[ revlog(RevlogReviewKind::Review, 10), RevlogEntry { button_chosen: 1, // Again interval: -600, ..revlog(RevlogReviewKind::Review, 8) }, revlog(RevlogReviewKind::Relearning, 8), revlog(RevlogReviewKind::Review, 6), ]; // | = Ignore before // A = Again // X = Relearning // R | A X R assert_eq!( convert_ignore_before(revlogs, false, days_ago_ms(9)), fsrs_items!([review(0), review(2)]) ); } #[test] fn ignore_before_date_between_learning_steps_when_reviewing() { let revlogs = &[ revlog(RevlogReviewKind::Learning, 10), revlog(RevlogReviewKind::Learning, 8), revlog(RevlogReviewKind::Review, 2), ]; // L | L R assert_ne!( convert_ignore_before(revlogs, false, days_ago_ms(9)), convert(revlogs, false) ); assert_eq!( convert_ignore_before(revlogs, false, days_ago_ms(9)) .unwrap() .last() .unwrap() .reviews .len(), 2 ); // | L L R assert_eq!( convert_ignore_before(revlogs, false, days_ago_ms(11)), convert(revlogs, false) ); } #[test] fn handle_ignore_before_when_no_learning_steps() { let revlogs = &[ revlog(RevlogReviewKind::Review, 10), revlog(RevlogReviewKind::Review, 8), revlog(RevlogReviewKind::Review, 6), ]; // R | R R assert_eq!( convert_ignore_before(revlogs, false, days_ago_ms(9)) .unwrap() .last() .unwrap() .reviews .len(), 2 ); } #[test] fn ignore_before_after_last_revlog_entry() { let revlogs = &[ revlog(RevlogReviewKind::Learning, 10), revlog(RevlogReviewKind::Review, 6), ]; // L R | assert_eq!(convert_ignore_before(revlogs, false, days_ago_ms(4)), None); } } ================================================ FILE: rslib/src/scheduler/fsrs/rescheduler.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use chrono::Datelike; use crate::prelude::*; use crate::scheduler::states::fuzz::constrained_fuzz_bounds; use crate::scheduler::states::load_balancer::build_easy_days_percentages; use crate::scheduler::states::load_balancer::calculate_easy_days_modifiers; use crate::scheduler::states::load_balancer::select_weighted_interval; use crate::scheduler::states::load_balancer::EasyDay; use crate::scheduler::states::load_balancer::LoadBalancerInterval; pub struct Rescheduler { today: i32, next_day_at: TimestampSecs, due_cnt_per_day_by_preset: HashMap>, due_today_by_preset: HashMap, reviewed_today_by_preset: HashMap, easy_days_percentages_by_preset: HashMap, } impl Rescheduler { pub fn new(col: &mut Collection) -> Result { let timing = col.timing_today()?; let deck_stats = col.storage.get_deck_due_counts()?; let deck_map = col.storage.get_decks_map()?; let did_to_dcid = deck_map .values() .filter_map(|deck| Some((deck.id, deck.config_id()?))) .collect::>(); let mut due_cnt_per_day_by_preset: HashMap> = HashMap::new(); for (did, due_date, count) in deck_stats { let deck_config_id = did_to_dcid.get(&did).or_not_found(did)?; due_cnt_per_day_by_preset .entry(*deck_config_id) .or_default() .entry(due_date) .and_modify(|e| *e += count) .or_insert(count); } let today = timing.days_elapsed as i32; let due_today_by_preset = due_cnt_per_day_by_preset .iter() .map(|(deck_config_id, config_dues)| { let due_today = config_dues .iter() .filter(|(&due, _)| due <= today) .map(|(_, &count)| count) .sum(); (*deck_config_id, due_today) }) .collect(); let next_day_at = timing.next_day_at; let reviewed_stats = col.storage.studied_today_by_deck(timing.next_day_at)?; let mut reviewed_today_by_preset: HashMap = HashMap::new(); for (did, count) in reviewed_stats { if let Some(&deck_config_id) = &did_to_dcid.get(&did) { *reviewed_today_by_preset.entry(deck_config_id).or_default() += count; } } let easy_days_percentages_by_preset = build_easy_days_percentages(col.storage.get_deck_config_map()?)?; Ok(Self { today, next_day_at, due_cnt_per_day_by_preset, due_today_by_preset, reviewed_today_by_preset, easy_days_percentages_by_preset, }) } pub fn update_due_cnt_per_day( &mut self, due_before: i32, due_after: i32, deck_config_id: DeckConfigId, ) { if let Some(counts) = self.due_cnt_per_day_by_preset.get_mut(&deck_config_id) { if let Some(count) = counts.get_mut(&due_before) { *count -= 1; } *counts.entry(due_after).or_default() += 1; } if due_before <= self.today && due_after > self.today { if let Some(count) = self.due_today_by_preset.get_mut(&deck_config_id) { *count -= 1; } } if due_before > self.today && due_after <= self.today { *self.due_today_by_preset.entry(deck_config_id).or_default() += 1; } } fn due_today(&self, deck_config_id: DeckConfigId) -> usize { *self.due_today_by_preset.get(&deck_config_id).unwrap_or(&0) } fn reviewed_today(&self, deck_config_id: DeckConfigId) -> usize { *self .reviewed_today_by_preset .get(&deck_config_id) .unwrap_or(&0) } pub fn find_interval( &self, interval: f32, minimum_interval: u32, maximum_interval: u32, days_elapsed: u32, deckconfig_id: DeckConfigId, fuzz_seed: Option, ) -> Option { let (before_days, after_days) = constrained_fuzz_bounds(interval, minimum_interval, maximum_interval); // Don't reschedule the card when it's overdue if after_days < days_elapsed { return None; } // Don't reschedule the card to the past let before_days = before_days.max(days_elapsed); // Generate possible intervals and their review counts let possible_intervals: Vec = (before_days..=after_days).collect(); let review_counts: Vec = possible_intervals .iter() .map(|&ivl| { if ivl > days_elapsed { let check_due = self.today + ivl as i32 - days_elapsed as i32; *self .due_cnt_per_day_by_preset .get(&deckconfig_id) .and_then(|counts| counts.get(&check_due)) .unwrap_or(&0) } else { // today's workload is the sum of backlogs, cards due today and cards reviewed // today self.due_today(deckconfig_id) + self.reviewed_today(deckconfig_id) } }) .collect(); let weekdays: Vec = possible_intervals .iter() .map(|&ivl| { self.next_day_at .adding_secs(days_elapsed as i64 * -86400) .adding_secs((ivl - 1) as i64 * 86400) .local_datetime() .unwrap() .weekday() .num_days_from_monday() as usize }) .collect(); let easy_days_load = self.easy_days_percentages_by_preset.get(&deckconfig_id)?; let easy_days_modifier = calculate_easy_days_modifiers(easy_days_load, &weekdays, &review_counts); let intervals = possible_intervals .iter() .enumerate() .map(|(interval_index, &target_interval)| LoadBalancerInterval { target_interval, review_count: review_counts[interval_index], sibling_modifier: 1.0, easy_days_modifier: easy_days_modifier[interval_index], }); select_weighted_interval(intervals, fuzz_seed) } } ================================================ FILE: rslib/src/scheduler/fsrs/retention.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::scheduler::SimulateFsrsReviewRequest; use fsrs::extract_simulator_config; use fsrs::SimulatorConfig; use fsrs::FSRS; use crate::prelude::*; use crate::revlog::RevlogEntry; #[derive(Default, Clone, Copy, Debug)] pub struct ComputeRetentionProgress { pub current: u32, pub total: u32, } impl Collection { pub fn compute_optimal_retention(&mut self, req: SimulateFsrsReviewRequest) -> Result { let mut anki_progress = self.new_progress_handler::(); let fsrs = FSRS::new(None)?; if req.days_to_simulate == 0 { invalid_input!("no days to simulate") } let (config, cards) = self.simulate_request_to_config(&req)?; Ok(fsrs .optimal_retention( &config, &req.params, |ip| { anki_progress .update(false, |p| { p.current = ip.current as u32; }) .is_ok() }, Some(cards), None, )? .clamp(0.7, 0.95)) } pub fn get_optimal_retention_parameters( &mut self, revlogs: Vec, ) -> Result { let fsrs_revlog: Vec = revlogs.into_iter().map(|r| r.into()).collect(); let params = extract_simulator_config(fsrs_revlog, self.timing_today()?.next_day_at.into(), true); Ok(params) } } impl From for fsrs::RevlogReviewKind { fn from(kind: crate::revlog::RevlogReviewKind) -> Self { match kind { crate::revlog::RevlogReviewKind::Learning => fsrs::RevlogReviewKind::Learning, crate::revlog::RevlogReviewKind::Review => fsrs::RevlogReviewKind::Review, crate::revlog::RevlogReviewKind::Relearning => fsrs::RevlogReviewKind::Relearning, crate::revlog::RevlogReviewKind::Filtered => fsrs::RevlogReviewKind::Filtered, crate::revlog::RevlogReviewKind::Manual | crate::revlog::RevlogReviewKind::Rescheduled => fsrs::RevlogReviewKind::Manual, } } } impl From for fsrs::RevlogEntry { fn from(entry: crate::revlog::RevlogEntry) -> Self { fsrs::RevlogEntry { id: entry.id.into(), cid: entry.cid.into(), usn: entry.usn.into(), button_chosen: entry.button_chosen, interval: entry.interval, last_interval: entry.last_interval, ease_factor: entry.ease_factor, taken_millis: entry.taken_millis, review_kind: entry.review_kind.into(), } } } ================================================ FILE: rslib/src/scheduler/fsrs/simulator.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::sync::Arc; use anki_proto::deck_config::deck_config::config::ReviewCardOrder; use anki_proto::deck_config::deck_config::config::ReviewCardOrder::*; use anki_proto::scheduler::SimulateFsrsReviewRequest; use anki_proto::scheduler::SimulateFsrsReviewResponse; use anki_proto::scheduler::SimulateFsrsWorkloadResponse; use fsrs::simulate; use fsrs::PostSchedulingFn; use fsrs::ReviewPriorityFn; use fsrs::SimulatorConfig; use fsrs::FSRS; use itertools::Itertools; use rand::rngs::StdRng; use rand::Rng; use rayon::iter::IntoParallelIterator; use rayon::iter::ParallelIterator; use crate::card::CardQueue; use crate::card::CardType; use crate::card::FsrsMemoryState; use crate::prelude::*; use crate::scheduler::states::fuzz::constrained_fuzz_bounds; use crate::scheduler::states::load_balancer::calculate_easy_days_modifiers; use crate::scheduler::states::load_balancer::interval_to_weekday; use crate::scheduler::states::load_balancer::parse_easy_days_percentages; use crate::scheduler::states::load_balancer::select_weighted_interval; use crate::scheduler::states::load_balancer::EasyDay; use crate::scheduler::states::load_balancer::LoadBalancerInterval; use crate::search::SortMode; pub(crate) fn apply_load_balance_and_easy_days( interval: f32, max_interval: f32, day_elapsed: usize, due_cnt_per_day: &[usize], rng: &mut StdRng, next_day_at: TimestampSecs, easy_days_percentages: &[EasyDay; 7], ) -> f32 { let (lower, upper) = constrained_fuzz_bounds(interval, 1, max_interval as u32); let mut review_counts = vec![0; upper as usize - lower as usize + 1]; // Fill review_counts with due counts for each interval let start = day_elapsed + lower as usize; let end = (day_elapsed + upper as usize + 1).min(due_cnt_per_day.len()); if start < due_cnt_per_day.len() { let copy_len = (end - start).min(review_counts.len()); review_counts[..copy_len].copy_from_slice(&due_cnt_per_day[start..start + copy_len]); } let possible_intervals: Vec = (lower..=upper).collect(); let weekdays = possible_intervals .iter() .map(|interval| { interval_to_weekday( *interval, next_day_at.adding_secs(day_elapsed as i64 * 86400), ) }) .collect::>(); let easy_days_modifier = calculate_easy_days_modifiers(easy_days_percentages, &weekdays, &review_counts); let intervals = possible_intervals .iter() .enumerate() .map(|(interval_index, &target_interval)| LoadBalancerInterval { target_interval, review_count: review_counts[interval_index], sibling_modifier: 1.0, easy_days_modifier: easy_days_modifier[interval_index], }); let fuzz_seed = rng.random(); select_weighted_interval(intervals, Some(fuzz_seed)).unwrap() as f32 } fn create_review_priority_fn( review_order: ReviewCardOrder, deck_size: usize, ) -> Option { // Helper macro to wrap closure in ReviewPriorityFn macro_rules! wrap { ($f:expr) => { Some(ReviewPriorityFn(std::sync::Arc::new($f))) }; } match review_order { // Ease-based ordering EaseAscending => wrap!(|c, _w| -(c.difficulty * 100.0) as i32), EaseDescending => wrap!(|c, _w| (c.difficulty * 100.0) as i32), // Interval-based ordering IntervalsAscending => wrap!(|c, _w| c.interval as i32), IntervalsDescending => wrap!(|c, _w| (c.interval as i32).saturating_neg()), // Retrievability-based ordering RetrievabilityAscending => { wrap!(move |c, w| (c.retrievability(w) * 1000.0) as i32) } RetrievabilityDescending => { wrap!(move |c, w| -(c.retrievability(w) * 1000.0) as i32) } // Due date ordering Day | DayThenDeck | DeckThenDay => { wrap!(|c, _w| c.scheduled_due() as i32) } // Random ordering Random => { wrap!(move |_c, _w| rand::rng().random_range(0..deck_size) as i32) } // Not implemented yet Added | ReverseAdded | RelativeOverdueness => None, } } pub(crate) fn is_included_card(c: &Card) -> bool { c.queue != CardQueue::Suspended && c.queue != CardQueue::PreviewRepeat && c.ctype != CardType::New } impl Collection { pub fn simulate_request_to_config( &mut self, req: &SimulateFsrsReviewRequest, ) -> Result<(SimulatorConfig, Vec)> { let guard = self.search_cards_into_table(&req.search, SortMode::NoOrder)?; let revlogs = guard .col .storage .get_revlog_entries_for_searched_cards_in_card_order()?; let mut cards = guard.col.storage.all_searched_cards()?; drop(guard); // calculate any missing memory state for c in &mut cards { if is_included_card(c) && c.memory_state.is_none() { let fsrs_data = self.compute_memory_state(c.id)?; c.memory_state = fsrs_data.state.map(Into::into); c.desired_retention = Some(fsrs_data.desired_retention); c.decay = Some(fsrs_data.decay); self.storage.update_card(c)?; } } let days_elapsed = self.timing_today().unwrap().days_elapsed as i32; let new_cards = cards .iter() .filter(|c| c.ctype == CardType::New && c.queue != CardQueue::Suspended) .count() + req.deck_size as usize; let fsrs = FSRS::new(Some(&req.params))?; let mut converted_cards = cards .into_iter() .filter(is_included_card) .filter_map(|c| { let memory_state = match c.memory_state { Some(state) => state, // cards that lack memory states after compute_memory_state have no FSRS items, // implying a truncated or ignored revlog None => fsrs .memory_state_from_sm2( c.ease_factor(), c.interval as f32, req.historical_retention, ) .ok()? .into(), }; Card::convert(c, days_elapsed, memory_state) }) .collect_vec(); let introduced_today_count = self .search_cards(&format!("{} introduced:1", &req.search), SortMode::NoOrder)? .len() .min(req.new_limit as usize); if req.new_limit > 0 { let new_cards = (0..new_cards).map(|i| fsrs::Card { id: -(i as i64), difficulty: f32::NEG_INFINITY, stability: 1e-8, // Not filtered by fsrs-rs last_date: f32::NEG_INFINITY, // Treated as a new card in simulation due: ((introduced_today_count + i) / req.new_limit as usize) as f32, interval: f32::NEG_INFINITY, lapses: 0, }); converted_cards.extend(new_cards); } let deck_size = converted_cards.len(); let p = self.get_optimal_retention_parameters(revlogs)?; let easy_days_percentages = parse_easy_days_percentages(&req.easy_days_percentages)?; let next_day_at = self.timing_today()?.next_day_at; let post_scheduling_fn: Option = if self.get_config_bool(BoolKey::LoadBalancerEnabled) { Some(PostSchedulingFn(Arc::new( move |card, max_interval, today, due_cnt_per_day, rng| { apply_load_balance_and_easy_days( card.interval, max_interval, today, due_cnt_per_day, rng, next_day_at, &easy_days_percentages, ) }, ))) } else { None }; let review_priority_fn = req .review_order .try_into() .ok() .and_then(|order| create_review_priority_fn(order, deck_size)); let config = SimulatorConfig { deck_size, learn_span: req.days_to_simulate as usize, max_cost_perday: f32::MAX, max_ivl: req.max_interval as f32, first_rating_prob: p.first_rating_prob, review_rating_prob: p.review_rating_prob, learn_limit: req.new_limit as usize, review_limit: req.review_limit as usize, new_cards_ignore_review_limit: req.new_cards_ignore_review_limit, suspend_after_lapses: req.suspend_after_lapse_count, post_scheduling_fn, review_priority_fn, learning_step_transitions: p.learning_step_transitions, relearning_step_transitions: p.relearning_step_transitions, state_rating_costs: p.state_rating_costs, learning_step_count: req.learning_step_count as usize, relearning_step_count: req.relearning_step_count as usize, }; Ok((config, converted_cards)) } pub fn simulate_review( &mut self, req: SimulateFsrsReviewRequest, ) -> Result { let (config, cards) = self.simulate_request_to_config(&req)?; let result = simulate( &config, &req.params, req.desired_retention, None, Some(cards), )?; Ok(SimulateFsrsReviewResponse { accumulated_knowledge_acquisition: result.memorized_cnt_per_day, daily_review_count: result .review_cnt_per_day .iter() .map(|x| *x as u32) .collect_vec(), daily_new_count: result .learn_cnt_per_day .iter() .map(|x| *x as u32) .collect_vec(), daily_time_cost: result.cost_per_day, }) } pub fn simulate_workload( &mut self, req: SimulateFsrsReviewRequest, ) -> Result { let (config, cards) = self.simulate_request_to_config(&req)?; let dr_workload = (70u32..=99u32) .into_par_iter() .map(|dr| { let result = simulate( &config, &req.params, dr as f32 / 100., None, Some(cards.clone()), )?; Ok(( dr, ( *result.memorized_cnt_per_day.last().unwrap_or(&0.), result.cost_per_day.iter().sum::(), result.review_cnt_per_day.iter().sum::() as u32 + result.learn_cnt_per_day.iter().sum::() as u32, ), )) }) .collect::>>()?; Ok(SimulateFsrsWorkloadResponse { memorized: dr_workload.iter().map(|(k, v)| (*k, v.0)).collect(), cost: dr_workload.iter().map(|(k, v)| (*k, v.1)).collect(), review_count: dr_workload.iter().map(|(k, v)| (*k, v.2)).collect(), }) } } impl Card { pub(crate) fn convert( card: Card, days_elapsed: i32, memory_state: FsrsMemoryState, ) -> Option { match card.queue { CardQueue::DayLearn | CardQueue::Review => { let due = card.original_or_current_due(); let relative_due = due - days_elapsed; let last_date = (relative_due - card.interval as i32).min(0) as f32; Some(fsrs::Card { id: card.id.0, difficulty: memory_state.difficulty, stability: memory_state.stability, last_date, due: relative_due as f32, interval: card.interval as f32, lapses: card.lapses, }) } CardQueue::New => None, CardQueue::Learn | CardQueue::SchedBuried | CardQueue::UserBuried => Some(fsrs::Card { id: card.id.0, difficulty: memory_state.difficulty, stability: memory_state.stability, last_date: 0.0, due: 0.0, interval: card.interval as f32, lapses: card.lapses, }), CardQueue::PreviewRepeat => None, CardQueue::Suspended => None, } } } ================================================ FILE: rslib/src/scheduler/fsrs/try_collect.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::error::AnkiError; use crate::invalid_input; // Roll our own implementation until this becomes stable // https://github.com/rust-lang/rust/issues/94047 #[allow(unused)] pub(crate) trait TryCollect: ExactSizeIterator { fn try_collect(self) -> Result<[Self::Item; N], AnkiError> where // Self: Sized, Self::Item: Copy + Default; } impl TryCollect for I where I: ExactSizeIterator, T: Copy + Default, { fn try_collect(self) -> Result<[T; N], AnkiError> { if self.len() != N { invalid_input!("expected {N}; got {}", self.len()); } let mut result = [T::default(); N]; for (index, value) in self.enumerate() { result[index] = value; } Ok(result) } } ================================================ FILE: rslib/src/scheduler/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::collection::Collection; use crate::config::SchedulerVersion; use crate::error::Result; use crate::prelude::*; pub mod answering; pub mod bury_and_suspend; pub(crate) mod congrats; pub(crate) mod filtered; pub mod fsrs; pub mod new; pub(crate) mod queue; mod reviews; mod service; pub mod states; pub mod timespan; pub mod timing; mod upgrade; use chrono::FixedOffset; pub use reviews::parse_due_date_str; use timing::sched_timing_today; use timing::SchedTimingToday; #[derive(Debug, Clone, Copy)] pub struct SchedulerInfo { pub version: SchedulerVersion, pub timing: SchedTimingToday, } impl Collection { pub fn scheduler_info(&mut self) -> Result { let now = TimestampSecs::now(); if let Some(mut info) = self.state.scheduler_info { if now < info.timing.next_day_at { info.timing.now = now; return Ok(info); } } let version = self.scheduler_version(); let timing = self.timing_for_timestamp(now)?; let info = SchedulerInfo { version, timing }; self.state.scheduler_info = Some(info); Ok(info) } pub fn timing_today(&mut self) -> Result { self.scheduler_info().map(|info| info.timing) } pub fn current_due_day(&mut self, delta: i32) -> Result { Ok(((self.timing_today()?.days_elapsed as i32) + delta).max(0) as u32) } pub(crate) fn timing_for_timestamp(&mut self, now: TimestampSecs) -> Result { let current_utc_offset = self.local_utc_offset_for_user()?; let rollover_hour = match self.scheduler_version() { SchedulerVersion::V1 => None, SchedulerVersion::V2 => { let configured_rollover = self.get_v2_rollover(); match configured_rollover { None => { // an older Anki version failed to set this; correct // the issue self.set_v2_rollover(4)?; Some(4) } val => val, } } }; sched_timing_today( self.storage.creation_stamp()?, now, self.creation_utc_offset(), current_utc_offset, rollover_hour, ) } /// In the client case, return the current local timezone offset, /// ensuring the config reflects the current value. /// In the server case, return the value set in the config, and /// fall back on UTC if it's missing/invalid. pub(crate) fn local_utc_offset_for_user(&mut self) -> Result { let config_tz = self .get_configured_utc_offset() .and_then(|v| FixedOffset::west_opt(v * 60)) .unwrap_or_else(|| FixedOffset::west_opt(0).unwrap()); let local_tz = TimestampSecs::now().local_utc_offset()?; Ok(if self.server { config_tz } else { // if the timezone has changed, update the config if config_tz != local_tz { self.set_configured_utc_offset(local_tz.utc_minus_local() / 60)?; } local_tz }) } /// Return the timezone offset at collection creation time. This should /// only be set when the V2 scheduler is active and the new timezone /// code is enabled. fn creation_utc_offset(&self) -> Option { self.get_creation_utc_offset() .and_then(|v| FixedOffset::west_opt(v * 60)) } pub fn rollover_for_current_scheduler(&self) -> Result { match self.scheduler_version() { SchedulerVersion::V1 => Err(AnkiError::SchedulerUpgradeRequired), SchedulerVersion::V2 => Ok(self.get_v2_rollover().unwrap_or(4)), } } pub(crate) fn set_rollover_for_current_scheduler(&mut self, hour: u8) -> Result<()> { match self.scheduler_version() { SchedulerVersion::V1 => Err(AnkiError::SchedulerUpgradeRequired), SchedulerVersion::V2 => self.set_v2_rollover(hour as u32), } } pub(crate) fn set_creation_stamp(&mut self, stamp: TimestampSecs) -> Result<()> { self.state.scheduler_info = None; self.storage.set_creation_stamp(stamp) } } ================================================ FILE: rslib/src/scheduler/new.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::collections::HashSet; pub use anki_proto::scheduler::schedule_cards_as_new_request::Context as ScheduleAsNewContext; pub use anki_proto::scheduler::RepositionDefaultsResponse; pub use anki_proto::scheduler::ScheduleCardsAsNewDefaultsResponse; use rand::seq::SliceRandom; use crate::card::CardQueue; use crate::card::CardType; use crate::config::BoolKey; use crate::config::SchedulerVersion; use crate::deckconfig::NewCardInsertOrder; use crate::prelude::*; use crate::search::JoinSearches; use crate::search::SearchNode; use crate::search::SortMode; use crate::search::StateKind; impl Card { pub(crate) fn original_or_current_due(&self) -> i32 { if self.is_filtered() { self.original_due } else { self.due } } pub(crate) fn last_position(&self) -> Option { if self.ctype == CardType::New { Some(self.original_or_current_due() as u32) } else { self.original_position } } /// True if the provided position has been used. /// (Always true, if restore_position is false.) pub(crate) fn schedule_as_new( &mut self, position: u32, reset_counts: bool, restore_position: bool, ) -> bool { let last_position = restore_position.then(|| self.last_position()).flatten(); self.remove_from_filtered_deck_before_reschedule(); self.due = last_position.unwrap_or(position) as i32; self.ctype = CardType::New; self.queue = CardQueue::New; self.interval = 0; self.ease_factor = 0; self.original_position = None; if reset_counts { self.reps = 0; self.lapses = 0; } self.memory_state = None; last_position.is_none() } /// If the card is new, change its position, and return true. fn set_new_position(&mut self, position: u32) -> bool { if self.ctype == CardType::New { if self.is_filtered() { self.original_due = position as i32; } else { self.due = position as i32; } true } else if self.queue == CardQueue::New { self.due = position as i32; true } else { false } } } pub(crate) struct NewCardSorter { position: HashMap, } #[derive(PartialEq, Eq)] pub enum NewCardDueOrder { NoteId, Random, Preserve, } impl NewCardSorter { pub(crate) fn new( cards: &[Card], starting_from: u32, step: u32, order: NewCardDueOrder, ) -> Self { let nids = nids_in_desired_order(cards, order); NewCardSorter { position: nids .into_iter() .enumerate() .map(|(i, nid)| (nid, ((i as u32) * step) + starting_from)) .collect(), } } pub(crate) fn position(&self, card: &Card) -> u32 { self.position .get(&card.note_id) .cloned() .unwrap_or_default() } } fn nids_in_desired_order(cards: &[Card], order: NewCardDueOrder) -> Vec { if order == NewCardDueOrder::Preserve { nids_in_preserved_order(cards) } else { let nids: HashSet<_> = cards.iter().map(|c| c.note_id).collect(); let mut nids: Vec<_> = nids.into_iter().collect(); match order { NewCardDueOrder::NoteId => { nids.sort_unstable(); } NewCardDueOrder::Random => { nids.shuffle(&mut rand::rng()); } NewCardDueOrder::Preserve => unreachable!(), } nids } } fn nids_in_preserved_order(cards: &[Card]) -> Vec { let mut seen = HashSet::new(); cards .iter() .filter_map(|card| { if seen.insert(card.note_id) { Some(card.note_id) } else { None } }) .collect() } impl Collection { pub fn reschedule_cards_as_new( &mut self, cids: &[CardId], log: bool, restore_position: bool, reset_counts: bool, context: Option, ) -> Result> { let usn = self.usn()?; let mut position = self.get_next_card_position(); self.transact(Op::ScheduleAsNew, |col| { let cards = col.all_cards_for_ids(cids, true)?; for mut card in cards { let original = card.clone(); if card.schedule_as_new(position, reset_counts, restore_position) { position += 1; } if log { col.log_manually_scheduled_review(&card, original.interval, usn)?; } col.update_card_inner(&mut card, original, usn)?; } col.set_next_card_position(position)?; match context { Some(ScheduleAsNewContext::Browser) => { col.set_config_bool_inner(BoolKey::RestorePositionBrowser, restore_position)?; col.set_config_bool_inner(BoolKey::ResetCountsBrowser, reset_counts)?; } Some(ScheduleAsNewContext::Reviewer) => { col.set_config_bool_inner(BoolKey::RestorePositionReviewer, restore_position)?; col.set_config_bool_inner(BoolKey::ResetCountsReviewer, reset_counts)?; } None => (), } Ok(()) }) } pub fn reschedule_cards_as_new_defaults( &self, context: ScheduleAsNewContext, ) -> ScheduleCardsAsNewDefaultsResponse { match context { ScheduleAsNewContext::Browser => ScheduleCardsAsNewDefaultsResponse { restore_position: self.get_config_bool(BoolKey::RestorePositionBrowser), reset_counts: self.get_config_bool(BoolKey::ResetCountsBrowser), }, ScheduleAsNewContext::Reviewer => ScheduleCardsAsNewDefaultsResponse { restore_position: self.get_config_bool(BoolKey::RestorePositionReviewer), reset_counts: self.get_config_bool(BoolKey::ResetCountsReviewer), }, } } pub fn sort_cards( &mut self, cids: &[CardId], starting_from: u32, step: u32, order: NewCardDueOrder, shift: bool, ) -> Result> { let usn = self.usn()?; self.transact(Op::SortCards, |col| { col.set_config_bool_inner( BoolKey::RandomOrderReposition, order == NewCardDueOrder::Random, )?; col.set_config_bool_inner(BoolKey::ShiftPositionOfExistingCards, shift)?; col.sort_cards_inner(cids, starting_from, step, order, shift, usn) }) } fn sort_cards_inner( &mut self, cids: &[CardId], starting_from: u32, step: u32, order: NewCardDueOrder, shift: bool, usn: Usn, ) -> Result { if self.scheduler_version() == SchedulerVersion::V1 { return Err(AnkiError::SchedulerUpgradeRequired); } if shift { self.shift_existing_cards(starting_from, step * cids.len() as u32, usn)?; } let cards = self.all_cards_for_ids(cids, true)?; let sorter = NewCardSorter::new(&cards, starting_from, step, order); let mut count = 0; for mut card in cards { let original = card.clone(); if card.set_new_position(sorter.position(&card)) { count += 1; self.update_card_inner(&mut card, original, usn)?; } } Ok(count) } pub fn reposition_defaults(&self) -> RepositionDefaultsResponse { RepositionDefaultsResponse { random: self.get_config_bool(BoolKey::RandomOrderReposition), shift: self.get_config_bool(BoolKey::ShiftPositionOfExistingCards), } } /// This is handled by update_deck_configs() now; this function has been /// kept around for now to support the old deck config screen. pub fn sort_deck_legacy(&mut self, deck: DeckId, random: bool) -> Result> { self.transact(Op::SortCards, |col| { col.sort_deck( deck, if random { NewCardInsertOrder::Random } else { NewCardInsertOrder::Due }, col.usn()?, ) }) } pub(crate) fn sort_deck( &mut self, deck: DeckId, order: NewCardInsertOrder, usn: Usn, ) -> Result { let cids = self.search_cards( SearchNode::DeckIdsWithoutChildren(deck.to_string()).and(StateKind::New), SortMode::NoOrder, )?; self.sort_cards_inner(&cids, 1, 1, order.into(), false, usn) } fn shift_existing_cards(&mut self, start: u32, by: u32, usn: Usn) -> Result<()> { for mut card in self.storage.all_cards_at_or_above_position(start)? { let original = card.clone(); card.set_new_position(card.due as u32 + by); self.update_card_inner(&mut card, original, usn)?; } Ok(()) } } impl From for NewCardDueOrder { fn from(o: NewCardInsertOrder) -> Self { match o { NewCardInsertOrder::Due => NewCardDueOrder::NoteId, NewCardInsertOrder::Random => NewCardDueOrder::Random, } } } #[cfg(test)] mod test { use super::*; #[test] fn new_order() { let mut c1 = Card::new(NoteId(6), 0, DeckId(0), 0); c1.id.0 = 2; let mut c2 = Card::new(NoteId(5), 0, DeckId(0), 0); c2.id.0 = 3; let mut c3 = Card::new(NoteId(4), 0, DeckId(0), 0); c3.id.0 = 1; let cards = vec![c1.clone(), c2.clone(), c3.clone()]; // Preserve let sorter = NewCardSorter::new(&cards, 0, 1, NewCardDueOrder::Preserve); assert_eq!(sorter.position(&c1), 0); assert_eq!(sorter.position(&c2), 1); assert_eq!(sorter.position(&c3), 2); // NoteId/step/starting let sorter = NewCardSorter::new(&cards, 3, 2, NewCardDueOrder::NoteId); assert_eq!(sorter.position(&c3), 3); assert_eq!(sorter.position(&c2), 5); assert_eq!(sorter.position(&c1), 7); // Random let mut c1_positions = HashSet::new(); for _ in 1..100 { let sorter = NewCardSorter::new(&cards, 0, 1, NewCardDueOrder::Random); c1_positions.insert(sorter.position(&c1)); if c1_positions.len() == cards.len() { return; } } unreachable!("not random"); } #[test] fn last_position() { // new card let mut card = Card::new(NoteId(0), 0, DeckId(1), 42); assert_eq!(card.last_position(), Some(42)); // in filtered deck card.original_deck_id.0 = 1; card.deck_id.0 = 2; card.original_due = 42; card.due = 123456789; card.queue = CardQueue::Review; assert_eq!(card.last_position(), Some(42)); // graduated card let mut card = Card::new(NoteId(0), 0, DeckId(1), 42); card.queue = CardQueue::Review; card.ctype = CardType::Review; card.due = 123456789; // only recent clients remember the original position assert_eq!(card.last_position(), None); card.original_position = Some(42); assert_eq!(card.last_position(), Some(42)); } #[test] fn scheduling_as_new() { let mut card = Card::new(NoteId(0), 0, DeckId(1), 42); card.reps = 4; card.lapses = 2; // keep counts and position card.schedule_as_new(1, false, true); assert_eq!((card.due, card.reps, card.lapses), (42, 4, 2)); // complete reset card.schedule_as_new(1, true, false); assert_eq!((card.due, card.reps, card.lapses), (1, 0, 0)); } } ================================================ FILE: rslib/src/scheduler/queue/builder/burying.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::super::BuryMode; use super::Context; use super::DueCard; use super::NewCard; use super::QueueBuilder; use crate::deckconfig::DeckConfig; use crate::prelude::*; pub(super) enum DueOrNewCard { Due(DueCard), New(NewCard), } impl DueOrNewCard { fn original_deck_id(&self) -> DeckId { match self { Self::Due(card) => card.original_deck_id.or(card.current_deck_id), Self::New(card) => card.original_deck_id.or(card.current_deck_id), } } fn note_id(&self) -> NoteId { match self { Self::Due(card) => card.note_id, Self::New(card) => card.note_id, } } } impl From for DueOrNewCard { fn from(card: DueCard) -> DueOrNewCard { DueOrNewCard::Due(card) } } impl From for DueOrNewCard { fn from(card: NewCard) -> DueOrNewCard { DueOrNewCard::New(card) } } impl Context { pub(super) fn bury_mode(&self, deck_id: DeckId) -> BuryMode { self.deck_map .get(&deck_id) .and_then(|deck| deck.config_id()) .and_then(|config_id| self.config_map.get(&config_id)) .map(BuryMode::from_deck_config) .unwrap_or_default() } } impl BuryMode { pub(crate) fn from_deck_config(config: &DeckConfig) -> BuryMode { let cfg = &config.inner; BuryMode { bury_new: cfg.bury_new, bury_reviews: cfg.bury_reviews, bury_interday_learning: cfg.bury_interday_learning, } } pub(crate) fn any_burying(self) -> bool { self.bury_interday_learning || self.bury_reviews || self.bury_new } } impl QueueBuilder { /// If burying is enabled in `new_settings`, existing entry will be updated. /// Returns a copy made before changing the entry, so that a card with /// burying enabled will bury future siblings, but not itself. pub(super) fn get_and_update_bury_mode_for_note( &mut self, card: DueOrNewCard, ) -> Option { let mut previous_mode = None; let new_mode = self.context.bury_mode(card.original_deck_id()); self.context .seen_note_ids .entry(card.note_id()) .and_modify(|entry| { previous_mode = Some(*entry); entry.bury_new |= new_mode.bury_new; entry.bury_reviews |= new_mode.bury_reviews; entry.bury_interday_learning |= new_mode.bury_interday_learning; }) .or_insert(new_mode); previous_mode } } ================================================ FILE: rslib/src/scheduler/queue/builder/gathering.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::DueCard; use super::NewCard; use super::QueueBuilder; use crate::deckconfig::NewCardGatherPriority; use crate::decks::limits::LimitKind; use crate::prelude::*; use crate::scheduler::queue::DueCardKind; use crate::storage::card::NewCardSorting; impl QueueBuilder { pub(super) fn gather_cards(&mut self, col: &mut Collection) -> Result<()> { self.gather_intraday_learning_cards(col)?; self.gather_due_cards(col, DueCardKind::Learning)?; self.gather_due_cards(col, DueCardKind::Review)?; self.gather_new_cards(col)?; Ok(()) } fn gather_intraday_learning_cards(&mut self, col: &mut Collection) -> Result<()> { col.storage.for_each_intraday_card_in_active_decks( self.context.timing.next_day_at, |card| { self.get_and_update_bury_mode_for_note(card.into()); self.learning.push(card); }, )?; Ok(()) } fn gather_due_cards(&mut self, col: &mut Collection, kind: DueCardKind) -> Result<()> { if self.limits.root_limit_reached(LimitKind::Review) { return Ok(()); } col.storage.for_each_due_card_in_active_decks( self.context.timing, self.context.sort_options.review_order, kind, self.context.fsrs, |card| { if self.limits.root_limit_reached(LimitKind::Review) { return Ok(false); } if !self .limits .limit_reached(card.current_deck_id, LimitKind::Review)? && self.add_due_card(card) { self.limits.decrement_deck_and_parent_limits( card.current_deck_id, LimitKind::Review, )?; } Ok(true) }, ) } fn gather_new_cards(&mut self, col: &mut Collection) -> Result<()> { let salt = Self::knuth_salt(self.context.timing.days_elapsed); match self.context.sort_options.new_gather_priority { NewCardGatherPriority::Deck => { self.gather_new_cards_by_deck(col, NewCardSorting::LowestPosition) } NewCardGatherPriority::DeckThenRandomNotes => { self.gather_new_cards_by_deck(col, NewCardSorting::RandomNotes(salt)) } NewCardGatherPriority::LowestPosition => { self.gather_new_cards_sorted(col, NewCardSorting::LowestPosition) } NewCardGatherPriority::HighestPosition => { self.gather_new_cards_sorted(col, NewCardSorting::HighestPosition) } NewCardGatherPriority::RandomNotes => { self.gather_new_cards_sorted(col, NewCardSorting::RandomNotes(salt)) } NewCardGatherPriority::RandomCards => { self.gather_new_cards_sorted(col, NewCardSorting::RandomCards(salt)) } } } fn gather_new_cards_by_deck( &mut self, col: &mut Collection, sort: NewCardSorting, ) -> Result<()> { for deck_id in col.storage.get_active_deck_ids_sorted()? { if self.limits.root_limit_reached(LimitKind::New) { break; } if self.limits.limit_reached(deck_id, LimitKind::New)? { continue; } col.storage .for_each_new_card_in_deck(deck_id, sort, |card| { let limit_reached = self.limits.limit_reached(deck_id, LimitKind::New)?; if !limit_reached && self.add_new_card(card) { self.limits .decrement_deck_and_parent_limits(deck_id, LimitKind::New)?; } Ok(!limit_reached) })?; } Ok(()) } fn gather_new_cards_sorted( &mut self, col: &mut Collection, order: NewCardSorting, ) -> Result<()> { col.storage .for_each_new_card_in_active_decks(order, |card| { if self.limits.root_limit_reached(LimitKind::New) { return Ok(false); } if !self .limits .limit_reached(card.current_deck_id, LimitKind::New)? && self.add_new_card(card) { self.limits .decrement_deck_and_parent_limits(card.current_deck_id, LimitKind::New)?; } Ok(true) }) } /// True if limit should be decremented. fn add_due_card(&mut self, card: DueCard) -> bool { let bury_this_card = self .get_and_update_bury_mode_for_note(card.into()) .map(|mode| match card.kind { DueCardKind::Review => mode.bury_reviews, DueCardKind::Learning => mode.bury_interday_learning, }) .unwrap_or_default(); if bury_this_card { false } else { match card.kind { DueCardKind::Review => self.review.push(card), DueCardKind::Learning => self.day_learning.push(card), } true } } // True if limit should be decremented. fn add_new_card(&mut self, card: NewCard) -> bool { let bury_this_card = self .get_and_update_bury_mode_for_note(card.into()) .map(|mode| mode.bury_new) .unwrap_or_default(); // no previous siblings seen? if bury_this_card { false } else { self.new.push(card); true } } // Generates a salt for use with fnvhash. Useful to increase randomness // when the base salt is a small integer. fn knuth_salt(base_salt: u32) -> u32 { base_salt.wrapping_mul(2654435761) } } ================================================ FILE: rslib/src/scheduler/queue/builder/intersperser.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /// Adapter to evenly mix two iterators of varying lengths into one. pub(crate) struct Intersperser where I: Iterator + ExactSizeIterator, { one: I, two: I2, one_idx: usize, two_idx: usize, one_len: usize, two_len: usize, ratio: f32, } impl Intersperser where I: ExactSizeIterator, I2: ExactSizeIterator, { pub fn new(one: I, two: I2) -> Self { let one_len = one.len(); let two_len = two.len(); let ratio = (one_len + 1) as f32 / (two_len + 1) as f32; Intersperser { one, two, one_idx: 0, two_idx: 0, one_len, two_len, ratio, } } fn one_idx(&self) -> Option { if self.one_idx == self.one_len { None } else { Some(self.one_idx) } } fn two_idx(&self) -> Option { if self.two_idx == self.two_len { None } else { Some(self.two_idx) } } fn next_one(&mut self) -> Option { self.one_idx += 1; self.one.next() } fn next_two(&mut self) -> Option { self.two_idx += 1; self.two.next() } } impl Iterator for Intersperser where I: ExactSizeIterator, I2: ExactSizeIterator, { type Item = I::Item; fn next(&mut self) -> Option { match (self.one_idx(), self.two_idx()) { (Some(idx1), Some(idx2)) => { let relative_idx2 = (idx2 + 1) as f32 * self.ratio; if relative_idx2 < (idx1 + 1) as f32 { self.next_two() } else { self.next_one() } } (Some(_), None) => self.next_one(), (None, Some(_)) => self.next_two(), (None, None) => None, } } fn size_hint(&self) -> (usize, Option) { let remaining = (self.one_len + self.two_len) - (self.one_idx + self.two_idx); (remaining, Some(remaining)) } } impl ExactSizeIterator for Intersperser where I: ExactSizeIterator, I2: ExactSizeIterator, { } #[cfg(test)] mod test { use super::Intersperser; fn intersperse(a: &[u32], b: &[u32]) -> Vec { Intersperser::new(a.iter().cloned(), b.iter().cloned()).collect() } #[test] fn interspersing() { let a = &[1, 2, 3]; let b = &[11, 22, 33]; assert_eq!(&intersperse(a, b), &[1, 11, 2, 22, 3, 33]); let b = &[11, 22]; assert_eq!(&intersperse(a, b), &[1, 11, 2, 22, 3]); // always add from longer iter first let b = &[11, 22, 33, 44, 55, 66]; assert_eq!(&intersperse(a, b), &[11, 1, 22, 33, 2, 44, 55, 3, 66]); // space is distributed as evenly as possible between elements of // the same iter and start and end let b = &[11, 22, 33, 44, 55, 66, 77, 88]; assert_eq!( &intersperse(a, b), &[11, 22, 1, 33, 44, 2, 55, 66, 3, 77, 88] ); let b = &[]; assert_eq!(&intersperse(a, b), &[1, 2, 3]); } } ================================================ FILE: rslib/src/scheduler/queue/builder/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod burying; mod gathering; pub(crate) mod intersperser; pub(crate) mod sized_chain; mod sorting; use std::collections::HashMap; use std::collections::VecDeque; use intersperser::Intersperser; use sized_chain::SizedChain; use super::BuryMode; use super::CardQueues; use super::Counts; use super::LearningQueueEntry; use super::MainQueueEntry; use super::MainQueueEntryKind; use crate::deckconfig::NewCardGatherPriority; use crate::deckconfig::NewCardSortOrder; use crate::deckconfig::ReviewCardOrder; use crate::deckconfig::ReviewMix; use crate::decks::limits::LimitTreeMap; use crate::prelude::*; use crate::scheduler::states::load_balancer::LoadBalancer; use crate::scheduler::timing::SchedTimingToday; /// Temporary holder for review cards that will be built into a queue. #[derive(Debug, Clone, Copy)] pub(crate) struct DueCard { pub id: CardId, pub note_id: NoteId, pub mtime: TimestampSecs, pub due: i32, pub current_deck_id: DeckId, pub original_deck_id: DeckId, pub kind: DueCardKind, } #[derive(Debug, Clone, Copy)] pub(crate) enum DueCardKind { Review, Learning, } /// Temporary holder for new cards that will be built into a queue. #[derive(Debug, Default, Clone, Copy)] pub(crate) struct NewCard { pub id: CardId, pub note_id: NoteId, pub mtime: TimestampSecs, pub current_deck_id: DeckId, pub original_deck_id: DeckId, pub template_index: u32, pub hash: u64, } impl From for MainQueueEntry { fn from(c: DueCard) -> Self { MainQueueEntry { id: c.id, mtime: c.mtime, kind: match c.kind { DueCardKind::Review => MainQueueEntryKind::Review, DueCardKind::Learning => MainQueueEntryKind::InterdayLearning, }, } } } impl From for MainQueueEntry { fn from(c: NewCard) -> Self { MainQueueEntry { id: c.id, mtime: c.mtime, kind: MainQueueEntryKind::New, } } } impl From for LearningQueueEntry { fn from(c: DueCard) -> Self { LearningQueueEntry { due: TimestampSecs(c.due as i64), id: c.id, mtime: c.mtime, } } } #[derive(Default, Clone, Debug)] pub(super) struct QueueSortOptions { pub(super) new_order: NewCardSortOrder, pub(super) new_gather_priority: NewCardGatherPriority, pub(super) review_order: ReviewCardOrder, pub(super) day_learn_mix: ReviewMix, pub(super) new_review_mix: ReviewMix, } #[derive(Debug)] pub(super) struct QueueBuilder { pub(super) new: Vec, pub(super) review: Vec, pub(super) learning: Vec, pub(super) day_learning: Vec, limits: LimitTreeMap, load_balancer: Option, context: Context, } /// Data container and helper for building queues. #[derive(Debug, Clone)] struct Context { timing: SchedTimingToday, config_map: HashMap, root_deck: Deck, sort_options: QueueSortOptions, seen_note_ids: HashMap, deck_map: HashMap, fsrs: bool, } impl QueueBuilder { pub(super) fn new(col: &mut Collection, deck_id: DeckId) -> Result { let timing = col.timing_for_timestamp(TimestampSecs::now())?; let new_cards_ignore_review_limit = col.get_config_bool(BoolKey::NewCardsIgnoreReviewLimit); let apply_all_parent_limits = col.get_config_bool(BoolKey::ApplyAllParentLimits); let config_map = col.storage.get_deck_config_map()?; let root_deck = col.storage.get_deck(deck_id)?.or_not_found(deck_id)?; let mut decks = col.storage.child_decks(&root_deck)?; decks.insert(0, root_deck.clone()); if apply_all_parent_limits { for parent in col.storage.parent_decks(&root_deck)? { decks.insert(0, parent); } } let limits = LimitTreeMap::build( &decks, &config_map, timing.days_elapsed, new_cards_ignore_review_limit, ); let sort_options = sort_options(&root_deck, &config_map); let deck_map = col.storage.get_decks_map()?; let load_balancer = col .get_config_bool(BoolKey::LoadBalancerEnabled) .then(|| { let did_to_dcid = deck_map .values() .filter_map(|deck| Some((deck.id, deck.config_id()?))) .collect::>(); LoadBalancer::new( timing.days_elapsed, did_to_dcid, col.timing_today()?.next_day_at, &col.storage, ) }) .transpose()?; Ok(QueueBuilder { new: Vec::new(), review: Vec::new(), learning: Vec::new(), day_learning: Vec::new(), limits, load_balancer, context: Context { timing, config_map, root_deck, sort_options, seen_note_ids: HashMap::new(), deck_map, fsrs: col.get_config_bool(BoolKey::Fsrs), }, }) } pub(super) fn build(mut self, learn_ahead_secs: i64) -> CardQueues { self.sort_new(); // intraday learning and total learn count let intraday_learning = sort_learning(self.learning); let now = TimestampSecs::now(); let cutoff = now.adding_secs(learn_ahead_secs); let learn_count = intraday_learning .iter() .take_while(|e| e.due <= cutoff) .count() + self.day_learning.len(); let review_count = self.review.len(); let new_count = self.new.len(); // merge interday and new cards into main let with_interday_learn = merge_day_learning( self.review, self.day_learning, self.context.sort_options.day_learn_mix, ); let main_iter = merge_new( with_interday_learn, self.new, self.context.sort_options.new_review_mix, ); CardQueues { counts: Counts { new: new_count, review: review_count, learning: learn_count, }, main: main_iter.collect(), intraday_learning, learn_ahead_secs, current_day: self.context.timing.days_elapsed, build_time: TimestampMillis::now(), load_balancer: self.load_balancer, current_learning_cutoff: now, } } } fn sort_options(deck: &Deck, config_map: &HashMap) -> QueueSortOptions { deck.config_id() .and_then(|config_id| config_map.get(&config_id)) .map(|config| QueueSortOptions { new_order: config.inner.new_card_sort_order(), new_gather_priority: config.inner.new_card_gather_priority(), review_order: config.inner.review_order(), day_learn_mix: config.inner.interday_learning_mix(), new_review_mix: config.inner.new_mix(), }) .unwrap_or_else(|| { // filtered decks do not space siblings QueueSortOptions { new_order: NewCardSortOrder::NoSort, ..Default::default() } }) } fn merge_day_learning( reviews: Vec, day_learning: Vec, mode: ReviewMix, ) -> Box> { let day_learning_iter = day_learning.into_iter().map(Into::into); let reviews_iter = reviews.into_iter().map(Into::into); match mode { ReviewMix::AfterReviews => Box::new(SizedChain::new(reviews_iter, day_learning_iter)), ReviewMix::BeforeReviews => Box::new(SizedChain::new(day_learning_iter, reviews_iter)), ReviewMix::MixWithReviews => Box::new(Intersperser::new(reviews_iter, day_learning_iter)), } } fn merge_new( review_iter: impl ExactSizeIterator + 'static, new: Vec, mode: ReviewMix, ) -> Box> { let new_iter = new.into_iter().map(Into::into); match mode { ReviewMix::BeforeReviews => Box::new(SizedChain::new(new_iter, review_iter)), ReviewMix::AfterReviews => Box::new(SizedChain::new(review_iter, new_iter)), ReviewMix::MixWithReviews => Box::new(Intersperser::new(review_iter, new_iter)), } } fn sort_learning(mut learning: Vec) -> VecDeque { learning.sort_unstable_by(|a, b| a.due.cmp(&b.due)); learning.into_iter().map(LearningQueueEntry::from).collect() } impl Collection { pub(crate) fn build_queues(&mut self, deck_id: DeckId) -> Result { let mut queues = QueueBuilder::new(self, deck_id)?; self.storage .update_active_decks(&queues.context.root_deck)?; queues.gather_cards(self)?; let queues = queues.build(self.learn_ahead_secs() as i64); Ok(queues) } } #[cfg(test)] mod test { use anki_proto::deck_config::deck_config::config::NewCardGatherPriority; use anki_proto::deck_config::deck_config::config::NewCardSortOrder; use super::*; use crate::card::CardQueue; use crate::card::CardType; impl Collection { fn set_deck_gather_order(&mut self, deck: &mut Deck, order: NewCardGatherPriority) { let mut conf = DeckConfig::default(); conf.inner.new_card_gather_priority = order as i32; conf.inner.new_card_sort_order = NewCardSortOrder::NoSort as i32; self.add_or_update_deck_config(&mut conf).unwrap(); deck.normal_mut().unwrap().config_id = conf.id.0; self.add_or_update_deck(deck).unwrap(); } fn set_deck_new_limit(&mut self, deck: &mut Deck, new_limit: u32) { let mut conf = DeckConfig::default(); conf.inner.new_per_day = new_limit; self.add_or_update_deck_config(&mut conf).unwrap(); deck.normal_mut().unwrap().config_id = conf.id.0; self.add_or_update_deck(deck).unwrap(); } fn set_deck_review_limit(&mut self, deck: DeckId, limit: u32) { let dcid = self.get_deck(deck).unwrap().unwrap().config_id().unwrap(); let mut conf = self.get_deck_config(dcid, false).unwrap().unwrap(); conf.inner.reviews_per_day = limit; self.add_or_update_deck_config(&mut conf).unwrap(); } fn queue_as_deck_and_template(&mut self, deck_id: DeckId) -> Vec<(DeckId, u16)> { self.build_queues(deck_id) .unwrap() .iter() .map(|entry| { let card = self.storage.get_card(entry.card_id()).unwrap().unwrap(); (card.deck_id, card.template_idx) }) .collect() } fn set_deck_review_order(&mut self, deck: &mut Deck, order: ReviewCardOrder) { let mut conf = DeckConfig::default(); conf.inner.review_order = order as i32; self.add_or_update_deck_config(&mut conf).unwrap(); deck.normal_mut().unwrap().config_id = conf.id.0; self.add_or_update_deck(deck).unwrap(); } fn queue_as_due_and_ivl(&mut self, deck_id: DeckId) -> Vec<(i32, u32)> { self.build_queues(deck_id) .unwrap() .iter() .map(|entry| { let card = self.storage.get_card(entry.card_id()).unwrap().unwrap(); (card.due, card.interval) }) .collect() } } #[test] fn should_build_empty_queue_if_limit_is_reached() { let mut col = Collection::new(); CardAdder::new().due_dates(["0"]).add(&mut col); col.set_deck_review_limit(DeckId(1), 0); assert_eq!(col.queue_as_deck_and_template(DeckId(1)), vec![]); } #[test] fn new_queue_building() -> Result<()> { let mut col = Collection::new(); // parent // ┣━━child━━grandchild // ┗━━child_2 let mut parent = DeckAdder::new("parent").add(&mut col); let mut child = DeckAdder::new("parent::child").add(&mut col); let child_2 = DeckAdder::new("parent::child_2").add(&mut col); let grandchild = DeckAdder::new("parent::child::grandchild").add(&mut col); // add 2 new cards to each deck for deck in [&parent, &child, &child_2, &grandchild] { CardAdder::new().siblings(2).deck(deck.id).add(&mut col); } // set child's new limit to 3, which should affect grandchild col.set_deck_new_limit(&mut child, 3); // depth-first tree order col.set_deck_gather_order(&mut parent, NewCardGatherPriority::Deck); let cards = vec![ (parent.id, 0), (parent.id, 1), (child.id, 0), (child.id, 1), (grandchild.id, 0), (child_2.id, 0), (child_2.id, 1), ]; assert_eq!(col.queue_as_deck_and_template(parent.id), cards); // insertion order col.set_deck_gather_order(&mut parent, NewCardGatherPriority::LowestPosition); let cards = vec![ (parent.id, 0), (parent.id, 1), (child.id, 0), (child.id, 1), (child_2.id, 0), (child_2.id, 1), (grandchild.id, 0), ]; assert_eq!(col.queue_as_deck_and_template(parent.id), cards); // inverted insertion order, but sibling order is preserved col.set_deck_gather_order(&mut parent, NewCardGatherPriority::HighestPosition); let cards = vec![ (grandchild.id, 0), (grandchild.id, 1), (child_2.id, 0), (child_2.id, 1), (child.id, 0), (parent.id, 0), (parent.id, 1), ]; assert_eq!(col.queue_as_deck_and_template(parent.id), cards); Ok(()) } #[test] fn review_queue_building() -> Result<()> { let mut col = Collection::new(); let mut deck = col.get_or_create_normal_deck("Default").unwrap(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut cards = vec![]; // relative overdueness let expected_queue = vec![ (-150, 1), (-100, 1), (-50, 1), (-150, 5), (-100, 5), (-50, 5), (-150, 20), (-150, 20), (-100, 20), (-50, 20), (-150, 100), (-100, 100), (-50, 100), (0, 1), (0, 5), (0, 20), (0, 100), ]; for t in expected_queue.iter() { let mut note = nt.new_note(); note.set_field(0, "foo")?; note.id.0 = 0; col.add_note(&mut note, deck.id)?; let mut card = col.storage.get_card_by_ordinal(note.id, 0)?.unwrap(); card.interval = t.1; card.due = t.0; card.ctype = CardType::Review; card.queue = CardQueue::Review; cards.push(card); } col.update_cards_maybe_undoable(cards, false)?; col.set_deck_review_order(&mut deck, ReviewCardOrder::RelativeOverdueness); assert_eq!(col.queue_as_due_and_ivl(deck.id), expected_queue); Ok(()) } impl Collection { fn card_queue_len(&mut self) -> usize { self.get_queued_cards(5, false).unwrap().cards.len() } } #[test] fn new_card_potentially_burying_review_card() { let mut col = Collection::new(); // add one new and one review card CardAdder::new().siblings(2).due_dates(["0"]).add(&mut col); // Potentially problematic config: New cards are shown first and would bury // review siblings. This poses a problem because we gather review cards first. col.update_default_deck_config(|config| { config.new_mix = ReviewMix::BeforeReviews as i32; config.bury_new = false; config.bury_reviews = true; }); let old_queue_len = col.card_queue_len(); col.answer_easy(); col.clear_study_queues(); // The number of cards in the queue must decrease by exactly 1, either because // no burying was performed, or the first built queue anticipated it and didn't // include the buried card. assert_eq!(col.card_queue_len(), old_queue_len - 1); } #[test] fn new_cards_may_ignore_review_limit() { let mut col = Collection::new(); col.set_config_bool(BoolKey::NewCardsIgnoreReviewLimit, true, false) .unwrap(); col.update_default_deck_config(|config| { config.reviews_per_day = 0; }); CardAdder::new().add(&mut col); // review limit doesn't apply to new card assert_eq!(col.card_queue_len(), 1); } #[test] fn reviews_dont_affect_new_limit_before_review_limit_is_reached() { let mut col = Collection::new(); col.update_default_deck_config(|config| { config.new_per_day = 1; }); CardAdder::new().siblings(2).due_dates(["0"]).add(&mut col); assert_eq!(col.card_queue_len(), 2); } #[test] fn may_apply_parent_limits() { let mut col = Collection::new(); col.set_config_bool(BoolKey::ApplyAllParentLimits, true, false) .unwrap(); col.update_default_deck_config(|config| { config.new_per_day = 0; }); let child = DeckAdder::new("Default::child") .with_config(|_| ()) .add(&mut col); CardAdder::new().deck(child.id).add(&mut col); col.set_current_deck(child.id).unwrap(); assert_eq!(col.card_queue_len(), 0); } } ================================================ FILE: rslib/src/scheduler/queue/builder/sized_chain.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /// The standard Rust chain does not implement ExactSizeIterator, and we need /// to keep track of size so we can intersperse. pub(crate) struct SizedChain { one: I, two: I2, one_idx: usize, two_idx: usize, one_len: usize, two_len: usize, } impl SizedChain where I: ExactSizeIterator, I2: ExactSizeIterator, { pub fn new(one: I, two: I2) -> Self { let one_len = one.len(); let two_len = two.len(); SizedChain { one, two, one_idx: 0, two_idx: 0, one_len, two_len, } } } impl Iterator for SizedChain where I: ExactSizeIterator, I2: ExactSizeIterator, { type Item = I::Item; fn next(&mut self) -> Option { if self.one_idx < self.one_len { self.one_idx += 1; self.one.next() } else if self.two_idx < self.two_len { self.two_idx += 1; self.two.next() } else { None } } fn size_hint(&self) -> (usize, Option) { let remaining = (self.one_len + self.two_len) - (self.one_idx + self.two_idx); (remaining, Some(remaining)) } } impl ExactSizeIterator for SizedChain where I: ExactSizeIterator, I2: ExactSizeIterator, { } #[cfg(test)] mod test { use super::SizedChain; fn chain(a: &[u32], b: &[u32]) -> Vec { SizedChain::new(a.iter().cloned(), b.iter().cloned()).collect() } #[test] fn sized_chain() { let a = &[1, 2, 3]; let b = &[11, 22, 33]; assert_eq!(&chain(a, b), &[1, 2, 3, 11, 22, 33]); } } ================================================ FILE: rslib/src/scheduler/queue/builder/sorting.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::cmp::Ordering; use std::hash::Hasher; use fnv::FnvHasher; use super::NewCard; use super::NewCardSortOrder; use super::QueueBuilder; impl QueueBuilder { pub(super) fn sort_new(&mut self) { match self.context.sort_options.new_order { // preserve gather order NewCardSortOrder::NoSort => (), NewCardSortOrder::Template => { // stable sort to preserve gather order self.new .sort_by(|a, b| a.template_index.cmp(&b.template_index)) } NewCardSortOrder::TemplateThenRandom => { self.hash_new_cards_by_id(); self.new.sort_unstable_by(cmp_template_then_hash); } NewCardSortOrder::RandomNoteThenTemplate => { self.hash_new_cards_by_note_id(); self.new.sort_unstable_by(cmp_hash_then_template); } NewCardSortOrder::RandomCard => { self.hash_new_cards_by_id(); self.new.sort_unstable_by(cmp_hash) } } } fn hash_new_cards_by_id(&mut self) { self.new .iter_mut() .for_each(|card| card.hash_id_with_salt(self.context.timing.days_elapsed as i64)); } fn hash_new_cards_by_note_id(&mut self) { self.new .iter_mut() .for_each(|card| card.hash_note_id_with_salt(self.context.timing.days_elapsed as i64)); } } fn cmp_hash(a: &NewCard, b: &NewCard) -> Ordering { a.hash.cmp(&b.hash) } fn cmp_template_then_hash(a: &NewCard, b: &NewCard) -> Ordering { (a.template_index, a.hash).cmp(&(b.template_index, b.hash)) } fn cmp_hash_then_template(a: &NewCard, b: &NewCard) -> Ordering { (a.hash, a.template_index).cmp(&(b.hash, b.template_index)) } // We sort based on a hash so that if the queue is rebuilt, remaining // cards come back in the same approximate order (mixing + due learning cards // may still result in a different card) impl NewCard { fn hash_id_with_salt(&mut self, salt: i64) { let mut hasher = FnvHasher::default(); hasher.write_i64(self.id.0); hasher.write_i64(salt); self.hash = hasher.finish(); } fn hash_note_id_with_salt(&mut self, salt: i64) { let mut hasher = FnvHasher::default(); hasher.write_i64(self.note_id.0); hasher.write_i64(salt); self.hash = hasher.finish(); } } ================================================ FILE: rslib/src/scheduler/queue/entry.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::LearningQueueEntry; use super::MainQueueEntry; use super::MainQueueEntryKind; use crate::card::CardQueue; use crate::prelude::*; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum QueueEntry { IntradayLearning(LearningQueueEntry), Main(MainQueueEntry), } impl QueueEntry { pub fn card_id(&self) -> CardId { match self { QueueEntry::IntradayLearning(e) => e.id, QueueEntry::Main(e) => e.id, } } pub fn mtime(&self) -> TimestampSecs { match self { QueueEntry::IntradayLearning(e) => e.mtime, QueueEntry::Main(e) => e.mtime, } } pub fn kind(&self) -> QueueEntryKind { match self { QueueEntry::IntradayLearning(_e) => QueueEntryKind::Learning, QueueEntry::Main(e) => match e.kind { MainQueueEntryKind::New => QueueEntryKind::New, MainQueueEntryKind::Review => QueueEntryKind::Review, MainQueueEntryKind::InterdayLearning => QueueEntryKind::Learning, }, } } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum QueueEntryKind { New, Learning, Review, } impl From<&Card> for QueueEntry { fn from(card: &Card) -> Self { let kind = match card.queue { CardQueue::Learn | CardQueue::PreviewRepeat => { return QueueEntry::IntradayLearning(LearningQueueEntry { due: TimestampSecs(card.due as i64), id: card.id, mtime: card.mtime, }); } CardQueue::New => MainQueueEntryKind::New, CardQueue::Review | CardQueue::DayLearn => MainQueueEntryKind::Review, CardQueue::Suspended | CardQueue::SchedBuried | CardQueue::UserBuried => { unreachable!() } }; QueueEntry::Main(MainQueueEntry { id: card.id, mtime: card.mtime, kind, }) } } impl From for QueueEntry { fn from(e: LearningQueueEntry) -> Self { Self::IntradayLearning(e) } } impl From for QueueEntry { fn from(e: MainQueueEntry) -> Self { Self::Main(e) } } impl From<&LearningQueueEntry> for QueueEntry { fn from(e: &LearningQueueEntry) -> Self { Self::IntradayLearning(*e) } } impl From<&MainQueueEntry> for QueueEntry { fn from(e: &MainQueueEntry) -> Self { Self::Main(*e) } } ================================================ FILE: rslib/src/scheduler/queue/learning.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::undo::CutoffSnapshot; use super::CardQueues; use crate::prelude::*; use crate::scheduler::timing::SchedTimingToday; #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord)] pub(crate) struct LearningQueueEntry { // due comes first, so the derived ordering sorts by due pub due: TimestampSecs, pub id: CardId, pub mtime: TimestampSecs, } impl CardQueues { /// Intraday learning cards that can be shown immediately. pub(super) fn intraday_now_iter(&self) -> impl Iterator { let cutoff = self.current_learning_cutoff; self.intraday_learning .iter() .take_while(move |e| e.due <= cutoff) } /// Intraday learning cards that can be shown after the main queue is empty. pub(super) fn intraday_ahead_iter(&self) -> impl Iterator { let cutoff = self.current_learning_cutoff; let ahead_cutoff = self.current_learn_ahead_cutoff(); self.intraday_learning .iter() .skip_while(move |e| e.due <= cutoff) .take_while(move |e| e.due <= ahead_cutoff) } /// Increase the cutoff to the current time, and increase the learning count /// for any new cards that now fall within the cutoff. pub(super) fn update_learning_cutoff_and_count(&mut self) -> CutoffSnapshot { let change = CutoffSnapshot { learning_count: self.counts.learning, learning_cutoff: self.current_learning_cutoff, }; let last_ahead_cutoff = self.current_learn_ahead_cutoff(); self.current_learning_cutoff = TimestampSecs::now(); let new_ahead_cutoff = self.current_learn_ahead_cutoff(); let new_learning_cards = self .intraday_learning .iter() .skip_while(|e| e.due <= last_ahead_cutoff) .take_while(|e| e.due <= new_ahead_cutoff) .count(); self.counts.learning += new_learning_cards; change } /// Given the just-answered `card`, place it back in the learning queues if /// it's still due today. Avoid placing it in a position where it would /// be shown again immediately. pub(super) fn maybe_requeue_learning_card( &mut self, card: &Card, timing: SchedTimingToday, ) -> Option { // not due today? if !card.is_intraday_learning() || card.due >= timing.next_day_at.0 as i32 { return None; } let entry = LearningQueueEntry { due: TimestampSecs(card.due as i64), id: card.id, mtime: card.mtime, }; Some(self.requeue_learning_entry(entry)) } pub(super) fn cutoff_snapshot(&self) -> Box { Box::new(CutoffSnapshot { learning_count: self.counts.learning, learning_cutoff: self.current_learning_cutoff, }) } pub(super) fn restore_cutoff(&mut self, change: &CutoffSnapshot) -> Box { let current = self.cutoff_snapshot(); self.counts.learning = change.learning_count; self.current_learning_cutoff = change.learning_cutoff; current } /// Caller must have validated learning entry is due today. pub(super) fn requeue_learning_entry( &mut self, mut entry: LearningQueueEntry, ) -> LearningQueueEntry { let cutoff = self.current_learn_ahead_cutoff(); // if the provided entry would be shown again immediately, see if we // can place it after the next card instead if entry.due <= cutoff && self.learning_collapsed() { if let Some(next) = self.intraday_learning.front() { if next.due >= entry.due && next.due.adding_secs(1) < cutoff { entry.due = next.due.adding_secs(1); } } } self.insert_intraday_learning_card(entry); entry } fn learning_collapsed(&self) -> bool { self.main.is_empty() } /// Remove the head of the intraday learning queue, and update counts. pub(super) fn pop_intraday_learning(&mut self) -> Option { self.intraday_learning.pop_front().inspect(|_head| { // FIXME: // under normal circumstances this should not go below 0, but currently // the Python unit tests answer learning cards before they're due self.counts.learning = self.counts.learning.saturating_sub(1); }) } /// Add an undone entry to the top of the intraday learning queue. pub(super) fn push_intraday_learning(&mut self, entry: LearningQueueEntry) { self.intraday_learning.push_front(entry); self.counts.learning += 1; } /// Adds an intraday learning card to the correct position of the queue, and /// increments learning count if card is due. pub(super) fn insert_intraday_learning_card(&mut self, entry: LearningQueueEntry) { if entry.due <= self.current_learn_ahead_cutoff() { self.counts.learning += 1; } let target_idx = self .intraday_learning .binary_search_by(|e| e.due.cmp(&entry.due)) .unwrap_or_else(|e| e); self.intraday_learning.insert(target_idx, entry); } /// Remove an inserted intraday learning card after a lapse is undone, /// adjusting counts. pub(super) fn remove_intraday_learning_card( &mut self, card_id: CardId, ) -> Option { if let Some(position) = self.intraday_learning.iter().position(|e| e.id == card_id) { let entry = self.intraday_learning.remove(position).unwrap(); if entry.due <= self .current_learning_cutoff .adding_secs(self.learn_ahead_secs) { // Theoretically this should never go below zero. self.counts.learning = self.counts.learning.saturating_sub(1); } Some(entry) } else { None } } fn current_learn_ahead_cutoff(&self) -> TimestampSecs { self.current_learning_cutoff .adding_secs(self.learn_ahead_secs) } } ================================================ FILE: rslib/src/scheduler/queue/main.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::CardQueues; use crate::prelude::*; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct MainQueueEntry { pub id: CardId, pub mtime: TimestampSecs, pub kind: MainQueueEntryKind, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum MainQueueEntryKind { New, Review, InterdayLearning, } impl CardQueues { /// Remove the head of the main queue, and update counts. pub(super) fn pop_main(&mut self) -> Option { self.main.pop_front().inspect(|head| { match head.kind { MainQueueEntryKind::New => self.counts.new -= 1, MainQueueEntryKind::Review => self.counts.review -= 1, MainQueueEntryKind::InterdayLearning => { // the bug causing learning counts to go below zero should // hopefully be fixed at this point, but ensure we don't wrap // if it isn't self.counts.learning = self.counts.learning.saturating_sub(1) } }; }) } /// Add an undone entry to the top of the main queue. pub(super) fn push_main(&mut self, entry: MainQueueEntry) { match entry.kind { MainQueueEntryKind::New => self.counts.new += 1, MainQueueEntryKind::Review => self.counts.review += 1, MainQueueEntryKind::InterdayLearning => self.counts.learning += 1, }; self.main.push_front(entry); } } ================================================ FILE: rslib/src/scheduler/queue/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod builder; mod entry; mod learning; mod main; pub(crate) mod undo; use std::collections::VecDeque; use anki_proto::scheduler::SchedulingContext; pub(crate) use builder::DueCard; pub(crate) use builder::DueCardKind; pub(crate) use builder::NewCard; pub(crate) use entry::QueueEntry; pub(crate) use entry::QueueEntryKind; pub(crate) use learning::LearningQueueEntry; pub(crate) use main::MainQueueEntry; pub(crate) use main::MainQueueEntryKind; use self::undo::QueueUpdate; use super::states::SchedulingStates; use super::timing::SchedTimingToday; use crate::prelude::*; use crate::scheduler::states::load_balancer::LoadBalancer; use crate::timestamp::TimestampSecs; #[derive(Debug)] pub(crate) struct CardQueues { counts: Counts, main: VecDeque, intraday_learning: VecDeque, current_day: u32, learn_ahead_secs: i64, build_time: TimestampMillis, /// Updated each time a card is answered, and by get_queued_cards() when the /// counts are zero. Ensures we don't show a newly-due learning card after a /// user returns from editing a review card. current_learning_cutoff: TimestampSecs, pub(crate) load_balancer: Option, } #[derive(Debug, Copy, Clone)] pub struct Counts { pub new: usize, pub learning: usize, pub review: usize, } impl Counts { fn all_zero(self) -> bool { self.new == 0 && self.learning == 0 && self.review == 0 } } #[derive(Debug, Clone)] pub struct QueuedCard { pub card: Card, pub kind: QueueEntryKind, pub states: SchedulingStates, pub context: SchedulingContext, } #[derive(Debug)] pub struct QueuedCards { pub cards: Vec, pub new_count: usize, pub learning_count: usize, pub review_count: usize, } /// When we encounter a card with new or review burying enabled, all future /// siblings need to be buried, regardless of their own settings. #[derive(Default, Debug, Clone, Copy)] pub(crate) struct BuryMode { pub(crate) bury_new: bool, pub(crate) bury_reviews: bool, pub(crate) bury_interday_learning: bool, } impl Collection { pub fn get_next_card(&mut self) -> Result> { self.get_queued_cards(1, false) .map(|queued| queued.cards.first().cloned()) } pub fn get_queued_cards( &mut self, fetch_limit: usize, intraday_learning_only: bool, ) -> Result { let queues = self.get_queues()?; let counts = queues.counts(); let entries: Vec<_> = if intraday_learning_only { queues .intraday_now_iter() .chain(queues.intraday_ahead_iter()) .map(Into::into) .collect() } else { queues.iter().take(fetch_limit).collect() }; let cards: Vec<_> = entries .into_iter() .map(|entry| { let card = self .storage .get_card(entry.card_id())? .or_not_found(entry.card_id())?; require!( card.mtime == entry.mtime(), "bug: card modified without updating queue: id:{} card:{} entry:{}", card.id, card.mtime, entry.mtime() ); // fixme: pass in card instead of id let next_states = self.get_scheduling_states(card.id)?; Ok(QueuedCard { context: new_scheduling_context(self, &card)?, card, states: next_states, kind: entry.kind(), }) }) .collect::>()?; Ok(QueuedCards { cards, new_count: counts.new, learning_count: counts.learning, review_count: counts.review, }) } } fn new_scheduling_context(col: &mut Collection, card: &Card) -> Result { Ok(SchedulingContext { deck_name: col .get_deck(card.original_or_current_deck_id())? .or_not_found(card.deck_id)? .human_name(), seed: card.review_seed(), }) } impl CardQueues { /// An iterator over the card queues, in the order the cards will /// be presented. fn iter(&self) -> impl Iterator + '_ { self.intraday_now_iter() .map(Into::into) .chain(self.main.iter().map(Into::into)) .chain(self.intraday_ahead_iter().map(Into::into)) } /// Remove the provided card from the top of the queues and /// adjust the counts. If it was not at the top, return an error. fn pop_entry(&mut self, id: CardId) -> Result { // This ignores the current cutoff, so may match if the provided // learning card is not yet due. It should not happen in normal // practice, but does happen in the Python unit tests, as they answer // learning cards early. if self .intraday_learning .front() .filter(|e| e.id == id) .is_some() { Ok(self.pop_intraday_learning().unwrap().into()) } else if self.main.front().filter(|e| e.id == id).is_some() { Ok(self.pop_main().unwrap().into()) } else { invalid_input!("not at top of queue") } } fn push_undo_entry(&mut self, entry: QueueEntry) { match entry { QueueEntry::IntradayLearning(entry) => self.push_intraday_learning(entry), QueueEntry::Main(entry) => self.push_main(entry), } } /// Return the current due counts. If there are no due cards, the learning /// cutoff is updated to the current time first, and any newly-due learning /// cards are added to the counts. pub(crate) fn counts(&mut self) -> Counts { if self.counts.all_zero() { // we discard the returned undo information in this case self.update_learning_cutoff_and_count(); } self.counts } fn is_stale(&self, current_day: u32) -> bool { self.current_day != current_day } } impl Collection { /// This is automatically done when transact() is called for everything /// except card answers, so unless you are modifying state outside of a /// transaction, you probably don't need this. pub(crate) fn clear_study_queues(&mut self) { self.state.card_queues = None; } pub(crate) fn maybe_clear_study_queues_after_op(&mut self, op: &OpChanges) { if op.op != Op::AnswerCard && op.requires_study_queue_rebuild() { self.state.card_queues = None; } } pub(crate) fn update_queues_after_answering_card( &mut self, card: &Card, timing: SchedTimingToday, is_finished_preview: bool, ) -> Result<()> { if let Some(queues) = &mut self.state.card_queues { let entry = queues.pop_entry(card.id)?; let requeued_learning = if is_finished_preview { None } else { queues.maybe_requeue_learning_card(card, timing) }; let cutoff_snapshot = queues.update_learning_cutoff_and_count(); let queue_build_time = queues.build_time; self.save_queue_update_undo(Box::new(QueueUpdate { entry, learning_requeue: requeued_learning, queue_build_time, cutoff_snapshot, })); } else { // we currently allow the queues to be empty for unit tests } Ok(()) } /// Get the card queues, building if necessary. pub(crate) fn get_queues(&mut self) -> Result<&mut CardQueues> { let deck = self.get_current_deck()?; self.clear_queues_if_day_changed()?; if self.state.card_queues.is_none() { self.state.card_queues = Some(self.build_queues(deck.id)?); } Ok(self.state.card_queues.as_mut().unwrap()) } // Returns queues if they are valid and have not been rebuilt. If build time has // changed, they are cleared. pub(crate) fn get_or_invalidate_queues( &mut self, build_time: TimestampMillis, ) -> Result> { self.clear_queues_if_day_changed()?; let same_build = self .state .card_queues .as_ref() .map(|q| q.build_time == build_time) .unwrap_or_default(); if same_build { Ok(self.state.card_queues.as_mut()) } else { self.clear_study_queues(); Ok(None) } } fn clear_queues_if_day_changed(&mut self) -> Result<()> { let timing = self.timing_today()?; let day_rolled_over = self .state .card_queues .as_ref() .map(|q| q.is_stale(timing.days_elapsed)) .unwrap_or(false); if day_rolled_over { self.discard_undo_and_study_queues(); self.unbury_on_day_rollover(timing.days_elapsed)?; } Ok(()) } } // test helpers #[cfg(test)] impl Collection { pub(crate) fn counts(&mut self) -> [usize; 3] { self.get_queued_cards(1, false) .map(|q| [q.new_count, q.learning_count, q.review_count]) .unwrap_or([0; 3]) } } ================================================ FILE: rslib/src/scheduler/queue/undo.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::LearningQueueEntry; use super::QueueEntry; use crate::prelude::*; #[derive(Debug)] pub(crate) enum UndoableQueueChange { CardAnswered(Box), CardAnswerUndone(Box), } #[derive(Debug)] pub(crate) struct QueueUpdate { pub entry: QueueEntry, pub learning_requeue: Option, pub queue_build_time: TimestampMillis, pub cutoff_snapshot: CutoffSnapshot, } /// Stores the old learning count and cutoff prior to the /// cutoff being adjusted after answering a card. #[derive(Debug)] pub(crate) struct CutoffSnapshot { pub learning_count: usize, pub learning_cutoff: TimestampSecs, } impl Collection { pub(crate) fn undo_queue_change(&mut self, change: UndoableQueueChange) -> Result<()> { match change { UndoableQueueChange::CardAnswered(update) => { if let Some(queues) = self.get_or_invalidate_queues(update.queue_build_time)? { queues.restore_cutoff(&update.cutoff_snapshot); if let Some(learning) = &update.learning_requeue { queues.remove_intraday_learning_card(learning.id); } queues.push_undo_entry(update.entry); } if let Some(card_queues) = self.state.card_queues.as_mut() { if let Some(load_balancer) = card_queues.load_balancer.as_mut() { match &update.entry { QueueEntry::IntradayLearning(entry) => { load_balancer.remove_card(entry.id); } QueueEntry::Main(entry) => { load_balancer.remove_card(entry.id); } } } } self.save_undo(UndoableQueueChange::CardAnswerUndone(update)); Ok(()) } UndoableQueueChange::CardAnswerUndone(update) => { if let Some(queues) = self.get_or_invalidate_queues(update.queue_build_time)? { queues.pop_entry(update.entry.card_id())?; if let Some(learning) = update.learning_requeue { queues.insert_intraday_learning_card(learning); } queues.restore_cutoff(&update.cutoff_snapshot); } self.save_undo(UndoableQueueChange::CardAnswered(update)); Ok(()) } } } pub(super) fn save_queue_update_undo(&mut self, change: Box) { self.save_undo(UndoableQueueChange::CardAnswered(change)) } } #[cfg(test)] mod test { use crate::card::CardQueue; use crate::card::CardType; use crate::deckconfig::LeechAction; use crate::prelude::*; fn add_note(col: &mut Collection, with_reverse: bool) -> Result { let nt = col .get_notetype_by_name("Basic (and reversed card)")? .unwrap(); let mut note = nt.new_note(); note.set_field(0, "one")?; if with_reverse { note.set_field(1, "two")?; } col.add_note(&mut note, DeckId(1))?; Ok(note.id) } #[test] fn undo() -> Result<()> { // add a note let mut col = Collection::new(); let nid = add_note(&mut col, true)?; // turn burying and leech suspension on let mut conf = col.storage.get_deck_config(DeckConfigId(1))?.unwrap(); conf.inner.bury_new = true; conf.inner.leech_action = LeechAction::Suspend as i32; col.storage.update_deck_conf(&conf)?; // get the first card let queued = col.get_next_card()?.unwrap(); let cid = queued.card.id; let sibling_cid = col.storage.all_card_ids_of_note_in_template_order(nid)?[1]; let assert_initial_state = |col: &mut Collection| -> Result<()> { let first = col.storage.get_card(cid)?.unwrap(); assert_eq!(first.queue, CardQueue::New); let sibling = col.storage.get_card(sibling_cid)?.unwrap(); assert_eq!(sibling.queue, CardQueue::New); Ok(()) }; assert_initial_state(&mut col)?; // immediately graduate the first card col.answer_easy(); // the sibling will be buried let sibling = col.storage.get_card(sibling_cid)?.unwrap(); assert_eq!(sibling.queue, CardQueue::SchedBuried); // make it due now, with 7 lapses. we use the storage layer directly, // bypassing undo let mut card = col.storage.get_card(cid)?.unwrap(); assert_eq!(card.ctype, CardType::Review); card.lapses = 7; card.due = 0; col.storage.update_card(&card)?; // fail it, which should cause it to be marked as a leech col.clear_study_queues(); col.answer_again(); let assert_post_review_state = |col: &mut Collection| -> Result<()> { let card = col.storage.get_card(cid)?.unwrap(); assert_eq!(card.interval, 1); assert_eq!(card.lapses, 8); assert_eq!( col.storage.get_all_revlog_entries(TimestampSecs(0))?.len(), 2 ); let note = col.storage.get_note(nid)?.unwrap(); assert_eq!(note.tags, vec!["leech".to_string()]); assert!(!col.storage.all_tags()?.is_empty()); let deck = col.get_deck(DeckId(1))?.unwrap(); assert_eq!(deck.common.review_studied, 1); assert!(col.get_next_card()?.is_none()); Ok(()) }; let assert_pre_review_state = |col: &mut Collection| -> Result<()> { // the card should have its old state, but a new mtime (which we can't // easily test without waiting) let card = col.storage.get_card(cid)?.unwrap(); assert_eq!(card.interval, 4); assert_eq!(card.lapses, 7); // the revlog entry should have been removed assert_eq!( col.storage.get_all_revlog_entries(TimestampSecs(0))?.len(), 1 ); // the note should no longer be tagged as a leech let note = col.storage.get_note(nid)?.unwrap(); assert!(note.tags.is_empty()); assert!(col.storage.all_tags()?.is_empty()); let deck = col.get_deck(DeckId(1))?.unwrap(); assert_eq!(deck.common.review_studied, 0); assert!(col.get_next_card()?.is_some()); assert_eq!(col.counts(), [0, 0, 1]); Ok(()) }; // ensure everything is restored on undo/redo assert_post_review_state(&mut col)?; col.undo()?; assert_pre_review_state(&mut col)?; col.redo()?; assert_post_review_state(&mut col)?; col.undo()?; assert_pre_review_state(&mut col)?; col.undo()?; assert_initial_state(&mut col)?; Ok(()) } #[test] fn undo_counts() -> Result<()> { let mut col = Collection::new(); if col.timing_today()?.near_cutoff() { return Ok(()); } assert_eq!(col.counts(), [0, 0, 0]); add_note(&mut col, true)?; assert_eq!(col.counts(), [2, 0, 0]); col.answer_again(); assert_eq!(col.counts(), [1, 1, 0]); col.answer_good(); assert_eq!(col.counts(), [0, 2, 0]); col.answer_again(); assert_eq!(col.counts(), [0, 2, 0]); // first card graduates col.answer_good(); assert_eq!(col.counts(), [0, 1, 0]); col.answer_easy(); assert_eq!(col.counts(), [0, 0, 0]); // now work backwards col.undo()?; assert_eq!(col.counts(), [0, 1, 0]); col.undo()?; assert_eq!(col.counts(), [0, 2, 0]); col.undo()?; assert_eq!(col.counts(), [0, 2, 0]); col.undo()?; assert_eq!(col.counts(), [1, 1, 0]); col.undo()?; assert_eq!(col.counts(), [2, 0, 0]); col.undo()?; assert_eq!(col.counts(), [0, 0, 0]); // and forwards again col.redo()?; assert_eq!(col.counts(), [2, 0, 0]); col.redo()?; assert_eq!(col.counts(), [1, 1, 0]); col.redo()?; assert_eq!(col.counts(), [0, 2, 0]); col.redo()?; assert_eq!(col.counts(), [0, 2, 0]); col.redo()?; assert_eq!(col.counts(), [0, 1, 0]); col.redo()?; assert_eq!(col.counts(), [0, 0, 0]); Ok(()) } #[test] fn redo_after_queue_invalidation_bug() -> Result<()> { // add a note to the default deck let mut col = Collection::new(); let _nid = add_note(&mut col, true)?; // add a deck and select it let mut deck = Deck::new_normal(); deck.name = NativeDeckName::from_human_name("foo"); col.add_deck(&mut deck)?; col.set_current_deck(deck.id)?; // select default again, which invalidates current queues col.set_current_deck(DeckId(1))?; // get the first card and answer it col.answer_easy(); // undo answer col.undo()?; // undo deck select, which invalidates the queues again col.undo()?; // redo deck select (another invalidation) col.redo()?; // when the card answer is redone, it shouldn't fail because // the queues are rebuilt after the card state is restored col.redo()?; Ok(()) } } ================================================ FILE: rslib/src/scheduler/reviews.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::sync::LazyLock; use rand::distr::Distribution; use rand::distr::Uniform; use regex::Regex; use super::answering::CardAnswer; use crate::card::Card; use crate::card::CardId; use crate::card::CardQueue; use crate::card::CardType; use crate::collection::Collection; use crate::config::StringKey; use crate::error::Result; use crate::prelude::*; use crate::scheduler::timing::is_unix_epoch_timestamp; impl Card { /// Make card due in `days_from_today`. /// If card is not a review card, convert it into one. /// Review/relearning cards have their interval preserved unless /// `force_reset` is true. /// If the card has no ease factor (it's new), `ease_factor` is used. fn set_due_date( &mut self, today: u32, next_day_start: i64, days_from_today: u32, ease_factor: f32, force_reset: bool, ) { let new_due = (today + days_from_today) as i32; let fsrs_enabled = self.memory_state.is_some(); let new_interval = if fsrs_enabled { if let Some(last_review_time) = self.last_review_time { let elapsed_days = TimestampSecs(next_day_start).elapsed_days_since(last_review_time); elapsed_days as u32 + days_from_today } else { let due = self.original_or_current_due(); let due_diff = if is_unix_epoch_timestamp(due) { let offset = (due as i64 - next_day_start) / 86_400; let due = (today as i64 + offset) as i32; new_due - due } else { new_due - due }; self.interval.saturating_add_signed(due_diff) } } else if force_reset || !matches!(self.ctype, CardType::Review | CardType::Relearn) { days_from_today.max(1) } else { self.interval.max(1) }; let ease_factor = (ease_factor * 1000.0).round() as u16; self.schedule_as_review(new_interval, new_due, ease_factor); } fn schedule_as_review(&mut self, interval: u32, due: i32, ease_factor: u16) { self.original_position = self.last_position(); self.remove_from_filtered_deck_before_reschedule(); self.interval = interval; self.due = due; self.ctype = CardType::Review; self.queue = CardQueue::Review; if self.ease_factor == 0 { // unlike the old Python code, we leave the ease factor alone // if it's already set self.ease_factor = ease_factor; } } } #[derive(Debug, PartialEq, Eq)] pub struct DueDateSpecifier { min: u32, max: u32, force_reset: bool, } pub fn parse_due_date_str(s: &str) -> Result { static RE: LazyLock = LazyLock::new(|| { Regex::new( r"(?x)^ # a number (?P\d+) # an optional hyphen and another number (?: - (?P\d+) )? # optional exclamation mark (?P!)? $ ", ) .unwrap() }); let caps = RE.captures(s).or_invalid(s)?; let min: u32 = caps.name("min").unwrap().as_str().parse()?; let max = if let Some(max) = caps.name("max") { max.as_str().parse()? } else { min }; let force_reset = caps.name("bang").is_some(); Ok(DueDateSpecifier { min: min.min(max), max: max.max(min), force_reset, }) } impl Collection { /// `days` should be in a format parseable by `parse_due_date_str`. /// If `context` is provided, provided key will be updated with the new /// value of `days`. pub fn set_due_date( &mut self, cids: &[CardId], days: &str, context: Option, ) -> Result> { let spec = parse_due_date_str(days)?; let usn = self.usn()?; let today = self.timing_today()?.days_elapsed; let next_day_start = self.timing_today()?.next_day_at.0; let mut rng = rand::rng(); let distribution = Uniform::new_inclusive(spec.min, spec.max).unwrap(); let mut decks_initial_ease: HashMap = HashMap::new(); self.transact(Op::SetDueDate, |col| { for mut card in col.all_cards_for_ids(cids, false)? { let deck_id = card.original_deck_id.or(card.deck_id); let ease_factor = match decks_initial_ease.get(&deck_id) { Some(ease) => *ease, None => { let deck = col.get_deck(deck_id)?.or_not_found(deck_id)?; let config_id = deck.config_id().or_invalid("home deck is filtered")?; let ease = col .get_deck_config(config_id, true)? // just for compiler; get_deck_config() is guaranteed to return a value .unwrap_or_default() .inner .initial_ease; decks_initial_ease.insert(deck_id, ease); ease } }; let original = card.clone(); let days_from_today = distribution.sample(&mut rng); card.set_due_date( today, next_day_start, days_from_today, ease_factor, spec.force_reset, ); col.log_manually_scheduled_review(&card, original.interval, usn)?; col.update_card_inner(&mut card, original, usn)?; } if let Some(key) = context { col.set_config_string_inner(key, days)?; } Ok(()) }) } pub fn grade_now(&mut self, cids: &[CardId], rating: i32) -> Result> { self.transact(Op::GradeNow, |col| { for &card_id in cids { let states = col.get_scheduling_states(card_id)?; let new_state = match rating { 0 => states.again, 1 => states.hard, 2 => states.good, 3 => states.easy, _ => invalid_input!("invalid rating"), }; let mut answer: CardAnswer = anki_proto::scheduler::CardAnswer { card_id: card_id.into(), current_state: Some(states.current.into()), new_state: Some(new_state.into()), rating, milliseconds_taken: 0, answered_at_millis: TimestampMillis::now().into(), } .into(); // Process the card without updating queues yet answer.from_queue = false; col.answer_card_inner(&mut answer)?; } Ok(()) }) } } #[cfg(test)] mod test { use super::*; use crate::prelude::*; #[test] fn parse() -> Result<()> { type S = DueDateSpecifier; assert!(parse_due_date_str("").is_err()); assert!(parse_due_date_str("x").is_err()); assert!(parse_due_date_str("-5").is_err()); assert_eq!( parse_due_date_str("5")?, S { min: 5, max: 5, force_reset: false } ); assert_eq!( parse_due_date_str("5!")?, S { min: 5, max: 5, force_reset: true } ); assert_eq!( parse_due_date_str("50-70")?, S { min: 50, max: 70, force_reset: false } ); assert_eq!( parse_due_date_str("70-50!")?, S { min: 50, max: 70, force_reset: true } ); Ok(()) } #[test] fn due_date() { let mut c = Card::new(NoteId(0), 0, DeckId(0), 0); // setting the due date of a new card will convert it c.set_due_date(5, 0, 2, 1.8, false); assert_eq!(c.ctype, CardType::Review); assert_eq!(c.due, 7); assert_eq!(c.interval, 2); assert_eq!(c.ease_factor, 1800); // reschedule it again the next day, shifting it from day 7 to day 9 c.set_due_date(6, 0, 3, 2.5, false); assert_eq!(c.due, 9); assert_eq!(c.interval, 2); assert_eq!(c.ease_factor, 1800); // interval doesn't change // we can bring cards forward too - return it to its original due date c.set_due_date(6, 0, 1, 2.4, false); assert_eq!(c.due, 7); assert_eq!(c.interval, 2); assert_eq!(c.ease_factor, 1800); // interval doesn't change // we can force the interval to be reset instead of shifted c.set_due_date(6, 0, 3, 2.3, true); assert_eq!(c.due, 9); assert_eq!(c.interval, 3); assert_eq!(c.ease_factor, 1800); // interval doesn't change // should work in a filtered deck c.interval = 2; c.ease_factor = 0; c.original_due = 7; c.original_deck_id = DeckId(1); c.due = -10000; c.queue = CardQueue::New; c.set_due_date(6, 0, 1, 2.2, false); assert_eq!(c.due, 7); assert_eq!(c.interval, 2); assert_eq!(c.ease_factor, 2200); assert_eq!(c.queue, CardQueue::Review); assert_eq!(c.original_due, 0); assert_eq!(c.original_deck_id, DeckId(0)); // relearning treated like review c.ctype = CardType::Relearn; c.original_due = c.due; c.due = 12345678; c.set_due_date(6, 0, 10, 2.1, false); assert_eq!(c.due, 16); assert_eq!(c.interval, 2); assert_eq!(c.ease_factor, 2200); // interval doesn't change } } ================================================ FILE: rslib/src/scheduler/service/answering.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::mem; use crate::prelude::*; use crate::scheduler::answering::CardAnswer; use crate::scheduler::answering::Rating; use crate::scheduler::queue::QueuedCard; use crate::scheduler::queue::QueuedCards; impl From for CardAnswer { fn from(mut answer: anki_proto::scheduler::CardAnswer) -> Self { let mut new_state = mem::take(&mut answer.new_state).unwrap_or_default(); let custom_data = mem::take(&mut new_state.custom_data); CardAnswer { card_id: CardId(answer.card_id), rating: answer.rating().into(), current_state: answer.current_state.unwrap_or_default().into(), new_state: new_state.into(), answered_at: TimestampMillis(answer.answered_at_millis), milliseconds_taken: answer.milliseconds_taken, custom_data, from_queue: true, } } } impl From for Rating { fn from(rating: anki_proto::scheduler::card_answer::Rating) -> Self { match rating { anki_proto::scheduler::card_answer::Rating::Again => Rating::Again, anki_proto::scheduler::card_answer::Rating::Hard => Rating::Hard, anki_proto::scheduler::card_answer::Rating::Good => Rating::Good, anki_proto::scheduler::card_answer::Rating::Easy => Rating::Easy, } } } impl From for anki_proto::scheduler::queued_cards::QueuedCard { fn from(queued_card: QueuedCard) -> Self { Self { card: Some(queued_card.card.into()), states: Some(queued_card.states.into()), context: Some(queued_card.context), queue: match queued_card.kind { crate::scheduler::queue::QueueEntryKind::New => { anki_proto::scheduler::queued_cards::Queue::New } crate::scheduler::queue::QueueEntryKind::Review => { anki_proto::scheduler::queued_cards::Queue::Review } crate::scheduler::queue::QueueEntryKind::Learning => { anki_proto::scheduler::queued_cards::Queue::Learning } } as i32, } } } impl From for anki_proto::scheduler::QueuedCards { fn from(queued_cards: QueuedCards) -> Self { Self { cards: queued_cards.cards.into_iter().map(Into::into).collect(), new_count: queued_cards.new_count as u32, learning_count: queued_cards.learning_count as u32, review_count: queued_cards.review_count as u32, } } } ================================================ FILE: rslib/src/scheduler/service/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod answering; mod states; use anki_proto::cards; use anki_proto::generic; use anki_proto::scheduler; use anki_proto::scheduler::ComputeFsrsParamsResponse; use anki_proto::scheduler::ComputeMemoryStateResponse; use anki_proto::scheduler::ComputeOptimalRetentionResponse; use anki_proto::scheduler::FsrsBenchmarkResponse; use anki_proto::scheduler::FuzzDeltaRequest; use anki_proto::scheduler::FuzzDeltaResponse; use anki_proto::scheduler::GetOptimalRetentionParametersResponse; use anki_proto::scheduler::SimulateFsrsReviewRequest; use anki_proto::scheduler::SimulateFsrsReviewResponse; use anki_proto::scheduler::SimulateFsrsWorkloadResponse; use fsrs::ComputeParametersInput; use fsrs::FSRSItem; use fsrs::FSRSReview; use fsrs::FSRS; use crate::backend::Backend; use crate::prelude::*; use crate::scheduler::fsrs::params::ComputeParamsRequest; use crate::scheduler::new::NewCardDueOrder; use crate::scheduler::states::CardState; use crate::scheduler::states::SchedulingStates; use crate::search::SortMode; use crate::stats::studied_today; impl crate::services::SchedulerService for Collection { /// This behaves like _updateCutoff() in older code - it also unburies at /// the start of a new day. fn sched_timing_today(&mut self) -> Result { let timing = self.timing_today()?; self.unbury_if_day_rolled_over(timing)?; Ok(timing.into()) } /// Fetch data from DB and return rendered string. fn studied_today(&mut self) -> Result { self.studied_today().map(Into::into) } /// Message rendering only, for old graphs. fn studied_today_message( &mut self, input: scheduler::StudiedTodayMessageRequest, ) -> Result { Ok(studied_today(input.cards, input.seconds as f32, &self.tr).into()) } fn update_stats(&mut self, input: scheduler::UpdateStatsRequest) -> Result<()> { self.transact_no_undo(|col| { let today = col.current_due_day(0)?; let usn = col.usn()?; col.update_deck_stats(today, usn, input) }) } fn extend_limits(&mut self, input: scheduler::ExtendLimitsRequest) -> Result<()> { self.transact_no_undo(|col| { let today = col.current_due_day(0)?; let usn = col.usn()?; col.extend_limits( today, usn, input.deck_id.into(), input.new_delta, input.review_delta, ) }) } fn counts_for_deck_today( &mut self, input: anki_proto::decks::DeckId, ) -> Result { self.counts_for_deck_today(input.did.into()) } fn congrats_info(&mut self) -> Result { self.congrats_info() } fn restore_buried_and_suspended_cards( &mut self, input: anki_proto::cards::CardIds, ) -> Result { let cids: Vec<_> = input.cids.into_iter().map(CardId).collect(); self.unbury_or_unsuspend_cards(&cids).map(Into::into) } fn unbury_deck( &mut self, input: scheduler::UnburyDeckRequest, ) -> Result { self.unbury_deck(input.deck_id.into(), input.mode()) .map(Into::into) } fn bury_or_suspend_cards( &mut self, input: scheduler::BuryOrSuspendCardsRequest, ) -> Result { let mode = input.mode(); let cids = if input.card_ids.is_empty() { self.storage .card_ids_of_notes(&input.note_ids.into_newtype(NoteId))? } else { input.card_ids.into_newtype(CardId) }; self.bury_or_suspend_cards(&cids, mode).map(Into::into) } fn empty_filtered_deck( &mut self, input: anki_proto::decks::DeckId, ) -> Result { self.empty_filtered_deck(input.did.into()).map(Into::into) } fn rebuild_filtered_deck( &mut self, input: anki_proto::decks::DeckId, ) -> Result { self.rebuild_filtered_deck(input.did.into()).map(Into::into) } fn schedule_cards_as_new( &mut self, input: scheduler::ScheduleCardsAsNewRequest, ) -> Result { let cids = input.card_ids.into_newtype(CardId); self.reschedule_cards_as_new( &cids, input.log, input.restore_position, input.reset_counts, input .context .and_then(|s| scheduler::schedule_cards_as_new_request::Context::try_from(s).ok()), ) .map(Into::into) } fn schedule_cards_as_new_defaults( &mut self, input: scheduler::ScheduleCardsAsNewDefaultsRequest, ) -> Result { Ok(Collection::reschedule_cards_as_new_defaults( self, input.context(), )) } fn set_due_date( &mut self, input: scheduler::SetDueDateRequest, ) -> Result { let config = input.config_key.map(|v| v.key().into()); let days = input.days; let cids = input.card_ids.into_newtype(CardId); self.set_due_date(&cids, &days, config).map(Into::into) } fn grade_now( &mut self, input: scheduler::GradeNowRequest, ) -> Result { self.grade_now(&input.card_ids.into_newtype(CardId), input.rating) .map(Into::into) } fn sort_cards( &mut self, input: scheduler::SortCardsRequest, ) -> Result { let cids = input.card_ids.into_newtype(CardId); let (start, step, random, shift) = ( input.starting_from, input.step_size, input.randomize, input.shift_existing, ); let order = if random { NewCardDueOrder::Random } else { NewCardDueOrder::Preserve }; self.sort_cards(&cids, start, step, order, shift) .map(Into::into) } fn reposition_defaults(&mut self) -> Result { Ok(Collection::reposition_defaults(self)) } fn sort_deck( &mut self, input: scheduler::SortDeckRequest, ) -> Result { self.sort_deck_legacy(input.deck_id.into(), input.randomize) .map(Into::into) } fn get_scheduling_states( &mut self, input: anki_proto::cards::CardId, ) -> Result { let cid: CardId = input.into(); self.get_scheduling_states(cid).map(Into::into) } fn describe_next_states( &mut self, input: scheduler::SchedulingStates, ) -> Result { let states: SchedulingStates = input.into(); self.describe_next_states(&states).map(Into::into) } fn state_is_leech(&mut self, input: scheduler::SchedulingState) -> Result { let state: CardState = input.into(); Ok(state.leeched().into()) } fn answer_card( &mut self, input: scheduler::CardAnswer, ) -> Result { self.answer_card(&mut input.into()).map(Into::into) } fn upgrade_scheduler(&mut self) -> Result<()> { self.transact_no_undo(|col| col.upgrade_to_v2_scheduler()) } fn get_queued_cards( &mut self, input: scheduler::GetQueuedCardsRequest, ) -> Result { self.get_queued_cards(input.fetch_limit as usize, input.intraday_learning_only) .map(Into::into) } fn custom_study( &mut self, input: scheduler::CustomStudyRequest, ) -> Result { self.custom_study(input).map(Into::into) } fn custom_study_defaults( &mut self, input: scheduler::CustomStudyDefaultsRequest, ) -> Result { self.custom_study_defaults(input.deck_id.into()) } fn compute_fsrs_params( &mut self, input: scheduler::ComputeFsrsParamsRequest, ) -> Result { self.compute_params(ComputeParamsRequest { search: &input.search, ignore_revlogs_before_ms: input.ignore_revlogs_before_ms.into(), current_preset: 1, total_presets: 1, current_params: &input.current_params, num_of_relearning_steps: input.num_of_relearning_steps as usize, health_check: input.health_check, }) } fn simulate_fsrs_review( &mut self, input: SimulateFsrsReviewRequest, ) -> Result { self.simulate_review(input) } fn simulate_fsrs_workload( &mut self, input: SimulateFsrsReviewRequest, ) -> Result { self.simulate_workload(input) } fn compute_optimal_retention( &mut self, input: SimulateFsrsReviewRequest, ) -> Result { Ok(ComputeOptimalRetentionResponse { optimal_retention: self.compute_optimal_retention(input)?, }) } fn evaluate_params( &mut self, input: scheduler::EvaluateParamsRequest, ) -> Result { let ret = self.evaluate_params( &input.search, input.ignore_revlogs_before_ms.into(), input.num_of_relearning_steps as usize, )?; Ok(scheduler::EvaluateParamsResponse { log_loss: ret.log_loss, rmse_bins: ret.rmse_bins, }) } fn evaluate_params_legacy( &mut self, input: scheduler::EvaluateParamsLegacyRequest, ) -> Result { let ret = self.evaluate_params_legacy( &input.params, &input.search, input.ignore_revlogs_before_ms.into(), )?; Ok(scheduler::EvaluateParamsResponse { log_loss: ret.log_loss, rmse_bins: ret.rmse_bins, }) } fn get_optimal_retention_parameters( &mut self, input: scheduler::GetOptimalRetentionParametersRequest, ) -> Result { let revlogs = self .search_cards_into_table(&input.search, SortMode::NoOrder)? .col .storage .get_revlog_entries_for_searched_cards_in_card_order()?; let simulator_config = self.get_optimal_retention_parameters(revlogs)?; Ok(GetOptimalRetentionParametersResponse { deck_size: simulator_config.deck_size as u32, learn_span: simulator_config.learn_span as u32, max_cost_perday: simulator_config.max_cost_perday, max_ivl: simulator_config.max_ivl, first_rating_prob: simulator_config.first_rating_prob.to_vec(), review_rating_prob: simulator_config.review_rating_prob.to_vec(), loss_aversion: 1.0, learn_limit: simulator_config.learn_limit as u32, review_limit: simulator_config.review_limit as u32, learning_step_transitions: simulator_config .learning_step_transitions .iter() .flatten() .cloned() .collect(), relearning_step_transitions: simulator_config .relearning_step_transitions .iter() .flatten() .cloned() .collect(), state_rating_costs: simulator_config .state_rating_costs .iter() .flatten() .cloned() .collect(), learning_step_count: simulator_config.learning_step_count as u32, relearning_step_count: simulator_config.relearning_step_count as u32, }) } fn compute_memory_state(&mut self, input: cards::CardId) -> Result { self.compute_memory_state(input.into()) } fn fuzz_delta(&mut self, input: FuzzDeltaRequest) -> Result { Ok(FuzzDeltaResponse { delta_days: self.get_fuzz_delta(input.card_id.into(), input.interval)?, }) } } impl crate::services::BackendSchedulerService for Backend { fn compute_fsrs_params_from_items( &self, req: scheduler::ComputeFsrsParamsFromItemsRequest, ) -> Result { let fsrs = FSRS::new(None)?; let fsrs_items = req.items.len() as u32; let params = fsrs.compute_parameters(ComputeParametersInput { train_set: req.items.into_iter().map(fsrs_item_proto_to_fsrs).collect(), progress: None, enable_short_term: true, num_relearning_steps: None, })?; Ok(ComputeFsrsParamsResponse { params, fsrs_items, health_check_passed: None, }) } fn fsrs_benchmark( &self, req: scheduler::FsrsBenchmarkRequest, ) -> Result { let fsrs = FSRS::new(None)?; let train_set = req .train_set .into_iter() .map(fsrs_item_proto_to_fsrs) .collect(); let params = fsrs.benchmark(ComputeParametersInput { train_set, progress: None, enable_short_term: true, num_relearning_steps: None, }); Ok(FsrsBenchmarkResponse { params }) } fn export_dataset(&self, req: scheduler::ExportDatasetRequest) -> Result<()> { self.with_col(|col| { col.export_dataset( req.min_entries.try_into().unwrap(), req.target_path.as_ref(), ) }) } } fn fsrs_item_proto_to_fsrs(item: anki_proto::scheduler::FsrsItem) -> FSRSItem { FSRSItem { reviews: item .reviews .into_iter() .map(fsrs_review_proto_to_fsrs) .collect(), } } fn fsrs_review_proto_to_fsrs(review: anki_proto::scheduler::FsrsReview) -> FSRSReview { FSRSReview { delta_t: review.delta_t, rating: review.rating, } } ================================================ FILE: rslib/src/scheduler/service/states/filtered.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::scheduler::states::FilteredState; impl From for anki_proto::scheduler::scheduling_state::Filtered { fn from(state: FilteredState) -> Self { anki_proto::scheduler::scheduling_state::Filtered { kind: Some(match state { FilteredState::Preview(state) => { anki_proto::scheduler::scheduling_state::filtered::Kind::Preview(state.into()) } FilteredState::Rescheduling(state) => { anki_proto::scheduler::scheduling_state::filtered::Kind::Rescheduling( state.into(), ) } }), } } } impl From for FilteredState { fn from(state: anki_proto::scheduler::scheduling_state::Filtered) -> Self { match state.kind.unwrap_or_else(|| { anki_proto::scheduler::scheduling_state::filtered::Kind::Preview(Default::default()) }) { anki_proto::scheduler::scheduling_state::filtered::Kind::Preview(state) => { FilteredState::Preview(state.into()) } anki_proto::scheduler::scheduling_state::filtered::Kind::Rescheduling(state) => { FilteredState::Rescheduling(state.into()) } } } } ================================================ FILE: rslib/src/scheduler/service/states/learning.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::scheduler::states::LearnState; impl From for LearnState { fn from(state: anki_proto::scheduler::scheduling_state::Learning) -> Self { LearnState { remaining_steps: state.remaining_steps, scheduled_secs: state.scheduled_secs, elapsed_secs: state.elapsed_secs, memory_state: state.memory_state.map(Into::into), } } } impl From for anki_proto::scheduler::scheduling_state::Learning { fn from(state: LearnState) -> Self { anki_proto::scheduler::scheduling_state::Learning { remaining_steps: state.remaining_steps, scheduled_secs: state.scheduled_secs, elapsed_secs: state.elapsed_secs, memory_state: state.memory_state.map(Into::into), } } } ================================================ FILE: rslib/src/scheduler/service/states/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod filtered; mod learning; mod new; mod normal; mod preview; mod relearning; mod rescheduling; mod review; use crate::scheduler::states::CardState; use crate::scheduler::states::NewState; use crate::scheduler::states::NormalState; use crate::scheduler::states::SchedulingStates; impl From for anki_proto::scheduler::SchedulingStates { fn from(choices: SchedulingStates) -> Self { anki_proto::scheduler::SchedulingStates { current: Some(choices.current.into()), again: Some(choices.again.into()), hard: Some(choices.hard.into()), good: Some(choices.good.into()), easy: Some(choices.easy.into()), } } } impl From for SchedulingStates { fn from(choices: anki_proto::scheduler::SchedulingStates) -> Self { SchedulingStates { current: choices.current.unwrap_or_default().into(), again: choices.again.unwrap_or_default().into(), hard: choices.hard.unwrap_or_default().into(), good: choices.good.unwrap_or_default().into(), easy: choices.easy.unwrap_or_default().into(), } } } impl From for anki_proto::scheduler::SchedulingState { fn from(state: CardState) -> Self { anki_proto::scheduler::SchedulingState { kind: Some(match state { CardState::Normal(state) => { anki_proto::scheduler::scheduling_state::Kind::Normal(state.into()) } CardState::Filtered(state) => { anki_proto::scheduler::scheduling_state::Kind::Filtered(state.into()) } }), custom_data: None, } } } impl From for CardState { fn from(state: anki_proto::scheduler::SchedulingState) -> Self { if let Some(value) = state.kind { match value { anki_proto::scheduler::scheduling_state::Kind::Normal(normal) => { CardState::Normal(normal.into()) } anki_proto::scheduler::scheduling_state::Kind::Filtered(filtered) => { CardState::Filtered(filtered.into()) } } } else { CardState::Normal(NormalState::New(NewState::default())) } } } ================================================ FILE: rslib/src/scheduler/service/states/new.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::scheduler::states::NewState; impl From for NewState { fn from(state: anki_proto::scheduler::scheduling_state::New) -> Self { NewState { position: state.position, } } } impl From for anki_proto::scheduler::scheduling_state::New { fn from(state: NewState) -> Self { anki_proto::scheduler::scheduling_state::New { position: state.position, } } } ================================================ FILE: rslib/src/scheduler/service/states/normal.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::scheduler::states::NormalState; impl From for anki_proto::scheduler::scheduling_state::Normal { fn from(state: NormalState) -> Self { anki_proto::scheduler::scheduling_state::Normal { kind: Some(match state { NormalState::New(state) => { anki_proto::scheduler::scheduling_state::normal::Kind::New(state.into()) } NormalState::Learning(state) => { anki_proto::scheduler::scheduling_state::normal::Kind::Learning(state.into()) } NormalState::Review(state) => { anki_proto::scheduler::scheduling_state::normal::Kind::Review(state.into()) } NormalState::Relearning(state) => { anki_proto::scheduler::scheduling_state::normal::Kind::Relearning(state.into()) } }), } } } impl From for NormalState { fn from(state: anki_proto::scheduler::scheduling_state::Normal) -> Self { match state.kind.unwrap_or_else(|| { anki_proto::scheduler::scheduling_state::normal::Kind::New(Default::default()) }) { anki_proto::scheduler::scheduling_state::normal::Kind::New(state) => { NormalState::New(state.into()) } anki_proto::scheduler::scheduling_state::normal::Kind::Learning(state) => { NormalState::Learning(state.into()) } anki_proto::scheduler::scheduling_state::normal::Kind::Review(state) => { NormalState::Review(state.into()) } anki_proto::scheduler::scheduling_state::normal::Kind::Relearning(state) => { NormalState::Relearning(state.into()) } } } } ================================================ FILE: rslib/src/scheduler/service/states/preview.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::scheduler::states::PreviewState; impl From for PreviewState { fn from(state: anki_proto::scheduler::scheduling_state::Preview) -> Self { PreviewState { scheduled_secs: state.scheduled_secs, finished: state.finished, } } } impl From for anki_proto::scheduler::scheduling_state::Preview { fn from(state: PreviewState) -> Self { anki_proto::scheduler::scheduling_state::Preview { scheduled_secs: state.scheduled_secs, finished: state.finished, } } } ================================================ FILE: rslib/src/scheduler/service/states/relearning.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::scheduler::states::RelearnState; impl From for RelearnState { fn from(state: anki_proto::scheduler::scheduling_state::Relearning) -> Self { RelearnState { review: state.review.unwrap_or_default().into(), learning: state.learning.unwrap_or_default().into(), } } } impl From for anki_proto::scheduler::scheduling_state::Relearning { fn from(state: RelearnState) -> Self { anki_proto::scheduler::scheduling_state::Relearning { review: Some(state.review.into()), learning: Some(state.learning.into()), } } } ================================================ FILE: rslib/src/scheduler/service/states/rescheduling.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::scheduler::states::ReschedulingFilterState; impl From for ReschedulingFilterState { fn from(state: anki_proto::scheduler::scheduling_state::ReschedulingFilter) -> Self { ReschedulingFilterState { original_state: state.original_state.unwrap_or_default().into(), } } } impl From for anki_proto::scheduler::scheduling_state::ReschedulingFilter { fn from(state: ReschedulingFilterState) -> Self { anki_proto::scheduler::scheduling_state::ReschedulingFilter { original_state: Some(state.original_state.into()), } } } ================================================ FILE: rslib/src/scheduler/service/states/review.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::scheduler::states::ReviewState; impl From for ReviewState { fn from(state: anki_proto::scheduler::scheduling_state::Review) -> Self { ReviewState { scheduled_days: state.scheduled_days, elapsed_days: state.elapsed_days, ease_factor: state.ease_factor, lapses: state.lapses, leeched: state.leeched, memory_state: state.memory_state.map(Into::into), } } } impl From for anki_proto::scheduler::scheduling_state::Review { fn from(state: ReviewState) -> Self { anki_proto::scheduler::scheduling_state::Review { scheduled_days: state.scheduled_days, elapsed_days: state.elapsed_days, ease_factor: state.ease_factor, lapses: state.lapses, leeched: state.leeched, memory_state: state.memory_state.map(Into::into), } } } ================================================ FILE: rslib/src/scheduler/states/filtered.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::IntervalKind; use super::PreviewState; use super::ReschedulingFilterState; use super::ReviewState; use super::SchedulingStates; use super::StateContext; use crate::revlog::RevlogReviewKind; #[derive(Debug, Clone, Copy, PartialEq)] pub enum FilteredState { Preview(PreviewState), Rescheduling(ReschedulingFilterState), } impl FilteredState { pub(crate) fn interval_kind(self) -> IntervalKind { match self { FilteredState::Preview(state) => state.interval_kind(), FilteredState::Rescheduling(state) => state.interval_kind(), } } pub(crate) fn revlog_kind(self) -> RevlogReviewKind { match self { FilteredState::Preview(state) => state.revlog_kind(), FilteredState::Rescheduling(state) => state.revlog_kind(), } } pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates { match self { FilteredState::Preview(state) => state.next_states(ctx), FilteredState::Rescheduling(state) => state.next_states(ctx), } } pub(crate) fn review_state(self) -> Option { match self { FilteredState::Preview(_) => None, FilteredState::Rescheduling(state) => state.original_state.review_state(), } } } ================================================ FILE: rslib/src/scheduler/states/fuzz.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::StateContext; use crate::collection::Collection; use crate::prelude::*; /// Describes a range of days for which a certain amount of fuzz is applied to /// the new interval. struct FuzzRange { start: f32, end: f32, factor: f32, } static FUZZ_RANGES: [FuzzRange; 3] = [ FuzzRange { start: 2.5, end: 7.0, factor: 0.15, }, FuzzRange { start: 7.0, end: 20.0, factor: 0.1, }, FuzzRange { start: 20.0, end: f32::MAX, factor: 0.05, }, ]; impl StateContext<'_> { /// Apply fuzz, respecting the passed bounds. pub(crate) fn with_review_fuzz(&self, interval: f32, minimum: u32, maximum: u32) -> u32 { self.load_balancer_ctx .as_ref() .and_then(|load_balancer_ctx| { load_balancer_ctx.find_interval(interval, minimum, maximum) }) .unwrap_or_else(|| with_review_fuzz(self.fuzz_factor, interval, minimum, maximum)) } } impl Collection { /// Used for FSRS add-on. pub(crate) fn get_fuzz_delta(&self, card_id: CardId, interval: u32) -> Result { let card = self.storage.get_card(card_id)?.or_not_found(card_id)?; let deck = self .storage .get_deck(card.deck_id)? .or_not_found(card.deck_id)?; let config = self.home_deck_config(deck.config_id(), card.original_deck_id)?; let fuzzed = with_review_fuzz( card.get_fuzz_factor(true), interval as f32, 1, config.inner.maximum_review_interval, ); Ok((fuzzed as i32) - (interval as i32)) } } pub(crate) fn with_review_fuzz( fuzz_factor: Option, interval: f32, minimum: u32, maximum: u32, ) -> u32 { if let Some(fuzz_factor) = fuzz_factor { let (lower, upper) = constrained_fuzz_bounds(interval, minimum, maximum); (lower as f32 + fuzz_factor * ((1 + upper - lower) as f32)).floor() as u32 } else { (interval.round() as u32).clamp(minimum, maximum) } } /// Return the bounds of the fuzz range, respecting `minimum` and `maximum`. /// Ensure the upper bound is larger than the lower bound, if `maximum` allows /// it and it is larger than 1. pub(crate) fn constrained_fuzz_bounds(interval: f32, minimum: u32, maximum: u32) -> (u32, u32) { let minimum = minimum.min(maximum); let interval = interval.clamp(minimum as f32, maximum as f32); let (mut lower, mut upper) = fuzz_bounds(interval); // minimum <= maximum and lower <= upper are assumed // now ensure minimum <= lower <= upper <= maximum lower = lower.clamp(minimum, maximum); upper = upper.clamp(minimum, maximum); if upper == lower && upper > 2 && upper < maximum { upper = lower + 1; }; (lower, upper) } pub(crate) fn fuzz_bounds(interval: f32) -> (u32, u32) { let delta = fuzz_delta(interval); ( (interval - delta).round() as u32, (interval + delta).round() as u32, ) } /// Return the amount of fuzz to apply to the interval in both directions. /// Short intervals do not get fuzzed. All other intervals get fuzzed by 1 day /// plus the number of its days in each defined fuzz range multiplied with the /// given factor. fn fuzz_delta(interval: f32) -> f32 { if interval < 2.5 { 0.0 } else { FUZZ_RANGES.iter().fold(1.0, |delta, range| { delta + range.factor * (interval.min(range.end) - range.start).max(0.0) }) } } #[cfg(test)] mod test { use super::*; #[test] fn with_review_fuzz() { let mut ctx = StateContext::defaults_for_testing(); // no fuzz assert_eq!(ctx.with_review_fuzz(1.5, 1, 100), 2); assert_eq!(ctx.with_review_fuzz(0.1, 1, 100), 1); assert_eq!(ctx.with_review_fuzz(101.0, 1, 100), 100); macro_rules! assert_lower_middle_upper { ($interval:expr, $minimum:expr, $maximum:expr, $lower:expr, $middle:expr, $upper:expr) => {{ ctx.fuzz_factor = Some(0.0); assert_eq!(ctx.with_review_fuzz($interval, $minimum, $maximum), $lower); ctx.fuzz_factor = Some(0.5); assert_eq!(ctx.with_review_fuzz($interval, $minimum, $maximum), $middle); ctx.fuzz_factor = Some(0.99); assert_eq!(ctx.with_review_fuzz($interval, $minimum, $maximum), $upper); }}; } // no fuzzing for an interval of 1-2.49 assert_lower_middle_upper!(1.0, 1, 1000, 1, 1, 1); assert_lower_middle_upper!(2.49, 1, 1000, 2, 2, 2); // 1 day for intervals >= 2.5 assert_lower_middle_upper!(2.5, 1, 1000, 2, 3, 4); // ... plus 0.15 for every day in the range 2.5-7 assert_lower_middle_upper!(7.0, 1, 1000, 5, 7, 9); // ... plus 0.1 for every day in the range 7-20 assert_lower_middle_upper!(17.0, 1, 1000, 14, 17, 20); // ... plus 0.05 for every day above 20 assert_lower_middle_upper!(37.0, 1, 1000, 33, 37, 41); // ensure fuzz range of at least 2, if allowed assert_lower_middle_upper!(2.0, 2, 1000, 2, 2, 2); assert_lower_middle_upper!(2.0, 3, 1000, 3, 4, 4); assert_lower_middle_upper!(2.0, 3, 3, 3, 3, 3); // fuzz range transitions assert_lower_middle_upper!(6.9, 3, 1000, 5, 7, 9); assert_lower_middle_upper!(7.0, 3, 1000, 5, 7, 9); assert_lower_middle_upper!(7.1, 3, 1000, 5, 7, 9); assert_lower_middle_upper!(19.9, 3, 1000, 17, 20, 23); assert_lower_middle_upper!(20.0, 3, 1000, 17, 20, 23); assert_lower_middle_upper!(20.1, 3, 1000, 17, 20, 23); // respect limits and preserve uniform distribution of valid intervals assert_lower_middle_upper!(100.0, 101, 1000, 101, 105, 108); assert_lower_middle_upper!(100.0, 1, 99, 92, 96, 99); assert_lower_middle_upper!(100.0, 97, 103, 97, 100, 103); } #[test] fn invalid_values_will_not_panic() { constrained_fuzz_bounds(1.0, 3, 2); } } ================================================ FILE: rslib/src/scheduler/states/interval_kind.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum IntervalKind { InSecs(u32), InDays(u32), } impl IntervalKind { /// Convert seconds-based intervals that pass the day barrier into days. pub(crate) fn maybe_as_days(self, secs_until_rollover: u32) -> Self { match self { IntervalKind::InSecs(secs) => { if secs >= secs_until_rollover { IntervalKind::InDays(((secs - secs_until_rollover) / 86_400) + 1) } else { IntervalKind::InSecs(secs) } } other => other, } } pub(crate) fn as_seconds(self) -> u32 { match self { IntervalKind::InSecs(secs) => secs, IntervalKind::InDays(days) => days.saturating_mul(86_400), } } pub(crate) fn as_revlog_interval(self) -> i32 { match self { IntervalKind::InDays(days) => days as i32, IntervalKind::InSecs(secs) => -i32::try_from(secs).unwrap_or(i32::MAX), } } } ================================================ FILE: rslib/src/scheduler/states/learning.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::interval_kind::IntervalKind; use super::CardState; use super::ReviewState; use super::SchedulingStates; use super::StateContext; use crate::card::FsrsMemoryState; use crate::revlog::RevlogReviewKind; #[derive(Debug, Clone, Copy, PartialEq)] pub struct LearnState { pub remaining_steps: u32, pub scheduled_secs: u32, pub elapsed_secs: u32, pub memory_state: Option, } impl LearnState { pub(crate) fn interval_kind(self) -> IntervalKind { IntervalKind::InSecs(self.scheduled_secs) } pub(crate) fn revlog_kind(self) -> RevlogReviewKind { RevlogReviewKind::Learning } pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates { SchedulingStates { current: self.into(), again: self.answer_again(ctx), hard: self.answer_hard(ctx), good: self.answer_good(ctx), easy: self.answer_easy(ctx).into(), } } fn answer_again(self, ctx: &StateContext) -> CardState { let memory_state = ctx.fsrs_next_states.as_ref().map(|s| s.again.memory.into()); if let Some(again_delay) = ctx.steps.again_delay_secs_learn() { LearnState { remaining_steps: ctx.steps.remaining_for_failed(), scheduled_secs: again_delay, elapsed_secs: 0, memory_state, } .into() } else { let (minimum, maximum) = ctx.min_and_max_review_intervals(1); let (interval, short_term) = if let Some(states) = &ctx.fsrs_next_states { ( states.again.interval, ctx.fsrs_allow_short_term && (ctx.fsrs_short_term_with_steps_enabled || ctx.steps.is_empty()) && states.again.interval < 0.5, ) } else { (ctx.graduating_interval_good as f32, false) }; if short_term { LearnState { remaining_steps: ctx.steps.remaining_for_failed(), scheduled_secs: (interval * 86_400.0) as u32, elapsed_secs: 0, memory_state, } .into() } else { ReviewState { scheduled_days: ctx.with_review_fuzz( interval.round().max(1.0), minimum, maximum, ), ease_factor: ctx.initial_ease_factor, memory_state, ..Default::default() } .into() } } } fn answer_hard(self, ctx: &StateContext) -> CardState { let memory_state = ctx.fsrs_next_states.as_ref().map(|s| s.hard.memory.into()); if let Some(hard_delay) = ctx.steps.hard_delay_secs(self.remaining_steps) { LearnState { scheduled_secs: hard_delay, elapsed_secs: 0, memory_state, ..self } .into() } else { let (minimum, maximum) = ctx.min_and_max_review_intervals(1); let (interval, short_term) = if let Some(states) = &ctx.fsrs_next_states { ( states.hard.interval, ctx.fsrs_allow_short_term && (ctx.fsrs_short_term_with_steps_enabled || ctx.steps.is_empty()) && states.hard.interval < 0.5, ) } else { (ctx.graduating_interval_good as f32, false) }; if short_term { LearnState { scheduled_secs: (interval * 86_400.0) as u32, elapsed_secs: 0, memory_state, ..self } .into() } else { ReviewState { scheduled_days: ctx.with_review_fuzz( interval.round().max(1.0), minimum, maximum, ), ease_factor: ctx.initial_ease_factor, memory_state, ..Default::default() } .into() } } } fn answer_good(self, ctx: &StateContext) -> CardState { let memory_state = ctx.fsrs_next_states.as_ref().map(|s| s.good.memory.into()); if let Some(good_delay) = ctx.steps.good_delay_secs(self.remaining_steps) { LearnState { remaining_steps: ctx.steps.remaining_for_good(self.remaining_steps), scheduled_secs: good_delay, elapsed_secs: 0, memory_state, } .into() } else { let (minimum, maximum) = ctx.min_and_max_review_intervals(1); let (interval, short_term) = if let Some(states) = &ctx.fsrs_next_states { ( states.good.interval, ctx.fsrs_allow_short_term && (ctx.fsrs_short_term_with_steps_enabled || ctx.steps.is_empty()) && states.good.interval < 0.5, ) } else { (ctx.graduating_interval_good as f32, false) }; if short_term { LearnState { scheduled_secs: (interval * 86_400.0) as u32, elapsed_secs: 0, memory_state, ..self } .into() } else { ReviewState { scheduled_days: ctx.with_review_fuzz( interval.round().max(1.0), minimum, maximum, ), ease_factor: ctx.initial_ease_factor, memory_state, ..Default::default() } .into() } } } fn answer_easy(self, ctx: &StateContext) -> ReviewState { let (mut minimum, maximum) = ctx.min_and_max_review_intervals(1); let interval = if let Some(states) = &ctx.fsrs_next_states { let good = ctx.with_review_fuzz(states.good.interval, minimum, maximum); minimum = good + 1; states.easy.interval.round().max(1.0) as u32 } else { ctx.graduating_interval_easy }; ReviewState { scheduled_days: ctx.with_review_fuzz(interval as f32, minimum, maximum), ease_factor: ctx.initial_ease_factor, memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.easy.memory.into()), ..Default::default() } } } ================================================ FILE: rslib/src/scheduler/states/load_balancer.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::collections::HashSet; use chrono::Datelike; use rand::distr::weighted::WeightedIndex; use rand::distr::Distribution; use rand::rngs::StdRng; use rand::SeedableRng; use super::fuzz::constrained_fuzz_bounds; use crate::card::CardId; use crate::deckconfig::DeckConfigId; use crate::error::InvalidInputError; use crate::notes::NoteId; use crate::prelude::*; use crate::storage::SqliteStorage; const MAX_LOAD_BALANCE_INTERVAL: usize = 90; // due to the nature of load balancing, we may schedule things in the future and // so need to keep more than just the `MAX_LOAD_BALANCE_INTERVAL` days in our // cache. a flat 10% increase over the max interval should be enough to not have // problems const LOAD_BALANCE_DAYS: usize = (MAX_LOAD_BALANCE_INTERVAL as f32 * 1.1) as usize; const SIBLING_PENALTY: f32 = 0.001; #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum EasyDay { Minimum, Reduced, Normal, } impl From for EasyDay { fn from(other: f32) -> EasyDay { match other { 1.0 => EasyDay::Normal, 0.0 => EasyDay::Minimum, _ => EasyDay::Reduced, } } } impl EasyDay { pub(crate) fn load_modifier(&self) -> f32 { match self { // this is a non-zero value so if all days are minimum, the load balancer will // proceed as normal EasyDay::Minimum => 0.0001, EasyDay::Reduced => 0.5, EasyDay::Normal => 1.0, } } } #[derive(Debug, Default)] struct LoadBalancerDay { cards: Vec<(CardId, NoteId)>, notes: HashSet, } impl LoadBalancerDay { fn add(&mut self, cid: CardId, nid: NoteId) { self.cards.push((cid, nid)); self.notes.insert(nid); } fn remove(&mut self, cid: CardId) { if let Some(index) = self.cards.iter().position(|c| c.0 == cid) { let (_, rnid) = self.cards.swap_remove(index); // if all cards of a note are removed, remove note if !self.cards.iter().any(|(_cid, nid)| *nid == rnid) { self.notes.remove(&rnid); } } } fn has_sibling(&self, nid: &NoteId) -> bool { self.notes.contains(nid) } } pub struct LoadBalancerContext<'a> { load_balancer: &'a LoadBalancer, note_id: Option, deckconfig_id: DeckConfigId, fuzz_seed: Option, } impl LoadBalancerContext<'_> { pub fn find_interval(&self, interval: f32, minimum: u32, maximum: u32) -> Option { self.load_balancer.find_interval( interval, minimum, maximum, self.deckconfig_id, self.fuzz_seed, self.note_id, ) } pub fn set_fuzz_seed(mut self, fuzz_seed: Option) -> Self { self.fuzz_seed = fuzz_seed; self } } #[derive(Debug)] pub struct LoadBalancer { /// Load balancer operates at the preset level, it only counts /// cards in the same preset as the card being balanced. days_by_preset: HashMap, easy_days_percentages_by_preset: HashMap, next_day_at: TimestampSecs, } impl LoadBalancer { pub fn new( today: u32, did_to_dcid: HashMap, next_day_at: TimestampSecs, storage: &SqliteStorage, ) -> Result { let cards_on_each_day = storage.get_all_cards_due_in_range(today, today + LOAD_BALANCE_DAYS as u32)?; let days_by_preset = cards_on_each_day .into_iter() // for each day, group all cards on each day by their deck config id .map(|cards_on_day| { cards_on_day .into_iter() .filter_map(|(cid, nid, did)| Some((cid, nid, did_to_dcid.get(&did)?))) .fold( HashMap::<_, Vec<_>>::new(), |mut day_group_by_dcid, (cid, nid, dcid)| { day_group_by_dcid.entry(dcid).or_default().push((cid, nid)); day_group_by_dcid }, ) }) .enumerate() // consolidate card by day groups into groups of [LoadBalancerDay; LOAD_BALANCE_DAYS]s .fold( HashMap::new(), |mut deckconfig_group, (day_index, days_grouped_by_dcid)| { for (group, cards) in days_grouped_by_dcid.into_iter() { let day = deckconfig_group .entry(*group) .or_insert_with(|| std::array::from_fn(|_| LoadBalancerDay::default())); for (cid, nid) in cards { day[day_index].add(cid, nid); } } deckconfig_group }, ); let configs = storage.get_deck_config_map()?; let easy_days_percentages_by_preset = build_easy_days_percentages(configs)?; Ok(LoadBalancer { days_by_preset, easy_days_percentages_by_preset, next_day_at, }) } pub fn review_context( &self, note_id: Option, deckconfig_id: DeckConfigId, ) -> LoadBalancerContext<'_> { LoadBalancerContext { load_balancer: self, note_id, deckconfig_id, fuzz_seed: None, } } /// The main load balancing function /// Given an interval and min/max range it does its best to find the best /// day within the standard fuzz range to schedule a card that leads to /// a consistent workload. /// /// It works by using a weighted random, assigning a weight between 0.0 and /// 1.0 to each day in the fuzz range for an interval. /// the weight takes into account the number of cards due on a day as well /// as the interval itself. /// `weight = (1 / (cards_due))**2 * (1 / target_interval)` /// /// By including the target_interval in the calculation, the interval is /// slightly biased to be due earlier. Without this, the load balancer /// ends up being very biased towards later days, especially around /// graduating intervals. /// /// if a note_id is provided, it attempts to avoid placing a card on a day /// that already has that note_id (aka avoid siblings) fn find_interval( &self, interval: f32, minimum: u32, maximum: u32, deckconfig_id: DeckConfigId, fuzz_seed: Option, note_id: Option, ) -> Option { // if we're sending a card far out into the future, the need to balance is low if interval as usize > MAX_LOAD_BALANCE_INTERVAL || minimum as usize > MAX_LOAD_BALANCE_INTERVAL { return None; } let (before_days, after_days) = constrained_fuzz_bounds(interval, minimum, maximum); let days = self.days_by_preset.get(&deckconfig_id)?; let interval_days = &days[before_days as usize..=after_days as usize]; // calculate review counts and expected distribution let (review_counts, weekdays): (Vec, Vec) = interval_days .iter() .enumerate() .map(|(i, day)| { ( day.cards.len(), interval_to_weekday(i as u32 + before_days, self.next_day_at), ) }) .unzip(); let easy_days_load = self.easy_days_percentages_by_preset.get(&deckconfig_id)?; let easy_days_modifier = calculate_easy_days_modifiers(easy_days_load, &weekdays, &review_counts); let intervals = interval_days .iter() .enumerate() .map(|(interval_index, interval_day)| { LoadBalancerInterval { target_interval: interval_index as u32 + before_days, review_count: review_counts[interval_index], // if there is a sibling on this day, give it a very low weight sibling_modifier: note_id .and_then(|note_id| { interval_day .has_sibling(¬e_id) .then_some(SIBLING_PENALTY) }) .unwrap_or(1.0), easy_days_modifier: easy_days_modifier[interval_index], } }); select_weighted_interval(intervals, fuzz_seed) } pub fn add_card(&mut self, cid: CardId, nid: NoteId, dcid: DeckConfigId, interval: u32) { if let Some(days) = self.days_by_preset.get_mut(&dcid) { if let Some(day) = days.get_mut(interval as usize) { day.add(cid, nid); } } } pub fn remove_card(&mut self, cid: CardId) { for (_, days) in self.days_by_preset.iter_mut() { for day in days.iter_mut() { day.remove(cid); } } } } pub(crate) fn parse_easy_days_percentages(percentages: &[f32]) -> Result<[EasyDay; 7]> { if percentages.is_empty() { return Ok([EasyDay::Normal; 7]); } Ok(TryInto::<[_; 7]>::try_into(percentages) .map_err(|_| { AnkiError::from(InvalidInputError { message: "expected 7 days".into(), source: None, backtrace: None, }) })? .map(EasyDay::from)) } pub(crate) fn build_easy_days_percentages( configs: HashMap, ) -> Result> { configs .into_iter() .map(|(dcid, conf)| { let easy_days_percentages = parse_easy_days_percentages(&conf.inner.easy_days_percentages)?; Ok((dcid, easy_days_percentages)) }) .collect() } // Determine which days to schedule to with respect to Easy Day settings // If a day is Normal, it will always be an option to schedule to // If a day is Minimum, it will almost never be an option to schedule to // If a day is Reduced, it will look at the amount of cards due in the fuzz // range to determine if scheduling a card on that day would put it // above the reduced threshold or not. // the resulting easy_days_modifier will be a vec of 0.0s and 1.0s, to be // used when calculating the day's weight. This turns the day on or off. // Note that it does not actually set it to 0.0, but a small // 0.0-ish number (see EASY_DAYS_MINIMUM_LOAD) to remove the need to // handle a handful of zero-related corner cases. pub(crate) fn calculate_easy_days_modifiers( easy_days_load: &[EasyDay; 7], weekdays: &[usize], review_counts: &[usize], ) -> Vec { let total_review_count: usize = review_counts.iter().sum(); let total_percents: f32 = weekdays .iter() .map(|&weekday| easy_days_load[weekday].load_modifier()) .sum(); weekdays .iter() .zip(review_counts.iter()) .map(|(&weekday, &review_count)| { let day = match easy_days_load[weekday] { EasyDay::Reduced => { const HALF: f32 = 0.5; let other_days_review_total = (total_review_count - review_count) as f32; let other_days_percent_total = total_percents - HALF; let normalized_count = review_count as f32 / HALF; let reduced_day_threshold = other_days_review_total / other_days_percent_total; if normalized_count > reduced_day_threshold { EasyDay::Minimum } else { EasyDay::Normal } } other => other, }; day.load_modifier() }) .collect() } pub struct LoadBalancerInterval { pub target_interval: u32, pub review_count: usize, pub sibling_modifier: f32, pub easy_days_modifier: f32, } pub fn select_weighted_interval( intervals: impl Iterator, fuzz_seed: Option, ) -> Option { let intervals_and_weights = intervals .map(|interval| { let weight = match interval.review_count { 0 => 1.0, // if theres no cards due on this day, give it the full 1.0 weight card_count => { let card_count_weight = (1.0 / card_count as f32).powf(2.15); let card_interval_weight = (1.0 / interval.target_interval as f32).powi(3); card_count_weight * card_interval_weight * interval.sibling_modifier * interval.easy_days_modifier } }; (interval.target_interval, weight) }) .collect::>(); let mut rng = StdRng::seed_from_u64(fuzz_seed?); let weighted_intervals = WeightedIndex::new(intervals_and_weights.iter().map(|k| k.1)).ok()?; let selected_interval_index = weighted_intervals.sample(&mut rng); Some(intervals_and_weights[selected_interval_index].0) } pub(crate) fn interval_to_weekday(interval: u32, next_day_at: TimestampSecs) -> usize { let target_datetime = next_day_at .adding_secs((interval - 1) as i64 * 86400) .local_datetime() .unwrap(); target_datetime.weekday().num_days_from_monday() as usize } ================================================ FILE: rslib/src/scheduler/states/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub(crate) mod filtered; pub(crate) mod fuzz; pub(crate) mod interval_kind; pub(crate) mod learning; pub(crate) mod load_balancer; pub(crate) mod new; pub(crate) mod normal; pub(crate) mod preview_filter; pub(crate) mod relearning; pub(crate) mod rescheduling_filter; pub(crate) mod review; pub(crate) mod steps; pub use filtered::FilteredState; use fsrs::NextStates; pub(crate) use interval_kind::IntervalKind; pub use learning::LearnState; use load_balancer::LoadBalancerContext; pub use new::NewState; pub use normal::NormalState; pub use preview_filter::PreviewState; pub use relearning::RelearnState; pub use rescheduling_filter::ReschedulingFilterState; pub use review::ReviewState; use self::steps::LearningSteps; use crate::revlog::RevlogReviewKind; use crate::scheduler::answering::PreviewDelays; #[derive(Debug, Clone, Copy, PartialEq)] pub enum CardState { Normal(NormalState), Filtered(FilteredState), } impl CardState { pub(crate) fn interval_kind(self) -> IntervalKind { match self { CardState::Normal(normal) => normal.interval_kind(), CardState::Filtered(filtered) => filtered.interval_kind(), } } pub(crate) fn revlog_kind(self) -> RevlogReviewKind { match self { CardState::Normal(normal) => normal.revlog_kind(), CardState::Filtered(filtered) => filtered.revlog_kind(), } } pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates { match self { CardState::Normal(state) => state.next_states(ctx), CardState::Filtered(state) => state.next_states(ctx), } } /// Returns underlying review state, if it exists. pub(crate) fn review_state(self) -> Option { match self { CardState::Normal(state) => state.review_state(), CardState::Filtered(state) => state.review_state(), } } pub(crate) fn leeched(self) -> bool { self.review_state().map(|r| r.leeched).unwrap_or_default() } /// Returns the position if it's a [NewState]. pub(super) fn new_position(&self) -> Option { match self { Self::Normal(NormalState::New(NewState { position })) | Self::Filtered(FilteredState::Rescheduling(ReschedulingFilterState { original_state: NormalState::New(NewState { position }), })) => Some(*position), _ => None, } } } /// Info required during state transitions. pub(crate) struct StateContext<'a> { /// In range `0.0..1.0`. Used to pick the final interval from the fuzz /// range. pub fuzz_factor: Option, pub fsrs_next_states: Option, pub fsrs_short_term_with_steps_enabled: bool, pub fsrs_allow_short_term: bool, // learning pub steps: LearningSteps<'a>, pub graduating_interval_good: u32, pub graduating_interval_easy: u32, pub initial_ease_factor: f32, // reviewing pub hard_multiplier: f32, pub easy_multiplier: f32, pub interval_multiplier: f32, pub maximum_review_interval: u32, pub leech_threshold: u32, pub load_balancer_ctx: Option>, // relearning pub relearn_steps: LearningSteps<'a>, pub lapse_multiplier: f32, pub minimum_lapse_interval: u32, // filtered pub in_filtered_deck: bool, pub preview_delays: PreviewDelays, } impl StateContext<'_> { /// Return the minimum and maximum review intervals. /// - `maximum` is `self.maximum_review_interval`, but at least 1. /// - `minimum` is as passed, but at least 1, and at most `maximum`. pub(crate) fn min_and_max_review_intervals(&self, minimum: u32) -> (u32, u32) { let maximum = self.maximum_review_interval.max(1); let minimum = minimum.clamp(1, maximum); (minimum, maximum) } #[cfg(test)] pub(crate) fn defaults_for_testing() -> Self { Self { fuzz_factor: None, steps: LearningSteps::new(&[1.0, 10.0]), graduating_interval_good: 1, graduating_interval_easy: 4, initial_ease_factor: 2.5, hard_multiplier: 1.2, easy_multiplier: 1.3, interval_multiplier: 1.0, maximum_review_interval: 36500, leech_threshold: 8, load_balancer_ctx: None, relearn_steps: LearningSteps::new(&[10.0]), lapse_multiplier: 0.0, minimum_lapse_interval: 1, in_filtered_deck: false, preview_delays: PreviewDelays { again: 1, hard: 10, good: 0, }, fsrs_next_states: None, fsrs_short_term_with_steps_enabled: false, fsrs_allow_short_term: false, } } } #[derive(Debug, Clone)] pub struct SchedulingStates { pub current: CardState, pub again: CardState, pub hard: CardState, pub good: CardState, pub easy: CardState, } impl From for CardState { fn from(state: NewState) -> Self { CardState::Normal(state.into()) } } impl From for CardState { fn from(state: ReviewState) -> Self { CardState::Normal(state.into()) } } impl From for CardState { fn from(state: LearnState) -> Self { CardState::Normal(state.into()) } } impl From for CardState { fn from(state: RelearnState) -> Self { CardState::Normal(state.into()) } } impl From for CardState { fn from(state: NormalState) -> Self { CardState::Normal(state) } } impl From for CardState { fn from(state: PreviewState) -> Self { CardState::Filtered(FilteredState::Preview(state)) } } impl From for CardState { fn from(state: ReschedulingFilterState) -> Self { CardState::Filtered(FilteredState::Rescheduling(state)) } } #[cfg(test)] mod test { use super::*; #[test] fn min_and_max_review_intervals() { let mut ctx = StateContext::defaults_for_testing(); ctx.maximum_review_interval = 0; assert_eq!(ctx.min_and_max_review_intervals(0), (1, 1)); assert_eq!(ctx.min_and_max_review_intervals(2), (1, 1)); ctx.maximum_review_interval = 3; assert_eq!(ctx.min_and_max_review_intervals(0), (1, 3)); assert_eq!(ctx.min_and_max_review_intervals(2), (2, 3)); assert_eq!(ctx.min_and_max_review_intervals(4), (3, 3)); } } ================================================ FILE: rslib/src/scheduler/states/new.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::interval_kind::IntervalKind; use crate::revlog::RevlogReviewKind; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct NewState { pub position: u32, } impl NewState { pub(crate) fn interval_kind(self) -> IntervalKind { // todo: consider packing the due number in here; it would allow us to restore // the original position of cards - though not as cheaply as if it were // a card property. IntervalKind::InSecs(0) } pub(crate) fn revlog_kind(self) -> RevlogReviewKind { RevlogReviewKind::Learning } } ================================================ FILE: rslib/src/scheduler/states/normal.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::interval_kind::IntervalKind; use super::LearnState; use super::NewState; use super::RelearnState; use super::ReviewState; use super::SchedulingStates; use super::StateContext; use crate::revlog::RevlogReviewKind; #[derive(Debug, Clone, Copy, PartialEq)] pub enum NormalState { New(NewState), Learning(LearnState), Review(ReviewState), Relearning(RelearnState), } impl NormalState { pub(crate) fn interval_kind(self) -> IntervalKind { match self { NormalState::New(state) => state.interval_kind(), NormalState::Learning(state) => state.interval_kind(), NormalState::Review(state) => state.interval_kind(), NormalState::Relearning(state) => state.interval_kind(), } } pub(crate) fn revlog_kind(self) -> RevlogReviewKind { match self { NormalState::New(state) => state.revlog_kind(), NormalState::Learning(state) => state.revlog_kind(), NormalState::Review(state) => state.revlog_kind(), NormalState::Relearning(state) => state.revlog_kind(), } } pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates { match self { NormalState::New(_) => { // New state acts like answering a failed learning card let next_states = LearnState { remaining_steps: ctx.steps.remaining_for_failed(), scheduled_secs: 0, elapsed_secs: 0, memory_state: None, } .next_states(ctx); // .. but with current as New, not Learning SchedulingStates { current: self.into(), ..next_states } } NormalState::Learning(state) => state.next_states(ctx), NormalState::Review(state) => state.next_states(ctx), NormalState::Relearning(state) => state.next_states(ctx), } } pub(crate) fn review_state(self) -> Option { match self { NormalState::New(_) => None, NormalState::Learning(_) => None, NormalState::Review(state) => Some(state), NormalState::Relearning(RelearnState { review, .. }) => Some(review), } } pub(crate) fn leeched(self) -> bool { self.review_state().map(|r| r.leeched).unwrap_or_default() } } impl From for NormalState { fn from(state: NewState) -> Self { NormalState::New(state) } } impl From for NormalState { fn from(state: ReviewState) -> Self { NormalState::Review(state) } } impl From for NormalState { fn from(state: LearnState) -> Self { NormalState::Learning(state) } } impl From for NormalState { fn from(state: RelearnState) -> Self { NormalState::Relearning(state) } } ================================================ FILE: rslib/src/scheduler/states/preview_filter.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::CardState; use super::IntervalKind; use super::SchedulingStates; use super::StateContext; use crate::revlog::RevlogReviewKind; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PreviewState { pub scheduled_secs: u32, pub finished: bool, } impl PreviewState { pub(crate) fn interval_kind(self) -> IntervalKind { IntervalKind::InSecs(self.scheduled_secs) } pub(crate) fn revlog_kind(self) -> RevlogReviewKind { RevlogReviewKind::Filtered } pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates { SchedulingStates { current: self.into(), again: delay_or_return(ctx.preview_delays.again), hard: delay_or_return(ctx.preview_delays.hard), good: delay_or_return(ctx.preview_delays.good), easy: delay_or_return(0), } } } fn delay_or_return(seconds: u32) -> CardState { if seconds == 0 { PreviewState { scheduled_secs: 0, finished: true, } } else { PreviewState { scheduled_secs: seconds, finished: false, } } .into() } ================================================ FILE: rslib/src/scheduler/states/relearning.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::interval_kind::IntervalKind; use super::CardState; use super::LearnState; use super::ReviewState; use super::SchedulingStates; use super::StateContext; use crate::revlog::RevlogReviewKind; #[derive(Debug, Clone, Copy, PartialEq)] pub struct RelearnState { pub learning: LearnState, pub review: ReviewState, } impl RelearnState { pub(crate) fn interval_kind(self) -> IntervalKind { self.learning.interval_kind() } pub(crate) fn revlog_kind(self) -> RevlogReviewKind { RevlogReviewKind::Relearning } pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates { SchedulingStates { current: self.into(), again: self.answer_again(ctx), hard: self.answer_hard(ctx), good: self.answer_good(ctx), easy: self.answer_easy(ctx).into(), } } fn answer_again(self, ctx: &StateContext) -> CardState { let (scheduled_days, memory_state) = self.review.failing_review_interval(ctx); if let Some(again_delay) = ctx.relearn_steps.again_delay_secs_learn() { RelearnState { learning: LearnState { remaining_steps: ctx.relearn_steps.remaining_for_failed(), scheduled_secs: again_delay, elapsed_secs: 0, memory_state, }, review: ReviewState { scheduled_days: scheduled_days.round().max(1.0) as u32, elapsed_days: 0, memory_state, ..self.review }, } .into() } else if let Some(states) = &ctx.fsrs_next_states { let (minimum, maximum) = ctx.min_and_max_review_intervals(1); let interval = states.again.interval; let again_review = ReviewState { scheduled_days: ctx.with_review_fuzz(interval.round().max(1.0), minimum, maximum), memory_state, ..self.review }; let again_relearn = RelearnState { learning: LearnState { remaining_steps: ctx.relearn_steps.remaining_for_failed(), scheduled_secs: (interval * 86_400.0) as u32, elapsed_secs: 0, memory_state, }, review: again_review, }; if ctx.fsrs_allow_short_term && (ctx.fsrs_short_term_with_steps_enabled || ctx.relearn_steps.is_empty()) && interval < 0.5 { again_relearn.into() } else { again_review.into() } } else { self.review.into() } } fn answer_hard(self, ctx: &StateContext) -> CardState { let memory_state = ctx.fsrs_next_states.as_ref().map(|s| s.hard.memory.into()); if let Some(hard_delay) = ctx .relearn_steps .hard_delay_secs(self.learning.remaining_steps) { RelearnState { learning: LearnState { scheduled_secs: hard_delay, memory_state, ..self.learning }, review: ReviewState { elapsed_days: 0, memory_state, ..self.review }, } .into() } else if let Some(states) = &ctx.fsrs_next_states { let (minimum, maximum) = ctx.min_and_max_review_intervals(1); let interval = states.hard.interval; let hard_review = ReviewState { scheduled_days: ctx.with_review_fuzz(interval.round().max(1.0), minimum, maximum), memory_state, ..self.review }; let hard_relearn = RelearnState { learning: LearnState { scheduled_secs: (interval * 86_400.0) as u32, memory_state, ..self.learning }, review: hard_review, }; if ctx.fsrs_allow_short_term && (ctx.fsrs_short_term_with_steps_enabled || ctx.relearn_steps.is_empty()) && interval < 0.5 { hard_relearn.into() } else { hard_review.into() } } else { self.review.into() } } fn answer_good(self, ctx: &StateContext) -> CardState { let memory_state = ctx.fsrs_next_states.as_ref().map(|s| s.good.memory.into()); if let Some(good_delay) = ctx .relearn_steps .good_delay_secs(self.learning.remaining_steps) { RelearnState { learning: LearnState { scheduled_secs: good_delay, remaining_steps: ctx .relearn_steps .remaining_for_good(self.learning.remaining_steps), elapsed_secs: 0, memory_state, }, review: ReviewState { elapsed_days: 0, memory_state, ..self.review }, } .into() } else if let Some(states) = &ctx.fsrs_next_states { let (minimum, maximum) = ctx.min_and_max_review_intervals(1); let interval = states.good.interval; let good_review = ReviewState { scheduled_days: ctx.with_review_fuzz(interval.round().max(1.0), minimum, maximum), memory_state, ..self.review }; let good_relearn = RelearnState { learning: LearnState { scheduled_secs: (interval * 86_400.0) as u32, remaining_steps: ctx .relearn_steps .remaining_for_good(self.learning.remaining_steps), memory_state, ..self.learning }, review: good_review, }; if ctx.fsrs_allow_short_term && (ctx.fsrs_short_term_with_steps_enabled || ctx.relearn_steps.is_empty()) && interval < 0.5 { good_relearn.into() } else { good_review.into() } } else { self.review.into() } } fn answer_easy(self, ctx: &StateContext) -> ReviewState { let scheduled_days = if let Some(states) = &ctx.fsrs_next_states { let (mut minimum, maximum) = ctx.min_and_max_review_intervals(1); let good = ctx.with_review_fuzz(states.good.interval, minimum, maximum); minimum = good + 1; let interval = states.easy.interval; ctx.with_review_fuzz(interval.round().max(1.0), minimum, maximum) } else { self.review.scheduled_days + 1 }; ReviewState { scheduled_days, elapsed_days: 0, memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.easy.memory.into()), ..self.review } } } ================================================ FILE: rslib/src/scheduler/states/rescheduling_filter.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::interval_kind::IntervalKind; use super::normal::NormalState; use super::CardState; use super::SchedulingStates; use super::StateContext; use crate::revlog::RevlogReviewKind; #[derive(Debug, Clone, Copy, PartialEq)] pub struct ReschedulingFilterState { pub original_state: NormalState, } impl ReschedulingFilterState { pub(crate) fn interval_kind(self) -> IntervalKind { self.original_state.interval_kind() } pub(crate) fn revlog_kind(self) -> RevlogReviewKind { self.original_state.revlog_kind() } pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates { let normal = self.original_state.next_states(ctx); if ctx.in_filtered_deck { SchedulingStates { current: self.into(), again: maybe_wrap(normal.again), hard: maybe_wrap(normal.hard), good: maybe_wrap(normal.good), easy: maybe_wrap(normal.easy), } } else { // card is marked as filtered, but not in a filtered deck; convert to normal normal } } } /// The review state is returned unchanged because cards are returned to /// their original deck in that state; other normal states are wrapped /// in the filtered state. Providing a filtered state is an error. fn maybe_wrap(state: CardState) -> CardState { match state { CardState::Normal(normal) => { if matches!(normal, NormalState::Review(_)) { normal.into() } else { ReschedulingFilterState { original_state: normal, } .into() } } CardState::Filtered(_) => { unreachable!() } } } ================================================ FILE: rslib/src/scheduler/states/review.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use fsrs::NextStates; use super::interval_kind::IntervalKind; use super::CardState; use super::LearnState; use super::RelearnState; use super::SchedulingStates; use super::StateContext; use crate::card::FsrsMemoryState; use crate::revlog::RevlogReviewKind; pub const INITIAL_EASE_FACTOR: f32 = 2.5; pub const MINIMUM_EASE_FACTOR: f32 = 1.3; pub const EASE_FACTOR_AGAIN_DELTA: f32 = -0.2; pub const EASE_FACTOR_HARD_DELTA: f32 = -0.15; pub const EASE_FACTOR_EASY_DELTA: f32 = 0.15; #[derive(Debug, Clone, Copy, PartialEq)] pub struct ReviewState { pub scheduled_days: u32, pub elapsed_days: u32, pub ease_factor: f32, pub lapses: u32, pub leeched: bool, pub memory_state: Option, } impl Default for ReviewState { fn default() -> Self { ReviewState { scheduled_days: 0, elapsed_days: 0, ease_factor: INITIAL_EASE_FACTOR, lapses: 0, leeched: false, memory_state: None, } } } impl ReviewState { pub(crate) fn days_late(&self) -> i32 { self.elapsed_days as i32 - self.scheduled_days as i32 } pub(crate) fn interval_kind(self) -> IntervalKind { // fixme: maybe use elapsed days in the future? would only // make sense for revlog's lastIvl, not for future interval IntervalKind::InDays(self.scheduled_days) } pub(crate) fn revlog_kind(self) -> RevlogReviewKind { if self.days_late() < 0 { RevlogReviewKind::Filtered } else { RevlogReviewKind::Review } } pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates { let (hard_interval, good_interval, easy_interval) = self.passing_review_intervals(ctx); SchedulingStates { current: self.into(), again: self.answer_again(ctx), hard: self.answer_hard(hard_interval, ctx).into(), good: self.answer_good(good_interval, ctx).into(), easy: self.answer_easy(easy_interval, ctx).into(), } } pub(crate) fn failing_review_interval( self, ctx: &StateContext, ) -> (f32, Option) { if let Some(states) = &ctx.fsrs_next_states { // In FSRS, fuzz is applied when the card leaves the relearning // stage (states.again.interval, Some(states.again.memory.into())) } else { let (minimum, maximum) = ctx.min_and_max_review_intervals(ctx.minimum_lapse_interval); let interval = ctx.with_review_fuzz( (self.scheduled_days as f32).max(1.0) * ctx.lapse_multiplier, minimum, maximum, ); (interval as f32, None) } } fn answer_again(self, ctx: &StateContext) -> CardState { let lapses = self.lapses + 1; let leeched = leech_threshold_met(lapses, ctx.leech_threshold); let (scheduled_days, memory_state) = self.failing_review_interval(ctx); let again_review = ReviewState { scheduled_days: scheduled_days.round().max(1.0) as u32, elapsed_days: 0, ease_factor: (self.ease_factor + EASE_FACTOR_AGAIN_DELTA).max(MINIMUM_EASE_FACTOR), lapses, leeched, memory_state, }; let again_relearn = RelearnState { learning: LearnState { remaining_steps: ctx.relearn_steps.remaining_for_failed(), scheduled_secs: (scheduled_days * 86_400.0) as u32, elapsed_secs: 0, memory_state, }, review: again_review, }; if let Some(again_delay) = ctx.relearn_steps.again_delay_secs_learn() { RelearnState { learning: LearnState { remaining_steps: ctx.relearn_steps.remaining_for_failed(), scheduled_secs: again_delay, elapsed_secs: 0, memory_state, }, review: again_review, } .into() } else if ctx.fsrs_allow_short_term && (ctx.fsrs_short_term_with_steps_enabled || ctx.relearn_steps.is_empty()) && scheduled_days < 0.5 { again_relearn.into() } else { again_review.into() } } fn answer_hard(self, scheduled_days: u32, ctx: &StateContext) -> ReviewState { ReviewState { scheduled_days, elapsed_days: 0, ease_factor: (self.ease_factor + EASE_FACTOR_HARD_DELTA).max(MINIMUM_EASE_FACTOR), memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.hard.memory.into()), ..self } } fn answer_good(self, scheduled_days: u32, ctx: &StateContext) -> ReviewState { ReviewState { scheduled_days, elapsed_days: 0, memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.good.memory.into()), ..self } } fn answer_easy(self, scheduled_days: u32, ctx: &StateContext) -> ReviewState { ReviewState { scheduled_days, elapsed_days: 0, ease_factor: self.ease_factor + EASE_FACTOR_EASY_DELTA, memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.easy.memory.into()), ..self } } /// Return the intervals for hard, good and easy, each of which depends on /// the previous. fn passing_review_intervals(self, ctx: &StateContext) -> (u32, u32, u32) { if let Some(states) = &ctx.fsrs_next_states { self.passing_fsrs_review_intervals(ctx, states) } else if self.days_late() < 0 { self.passing_early_review_intervals(ctx) } else { self.passing_nonearly_review_intervals(ctx) } } fn passing_fsrs_review_intervals( self, ctx: &StateContext, states: &NextStates, ) -> (u32, u32, u32) { // If the interval is larger than last time, don't allow fuzz to go backwards let greater_than_last = |interval: u32| { if interval > self.scheduled_days { self.scheduled_days + 1 } else { // User may have changed their retention factor; don't limit 0 } }; let hard = constrain_passing_interval( ctx, states.hard.interval, greater_than_last(states.hard.interval.round() as u32).max(1), true, ); let good = constrain_passing_interval( ctx, states.good.interval, greater_than_last(states.good.interval.round() as u32).max(hard + 1), true, ); let easy = constrain_passing_interval( ctx, states.easy.interval, greater_than_last(states.easy.interval.round() as u32).max(good + 1), true, ); (hard, good, easy) } fn passing_nonearly_review_intervals(self, ctx: &StateContext) -> (u32, u32, u32) { let current_interval = (self.scheduled_days as f32).max(1.0); let days_late = self.days_late().max(0) as f32; // hard let hard_factor = ctx.hard_multiplier; let hard_minimum = if hard_factor <= 1.0 { 0 } else { self.scheduled_days + 1 }; let hard_interval = constrain_passing_interval(ctx, current_interval * hard_factor, hard_minimum, true); // good let good_minimum = if hard_factor <= 1.0 { self.scheduled_days + 1 } else { hard_interval + 1 }; let good_interval = constrain_passing_interval( ctx, (current_interval + days_late / 2.0) * self.ease_factor, good_minimum, true, ); // easy let easy_interval = constrain_passing_interval( ctx, (current_interval + days_late) * self.ease_factor * ctx.easy_multiplier, good_interval + 1, true, ); (hard_interval, good_interval, easy_interval) } /// Mostly direct port from the Python version for now, so we can confirm /// implementation is correct. /// FIXME: this needs reworking in the future; it overly penalizes reviews /// done shortly before the due date. fn passing_early_review_intervals(self, ctx: &StateContext) -> (u32, u32, u32) { let scheduled = (self.scheduled_days as f32).max(1.0); let elapsed = self.elapsed_days as f32; let hard_interval = { let factor = ctx.hard_multiplier; let half_usual = factor / 2.0; constrain_passing_interval( ctx, (elapsed * factor).max(scheduled * half_usual), 0, false, ) }; let good_interval = constrain_passing_interval(ctx, (elapsed * self.ease_factor).max(scheduled), 0, false); let easy_interval = { let reduced_bonus = ctx.easy_multiplier - (ctx.easy_multiplier - 1.0) / 2.0; constrain_passing_interval( ctx, (elapsed * self.ease_factor).max(scheduled) * reduced_bonus, 0, false, ) }; (hard_interval, good_interval, easy_interval) } } /// True when lapses is at threshold, or every half threshold after that. /// Non-even thresholds round up the half threshold. fn leech_threshold_met(lapses: u32, threshold: u32) -> bool { if threshold > 0 { let half_threshold = (threshold as f32 / 2.0).ceil().max(1.0) as u32; // at threshold, and every half threshold after that, rounding up lapses >= threshold && (lapses - threshold) % half_threshold == 0 } else { false } } /// Transform the provided hard/good/easy interval. /// - Apply configured interval multiplier if not FSRS. /// - Apply fuzz. /// - Ensure it is at least `minimum`, and at least 1. /// - Ensure it is at or below the configured maximum interval. fn constrain_passing_interval(ctx: &StateContext, interval: f32, minimum: u32, fuzz: bool) -> u32 { let interval = if ctx.fsrs_next_states.is_some() { interval } else { interval * ctx.interval_multiplier }; let (minimum, maximum) = ctx.min_and_max_review_intervals(minimum); if fuzz { ctx.with_review_fuzz(interval, minimum, maximum) } else { (interval.round() as u32).clamp(minimum, maximum) } } #[cfg(test)] mod test { use super::*; #[test] fn leech_threshold() { assert!(!leech_threshold_met(0, 3)); assert!(!leech_threshold_met(1, 3)); assert!(!leech_threshold_met(2, 3)); assert!(leech_threshold_met(3, 3)); assert!(!leech_threshold_met(4, 3)); assert!(leech_threshold_met(5, 3)); assert!(!leech_threshold_met(6, 3)); assert!(leech_threshold_met(7, 3)); assert!(!leech_threshold_met(7, 8)); assert!(leech_threshold_met(8, 8)); assert!(!leech_threshold_met(9, 8)); assert!(!leech_threshold_met(10, 8)); assert!(!leech_threshold_met(11, 8)); assert!(leech_threshold_met(12, 8)); assert!(!leech_threshold_met(13, 8)); // 0 means off assert!(!leech_threshold_met(0, 0)); // no div by zero; half of 1 is 1 assert!(!leech_threshold_met(0, 1)); assert!(leech_threshold_met(1, 1)); assert!(leech_threshold_met(2, 1)); assert!(leech_threshold_met(3, 1)); } #[test] fn extreme_multiplier_fuzz() { let mut ctx = StateContext::defaults_for_testing(); // our calculations should work correctly with a low ease or non-default // multiplier let state = ReviewState { scheduled_days: 1, elapsed_days: 1, ease_factor: 1.3, lapses: 0, leeched: false, memory_state: None, }; ctx.fuzz_factor = Some(0.0); assert_eq!(state.passing_review_intervals(&ctx), (2, 3, 4)); // this is a silly multiplier, but it shouldn't underflow ctx.interval_multiplier = 0.1; assert_eq!(state.passing_review_intervals(&ctx), (2, 3, 4)); ctx.fuzz_factor = Some(0.99); assert_eq!(state.passing_review_intervals(&ctx), (2, 4, 6)); // maximum must be respected no matter what ctx.interval_multiplier = 10.0; ctx.maximum_review_interval = 5; assert_eq!(state.passing_review_intervals(&ctx), (5, 5, 5)); } #[test] fn low_hard_multiplier_does_not_pull_good_down() { let mut ctx = StateContext::defaults_for_testing(); // our calculations should work correctly with a low ease or non-default // multiplier ctx.hard_multiplier = 0.1; let state = ReviewState { scheduled_days: 2, elapsed_days: 2, ease_factor: 1.3, lapses: 0, leeched: false, memory_state: None, }; ctx.fuzz_factor = Some(0.0); assert_eq!(state.passing_review_intervals(&ctx), (1, 3, 4)); } } ================================================ FILE: rslib/src/scheduler/states/steps.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html const DAY: u32 = 60 * 60 * 24; #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct LearningSteps<'a> { /// The steps in minutes. steps: &'a [f32], } fn to_secs(v: f32) -> u32 { (v * 60.0) as u32 } impl LearningSteps<'_> { /// Takes `steps` as minutes. pub(crate) fn new(steps: &[f32]) -> LearningSteps<'_> { LearningSteps { steps } } /// Strip off 'learning today', and ensure index is in bounds. fn get_index(self, remaining: u32) -> usize { let total = self.steps.len(); total .saturating_sub((remaining % 1000) as usize) .min(total.saturating_sub(1)) } fn secs_at_index(&self, index: usize) -> Option { self.steps.get(index).copied().map(to_secs) } pub(crate) fn again_delay_secs_learn(&self) -> Option { self.secs_at_index(0) } pub(crate) fn hard_delay_secs(self, remaining: u32) -> Option { let idx = self.get_index(remaining); self.secs_at_index(idx) // if current is invalid, try first step .or_else(|| self.steps.first().copied().map(to_secs)) .map(|current| { if idx == 0 { self.hard_delay_secs_for_first_step(current) } else { current } }) } /// Special case the hard interval for the first step to avoid equality with /// the again interval. Also ensure it's smaller than the good interval, /// at least with reasonable settings. fn hard_delay_secs_for_first_step(self, again_secs: u32) -> u32 { if let Some(next) = self.secs_at_index(1) { // average of first (again) and second (good) steps maybe_round_in_days(again_secs.saturating_add(next) / 2) } else { // 50% more than the again secs, but at most one day more // otherwise, a learning step of 3 days and a graduating interval of 4 days e.g. // would lead to the hard interval being larger than the good interval let secs = (again_secs.saturating_mul(3) / 2).min(again_secs.saturating_add(DAY)); maybe_round_in_days(secs) } } pub(crate) fn good_delay_secs(self, remaining: u32) -> Option { let idx = self.get_index(remaining); self.secs_at_index(idx + 1) } pub(crate) fn current_delay_secs(self, remaining: u32) -> u32 { let idx = self.get_index(remaining); self.secs_at_index(idx).unwrap_or_default() } pub(crate) fn remaining_for_good(self, remaining: u32) -> u32 { let idx = self.get_index(remaining); self.steps.len().saturating_sub(idx + 1) as u32 } pub(crate) fn remaining_for_failed(self) -> u32 { self.steps.len() as u32 } pub(crate) fn is_empty(&self) -> bool { self.steps.is_empty() } } /// If the given interval in seconds surpasses 1 day, rounds it to a whole /// number of days. Ensures that the user gets the same results earlier and /// later in the day. Returns seconds. fn maybe_round_in_days(secs: u32) -> u32 { if secs > DAY { ((secs as f32 / DAY as f32).round() as u32).saturating_mul(DAY) } else { secs } } #[cfg(test)] mod test { use super::*; macro_rules! assert_delay_secs { ($steps:expr, $remaining:expr, $again_delay:expr, $hard_delay:expr, $good_delay:expr) => { let steps = LearningSteps::new(&$steps); assert_eq!(steps.again_delay_secs_learn(), $again_delay); assert_eq!(steps.hard_delay_secs($remaining), $hard_delay); assert_eq!(steps.good_delay_secs($remaining), $good_delay); }; } #[test] fn delay_secs() { // if no other step, hard delay is 50% above again secs assert_delay_secs!([10.0], 1, Some(600), Some(900), None); // but at most one day more than again secs assert_delay_secs!( [(3 * DAY / 60) as f32], 1, Some(3 * DAY), Some(4 * DAY), None ); assert_delay_secs!([1.0, 10.0], 2, Some(60), Some(330), Some(600)); assert_delay_secs!([1.0, 10.0], 1, Some(60), Some(600), None); assert_delay_secs!([1.0, 10.0, 100.0], 3, Some(60), Some(330), Some(600)); assert_delay_secs!([1.0, 10.0, 100.0], 2, Some(60), Some(600), Some(6000)); assert_delay_secs!([1.0, 10.0, 100.0], 1, Some(60), Some(6000), None); } #[test] fn rounding_days() { assert_eq!(maybe_round_in_days(DAY - 1), DAY - 1); assert_eq!(maybe_round_in_days((1.5 * DAY as f32) as u32), 2 * DAY); } } ================================================ FILE: rslib/src/scheduler/timespan.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_i18n::I18n; /// Short string like '4d' to place above answer buttons. pub fn answer_button_time(seconds: f32, tr: &I18n) -> String { let span = Timespan::from_secs(seconds).natural_span(); let amount = span.as_rounded_unit_for_answer_buttons(); match span.unit() { TimespanUnit::Seconds => tr.scheduling_answer_button_time_seconds(amount), TimespanUnit::Minutes => tr.scheduling_answer_button_time_minutes(amount), TimespanUnit::Hours => tr.scheduling_answer_button_time_hours(amount), TimespanUnit::Days => tr.scheduling_answer_button_time_days(amount), TimespanUnit::Months => tr.scheduling_answer_button_time_months(amount), TimespanUnit::Years => tr.scheduling_answer_button_time_years(amount), } .into() } /// Short string like '4d' to place above answer buttons. /// Times within the collapse time are represented like '<10m' pub fn answer_button_time_collapsible(seconds: u32, collapse_secs: u32, tr: &I18n) -> String { let string = answer_button_time(seconds as f32, tr); if seconds == 0 { tr.scheduling_end().into() } else if seconds < collapse_secs { format!("<{string}") } else { string } } /// Describe the given seconds using the largest appropriate unit. /// If precise is true, show to two decimal places, eg /// eg 70 seconds -> "1.17 minutes" /// If false, seconds and days are shown without decimals. pub fn time_span(seconds: f32, tr: &I18n, precise: bool) -> String { let span = Timespan::from_secs(seconds).natural_span(); let amount = if precise { span.as_unit() } else { span.as_rounded_unit() }; match span.unit() { TimespanUnit::Seconds => tr.scheduling_time_span_seconds(amount), TimespanUnit::Minutes => tr.scheduling_time_span_minutes(amount), TimespanUnit::Hours => tr.scheduling_time_span_hours(amount), TimespanUnit::Days => tr.scheduling_time_span_days(amount), TimespanUnit::Months => tr.scheduling_time_span_months(amount), TimespanUnit::Years => tr.scheduling_time_span_years(amount), } .into() } const SECOND: f32 = 1.0; const MINUTE: f32 = 60.0 * SECOND; const HOUR: f32 = 60.0 * MINUTE; const DAY: f32 = 24.0 * HOUR; const YEAR: f32 = 365.0 * DAY; const MONTH: f32 = YEAR / 12.0; #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) enum TimespanUnit { Seconds, Minutes, Hours, Days, Months, Years, } impl TimespanUnit { pub fn as_str(self) -> &'static str { match self { TimespanUnit::Seconds => "seconds", TimespanUnit::Minutes => "minutes", TimespanUnit::Hours => "hours", TimespanUnit::Days => "days", TimespanUnit::Months => "months", TimespanUnit::Years => "years", } } } #[derive(Clone, Copy)] pub(crate) struct Timespan { seconds: f32, unit: TimespanUnit, } impl Timespan { pub fn from_secs(seconds: f32) -> Self { Timespan { seconds, unit: TimespanUnit::Seconds, } } /// Return the value as the configured unit, eg seconds=70/unit=Minutes /// returns 1.17 pub fn as_unit(self) -> f32 { let s = self.seconds; match self.unit { TimespanUnit::Seconds => s, TimespanUnit::Minutes => s / MINUTE, TimespanUnit::Hours => s / HOUR, TimespanUnit::Days => s / DAY, TimespanUnit::Months => s / MONTH, TimespanUnit::Years => s / YEAR, } } pub fn to_unit(self, unit: TimespanUnit) -> Timespan { Timespan { seconds: self.seconds, unit, } } /// Round seconds and days to integers, otherwise /// truncates to one decimal place. pub fn as_rounded_unit(self) -> f32 { match self.unit { // seconds/minutes/days as integer TimespanUnit::Seconds | TimespanUnit::Days => self.as_unit().round(), // other values shown to 1 decimal place _ => (self.as_unit() * 10.0).round() / 10.0, } } /// Round seconds, minutes and days to integers, otherwise /// truncates to one decimal place. pub fn as_rounded_unit_for_answer_buttons(self) -> f32 { match self.unit { // seconds/minutes/days as integer TimespanUnit::Seconds | TimespanUnit::Minutes | TimespanUnit::Days => { self.as_unit().round() } // other values shown to 1 decimal place _ => (self.as_unit() * 10.0).round() / 10.0, } } pub fn unit(self) -> TimespanUnit { self.unit } /// Return a new timespan in the most appropriate unit, eg /// 70 secs -> timespan in minutes pub fn natural_span(self) -> Timespan { let secs = self.seconds.abs(); let unit = if secs < MINUTE { TimespanUnit::Seconds } else if secs < HOUR { TimespanUnit::Minutes } else if secs < DAY { TimespanUnit::Hours } else if secs < MONTH { TimespanUnit::Days } else if secs < YEAR { TimespanUnit::Months } else { TimespanUnit::Years }; Timespan { seconds: self.seconds, unit, } } } #[cfg(test)] mod test { use anki_i18n::I18n; use crate::scheduler::timespan::answer_button_time; use crate::scheduler::timespan::time_span; use crate::scheduler::timespan::MONTH; #[test] fn answer_buttons() { let tr = I18n::template_only(); assert_eq!(answer_button_time(30.0, &tr), "30s"); assert_eq!(answer_button_time(70.0, &tr), "1m"); assert_eq!(answer_button_time(1.1 * MONTH, &tr), "1.1mo"); } #[test] fn time_spans() { let tr = I18n::template_only(); assert_eq!(time_span(1.0, &tr, false), "1 second"); assert_eq!(time_span(30.3, &tr, false), "30 seconds"); assert_eq!(time_span(30.3, &tr, true), "30.3 seconds"); assert_eq!(time_span(90.0, &tr, false), "1.5 minutes"); assert_eq!(time_span(45.0 * 86_400.0, &tr, false), "1.5 months"); assert_eq!(time_span(364.0 * 86_400.0, &tr, false), "12 months"); assert_eq!(time_span(364.0 * 86_400.0, &tr, true), "11.97 months"); assert_eq!(time_span(365.0 * 86_400.0, &tr, false), "1 year"); assert_eq!(time_span(365.0 * 86_400.0, &tr, true), "1 year"); assert_eq!(time_span(365.0 * 86_400.0 * 1.5, &tr, false), "1.5 years"); } } ================================================ FILE: rslib/src/scheduler/timing.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use chrono::DateTime; use chrono::Datelike; use chrono::Duration; use chrono::FixedOffset; use chrono::Timelike; use crate::prelude::*; #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub struct SchedTimingToday { pub now: TimestampSecs, /// The number of days that have passed since the collection was created. pub days_elapsed: u32, /// Timestamp of the next day rollover. pub next_day_at: TimestampSecs, } /// Timing information for the current day. /// - creation_secs is a UNIX timestamp of the collection creation time /// - creation_utc_offset is the UTC offset at collection creation time /// - current_secs is a timestamp of the current time /// - current_utc_offset is the current UTC offset /// - rollover_hour is the hour of the day the rollover happens (eg 4 for 4am) pub fn sched_timing_today_v2_new( creation_secs: TimestampSecs, creation_utc_offset: FixedOffset, current_secs: TimestampSecs, current_utc_offset: FixedOffset, rollover_hour: u8, ) -> Result { // get date(times) based on timezone offsets let created_datetime = creation_secs.datetime(creation_utc_offset)?; let now_datetime = current_secs.datetime(current_utc_offset)?; // rollover let rollover_today_datetime = rollover_datetime(now_datetime, rollover_hour); let rollover_passed = rollover_today_datetime <= now_datetime; let next_day_at = TimestampSecs(if rollover_passed { (rollover_today_datetime + Duration::days(1)).timestamp() } else { rollover_today_datetime.timestamp() }); // day count let days_elapsed = days_elapsed(created_datetime, now_datetime, rollover_passed); Ok(SchedTimingToday { now: current_secs, days_elapsed, next_day_at, }) } fn rollover_datetime(date: DateTime, rollover_hour: u8) -> DateTime { date.with_hour((rollover_hour % 24) as u32) .unwrap() .with_minute(0) .unwrap() .with_second(0) .unwrap() .with_nanosecond(0) .unwrap() } /// The number of times the day rolled over between two dates. fn days_elapsed( start_date: DateTime, end_date: DateTime, rollover_passed: bool, ) -> u32 { let days = end_date.num_days_from_ce() - start_date.num_days_from_ce(); // current day doesn't count before rollover time let days = if rollover_passed { days } else { days - 1 }; // minimum of 0 days.max(0) as u32 } /// Build a FixedOffset struct, capping minutes_west if out of bounds. pub(crate) fn fixed_offset_from_minutes(minutes_west: i32) -> FixedOffset { let bounded_minutes = minutes_west.clamp(-23 * 60, 23 * 60); FixedOffset::west_opt(bounded_minutes * 60).unwrap() } /// For the given timestamp, return minutes west of UTC in the /// local timezone. /// eg, Australia at +10 hours is -600. /// Includes the daylight savings offset if applicable. pub fn local_minutes_west_for_stamp(stamp: TimestampSecs) -> Result { Ok(stamp.local_datetime()?.offset().utc_minus_local() / 60) } pub(crate) fn v1_creation_date() -> i64 { let now = TimestampSecs::now(); v1_creation_date_inner(now, local_minutes_west_for_stamp(now).unwrap()) } fn v1_creation_date_inner(now: TimestampSecs, mins_west: i32) -> i64 { let offset = fixed_offset_from_minutes(mins_west); let now_dt = now.datetime(offset).unwrap(); let four_am_dt = rollover_datetime(now_dt, 4); let four_am_stamp = four_am_dt.timestamp(); if four_am_dt > now_dt { four_am_stamp - 86_400 } else { four_am_stamp } } fn sched_timing_today_v1(crt: TimestampSecs, now: TimestampSecs) -> SchedTimingToday { let days_elapsed = (now.0 - crt.0) / 86_400; let next_day_at = TimestampSecs(crt.0 + (days_elapsed + 1) * 86_400); SchedTimingToday { now, days_elapsed: days_elapsed as u32, next_day_at, } } fn sched_timing_today_v2_legacy( crt: TimestampSecs, rollover: u8, now: TimestampSecs, current_utc_offset: FixedOffset, ) -> Result { let crt_at_rollover = rollover_datetime(crt.datetime(current_utc_offset)?, rollover).timestamp(); let days_elapsed = (now.0 - crt_at_rollover) / 86_400; let mut next_day_at = TimestampSecs(rollover_datetime(now.datetime(current_utc_offset)?, rollover).timestamp()); if next_day_at < now { next_day_at = next_day_at.adding_secs(86_400); } Ok(SchedTimingToday { now, days_elapsed: days_elapsed as u32, next_day_at, }) } // ---------------------------------- /// Decide which scheduler timing to use based on the provided input, /// and return the relevant timing info. pub(crate) fn sched_timing_today( creation_secs: TimestampSecs, current_secs: TimestampSecs, creation_utc_offset: Option, current_utc_offset: FixedOffset, rollover_hour: Option, ) -> Result { match (rollover_hour, creation_utc_offset) { (None, _) => { // if rollover unset, v1 scheduler Ok(sched_timing_today_v1(creation_secs, current_secs)) } (Some(rollover), None) => { // if creationOffset unset, v2 scheduler with legacy cutoff handling sched_timing_today_v2_legacy(creation_secs, rollover, current_secs, current_utc_offset) } (Some(rollover), Some(creation_utc_offset)) => { // v2 scheduler, new cutoff handling sched_timing_today_v2_new( creation_secs, creation_utc_offset, current_secs, current_utc_offset, rollover, ) } } } /// True if provided due number looks like a seconds-based timestamp. pub fn is_unix_epoch_timestamp(due: i32) -> bool { due > 1_000_000_000 } #[cfg(test)] mod test { use chrono::FixedOffset; use chrono::Local; use chrono::TimeZone; use super::*; // test helper impl SchedTimingToday { /// Check if less than 25 minutes until the rollover pub fn near_cutoff(&self) -> bool { let near = TimestampSecs::now().adding_secs(60 * 25) > self.next_day_at; if near { println!("this would fail near the rollover time"); } near } } // static timezone for tests const AEST_MINS_WEST: i32 = -600; fn aest_offset() -> FixedOffset { FixedOffset::west_opt(AEST_MINS_WEST * 60).unwrap() } #[test] fn fixed_offset() { let offset = fixed_offset_from_minutes(AEST_MINS_WEST); assert_eq!(offset.utc_minus_local(), AEST_MINS_WEST * 60); } // helper fn elap(start: i64, end: i64, start_west: i32, end_west: i32, rollhour: u8) -> u32 { let start = TimestampSecs(start); let end = TimestampSecs(end); let start_west = FixedOffset::west_opt(start_west * 60).unwrap(); let end_west = FixedOffset::west_opt(end_west * 60).unwrap(); let today = sched_timing_today_v2_new(start, start_west, end, end_west, rollhour).unwrap(); today.days_elapsed } #[test] fn days_elapsed() { let local_offset = local_minutes_west_for_stamp(TimestampSecs::now()).unwrap(); let created_dt = FixedOffset::west_opt(local_offset * 60) .unwrap() .with_ymd_and_hms(2019, 12, 1, 2, 0, 0) .latest() .unwrap(); let crt = created_dt.timestamp(); // days can't be negative assert_eq!(elap(crt, crt, local_offset, local_offset, 4), 0); assert_eq!(elap(crt, crt - 86_400, local_offset, local_offset, 4), 0); // 2am the next day is still the same day assert_eq!(elap(crt, crt + 24 * 3600, local_offset, local_offset, 4), 0); // day rolls over at 4am assert_eq!(elap(crt, crt + 26 * 3600, local_offset, local_offset, 4), 1); // the longest extra delay is +23, or 19 hours past the 4 hour default assert_eq!( elap(crt, crt + (26 + 18) * 3600, local_offset, local_offset, 23), 0 ); assert_eq!( elap(crt, crt + (26 + 19) * 3600, local_offset, local_offset, 23), 1 ); let mdt = FixedOffset::west_opt(6 * 60 * 60).unwrap(); let mdt_offset = mdt.utc_minus_local() / 60; let mst = FixedOffset::west_opt(7 * 60 * 60).unwrap(); let mst_offset = mst.utc_minus_local() / 60; // a collection created @ midnight in MDT in the past let crt = mdt .with_ymd_and_hms(2018, 8, 6, 0, 0, 0) .latest() .unwrap() .timestamp(); // with the current time being MST let now = mst .with_ymd_and_hms(2019, 12, 26, 20, 0, 0) .latest() .unwrap() .timestamp(); assert_eq!(elap(crt, now, mdt_offset, mst_offset, 4), 507); // the previous implementation generated a different elapsed number of days with // a change to DST, but the number shouldn't change assert_eq!(elap(crt, now, mdt_offset, mdt_offset, 4), 507); // collection created at 3am on the 6th, so day 1 starts at 4am on the 7th, and // day 3 on the 9th. let crt = mdt .with_ymd_and_hms(2018, 8, 6, 3, 0, 0) .latest() .unwrap() .timestamp(); let now = mst .with_ymd_and_hms(2018, 8, 9, 1, 59, 59) .latest() .unwrap() .timestamp(); assert_eq!(elap(crt, now, mdt_offset, mst_offset, 4), 2); let now = mst .with_ymd_and_hms(2018, 8, 9, 3, 59, 59) .latest() .unwrap() .timestamp(); assert_eq!(elap(crt, now, mdt_offset, mst_offset, 4), 2); let now = mst .with_ymd_and_hms(2018, 8, 9, 4, 0, 0) .latest() .unwrap() .timestamp(); assert_eq!(elap(crt, now, mdt_offset, mst_offset, 4), 3); // try a bunch of combinations of creation time, current time, and rollover hour let hours_of_interest = &[0, 1, 4, 12, 22, 23]; for creation_hour in hours_of_interest { let crt_dt = mdt .with_ymd_and_hms(2018, 8, 6, *creation_hour, 0, 0) .latest() .unwrap(); let crt_stamp = crt_dt.timestamp(); let crt_offset = mdt_offset; for current_day in 0..=3 { for current_hour in hours_of_interest { for rollover_hour in hours_of_interest { let end_dt = mdt .with_ymd_and_hms(2018, 8, 6 + current_day, *current_hour, 0, 0) .latest() .unwrap(); let end_stamp = end_dt.timestamp(); let end_offset = mdt_offset; let elap_day = if *current_hour < *rollover_hour { current_day.max(1) - 1 } else { current_day }; assert_eq!( elap( crt_stamp, end_stamp, crt_offset, end_offset, *rollover_hour as u8 ), elap_day ); } } } } } #[test] fn next_day_at() { let rollhour = 4; let crt = Local .with_ymd_and_hms(2019, 1, 1, 2, 0, 0) .latest() .unwrap(); // before the rollover, the next day should be later on the same day let now = Local .with_ymd_and_hms(2019, 1, 3, 2, 0, 0) .latest() .unwrap(); let next_day_at = Local .with_ymd_and_hms(2019, 1, 3, rollhour, 0, 0) .latest() .unwrap(); let today = sched_timing_today_v2_new( TimestampSecs(crt.timestamp()), *crt.offset(), TimestampSecs(now.timestamp()), *now.offset(), rollhour as u8, ) .unwrap(); assert_eq!(today.next_day_at.0, next_day_at.timestamp()); // after the rollover, the next day should be the next day let now = Local .with_ymd_and_hms(2019, 1, 3, rollhour, 0, 0) .latest() .unwrap(); let next_day_at = Local .with_ymd_and_hms(2019, 1, 4, rollhour, 0, 0) .latest() .unwrap(); let today = sched_timing_today_v2_new( TimestampSecs(crt.timestamp()), *crt.offset(), TimestampSecs(now.timestamp()), *now.offset(), rollhour as u8, ) .unwrap(); assert_eq!(today.next_day_at.0, next_day_at.timestamp()); // after the rollover, the next day should be the next day let now = Local .with_ymd_and_hms(2019, 1, 3, rollhour + 3, 0, 0) .latest() .unwrap(); let next_day_at = Local .with_ymd_and_hms(2019, 1, 4, rollhour, 0, 0) .latest() .unwrap(); let today = sched_timing_today_v2_new( TimestampSecs(crt.timestamp()), *crt.offset(), TimestampSecs(now.timestamp()), *now.offset(), rollhour as u8, ) .unwrap(); assert_eq!(today.next_day_at.0, next_day_at.timestamp()); } #[test] fn legacy_timing() { let now = TimestampSecs(1584491078); assert_eq!( sched_timing_today_v1(TimestampSecs(1575226800), now), SchedTimingToday { now, days_elapsed: 107, next_day_at: TimestampSecs(1584558000) } ); assert_eq!( sched_timing_today_v2_legacy(TimestampSecs(1533564000), 0, now, aest_offset()), Ok(SchedTimingToday { now, days_elapsed: 589, next_day_at: TimestampSecs(1584540000) }) ); assert_eq!( sched_timing_today_v2_legacy(TimestampSecs(1524038400), 4, now, aest_offset()), Ok(SchedTimingToday { now, days_elapsed: 700, next_day_at: TimestampSecs(1584554400) }) ); } #[test] fn legacy_creation_stamp() { let offset = fixed_offset_from_minutes(AEST_MINS_WEST); let now = TimestampSecs( offset .with_ymd_and_hms(2020, 5, 10, 9, 30, 30) .latest() .unwrap() .timestamp(), ); assert_eq!( v1_creation_date_inner(now, AEST_MINS_WEST), offset .with_ymd_and_hms(2020, 5, 10, 4, 0, 0) .latest() .unwrap() .timestamp() ); let now = TimestampSecs( offset .with_ymd_and_hms(2020, 5, 10, 1, 30, 30) .latest() .unwrap() .timestamp(), ); assert_eq!( v1_creation_date_inner(now, AEST_MINS_WEST), offset .with_ymd_and_hms(2020, 5, 9, 4, 0, 0) .latest() .unwrap() .timestamp() ); } } ================================================ FILE: rslib/src/scheduler/upgrade.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use super::timing::local_minutes_west_for_stamp; use crate::card::CardQueue; use crate::card::CardType; use crate::config::SchedulerVersion; use crate::prelude::*; use crate::search::SortMode; struct V1FilteredDeckInfo { /// True if the filtered deck had rescheduling enabled. reschedule: bool, /// If the filtered deck had custom steps enabled, `original_step_count` /// contains the step count of the home deck, which will be used to ensure /// the remaining steps of the card are not out of bounds. original_step_count: Option, } impl Card { /// Update relearning cards and cards in filtered decks. /// `filtered_info` should be provided if card is in a filtered deck. fn upgrade_to_v2(&mut self, filtered_info: Option) { // relearning cards have their own type if self.ctype == CardType::Review && matches!(self.queue, CardQueue::Learn | CardQueue::DayLearn) { self.ctype = CardType::Relearn; } // filtered deck handling if let Some(info) = filtered_info { // cap remaining count to home deck if let Some(step_count) = info.original_step_count { self.remaining_steps = self.remaining_steps.min(step_count); } if info.reschedule { // only new cards should be in the new queue if self.queue == CardQueue::New && self.ctype != CardType::New { self.restore_queue_from_type(); } } else { // preview cards start in the review queue in v2 if self.queue == CardQueue::New { self.queue = CardQueue::Review; } // to ensure learning cards are reset to new on exit, we must // make them new now if self.ctype == CardType::Learn { self.queue = CardQueue::PreviewRepeat; self.ctype = CardType::New; } } } } } fn get_filter_info_for_card( card: &Card, decks: &HashMap, configs: &HashMap, ) -> Option { if card.original_deck_id.0 == 0 { None } else { let (had_custom_steps, reschedule) = if let Some(deck) = decks.get(&card.deck_id) { if let DeckKind::Filtered(filtered) = &deck.kind { (!filtered.delays.is_empty(), filtered.reschedule) } else { // not a filtered deck, give up return None; } } else { // missing filtered deck, give up return None; }; let original_step_count = if had_custom_steps { let home_conf_id = decks .get(&card.original_deck_id) .and_then(|deck| deck.config_id()) .unwrap_or(DeckConfigId(1)); Some( configs .get(&home_conf_id) .map(|config| { if card.ctype == CardType::Review { config.inner.relearn_steps.len() } else { config.inner.learn_steps.len() } }) .unwrap_or(0) as u32, ) } else { None }; Some(V1FilteredDeckInfo { reschedule, original_step_count, }) } } impl Collection { /// Expects an existing transaction. No-op if already on v2. pub(crate) fn upgrade_to_v2_scheduler(&mut self) -> Result<()> { if self.scheduler_version() == SchedulerVersion::V2 { // nothing to do return Ok(()); } self.storage.upgrade_revlog_to_v2()?; self.upgrade_cards_to_v2()?; self.set_scheduler_version_config_key(SchedulerVersion::V2)?; // enable new timezone code by default let created = self.storage.creation_stamp()?; if self.get_creation_utc_offset().is_none() { self.set_creation_utc_offset(Some(local_minutes_west_for_stamp(created)?))?; } // force full sync self.set_schema_modified() } fn upgrade_cards_to_v2(&mut self) -> Result<()> { let guard = self.search_cards_into_table( // can't add 'is:learn' here, as it matches on card type, not card queue "deck:filtered OR is:review", SortMode::NoOrder, )?; if guard.cards > 0 { let decks = guard.col.storage.get_decks_map()?; let configs = guard.col.storage.get_deck_config_map()?; guard.col.storage.for_each_card_in_search(|mut card| { let filtered_info = get_filter_info_for_card(&card, &decks, &configs); card.upgrade_to_v2(filtered_info); guard.col.storage.update_card(&card) })?; } Ok(()) } } #[cfg(test)] mod test { use super::*; #[test] fn v2_card() { let mut c = Card { ctype: CardType::Review, queue: CardQueue::DayLearn, ..Default::default() }; // relearning cards should be reclassified c.upgrade_to_v2(None); assert_eq!(c.ctype, CardType::Relearn); // check step capping c.remaining_steps = 5005; c.upgrade_to_v2(Some(V1FilteredDeckInfo { reschedule: true, original_step_count: Some(2), })); assert_eq!(c.remaining_steps, 2); // with rescheduling off, relearning cards don't need changing c.upgrade_to_v2(Some(V1FilteredDeckInfo { reschedule: false, original_step_count: None, })); assert_eq!(c.ctype, CardType::Relearn); assert_eq!(c.queue, CardQueue::DayLearn); // but learning cards are reset to new c.ctype = CardType::Learn; c.upgrade_to_v2(Some(V1FilteredDeckInfo { reschedule: false, original_step_count: None, })); assert_eq!(c.ctype, CardType::New); assert_eq!(c.queue, CardQueue::PreviewRepeat); // (early) reviews should be moved back from the new queue c.ctype = CardType::Review; c.queue = CardQueue::New; c.upgrade_to_v2(Some(V1FilteredDeckInfo { reschedule: true, original_step_count: None, })); assert_eq!(c.ctype, CardType::Review); assert_eq!(c.queue, CardQueue::Review); } } ================================================ FILE: rslib/src/search/builder.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::mem; use itertools::Itertools; use super::writer::write_nodes; use super::FieldSearchMode; use super::Node; use super::SearchNode; use super::StateKind; use super::TemplateKind; use crate::prelude::*; use crate::storage::comma_separated_ids; use crate::text::escape_anki_wildcards_for_search_node; pub trait Negated { fn negated(self) -> Node; } pub trait JoinSearches { /// Concatenates two sets of [Node]s, inserting [Node::And], and grouping, /// if appropriate. fn and(self, other: impl Into) -> SearchBuilder; /// Concatenates two sets of [Node]s, inserting [Node::Or], and grouping, if /// appropriate. fn or(self, other: impl Into) -> SearchBuilder; /// Concatenates two sets of [Node]s, inserting [Node::And] if appropriate, /// but without grouping either set. fn and_flat(self, other: impl Into) -> SearchBuilder; /// Concatenates two sets of [Node]s, inserting [Node::Or] if appropriate, /// but without grouping either set. fn or_flat(self, other: impl Into) -> SearchBuilder; } impl> Negated for T { fn negated(self) -> Node { let node: Node = self.into(); if let Node::Not(inner) = node { *inner } else { Node::Not(Box::new(node)) } } } impl> JoinSearches for T { fn and(self, other: impl Into) -> SearchBuilder { self.into().join_other(other.into(), Node::And, true) } fn or(self, other: impl Into) -> SearchBuilder { self.into().join_other(other.into(), Node::Or, true) } fn and_flat(self, other: impl Into) -> SearchBuilder { self.into().join_other(other.into(), Node::And, false) } fn or_flat(self, other: impl Into) -> SearchBuilder { self.into().join_other(other.into(), Node::Or, false) } } /// Helper to programmatically build searches. #[derive(Debug, PartialEq, Clone)] pub struct SearchBuilder(Vec); impl SearchBuilder { pub fn new() -> Self { Self(vec![]) } /// Construct [SearchBuilder] with this [Node], or its inner [Node]s, /// if it is a [Node::Group] pub fn from_root(node: Node) -> Self { match node { Node::Group(nodes) => Self(nodes), _ => Self(vec![node]), } } /// Construct [SearchBuilder] where given [Node]s are joined by [Node::And]. pub fn all(iter: impl IntoIterator>) -> Self { Self(Itertools::intersperse(iter.into_iter().map(Into::into), Node::And).collect()) } /// Construct [SearchBuilder] where given [Node]s are joined by [Node::Or]. pub fn any(iter: impl IntoIterator>) -> Self { Self(Itertools::intersperse(iter.into_iter().map(Into::into), Node::Or).collect()) } pub fn is_empty(&self) -> bool { self.0.is_empty() } pub fn len(&self) -> usize { self.0.len() } fn join_other(mut self, mut other: Self, joiner: Node, group: bool) -> Self { if group { self = self.group(); other = other.group(); } if !(self.is_empty() || other.is_empty()) { self.0.push(joiner); } self.0.append(&mut other.0); self } /// Wrap [Node]s in [Node::Group] if there is more than 1. pub fn group(mut self) -> Self { if self.len() > 1 { self.0 = vec![Node::Group(mem::take(&mut self.0))]; } self } pub fn write(&self) -> String { write_nodes(&self.0) } /// Construct [SearchBuilder] matching any given deck, excluding children. pub fn from_decks(decks: &[DeckId]) -> Self { SearchNode::DeckIdsWithoutChildren(comma_separated_ids(decks)).into() } /// Construct [SearchBuilder] matching learning, but not relearning cards. pub fn learning_cards() -> Self { StateKind::Learning.and(StateKind::Review.negated()) } /// Construct [SearchBuilder] matching relearning cards. pub fn relearning_cards() -> Self { StateKind::Learning.and(StateKind::Review) } } impl> From for SearchBuilder { fn from(node: T) -> Self { Self(vec![node.into()]) } } impl TryIntoSearch for SearchBuilder { fn try_into_search(self) -> Result { Ok(self.group().0.remove(0)) } } impl Default for SearchBuilder { fn default() -> Self { Self::new() } } impl SearchNode { pub fn from_deck_id(did: impl Into, with_children: bool) -> Self { if with_children { Self::DeckIdWithChildren(did.into()) } else { Self::DeckIdsWithoutChildren(did.into().to_string()) } } /// Construct [SearchNode] from an unescaped deck name. pub fn from_deck_name(name: &str) -> Self { Self::Deck(escape_anki_wildcards_for_search_node(name)) } /// Construct [SearchNode] from an unescaped tag name. pub fn from_tag_name(name: &str) -> Self { Self::Tag { tag: escape_anki_wildcards_for_search_node(name), mode: FieldSearchMode::Normal, } } /// Construct [SearchNode] from an unescaped notetype name. pub fn from_notetype_name(name: &str) -> Self { Self::Notetype(escape_anki_wildcards_for_search_node(name)) } /// Construct [SearchNode] from an unescaped template name. pub fn from_template_name(name: &str) -> Self { Self::CardTemplate(TemplateKind::Name(escape_anki_wildcards_for_search_node( name, ))) } pub fn from_note_ids, N: Into>(ids: I) -> Self { Self::NoteIds(ids.into_iter().map(Into::into).join(",")) } pub fn from_card_ids, C: Into>(ids: I) -> Self { Self::CardIds(ids.into_iter().map(Into::into).join(",")) } } impl> From for Node { fn from(node: T) -> Self { Self::Search(node.into()) } } impl From for SearchNode { fn from(id: NotetypeId) -> Self { SearchNode::NotetypeId(id) } } impl From for SearchNode { fn from(k: TemplateKind) -> Self { SearchNode::CardTemplate(k) } } impl From for SearchNode { fn from(n: NoteId) -> Self { SearchNode::NoteIds(format!("{n}")) } } impl From for SearchNode { fn from(k: StateKind) -> Self { SearchNode::State(k) } } #[cfg(test)] mod test { use super::*; #[test] fn negating() { let node = Node::Search(SearchNode::UnqualifiedText("foo".to_string())); let neg_node = Node::Not(Box::new(Node::Search(SearchNode::UnqualifiedText( "foo".to_string(), )))); assert_eq!(node.clone().negated(), neg_node); assert_eq!(node.clone().negated().negated(), node); assert_eq!( StateKind::Due.negated(), Node::Not(Box::new(Node::Search(SearchNode::State(StateKind::Due)))) ) } #[test] fn joining() { assert_eq!( StateKind::Due .or(StateKind::New) .and(SearchBuilder::any((1..4).map(SearchNode::Flag))) .write(), "(is:due OR is:new) (flag:1 OR flag:2 OR flag:3)" ); assert_eq!( StateKind::Due .or(StateKind::New) .and_flat(SearchBuilder::any((1..4).map(SearchNode::Flag))) .write(), "is:due OR is:new flag:1 OR flag:2 OR flag:3" ); assert_eq!( StateKind::Due .or(StateKind::New) .or(StateKind::Learning) .or(StateKind::Review) .write(), "((is:due OR is:new) OR is:learn) OR is:review" ); assert_eq!( StateKind::Due .or_flat(StateKind::New) .or_flat(StateKind::Learning) .or_flat(StateKind::Review) .write(), "is:due OR is:new OR is:learn OR is:review" ); } } ================================================ FILE: rslib/src/search/card_mod_order.sql ================================================ DROP TABLE IF EXISTS sort_order; CREATE TEMPORARY TABLE sort_order ( pos integer PRIMARY KEY, nid integer NOT NULL UNIQUE ); INSERT INTO sort_order (nid) SELECT nid FROM cards GROUP BY nid ORDER BY MAX(mod); ================================================ FILE: rslib/src/search/deck_order.sql ================================================ DROP TABLE IF EXISTS sort_order; CREATE TEMPORARY TABLE sort_order ( pos integer PRIMARY KEY, did integer NOT NULL UNIQUE ); INSERT INTO sort_order (did) SELECT id FROM decks ORDER BY name; ================================================ FILE: rslib/src/search/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod builder; mod parser; mod service; mod sqlwriter; pub(crate) mod writer; use std::borrow::Cow; pub use builder::JoinSearches; pub use builder::Negated; pub use builder::SearchBuilder; pub use parser::parse as parse_search; pub use parser::FieldSearchMode; pub use parser::Node; pub use parser::PropertyKind; pub use parser::RatingKind; pub use parser::SearchNode; pub use parser::StateKind; pub use parser::TemplateKind; use rusqlite::params_from_iter; use rusqlite::types::FromSql; use sqlwriter::RequiredTable; use sqlwriter::SqlWriter; pub use writer::replace_search_node; use crate::browser_table::Column; use crate::card::CardType; use crate::prelude::*; use crate::scheduler::timing::SchedTimingToday; #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum ReturnItemType { Cards, Notes, } #[derive(Debug, PartialEq, Eq, Clone)] pub enum SortMode { NoOrder, Builtin { column: Column, reverse: bool }, Custom(String), } pub trait AsReturnItemType { fn as_return_item_type() -> ReturnItemType; } impl AsReturnItemType for CardId { fn as_return_item_type() -> ReturnItemType { ReturnItemType::Cards } } impl AsReturnItemType for NoteId { fn as_return_item_type() -> ReturnItemType { ReturnItemType::Notes } } impl ReturnItemType { fn required_table(&self) -> RequiredTable { match self { ReturnItemType::Cards => RequiredTable::Cards, ReturnItemType::Notes => RequiredTable::Notes, } } } impl SortMode { fn required_table(&self) -> RequiredTable { match self { SortMode::NoOrder => RequiredTable::CardsOrNotes, SortMode::Builtin { column, .. } => column.required_table(), SortMode::Custom(ref text) => { if text.contains("n.") { if text.contains("c.") { RequiredTable::CardsAndNotes } else { RequiredTable::Notes } } else { RequiredTable::Cards } } } } } impl Column { fn required_table(self) -> RequiredTable { match self { Column::Cards | Column::NoteCreation | Column::NoteMod | Column::Notetype | Column::SortField | Column::Tags => RequiredTable::Notes, _ => RequiredTable::CardsOrNotes, } } } pub trait TryIntoSearch { fn try_into_search(self) -> Result; } impl TryIntoSearch for &str { fn try_into_search(self) -> Result { parser::parse(self).map(Node::Group) } } impl TryIntoSearch for &String { fn try_into_search(self) -> Result { parser::parse(self).map(Node::Group) } } impl TryIntoSearch for T where T: Into, { fn try_into_search(self) -> Result { Ok(self.into()) } } pub struct CardTableGuard<'a> { pub col: &'a mut Collection, pub cards: usize, } impl Drop for CardTableGuard<'_> { fn drop(&mut self) { if let Err(err) = self.col.storage.clear_searched_cards_table() { println!("{err:?}"); } } } pub struct NoteTableGuard<'a> { pub col: &'a mut Collection, pub notes: usize, } impl Drop for NoteTableGuard<'_> { fn drop(&mut self) { if let Err(err) = self.col.storage.clear_searched_notes_table() { println!("{err:?}"); } } } impl Collection { pub fn search_cards(&mut self, search: N, mode: SortMode) -> Result> where N: TryIntoSearch, { self.search(search, mode) } pub fn search_notes(&mut self, search: N, mode: SortMode) -> Result> where N: TryIntoSearch, { self.search(search, mode) } pub fn search_notes_unordered(&mut self, search: N) -> Result> where N: TryIntoSearch, { self.search(search, SortMode::NoOrder) } } impl Collection { fn search(&mut self, search: N, mode: SortMode) -> Result> where N: TryIntoSearch, T: FromSql + AsReturnItemType, { let item_type = T::as_return_item_type(); let top_node = search.try_into_search()?; let writer = SqlWriter::new(self, item_type); let (mut sql, args) = writer.build_query(&top_node, mode.required_table())?; self.add_order(&mut sql, item_type, mode)?; let mut stmt = self.storage.db.prepare(&sql)?; let ids: Vec<_> = stmt .query_map(params_from_iter(args.iter()), |row| row.get(0))? .collect::>()?; Ok(ids) } fn add_order( &mut self, sql: &mut String, item_type: ReturnItemType, mode: SortMode, ) -> Result<()> { match mode { SortMode::NoOrder => (), SortMode::Builtin { column, reverse } => { prepare_sort(self, column, item_type)?; sql.push_str(" order by "); write_order(sql, item_type, column, reverse, self.timing_today()?)?; } SortMode::Custom(order_clause) => { sql.push_str(" order by "); sql.push_str(&order_clause); } } Ok(()) } /// Place the matched card ids into a temporary 'search_cids' table /// instead of returning them. Returns a guard with a collection reference /// and the number of added cards. When the guard is dropped, the temporary /// table is cleaned up. pub(crate) fn search_cards_into_table( &mut self, search: impl TryIntoSearch, mode: SortMode, ) -> Result> { let top_node = search.try_into_search()?; let writer = SqlWriter::new(self, ReturnItemType::Cards); let want_order = mode != SortMode::NoOrder; let (mut sql, args) = writer.build_query(&top_node, mode.required_table())?; self.add_order(&mut sql, ReturnItemType::Cards, mode)?; if want_order { self.storage .setup_searched_cards_table_to_preserve_order()?; } else { self.storage.setup_searched_cards_table()?; } let sql = format!("insert into search_cids {sql}"); let cards = self .storage .db .prepare(&sql)? .execute(params_from_iter(args))?; Ok(CardTableGuard { cards, col: self }) } pub(crate) fn all_cards_for_search(&mut self, search: impl TryIntoSearch) -> Result> { let guard = self.search_cards_into_table(search, SortMode::NoOrder)?; guard.col.storage.all_searched_cards() } pub(crate) fn all_cards_for_search_in_order( &mut self, search: impl TryIntoSearch, mode: SortMode, ) -> Result> { let guard = self.search_cards_into_table(search, mode)?; guard.col.storage.all_searched_cards_in_search_order() } pub(crate) fn all_cards_for_ids( &self, cards: &[CardId], preserve_order: bool, ) -> Result> { self.storage.with_searched_cards_table(preserve_order, || { self.storage.set_search_table_to_card_ids(cards)?; if preserve_order { self.storage.all_searched_cards_in_search_order() } else { self.storage.all_searched_cards() } }) } pub(crate) fn for_each_card_in_search( &mut self, search: impl TryIntoSearch, mut func: impl FnMut(&Collection, Card) -> Result<()>, ) -> Result<()> { let guard = self.search_cards_into_table(search, SortMode::NoOrder)?; guard .col .storage .for_each_card_in_search(|card| func(guard.col, card)) } /// Place the matched card ids into a temporary 'search_nids' table /// instead of returning them. Returns a guard with a collection reference /// and the number of added notes. When the guard is dropped, the temporary /// table is cleaned up. pub(crate) fn search_notes_into_table( &mut self, search: impl TryIntoSearch, ) -> Result> { let top_node = search.try_into_search()?; let writer = SqlWriter::new(self, ReturnItemType::Notes); let mode = SortMode::NoOrder; let (sql, args) = writer.build_query(&top_node, mode.required_table())?; self.storage.setup_searched_notes_table()?; let sql = format!("insert into search_nids {sql}"); let notes = self .storage .db .prepare(&sql)? .execute(params_from_iter(args))?; Ok(NoteTableGuard { notes, col: self }) } /// Place the ids of cards with notes in 'search_nids' into 'search_cids'. /// Returns number of added cards. pub(crate) fn search_cards_of_notes_into_table(&mut self) -> Result> { self.storage.setup_searched_cards_table()?; let cards = self.storage.search_cards_of_notes_into_table()?; Ok(CardTableGuard { cards, col: self }) } } /// Add the order clause to the sql. fn write_order( sql: &mut String, item_type: ReturnItemType, column: Column, reverse: bool, timing: SchedTimingToday, ) -> Result<()> { let order = match item_type { ReturnItemType::Cards => card_order_from_sort_column(column, timing), ReturnItemType::Notes => note_order_from_sort_column(column), }; require!(!order.is_empty(), "Can't sort {item_type:?} by {column:?}."); if reverse { sql.push_str( &order .to_ascii_lowercase() .replace(" desc", "") .replace(" asc", " desc"), ) } else { sql.push_str(&order); } Ok(()) } fn card_order_from_sort_column(column: Column, timing: SchedTimingToday) -> Cow<'static, str> { match column { Column::CardMod => "c.mod asc".into(), Column::Cards => concat!( "coalesce((select pos from sort_order where ntid = n.mid and ord = c.ord),", // need to fall back on ord 0 for cloze cards "(select pos from sort_order where ntid = n.mid and ord = 0)) asc, ord asc" ) .into(), Column::Deck => "(select pos from sort_order where did = c.did) asc".into(), Column::Due => format!("(case when c.due > 1000000000 or c.type = {} then due else (due - {}) * 86400 + {} end) asc", CardType::New as i8, timing.days_elapsed, TimestampSecs::now().0).into(), Column::Ease => format!("c.type = {} asc, c.factor asc", CardType::New as i8).into(), Column::Interval => "c.ivl asc".into(), Column::Lapses => "c.lapses asc".into(), Column::NoteCreation => "n.id asc, c.ord asc".into(), Column::NoteMod => "n.mod asc, c.ord asc".into(), Column::Notetype => "(select pos from sort_order where ntid = n.mid) asc".into(), Column::OriginalPosition => "(select pos from sort_order where nid = c.nid) asc".into(), Column::Reps => "c.reps asc".into(), Column::SortField => "n.sfld collate nocase asc, c.ord asc".into(), Column::Tags => "n.tags asc".into(), Column::Answer | Column::Custom | Column::Question => "".into(), Column::Stability => "extract_fsrs_variable(c.data, 's') asc".into(), Column::Difficulty => "extract_fsrs_variable(c.data, 'd') asc".into(), Column::Retrievability => format!( "extract_fsrs_retrievability(c.data, case when c.odue !=0 then c.odue else c.due end, c.ivl, {}, {}, {}) asc", timing.days_elapsed, timing.next_day_at.0, timing.now.0, ) .into(), } } fn note_order_from_sort_column(column: Column) -> Cow<'static, str> { match column { Column::CardMod | Column::Cards | Column::Deck | Column::Due | Column::Ease | Column::Interval | Column::Lapses | Column::OriginalPosition | Column::Reps => "(select pos from sort_order where nid = n.id) asc".into(), Column::NoteCreation => "n.id asc".into(), Column::NoteMod => "n.mod asc".into(), Column::Notetype => "(select pos from sort_order where ntid = n.mid) asc".into(), Column::SortField => "n.sfld collate nocase asc".into(), Column::Tags => "n.tags asc".into(), Column::Answer | Column::Custom | Column::Question | Column::Stability | Column::Difficulty | Column::Retrievability => "".into(), } } fn prepare_sort(col: &mut Collection, column: Column, item_type: ReturnItemType) -> Result<()> { let temp_string; let sql = match item_type { ReturnItemType::Cards => match column { Column::Cards => include_str!("template_order.sql"), Column::Deck => include_str!("deck_order.sql"), Column::Notetype => include_str!("notetype_order.sql"), Column::OriginalPosition => include_str!("note_original_position_order.sql"), _ => return Ok(()), }, ReturnItemType::Notes => match column { Column::Cards => include_str!("note_cards_order.sql"), Column::CardMod => include_str!("card_mod_order.sql"), Column::Deck => include_str!("note_decks_order.sql"), Column::Due => { temp_string = format!("{} ORDER BY MIN({});", include_str!("note_due_order.sql"), format_args!("CASE WHEN due > 1000000000 OR type = {ctype} THEN due ELSE (due - {today}) * 86400 + {current_timestamp} END", ctype = CardType::New as i8, today = col.timing_today()?.days_elapsed, current_timestamp = TimestampSecs::now().0)); &temp_string } Column::Ease => include_str!("note_ease_order.sql"), Column::Interval => include_str!("note_interval_order.sql"), Column::Lapses => include_str!("note_lapses_order.sql"), Column::OriginalPosition => include_str!("note_original_position_order.sql"), Column::Reps => include_str!("note_reps_order.sql"), Column::Notetype => include_str!("notetype_order.sql"), _ => return Ok(()), }, }; col.storage.db.execute_batch(sql)?; Ok(()) } #[cfg(test)] mod test { use anki_proto::search::browser_columns::Sorting; use strum::IntoEnumIterator; use super::*; impl SchedTimingToday { pub(crate) fn zero() -> Self { SchedTimingToday { now: TimestampSecs(0), days_elapsed: 0, next_day_at: TimestampSecs(0), } } } #[test] fn column_default_sort_order_should_match_order_by_clause() { let timing = SchedTimingToday::zero(); for column in Column::iter() { assert_eq!( card_order_from_sort_column(column, timing).is_empty(), matches!(column.default_cards_order(), Sorting::None) ); assert_eq!( note_order_from_sort_column(column).is_empty(), matches!(column.default_notes_order(), Sorting::None) ); } } } ================================================ FILE: rslib/src/search/note_cards_order.sql ================================================ DROP TABLE IF EXISTS sort_order; CREATE TEMPORARY TABLE sort_order ( pos integer PRIMARY KEY, nid integer NOT NULL UNIQUE ); INSERT INTO sort_order (nid) SELECT nid FROM cards GROUP BY nid ORDER BY COUNT(*); ================================================ FILE: rslib/src/search/note_decks_order.sql ================================================ DROP TABLE IF EXISTS sort_order; CREATE TEMPORARY TABLE sort_order ( pos integer PRIMARY KEY, nid integer NOT NULL UNIQUE ); INSERT INTO sort_order (nid) SELECT nid FROM cards JOIN ( SELECT id, row_number() OVER( ORDER BY name ) AS pos FROM decks ) decks ON cards.did = decks.id GROUP BY nid ORDER BY COUNT(DISTINCT did), decks.pos; ================================================ FILE: rslib/src/search/note_due_order.sql ================================================ DROP TABLE IF EXISTS sort_order; CREATE TEMPORARY TABLE sort_order ( pos integer PRIMARY KEY, nid integer NOT NULL UNIQUE ); INSERT INTO sort_order (nid) SELECT nid FROM cards WHERE ( odid = 0 AND type != 0 AND queue > 0 ) GROUP BY nid ================================================ FILE: rslib/src/search/note_ease_order.sql ================================================ DROP TABLE IF EXISTS sort_order; CREATE TEMPORARY TABLE sort_order ( pos integer PRIMARY KEY, nid integer NOT NULL UNIQUE ); INSERT INTO sort_order (nid) SELECT nid FROM cards WHERE type != 0 GROUP BY nid ORDER BY AVG(factor); ================================================ FILE: rslib/src/search/note_interval_order.sql ================================================ DROP TABLE IF EXISTS sort_order; CREATE TEMPORARY TABLE sort_order ( pos integer PRIMARY KEY, nid integer NOT NULL UNIQUE ); INSERT INTO sort_order (nid) SELECT nid FROM cards WHERE type IN (2, 3) GROUP BY nid ORDER BY AVG(ivl); ================================================ FILE: rslib/src/search/note_lapses_order.sql ================================================ DROP TABLE IF EXISTS sort_order; CREATE TEMPORARY TABLE sort_order ( pos integer PRIMARY KEY, nid integer NOT NULL UNIQUE ); INSERT INTO sort_order (nid) SELECT nid FROM cards GROUP BY nid ORDER BY SUM(lapses); ================================================ FILE: rslib/src/search/note_original_position_order.sql ================================================ DROP TABLE IF EXISTS sort_order; CREATE TEMPORARY TABLE sort_order ( pos integer PRIMARY KEY, nid integer NOT NULL UNIQUE ); INSERT INTO sort_order (nid) SELECT nid FROM cards GROUP BY nid ORDER BY COALESCE( extract_original_position(data), CASE WHEN type == 0 THEN due ELSE 0 END ); ================================================ FILE: rslib/src/search/note_reps_order.sql ================================================ DROP TABLE IF EXISTS sort_order; CREATE TEMPORARY TABLE sort_order ( pos integer PRIMARY KEY, nid integer NOT NULL UNIQUE ); INSERT INTO sort_order (nid) SELECT nid FROM cards GROUP BY nid ORDER BY SUM(reps); ================================================ FILE: rslib/src/search/notetype_order.sql ================================================ DROP TABLE IF EXISTS sort_order; CREATE TEMPORARY TABLE sort_order ( pos integer PRIMARY KEY, ntid integer NOT NULL UNIQUE ); INSERT INTO sort_order (ntid) SELECT id FROM notetypes ORDER BY name; ================================================ FILE: rslib/src/search/parser.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::sync::LazyLock; use anki_proto::search::search_node::FieldSearchMode as FieldSearchModeProto; use nom::branch::alt; use nom::bytes::complete::escaped; use nom::bytes::complete::is_not; use nom::bytes::complete::tag; use nom::character::complete::alphanumeric1; use nom::character::complete::anychar; use nom::character::complete::char; use nom::character::complete::none_of; use nom::character::complete::one_of; use nom::combinator::map; use nom::combinator::recognize; use nom::combinator::verify; use nom::error::ErrorKind as NomErrorKind; use nom::multi::many0; use nom::sequence::preceded; use nom::sequence::separated_pair; use nom::Parser; use regex::Captures; use regex::Regex; use crate::error::ParseError; use crate::error::Result; use crate::error::SearchErrorKind as FailKind; use crate::prelude::*; type IResult<'a, O> = std::result::Result<(&'a str, O), nom::Err>>; type ParseResult<'a, O> = std::result::Result>>; fn parse_failure(input: &str, kind: FailKind) -> nom::Err> { nom::Err::Failure(ParseError::Anki(input, kind)) } fn parse_error(input: &str) -> nom::Err> { nom::Err::Error(ParseError::Anki(input, FailKind::Other { info: None })) } #[derive(Debug, PartialEq, Clone)] pub enum Node { And, Or, Not(Box), Group(Vec), Search(SearchNode), } #[derive(Copy, Debug, PartialEq, Eq, Clone)] pub enum FieldSearchMode { Normal, Regex, NoCombining, } impl From for FieldSearchMode { fn from(mode: FieldSearchModeProto) -> Self { match mode { FieldSearchModeProto::Normal => Self::Normal, FieldSearchModeProto::Regex => Self::Regex, FieldSearchModeProto::Nocombining => Self::NoCombining, } } } #[derive(Debug, PartialEq, Clone)] pub enum SearchNode { // text without a colon UnqualifiedText(String), // foo:bar, where foo doesn't match a term below SingleField { field: String, text: String, mode: FieldSearchMode, }, AddedInDays(u32), EditedInDays(u32), CardTemplate(TemplateKind), Deck(String), /// Matches cards in a list of deck ids. Cards are matched even if they are /// in a filtered deck. DeckIdsWithoutChildren(String), /// Matches cards in a deck or its children (original_deck_id is not /// checked, so filtered cards are not matched). DeckIdWithChildren(DeckId), IntroducedInDays(u32), NotetypeId(NotetypeId), Notetype(String), Rated { days: u32, ease: RatingKind, }, Tag { tag: String, mode: FieldSearchMode, }, Duplicates { notetype_id: NotetypeId, text: String, }, State(StateKind), Flag(u8), NoteIds(String), CardIds(String), Property { operator: String, kind: PropertyKind, }, WholeCollection, Regex(String), NoCombining(String), StripClozes(String), WordBoundary(String), CustomData(String), Preset(String), } #[derive(Debug, PartialEq, Clone)] pub enum PropertyKind { Due(i32), Interval(u32), Reps(u32), Lapses(u32), Ease(f32), Position(u32), Rated(i32, RatingKind), Stability(f32), Difficulty(f32), Retrievability(f32), CustomDataNumber { key: String, value: f32 }, CustomDataString { key: String, value: String }, } #[derive(Debug, PartialEq, Eq, Clone)] pub enum StateKind { New, Review, Learning, Due, Buried, UserBuried, SchedBuried, Suspended, } #[derive(Debug, PartialEq, Eq, Clone)] pub enum TemplateKind { Ordinal(u16), Name(String), } #[derive(Debug, PartialEq, Eq, Clone)] pub enum RatingKind { AnswerButton(u8), AnyAnswerButton, ManualReschedule, } /// Parse the input string into a list of nodes. pub fn parse(input: &str) -> Result> { let input = input.trim(); if input.is_empty() { return Ok(vec![Node::Search(SearchNode::WholeCollection)]); } match group_inner(input) { Ok(("", nodes)) => Ok(nodes), // unmatched ) is only char not consumed by any node parser Ok((remaining, _)) => Err(parse_failure(remaining, FailKind::UnopenedGroup).into()), Err(err) => Err(err.into()), } } /// Zero or more nodes inside brackets, eg 'one OR two -three'. /// Empty vec must be handled by caller. fn group_inner(input: &str) -> IResult<'_, Vec> { let mut remaining = input; let mut nodes = vec![]; loop { match node(remaining) { Ok((rem, node)) => { remaining = rem; if nodes.len() % 2 == 0 { // before adding the node, if the length is even then the node // must not be a boolean if node == Node::And { return Err(parse_failure(input, FailKind::MisplacedAnd)); } else if node == Node::Or { return Err(parse_failure(input, FailKind::MisplacedOr)); } } else { // if the length is odd, the next item must be a boolean. if it's // not, add an implicit and if !matches!(node, Node::And | Node::Or) { nodes.push(Node::And); } } nodes.push(node); } Err(e) => match e { nom::Err::Error(_) => break, _ => return Err(e), }, }; } if let Some(last) = nodes.last() { match last { Node::And => return Err(parse_failure(input, FailKind::MisplacedAnd)), Node::Or => return Err(parse_failure(input, FailKind::MisplacedOr)), _ => (), } } let (remaining, _) = whitespace0(remaining)?; Ok((remaining, nodes)) } fn whitespace0(s: &str) -> IResult<'_, Vec> { many0(one_of(" \u{3000}")).parse(s) } /// Optional leading space, then a (negated) group or text fn node(s: &str) -> IResult<'_, Node> { preceded(whitespace0, alt((negated_node, group, text))).parse(s) } fn negated_node(s: &str) -> IResult<'_, Node> { map(preceded(char('-'), alt((group, text))), |node| { Node::Not(Box::new(node)) }) .parse(s) } /// One or more nodes surrounded by brackets, eg (one OR two) fn group(s: &str) -> IResult<'_, Node> { let (opened, _) = char('(')(s)?; let (tail, inner) = group_inner(opened)?; if let Some(remaining) = tail.strip_prefix(')') { if inner.is_empty() { Err(parse_failure(s, FailKind::EmptyGroup)) } else { Ok((remaining, Node::Group(inner))) } } else { Err(parse_failure(s, FailKind::UnclosedGroup)) } } /// Either quoted or unquoted text fn text(s: &str) -> IResult<'_, Node> { alt((quoted_term, partially_quoted_term, unquoted_term)).parse(s) } /// Quoted text, including the outer double quotes. fn quoted_term(s: &str) -> IResult<'_, Node> { let (remaining, term) = quoted_term_str(s)?; Ok((remaining, Node::Search(search_node_for_text(term)?))) } /// eg deck:"foo bar" - quotes must come after the : fn partially_quoted_term(s: &str) -> IResult<'_, Node> { let (remaining, (key, val)) = separated_pair( escaped(is_not("\"(): \u{3000}\\"), '\\', none_of(" \u{3000}")), char(':'), quoted_term_str, ) .parse(s)?; Ok(( remaining, Node::Search(search_node_for_text_with_argument(key, val)?), )) } /// Unquoted text, terminated by whitespace or unescaped ", ( or ) fn unquoted_term(s: &str) -> IResult<'_, Node> { match escaped(is_not("\"() \u{3000}\\"), '\\', none_of(" \u{3000}"))(s) { Ok((tail, term)) => { if term.is_empty() { Err(parse_error(s)) } else if term.eq_ignore_ascii_case("and") { Ok((tail, Node::And)) } else if term.eq_ignore_ascii_case("or") { Ok((tail, Node::Or)) } else { Ok((tail, Node::Search(search_node_for_text(term)?))) } } Err(err) => { if let nom::Err::Error((c, NomErrorKind::NoneOf)) = err { Err(parse_failure( s, FailKind::UnknownEscape { provided: format!("\\{c}"), }, )) } else if "\"() \u{3000}".contains(s.chars().next().unwrap()) { Err(parse_error(s)) } else { // input ends in an odd number of backslashes Err(parse_failure( s, FailKind::UnknownEscape { provided: '\\'.to_string(), }, )) } } } } /// Non-empty string delimited by unescaped double quotes. fn quoted_term_str(s: &str) -> IResult<'_, &str> { let (opened, _) = char('"')(s)?; if let Ok((tail, inner)) = escaped::<_, ParseError, _, _>(is_not(r#""\"#), '\\', anychar).parse(opened) { if let Ok((remaining, _)) = char::<_, ParseError>('"')(tail) { Ok((remaining, inner)) } else { Err(parse_failure(s, FailKind::UnclosedQuote)) } } else { Err(parse_failure( s, match opened.chars().next().unwrap() { '"' => FailKind::EmptyQuote, // no unescaped " and a trailing \ _ => FailKind::UnclosedQuote, }, )) } } /// Determine if text is a qualified search, and handle escaped chars. /// Expect well-formed input: unempty and no trailing \. fn search_node_for_text(s: &str) -> ParseResult<'_, SearchNode> { // leading : is only possible error for well-formed input let (tail, head) = verify(escaped(is_not(r":\"), '\\', anychar), |t: &str| { !t.is_empty() }) .parse(s) .map_err(|_: nom::Err| parse_failure(s, FailKind::MissingKey))?; if tail.is_empty() { Ok(SearchNode::UnqualifiedText(unescape(head)?)) } else { search_node_for_text_with_argument(head, &tail[1..]) } } /// Convert a colon-separated key/val pair into the relevant search type. fn search_node_for_text_with_argument<'a>( key: &'a str, val: &'a str, ) -> ParseResult<'a, SearchNode> { Ok(match key.to_ascii_lowercase().as_str() { "deck" => SearchNode::Deck(unescape(val)?), "note" => SearchNode::Notetype(unescape(val)?), "tag" => parse_tag(val)?, "card" => parse_template(val)?, "flag" => parse_flag(val)?, "resched" => parse_resched(val)?, "prop" => parse_prop(val)?, "added" => parse_added(val)?, "edited" => parse_edited(val)?, "introduced" => parse_introduced(val)?, "rated" => parse_rated(val)?, "is" => parse_state(val)?, "did" => SearchNode::DeckIdsWithoutChildren(check_id_list(val, key)?.into()), "mid" => parse_mid(val)?, "nid" => SearchNode::NoteIds(check_id_list(val, key)?.into()), "cid" => SearchNode::CardIds(check_id_list(val, key)?.into()), "re" => SearchNode::Regex(unescape_quotes(val)), "nc" => SearchNode::NoCombining(unescape(val)?), "sc" => SearchNode::StripClozes(unescape(val)?), "w" => SearchNode::WordBoundary(unescape(val)?), "dupe" => parse_dupe(val)?, "has-cd" => SearchNode::CustomData(unescape(val)?), "preset" => SearchNode::Preset(val.into()), // anything else is a field search _ => parse_single_field(key, val)?, }) } fn parse_tag(s: &str) -> ParseResult<'_, SearchNode> { Ok(if let Some(re) = s.strip_prefix("re:") { SearchNode::Tag { tag: unescape_quotes(re), mode: FieldSearchMode::Regex, } } else if let Some(nc) = s.strip_prefix("nc:") { SearchNode::Tag { tag: unescape(nc)?, mode: FieldSearchMode::NoCombining, } } else { SearchNode::Tag { tag: unescape(s)?, mode: FieldSearchMode::Normal, } }) } fn parse_template(s: &str) -> ParseResult<'_, SearchNode> { Ok(SearchNode::CardTemplate(match s.parse::() { Ok(n) => TemplateKind::Ordinal(n.max(1) - 1), Err(_) => TemplateKind::Name(unescape(s)?), })) } /// flag:0-7 fn parse_flag(s: &str) -> ParseResult<'_, SearchNode> { if let Ok(flag) = s.parse::() { if flag > 7 { Err(parse_failure(s, FailKind::InvalidFlag)) } else { Ok(SearchNode::Flag(flag)) } } else { Err(parse_failure(s, FailKind::InvalidFlag)) } } /// eg resched:3 fn parse_resched(s: &str) -> ParseResult<'_, SearchNode> { parse_u32(s, "resched:").map(|days| SearchNode::Rated { days, ease: RatingKind::ManualReschedule, }) } /// eg prop:ivl>3, prop:ease!=2.5 fn parse_prop(prop_clause: &str) -> ParseResult<'_, SearchNode> { let (tail, prop) = alt(( tag("ivl"), tag("due"), tag("reps"), tag("lapses"), tag("ease"), tag("pos"), tag("rated"), tag("resched"), tag("s"), tag("d"), tag("r"), recognize(preceded(tag("cdn:"), alphanumeric1)), recognize(preceded(tag("cds:"), alphanumeric1)), )) .parse(prop_clause) .map_err(|_: nom::Err| { parse_failure( prop_clause, FailKind::InvalidPropProperty { provided: prop_clause.into(), }, ) })?; let (num, operator) = alt(( tag("<="), tag(">="), tag("!="), tag("="), tag("<"), tag(">"), )) .parse(tail) .map_err(|_: nom::Err| { parse_failure( prop_clause, FailKind::InvalidPropOperator { provided: prop.to_string(), }, ) })?; let kind = match prop { "ease" => PropertyKind::Ease(parse_f32(num, prop_clause)?), "due" => PropertyKind::Due(parse_i32(num, prop_clause)?), "rated" => parse_prop_rated(num, prop_clause)?, "resched" => PropertyKind::Rated( parse_negative_i32(num, prop_clause)?, RatingKind::ManualReschedule, ), "ivl" => PropertyKind::Interval(parse_u32(num, prop_clause)?), "reps" => PropertyKind::Reps(parse_u32(num, prop_clause)?), "lapses" => PropertyKind::Lapses(parse_u32(num, prop_clause)?), "pos" => PropertyKind::Position(parse_u32(num, prop_clause)?), "s" => PropertyKind::Stability(parse_f32(num, prop_clause)?), "d" => PropertyKind::Difficulty(parse_f32(num, prop_clause)?), "r" => PropertyKind::Retrievability(parse_f32(num, prop_clause)?), prop if prop.starts_with("cdn:") => PropertyKind::CustomDataNumber { key: prop.strip_prefix("cdn:").unwrap().into(), value: parse_f32(num, prop_clause)?, }, prop if prop.starts_with("cds:") => PropertyKind::CustomDataString { key: prop.strip_prefix("cds:").unwrap().into(), value: num.into(), }, _ => unreachable!(), }; Ok(SearchNode::Property { operator: operator.to_string(), kind, }) } fn parse_u32<'a>(num: &str, context: &'a str) -> ParseResult<'a, u32> { num.parse().map_err(|_e| { parse_failure( context, FailKind::InvalidPositiveWholeNumber { context: context.into(), provided: num.into(), }, ) }) } fn parse_i32<'a>(num: &str, context: &'a str) -> ParseResult<'a, i32> { num.parse().map_err(|_e| { parse_failure( context, FailKind::InvalidWholeNumber { context: context.into(), provided: num.into(), }, ) }) } fn parse_negative_i32<'a>(num: &str, context: &'a str) -> ParseResult<'a, i32> { num.parse() .map_err(|_| ()) .and_then(|n| if n > 0 { Err(()) } else { Ok(n) }) .map_err(|_| { parse_failure( context, FailKind::InvalidNegativeWholeNumber { context: context.into(), provided: num.into(), }, ) }) } fn parse_f32<'a>(num: &str, context: &'a str) -> ParseResult<'a, f32> { num.parse().map_err(|_e| { parse_failure( context, FailKind::InvalidNumber { context: context.into(), provided: num.into(), }, ) }) } fn parse_i64<'a>(num: &str, context: &'a str) -> ParseResult<'a, i64> { num.parse().map_err(|_e| { parse_failure( context, FailKind::InvalidWholeNumber { context: context.into(), provided: num.into(), }, ) }) } fn parse_answer_button<'a>(num: Option<&str>, context: &'a str) -> ParseResult<'a, RatingKind> { Ok(if let Some(num) = num { RatingKind::AnswerButton( num.parse() .map_err(|_| ()) .and_then(|n| if matches!(n, 1..=4) { Ok(n) } else { Err(()) }) .map_err(|_| { parse_failure( context, FailKind::InvalidAnswerButton { context: context.into(), provided: num.into(), }, ) })?, ) } else { RatingKind::AnyAnswerButton }) } fn parse_prop_rated<'a>(num: &str, context: &'a str) -> ParseResult<'a, PropertyKind> { let mut it = num.splitn(2, ':'); let days = parse_negative_i32(it.next().unwrap(), context)?; let button = parse_answer_button(it.next(), context)?; Ok(PropertyKind::Rated(days, button)) } /// eg added:1 fn parse_added(s: &str) -> ParseResult<'_, SearchNode> { parse_u32(s, "added:").map(|n| SearchNode::AddedInDays(n.max(1))) } /// eg edited:1 fn parse_edited(s: &str) -> ParseResult<'_, SearchNode> { parse_u32(s, "edited:").map(|n| SearchNode::EditedInDays(n.max(1))) } /// eg introduced:1 fn parse_introduced(s: &str) -> ParseResult<'_, SearchNode> { parse_u32(s, "introduced:").map(|n| SearchNode::IntroducedInDays(n.max(1))) } /// eg rated:3 or rated:10:2 /// second arg must be between 1-4 fn parse_rated(s: &str) -> ParseResult<'_, SearchNode> { let mut it = s.splitn(2, ':'); let days = parse_u32(it.next().unwrap(), "rated:")?.max(1); let button = parse_answer_button(it.next(), s)?; Ok(SearchNode::Rated { days, ease: button }) } /// eg is:due fn parse_state(s: &str) -> ParseResult<'_, SearchNode> { use StateKind::*; Ok(SearchNode::State(match s { "new" => New, "review" => Review, "learn" => Learning, "due" => Due, "buried" => Buried, "buried-manually" => UserBuried, "buried-sibling" => SchedBuried, "suspended" => Suspended, _ => { return Err(parse_failure( s, FailKind::InvalidState { provided: s.into() }, )) } })) } fn parse_mid(s: &str) -> ParseResult<'_, SearchNode> { parse_i64(s, "mid:").map(|n| SearchNode::NotetypeId(n.into())) } /// ensure a list of ids contains only numbers and commas, returning unchanged /// if true used by nid: and cid: fn check_id_list<'a>(s: &'a str, context: &str) -> ParseResult<'a, &'a str> { static RE: LazyLock = LazyLock::new(|| Regex::new(r"^(\d+,)*\d+$").unwrap()); if RE.is_match(s) { Ok(s) } else { Err(parse_failure( s, // id lists are undocumented, so no translation FailKind::Other { info: Some(format!("expected only digits and commas in {context}:")), }, )) } } /// eg dupe:1231,hello fn parse_dupe(s: &str) -> ParseResult<'_, SearchNode> { let mut it = s.splitn(2, ','); let ntid = parse_i64(it.next().unwrap(), s)?; if let Some(text) = it.next() { Ok(SearchNode::Duplicates { notetype_id: ntid.into(), text: unescape_quotes_and_backslashes(text), }) } else { // this is an undocumented keyword, so no translation/help Err(parse_failure( s, FailKind::Other { info: Some("invalid 'dupe:' search".into()), }, )) } } fn parse_single_field<'a>(key: &'a str, val: &'a str) -> ParseResult<'a, SearchNode> { Ok(if let Some(stripped) = val.strip_prefix("re:") { SearchNode::SingleField { field: unescape(key)?, text: unescape_quotes(stripped), mode: FieldSearchMode::Regex, } } else if let Some(stripped) = val.strip_prefix("nc:") { SearchNode::SingleField { field: unescape(key)?, text: unescape_quotes(stripped), mode: FieldSearchMode::NoCombining, } } else { SearchNode::SingleField { field: unescape(key)?, text: unescape(val)?, mode: FieldSearchMode::Normal, } }) } /// For strings without unescaped ", convert \" to " fn unescape_quotes(s: &str) -> String { if s.contains('"') { s.replace(r#"\""#, "\"") } else { s.into() } } /// For non-globs like dupe text without any assumption about the content fn unescape_quotes_and_backslashes(s: &str) -> String { if s.contains('"') || s.contains('\\') { s.replace(r#"\""#, "\"").replace(r"\\", r"\") } else { s.into() } } /// Unescape chars with special meaning to the parser. fn unescape(txt: &str) -> ParseResult<'_, String> { if let Some(seq) = invalid_escape_sequence(txt) { Err(parse_failure( txt, FailKind::UnknownEscape { provided: seq }, )) } else { Ok(if is_parser_escape(txt) { static RE: LazyLock = LazyLock::new(|| Regex::new(r#"\\[\\":()-]"#).unwrap()); RE.replace_all(txt, |caps: &Captures| match &caps[0] { r"\\" => r"\\", "\\\"" => "\"", r"\:" => ":", r"\(" => "(", r"\)" => ")", r"\-" => "-", _ => unreachable!(), }) .into() } else { txt.into() }) } } /// Return invalid escape sequence if any. fn invalid_escape_sequence(txt: &str) -> Option { // odd number of \s not followed by an escapable character static RE: LazyLock = LazyLock::new(|| { Regex::new( r#"(?x) (?:^|[^\\]) # not a backslash (?:\\\\)* # even number of backslashes (\\ # single backslash (?:[^\\":*_()-]|$)) # anything but an escapable char "#, ) .unwrap() }); let caps = RE.captures(txt)?; Some(caps[1].to_string()) } /// Check string for escape sequences handled by the parser: ":()- fn is_parser_escape(txt: &str) -> bool { // odd number of \s followed by a char with special meaning to the parser static RE: LazyLock = LazyLock::new(|| { Regex::new( r#"(?x) (?:^|[^\\]) # not a backslash (?:\\\\)* # even number of backslashes \\ # single backslash [":()-] # parser escape "#, ) .unwrap() }); RE.is_match(txt) } #[cfg(test)] mod test { use super::*; use crate::error::SearchErrorKind; #[test] fn parsing() -> Result<()> { use Node::*; use SearchNode::*; assert_eq!(parse("")?, vec![Search(WholeCollection)]); assert_eq!(parse(" ")?, vec![Search(WholeCollection)]); // leading/trailing/interspersed whitespace assert_eq!( parse(" t t2 ")?, vec![ Search(UnqualifiedText("t".into())), And, Search(UnqualifiedText("t2".into())) ] ); // including in groups assert_eq!( parse("( t t2 )")?, vec![Group(vec![ Search(UnqualifiedText("t".into())), And, Search(UnqualifiedText("t2".into())) ])] ); assert_eq!( parse(r#"hello -(world and "foo:bar baz") OR test"#)?, vec![ Search(UnqualifiedText("hello".into())), And, Not(Box::new(Group(vec![ Search(UnqualifiedText("world".into())), And, Search(SingleField { field: "foo".into(), text: "bar baz".into(), mode: FieldSearchMode::Normal, }) ]))), Or, Search(UnqualifiedText("test".into())) ] ); assert_eq!( parse("foo:re:bar")?, vec![Search(SingleField { field: "foo".into(), text: "bar".into(), mode: FieldSearchMode::Regex, })] ); assert_eq!( parse("foo:nc:bar")?, vec![Search(SingleField { field: "foo".into(), text: "bar".into(), mode: FieldSearchMode::NoCombining, })] ); // escaping is independent of quotation assert_eq!( parse(r#""field:va\"lue""#)?, vec![Search(SingleField { field: "field".into(), text: "va\"lue".into(), mode: FieldSearchMode::Normal, })] ); assert_eq!(parse(r#""field:va\"lue""#)?, parse(r#"field:"va\"lue""#)?,); assert_eq!(parse(r#""field:va\"lue""#)?, parse(r#"field:va\"lue"#)?,); // parser unescapes ":()- assert_eq!( parse(r#"\"\:\(\)\-"#)?, vec![Search(UnqualifiedText(r#"":()-"#.into())),] ); // parser doesn't unescape unescape \*_ assert_eq!( parse(r"\\\*\_")?, vec![Search(UnqualifiedText(r"\\\*\_".into())),] ); // escaping parentheses is optional (only) inside quotes assert_eq!(parse(r#""\)\(""#), parse(r#"")(""#)); // escaping : is optional if it is preceded by another : assert_eq!(parse("field:val:ue"), parse(r"field:val\:ue")); assert_eq!(parse(r#""field:val:ue""#), parse(r"field:val\:ue")); assert_eq!(parse(r#"field:"val:ue""#), parse(r"field:val\:ue")); // escaping - is optional if it cannot be mistaken for a negator assert_eq!(parse("-"), parse(r"\-")); assert_eq!(parse("A-"), parse(r"A\-")); assert_eq!(parse(r#""-A""#), parse(r"\-A")); assert_ne!(parse("-A"), parse(r"\-A")); // any character should be escapable on the right side of re: assert_eq!( parse(r#""re:\btest\%""#)?, vec![Search(Regex(r"\btest\%".into()))] ); // no exceptions for escaping " assert_eq!( parse(r#"re:te\"st"#)?, vec![Search(Regex(r#"te"st"#.into()))] ); // spaces are optional if node separation is clear assert_eq!(parse(r#"a"b"(c)"#)?, parse("a b (c)")?); assert_eq!(parse("added:3")?, vec![Search(AddedInDays(3))]); assert_eq!( parse("card:front")?, vec![Search(CardTemplate(TemplateKind::Name("front".into())))] ); assert_eq!( parse("card:3")?, vec![Search(CardTemplate(TemplateKind::Ordinal(2)))] ); // 0 must not cause a crash due to underflow assert_eq!( parse("card:0")?, vec![Search(CardTemplate(TemplateKind::Ordinal(0)))] ); assert_eq!(parse("deck:default")?, vec![Search(Deck("default".into()))]); assert_eq!( parse("deck:\"default one\"")?, vec![Search(Deck("default one".into()))] ); assert_eq!( parse("preset:default")?, vec![Search(Preset("default".into()))] ); assert_eq!(parse("note:basic")?, vec![Search(Notetype("basic".into()))]); assert_eq!( parse("tag:hard")?, vec![Search(Tag { tag: "hard".into(), mode: FieldSearchMode::Normal })] ); assert_eq!( parse(r"tag:re:\\")?, vec![Search(Tag { tag: r"\\".into(), mode: FieldSearchMode::Regex })] ); assert_eq!( parse("nid:1237123712,2,3")?, vec![Search(NoteIds("1237123712,2,3".into()))] ); assert_eq!(parse("is:due")?, vec![Search(State(StateKind::Due))]); assert_eq!(parse("flag:3")?, vec![Search(Flag(3))]); assert_eq!( parse("prop:ivl>3")?, vec![Search(Property { operator: ">".into(), kind: PropertyKind::Interval(3) })] ); assert_eq!( parse("prop:ease<=3.3")?, vec![Search(Property { operator: "<=".into(), kind: PropertyKind::Ease(3.3) })] ); assert_eq!( parse("prop:cdn:abc<=1")?, vec![Search(Property { operator: "<=".into(), kind: PropertyKind::CustomDataNumber { key: "abc".into(), value: 1.0 } })] ); assert_eq!( parse("prop:cds:abc=foo")?, vec![Search(Property { operator: "=".into(), kind: PropertyKind::CustomDataString { key: "abc".into(), value: "foo".into() } })] ); assert_eq!( parse("\"prop:cds:abc=foo bar\"")?, vec![Search(Property { operator: "=".into(), kind: PropertyKind::CustomDataString { key: "abc".into(), value: "foo bar".into() } })] ); assert_eq!(parse("has-cd:r")?, vec![Search(CustomData("r".into()))]); Ok(()) } #[test] fn errors() { use FailKind::*; use crate::error::AnkiError; fn assert_err_kind(input: &str, kind: FailKind) { assert_eq!(parse(input), Err(AnkiError::SearchError { source: kind })); } fn failkind(input: &str) -> SearchErrorKind { if let Err(AnkiError::SearchError { source: err }) = parse(input) { err } else { panic!("expected search error"); } } assert_err_kind("foo and", MisplacedAnd); assert_err_kind("and foo", MisplacedAnd); assert_err_kind("and", MisplacedAnd); assert_err_kind("foo or", MisplacedOr); assert_err_kind("or foo", MisplacedOr); assert_err_kind("or", MisplacedOr); assert_err_kind("()", EmptyGroup); assert_err_kind("( )", EmptyGroup); assert_err_kind("(foo () bar)", EmptyGroup); assert_err_kind(")", UnopenedGroup); assert_err_kind("foo ) bar", UnopenedGroup); assert_err_kind("(foo) bar)", UnopenedGroup); assert_err_kind("(", UnclosedGroup); assert_err_kind("foo ( bar", UnclosedGroup); assert_err_kind("(foo (bar)", UnclosedGroup); assert_err_kind(r#""""#, EmptyQuote); assert_err_kind(r#"foo:"""#, EmptyQuote); assert_err_kind(r#" " "#, UnclosedQuote); assert_err_kind(r#"" foo"#, UnclosedQuote); assert_err_kind(r#""\"#, UnclosedQuote); assert_err_kind(r#"foo:"bar"#, UnclosedQuote); assert_err_kind(r#"foo:"bar\"#, UnclosedQuote); assert_err_kind(":", MissingKey); assert_err_kind(":foo", MissingKey); assert_err_kind(r#":"foo""#, MissingKey); assert_err_kind( r"\", UnknownEscape { provided: r"\".to_string(), }, ); assert_err_kind( r"\%", UnknownEscape { provided: r"\%".to_string(), }, ); assert_err_kind( r"foo\", UnknownEscape { provided: r"\".to_string(), }, ); assert_err_kind( r"\foo", UnknownEscape { provided: r"\f".to_string(), }, ); assert_err_kind( r"\ ", UnknownEscape { provided: r"\".to_string(), }, ); assert_err_kind( r#""\ ""#, UnknownEscape { provided: r"\ ".to_string(), }, ); for term in &[ "nid:1_2,3", "nid:1,2,x", "nid:,2,3", "nid:1,2,", "cid:1_2,3", "cid:1,2,x", "cid:,2,3", "cid:1,2,", ] { assert!(matches!(failkind(term), SearchErrorKind::Other { .. })); } assert_err_kind( "is:foo", InvalidState { provided: "foo".into(), }, ); assert_err_kind( "is:DUE", InvalidState { provided: "DUE".into(), }, ); assert_err_kind( "is:New", InvalidState { provided: "New".into(), }, ); assert_err_kind( "is:", InvalidState { provided: "".into(), }, ); assert_err_kind( r#""is:learn ""#, InvalidState { provided: "learn ".into(), }, ); assert_err_kind(r#""flag: ""#, InvalidFlag); assert_err_kind("flag:-0", InvalidFlag); assert_err_kind("flag:", InvalidFlag); assert_err_kind("flag:8", InvalidFlag); assert_err_kind("flag:1.1", InvalidFlag); for term in &["added", "edited", "rated", "resched"] { assert!(matches!( failkind(&format!("{term}:1.1")), SearchErrorKind::InvalidPositiveWholeNumber { .. } )); assert!(matches!( failkind(&format!("{term}:-1")), SearchErrorKind::InvalidPositiveWholeNumber { .. } )); assert!(matches!( failkind(&format!("{term}:")), SearchErrorKind::InvalidPositiveWholeNumber { .. } )); assert!(matches!( failkind(&format!("{term}:foo")), SearchErrorKind::InvalidPositiveWholeNumber { .. } )); } assert!(matches!( failkind("rated:1:"), SearchErrorKind::InvalidAnswerButton { .. } )); assert!(matches!( failkind("rated:2:-1"), SearchErrorKind::InvalidAnswerButton { .. } )); assert!(matches!( failkind("rated:3:1.1"), SearchErrorKind::InvalidAnswerButton { .. } )); assert!(matches!( failkind("rated:0:foo"), SearchErrorKind::InvalidAnswerButton { .. } )); assert!(matches!( failkind("dupe:"), SearchErrorKind::InvalidWholeNumber { .. } )); assert!(matches!( failkind("dupe:1.1"), SearchErrorKind::InvalidWholeNumber { .. } )); assert!(matches!( failkind("dupe:foo"), SearchErrorKind::InvalidWholeNumber { .. } )); assert_err_kind( "prop:", InvalidPropProperty { provided: "".into(), }, ); assert_err_kind( "prop:=1", InvalidPropProperty { provided: "=1".into(), }, ); assert_err_kind( "prop:DUE<5", InvalidPropProperty { provided: "DUE<5".into(), }, ); assert_err_kind( "prop:cdn=5", InvalidPropProperty { provided: "cdn=5".to_string(), }, ); assert_err_kind( "prop:cdn:=5", InvalidPropProperty { provided: "cdn:=5".to_string(), }, ); assert_err_kind( "prop:cds=s", InvalidPropProperty { provided: "cds=s".to_string(), }, ); assert_err_kind( "prop:cds:=s", InvalidPropProperty { provided: "cds:=s".to_string(), }, ); assert_err_kind( "prop:lapses", InvalidPropOperator { provided: "lapses".to_string(), }, ); assert_err_kind( "prop:pos~1", InvalidPropOperator { provided: "pos".to_string(), }, ); assert_err_kind( "prop:reps10", InvalidPropOperator { provided: "reps".to_string(), }, ); // unsigned for term in &["ivl", "reps", "lapses", "pos"] { assert!(matches!( failkind(&format!("prop:{term}>")), SearchErrorKind::InvalidPositiveWholeNumber { .. } )); assert!(matches!( failkind(&format!("prop:{term}=0.5")), SearchErrorKind::InvalidPositiveWholeNumber { .. } )); assert!(matches!( failkind(&format!("prop:{term}!=-1")), SearchErrorKind::InvalidPositiveWholeNumber { .. } )); assert!(matches!( failkind(&format!("prop:{term}"), SearchErrorKind::InvalidWholeNumber { .. } )); assert!(matches!( failkind("prop:due=0.5"), SearchErrorKind::InvalidWholeNumber { .. } )); // float assert!(matches!( failkind("prop:ease>"), SearchErrorKind::InvalidNumber { .. } )); assert!(matches!( failkind("prop:ease!=one"), SearchErrorKind::InvalidNumber { .. } )); assert!(matches!( failkind("prop:ease<1,3"), SearchErrorKind::InvalidNumber { .. } )); } } ================================================ FILE: rslib/src/search/service/browser_table.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::str::FromStr; use anki_i18n::I18n; use crate::browser_table; impl browser_table::Column { pub fn to_pb_column(self, i18n: &I18n) -> anki_proto::search::browser_columns::Column { anki_proto::search::browser_columns::Column { key: self.to_string(), cards_mode_label: self.cards_mode_label(i18n), notes_mode_label: self.notes_mode_label(i18n), sorting_cards: self.default_cards_order() as i32, sorting_notes: self.default_notes_order() as i32, uses_cell_font: self.uses_cell_font(), alignment: self.alignment() as i32, cards_mode_tooltip: self.cards_mode_tooltip(i18n), notes_mode_tooltip: self.notes_mode_tooltip(i18n), } } } pub(crate) fn string_list_to_browser_columns( list: anki_proto::generic::StringList, ) -> Vec { list.vals .into_iter() .map(|c| browser_table::Column::from_str(&c).unwrap_or_default()) .collect() } ================================================ FILE: rslib/src/search/service/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod browser_table; mod search_node; use std::str::FromStr; use std::sync::Arc; use anki_proto::generic; use anki_proto::search::sort_order::Value as SortOrderProto; use crate::browser_table::Column; use crate::notes::service::to_note_ids; use crate::prelude::*; use crate::search::replace_search_node; use crate::search::service::browser_table::string_list_to_browser_columns; use crate::search::JoinSearches; use crate::search::Node; use crate::search::SortMode; impl crate::services::SearchService for Collection { fn build_search_string( &mut self, input: anki_proto::search::SearchNode, ) -> Result { let node: Node = input.try_into()?; Ok(SearchBuilder::from_root(node).write().into()) } fn search_cards( &mut self, input: anki_proto::search::SearchRequest, ) -> Result { let order = input.order.unwrap_or_default().value.into(); let cids = self.search_cards(&input.search, order)?; Ok(anki_proto::search::SearchResponse { ids: cids.into_iter().map(|v| v.0).collect(), }) } fn search_notes( &mut self, input: anki_proto::search::SearchRequest, ) -> Result { let order = input.order.unwrap_or_default().value.into(); let nids = self.search_notes(&input.search, order)?; Ok(anki_proto::search::SearchResponse { ids: nids.into_iter().map(|v| v.0).collect(), }) } fn join_search_nodes( &mut self, input: anki_proto::search::JoinSearchNodesRequest, ) -> Result { let existing_node: Node = input.existing_node.unwrap_or_default().try_into()?; let additional_node: Node = input.additional_node.unwrap_or_default().try_into()?; Ok( match anki_proto::search::search_node::group::Joiner::try_from(input.joiner) .unwrap_or_default() { anki_proto::search::search_node::group::Joiner::And => { existing_node.and_flat(additional_node) } anki_proto::search::search_node::group::Joiner::Or => { existing_node.or_flat(additional_node) } } .write() .into(), ) } fn replace_search_node( &mut self, input: anki_proto::search::ReplaceSearchNodeRequest, ) -> Result { let existing = { let node = input.existing_node.unwrap_or_default().try_into()?; if let Node::Group(nodes) = node { nodes } else { vec![node] } }; let replacement = input.replacement_node.unwrap_or_default().try_into()?; Ok(replace_search_node(existing, replacement).into()) } fn find_and_replace( &mut self, input: anki_proto::search::FindAndReplaceRequest, ) -> Result { let mut search = if input.regex { input.search } else { regex::escape(&input.search) }; if !input.match_case { search = format!("(?i){search}"); } let mut nids = to_note_ids(input.nids); let field_name = if input.field_name.is_empty() { None } else { Some(input.field_name) }; let repl = input.replacement; if nids.is_empty() { nids = self.search_notes_unordered("")? }; self.find_and_replace(nids, &search, &repl, field_name) .map(Into::into) } fn all_browser_columns(&mut self) -> Result { Ok(Collection::all_browser_columns(self)) } fn set_active_browser_columns(&mut self, input: generic::StringList) -> Result<()> { self.state.active_browser_columns = Some(Arc::new(string_list_to_browser_columns(input))); Ok(()) } fn browser_row_for_id( &mut self, input: generic::Int64, ) -> Result { self.browser_row_for_id(input.val) } } impl From> for SortMode { fn from(order: Option) -> Self { use anki_proto::search::sort_order::Value as V; match order.unwrap_or(V::None(generic::Empty {})) { V::None(_) => SortMode::NoOrder, V::Custom(s) => SortMode::Custom(s), V::Builtin(b) => SortMode::Builtin { column: Column::from_str(&b.column).unwrap_or_default(), reverse: b.reverse, }, } } } ================================================ FILE: rslib/src/search/service/search_node.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::search::search_node::IdList; use itertools::Itertools; use crate::prelude::*; use crate::search::parse_search; use crate::search::FieldSearchMode; use crate::search::Negated; use crate::search::Node; use crate::search::PropertyKind; use crate::search::RatingKind; use crate::search::SearchNode; use crate::search::StateKind; use crate::search::TemplateKind; use crate::text::escape_anki_wildcards; use crate::text::escape_anki_wildcards_for_search_node; impl TryFrom for Node { type Error = AnkiError; fn try_from(msg: anki_proto::search::SearchNode) -> std::result::Result { use anki_proto::search::search_node::group::Joiner; use anki_proto::search::search_node::Filter; use anki_proto::search::search_node::Flag; Ok(if let Some(filter) = msg.filter { match filter { Filter::Tag(s) => SearchNode::from_tag_name(&s).into(), Filter::Deck(s) => SearchNode::from_deck_name(&s).into(), Filter::Note(s) => SearchNode::from_notetype_name(&s).into(), Filter::Template(u) => { Node::Search(SearchNode::CardTemplate(TemplateKind::Ordinal(u as u16))) } Filter::Nid(nid) => Node::Search(SearchNode::NoteIds(nid.to_string())), Filter::Nids(nids) => Node::Search(SearchNode::NoteIds(id_list_to_string(nids))), Filter::Dupe(dupe) => Node::Search(SearchNode::Duplicates { notetype_id: dupe.notetype_id.into(), text: dupe.first_field, }), Filter::FieldName(s) => Node::Search(SearchNode::SingleField { field: escape_anki_wildcards_for_search_node(&s), text: "_*".to_string(), mode: FieldSearchMode::Normal, }), Filter::Rated(rated) => Node::Search(SearchNode::Rated { days: rated.days, ease: rated.rating().into(), }), Filter::AddedInDays(u) => Node::Search(SearchNode::AddedInDays(u)), Filter::IntroducedInDays(u) => Node::Search(SearchNode::IntroducedInDays(u)), Filter::DueInDays(i) => Node::Search(SearchNode::Property { operator: "<=".to_string(), kind: PropertyKind::Due(i), }), Filter::DueOnDay(i) => Node::Search(SearchNode::Property { operator: "=".to_string(), kind: PropertyKind::Due(i), }), Filter::EditedInDays(u) => Node::Search(SearchNode::EditedInDays(u)), Filter::CardState(state) => Node::Search(SearchNode::State( anki_proto::search::search_node::CardState::try_from(state) .unwrap_or_default() .into(), )), Filter::Flag(flag) => match Flag::try_from(flag).unwrap_or(Flag::Any) { Flag::None => Node::Search(SearchNode::Flag(0)), Flag::Any => Node::Not(Box::new(Node::Search(SearchNode::Flag(0)))), Flag::Red => Node::Search(SearchNode::Flag(1)), Flag::Orange => Node::Search(SearchNode::Flag(2)), Flag::Green => Node::Search(SearchNode::Flag(3)), Flag::Blue => Node::Search(SearchNode::Flag(4)), Flag::Pink => Node::Search(SearchNode::Flag(5)), Flag::Turquoise => Node::Search(SearchNode::Flag(6)), Flag::Purple => Node::Search(SearchNode::Flag(7)), }, Filter::Negated(term) => Node::try_from(*term)?.negated(), Filter::Group(mut group) => { match group.nodes.len() { 0 => invalid_input!("empty group"), // a group of 1 doesn't need to be a group 1 => group.nodes.pop().unwrap().try_into()?, // 2+ nodes _ => { let joiner = match group.joiner() { Joiner::And => Node::And, Joiner::Or => Node::Or, }; let parsed: Vec<_> = group .nodes .into_iter() .map(TryFrom::try_from) .collect::>()?; let joined = Itertools::intersperse(parsed.into_iter(), joiner).collect(); Node::Group(joined) } } } Filter::ParsableText(text) => { let mut nodes = parse_search(&text)?; if nodes.len() == 1 { nodes.pop().unwrap() } else { Node::Group(nodes) } } Filter::Field(field) => Node::Search(SearchNode::SingleField { field: escape_anki_wildcards(&field.field_name), text: escape_anki_wildcards(&field.text), mode: field.mode().into(), }), Filter::LiteralText(text) => { let text = escape_anki_wildcards(&text); Node::Search(SearchNode::UnqualifiedText(text)) } } } else { Node::Search(SearchNode::WholeCollection) }) } } impl From for RatingKind { fn from(r: anki_proto::search::search_node::Rating) -> Self { match r { anki_proto::search::search_node::Rating::Again => RatingKind::AnswerButton(1), anki_proto::search::search_node::Rating::Hard => RatingKind::AnswerButton(2), anki_proto::search::search_node::Rating::Good => RatingKind::AnswerButton(3), anki_proto::search::search_node::Rating::Easy => RatingKind::AnswerButton(4), anki_proto::search::search_node::Rating::Any => RatingKind::AnyAnswerButton, anki_proto::search::search_node::Rating::ByReschedule => RatingKind::ManualReschedule, } } } impl From for StateKind { fn from(k: anki_proto::search::search_node::CardState) -> Self { match k { anki_proto::search::search_node::CardState::New => StateKind::New, anki_proto::search::search_node::CardState::Learn => StateKind::Learning, anki_proto::search::search_node::CardState::Review => StateKind::Review, anki_proto::search::search_node::CardState::Due => StateKind::Due, anki_proto::search::search_node::CardState::Suspended => StateKind::Suspended, anki_proto::search::search_node::CardState::Buried => StateKind::Buried, } } } fn id_list_to_string(list: IdList) -> String { list.ids .iter() .map(|i| i.to_string()) .collect::>() .join(",") } ================================================ FILE: rslib/src/search/sqlwriter.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::fmt::Write; use std::ops::Range; use itertools::Itertools; use super::parser::FieldSearchMode; use super::parser::Node; use super::parser::PropertyKind; use super::parser::RatingKind; use super::parser::SearchNode; use super::parser::StateKind; use super::parser::TemplateKind; use super::ReturnItemType; use crate::card::CardQueue; use crate::card::CardType; use crate::collection::Collection; use crate::error::Result; use crate::notes::field_checksum; use crate::notetype::NotetypeId; use crate::prelude::*; use crate::storage::ids_to_string; use crate::storage::ProcessTextFlags; use crate::text::glob_matcher; use crate::text::is_glob; use crate::text::normalize_to_nfc; use crate::text::strip_html_preserving_media_filenames; use crate::text::to_custom_re; use crate::text::to_re; use crate::text::to_sql; use crate::text::to_text; use crate::text::without_combining; use crate::timestamp::TimestampSecs; pub(crate) struct SqlWriter<'a> { col: &'a mut Collection, sql: String, item_type: ReturnItemType, args: Vec, normalize_note_text: bool, table: RequiredTable, } impl SqlWriter<'_> { pub(crate) fn new(col: &mut Collection, item_type: ReturnItemType) -> SqlWriter<'_> { let normalize_note_text = col.get_config_bool(BoolKey::NormalizeNoteText); let sql = String::new(); let args = vec![]; SqlWriter { col, sql, item_type, args, normalize_note_text, table: item_type.required_table(), } } pub(super) fn build_query( mut self, node: &Node, table: RequiredTable, ) -> Result<(String, Vec)> { self.table = self.table.combine(table.combine(node.required_table())); self.write_table_sql(); self.write_node_to_sql(node)?; Ok((self.sql, self.args)) } fn write_table_sql(&mut self) { let sql = match self.table { RequiredTable::Cards => "select c.id from cards c where ", RequiredTable::Notes => "select n.id from notes n where ", _ => match self.item_type { ReturnItemType::Cards => "select c.id from cards c, notes n where c.nid=n.id and ", ReturnItemType::Notes => { "select distinct n.id from cards c, notes n where c.nid=n.id and " } }, }; self.sql.push_str(sql); } /// As an optimization we can omit the cards or notes tables from /// certain queries. For code that specifies a note id, we need to /// choose the appropriate column name. fn note_id_column(&self) -> &'static str { match self.table { RequiredTable::Notes | RequiredTable::CardsAndNotes => "n.id", RequiredTable::Cards => "c.nid", RequiredTable::CardsOrNotes => unreachable!(), } } fn write_node_to_sql(&mut self, node: &Node) -> Result<()> { match node { Node::And => write!(self.sql, " and ").unwrap(), Node::Or => write!(self.sql, " or ").unwrap(), Node::Not(node) => { write!(self.sql, "not ").unwrap(); self.write_node_to_sql(node)?; } Node::Group(nodes) => { write!(self.sql, "(").unwrap(); for node in nodes { self.write_node_to_sql(node)?; } write!(self.sql, ")").unwrap(); } Node::Search(search) => self.write_search_node_to_sql(search)?, }; Ok(()) } /// Convert search text to NFC if note normalization is enabled. fn norm_note<'a>(&self, text: &'a str) -> Cow<'a, str> { if self.normalize_note_text { normalize_to_nfc(text) } else { text.into() } } // NOTE: when adding any new nodes in the future, make sure that they are either // a single search term, or they wrap multiple terms in parentheses, as can // be seen in the sql() unit test at the bottom of the file. fn write_search_node_to_sql(&mut self, node: &SearchNode) -> Result<()> { use normalize_to_nfc as norm; match node { // note fields related SearchNode::UnqualifiedText(text) => { let text = &self.norm_note(text); self.write_unqualified( text, self.col.get_config_bool(BoolKey::IgnoreAccentsInSearch), false, )? } SearchNode::SingleField { field, text, mode } => { self.write_field(&norm(field), &self.norm_note(text), *mode)? } SearchNode::Duplicates { notetype_id, text } => { self.write_dupe(*notetype_id, &self.norm_note(text))? } SearchNode::Regex(re) => self.write_regex(&self.norm_note(re), false)?, SearchNode::NoCombining(text) => { self.write_unqualified(&self.norm_note(text), true, false)? } SearchNode::StripClozes(text) => self.write_unqualified( &self.norm_note(text), self.col.get_config_bool(BoolKey::IgnoreAccentsInSearch), true, )?, SearchNode::WordBoundary(text) => self.write_word_boundary(&self.norm_note(text))?, // other SearchNode::AddedInDays(days) => self.write_added(*days)?, SearchNode::EditedInDays(days) => self.write_edited(*days)?, SearchNode::IntroducedInDays(days) => self.write_introduced(*days)?, SearchNode::CardTemplate(template) => match template { TemplateKind::Ordinal(_) => self.write_template(template), TemplateKind::Name(name) => { self.write_template(&TemplateKind::Name(norm(name).into())) } }, SearchNode::Deck(deck) => self.write_deck(&norm(deck))?, SearchNode::NotetypeId(ntid) => { write!(self.sql, "n.mid = {ntid}").unwrap(); } SearchNode::DeckIdsWithoutChildren(dids) => { write!( self.sql, "c.did in ({dids}) or (c.odid != 0 and c.odid in ({dids}))" ) .unwrap(); } SearchNode::DeckIdWithChildren(did) => self.write_deck_id_with_children(*did)?, SearchNode::Notetype(notetype) => self.write_notetype(&norm(notetype)), SearchNode::Rated { days, ease } => self.write_rated(">", -i64::from(*days), ease)?, SearchNode::Tag { tag, mode } => self.write_tag(&norm(tag), *mode), SearchNode::State(state) => self.write_state(state)?, SearchNode::Flag(flag) => { write!(self.sql, "(c.flags & 7) == {flag}").unwrap(); } SearchNode::NoteIds(nids) => { write!(self.sql, "{} in ({})", self.note_id_column(), nids).unwrap(); } SearchNode::CardIds(cids) => { write!(self.sql, "c.id in ({cids})").unwrap(); } SearchNode::Property { operator, kind } => self.write_prop(operator, kind)?, SearchNode::CustomData(key) => self.write_custom_data(key)?, SearchNode::WholeCollection => write!(self.sql, "true").unwrap(), SearchNode::Preset(name) => self.write_deck_preset(name)?, }; Ok(()) } fn write_unqualified( &mut self, text: &str, no_combining: bool, strip_clozes: bool, ) -> Result<()> { let text = to_sql(text); let text = if no_combining { without_combining(&text) } else { text }; // implicitly wrap in % let text = format!("%{text}%"); self.args.push(text); let arg_idx = self.args.len(); let mut process_text_flags = ProcessTextFlags::empty(); if no_combining { process_text_flags.insert(ProcessTextFlags::NoCombining); } if strip_clozes { process_text_flags.insert(ProcessTextFlags::StripClozes); } let (sfld_expr, flds_expr) = if !process_text_flags.is_empty() { let bits = process_text_flags.bits(); ( Cow::from(format!( "coalesce(process_text(cast(n.sfld as text), {bits}), n.sfld)" )), Cow::from(format!("coalesce(process_text(n.flds, {bits}), n.flds)")), ) } else { (Cow::from("n.sfld"), Cow::from("n.flds")) }; if strip_clozes { let cloze_notetypes_only_clause = self .col .get_all_notetypes()? .iter() .filter(|nt| nt.is_cloze()) .map(|nt| format!("n.mid = {}", nt.id)) .join(" or "); write!(self.sql, "({cloze_notetypes_only_clause}) and ").unwrap(); } if let Some(field_indicies_by_notetype) = self.included_fields_by_notetype()? { let field_idx_str = format!("' || ?{arg_idx} || '"); let other_idx_str = "%".to_string(); let notetype_clause = |ctx: &UnqualifiedSearchContext| -> String { let field_index_clause = |range: &Range| { let f = (0..ctx.total_fields_in_note) .filter_map(|i| { if i as u32 == range.start { Some(&field_idx_str) } else if range.contains(&(i as u32)) { None } else { Some(&other_idx_str) } }) .join("\x1f"); format!("{flds_expr} like '{f}' escape '\\'") }; let mut all_field_clauses: Vec = ctx .field_ranges_to_search .iter() .map(field_index_clause) .collect(); if !ctx.sortf_excluded { all_field_clauses.push(format!("{sfld_expr} like ?{arg_idx} escape '\\'")); } format!( "(n.mid = {mid} and ({all_field_clauses}))", mid = ctx.ntid, all_field_clauses = all_field_clauses.join(" or ") ) }; let all_notetype_clauses = field_indicies_by_notetype .iter() .map(notetype_clause) .join(" or "); write!(self.sql, "({all_notetype_clauses})").unwrap(); } else { write!( self.sql, "({sfld_expr} like ?{arg_idx} escape '\\' or {flds_expr} like ?{arg_idx} escape '\\')" ) .unwrap(); } Ok(()) } fn write_tag(&mut self, tag: &str, mode: FieldSearchMode) { if mode == FieldSearchMode::Regex { self.args.push(format!("(?i){tag}")); write!(self.sql, "regexp_tags(?{}, n.tags)", self.args.len()).unwrap(); } else { match tag { "none" => { write!(self.sql, "n.tags = ''").unwrap(); } "*" => { write!(self.sql, "true").unwrap(); } s if s.contains(' ') => write!(self.sql, "false").unwrap(), text => { let text = if mode == FieldSearchMode::Normal { write!(self.sql, "n.tags regexp ?").unwrap(); Cow::from(text) } else { write!( self.sql, "coalesce(process_text(n.tags, {}), n.tags) regexp ?", ProcessTextFlags::NoCombining.bits() ) .unwrap(); without_combining(text) }; let re = &to_custom_re(&text, r"\S"); self.args.push(format!("(?i).* {re}(::| ).*")); } } } } fn write_rated(&mut self, op: &str, days: i64, ease: &RatingKind) -> Result<()> { let today_cutoff = self.col.timing_today()?.next_day_at; let target_cutoff_ms = today_cutoff.adding_secs(86_400 * days).as_millis(); let day_before_cutoff_ms = today_cutoff.adding_secs(86_400 * (days - 1)).as_millis(); write!(self.sql, "c.id in (select cid from revlog where id").unwrap(); match op { ">" => write!(self.sql, " >= {target_cutoff_ms}"), ">=" => write!(self.sql, " >= {day_before_cutoff_ms}"), "<" => write!(self.sql, " < {day_before_cutoff_ms}"), "<=" => write!(self.sql, " < {target_cutoff_ms}"), "=" => write!( self.sql, " between {} and {}", day_before_cutoff_ms, target_cutoff_ms.0 - 1 ), "!=" => write!( self.sql, " not between {} and {}", day_before_cutoff_ms, target_cutoff_ms.0 - 1 ), _ => unreachable!("unexpected op"), } .unwrap(); match ease { RatingKind::AnswerButton(u) => write!(self.sql, " and ease = {u})"), RatingKind::AnyAnswerButton => write!(self.sql, " and ease > 0)"), RatingKind::ManualReschedule => write!(self.sql, " and ease = 0)"), } .unwrap(); Ok(()) } fn write_prop(&mut self, op: &str, kind: &PropertyKind) -> Result<()> { let timing = self.col.timing_today()?; match kind { PropertyKind::Due(days) => { let day = days + (timing.days_elapsed as i32); write!( self.sql, // SQL does integer division if both parameters are integers "(\ (c.queue in ({rev},{daylrn}) and (case when c.odue != 0 then c.odue else c.due end) {op} {day}) or \ (c.queue in ({lrn},{previewrepeat}) and (((case when c.odue != 0 then c.odue else c.due end) - {cutoff}) / 86400) {op} {days})\ )", rev = CardQueue::Review as u8, daylrn = CardQueue::DayLearn as u8, op = op, day = day, lrn = CardQueue::Learn as i8, previewrepeat = CardQueue::PreviewRepeat as i8, cutoff = timing.next_day_at, days = days ).unwrap() } PropertyKind::Position(pos) => write!( self.sql, "(c.type = {t} and (case when c.odue != 0 then c.odue else c.due end) {op} {pos})", t = CardType::New as u8, op = op, pos = pos ) .unwrap(), PropertyKind::Interval(ivl) => write!(self.sql, "ivl {op} {ivl}").unwrap(), PropertyKind::Reps(reps) => write!(self.sql, "reps {op} {reps}").unwrap(), PropertyKind::Lapses(days) => write!(self.sql, "lapses {op} {days}").unwrap(), PropertyKind::Ease(ease) => { write!(self.sql, "factor {} {}", op, (ease * 1000.0) as u32).unwrap() } PropertyKind::Rated(days, ease) => self.write_rated(op, i64::from(*days), ease)?, PropertyKind::CustomDataNumber { key, value } => { write!( self.sql, "cast(extract_custom_data(c.data, '{key}') as float) {op} {value}" ) .unwrap(); } PropertyKind::CustomDataString { key, value } => { write!( self.sql, "extract_custom_data(c.data, '{key}') {op} '{value}'" ) .unwrap(); } PropertyKind::Stability(s) => { write!(self.sql, "extract_fsrs_variable(c.data, 's') {op} {s}").unwrap() } PropertyKind::Difficulty(d) => { let d = d * 9.0 + 1.0; write!(self.sql, "extract_fsrs_variable(c.data, 'd') {op} {d}").unwrap() } PropertyKind::Retrievability(r) => { let (elap, next_day_at, now) = { let timing = self.col.timing_today()?; (timing.days_elapsed, timing.next_day_at, timing.now) }; const NEW_TYPE: i8 = CardType::New as i8; write!( self.sql, "case when c.type = {NEW_TYPE} then false else (extract_fsrs_retrievability(c.data, case when c.odue !=0 then c.odue else c.due end, c.ivl, {elap}, {next_day_at}, {now}) {op} {r}) end" ) .unwrap() } } Ok(()) } fn write_custom_data(&mut self, key: &str) -> Result<()> { write!(self.sql, "extract_custom_data(c.data, '{key}') is not null").unwrap(); Ok(()) } fn write_state(&mut self, state: &StateKind) -> Result<()> { let timing = self.col.timing_today()?; match state { StateKind::New => write!(self.sql, "c.type = {}", CardType::New as i8), StateKind::Review => write!( self.sql, "c.type in ({}, {})", CardType::Review as i8, CardType::Relearn as i8, ), StateKind::Learning => write!( self.sql, "c.type in ({}, {})", CardType::Learn as i8, CardType::Relearn as i8, ), StateKind::Buried => write!( self.sql, "c.queue in ({},{})", CardQueue::SchedBuried as i8, CardQueue::UserBuried as i8 ), StateKind::Suspended => write!(self.sql, "c.queue = {}", CardQueue::Suspended as i8), StateKind::Due => write!( self.sql, "(\ (c.queue in ({rev},{daylrn}) and c.due <= {today}) or \ (c.queue in ({lrn},{previewrepeat}) and c.due <= {learncutoff})\ )", rev = CardQueue::Review as i8, daylrn = CardQueue::DayLearn as i8, today = timing.days_elapsed, lrn = CardQueue::Learn as i8, previewrepeat = CardQueue::PreviewRepeat as i8, learncutoff = TimestampSecs::now().0 + (self.col.learn_ahead_secs() as i64), ), StateKind::UserBuried => write!(self.sql, "c.queue = {}", CardQueue::UserBuried as i8), StateKind::SchedBuried => { write!(self.sql, "c.queue = {}", CardQueue::SchedBuried as i8) } } .unwrap(); Ok(()) } fn write_deck(&mut self, deck: &str) -> Result<()> { match deck { "*" => write!(self.sql, "true").unwrap(), "filtered" => write!(self.sql, "c.odid != 0").unwrap(), deck => { // rewrite "current" to the current deck name let native_deck = if deck == "current" { let current_did = self.col.get_current_deck_id(); regex::escape( self.col .storage .get_deck(current_did)? .map(|d| d.name) .unwrap_or_else(|| NativeDeckName::from_native_str("Default")) .as_native_str(), ) } else { NativeDeckName::from_human_name(to_re(deck)) .as_native_str() .to_string() }; // convert to a regex that includes child decks self.args.push(format!("(?i)^{native_deck}($|\x1f)")); let arg_idx = self.args.len(); self.sql.push_str(&format!(concat!( "(c.did in (select id from decks where name regexp ?{n})", " or (c.odid != 0 and c.odid in (select id from decks where name regexp ?{n})))"), n=arg_idx )); } }; Ok(()) } fn write_deck_id_with_children(&mut self, deck_id: DeckId) -> Result<()> { if let Some(parent) = self.col.get_deck(deck_id)? { let ids = self.col.storage.deck_id_with_children(&parent)?; let mut buf = String::new(); ids_to_string(&mut buf, &ids); write!(self.sql, "c.did in {buf}",).unwrap(); } else { self.sql.push_str("false") } Ok(()) } fn write_template(&mut self, template: &TemplateKind) { match template { TemplateKind::Ordinal(n) => { write!(self.sql, "c.ord = {n}").unwrap(); } TemplateKind::Name(name) => { if is_glob(name) { let re = format!("(?i)^{}$", to_re(name)); self.sql.push_str( "(n.mid,c.ord) in (select ntid,ord from templates where name regexp ?)", ); self.args.push(re); } else { self.sql.push_str( "(n.mid,c.ord) in (select ntid,ord from templates where name = ?)", ); self.args.push(to_text(name).into()); } } }; } fn write_notetype(&mut self, nt_name: &str) { if is_glob(nt_name) { let re = format!("(?i)^{}$", to_re(nt_name)); self.sql .push_str("n.mid in (select id from notetypes where name regexp ?)"); self.args.push(re); } else { self.sql .push_str("n.mid in (select id from notetypes where name = ?)"); self.args.push(to_text(nt_name).into()); } } fn write_field(&mut self, field_name: &str, val: &str, mode: FieldSearchMode) -> Result<()> { if matches!(field_name, "*" | "_*" | "*_") { if mode == FieldSearchMode::Regex { self.write_all_fields_regexp(val); } else { self.write_all_fields(val); } Ok(()) } else if mode == FieldSearchMode::Regex { self.write_single_field_regexp(field_name, val) } else if mode == FieldSearchMode::NoCombining { self.write_single_field_nc(field_name, val) } else { self.write_single_field(field_name, val) } } fn write_all_fields_regexp(&mut self, val: &str) { self.args.push(format!("(?i){val}")); write!(self.sql, "regexp_fields(?{}, n.flds)", self.args.len()).unwrap(); } fn write_all_fields(&mut self, val: &str) { self.args.push(format!("(?is)^{}$", to_re(val))); write!(self.sql, "regexp_fields(?{}, n.flds)", self.args.len()).unwrap(); } fn write_single_field_nc(&mut self, field_name: &str, val: &str) -> Result<()> { let field_indicies_by_notetype = self.num_fields_and_fields_indices_by_notetype( field_name, matches!(val, "*" | "_*" | "*_"), )?; if field_indicies_by_notetype.is_empty() { write!(self.sql, "false").unwrap(); return Ok(()); } let val = to_sql(val); let val = without_combining(&val); self.args.push(val.into()); let arg_idx = self.args.len(); let field_idx_str = format!("' || ?{arg_idx} || '"); let other_idx_str = "%".to_string(); let notetype_clause = |ctx: &FieldQualifiedSearchContext| -> String { let field_index_clause = |range: &Range| { let f = (0..ctx.total_fields_in_note) .filter_map(|i| { if i as u32 == range.start { Some(&field_idx_str) } else if range.contains(&(i as u32)) { None } else { Some(&other_idx_str) } }) .join("\x1f"); format!( "coalesce(process_text(n.flds, {}), n.flds) like '{f}' escape '\\'", ProcessTextFlags::NoCombining.bits() ) }; let all_field_clauses = ctx .field_ranges_to_search .iter() .map(field_index_clause) .join(" or "); format!("(n.mid = {mid} and ({all_field_clauses}))", mid = ctx.ntid) }; let all_notetype_clauses = field_indicies_by_notetype .iter() .map(notetype_clause) .join(" or "); write!(self.sql, "({all_notetype_clauses})").unwrap(); Ok(()) } fn write_single_field_regexp(&mut self, field_name: &str, val: &str) -> Result<()> { let field_indicies_by_notetype = self.fields_indices_by_notetype(field_name)?; if field_indicies_by_notetype.is_empty() { write!(self.sql, "false").unwrap(); return Ok(()); } self.args.push(format!("(?i){val}")); let arg_idx = self.args.len(); let all_notetype_clauses = field_indicies_by_notetype .iter() .map(|(mid, field_indices)| { let field_index_list = field_indices.iter().join(", "); format!("(n.mid = {mid} and regexp_fields(?{arg_idx}, n.flds, {field_index_list}))") }) .join(" or "); write!(self.sql, "({all_notetype_clauses})").unwrap(); Ok(()) } fn write_single_field(&mut self, field_name: &str, val: &str) -> Result<()> { let field_indicies_by_notetype = self.num_fields_and_fields_indices_by_notetype( field_name, matches!(val, "*" | "_*" | "*_"), )?; if field_indicies_by_notetype.is_empty() { write!(self.sql, "false").unwrap(); return Ok(()); } self.args.push(to_sql(val).into()); let arg_idx = self.args.len(); let field_idx_str = format!("' || ?{arg_idx} || '"); let other_idx_str = "%".to_string(); let notetype_clause = |ctx: &FieldQualifiedSearchContext| -> String { let field_index_clause = |range: &Range| { let f = (0..ctx.total_fields_in_note) .filter_map(|i| { if i as u32 == range.start { Some(&field_idx_str) } else if range.contains(&(i as u32)) { None } else { Some(&other_idx_str) } }) .join("\x1f"); format!("n.flds like '{f}' escape '\\'") }; let all_field_clauses = ctx .field_ranges_to_search .iter() .map(field_index_clause) .join(" or "); format!("(n.mid = {mid} and ({all_field_clauses}))", mid = ctx.ntid) }; let all_notetype_clauses = field_indicies_by_notetype .iter() .map(notetype_clause) .join(" or "); write!(self.sql, "({all_notetype_clauses})").unwrap(); Ok(()) } fn num_fields_and_fields_indices_by_notetype( &mut self, field_name: &str, test_for_nonempty: bool, ) -> Result> { let matches_glob = glob_matcher(field_name); let mut field_map = vec![]; for nt in self.col.get_all_notetypes()? { let matched_fields = nt .fields .iter() .filter(|&field| matches_glob(&field.name)) .map(|field| field.ord.unwrap_or_default()) .collect_ranges(!test_for_nonempty); if !matched_fields.is_empty() { field_map.push(FieldQualifiedSearchContext { ntid: nt.id, total_fields_in_note: nt.fields.len(), field_ranges_to_search: matched_fields, }); } } // for now, sort the map for the benefit of unit tests field_map.sort_by_key(|v| v.ntid); Ok(field_map) } fn fields_indices_by_notetype( &mut self, field_name: &str, ) -> Result)>> { let matches_glob = glob_matcher(field_name); let mut field_map = vec![]; for nt in self.col.get_all_notetypes()? { let matched_fields: Vec = nt .fields .iter() .filter(|&field| matches_glob(&field.name)) .map(|field| field.ord.unwrap_or_default()) .collect(); if !matched_fields.is_empty() { field_map.push((nt.id, matched_fields)); } } // for now, sort the map for the benefit of unit tests field_map.sort(); Ok(field_map) } fn included_fields_by_notetype(&mut self) -> Result>> { let mut any_excluded = false; let mut field_map = vec![]; for nt in self.col.get_all_notetypes()? { let mut sortf_excluded = false; let matched_fields = nt .fields .iter() .filter_map(|field| { let ord = field.ord.unwrap_or_default(); if field.config.exclude_from_search { any_excluded = true; sortf_excluded |= ord == nt.config.sort_field_idx; } (!field.config.exclude_from_search).then_some(ord) }) .collect_ranges(true); if !matched_fields.is_empty() { field_map.push(UnqualifiedSearchContext { ntid: nt.id, total_fields_in_note: nt.fields.len(), sortf_excluded, field_ranges_to_search: matched_fields, }); } } if any_excluded { Ok(Some(field_map)) } else { Ok(None) } } fn included_fields_for_unqualified_regex( &mut self, ) -> Result>> { let mut any_excluded = false; let mut field_map = vec![]; for nt in self.col.get_all_notetypes()? { let matched_fields: Vec = nt .fields .iter() .filter_map(|field| { any_excluded |= field.config.exclude_from_search; (!field.config.exclude_from_search).then_some(field.ord.unwrap_or_default()) }) .collect(); field_map.push(UnqualifiedRegexSearchContext { ntid: nt.id, total_fields_in_note: nt.fields.len(), fields_to_search: matched_fields, }); } if any_excluded { Ok(Some(field_map)) } else { Ok(None) } } fn write_dupe(&mut self, ntid: NotetypeId, text: &str) -> Result<()> { let text_nohtml = strip_html_preserving_media_filenames(text); let csum = field_checksum(text_nohtml.as_ref()); let nids: Vec<_> = self .col .storage .note_fields_by_checksum(ntid, csum)? .into_iter() .filter_map(|(nid, field)| { if strip_html_preserving_media_filenames(&field) == text_nohtml { Some(nid) } else { None } }) .collect(); self.sql += "n.id in "; ids_to_string(&mut self.sql, &nids); Ok(()) } fn previous_day_cutoff(&mut self, days_back: u32) -> Result { let timing = self.col.timing_today()?; Ok(timing.next_day_at.adding_secs(-86_400 * days_back as i64)) } fn write_added(&mut self, days: u32) -> Result<()> { let cutoff = self.previous_day_cutoff(days)?.as_millis(); write!(self.sql, "c.id > {cutoff}").unwrap(); Ok(()) } fn write_edited(&mut self, days: u32) -> Result<()> { let cutoff = self.previous_day_cutoff(days)?; write!(self.sql, "n.mod > {cutoff}").unwrap(); Ok(()) } fn write_introduced(&mut self, days: u32) -> Result<()> { let cutoff = self.previous_day_cutoff(days)?.as_millis(); write!( self.sql, concat!( "((SELECT coalesce(min(id) > {cutoff}, false) FROM revlog WHERE cid = c.id ", // Exclude manual reschedulings "AND ease != 0) ", // Logically redundant, speeds up query "AND c.id IN (SELECT cid FROM revlog WHERE id > {cutoff}))" ), cutoff = cutoff, ) .unwrap(); Ok(()) } fn write_regex(&mut self, word: &str, no_combining: bool) -> Result<()> { let flds_expr = if no_combining { Cow::from(format!( "coalesce(process_text(n.flds, {}), n.flds)", ProcessTextFlags::NoCombining.bits() )) } else { Cow::from("n.flds") }; let word = if no_combining { without_combining(word) } else { std::borrow::Cow::Borrowed(word) }; self.args.push(format!(r"(?i){word}")); let arg_idx = self.args.len(); if let Some(field_indices_by_notetype) = self.included_fields_for_unqualified_regex()? { let notetype_clause = |ctx: &UnqualifiedRegexSearchContext| -> String { let clause = if ctx.fields_to_search.len() == ctx.total_fields_in_note { format!("{flds_expr} regexp ?{arg_idx}") } else { let indices = ctx.fields_to_search.iter().join(","); format!("regexp_fields(?{arg_idx}, {flds_expr}, {indices})") }; format!("(n.mid = {mid} and {clause})", mid = ctx.ntid) }; let all_notetype_clauses = field_indices_by_notetype .iter() .map(notetype_clause) .join(" or "); write!(self.sql, "({all_notetype_clauses})").unwrap(); } else { write!(self.sql, "{flds_expr} regexp ?{arg_idx}").unwrap(); } Ok(()) } fn write_word_boundary(&mut self, word: &str) -> Result<()> { let re = format!(r"\b{}\b", to_re(word)); self.write_regex( &re, self.col.get_config_bool(BoolKey::IgnoreAccentsInSearch), ) } fn write_deck_preset(&mut self, name: &str) -> Result<()> { let dcid = self.col.storage.get_deck_config_id_by_name(name)?; if dcid.is_none() { write!(self.sql, "false").unwrap(); return Ok(()); }; let mut str_ids = String::new(); let deck_ids = self .col .storage .get_all_decks()? .into_iter() .filter_map(|d| { if d.config_id() == dcid { Some(d.id) } else { None } }); ids_to_string(&mut str_ids, deck_ids); write!(self.sql, "(c.did in {str_ids} or c.odid in {str_ids})").unwrap(); Ok(()) } } #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum RequiredTable { Notes, Cards, CardsAndNotes, CardsOrNotes, } impl RequiredTable { fn combine(self, other: RequiredTable) -> RequiredTable { match (self, other) { (RequiredTable::CardsAndNotes, _) => RequiredTable::CardsAndNotes, (_, RequiredTable::CardsAndNotes) => RequiredTable::CardsAndNotes, (RequiredTable::CardsOrNotes, b) => b, (a, RequiredTable::CardsOrNotes) => a, (a, b) => { if a == b { a } else { RequiredTable::CardsAndNotes } } } } } /// Given a list of numbers, create one or more ranges, collapsing /// contiguous numbers. trait CollectRanges { type Item; fn collect_ranges(self, join: bool) -> Vec>; } impl< Idx: Copy + PartialOrd + std::ops::Add + From, I: IntoIterator, > CollectRanges for I { type Item = Idx; fn collect_ranges(self, join: bool) -> Vec> { let mut result = Vec::new(); let mut iter = self.into_iter(); let next = iter.next(); if next.is_none() { return result; } let mut start = next.unwrap(); let mut end = next.unwrap(); for i in iter { if join && i == end + 1.into() { end = end + 1.into(); } else { result.push(start..end + 1.into()); start = i; end = i; } } result.push(start..end + 1.into()); result } } struct FieldQualifiedSearchContext { ntid: NotetypeId, total_fields_in_note: usize, /// This may include more than one field in the case the user /// has searched with a wildcard, eg f*:foo. field_ranges_to_search: Vec>, } struct UnqualifiedSearchContext { ntid: NotetypeId, total_fields_in_note: usize, sortf_excluded: bool, field_ranges_to_search: Vec>, } struct UnqualifiedRegexSearchContext { ntid: NotetypeId, total_fields_in_note: usize, /// Unlike the other contexts, this contains each individual index /// instead of a list of ranges. fields_to_search: Vec, } impl Node { fn required_table(&self) -> RequiredTable { match self { Node::And => RequiredTable::CardsOrNotes, Node::Or => RequiredTable::CardsOrNotes, Node::Not(node) => node.required_table(), Node::Group(nodes) => nodes.iter().fold(RequiredTable::CardsOrNotes, |cur, node| { cur.combine(node.required_table()) }), Node::Search(node) => node.required_table(), } } } impl SearchNode { fn required_table(&self) -> RequiredTable { match self { SearchNode::AddedInDays(_) => RequiredTable::Cards, SearchNode::IntroducedInDays(_) => RequiredTable::Cards, SearchNode::Deck(_) => RequiredTable::Cards, SearchNode::DeckIdsWithoutChildren(_) => RequiredTable::Cards, SearchNode::DeckIdWithChildren(_) => RequiredTable::Cards, SearchNode::Rated { .. } => RequiredTable::Cards, SearchNode::State(_) => RequiredTable::Cards, SearchNode::Flag(_) => RequiredTable::Cards, SearchNode::CardIds(_) => RequiredTable::Cards, SearchNode::Property { .. } => RequiredTable::Cards, SearchNode::CustomData { .. } => RequiredTable::Cards, SearchNode::Preset(_) => RequiredTable::Cards, SearchNode::UnqualifiedText(_) => RequiredTable::Notes, SearchNode::SingleField { .. } => RequiredTable::Notes, SearchNode::Tag { .. } => RequiredTable::Notes, SearchNode::Duplicates { .. } => RequiredTable::Notes, SearchNode::Regex(_) => RequiredTable::Notes, SearchNode::NoCombining(_) => RequiredTable::Notes, SearchNode::StripClozes(_) => RequiredTable::Notes, SearchNode::WordBoundary(_) => RequiredTable::Notes, SearchNode::NotetypeId(_) => RequiredTable::Notes, SearchNode::Notetype(_) => RequiredTable::Notes, SearchNode::EditedInDays(_) => RequiredTable::Notes, SearchNode::NoteIds(_) => RequiredTable::CardsOrNotes, SearchNode::WholeCollection => RequiredTable::CardsOrNotes, SearchNode::CardTemplate(_) => RequiredTable::CardsAndNotes, } } } #[cfg(test)] mod test { use anki_io::write_file; use tempfile::tempdir; use super::super::parser::parse; use super::*; use crate::collection::Collection; use crate::collection::CollectionBuilder; // shortcut fn s(req: &mut Collection, search: &str) -> (String, Vec) { let node = Node::Group(parse(search).unwrap()); let mut writer = SqlWriter::new(req, ReturnItemType::Cards); writer.table = RequiredTable::Notes.combine(node.required_table()); writer.write_node_to_sql(&node).unwrap(); (writer.sql, writer.args) } #[test] fn sql() { // re-use the mediacheck .anki2 file for now use crate::media::check::test::MEDIACHECK_ANKI2; let dir = tempdir().unwrap(); let col_path = dir.path().join("col.anki2"); write_file(&col_path, MEDIACHECK_ANKI2).unwrap(); let mut col = CollectionBuilder::new(col_path).build().unwrap(); let ctx = &mut col; // unqualified search assert_eq!( s(ctx, "te*st"), ( "((n.sfld like ?1 escape '\\' or n.flds like ?1 escape '\\'))".into(), vec!["%te%st%".into()] ) ); assert_eq!(s(ctx, "te%st").1, vec![r"%te\%st%".to_string()]); // user should be able to escape wildcards assert_eq!(s(ctx, r"te\*s\_t").1, vec!["%te*s\\_t%".to_string()]); // field search assert_eq!( s(ctx, "front:te*st"), ( concat!( "(((n.mid = 1581236385344 and (n.flds like '' || ?1 || '\u{1f}%' escape '\\')) or ", "(n.mid = 1581236385345 and (n.flds like '' || ?1 || '\u{1f}%\u{1f}%' escape '\\')) or ", "(n.mid = 1581236385346 and (n.flds like '' || ?1 || '\u{1f}%' escape '\\')) or ", "(n.mid = 1581236385347 and (n.flds like '' || ?1 || '\u{1f}%' escape '\\'))))" ) .into(), vec!["te%st".into()] ) ); // field search with regex assert_eq!( s(ctx, "front:re:te.*st"), ( concat!( "(((n.mid = 1581236385344 and regexp_fields(?1, n.flds, 0)) or ", "(n.mid = 1581236385345 and regexp_fields(?1, n.flds, 0)) or ", "(n.mid = 1581236385346 and regexp_fields(?1, n.flds, 0)) or ", "(n.mid = 1581236385347 and regexp_fields(?1, n.flds, 0))))" ) .into(), vec!["(?i)te.*st".into()] ) ); // field search with no-combine assert_eq!( s(ctx, "front:nc:frânçais"), ( concat!( "(((n.mid = 1581236385344 and (coalesce(process_text(n.flds, 1), n.flds) like '' || ?1 || '\u{1f}%' escape '\\')) or ", "(n.mid = 1581236385345 and (coalesce(process_text(n.flds, 1), n.flds) like '' || ?1 || '\u{1f}%\u{1f}%' escape '\\')) or ", "(n.mid = 1581236385346 and (coalesce(process_text(n.flds, 1), n.flds) like '' || ?1 || '\u{1f}%' escape '\\')) or ", "(n.mid = 1581236385347 and (coalesce(process_text(n.flds, 1), n.flds) like '' || ?1 || '\u{1f}%' escape '\\'))))" ) .into(), vec!["francais".into()] ) ); // all field search assert_eq!( s(ctx, "*:te*st"), ( "(regexp_fields(?1, n.flds))".into(), vec!["(?is)^te.*st$".into()] ) ); // all field search with regex assert_eq!( s(ctx, "*:re:te.*st"), ( "(regexp_fields(?1, n.flds))".into(), vec!["(?i)te.*st".into()] ) ); // added let timing = ctx.timing_today().unwrap(); assert_eq!( s(ctx, "added:3").0, format!("(c.id > {})", (timing.next_day_at.0 - (86_400 * 3)) * 1_000) ); assert_eq!(s(ctx, "added:0").0, s(ctx, "added:1").0,); // introduced assert_eq!( s(ctx, "introduced:3").0, format!( concat!( "(((SELECT coalesce(min(id) > {cutoff}, false) FROM revlog WHERE cid = c.id AND ease != 0) ", "AND c.id IN (SELECT cid FROM revlog WHERE id > {cutoff})))" ), cutoff = (timing.next_day_at.0 - (86_400 * 3)) * 1_000, ) ); assert_eq!(s(ctx, "introduced:0").0, s(ctx, "introduced:1").0,); // deck assert_eq!( s(ctx, "deck:default"), ( "((c.did in (select id from decks where name regexp ?1) or (c.odid != 0 and \ c.odid in (select id from decks where name regexp ?1))))" .into(), vec!["(?i)^default($|\u{1f})".into()] ) ); assert_eq!( s(ctx, "deck:current").1, vec!["(?i)^Default($|\u{1f})".to_string()] ); assert_eq!(s(ctx, "deck:d*").1, vec!["(?i)^d.*($|\u{1f})".to_string()]); assert_eq!(s(ctx, "deck:filtered"), ("(c.odid != 0)".into(), vec![],)); // card assert_eq!( s(ctx, r#""card:card 1""#), ( "((n.mid,c.ord) in (select ntid,ord from templates where name = ?))".into(), vec!["card 1".into()] ) ); // IDs assert_eq!(s(ctx, "mid:3"), ("(n.mid = 3)".into(), vec![])); assert_eq!(s(ctx, "nid:3"), ("(n.id in (3))".into(), vec![])); assert_eq!(s(ctx, "nid:3,4"), ("(n.id in (3,4))".into(), vec![])); assert_eq!(s(ctx, "cid:3,4"), ("(c.id in (3,4))".into(), vec![])); // flags assert_eq!(s(ctx, "flag:2"), ("((c.flags & 7) == 2)".into(), vec![])); assert_eq!(s(ctx, "flag:0"), ("((c.flags & 7) == 0)".into(), vec![])); // dupes assert_eq!(s(ctx, "dupe:123,test"), ("(n.id in ())".into(), vec![])); // tags assert_eq!( s(ctx, r"tag:one"), ( "(n.tags regexp ?)".into(), vec!["(?i).* one(::| ).*".into()] ) ); assert_eq!( s(ctx, r"tag:foo::bar"), ( "(n.tags regexp ?)".into(), vec!["(?i).* foo::bar(::| ).*".into()] ) ); assert_eq!( s(ctx, r"tag:o*n\*et%w%oth_re\_e"), ( "(n.tags regexp ?)".into(), vec![r"(?i).* o\S*n\*et%w%oth\Sre_e(::| ).*".into()] ) ); assert_eq!(s(ctx, "tag:none"), ("(n.tags = '')".into(), vec![])); assert_eq!(s(ctx, "tag:*"), ("(true)".into(), vec![])); assert_eq!( s(ctx, "tag:re:.ne|tw."), ( "(regexp_tags(?1, n.tags))".into(), vec!["(?i).ne|tw.".into()] ) ); // state assert_eq!( s(ctx, "is:suspended").0, format!("(c.queue = {})", CardQueue::Suspended as i8) ); assert_eq!( s(ctx, "is:new").0, format!("(c.type = {})", CardType::New as i8) ); // rated assert_eq!( s(ctx, "rated:2").0, format!( "(c.id in (select cid from revlog where id >= {} and ease > 0))", (timing.next_day_at.0 - (86_400 * 2)) * 1_000 ) ); assert_eq!( s(ctx, "rated:400:1").0, format!( "(c.id in (select cid from revlog where id >= {} and ease = 1))", (timing.next_day_at.0 - (86_400 * 400)) * 1_000 ) ); assert_eq!(s(ctx, "rated:0").0, s(ctx, "rated:1").0); // resched assert_eq!( s(ctx, "resched:400").0, format!( "(c.id in (select cid from revlog where id >= {} and ease = 0))", (timing.next_day_at.0 - (86_400 * 400)) * 1_000 ) ); // props assert_eq!(s(ctx, "prop:lapses=3").0, "(lapses = 3)".to_string()); assert_eq!(s(ctx, "prop:ease>=2.5").0, "(factor >= 2500)".to_string()); assert_eq!( s(ctx, "prop:due!=-1").0, format!( "(((c.queue in (2,3) and \n (case when \ c.odue != 0 then c.odue else c.due end) != {days}) or (c.queue in (1,4) and (((case when c.odue != 0 then c.odue else c.due end) - {cutoff}) / 86400) != -1)))", days = timing.days_elapsed - 1, cutoff = timing.next_day_at ) ); assert_eq!(s(ctx, "prop:rated>-5:3").0, s(ctx, "rated:5:3").0); assert_eq!( &s(ctx, "prop:cdn:r=1").0, "(cast(extract_custom_data(c.data, 'r') as float) = 1)" ); assert_eq!( &s(ctx, "prop:cds:r=s").0, "(extract_custom_data(c.data, 'r') = 's')" ); // note types by name assert_eq!( s(ctx, "note:basic"), ( "(n.mid in (select id from notetypes where name = ?))".into(), vec!["basic".into()] ) ); assert_eq!( s(ctx, "note:basic*"), ( "(n.mid in (select id from notetypes where name regexp ?))".into(), vec!["(?i)^basic.*$".into()] ) ); // regex assert_eq!( s(ctx, r"re:\bone"), ("(n.flds regexp ?1)".into(), vec![r"(?i)\bone".into()]) ); // word boundary assert_eq!( s(ctx, r"w:foo"), ("(n.flds regexp ?1)".into(), vec![r"(?i)\bfoo\b".into()]) ); assert_eq!( s(ctx, r"w:*foo"), ("(n.flds regexp ?1)".into(), vec![r"(?i)\b.*foo\b".into()]) ); assert_eq!( s(ctx, r"w:*fo_o*"), ( "(n.flds regexp ?1)".into(), vec![r"(?i)\b.*fo.o.*\b".into()] ) ); // has-cd assert_eq!( &s(ctx, "has-cd:r").0, "(extract_custom_data(c.data, 'r') is not null)" ); // preset search assert_eq!( &s(ctx, "preset:default").0, "((c.did in (1) or c.odid in (1)))" ); assert_eq!(&s(ctx, "preset:typo").0, "(false)"); // strip clozes assert_eq!(&s(ctx, "sc:abcdef").0, "((n.mid = 1581236385343) and (coalesce(process_text(cast(n.sfld as text), 2), n.sfld) like ?1 escape '\\' or coalesce(process_text(n.flds, 2), n.flds) like ?1 escape '\\'))"); } #[test] fn required_table() { assert_eq!( Node::Group(parse("").unwrap()).required_table(), RequiredTable::CardsOrNotes ); assert_eq!( Node::Group(parse("test").unwrap()).required_table(), RequiredTable::Notes ); assert_eq!( Node::Group(parse("cid:1").unwrap()).required_table(), RequiredTable::Cards ); assert_eq!( Node::Group(parse("cid:1 test").unwrap()).required_table(), RequiredTable::CardsAndNotes ); assert_eq!( Node::Group(parse("nid:1").unwrap()).required_table(), RequiredTable::CardsOrNotes ); assert_eq!( Node::Group(parse("cid:1 nid:1").unwrap()).required_table(), RequiredTable::Cards ); assert_eq!( Node::Group(parse("test nid:1").unwrap()).required_table(), RequiredTable::Notes ); } #[allow(clippy::single_range_in_vec_init)] #[test] fn ranges() { assert_eq!([1, 2, 3].collect_ranges(true), [1..4]); assert_eq!([1, 3, 4].collect_ranges(true), [1..2, 3..5]); assert_eq!([1, 2, 5, 6].collect_ranges(false), [1..2, 2..3, 5..6, 6..7]); } } ================================================ FILE: rslib/src/search/template_order.sql ================================================ DROP TABLE IF EXISTS sort_order; CREATE TEMPORARY TABLE sort_order ( pos integer PRIMARY KEY, ntid integer NOT NULL, ord integer NOT NULL, UNIQUE(ntid, ord) ); INSERT INTO sort_order (ntid, ord) SELECT ntid, ord FROM templates ORDER BY name ================================================ FILE: rslib/src/search/writer.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::mem; use std::sync::LazyLock; use regex::Regex; use crate::notetype::NotetypeId as NotetypeIdType; use crate::prelude::*; use crate::search::parser::parse; use crate::search::parser::FieldSearchMode; use crate::search::parser::Node; use crate::search::parser::PropertyKind; use crate::search::parser::RatingKind; use crate::search::parser::SearchNode; use crate::search::parser::StateKind; use crate::search::parser::TemplateKind; use crate::text::escape_anki_wildcards; /// Given an existing parsed search, if the provided `replacement` is a single /// search node such as a deck:xxx search, replace any instances of that search /// in `existing` with the new value. Then return the possibly modified first /// search as a string. pub fn replace_search_node(mut existing: Vec, replacement: Node) -> String { if let Node::Search(search_node) = replacement { fn update_node_vec(old_nodes: &mut [Node], new_node: &SearchNode) { fn update_node(old_node: &mut Node, new_node: &SearchNode) { match old_node { Node::Not(n) => update_node(n, new_node), Node::Group(ns) => update_node_vec(ns, new_node), Node::Search(n) => { if mem::discriminant(n) == mem::discriminant(new_node) { *n = new_node.clone(); } } _ => (), } } old_nodes.iter_mut().for_each(|n| update_node(n, new_node)); } update_node_vec(&mut existing, &search_node); } write_nodes(&existing) } pub(super) fn write_nodes(nodes: &[Node]) -> String { nodes.iter().map(write_node).collect() } #[allow(clippy::to_string_trait_impl)] impl ToString for Node { fn to_string(&self) -> String { write_node(self) } } fn write_node(node: &Node) -> String { use Node::*; match node { And => " ".to_string(), Or => " OR ".to_string(), Not(n) => format!("-{}", write_node(n)), Group(ns) => format!("({})", write_nodes(ns)), Search(n) => write_search_node(n), } } fn write_search_node(node: &SearchNode) -> String { use SearchNode::*; match node { UnqualifiedText(s) => maybe_quote(&s.replace(':', "\\:")), SingleField { field, text, mode } => write_single_field(field, text, *mode), AddedInDays(u) => format!("added:{u}"), EditedInDays(u) => format!("edited:{u}"), IntroducedInDays(u) => format!("introduced:{u}"), CardTemplate(t) => write_template(t), Deck(s) => maybe_quote(&format!("deck:{s}")), DeckIdsWithoutChildren(s) => format!("did:{s}"), // not exposed on the GUI end DeckIdWithChildren(_) => "".to_string(), NotetypeId(NotetypeIdType(i)) => format!("mid:{i}"), Notetype(s) => maybe_quote(&format!("note:{s}")), Rated { days, ease } => write_rated(days, ease), Tag { tag, mode } => write_single_field("tag", tag, *mode), Duplicates { notetype_id, text } => write_dupe(notetype_id, text), State(k) => write_state(k), Flag(u) => format!("flag:{u}"), NoteIds(s) => format!("nid:{s}"), CardIds(s) => format!("cid:{s}"), Property { operator, kind } => write_property(operator, kind), WholeCollection => "deck:*".to_string(), Regex(s) => maybe_quote(&format!("re:{s}")), NoCombining(s) => maybe_quote(&format!("nc:{s}")), StripClozes(s) => maybe_quote(&format!("sc:{s}")), WordBoundary(s) => maybe_quote(&format!("w:{s}")), CustomData(k) => maybe_quote(&format!("has-cd:{k}")), Preset(s) => maybe_quote(&format!("preset:{s}")), } } /// Escape double quotes and wrap in double quotes if necessary. fn maybe_quote(txt: &str) -> String { if needs_quotation(txt) { format!("\"{}\"", txt.replace('\"', "\\\"")) } else { txt.replace('\"', "\\\"") } } /// Checks for the reserved keywords "and" and "or", a prepended hyphen, /// whitespace and brackets. fn needs_quotation(txt: &str) -> bool { static RE: LazyLock = LazyLock::new(|| Regex::new("(?i)^and$|^or$|^-.| |\u{3000}|\\(|\\)").unwrap()); RE.is_match(txt) } /// Also used by tag search, which has the same syntax. fn write_single_field(field: &str, text: &str, mode: FieldSearchMode) -> String { let prefix = match mode { FieldSearchMode::Normal => "", FieldSearchMode::Regex => "re:", FieldSearchMode::NoCombining => "nc:", }; let text = if mode == FieldSearchMode::Normal && (text.starts_with("re:") || text.starts_with("nc:")) { text.replacen(':', "\\:", 1) } else { text.to_string() }; maybe_quote(&format!( "{}:{}{}", field.replace(':', "\\:"), prefix, &text )) } fn write_template(template: &TemplateKind) -> String { match template { TemplateKind::Ordinal(u) => format!("card:{}", u + 1), TemplateKind::Name(s) => maybe_quote(&format!("card:{s}")), } } fn write_rated(days: &u32, ease: &RatingKind) -> String { use RatingKind::*; match ease { AnswerButton(n) => format!("rated:{days}:{n}"), AnyAnswerButton => format!("rated:{days}"), ManualReschedule => format!("resched:{days}"), } } /// Escape double quotes and backslashes: \" fn write_dupe(notetype_id: &NotetypeId, text: &str) -> String { let esc = text.replace('\\', r"\\"); maybe_quote(&format!("dupe:{notetype_id},{esc}")) } fn write_state(kind: &StateKind) -> String { use StateKind::*; format!( "is:{}", match kind { New => "new", Review => "review", Learning => "learn", Due => "due", Buried => "buried", UserBuried => "buried-manually", SchedBuried => "buried-sibling", Suspended => "suspended", } ) } fn write_property(operator: &str, kind: &PropertyKind) -> String { use PropertyKind::*; match kind { Due(i) => format!("prop:due{operator}{i}"), Interval(u) => format!("prop:ivl{operator}{u}"), Reps(u) => format!("prop:reps{operator}{u}"), Lapses(u) => format!("prop:lapses{operator}{u}"), Ease(f) => format!("prop:ease{operator}{f}"), Position(u) => format!("prop:pos{operator}{u}"), Stability(u) => format!("prop:s{operator}{u}"), Difficulty(u) => format!("prop:d{operator}{u}"), Retrievability(u) => format!("prop:r{operator}{u}"), Rated(u, ease) => match ease { RatingKind::AnswerButton(val) => format!("prop:rated{operator}{u}:{val}"), RatingKind::AnyAnswerButton => format!("prop:rated{operator}{u}"), RatingKind::ManualReschedule => format!("prop:resched{operator}{u}"), }, CustomDataNumber { key, value } => format!("prop:cdn:{key}{operator}{value}"), CustomDataString { key, value } => { maybe_quote(&format!("prop:cds:{key}{operator}{value}",)) } } } pub(crate) fn deck_search(name: &str) -> String { write_nodes(&[Node::Search(SearchNode::Deck(escape_anki_wildcards(name)))]) } /// Take an Anki-style search string and convert it into an equivalent /// search string with normalized syntax. pub(crate) fn normalize_search(input: &str) -> Result { Ok(write_nodes(&parse(input)?)) } #[cfg(test)] mod test { use super::*; use crate::error::Result; use crate::search::parse_search as parse; #[test] fn normalizing() { // remove redundant quotes assert_eq!( r#"foo "b a r""#, normalize_search(r#""foo" "b a r""#).unwrap() ); assert_eq!("field:foo", normalize_search(r#"field:"foo""#).unwrap()); // escape by quoting where possible assert_eq!(r#""(" ")""#, normalize_search(r"\( \)").unwrap()); assert_eq!(r#""-foo""#, normalize_search(r"\-foo").unwrap()); assert_eq!(r"\*\:\_", normalize_search(r"\*\:\_").unwrap()); // remove redundant escapes assert_eq!("deck::", normalize_search(r"deck:\:").unwrap()); assert_eq!("-", normalize_search(r"\-").unwrap()); assert_eq!("--", normalize_search(r"-\-").unwrap()); // ANDs are implicit, ORs in upper case assert_eq!("1 2 OR 3", normalize_search(r"1 and 2 or 3").unwrap()); assert_eq!(r#""f o o" bar"#, normalize_search(r#""f o o"bar"#).unwrap()); // AND and OR must be escaped regardless of case assert_eq!(r#""aNd" "oR""#, normalize_search(r#""aNd" "oR""#).unwrap()); // normalize numbers assert_eq!("prop:ease>1", normalize_search("prop:ease>1.0").unwrap()); } #[test] fn replacing() -> Result<()> { assert_eq!( replace_search_node(parse("deck:baz bar")?, parse("deck:foo")?.pop().unwrap()), "deck:foo bar", ); assert_eq!( replace_search_node( parse("tag:foo Or tag:bar")?, parse("tag:baz")?.pop().unwrap() ), "tag:baz OR tag:baz", ); assert_eq!( replace_search_node( parse("foo or (-foo tag:baz)")?, parse("bar")?.pop().unwrap() ), "bar OR (-bar tag:baz)", ); assert_eq!( replace_search_node(parse("is:due")?, parse("-is:new")?.pop().unwrap()), "is:due" ); assert_eq!( replace_search_node(parse("added:1")?, parse("is:due")?.pop().unwrap()), "added:1" ); Ok(()) } } ================================================ FILE: rslib/src/serde.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use serde::Deserialize as DeTrait; use serde::Deserializer; pub(crate) use serde_aux::field_attributes::deserialize_bool_from_anything; pub(crate) use serde_aux::field_attributes::deserialize_number_from_string; use serde_json::Value; use crate::timestamp::TimestampSecs; /// Note: if you wish to cover the case where a field is missing, make sure you /// also use the `serde(default)` flag. pub(crate) fn default_on_invalid<'de, T, D>(deserializer: D) -> Result where T: Default + DeTrait<'de>, D: Deserializer<'de>, { let v: Value = DeTrait::deserialize(deserializer)?; Ok(T::deserialize(v).unwrap_or_default()) } pub(crate) fn is_default(t: &T) -> bool { *t == Default::default() } pub(crate) fn deserialize_int_from_number<'de, T, D>(deserializer: D) -> Result where D: Deserializer<'de>, T: serde::Deserialize<'de> + FromI64, { #[derive(DeTrait)] #[serde(untagged)] enum IntOrFloat { Int(i64), Float(f64), } match IntOrFloat::deserialize(deserializer)? { IntOrFloat::Float(f) => Ok(T::from_i64(f as i64)), IntOrFloat::Int(i) => Ok(T::from_i64(i)), } } // It may be possible to use the num_traits crate instead in the future. pub(crate) trait FromI64 { fn from_i64(val: i64) -> Self; } impl FromI64 for i32 { fn from_i64(val: i64) -> Self { val as Self } } impl FromI64 for u32 { fn from_i64(val: i64) -> Self { val.max(0) as Self } } impl FromI64 for i64 { fn from_i64(val: i64) -> Self { val } } impl FromI64 for TimestampSecs { fn from_i64(val: i64) -> Self { TimestampSecs(val) } } #[cfg(test)] mod test { use serde::Deserialize; use super::*; #[derive(Deserialize, Debug, PartialEq, Eq)] struct MaybeInvalid { #[serde(deserialize_with = "default_on_invalid", default)] field: Option, } #[test] fn invalid_or_missing() { assert_eq!( serde_json::from_str::(r#"{"field": 5}"#).unwrap(), MaybeInvalid { field: Some(5) } ); assert_eq!( serde_json::from_str::(r#"{"field": "5"}"#).unwrap(), MaybeInvalid { field: None } ); assert_eq!( serde_json::from_str::(r#"{"another": 5}"#).unwrap(), MaybeInvalid { field: None } ); } } ================================================ FILE: rslib/src/services.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html #![allow(clippy::redundant_closure)] // Includes the automatically-generated *Service and Backend*Service traits, // and some impls on Backend and Collection. include!(concat!(env!("OUT_DIR"), "/backend.rs")); ================================================ FILE: rslib/src/stats/card.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use fsrs::FSRS; use fsrs::FSRS5_DEFAULT_DECAY; use crate::card::CardType; use crate::card::FsrsMemoryState; use crate::prelude::*; use crate::revlog::RevlogEntry; use crate::scheduler::fsrs::memory_state::fsrs_item_for_memory_state; use crate::scheduler::fsrs::params::ignore_revlogs_before_ms_from_config; use crate::scheduler::timing::is_unix_epoch_timestamp; impl Collection { pub fn card_stats(&mut self, cid: CardId) -> Result { let card = self.storage.get_card(cid)?.or_not_found(cid)?; let note = self .storage .get_note(card.note_id)? .or_not_found(card.note_id)?; let nt = self .get_notetype(note.notetype_id)? .or_not_found(note.notetype_id)?; let deck = self .storage .get_deck(card.deck_id)? .or_not_found(card.deck_id)?; let revlog = self.storage.get_revlog_entries_for_card(card.id)?; let (average_secs, total_secs) = average_and_total_secs_strings(&revlog); let timing = self.timing_today()?; let last_review_time = if let Some(last_review_time) = card.last_review_time { last_review_time } else { let mut new_card = card.clone(); let last_review_time = self .storage .time_of_last_review(card.id)? .unwrap_or_default(); new_card.last_review_time = Some(last_review_time); self.storage.update_card(&new_card)?; last_review_time }; let seconds_elapsed = timing.now.elapsed_secs_since(last_review_time) as u32; let fsrs_retrievability = card .memory_state .zip(Some(seconds_elapsed)) .zip(Some(card.decay.unwrap_or(FSRS5_DEFAULT_DECAY))) .map(|((state, seconds), decay)| { FSRS::new(None).unwrap().current_retrievability_seconds( state.into(), seconds, decay, ) }); let original_deck = if card.original_deck_id == DeckId(0) { deck.clone() } else { self.storage .get_deck(card.original_deck_id)? .or_not_found(card.original_deck_id)? }; let config_id = original_deck.config_id().unwrap(); let preset = self .get_deck_config(config_id, true)? .or_not_found(config_id.to_string())?; Ok(anki_proto::stats::CardStatsResponse { card_id: card.id.into(), note_id: card.note_id.into(), deck: deck.human_name(), added: card.id.as_secs().0, first_review: revlog .iter() .find(|entry| entry.has_rating()) .map(|entry| entry.id.as_secs().0), // last_review_time is not used to ensure cram revlogs are included. latest_review: revlog .iter() .rfind(|entry| entry.has_rating()) .map(|entry| entry.id.as_secs().0), due_date: self.due_date(&card)?, due_position: self.position(&card), interval: card.interval, ease: card.ease_factor as u32, reviews: card.reps, lapses: card.lapses, average_secs, total_secs, card_type: nt.get_template(card.template_idx)?.name.clone(), notetype: nt.name.clone(), revlog: self.stats_revlog_entries_with_memory_state(&card, revlog)?, memory_state: card.memory_state.map(Into::into), fsrs_retrievability, custom_data: card.custom_data, fsrs_params: preset.fsrs_params().to_vec(), preset: preset.name, original_deck: if original_deck != deck { Some(original_deck.human_name()) } else { None }, desired_retention: card.desired_retention, }) } pub fn get_review_logs(&mut self, cid: CardId) -> Result { let revlogs = self.storage.get_revlog_entries_for_card(cid)?; Ok(anki_proto::stats::ReviewLogs { entries: revlogs.iter().rev().map(stats_revlog_entry).collect(), }) } fn due_date(&mut self, card: &Card) -> Result> { Ok(match card.ctype { CardType::New => None, CardType::Review | CardType::Learn | CardType::Relearn => { let due = if card.original_due != 0 { card.original_due } else { card.due }; if !is_unix_epoch_timestamp(due) { let days_remaining = due - (self.timing_today()?.days_elapsed as i32); let mut due_timestamp = TimestampSecs::now(); due_timestamp.0 += (days_remaining as i64) * 86_400; Some(due_timestamp.0) } else { Some(due as i64) } } }) } fn position(&mut self, card: &Card) -> Option { if let Some(original_pos) = card.original_position { return Some(original_pos as i32); } match card.ctype { CardType::New => Some(card.due), _ => None, } } fn stats_revlog_entries_with_memory_state( self: &mut Collection, card: &Card, revlog: Vec, ) -> Result> { let deck_id = card.original_deck_id.or(card.deck_id); let deck = self.get_deck(deck_id)?.or_not_found(card.deck_id)?; let conf_id = DeckConfigId(deck.normal()?.config_id); let config = self .storage .get_deck_config(conf_id)? .or_not_found(conf_id)?; let historical_retention = config.inner.historical_retention; let fsrs = FSRS::new(Some(config.fsrs_params()))?; let next_day_at = self.timing_today()?.next_day_at; let ignore_before = ignore_revlogs_before_ms_from_config(&config)?; let mut result = Vec::new(); if let Some(item) = fsrs_item_for_memory_state( &fsrs, revlog.clone(), next_day_at, historical_retention, ignore_before, )? { let memory_states = fsrs.historical_memory_states(item.item, item.starting_state)?; let mut revlog_index = 0; for entry in revlog { let mut stats_entry = stats_revlog_entry(&entry); let memory_state: Option = if revlog_index >= memory_states.len() { // The removed revlog is in the end of the revlog, so we use the last memory // state Some(memory_states[memory_states.len() - 1].into()) } else if entry.id == item.filtered_revlogs[revlog_index].id { revlog_index += 1; Some(memory_states[revlog_index - 1].into()) } else if revlog_index == 0 { // The removed revlog is in the start of the revlog, so we don't have a memory // state for it None } else { // The removed revlog is in the middle of the revlog, so we use the memory // state for the previous revlog entry Some(memory_states[revlog_index].into()) }; stats_entry.memory_state = memory_state.map(|s| s.into()); result.push(stats_entry); } Ok(result.into_iter().rev().collect()) } else { Ok(revlog.iter().rev().map(stats_revlog_entry).collect()) } } } fn average_and_total_secs_strings(revlog: &[RevlogEntry]) -> (f32, f32) { let normal_answer_count = revlog.iter().filter(|r| r.has_rating()).count(); let total_secs: f32 = revlog .iter() .map(|entry| (entry.taken_millis as f32) / 1000.0) .sum(); if normal_answer_count == 0 || total_secs == 0.0 { (0.0, 0.0) } else { (total_secs / normal_answer_count as f32, total_secs) } } fn stats_revlog_entry( entry: &RevlogEntry, ) -> anki_proto::stats::card_stats_response::StatsRevlogEntry { anki_proto::stats::card_stats_response::StatsRevlogEntry { time: entry.id.as_secs().0, review_kind: entry.review_kind.into(), button_chosen: entry.button_chosen as u32, interval: entry.interval_secs(), ease: entry.ease_factor, taken_secs: entry.taken_millis as f32 / 1000., memory_state: None, last_interval: entry.last_interval_secs(), } } #[cfg(test)] mod test { use super::*; use crate::search::SortMode; #[test] fn stats() -> Result<()> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); col.add_note(&mut note, DeckId(1))?; let cid = col.search_cards("", SortMode::NoOrder)?[0]; let _report = col.card_stats(cid)?; //println!("report {}", report); Ok(()) } } ================================================ FILE: rslib/src/stats/graphs/added.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::stats::graphs_response::Added; use super::GraphsContext; impl GraphsContext { pub(super) fn added_days(&self) -> Added { let mut data = Added::default(); for card in &self.cards { // this could perhaps be simplified; it currently tries to match the old TS code // logic let day = ((card.id.as_secs().elapsed_secs_since(self.next_day_start) as f64) / 86_400.0) .ceil() as i32; *data.added.entry(day).or_insert_with(Default::default) += 1; } data } } ================================================ FILE: rslib/src/stats/graphs/buttons.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::stats::graphs_response::buttons::ButtonCounts; use anki_proto::stats::graphs_response::Buttons; use super::GraphsContext; use crate::revlog::RevlogEntry; use crate::revlog::RevlogReviewKind; impl GraphsContext { pub(super) fn buttons(&self) -> Buttons { let mut all_time = ButtonCounts { learning: vec![0; 4], young: vec![0; 4], mature: vec![0; 4], }; let mut conditional_buckets = vec![ ( self.next_day_start.adding_secs(-86_400 * 365), all_time.clone(), ), ( self.next_day_start.adding_secs(-86_400 * 90), all_time.clone(), ), ( self.next_day_start.adding_secs(-86_400 * 30), all_time.clone(), ), ]; 'outer: for review in &self.revlog { let Some(interval_bucket) = interval_bucket(review) else { continue; }; let Some(button_idx) = button_index(review.button_chosen) else { continue; }; let review_secs = review.id.as_secs(); increment_button_counts(&mut all_time, interval_bucket, button_idx); for (stamp, bucket) in &mut conditional_buckets { if &review_secs < stamp { continue 'outer; } increment_button_counts(bucket, interval_bucket, button_idx); } } Buttons { one_month: Some(conditional_buckets.pop().unwrap().1), three_months: Some(conditional_buckets.pop().unwrap().1), one_year: Some(conditional_buckets.pop().unwrap().1), all_time: Some(all_time), } } } #[derive(Clone, Copy)] enum IntervalBucket { Learning, Young, Mature, } fn increment_button_counts(counts: &mut ButtonCounts, bucket: IntervalBucket, button_idx: usize) { match bucket { IntervalBucket::Learning => counts.learning[button_idx] += 1, IntervalBucket::Young => counts.young[button_idx] += 1, IntervalBucket::Mature => counts.mature[button_idx] += 1, } } fn interval_bucket(review: &RevlogEntry) -> Option { match review.review_kind { RevlogReviewKind::Learning | RevlogReviewKind::Relearning | RevlogReviewKind::Filtered => { Some(IntervalBucket::Learning) } RevlogReviewKind::Review => Some(if review.last_interval < 21 { IntervalBucket::Young } else { IntervalBucket::Mature }), RevlogReviewKind::Manual | RevlogReviewKind::Rescheduled => None, } } fn button_index(button_chosen: u8) -> Option { if (1..=4).contains(&button_chosen) { Some((button_chosen - 1) as usize) } else { None } } ================================================ FILE: rslib/src/stats/graphs/card_counts.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::stats::graphs_response::card_counts::Counts; use anki_proto::stats::graphs_response::CardCounts; use crate::card::Card; use crate::card::CardQueue; use crate::card::CardType; use crate::stats::graphs::GraphsContext; impl GraphsContext { pub(super) fn card_counts(&self) -> CardCounts { let mut excluding_inactive = Counts::default(); let mut including_inactive = Counts::default(); for card in &self.cards { match card.queue { CardQueue::Suspended => { excluding_inactive.suspended += 1; } CardQueue::SchedBuried | CardQueue::UserBuried => { excluding_inactive.buried += 1; } _ => increment_counts(&mut excluding_inactive, card), }; increment_counts(&mut including_inactive, card); } CardCounts { excluding_inactive: Some(excluding_inactive), including_inactive: Some(including_inactive), } } } fn increment_counts(counts: &mut Counts, card: &Card) { match card.ctype { CardType::New => { counts.new_cards += 1; } CardType::Learn => { counts.learn += 1; } CardType::Review => { if card.interval < 21 { counts.young += 1; } else { counts.mature += 1; } } CardType::Relearn => { counts.relearn += 1; } } } ================================================ FILE: rslib/src/stats/graphs/eases.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::stats::graphs_response::Eases; use crate::card::CardType; use crate::stats::graphs::GraphsContext; impl GraphsContext { /// (SM-2, FSRS) pub(super) fn eases(&self) -> (Eases, Eases) { let mut eases = Eases::default(); let mut ease_values = Vec::new(); let mut difficulty = Eases::default(); let mut difficulty_values = Vec::new(); for card in &self.cards { if let Some(state) = card.memory_state { *difficulty .eases .entry(percent_to_bin(state.difficulty() * 100.0, 1)) .or_insert_with(Default::default) += 1; difficulty_values.push(state.difficulty()); } else if matches!(card.ctype, CardType::Review | CardType::Relearn) { *eases .eases .entry((card.ease_factor / 10) as u32) .or_insert_with(Default::default) += 1; ease_values.push(card.ease_factor as f32); } } eases.average = median(&mut ease_values) / 10.0; difficulty.average = median(&mut difficulty_values) * 100.0; (eases, difficulty) } } /// Helper function to calculate the median of a vector fn median(data: &mut [f32]) -> f32 { if data.is_empty() { return 0.0; } data.sort_by(|a, b| a.partial_cmp(b).unwrap()); let mid = data.len() / 2; if data.len() % 2 == 0 { (data[mid - 1] + data[mid]) / 2.0 } else { data[mid] } } /// Bins the number into a bin of 0, 5, .. 95 pub(super) fn percent_to_bin(x: f32, bin_size: u32) -> u32 { if x == 100.0 { 100 - bin_size } else { ((x / bin_size as f32).floor() * bin_size as f32) as u32 } } #[cfg(test)] mod tests { use super::*; #[test] fn bins() { assert_eq!(percent_to_bin(0.0, 5), 0); assert_eq!(percent_to_bin(4.9, 5), 0); assert_eq!(percent_to_bin(5.0, 5), 5); assert_eq!(percent_to_bin(9.9, 5), 5); assert_eq!(percent_to_bin(99.9, 5), 95); assert_eq!(percent_to_bin(100.0, 5), 95); } } ================================================ FILE: rslib/src/stats/graphs/future_due.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use anki_proto::stats::graphs_response::FutureDue; use super::GraphsContext; use crate::card::CardQueue; use crate::card::CardType; use crate::scheduler::timing::is_unix_epoch_timestamp; impl GraphsContext { pub(super) fn future_due(&self) -> FutureDue { let mut have_backlog = false; let mut due_by_day: HashMap = Default::default(); let mut daily_load = 0.0; for c in &self.cards { // matched on type because queue changes on burying or suspending a new card if c.ctype == CardType::New { continue; } if c.queue == CardQueue::Suspended { continue; } let due = c.original_or_current_due(); let due_day = if is_unix_epoch_timestamp(due) { let offset = due as i64 - self.next_day_start.0; (offset / 86_400) as i32 } else { due - (self.days_elapsed as i32) }; daily_load += 1.0 / c.interval.max(1) as f32; // still want to filtered out buried cards that are due today if due_day <= 0 && matches!(c.queue, CardQueue::UserBuried | CardQueue::SchedBuried) { continue; } have_backlog |= due_day < 0; *due_by_day.entry(due_day).or_default() += 1; } FutureDue { future_due: due_by_day, have_backlog, daily_load: daily_load as u32, } } } ================================================ FILE: rslib/src/stats/graphs/hours.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::stats::graphs_response::hours::Hour; use anki_proto::stats::graphs_response::Hours; use crate::revlog::RevlogReviewKind; use crate::stats::graphs::GraphsContext; impl GraphsContext { pub(super) fn hours(&self) -> Hours { let mut data = Hours { one_month: vec![Default::default(); 24], three_months: vec![Default::default(); 24], one_year: vec![Default::default(); 24], all_time: vec![Default::default(); 24], }; let mut conditional_buckets = [ ( self.next_day_start.adding_secs(-86_400 * 365), &mut data.one_year, ), ( self.next_day_start.adding_secs(-86_400 * 90), &mut data.three_months, ), ( self.next_day_start.adding_secs(-86_400 * 30), &mut data.one_month, ), ]; 'outer: for review in &self.revlog { if matches!( review.review_kind, RevlogReviewKind::Filtered | RevlogReviewKind::Manual | RevlogReviewKind::Rescheduled ) { continue; } let review_secs = review.id.as_secs(); let hour = (((review_secs.0 + self.local_offset_secs) / 3600) % 24) as usize; let correct = review.button_chosen > 1; increment_count_for_hour(&mut data.all_time[hour], correct); for (stamp, bucket) in &mut conditional_buckets { if &review_secs < stamp { continue 'outer; } increment_count_for_hour(&mut bucket[hour], correct); } } data } } pub(crate) fn increment_count_for_hour(hour: &mut Hour, correct: bool) { hour.total += 1; if correct { hour.correct += 1; } } ================================================ FILE: rslib/src/stats/graphs/intervals.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::stats::graphs_response::Intervals; use crate::card::CardType; use crate::stats::graphs::GraphsContext; impl GraphsContext { pub(super) fn intervals(&self) -> Intervals { let mut data = Intervals::default(); for card in &self.cards { if matches!(card.ctype, CardType::Review | CardType::Relearn) { *data .intervals .entry(card.interval) .or_insert_with(Default::default) += 1; } } data } pub(super) fn stability(&self) -> Intervals { let mut data = Intervals::default(); for card in &self.cards { if let Some(state) = &card.memory_state { *data .intervals .entry(state.stability.round() as u32) .or_insert_with(Default::default) += 1; } } data } } ================================================ FILE: rslib/src/stats/graphs/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod added; mod buttons; mod card_counts; mod eases; mod future_due; mod hours; mod intervals; mod retention; mod retrievability; mod reviews; mod today; use crate::config::BoolKey; use crate::config::Weekday; use crate::prelude::*; use crate::revlog::RevlogEntry; use crate::search::SortMode; struct GraphsContext { revlog: Vec, cards: Vec, next_day_start: TimestampSecs, days_elapsed: u32, local_offset_secs: i64, } impl Collection { pub(crate) fn graph_data_for_search( &mut self, search: &str, days: u32, ) -> Result { let guard = self.search_cards_into_table(search, SortMode::NoOrder)?; let all = search.trim().is_empty(); guard.col.graph_data(all, days) } fn graph_data(&mut self, all: bool, days: u32) -> Result { let timing = self.timing_today()?; let revlog_start = if days > 0 { timing .next_day_at .adding_secs(-(((days as i64) + 1) * 86_400)) } else { TimestampSecs(0) }; let offset = self.local_utc_offset_for_user()?; let local_offset_secs = offset.local_minus_utc() as i64; let revlog = if all { self.storage.get_all_revlog_entries(revlog_start)? } else { self.storage .get_revlog_entries_for_searched_cards_after_stamp(revlog_start)? }; let ctx = GraphsContext { revlog, days_elapsed: timing.days_elapsed, cards: self.storage.all_searched_cards()?, next_day_start: timing.next_day_at, local_offset_secs, }; let (eases, difficulty) = ctx.eases(); let resp = anki_proto::stats::GraphsResponse { added: Some(ctx.added_days()), reviews: Some(ctx.review_counts_and_times()), true_retention: Some(ctx.calculate_true_retention()), future_due: Some(ctx.future_due()), intervals: Some(ctx.intervals()), stability: Some(ctx.stability()), eases: Some(eases), difficulty: Some(difficulty), today: Some(ctx.today()), hours: Some(ctx.hours()), buttons: Some(ctx.buttons()), card_counts: Some(ctx.card_counts()), rollover_hour: self.rollover_for_current_scheduler()? as u32, retrievability: Some(ctx.retrievability()), fsrs: self.get_config_bool(BoolKey::Fsrs), }; Ok(resp) } pub(crate) fn get_graph_preferences(&self) -> anki_proto::stats::GraphPreferences { anki_proto::stats::GraphPreferences { calendar_first_day_of_week: self.get_first_day_of_week() as i32, card_counts_separate_inactive: self .get_config_bool(BoolKey::CardCountsSeparateInactive), browser_links_supported: true, future_due_show_backlog: self.get_config_bool(BoolKey::FutureDueShowBacklog), } } pub(crate) fn set_graph_preferences( &mut self, prefs: anki_proto::stats::GraphPreferences, ) -> Result<()> { self.set_first_day_of_week(match prefs.calendar_first_day_of_week { 1 => Weekday::Monday, 5 => Weekday::Friday, 6 => Weekday::Saturday, _ => Weekday::Sunday, })?; self.set_config_bool_inner( BoolKey::CardCountsSeparateInactive, prefs.card_counts_separate_inactive, )?; self.set_config_bool_inner(BoolKey::FutureDueShowBacklog, prefs.future_due_show_backlog)?; Ok(()) } } ================================================ FILE: rslib/src/stats/graphs/retention.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use anki_proto::stats::graphs_response::true_retention_stats::TrueRetention; use anki_proto::stats::graphs_response::TrueRetentionStats; use super::GraphsContext; use super::TimestampSecs; use crate::revlog::RevlogReviewKind; impl GraphsContext { pub fn calculate_true_retention(&self) -> TrueRetentionStats { let mut stats = TrueRetentionStats::default(); // create periods let day = 86400; let periods = vec![ ( "today", self.next_day_start.adding_secs(-day), self.next_day_start, ), ( "yesterday", self.next_day_start.adding_secs(-2 * day), self.next_day_start.adding_secs(-day), ), ( "week", self.next_day_start.adding_secs(-7 * day), self.next_day_start, ), ( "month", self.next_day_start.adding_secs(-30 * day), self.next_day_start, ), ( "year", self.next_day_start.adding_secs(-365 * day), self.next_day_start, ), ("all_time", TimestampSecs(0), self.next_day_start), ]; // create period stats let mut period_stats: HashMap<&str, TrueRetention> = periods .iter() .map(|(name, _, _)| (*name, TrueRetention::default())) .collect(); self.revlog .iter() .filter(|review| { review.has_rating_and_affects_scheduling() // cards with an interval ≥ 1 day && (review.review_kind == RevlogReviewKind::Review || review.last_interval <= -86400 || review.last_interval >= 1) }) .for_each(|review| { for (period_name, start, end) in &periods { if review.id.as_secs() >= *start && review.id.as_secs() < *end { let period_stat = period_stats.get_mut(period_name).unwrap(); const MATURE_IVL: i32 = 21; // mature interval is 21 days match (review.last_interval < MATURE_IVL, review.button_chosen) { (true, 1) => period_stat.young_failed += 1, (true, _) => period_stat.young_passed += 1, (false, 1) => period_stat.mature_failed += 1, (false, _) => period_stat.mature_passed += 1, } } } }); stats.today = Some(period_stats["today"]); stats.yesterday = Some(period_stats["yesterday"]); stats.week = Some(period_stats["week"]); stats.month = Some(period_stats["month"]); stats.year = Some(period_stats["year"]); stats.all_time = Some(period_stats["all_time"]); stats } } ================================================ FILE: rslib/src/stats/graphs/retrievability.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::stats::graphs_response::Retrievability; use fsrs::FSRS; use fsrs::FSRS5_DEFAULT_DECAY; use crate::prelude::TimestampSecs; use crate::scheduler::timing::SchedTimingToday; use crate::stats::graphs::eases::percent_to_bin; use crate::stats::graphs::GraphsContext; impl GraphsContext { /// (SM-2, FSRS) pub(super) fn retrievability(&self) -> Retrievability { let mut retrievability = Retrievability::default(); let mut card_with_retrievability_count: usize = 0; let timing = SchedTimingToday { days_elapsed: self.days_elapsed, now: TimestampSecs::now(), next_day_at: self.next_day_start, }; let fsrs = FSRS::new(None).unwrap(); // note id -> (sum, count) let mut note_retrievability: std::collections::HashMap = std::collections::HashMap::new(); for card in &self.cards { let entry = note_retrievability .entry(card.note_id.0) .or_insert((0.0, 0)); entry.1 += 1; if let Some(state) = card.memory_state { let elapsed_seconds = card.seconds_since_last_review(&timing).unwrap_or_default(); let r = fsrs.current_retrievability_seconds( state.into(), elapsed_seconds, card.decay.unwrap_or(FSRS5_DEFAULT_DECAY), ); *retrievability .retrievability .entry(percent_to_bin(r * 100.0, 1)) .or_insert_with(Default::default) += 1; retrievability.sum_by_card += r; card_with_retrievability_count += 1; entry.0 += r; } else { entry.0 += 0.0; } } if card_with_retrievability_count != 0 { retrievability.average = retrievability.sum_by_card * 100.0 / card_with_retrievability_count as f32; } retrievability.sum_by_note = note_retrievability .values() .map(|(sum, count)| sum / *count as f32) .sum(); retrievability } } ================================================ FILE: rslib/src/stats/graphs/reviews.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::stats::graphs_response::ReviewCountsAndTimes; use super::GraphsContext; use crate::revlog::RevlogReviewKind; impl GraphsContext { pub(super) fn review_counts_and_times(&self) -> ReviewCountsAndTimes { let mut data = ReviewCountsAndTimes::default(); for review in &self.revlog { if review.review_kind == RevlogReviewKind::Manual || review.review_kind == RevlogReviewKind::Rescheduled { continue; } let day = (review.id.as_secs().elapsed_secs_since(self.next_day_start) / 86_400) as i32; let count = data.count.entry(day).or_insert_with(Default::default); let time = data.time.entry(day).or_insert_with(Default::default); match review.review_kind { RevlogReviewKind::Learning => { count.learn += 1; time.learn += review.taken_millis; } RevlogReviewKind::Relearning => { count.relearn += 1; time.relearn += review.taken_millis; } RevlogReviewKind::Review => { if review.last_interval < 21 { count.young += 1; time.young += review.taken_millis; } else { count.mature += 1; time.mature += review.taken_millis; } } RevlogReviewKind::Filtered => { count.filtered += 1; time.filtered += review.taken_millis; } RevlogReviewKind::Manual | RevlogReviewKind::Rescheduled => unreachable!(), } } data } } ================================================ FILE: rslib/src/stats/graphs/today.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::stats::graphs_response::Today; use crate::revlog::RevlogReviewKind; use crate::stats::graphs::GraphsContext; impl GraphsContext { pub(super) fn today(&self) -> Today { let mut today = Today::default(); let start_of_today_ms = self.next_day_start.adding_secs(-86_400).as_millis().0; for review in self.revlog.iter().rev() { if review.id.0 < start_of_today_ms { continue; } if review.review_kind == RevlogReviewKind::Manual || review.review_kind == RevlogReviewKind::Rescheduled { continue; } // total today.answer_count += 1; today.answer_millis += review.taken_millis; // correct if review.button_chosen > 1 { today.correct_count += 1; } // mature if review.last_interval >= 21 { today.mature_count += 1; if review.button_chosen > 1 { today.mature_correct += 1; } } // type counts match review.review_kind { RevlogReviewKind::Learning => today.learn_count += 1, RevlogReviewKind::Review => today.review_count += 1, RevlogReviewKind::Relearning => today.relearn_count += 1, RevlogReviewKind::Filtered => today.early_review_count += 1, RevlogReviewKind::Manual | RevlogReviewKind::Rescheduled => unreachable!(), } } today } } ================================================ FILE: rslib/src/stats/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod card; mod graphs; mod service; mod today; pub use today::studied_today; ================================================ FILE: rslib/src/stats/service.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::collection::Collection; use crate::error; use crate::revlog::RevlogReviewKind; impl crate::services::StatsService for Collection { fn card_stats( &mut self, input: anki_proto::cards::CardId, ) -> error::Result { self.card_stats(input.cid.into()) } fn get_review_logs( &mut self, input: anki_proto::cards::CardId, ) -> error::Result { self.get_review_logs(input.cid.into()) } fn graphs( &mut self, input: anki_proto::stats::GraphsRequest, ) -> error::Result { self.graph_data_for_search(&input.search, input.days) } fn get_graph_preferences(&mut self) -> error::Result { Ok(Collection::get_graph_preferences(self)) } fn set_graph_preferences( &mut self, input: anki_proto::stats::GraphPreferences, ) -> error::Result<()> { self.set_graph_preferences(input) } } impl From for i32 { fn from(kind: RevlogReviewKind) -> Self { (match kind { RevlogReviewKind::Learning => anki_proto::stats::revlog_entry::ReviewKind::Learning, RevlogReviewKind::Review => anki_proto::stats::revlog_entry::ReviewKind::Review, RevlogReviewKind::Relearning => anki_proto::stats::revlog_entry::ReviewKind::Relearning, RevlogReviewKind::Filtered => anki_proto::stats::revlog_entry::ReviewKind::Filtered, RevlogReviewKind::Manual => anki_proto::stats::revlog_entry::ReviewKind::Manual, RevlogReviewKind::Rescheduled => { anki_proto::stats::revlog_entry::ReviewKind::Rescheduled } }) as i32 } } ================================================ FILE: rslib/src/stats/today.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_i18n::I18n; use crate::prelude::*; use crate::scheduler::timespan::Timespan; use crate::scheduler::timespan::TimespanUnit; pub fn studied_today(cards: u32, secs: f32, tr: &I18n) -> String { let span = Timespan::from_secs(secs).natural_span(); let unit = std::cmp::min(span.unit(), TimespanUnit::Minutes); let amount = span.to_unit(unit).as_unit(); let secs_per_card = if cards > 0 { secs / (cards as f32) } else { 0.0 }; tr.statistics_studied_today(unit.as_str(), secs_per_card, amount, cards) .into() } impl Collection { pub fn studied_today(&mut self) -> Result { let timing = self.timing_today()?; let today = self.storage.studied_today(timing.next_day_at)?; Ok(studied_today(today.cards, today.seconds as f32, &self.tr)) } } #[cfg(test)] mod test { use anki_i18n::I18n; use super::studied_today; #[test] fn today() { // temporary test of fluent term handling let tr = I18n::template_only(); assert_eq!( &studied_today(3, 13.0, &tr).replace('\n', " "), "Studied 3 cards in 13 seconds today (4.33s/card)" ); assert_eq!( &studied_today(300, 5400.0, &tr).replace('\n', " "), "Studied 300 cards in 90 minutes today (18s/card)" ); } } ================================================ FILE: rslib/src/storage/card/active_new_cards.sql ================================================ SELECT id, nid, ord, cast(mod AS integer), did, odid FROM cards WHERE did IN ( SELECT id FROM active_decks ) AND queue = 0 ================================================ FILE: rslib/src/storage/card/add_card.sql ================================================ INSERT INTO cards ( id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data ) VALUES ( ( CASE WHEN ?1 IN ( SELECT id FROM cards ) THEN ( SELECT max(id) + 1 FROM cards ) ELSE ?1 END ), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) ================================================ FILE: rslib/src/storage/card/add_card_if_unique.sql ================================================ INSERT OR IGNORE INTO cards ( id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) ================================================ FILE: rslib/src/storage/card/add_or_update.sql ================================================ INSERT OR REPLACE INTO cards ( id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) ================================================ FILE: rslib/src/storage/card/at_or_above_position.sql ================================================ INSERT INTO search_cids SELECT id FROM cards WHERE due >= ? AND type = ? ================================================ FILE: rslib/src/storage/card/congrats.sql ================================================ SELECT coalesce( sum( queue IN (:review_queue, :day_learn_queue) AND due <= :today ), 0 ) AS review_count, coalesce(sum(queue = :new_queue), 0) AS new_count, coalesce(sum(queue = :sched_buried_queue), 0) AS sched_buried, coalesce(sum(queue = :user_buried_queue), 0) AS user_buried, coalesce(sum(queue = :learn_queue), 0) AS learn_count, max( 0, coalesce( min( CASE WHEN queue = :learn_queue THEN due ELSE NULL END ), 0 ) ) AS first_learn_due FROM cards WHERE did IN ( SELECT id FROM active_decks ) ================================================ FILE: rslib/src/storage/card/data.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use rusqlite::types::FromSql; use rusqlite::types::FromSqlError; use rusqlite::types::ValueRef; use serde::Deserialize; use serde::Serialize; use serde_json::Value; use crate::card::FsrsMemoryState; use crate::prelude::*; use crate::serde::default_on_invalid; /// Helper for serdeing the card data column. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] #[serde(default)] pub(crate) struct CardData { #[serde( rename = "pos", skip_serializing_if = "Option::is_none", deserialize_with = "default_on_invalid" )] pub(crate) original_position: Option, #[serde( rename = "s", skip_serializing_if = "Option::is_none", deserialize_with = "default_on_invalid" )] pub(crate) fsrs_stability: Option, #[serde( rename = "d", skip_serializing_if = "Option::is_none", deserialize_with = "default_on_invalid" )] pub(crate) fsrs_difficulty: Option, #[serde( rename = "dr", skip_serializing_if = "Option::is_none", deserialize_with = "default_on_invalid" )] pub(crate) fsrs_desired_retention: Option, #[serde( skip_serializing_if = "Option::is_none", deserialize_with = "default_on_invalid" )] pub(crate) decay: Option, #[serde( rename = "lrt", skip_serializing_if = "Option::is_none", deserialize_with = "default_on_invalid" )] pub(crate) last_review_time: Option, /// A string representation of a JSON object storing optional data /// associated with the card, so v3 custom scheduling code can persist /// state. #[serde(default, rename = "cd", skip_serializing_if = "meta_is_empty")] pub(crate) custom_data: String, } impl CardData { pub(crate) fn from_card(card: &Card) -> Self { Self { original_position: card.original_position, fsrs_stability: card.memory_state.as_ref().map(|m| m.stability), fsrs_difficulty: card.memory_state.as_ref().map(|m| m.difficulty), fsrs_desired_retention: card.desired_retention, decay: card.decay, last_review_time: card.last_review_time, custom_data: card.custom_data.clone(), } } pub(crate) fn from_str(s: &str) -> Self { serde_json::from_str(s).unwrap_or_default() } pub(crate) fn memory_state(&self) -> Option { if let Some(stability) = self.fsrs_stability { if let Some(difficulty) = self.fsrs_difficulty { return Some(FsrsMemoryState { stability, difficulty, }); } } None } pub(crate) fn convert_to_json(&mut self) -> Result { if let Some(v) = &mut self.fsrs_stability { round_to_places(v, 4) } if let Some(v) = &mut self.fsrs_difficulty { round_to_places(v, 3) } if let Some(v) = &mut self.fsrs_desired_retention { round_to_places(v, 2) } if let Some(v) = &mut self.decay { round_to_places(v, 3) } serde_json::to_string(&self).map_err(Into::into) } } fn round_to_places(value: &mut f32, decimal_places: u32) { let factor = 10_f32.powi(decimal_places as i32); *value = (*value * factor).round() / factor; } impl FromSql for CardData { /// Infallible; invalid/missing data results in the default value. fn column_result(value: ValueRef<'_>) -> std::result::Result { if let ValueRef::Text(s) = value { Ok(serde_json::from_slice(s).unwrap_or_default()) } else { Ok(Self::default()) } } } /// Serialize the JSON `data` for a card. pub(crate) fn card_data_string(card: &Card) -> String { CardData::from_card(card).convert_to_json().unwrap() } fn meta_is_empty(s: &str) -> bool { matches!(s, "" | "{}") } fn validate_custom_data(json_str: &str) -> Result<()> { if !meta_is_empty(json_str) { let object: HashMap<&str, Value> = serde_json::from_str(json_str).or_invalid("custom data not an object")?; require!( object.keys().all(|k| k.len() <= 8), "custom data keys must be <= 8 bytes" ); require!( json_str.len() <= 100, "serialized custom data must be under 100 bytes" ); } Ok(()) } impl Card { pub(crate) fn validate_custom_data(&self) -> Result<()> { validate_custom_data(&self.custom_data) } } #[cfg(test)] mod test { use super::*; #[test] fn validation() { assert!(validate_custom_data("").is_ok()); assert!(validate_custom_data("{}").is_ok()); assert!(validate_custom_data(r#"{"foo": 5}"#).is_ok()); assert!(validate_custom_data(r#"["foo"]"#).is_err()); assert!(validate_custom_data(r#"{"日": 5}"#).is_ok()); assert!(validate_custom_data(r#"{"日本語": 5}"#).is_err()); assert!(validate_custom_data(&format!(r#"{{"foo": "{}"}}"#, "x".repeat(100))).is_err()); } #[test] fn compact_floats() { let mut data = CardData { original_position: None, fsrs_stability: Some(123.45678), fsrs_difficulty: Some(1.234567), fsrs_desired_retention: Some(0.987654), decay: Some(0.123456), last_review_time: None, custom_data: "".to_string(), }; assert_eq!( data.convert_to_json().unwrap(), r#"{"s":123.4568,"d":1.235,"dr":0.99,"decay":0.123}"# ); } } ================================================ FILE: rslib/src/storage/card/deck_due_counts.sql ================================================ SELECT CASE WHEN odid == 0 THEN did ELSE odid END AS original_did, CASE WHEN odid == 0 THEN due ELSE odue END AS true_due, COUNT() AS COUNT FROM cards WHERE type = 2 AND queue != -1 GROUP BY original_did, true_due ================================================ FILE: rslib/src/storage/card/due_cards.sql ================================================ SELECT id, nid, due, cast(ivl AS integer), cast(mod AS integer), did, odid FROM cards WHERE did IN ( SELECT id FROM active_decks ) AND ( queue = ? AND due <= ? ) ================================================ FILE: rslib/src/storage/card/filtered.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::decks::FilteredSearchOrder; use crate::decks::FilteredSearchTerm; use crate::scheduler::timing::SchedTimingToday; use crate::storage::sqlite::SqlSortOrder; pub(crate) fn order_and_limit_for_search( term: &FilteredSearchTerm, timing: SchedTimingToday, fsrs: bool, ) -> String { let temp_string; let today = timing.days_elapsed; let next_day_at = timing.next_day_at.0; let now = timing.now.0; let order = match term.order() { FilteredSearchOrder::OldestReviewedFirst => "(select max(id) from revlog where cid=c.id)", FilteredSearchOrder::Random => "random()", FilteredSearchOrder::IntervalsAscending => "ivl", FilteredSearchOrder::IntervalsDescending => "ivl desc", FilteredSearchOrder::Lapses => "lapses desc", FilteredSearchOrder::Added => "n.id, c.ord", FilteredSearchOrder::ReverseAdded => "n.id desc, c.ord asc", FilteredSearchOrder::Due => { let current_timestamp = timing.now.0; temp_string = format!( "(case when c.due > 1000000000 then due else (due - {today}) * 86400 + {current_timestamp} end), c.ord"); &temp_string } FilteredSearchOrder::RetrievabilityAscending => { temp_string = build_retrievability_query(fsrs, today, next_day_at, now, SqlSortOrder::Ascending); &temp_string } FilteredSearchOrder::RetrievabilityDescending => { temp_string = build_retrievability_query(fsrs, today, next_day_at, now, SqlSortOrder::Descending); &temp_string } FilteredSearchOrder::RelativeOverdueness => { temp_string = format!("extract_fsrs_relative_retrievability(data, case when odue !=0 then odue else due end, ivl, {today}, {next_day_at}, {now}) asc"); &temp_string } }; format!("{}, fnvhash(c.id, c.mod) limit {}", order, term.limit) } fn build_retrievability_query( fsrs: bool, today: u32, next_day_at: i64, now: i64, order: SqlSortOrder, ) -> String { if fsrs { format!( "extract_fsrs_retrievability(c.data, case when c.odue !=0 then c.odue else c.due end, ivl, {today}, {next_day_at}, {now}) {order}" ) } else { String::new() } } ================================================ FILE: rslib/src/storage/card/fix_due_new.sql ================================================ UPDATE cards SET due = ( CASE WHEN type = 0 AND queue != 4 THEN 1000000 + due % 1000000 ELSE due END ), mod = ?1, usn = ?2 WHERE due != ( CASE WHEN type = 0 AND queue != 4 THEN 1000000 + due % 1000000 ELSE due END ) AND due >= 1000000; ================================================ FILE: rslib/src/storage/card/fix_due_other.sql ================================================ UPDATE cards SET due = ( CASE WHEN queue = 2 AND due > 100000 THEN ?1 ELSE min(max(round(due), -2147483648), 2147483647) END ), mod = ?2, usn = ?3 WHERE due != ( CASE WHEN queue = 2 AND due > 100000 THEN ?1 ELSE min(max(round(due), -2147483648), 2147483647) END ); ================================================ FILE: rslib/src/storage/card/fix_ivl.sql ================================================ UPDATE cards SET ivl = min(max(round(ivl), 0), 2147483647), mod = ?1, usn = ?2 WHERE ivl != min(max(round(ivl), 0), 2147483647) ================================================ FILE: rslib/src/storage/card/fix_low_ease.sql ================================================ UPDATE cards SET factor = 2500, usn = ? WHERE factor != 0 AND factor <= 2000 AND ( did IN DECK_IDS OR odid IN DECK_IDS ) ================================================ FILE: rslib/src/storage/card/fix_mod.sql ================================================ UPDATE cards SET mod = cast(mod AS integer) WHERE mod != cast(mod AS integer) ================================================ FILE: rslib/src/storage/card/fix_odue.sql ================================================ UPDATE cards SET odue = ( CASE WHEN odue > 0 AND ( type = 1 OR queue = 2 ) AND NOT ?3 AND NOT odid THEN 0 ELSE min(max(round(odue), -2147483648), 2147483647) END ), mod = ?1, usn = ?2 WHERE odue != ( CASE WHEN odue > 0 AND ( type = 1 OR queue = 2 ) AND NOT ?3 AND NOT odid THEN 0 ELSE min(max(round(odue), -2147483648), 2147483647) END ); ================================================ FILE: rslib/src/storage/card/fix_ordinal.sql ================================================ UPDATE cards SET ord = max(0, min(30000, ord)), mod = ?1, usn = ?2 WHERE ord != max(0, min(30000, ord)) ================================================ FILE: rslib/src/storage/card/get_card.sql ================================================ SELECT id, nid, did, ord, cast(mod AS integer), usn, type, queue, due, cast(ivl AS integer), factor, reps, lapses, left, odue, odid, flags, data FROM cards ================================================ FILE: rslib/src/storage/card/get_card_entry.sql ================================================ SELECT id, nid, CASE WHEN odid = 0 THEN did ELSE odid END AS did FROM cards; ================================================ FILE: rslib/src/storage/card/get_ignored_before_count.sql ================================================ SELECT COUNT(DISTINCT cid) FROM revlog WHERE id > ? AND type == 0 AND cid IN search_cids ================================================ FILE: rslib/src/storage/card/intraday_due.sql ================================================ SELECT id, nid, due, cast(mod AS integer), did, odid FROM cards WHERE did IN ( SELECT id FROM active_decks ) AND ( queue IN (1, 4) AND due <= ? ) ================================================ FILE: rslib/src/storage/card/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub(crate) mod data; pub(crate) mod filtered; use std::collections::HashSet; use std::convert::TryFrom; use std::fmt; use std::result; use anki_proto::stats::CardEntry; use rusqlite::named_params; use rusqlite::params; use rusqlite::types::FromSql; use rusqlite::types::FromSqlError; use rusqlite::types::ValueRef; use rusqlite::OptionalExtension; use rusqlite::Row; use self::data::CardData; use super::ids_to_string; use super::sqlite::SqlSortOrder; use crate::card::Card; use crate::card::CardId; use crate::card::CardQueue; use crate::card::CardType; use crate::deckconfig::DeckConfigId; use crate::deckconfig::ReviewCardOrder; use crate::decks::Deck; use crate::decks::DeckId; use crate::decks::DeckKind; use crate::error::Result; use crate::notes::NoteId; use crate::scheduler::congrats::CongratsInfo; use crate::scheduler::fsrs::memory_state::get_last_revlog_info; use crate::scheduler::queue::BuryMode; use crate::scheduler::queue::DueCard; use crate::scheduler::queue::DueCardKind; use crate::scheduler::queue::NewCard; use crate::scheduler::timing::SchedTimingToday; use crate::timestamp::TimestampMillis; use crate::timestamp::TimestampSecs; use crate::types::Usn; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct CardFixStats { pub new_cards_fixed: usize, pub other_cards_fixed: usize, pub last_review_time_fixed: usize, } impl FromSql for CardType { fn column_result(value: ValueRef<'_>) -> result::Result { if let ValueRef::Integer(i) = value { Ok(Self::try_from(i as u8).map_err(|_| FromSqlError::InvalidType)?) } else { Err(FromSqlError::InvalidType) } } } impl FromSql for CardQueue { fn column_result(value: ValueRef<'_>) -> result::Result { if let ValueRef::Integer(i) = value { Ok(Self::try_from(i as i8).map_err(|_| FromSqlError::InvalidType)?) } else { Err(FromSqlError::InvalidType) } } } fn row_to_card(row: &Row) -> result::Result { let data: CardData = row.get(17)?; Ok(Card { id: row.get(0)?, note_id: row.get(1)?, deck_id: row.get(2)?, template_idx: row.get(3)?, mtime: row.get(4)?, usn: row.get(5)?, ctype: row.get(6)?, queue: row.get(7)?, due: row.get(8).ok().unwrap_or_default(), interval: row.get(9)?, ease_factor: row.get(10)?, reps: row.get(11)?, lapses: row.get(12)?, remaining_steps: row.get(13)?, original_due: row.get(14).ok().unwrap_or_default(), original_deck_id: row.get(15)?, flags: row.get(16)?, original_position: data.original_position, memory_state: data.memory_state(), desired_retention: data.fsrs_desired_retention, decay: data.decay, last_review_time: data.last_review_time, custom_data: data.custom_data, }) } fn row_to_card_entry(row: &Row) -> Result { Ok(CardEntry { id: row.get(0)?, note_id: row.get(1)?, deck_id: row.get(2)?, }) } fn row_to_new_card(row: &Row) -> result::Result { Ok(NewCard { id: row.get(0)?, note_id: row.get(1)?, template_index: row.get(2)?, mtime: row.get(3)?, current_deck_id: row.get(4)?, original_deck_id: row.get(5)?, hash: 0, }) } impl super::SqliteStorage { pub fn get_card(&self, cid: CardId) -> Result> { self.db .prepare_cached(concat!(include_str!("get_card.sql"), " where id = ?"))? .query_row(params![cid], row_to_card) .optional() .map_err(Into::into) } pub(crate) fn get_all_card_entries(&self) -> Result> { self.db .prepare_cached(include_str!("get_card_entry.sql"))? .query_and_then([], row_to_card_entry)? .collect() } pub(crate) fn update_card(&self, card: &Card) -> Result<()> { let mut stmt = self.db.prepare_cached(include_str!("update_card.sql"))?; stmt.execute(params![ card.note_id, card.deck_id, card.template_idx, card.mtime, card.usn, card.ctype as u8, card.queue as i8, card.due, card.interval, card.ease_factor, card.reps, card.lapses, card.remaining_steps, card.original_due, card.original_deck_id, card.flags, CardData::from_card(card).convert_to_json()?, card.id, ])?; Ok(()) } pub(crate) fn add_card(&self, card: &mut Card) -> Result<()> { let now = TimestampMillis::now().0; let mut stmt = self.db.prepare_cached(include_str!("add_card.sql"))?; stmt.execute(params![ now, card.note_id, card.deck_id, card.template_idx, card.mtime, card.usn, card.ctype as u8, card.queue as i8, card.due, card.interval, card.ease_factor, card.reps, card.lapses, card.remaining_steps, card.original_due, card.original_deck_id, card.flags, CardData::from_card(card).convert_to_json()?, ])?; card.id = CardId(self.db.last_insert_rowid()); Ok(()) } /// Add card if id is unique. True if card was added. pub(crate) fn add_card_if_unique(&self, card: &Card) -> Result { self.db .prepare_cached(include_str!("add_card_if_unique.sql"))? .execute(params![ card.id, card.note_id, card.deck_id, card.template_idx, card.mtime, card.usn, card.ctype as u8, card.queue as i8, card.due, card.interval, card.ease_factor, card.reps, card.lapses, card.remaining_steps, card.original_due, card.original_deck_id, card.flags, CardData::from_card(card).convert_to_json()?, ]) .map(|n_rows| n_rows == 1) .map_err(Into::into) } /// Add or update card, using the provided ID. Used for syncing & undoing. pub(crate) fn add_or_update_card(&self, card: &Card) -> Result<()> { let mut stmt = self.db.prepare_cached(include_str!("add_or_update.sql"))?; stmt.execute(params![ card.id, card.note_id, card.deck_id, card.template_idx, card.mtime, card.usn, card.ctype as u8, card.queue as i8, card.due, card.interval, card.ease_factor, card.reps, card.lapses, card.remaining_steps, card.original_due, card.original_deck_id, card.flags, CardData::from_card(card).convert_to_json()?, ])?; Ok(()) } pub(crate) fn remove_card(&self, cid: CardId) -> Result<()> { self.db .prepare_cached("delete from cards where id = ?")? .execute([cid])?; Ok(()) } pub(crate) fn for_each_intraday_card_in_active_decks( &self, learn_cutoff: TimestampSecs, mut func: F, ) -> Result<()> where F: FnMut(DueCard), { let mut stmt = self.db.prepare_cached(include_str!("intraday_due.sql"))?; let mut rows = stmt.query(params![learn_cutoff])?; while let Some(row) = rows.next()? { func(DueCard { id: row.get(0)?, note_id: row.get(1)?, due: row.get(2).ok().unwrap_or_default(), mtime: row.get(3)?, current_deck_id: row.get(4)?, original_deck_id: row.get(5)?, kind: DueCardKind::Learning, }) } Ok(()) } /// Call func() for each review card or interday learning card, stopping /// when it returns false or no more cards found. pub(crate) fn for_each_due_card_in_active_decks( &self, timing: SchedTimingToday, order: ReviewCardOrder, kind: DueCardKind, fsrs: bool, mut func: F, ) -> Result<()> where F: FnMut(DueCard) -> Result, { let order_clause = review_order_sql(order, timing, fsrs); let mut stmt = self.db.prepare_cached(&format!( "{} order by {}", include_str!("due_cards.sql"), order_clause ))?; let queue = match kind { DueCardKind::Review => CardQueue::Review, DueCardKind::Learning => CardQueue::DayLearn, }; let mut rows = stmt.query(params![queue as i8, timing.days_elapsed])?; while let Some(row) = rows.next()? { if !func(DueCard { id: row.get(0)?, note_id: row.get(1)?, due: row.get(2).ok().unwrap_or_default(), mtime: row.get(4)?, current_deck_id: row.get(5)?, original_deck_id: row.get(6)?, kind, })? { break; } } Ok(()) } /// Call func() for each new card in the provided deck, stopping when it /// returns or no more cards found. pub(crate) fn for_each_new_card_in_deck( &self, deck: DeckId, sort: NewCardSorting, mut func: F, ) -> Result<()> where F: FnMut(NewCard) -> Result, { let mut stmt = self.db.prepare_cached(&format!( "{} ORDER BY {}", include_str!("new_cards.sql"), sort.write() ))?; let mut rows = stmt.query(params![deck])?; while let Some(row) = rows.next()? { if !func(row_to_new_card(row)?)? { break; } } Ok(()) } /// Call func() for each new card in the active decks, stopping when it /// returns false or no more cards found. pub(crate) fn for_each_new_card_in_active_decks( &self, order: NewCardSorting, mut func: F, ) -> Result<()> where F: FnMut(NewCard) -> Result, { let mut stmt = self.db.prepare_cached(&format!( "{} ORDER BY {}", include_str!("active_new_cards.sql"), order.write(), ))?; let mut rows = stmt.query(params![])?; while let Some(row) = rows.next()? { if !func(row_to_new_card(row)?)? { break; } } Ok(()) } /// Fix some invalid card properties, and return number of changed cards. pub(crate) fn fix_card_properties( &self, today: u32, mtime: TimestampSecs, usn: Usn, v1_sched: bool, ) -> Result { let new_cnt = self .db .prepare(include_str!("fix_due_new.sql"))? .execute(params![mtime, usn])?; let mut other_cnt = self .db .prepare(include_str!("fix_due_other.sql"))? .execute(params![today, mtime, usn])?; other_cnt += self .db .prepare(include_str!("fix_odue.sql"))? .execute(params![mtime, usn, v1_sched])?; other_cnt += self .db .prepare(include_str!("fix_ivl.sql"))? .execute(params![mtime, usn])?; other_cnt += self .db .prepare(include_str!("fix_mod.sql"))? .execute(params![])?; other_cnt += self .db .prepare(include_str!("fix_ordinal.sql"))? .execute(params![mtime, usn])?; let mut last_review_time_cnt = 0; let revlog = self.get_all_revlog_entries_in_card_order()?; let last_revlog_info = get_last_revlog_info(&revlog); for (card_id, last_revlog_info) in last_revlog_info { let card = self.get_card(card_id)?; if last_revlog_info.last_reviewed_at.is_none() { continue; } else if let Some(mut card) = card { if card.ctype != CardType::New && card.last_review_time.is_none() { card.last_review_time = last_revlog_info.last_reviewed_at; self.update_card(&card)?; last_review_time_cnt += 1; } } } Ok(CardFixStats { new_cards_fixed: new_cnt, other_cards_fixed: other_cnt, last_review_time_fixed: last_review_time_cnt, }) } pub(crate) fn delete_orphaned_cards(&self) -> Result { self.db .prepare("delete from cards where nid not in (select id from notes)")? .execute([]) .map_err(Into::into) } pub(crate) fn all_filtered_cards_by_deck(&self) -> Result> { self.db .prepare("select id, did from cards where odid > 0")? .query_and_then([], |r| -> Result<_> { Ok((r.get(0)?, r.get(1)?)) })? .collect() } pub(crate) fn max_new_card_position(&self) -> Result { self.db .prepare("select max(due)+1 from cards where type=0")? .query_row([], |r| r.get(0)) .map_err(Into::into) } pub(crate) fn get_card_by_ordinal(&self, nid: NoteId, ord: u16) -> Result> { self.db .prepare_cached(concat!( include_str!("get_card.sql"), " where nid = ? and ord = ?" ))? .query_row(params![nid, ord], row_to_card) .optional() .map_err(Into::into) } pub(crate) fn clear_pending_card_usns(&self) -> Result<()> { self.db .prepare("update cards set usn = 0 where usn = -1")? .execute([])?; Ok(()) } pub(crate) fn have_at_least_one_card(&self) -> Result { self.db .prepare_cached("select null from cards")? .query([])? .next() .map(|o| o.is_some()) .map_err(Into::into) } pub fn all_cards_of_note(&self, nid: NoteId) -> Result> { self.db .prepare_cached(concat!(include_str!("get_card.sql"), " where nid = ?"))? .query_and_then([nid], |r| row_to_card(r).map_err(Into::into))? .collect() } pub(crate) fn all_cards_of_notes_above_ordinal( &mut self, note_ids: &[NoteId], ordinal: usize, ) -> Result> { self.with_ids_in_searched_notes_table(note_ids, || { self.db .prepare_cached(concat!( include_str!("get_card.sql"), " where nid in (select nid from search_nids) and ord > ?" ))? .query_and_then([ordinal as i64], |r| row_to_card(r).map_err(Into::into))? .collect() }) } pub fn all_card_ids_of_note_in_template_order(&self, nid: NoteId) -> Result> { self.db .prepare_cached("select id from cards where nid = ? order by ord")? .query_and_then([nid], |r| Ok(CardId(r.get(0)?)))? .collect() } pub(crate) fn get_all_card_ids(&self) -> Result> { self.db .prepare("SELECT id FROM cards")? .query_and_then([], |row| Ok(row.get(0)?))? .collect() } pub(crate) fn all_cards_as_nid_and_ord(&self) -> Result> { self.db .prepare("SELECT nid, ord FROM cards")? .query_and_then([], |r| Ok((NoteId(r.get(0)?), r.get(1)?)))? .collect() } pub(crate) fn card_ids_of_notes(&self, nids: &[NoteId]) -> Result> { let mut stmt = self .db .prepare_cached("select id from cards where nid = ?")?; let mut cids = vec![]; for nid in nids { for cid in stmt.query_map([nid], |row| row.get(0))? { cids.push(cid?); } } Ok(cids) } pub(crate) fn all_siblings_for_bury( &self, cid: CardId, nid: NoteId, bury_mode: BuryMode, ) -> Result> { let params = named_params! { ":card_id": cid, ":note_id": nid, ":include_new": bury_mode.bury_new, ":include_reviews": bury_mode.bury_reviews, ":include_day_learn": bury_mode.bury_interday_learning , ":new_queue": CardQueue::New as i8, ":review_queue": CardQueue::Review as i8, ":daylearn_queue": CardQueue::DayLearn as i8, }; self.with_searched_cards_table(false, || { self.db .prepare_cached(include_str!("siblings_for_bury.sql"))? .execute(params)?; self.all_searched_cards() }) } pub(crate) fn with_searched_cards_table( &self, preserve_order: bool, func: impl FnOnce() -> Result, ) -> Result { if preserve_order { self.setup_searched_cards_table_to_preserve_order()?; } else { self.setup_searched_cards_table()?; } let result = func(); self.clear_searched_cards_table()?; result } pub(crate) fn note_ids_of_cards(&self, cids: &[CardId]) -> Result> { let mut stmt = self .db .prepare_cached("select nid from cards where id = ?")?; let mut nids = HashSet::new(); for cid in cids { if let Some(nid) = stmt .query_row([cid], |r| r.get::<_, NoteId>(0)) .optional()? { nids.insert(nid); } } Ok(nids) } /// Place the ids of cards with notes in 'search_nids' into 'search_cids'. /// Returns number of added cards. pub(crate) fn search_cards_of_notes_into_table(&self) -> Result { self.db .prepare(include_str!("search_cards_of_notes_into_table.sql"))? .execute([]) .map_err(Into::into) } pub(crate) fn all_searched_cards(&self) -> Result> { self.db .prepare_cached(concat!( include_str!("get_card.sql"), " where id in (select cid from search_cids)" ))? .query_and_then([], |r| row_to_card(r).map_err(Into::into))? .collect() } pub(crate) fn all_searched_cards_in_search_order(&self) -> Result> { self.db .prepare_cached(concat!( include_str!("get_card.sql"), ", search_cids where cards.id = search_cids.cid order by search_cids.rowid" ))? .query_and_then([], |r| row_to_card(r).map_err(Into::into))? .collect() } /// Cards will arrive in card id order, not search order. pub(crate) fn for_each_card_in_search(&self, mut func: F) -> Result<()> where F: FnMut(Card) -> Result<()>, { let mut stmt = self.db.prepare_cached(concat!( include_str!("get_card.sql"), " where id in (select cid from search_cids)" ))?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { let card = row_to_card(row)?; func(card)? } Ok(()) } pub(crate) fn get_all_cards_due_in_range( &self, min_day: u32, max_day: u32, ) -> Result>> { Ok(self .db .prepare_cached("select id, nid, did, due from cards where due >= ?1 and due < ?2 ")? .query_and_then([min_day, max_day], |row: &Row| { Ok::<_, rusqlite::Error>(( row.get::<_, CardId>(0)?, row.get::<_, NoteId>(1)?, row.get::<_, DeckId>(2)?, row.get::<_, i32>(3)?, )) })? .flatten() .fold( vec![Vec::new(); (max_day - min_day) as usize], |mut acc, (card_id, note_id, deck_id, due)| { acc[due as usize - min_day as usize].push((card_id, note_id, deck_id)); acc }, )) } pub(crate) fn get_deck_due_counts(&self) -> Result> { self.db .prepare(include_str!("deck_due_counts.sql"))? .query_and_then([], |row| -> Result<_> { Ok((DeckId(row.get(0)?), row.get(1)?, row.get(2)?)) })? .collect() } pub(crate) fn congrats_info(&self, current: &Deck, today: u32) -> Result { // NOTE: this line is obsolete in v3 as it's run on queue build, but kept to // prevent errors for v1/v2 users before they upgrade self.update_active_decks(current)?; self.db .prepare(include_str!("congrats.sql"))? .query_and_then( named_params! { ":review_queue": CardQueue::Review as i8, ":day_learn_queue": CardQueue::DayLearn as i8, ":new_queue": CardQueue::New as i8, ":user_buried_queue": CardQueue::UserBuried as i8, ":sched_buried_queue": CardQueue::SchedBuried as i8, ":learn_queue": CardQueue::Learn as i8, ":today": today, }, |row| { Ok(CongratsInfo { review_remaining: row.get::<_, u32>(0)? > 0, new_remaining: row.get::<_, u32>(1)? > 0, have_sched_buried: row.get::<_, u32>(2)? > 0, have_user_buried: row.get::<_, u32>(3)? > 0, learn_count: row.get(4)?, next_learn_due: row.get(5)?, }) }, )? .next() .unwrap() } pub(crate) fn all_cards_at_or_above_position(&self, start: u32) -> Result> { self.with_searched_cards_table(false, || { self.db .prepare(include_str!("at_or_above_position.sql"))? .execute([start, CardType::New as u32])?; self.all_searched_cards() }) } pub(crate) fn setup_searched_cards_table(&self) -> Result<()> { self.db .execute_batch(include_str!("search_cids_setup.sql"))?; Ok(()) } pub(crate) fn setup_searched_cards_table_to_preserve_order(&self) -> Result<()> { self.db .execute_batch(include_str!("search_cids_setup_ordered.sql"))?; Ok(()) } pub(crate) fn clear_searched_cards_table(&self) -> Result<()> { self.db.execute("drop table if exists search_cids", [])?; Ok(()) } /// Injects the provided card IDs into the search_cids table, for /// when ids have arrived outside of a search. pub(crate) fn set_search_table_to_card_ids(&self, cards: &[CardId]) -> Result<()> { let mut stmt = self .db .prepare_cached("insert into search_cids values (?)")?; for cid in cards { stmt.execute([cid])?; } Ok(()) } /// Fix cards with low eases due to schema 15 bug. /// Deck configs were defaulting to 2.5% ease, which was capped to /// 130% when the deck options were edited for the first time. pub(crate) fn fix_low_card_eases_for_configs( &self, configs: &[DeckConfigId], server: bool, ) -> Result<()> { let mut affected_decks = vec![]; for conf in configs { for (deck_id, _name) in self.get_all_deck_names()? { if let Some(deck) = self.get_deck(deck_id)? { if let DeckKind::Normal(normal) = &deck.kind { if normal.config_id == conf.0 { affected_decks.push(deck.id); } } } } } let mut ids = String::new(); ids_to_string(&mut ids, &affected_decks); let sql = include_str!("fix_low_ease.sql").replace("DECK_IDS", &ids); self.db.prepare(&sql)?.execute(params![self.usn(server)?])?; Ok(()) } pub(crate) fn get_card_count_with_ignore_before( &self, ignore_before: TimestampMillis, ) -> Result { Ok(self .db .prepare(include_str!("get_ignored_before_count.sql"))? .query(params![ignore_before.0])? .next() .unwrap() .unwrap() .get(0)?) } #[cfg(test)] pub(crate) fn get_all_cards(&self) -> Vec { self.db .prepare("SELECT * FROM cards") .unwrap() .query_and_then([], row_to_card) .unwrap() .collect::>() .unwrap() } } #[derive(Clone, Copy)] pub(crate) enum ReviewOrderSubclause { Day, Deck, Random, IntervalsAscending, IntervalsDescending, EaseAscending, EaseDescending, /// FSRS DifficultyAscending, /// FSRS DifficultyDescending, RetrievabilityFsrs { timing: SchedTimingToday, order: SqlSortOrder, }, RelativeOverdueness { fsrs: bool, timing: SchedTimingToday, }, Added, ReverseAdded, } impl fmt::Display for ReviewOrderSubclause { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let temp_string; let clause = match self { ReviewOrderSubclause::Day => "due", ReviewOrderSubclause::Deck => "(select rowid from active_decks ad where ad.id = did)", ReviewOrderSubclause::Random => "fnvhash(id, mod)", ReviewOrderSubclause::IntervalsAscending => "ivl asc", ReviewOrderSubclause::IntervalsDescending => "ivl desc", ReviewOrderSubclause::EaseAscending => "factor asc", ReviewOrderSubclause::EaseDescending => "factor desc", ReviewOrderSubclause::DifficultyAscending => "extract_fsrs_variable(data, 'd') asc", ReviewOrderSubclause::DifficultyDescending => "extract_fsrs_variable(data, 'd') desc", ReviewOrderSubclause::RetrievabilityFsrs { timing, order } => { let today = timing.days_elapsed; let next_day_at = timing.next_day_at.0; let now = timing.now.0; temp_string = format!("extract_fsrs_retrievability(data, case when odue !=0 then odue else due end, ivl, {today}, {next_day_at}, {now}) {order}"); &temp_string } ReviewOrderSubclause::RelativeOverdueness { fsrs, timing } => { let today = timing.days_elapsed; let next_day_at = timing.next_day_at.0; let now = timing.now.0; temp_string = if *fsrs { format!("extract_fsrs_relative_retrievability(data, case when odue !=0 then odue else due end, ivl, {today}, {next_day_at}, {now}) asc") } else { format!( // - (elapsed days+0.001)/(scheduled interval) "-(1 + cast({today}-due+0.001 as real)/ivl) asc" ) }; &temp_string } ReviewOrderSubclause::Added => "nid asc, ord asc", ReviewOrderSubclause::ReverseAdded => "nid desc, ord asc", }; write!(f, "{clause}") } } fn review_order_sql(order: ReviewCardOrder, timing: SchedTimingToday, fsrs: bool) -> String { let mut subclauses = match order { ReviewCardOrder::Day => vec![ReviewOrderSubclause::Day], ReviewCardOrder::DayThenDeck => vec![ReviewOrderSubclause::Day, ReviewOrderSubclause::Deck], ReviewCardOrder::DeckThenDay => vec![ReviewOrderSubclause::Deck, ReviewOrderSubclause::Day], ReviewCardOrder::IntervalsAscending => vec![ReviewOrderSubclause::IntervalsAscending], ReviewCardOrder::IntervalsDescending => vec![ReviewOrderSubclause::IntervalsDescending], ReviewCardOrder::EaseAscending => { vec![if fsrs { ReviewOrderSubclause::DifficultyDescending } else { ReviewOrderSubclause::EaseAscending }] } ReviewCardOrder::EaseDescending => vec![if fsrs { ReviewOrderSubclause::DifficultyAscending } else { ReviewOrderSubclause::EaseDescending }], ReviewCardOrder::RetrievabilityAscending => { vec![ReviewOrderSubclause::RetrievabilityFsrs { timing, order: SqlSortOrder::Ascending, }] } ReviewCardOrder::RetrievabilityDescending => { vec![ReviewOrderSubclause::RetrievabilityFsrs { timing, order: SqlSortOrder::Descending, }] } ReviewCardOrder::RelativeOverdueness => { vec![ReviewOrderSubclause::RelativeOverdueness { fsrs, timing }] } ReviewCardOrder::Random => vec![], ReviewCardOrder::Added => vec![ReviewOrderSubclause::Added], ReviewCardOrder::ReverseAdded => vec![ReviewOrderSubclause::ReverseAdded], }; subclauses.push(ReviewOrderSubclause::Random); let v: Vec<_> = subclauses .iter() .map(ReviewOrderSubclause::to_string) .collect(); v.join(", ") } #[derive(Debug, Clone, Copy)] pub(crate) enum NewCardSorting { /// Ascending position, consecutive siblings, /// provided they have the same position. LowestPosition, /// Descending position, consecutive siblings, /// provided they have the same position. HighestPosition, /// Random, but with consecutive siblings. /// For some given salt the order is stable. RandomNotes(u32), /// Fully random. /// For some given salt the order is stable. RandomCards(u32), } impl NewCardSorting { fn write(self) -> String { match self { NewCardSorting::LowestPosition => "due ASC, ord ASC".to_string(), NewCardSorting::HighestPosition => "due DESC, ord ASC".to_string(), NewCardSorting::RandomNotes(salt) => format!("fnvhash(nid, {salt}), ord ASC"), NewCardSorting::RandomCards(salt) => format!("fnvhash(id, {salt})"), } } } #[cfg(test)] mod test { use std::path::Path; use anki_i18n::I18n; use crate::card::Card; use crate::storage::SqliteStorage; #[test] fn add_card() { let tr = I18n::template_only(); let storage = SqliteStorage::open_or_create(Path::new(":memory:"), &tr, false, false).unwrap(); let mut card = Card::default(); storage.add_card(&mut card).unwrap(); let id1 = card.id; storage.add_card(&mut card).unwrap(); assert_ne!(id1, card.id); } } ================================================ FILE: rslib/src/storage/card/new_cards.sql ================================================ SELECT id, nid, ord, cast(mod AS integer), did, odid FROM cards WHERE did = ? AND queue = 0 ================================================ FILE: rslib/src/storage/card/search_cards_of_notes_into_table.sql ================================================ INSERT INTO search_cids SELECT id FROM cards WHERE nid IN ( SELECT nid FROM search_nids ) ================================================ FILE: rslib/src/storage/card/search_cids_setup.sql ================================================ DROP TABLE IF EXISTS search_cids; CREATE TEMPORARY TABLE search_cids (cid integer PRIMARY KEY NOT NULL); ================================================ FILE: rslib/src/storage/card/search_cids_setup_ordered.sql ================================================ DROP TABLE IF EXISTS search_cids; CREATE TEMPORARY TABLE search_cids (cid integer NOT NULL); ================================================ FILE: rslib/src/storage/card/siblings_for_bury.sql ================================================ INSERT INTO search_cids SELECT id FROM cards WHERE id != :card_id AND nid = :note_id AND ( ( :include_new AND queue = :new_queue ) OR ( :include_reviews AND queue = :review_queue ) OR ( :include_day_learn AND queue = :daylearn_queue ) ); ================================================ FILE: rslib/src/storage/card/update_card.sql ================================================ UPDATE cards SET nid = ?, did = ?, ord = ?, mod = ?, usn = ?, type = ?, queue = ?, due = ?, ivl = ?, factor = ?, reps = ?, lapses = ?, left = ?, odue = ?, odid = ?, flags = ?, data = ? WHERE id = ? ================================================ FILE: rslib/src/storage/collection_timestamps.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use rusqlite::params; use super::SqliteStorage; use crate::collection::timestamps::CollectionTimestamps; use crate::prelude::*; impl SqliteStorage { pub(crate) fn get_collection_timestamps(&self) -> Result { self.db .prepare_cached("select mod, scm, ls from col")? .query_row([], |row| { Ok(CollectionTimestamps { collection_change: row.get(0)?, schema_change: row.get(1)?, last_sync: row.get(2)?, }) }) .map_err(Into::into) } pub(crate) fn set_schema_modified_time(&self, stamp: TimestampMillis) -> Result<()> { self.db .prepare_cached("update col set scm = ?")? .execute([stamp])?; Ok(()) } pub(crate) fn set_last_sync(&self, stamp: TimestampMillis) -> Result<()> { self.db.prepare("update col set ls = ?")?.execute([stamp])?; Ok(()) } pub(crate) fn set_modified_time(&self, stamp: TimestampMillis) -> Result<()> { self.db .prepare_cached("update col set mod=?")? .execute(params![stamp])?; Ok(()) } // Creation timestamp is used less frequently, and has separate accessor pub(crate) fn creation_stamp(&self) -> Result { self.db .prepare_cached("select crt from col")? .query_row([], |row| row.get(0)) .map_err(Into::into) } pub(crate) fn set_creation_stamp(&self, stamp: TimestampSecs) -> Result<()> { self.db .prepare("update col set crt = ?")? .execute([stamp])?; Ok(()) } } ================================================ FILE: rslib/src/storage/config/add.sql ================================================ INSERT OR REPLACE INTO config (KEY, usn, mtime_secs, val) VALUES (?, ?, ?, ?) ================================================ FILE: rslib/src/storage/config/get.sql ================================================ SELECT val FROM config WHERE KEY = ? ================================================ FILE: rslib/src/storage/config/get_entry.sql ================================================ SELECT val, usn, mtime_secs FROM config WHERE KEY = ? ================================================ FILE: rslib/src/storage/config/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use rusqlite::params; use serde::de::DeserializeOwned; use serde_json::Value; use super::SqliteStorage; use crate::config::ConfigEntry; use crate::error::Result; use crate::timestamp::TimestampSecs; use crate::types::Usn; impl SqliteStorage { pub(crate) fn set_config_entry(&self, entry: &ConfigEntry) -> Result<()> { self.db .prepare_cached(include_str!("add.sql"))? .execute(params![&entry.key, entry.usn, entry.mtime, &entry.value])?; Ok(()) } pub(crate) fn remove_config(&self, key: &str) -> Result<()> { self.db .prepare_cached("delete from config where key=?")? .execute([key])?; Ok(()) } pub(crate) fn get_config_value(&self, key: &str) -> Result> { self.db .prepare_cached(include_str!("get.sql"))? .query_and_then([key], |row| { let blob = row.get_ref_unwrap(0).as_blob()?; serde_json::from_slice(blob).map_err(Into::into) })? .next() .transpose() } /// Return the raw bytes and other metadata, for undoing. pub(crate) fn get_config_entry(&self, key: &str) -> Result>> { self.db .prepare_cached(include_str!("get_entry.sql"))? .query_and_then([key], |row| { Ok(ConfigEntry::boxed( key, row.get(0)?, row.get(1)?, row.get(2)?, )) })? .next() .transpose() } /// Prefix is expected to end with '_'. pub(crate) fn get_config_prefix(&self, prefix: &str) -> Result)>> { let mut end = prefix.to_string(); assert_eq!(end.pop(), Some('_')); end.push(std::char::from_u32('_' as u32 + 1).unwrap()); self.db .prepare("select key, val from config where key > ? and key < ?")? .query_and_then(params![prefix, &end], |row| Ok((row.get(0)?, row.get(1)?)))? .collect() } pub(crate) fn get_all_config(&self) -> Result> { self.db .prepare("select key, val from config")? .query_and_then([], |row| { let val: Value = serde_json::from_slice(row.get_ref_unwrap(1).as_blob()?)?; Ok((row.get::(0)?, val)) })? .collect() } pub(crate) fn set_all_config( &self, conf: HashMap, usn: Usn, mtime: TimestampSecs, ) -> Result<()> { self.db.execute("delete from config", [])?; for (key, val) in conf.iter() { self.set_config_entry(&ConfigEntry::boxed( key, serde_json::to_vec(&val)?, usn, mtime, ))?; } Ok(()) } pub(crate) fn clear_config_usns(&self) -> Result<()> { self.db .prepare("update config set usn = 0 where usn != 0")? .execute([])?; Ok(()) } // Upgrading/downgrading pub(super) fn upgrade_config_to_schema14(&self) -> Result<()> { let conf = self .db .query_row_and_then("select conf from col", [], |row| { let conf: Result> = serde_json::from_str(row.get_ref_unwrap(0).as_str()?).map_err(Into::into); conf })?; self.set_all_config(conf, Usn(0), TimestampSecs(0))?; self.db.execute_batch("update col set conf=''")?; Ok(()) } pub(super) fn downgrade_config_from_schema14(&self) -> Result<()> { let allconf = self.get_all_config()?; self.db .execute("update col set conf=?", [serde_json::to_string(&allconf)?])?; Ok(()) } } ================================================ FILE: rslib/src/storage/dbcheck/invalid_ids_count.sql ================================================ SELECT ( SELECT COUNT(*) FROM notes WHERE id > :cutoff ) + ( SELECT COUNT(*) FROM cards WHERE id > :cutoff ) + ( SELECT COUNT(*) FROM revlog WHERE id > :cutoff ); ================================================ FILE: rslib/src/storage/dbcheck/invalid_ids_create.sql ================================================ DROP TABLE IF EXISTS invalid_ids; CREATE TEMPORARY TABLE invalid_ids AS WITH max_existing_valid_id AS ( SELECT coalesce(max(id), 0) AS max_id FROM "{source_table}" WHERE id <= "{max_valid_id}" ), first_new_id AS ( SELECT CASE WHEN "{new_id}" > ( SELECT max_id FROM max_existing_valid_id ) THEN "{new_id}" ELSE ( SELECT max_id FROM max_existing_valid_id ) + 1 END AS id ) SELECT id, ( SELECT id FROM first_new_id ) + row_number() OVER ( ORDER BY id ) - 1 AS new_id FROM "{source_table}" WHERE id > "{max_valid_id}"; CREATE INDEX invalid_ids_id_idx ON invalid_ids (id); ================================================ FILE: rslib/src/storage/dbcheck/invalid_ids_drop.sql ================================================ DROP TABLE IF EXISTS invalid_ids; ================================================ FILE: rslib/src/storage/dbcheck/invalid_ids_update.sql ================================================ UPDATE "{target_table}" SET "{id_column}" = ( SELECT invalid_ids.new_id FROM invalid_ids WHERE invalid_ids.id = "{target_table}"."{id_column}" LIMIT 1 ) WHERE "{target_table}"."{id_column}" IN ( SELECT invalid_ids.id FROM invalid_ids ); ================================================ FILE: rslib/src/storage/dbcheck/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::prelude::*; impl super::SqliteStorage { /// True if any ids used as timestamps are larger than `cutoff`. pub(crate) fn invalid_ids(&self, cutoff: i64) -> Result { Ok(self .db .query_row_and_then(include_str!("invalid_ids_count.sql"), [cutoff], |r| { r.get(0) })?) } /// Ensures all ids used as timestamps are `max_valid_id` or lower. /// If not, new ids will be assigned starting at whichever is larger, /// `new_id` or the next free valid id. /// `new_id` must be a valid id, i.e. lower or equal to `max_valid_id`. pub(crate) fn fix_invalid_ids(&self, max_valid_id: i64, new_id: i64) -> Result<()> { require!(new_id <= max_valid_id, "new_id is invalid"); for (source_table, foreign_table) in [ ("notes", Some(("cards", "nid"))), ("cards", Some(("revlog", "cid"))), ("revlog", None), ] { self.setup_invalid_ids_table(source_table, max_valid_id, new_id)?; self.update_invalid_ids_from_table(source_table, "id")?; if let Some((target_table, id_column)) = foreign_table { self.update_invalid_ids_from_table(target_table, id_column)?; } } self.db.execute(include_str!("invalid_ids_drop.sql"), [])?; Ok(()) } fn setup_invalid_ids_table( &self, source_table: &str, max_valid_id: i64, new_id: i64, ) -> Result<()> { self.db.execute_batch(&format!( include_str!("invalid_ids_create.sql"), source_table = source_table, max_valid_id = max_valid_id, new_id = new_id, ))?; Ok(()) } /// Fix the invalid ids in `id_column` of `target_table` using the map from /// the invalid ids temporary table. fn update_invalid_ids_from_table(&self, target_table: &str, id_column: &str) -> Result<()> { self.db.execute_batch(&format!( include_str!("invalid_ids_update.sql"), target_table = target_table, id_column = id_column, ))?; Ok(()) } } #[cfg(test)] mod test { use crate::prelude::*; #[test] fn any_invalid_ids() { let mut col = Collection::new(); assert_eq!(col.storage.invalid_ids(0).unwrap(), 0); NoteAdder::basic(&mut col).add(&mut col); // 1 card and 1 note assert_eq!(col.storage.invalid_ids(0).unwrap(), 2); assert_eq!( col.storage.invalid_ids(TimestampMillis::now().0).unwrap(), 0 ); } #[test] fn fix_invalid_note_ids_only_and_update_cards() { let mut col = Collection::new(); let valid = NoteAdder::basic(&mut col).add(&mut col); NoteAdder::basic(&mut col).add(&mut col); col.storage.fix_invalid_ids(valid.id.0, 42).unwrap(); assert_eq!(col.storage.all_cards_of_note(valid.id).unwrap().len(), 1); assert_eq!(col.storage.all_cards_of_note(NoteId(42)).unwrap().len(), 1); } #[test] fn fix_invalid_card_ids_only() { let mut col = Collection::new(); let mut cards = CardAdder::new().siblings(3).add(&mut col); col.storage.fix_invalid_ids(cards[0].id.0, 42).unwrap(); cards.sort_by(|c1, c2| c1.id.cmp(&c2.id)); cards[1].id.0 = 42; cards[2].id.0 = 43; let old_first_card = cards.remove(0); cards.push(old_first_card); let mut new_cards = col.storage.get_all_cards(); new_cards.sort_by(|c1, c2| c1.id.cmp(&c2.id)); assert_eq!(new_cards, cards); } #[test] fn update_revlog_when_fixing_card_ids() { let mut col = Collection::new(); CardAdder::new().due_dates(["7"]).add(&mut col); col.storage.fix_invalid_ids(42, 42).unwrap(); // revlog id was also reset to 42 let revlog_entry = col.storage.get_revlog_entry(RevlogId(42)).unwrap().unwrap(); assert_eq!(revlog_entry.cid.0, 42); } } ================================================ FILE: rslib/src/storage/deck/active_deck_ids_sorted.sql ================================================ SELECT id FROM decks WHERE id IN ( SELECT id FROM active_decks ) ORDER BY name ================================================ FILE: rslib/src/storage/deck/add_or_update_deck.sql ================================================ INSERT OR REPLACE INTO decks (id, name, mtime_secs, usn, common, kind) VALUES (?, ?, ?, ?, ?, ?) ================================================ FILE: rslib/src/storage/deck/all_decks_and_original_of_search_cards.sql ================================================ WITH cids AS ( SELECT cid FROM search_cids ) SELECT did FROM cards WHERE id IN cids UNION SELECT odid FROM cards WHERE odid != 0 AND id IN cids ================================================ FILE: rslib/src/storage/deck/all_decks_of_search_notes.sql ================================================ SELECT nid, did FROM cards WHERE nid IN ( SELECT nid FROM search_nids ) GROUP BY nid HAVING ord = MIN(ord) ================================================ FILE: rslib/src/storage/deck/alloc_id.sql ================================================ SELECT CASE WHEN ?1 IN ( SELECT id FROM decks ) THEN ( SELECT max(id) + 1 FROM decks ) ELSE ?1 END; ================================================ FILE: rslib/src/storage/deck/cards_for_deck.sql ================================================ SELECT id FROM cards WHERE did = ?1 OR ( odid != 0 AND odid = ?1 ) ================================================ FILE: rslib/src/storage/deck/due_counts.sql ================================================ SELECT did, -- new sum(queue = :new_queue), -- reviews sum( queue = :review_queue AND due <= :day_cutoff ), -- interday learning sum( queue = :daylearn_queue AND due <= :day_cutoff ), -- intraday learning sum( ( ( queue = :learn_queue AND due < :learn_cutoff ) OR ( queue = :preview_queue AND due <= :learn_cutoff ) ) ), -- total COUNT(1) FROM cards ================================================ FILE: rslib/src/storage/deck/get_deck.sql ================================================ SELECT id, name, mtime_secs, usn, common, kind FROM decks ================================================ FILE: rslib/src/storage/deck/missing-decks.sql ================================================ SELECT DISTINCT did FROM cards WHERE did NOT IN ( SELECT id FROM decks ); ================================================ FILE: rslib/src/storage/deck/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::collections::HashSet; use std::iter; use prost::Message; use rusqlite::named_params; use rusqlite::params; use rusqlite::Row; use unicase::UniCase; use super::SqliteStorage; use crate::card::CardQueue; use crate::decks::immediate_parent_name; use crate::decks::DeckCommon; use crate::decks::DeckKindContainer; use crate::decks::DeckSchema11; use crate::decks::DueCounts; use crate::error::DbErrorKind; use crate::prelude::*; fn row_to_deck(row: &Row) -> Result { let common = DeckCommon::decode(row.get_ref_unwrap(4).as_blob()?)?; let kind = DeckKindContainer::decode(row.get_ref_unwrap(5).as_blob()?)?; let id = row.get(0)?; Ok(Deck { id, name: NativeDeckName::from_native_str(row.get_ref_unwrap(1).as_str()?), mtime_secs: row.get(2)?, usn: row.get(3)?, common, kind: kind.kind.ok_or_else(|| { AnkiError::db_error( format!("invalid deck kind: {id}"), DbErrorKind::MissingEntity, ) })?, }) } fn row_to_due_counts(row: &Row) -> Result<(DeckId, DueCounts)> { let deck_id = row.get(0)?; let new = row.get(1)?; let review = row.get(2)?; let interday_learning: u32 = row.get(3)?; let intraday_learning: u32 = row.get(4)?; let total_cards: u32 = row.get(5)?; // used as-is in v1/v2; recalculated in v3 after limits are applied let learning = intraday_learning + interday_learning; Ok(( deck_id, DueCounts { new, review, learning, intraday_learning, interday_learning, total_cards, }, )) } impl SqliteStorage { pub(crate) fn get_all_decks_as_schema11(&self) -> Result> { self.get_all_decks() .map(|r| r.into_iter().map(|d| (d.id, d.into())).collect()) } pub(crate) fn get_deck(&self, did: DeckId) -> Result> { self.db .prepare_cached(concat!(include_str!("get_deck.sql"), " where id = ?"))? .query_and_then([did], row_to_deck)? .next() .transpose() } pub(crate) fn get_deck_by_name(&self, machine_name: &str) -> Result> { self.db .prepare_cached(concat!(include_str!("get_deck.sql"), " WHERE name = ?"))? .query_and_then([machine_name], row_to_deck)? .next() .transpose() } pub(crate) fn get_all_decks(&self) -> Result> { self.db .prepare(include_str!("get_deck.sql"))? .query_and_then([], row_to_deck)? .collect() } pub(crate) fn get_decks_map(&self) -> Result> { self.db .prepare(include_str!("get_deck.sql"))? .query_and_then([], row_to_deck)? .map(|res| res.map(|d| (d.id, d))) .collect() } /// Get all deck names in sorted, human-readable form (::) pub(crate) fn get_all_deck_names(&self) -> Result> { self.db .prepare("select id, name from decks order by name")? .query_and_then([], |row| { Ok(( row.get(0)?, row.get_ref_unwrap(1).as_str()?.replace('\x1f', "::"), )) })? .collect() } pub(crate) fn get_deck_id(&self, machine_name: &str) -> Result> { self.db .prepare("select id from decks where name = ?")? .query_and_then([machine_name], |row| row.get(0))? .next() .transpose() .map_err(Into::into) } pub(crate) fn get_decks_for_search_cards(&self) -> Result> { self.db .prepare_cached(concat!( include_str!("get_deck.sql"), " WHERE id IN (SELECT DISTINCT did FROM cards WHERE id IN", " (SELECT cid FROM search_cids))", ))? .query_and_then([], row_to_deck)? .collect() } pub(crate) fn get_decks_and_original_for_search_cards(&self) -> Result> { self.db .prepare_cached(concat!( include_str!("get_deck.sql"), " WHERE id IN (", include_str!("all_decks_and_original_of_search_cards.sql"), ")", ))? .query_and_then([], row_to_deck)? .collect() } /// Returns the deck id of the first existing card of every searched note. pub(crate) fn all_decks_of_search_notes(&self) -> Result> { self.db .prepare_cached(include_str!("all_decks_of_search_notes.sql"))? .query_and_then([], |r| Ok((r.get(0)?, r.get(1)?)))? .collect() } // caller should ensure name unique pub(crate) fn add_deck(&self, deck: &mut Deck) -> Result<()> { assert_eq!(deck.id.0, 0); deck.id.0 = self .db .prepare(include_str!("alloc_id.sql"))? .query_row([TimestampMillis::now()], |r| r.get(0))?; self.add_or_update_deck_with_existing_id(deck) .inspect_err(|_err| { // restore id of 0 deck.id.0 = 0; }) } pub(crate) fn update_deck(&self, deck: &Deck) -> Result<()> { require!(deck.id.0 != 0, "deck with id 0"); let mut stmt = self.db.prepare_cached(include_str!("update_deck.sql"))?; let mut common = vec![]; deck.common.encode(&mut common)?; let kind_enum = DeckKindContainer { kind: Some(deck.kind.clone()), }; let mut kind = vec![]; kind_enum.encode(&mut kind)?; let count = stmt.execute(params![ deck.name.as_native_str(), deck.mtime_secs, deck.usn, common, kind, deck.id ])?; require!(count != 0, "update_deck() called with non-existent deck"); Ok(()) } /// Used for syncing&undo; will keep existing ID. Shouldn't be used to add /// new decks locally, since it does not allocate an id. pub(crate) fn add_or_update_deck_with_existing_id(&self, deck: &Deck) -> Result<()> { require!(deck.id.0 != 0, "deck with id 0"); let mut stmt = self .db .prepare_cached(include_str!("add_or_update_deck.sql"))?; let mut common = vec![]; deck.common.encode(&mut common)?; let kind_enum = DeckKindContainer { kind: Some(deck.kind.clone()), }; let mut kind = vec![]; kind_enum.encode(&mut kind)?; stmt.execute(params![ deck.id, deck.name.as_native_str(), deck.mtime_secs, deck.usn, common, kind ])?; Ok(()) } pub(crate) fn remove_deck(&self, did: DeckId) -> Result<()> { self.db .prepare_cached("delete from decks where id = ?")? .execute([did])?; Ok(()) } pub(crate) fn all_cards_in_single_deck(&self, did: DeckId) -> Result> { self.db .prepare_cached(include_str!("cards_for_deck.sql"))? .query_and_then([did], |r| r.get(0).map_err(Into::into))? .collect() } /// Returns the descendants of the given [Deck] in preorder. pub(crate) fn child_decks(&self, parent: &Deck) -> Result> { let prefix_start = format!("{}\x1f", parent.name); let prefix_end = format!("{}\x20", parent.name); self.db .prepare_cached(concat!( include_str!("get_deck.sql"), " where name >= ? and name < ? order by name" ))? .query_and_then([prefix_start, prefix_end], row_to_deck)? .collect() } pub(crate) fn deck_id_with_children(&self, parent: &Deck) -> Result> { let prefix_start = format!("{}\x1f", parent.name); let prefix_end = format!("{}\x20", parent.name); self.db .prepare_cached("select id from decks where id = ? or (name >= ? and name < ?)")? .query_and_then(params![parent.id, prefix_start, prefix_end], |row| { row.get(0).map_err(Into::into) })? .collect() } pub(crate) fn deck_with_children(&self, deck_id: DeckId) -> Result> { let deck = self.get_deck(deck_id)?.or_not_found(deck_id)?; let prefix_start = format!("{}\x1f", deck.name); let prefix_end = format!("{}\x20", deck.name); iter::once(Ok(deck)) .chain( self.db .prepare_cached(concat!( include_str!("get_deck.sql"), " where name > ? and name < ?" ))? .query_and_then([prefix_start, prefix_end], row_to_deck)?, ) .collect() } /// Return the parents of `child`, with the most immediate parent coming /// first. pub(crate) fn parent_decks(&self, child: &Deck) -> Result> { let mut decks: Vec = vec![]; while let Some(parent_name) = immediate_parent_name( decks .last() .map(|d| &d.name) .unwrap_or_else(|| &child.name) .as_native_str(), ) { if let Some(parent_did) = self.get_deck_id(parent_name)? { let parent = self.get_deck(parent_did)?.unwrap(); decks.push(parent); } else { // missing parent break; } } Ok(decks) } pub(crate) fn due_counts( &self, day_cutoff: u32, learn_cutoff: u32, ) -> Result> { let params = named_params! { ":new_queue": CardQueue::New as u8, ":review_queue": CardQueue::Review as u8, ":day_cutoff": day_cutoff, ":learn_queue": CardQueue::Learn as u8, ":learn_cutoff": learn_cutoff, ":daylearn_queue": CardQueue::DayLearn as u8, ":preview_queue": CardQueue::PreviewRepeat as u8, } .to_vec(); let sql = concat!(include_str!("due_counts.sql"), " group by did"); self.db .prepare_cached(sql)? .query_and_then(&*params, row_to_due_counts)? .collect() } /// Decks referenced by cards but missing. pub(crate) fn missing_decks(&self) -> Result> { self.db .prepare(include_str!("missing-decks.sql"))? .query_and_then([], |r| r.get(0).map_err(Into::into))? .collect() } pub(crate) fn deck_is_empty(&self, did: DeckId) -> Result { self.db .prepare_cached("select null from cards where did=?")? .query([did])? .next() .map(|o| o.is_none()) .map_err(Into::into) } pub(crate) fn clear_deck_usns(&self) -> Result<()> { self.db .prepare("update decks set usn = 0 where usn != 0")? .execute([])?; Ok(()) } /// Write active decks into temporary active_decks table. pub(crate) fn update_active_decks(&self, current: &Deck) -> Result<()> { self.db.execute_batch(concat!( "drop table if exists active_decks;", "create temporary table active_decks (id integer not null unique);" ))?; let top = current.name.as_native_str(); let prefix_start = &format!("{top}\x1f"); let prefix_end = &format!("{top}\x20"); self.db .prepare_cached(include_str!("update_active.sql"))? .execute([top, prefix_start, prefix_end])?; Ok(()) } pub(crate) fn get_active_deck_ids_sorted(&self) -> Result> { self.db .prepare_cached(include_str!("active_deck_ids_sorted.sql"))? .query_and_then([], |row| row.get(0).map_err(Into::into))? .collect() } // Upgrading/downgrading/legacy pub(super) fn add_default_deck(&self, tr: &I18n) -> Result<()> { let mut deck = Deck::new_normal(); deck.id.0 = 1; // fixme: separate key deck.name = NativeDeckName::from_native_str(tr.deck_config_default_name()); self.add_or_update_deck_with_existing_id(&deck) } pub(crate) fn upgrade_decks_to_schema15(&self, server: bool) -> Result<()> { let usn = self.usn(server)?; let decks = self .get_schema11_decks() .map_err(|e| AnkiError::JsonError { info: format!("decoding decks: {e}"), })?; let mut names = HashSet::new(); for (_id, deck) in decks { let oldname = deck.name().to_string(); let mut deck = Deck::from(deck); if deck.human_name() != oldname { deck.set_modified(usn); } loop { let name = UniCase::new(deck.name.as_native_str().to_string()); if !names.contains(&name) { names.insert(name); break; } deck.name.add_suffix("_"); deck.set_modified(usn); } self.add_or_update_deck_with_existing_id(&deck)?; } self.db.execute("update col set decks = ''", [])?; Ok(()) } pub(crate) fn downgrade_decks_from_schema15(&self) -> Result<()> { let decks = self.get_all_decks_as_schema11()?; self.set_schema11_decks(decks) } fn get_schema11_decks(&self) -> Result> { let mut stmt = self.db.prepare("select decks from col")?; let decks = stmt .query_and_then([], |row| -> Result> { let v: HashMap = serde_json::from_str(row.get_ref_unwrap(0).as_str()?)?; Ok(v) })? .next() .ok_or_else(|| AnkiError::db_error("col table empty", DbErrorKind::MissingEntity))??; Ok(decks) } pub(crate) fn set_schema11_decks(&self, decks: HashMap) -> Result<()> { let json = serde_json::to_string(&decks)?; self.db.execute("update col set decks = ?", [json])?; Ok(()) } } ================================================ FILE: rslib/src/storage/deck/update_active.sql ================================================ INSERT INTO active_decks SELECT id FROM decks WHERE name = ? OR ( name >= ? AND name < ? ) ================================================ FILE: rslib/src/storage/deck/update_deck.sql ================================================ UPDATE decks SET name = ?, mtime_secs = ?, usn = ?, common = ?, kind = ? WHERE id = ? ================================================ FILE: rslib/src/storage/deckconfig/add.sql ================================================ INSERT INTO deck_config (id, name, mtime_secs, usn, config) VALUES ( ( CASE WHEN ?1 IN ( SELECT id FROM deck_config ) THEN ( SELECT max(id) + 1 FROM deck_config ) ELSE ?1 END ), ?, ?, ?, ? ); ================================================ FILE: rslib/src/storage/deckconfig/add_if_unique.sql ================================================ INSERT OR IGNORE INTO deck_config (id, name, mtime_secs, usn, config) VALUES (?, ?, ?, ?, ?); ================================================ FILE: rslib/src/storage/deckconfig/add_or_update.sql ================================================ INSERT OR REPLACE INTO deck_config (id, name, mtime_secs, usn, config) VALUES (?, ?, ?, ?, ?); ================================================ FILE: rslib/src/storage/deckconfig/get.sql ================================================ SELECT id, name, mtime_secs, usn, config FROM deck_config ================================================ FILE: rslib/src/storage/deckconfig/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use prost::Message; use rusqlite::params; use rusqlite::Row; use serde_json::Value; use super::SqliteStorage; use crate::deckconfig::ensure_deck_config_values_valid; use crate::deckconfig::DeckConfSchema11; use crate::deckconfig::DeckConfig; use crate::deckconfig::DeckConfigId; use crate::deckconfig::DeckConfigInner; use crate::prelude::*; fn row_to_deckconf(row: &Row, fix_invalid: bool) -> Result { let mut config = DeckConfigInner::decode(row.get_ref_unwrap(4).as_blob()?)?; if fix_invalid { ensure_deck_config_values_valid(&mut config); } Ok(DeckConfig { id: row.get(0)?, name: row.get(1)?, mtime_secs: row.get(2)?, usn: row.get(3)?, inner: config, }) } impl SqliteStorage { pub(crate) fn all_deck_config(&self) -> Result> { self.db .prepare_cached(include_str!("get.sql"))? .query_and_then([], |row| row_to_deckconf(row, true))? .collect() } /// Does not cap values to those expected by the latest schema. pub(crate) fn all_deck_config_for_schema16_upgrade(&self) -> Result> { self.db .prepare_cached(include_str!("get.sql"))? .query_and_then([], |row| row_to_deckconf(row, false))? .collect() } pub(crate) fn get_deck_config_map(&self) -> Result> { self.db .prepare_cached(include_str!("get.sql"))? .query_and_then([], |row| row_to_deckconf(row, true))? .map(|res| res.map(|d| (d.id, d))) .collect() } pub(crate) fn get_deck_config(&self, dcid: DeckConfigId) -> Result> { self.db .prepare_cached(concat!(include_str!("get.sql"), " where id = ?"))? .query_and_then(params![dcid], |row| row_to_deckconf(row, true))? .next() .transpose() } pub(crate) fn get_deck_config_id_by_name(&self, name: &str) -> Result> { self.db .prepare_cached("select id from deck_config WHERE name = ?")? .query_and_then([name], |row| Ok::<_, AnkiError>(DeckConfigId(row.get(0)?)))? .next() .transpose() } pub(crate) fn add_deck_conf(&self, conf: &mut DeckConfig) -> Result<()> { let mut conf_bytes = vec![]; conf.inner.encode(&mut conf_bytes)?; self.db .prepare_cached(include_str!("add.sql"))? .execute(params![ conf.id, conf.name, conf.mtime_secs, conf.usn, conf_bytes, ])?; let id = self.db.last_insert_rowid(); if conf.id.0 != id { conf.id.0 = id; } Ok(()) } pub(crate) fn add_deck_conf_if_unique(&self, conf: &DeckConfig) -> Result { let mut conf_bytes = vec![]; conf.inner.encode(&mut conf_bytes)?; self.db .prepare_cached(include_str!("add_if_unique.sql"))? .execute(params![ conf.id, conf.name, conf.mtime_secs, conf.usn, conf_bytes, ]) .map(|added| added == 1) .map_err(Into::into) } pub(crate) fn update_deck_conf(&self, conf: &DeckConfig) -> Result<()> { let mut conf_bytes = vec![]; conf.inner.encode(&mut conf_bytes)?; self.db .prepare_cached(include_str!("update.sql"))? .execute(params![ conf.name, conf.mtime_secs, conf.usn, conf_bytes, conf.id, ])?; Ok(()) } /// Used for syncing&undo; will keep provided ID. Shouldn't be used to add /// new config normally, since it does not allocate an id. pub(crate) fn add_or_update_deck_config_with_existing_id( &self, conf: &DeckConfig, ) -> Result<()> { require!(conf.id.0 != 0, "deck with id 0"); let mut conf_bytes = vec![]; conf.inner.encode(&mut conf_bytes)?; self.db .prepare_cached(include_str!("add_or_update.sql"))? .execute(params![ conf.id, conf.name, conf.mtime_secs, conf.usn, conf_bytes, ])?; Ok(()) } pub(crate) fn remove_deck_conf(&self, dcid: DeckConfigId) -> Result<()> { self.db .prepare_cached("delete from deck_config where id=?")? .execute(params![dcid])?; Ok(()) } pub(crate) fn clear_deck_conf_usns(&self) -> Result<()> { self.db .prepare("update deck_config set usn = 0 where usn != 0")? .execute([])?; Ok(()) } // Creating/upgrading/downgrading pub(super) fn add_default_deck_config(&self, tr: &I18n) -> Result<()> { let mut conf = DeckConfig::default(); conf.id.0 = 1; conf.name = tr.deck_config_default_name().into(); self.add_deck_conf(&mut conf) } // schema 11->14 fn add_deck_conf_schema14(&self, conf: &mut DeckConfSchema11) -> Result<()> { self.db .prepare_cached(include_str!("add.sql"))? .execute(params![ conf.id, conf.name, conf.mtime, conf.usn, &serde_json::to_vec(conf)?, ])?; let id = self.db.last_insert_rowid(); if conf.id.0 != id { conf.id.0 = id; } Ok(()) } pub(super) fn upgrade_deck_conf_to_schema14(&self) -> Result<()> { let conf: HashMap = self.db .query_row_and_then("select dconf from col", [], |row| -> Result<_> { let text = row.get_ref_unwrap(0).as_str()?; // try direct parse serde_json::from_str(text) .or_else(|_| { // failed, and could be caused by duplicate keys. Serialize into // a value first to discard them, then try again let conf: Value = serde_json::from_str(text)?; serde_json::from_value(conf) }) .map_err(|e| AnkiError::JsonError { info: format!("decoding deck config: {e}"), }) })?; for (id, mut conf) in conf.into_iter() { // buggy clients may have failed to set inner id to match hash key conf.id = id; self.add_deck_conf_schema14(&mut conf)?; } self.db.execute_batch("update col set dconf=''")?; Ok(()) } // schema 14->15 fn all_deck_config_schema14(&self) -> Result> { self.db .prepare_cached("select config from deck_config")? .query_and_then([], |row| -> Result<_> { Ok(serde_json::from_slice(row.get_ref_unwrap(0).as_blob()?)?) })? .collect() } pub(super) fn upgrade_deck_conf_to_schema15(&self) -> Result<()> { for conf in self.all_deck_config_schema14()? { let mut conf: DeckConfig = conf.into(); // schema 15 stored starting ease of 2.5 as 250 conf.inner.initial_ease *= 100.0; self.update_deck_conf(&conf)?; } Ok(()) } // schema 15->16 pub(super) fn upgrade_deck_conf_to_schema16(&self, server: bool) -> Result<()> { let mut invalid_configs = vec![]; for mut conf in self.all_deck_config_for_schema16_upgrade()? { // schema 16 changed starting ease of 250 to 2.5 conf.inner.initial_ease /= 100.0; // new deck configs created with schema 15 had the wrong // ease set - reset any deck configs at the minimum ease // to the default 250% if conf.inner.initial_ease <= 1.3 { conf.inner.initial_ease = 2.5; invalid_configs.push(conf.id); } self.update_deck_conf(&conf)?; } self.fix_low_card_eases_for_configs(&invalid_configs, server) } // schema 15->11 pub(super) fn downgrade_deck_conf_from_schema16(&self) -> Result<()> { let allconf = self.all_deck_config()?; let confmap: HashMap = allconf .into_iter() .map(|c| -> DeckConfSchema11 { c.into() }) .map(|c| (c.id, c)) .collect(); self.db.execute( "update col set dconf=?", params![serde_json::to_string(&confmap)?], )?; Ok(()) } } ================================================ FILE: rslib/src/storage/deckconfig/update.sql ================================================ UPDATE deck_config SET name = ?, mtime_secs = ?, usn = ?, config = ? WHERE id = ?; ================================================ FILE: rslib/src/storage/graves/add.sql ================================================ INSERT OR IGNORE INTO graves (usn, oid, type) VALUES (?, ?, ?) ================================================ FILE: rslib/src/storage/graves/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::convert::TryFrom; use num_enum::TryFromPrimitive; use rusqlite::params; use super::SqliteStorage; use crate::prelude::*; use crate::sync::collection::graves::Graves; #[derive(TryFromPrimitive)] #[repr(u8)] enum GraveKind { Card, Note, Deck, } impl SqliteStorage { pub(crate) fn clear_all_graves(&self) -> Result<()> { self.db.execute("delete from graves", [])?; Ok(()) } pub(crate) fn add_card_grave(&self, cid: CardId, usn: Usn) -> Result<()> { self.add_grave(cid.0, GraveKind::Card, usn) } pub(crate) fn add_note_grave(&self, nid: NoteId, usn: Usn) -> Result<()> { self.add_grave(nid.0, GraveKind::Note, usn) } pub(crate) fn add_deck_grave(&self, did: DeckId, usn: Usn) -> Result<()> { self.add_grave(did.0, GraveKind::Deck, usn) } pub(crate) fn remove_card_grave(&self, cid: CardId) -> Result<()> { self.remove_grave(cid.0, GraveKind::Card) } pub(crate) fn remove_note_grave(&self, nid: NoteId) -> Result<()> { self.remove_grave(nid.0, GraveKind::Note) } pub(crate) fn remove_deck_grave(&self, did: DeckId) -> Result<()> { self.remove_grave(did.0, GraveKind::Deck) } pub(crate) fn pending_graves(&self, pending_usn: Usn) -> Result { let mut stmt = self.db.prepare(&format!( "select oid, type from graves where {}", pending_usn.pending_object_clause() ))?; let mut rows = stmt.query([pending_usn])?; let mut graves = Graves::default(); while let Some(row) = rows.next()? { let oid: i64 = row.get(0)?; let kind = GraveKind::try_from(row.get::<_, u8>(1)?).or_invalid("invalid grave kind")?; match kind { GraveKind::Card => graves.cards.push(CardId(oid)), GraveKind::Note => graves.notes.push(NoteId(oid)), GraveKind::Deck => graves.decks.push(DeckId(oid)), } } Ok(graves) } pub(crate) fn update_pending_grave_usns(&self, new_usn: Usn) -> Result<()> { self.db .prepare("update graves set usn=? where usn=-1")? .execute([new_usn])?; Ok(()) } fn add_grave(&self, oid: i64, kind: GraveKind, usn: Usn) -> Result<()> { self.db .prepare_cached(include_str!("add.sql"))? .execute(params![usn, oid, kind as u8])?; Ok(()) } /// Only useful when undoing fn remove_grave(&self, oid: i64, kind: GraveKind) -> Result<()> { self.db .prepare_cached(include_str!("remove.sql"))? .execute(params![oid, kind as u8])?; Ok(()) } } ================================================ FILE: rslib/src/storage/graves/remove.sql ================================================ DELETE FROM graves WHERE oid = ? AND type = ? ================================================ FILE: rslib/src/storage/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub(crate) mod card; mod collection_timestamps; mod config; mod dbcheck; mod deck; mod deckconfig; mod graves; mod note; mod notetype; mod revlog; mod sqlite; mod sync; mod sync_check; mod tag; mod upgrades; use std::fmt::Write; pub(crate) use sqlite::ProcessTextFlags; pub(crate) use sqlite::SqliteStorage; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SchemaVersion { V11, V18, } impl SchemaVersion { pub(super) fn has_journal_mode_delete(self) -> bool { self == Self::V11 } } /// Write a list of IDs as '(x,y,...)' into the provided string. pub fn ids_to_string(buf: &mut String, ids: I) where D: std::fmt::Display, I: IntoIterator, { buf.push('('); write_comma_separated_ids(buf, ids); buf.push(')'); } /// Write a list of Ids as 'x,y,...' into the provided string. pub(crate) fn write_comma_separated_ids(buf: &mut String, ids: I) where D: std::fmt::Display, I: IntoIterator, { let mut trailing_sep = false; for id in ids { write!(buf, "{id},").unwrap(); trailing_sep = true; } if trailing_sep { buf.pop(); } } pub(crate) fn comma_separated_ids(ids: &[T]) -> String where T: std::fmt::Display, { let mut buf = String::new(); write_comma_separated_ids(&mut buf, ids); buf } #[cfg(test)] mod test { use super::ids_to_string; #[test] fn ids_string() { let mut s = String::new(); ids_to_string(&mut s, [0; 0]); assert_eq!(s, "()"); s.clear(); ids_to_string(&mut s, [7]); assert_eq!(s, "(7)"); s.clear(); ids_to_string(&mut s, [7, 6]); assert_eq!(s, "(7,6)"); s.clear(); ids_to_string(&mut s, [7, 6, 5]); assert_eq!(s, "(7,6,5)"); s.clear(); } } ================================================ FILE: rslib/src/storage/note/add.sql ================================================ INSERT INTO notes ( id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data ) VALUES ( ( CASE WHEN ?1 IN ( SELECT id FROM notes ) THEN ( SELECT max(id) + 1 FROM notes ) ELSE ?1 END ), ?, ?, ?, ?, ?, ?, ?, ?, 0, "" ) ================================================ FILE: rslib/src/storage/note/add_if_unique.sql ================================================ INSERT OR IGNORE INTO notes ( id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, "" ) ================================================ FILE: rslib/src/storage/note/add_or_update.sql ================================================ INSERT OR REPLACE INTO notes ( id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, "" ) ================================================ FILE: rslib/src/storage/note/get.sql ================================================ SELECT id, guid, mid, mod, usn, tags, flds, cast(sfld AS text), csum FROM notes ================================================ FILE: rslib/src/storage/note/get_tags.sql ================================================ SELECT id, mod, usn, tags FROM notes ================================================ FILE: rslib/src/storage/note/get_without_fields.sql ================================================ SELECT id, guid, mid, mod, usn, tags, "", cast(sfld AS text), csum FROM notes ================================================ FILE: rslib/src/storage/note/is_orphaned.sql ================================================ SELECT COUNT(id) = 0 FROM cards WHERE nid = ?; ================================================ FILE: rslib/src/storage/note/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::collections::HashSet; use rusqlite::params; use rusqlite::Row; use unicase::UniCase; use crate::import_export::package::NoteMeta; use crate::notes::NoteTags; use crate::prelude::*; use crate::tags::immediate_parent_name_unicase; use crate::tags::join_tags; use crate::tags::split_tags; pub(crate) fn split_fields(fields: &str) -> Vec { fields.split('\x1f').map(Into::into).collect() } pub(crate) fn join_fields(fields: &[String]) -> String { fields.join("\x1f") } impl super::SqliteStorage { pub fn get_note(&self, nid: NoteId) -> Result> { self.db .prepare_cached(concat!(include_str!("get.sql"), " where id = ?"))? .query_and_then(params![nid], row_to_note)? .next() .transpose() } pub fn get_note_without_fields(&self, nid: NoteId) -> Result> { self.db .prepare_cached(concat!( include_str!("get_without_fields.sql"), " where id = ?" ))? .query_and_then(params![nid], row_to_note)? .next() .transpose() } pub fn get_all_note_ids(&self) -> Result> { self.db .prepare("SELECT id FROM notes")? .query_and_then([], |row| Ok(row.get(0)?))? .collect() } /// If fields have been modified, caller must call note.prepare_for_update() /// prior to calling this. pub(crate) fn update_note(&self, note: &Note) -> Result<()> { assert_ne!(note.id.0, 0); let mut stmt = self.db.prepare_cached(include_str!("update.sql"))?; stmt.execute(params![ note.guid, note.notetype_id, note.mtime, note.usn, join_tags(¬e.tags), join_fields(note.fields()), note.sort_field.as_ref().unwrap(), note.checksum.unwrap(), note.id ])?; Ok(()) } pub(crate) fn add_note(&self, note: &mut Note) -> Result<()> { assert_eq!(note.id.0, 0); let mut stmt = self.db.prepare_cached(include_str!("add.sql"))?; stmt.execute(params![ TimestampMillis::now(), note.guid, note.notetype_id, note.mtime, note.usn, join_tags(¬e.tags), join_fields(note.fields()), note.sort_field.as_ref().unwrap(), note.checksum.unwrap(), ])?; note.id.0 = self.db.last_insert_rowid(); Ok(()) } pub(crate) fn add_note_if_unique(&self, note: &Note) -> Result { self.db .prepare_cached(include_str!("add_if_unique.sql"))? .execute(params![ note.id, note.guid, note.notetype_id, note.mtime, note.usn, join_tags(¬e.tags), join_fields(note.fields()), note.sort_field.as_ref().unwrap(), note.checksum.unwrap(), ]) .map(|added| added == 1) .map_err(Into::into) } /// Add or update the provided note, preserving ID. Used by the syncing /// code. pub(crate) fn add_or_update_note(&self, note: &Note) -> Result<()> { let mut stmt = self.db.prepare_cached(include_str!("add_or_update.sql"))?; stmt.execute(params![ note.id, note.guid, note.notetype_id, note.mtime, note.usn, join_tags(¬e.tags), join_fields(note.fields()), note.sort_field.as_ref().unwrap(), note.checksum.unwrap(), ])?; Ok(()) } pub(crate) fn remove_note(&self, nid: NoteId) -> Result<()> { self.db .prepare_cached("delete from notes where id = ?")? .execute([nid])?; Ok(()) } pub(crate) fn note_is_orphaned(&self, nid: NoteId) -> Result { self.db .prepare_cached(include_str!("is_orphaned.sql"))? .query_row([nid], |r| r.get(0)) .map_err(Into::into) } pub(crate) fn clear_pending_note_usns(&self) -> Result<()> { self.db .prepare("update notes set usn = 0 where usn = -1")? .execute([])?; Ok(()) } pub(crate) fn fix_invalid_utf8_in_note(&self, nid: NoteId) -> Result<()> { self.db .query_row( "select cast(flds as blob), cast(tags as blob) from notes where id=?", [nid], |row| { let fixed_flds: Vec = row.get(0)?; let fixed_str = String::from_utf8_lossy(&fixed_flds); let fixed_tags: Vec = row.get(1)?; let fixed_tags = String::from_utf8_lossy(&fixed_tags); self.db.execute( "update notes set flds = ?, sfld = '', tags = ? where id = ?", params![fixed_str, fixed_tags, nid], ) }, ) .map_err(Into::into) .map(|_| ()) } /// Returns [(nid, field 0)] of notes with the same checksum. /// The caller should strip the fields and compare to see if they actually /// match. pub(crate) fn note_fields_by_checksum( &self, ntid: NotetypeId, csum: u32, ) -> Result> { self.db .prepare("select id, field_at_index(flds, 0) from notes where csum=? and mid=?")? .query_and_then(params![csum, ntid], |r| Ok((r.get(0)?, r.get(1)?)))? .collect() } /// Returns [(nid, field 0)] of notes with the same checksum. /// The caller should strip the fields and compare to see if they actually /// match. pub(crate) fn all_notes_by_type_and_checksum( &self, ) -> Result>> { let mut map = HashMap::new(); let mut stmt = self.db.prepare("SELECT mid, csum, id FROM notes")?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { map.entry((row.get(0)?, row.get(1)?)) .or_insert_with(Vec::new) .push(row.get(2)?); } Ok(map) } pub(crate) fn all_notes_by_type_checksum_and_deck( &self, ) -> Result>> { let mut map = HashMap::new(); let mut stmt = self .db .prepare(include_str!("notes_types_checksums_decks.sql"))?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { map.entry((row.get(1)?, row.get(2)?, row.get(3)?)) .or_insert_with(Vec::new) .push(row.get(0)?); } Ok(map) } /// Return total number of notes. Slow. pub(crate) fn total_notes(&self) -> Result { self.db .prepare("select count() from notes")? .query_row([], |r| r.get(0)) .map_err(Into::into) } /// All tags referenced by notes, and any parent tags as well. pub(crate) fn all_tags_in_notes(&self) -> Result>> { let mut stmt = self .db .prepare_cached("select tags from notes where tags != ''")?; let mut query = stmt.query([])?; let mut seen: HashSet> = HashSet::new(); while let Some(rows) = query.next()? { for tag in split_tags(rows.get_ref_unwrap(0).as_str()?) { seen.insert(UniCase::new(tag.to_string())); let mut tag_unicase = UniCase::new(tag); while let Some(parent_name) = immediate_parent_name_unicase(tag_unicase) { seen.insert(UniCase::new(parent_name.to_string())); tag_unicase = UniCase::new(&parent_name); } } } Ok(seen) } pub(crate) fn get_note_tags_by_id(&mut self, note_id: NoteId) -> Result> { self.db .prepare_cached(&format!("{} where id = ?", include_str!("get_tags.sql")))? .query_and_then([note_id], row_to_note_tags)? .next() .transpose() } pub(crate) fn get_note_tags_by_id_list(&self, note_ids: &[NoteId]) -> Result> { self.with_ids_in_searched_notes_table(note_ids, || { self.db .prepare_cached(&format!( "{} where id in (select nid from search_nids)", include_str!("get_tags.sql") ))? .query_and_then([], row_to_note_tags)? .collect() }) } pub(crate) fn for_each_note_tag_in_searched_notes(&self, mut func: F) -> Result<()> where F: FnMut(&str), { let mut stmt = self .db .prepare_cached("select tags from notes where id in (select nid from search_nids)")?; let mut rows = stmt.query(params![])?; while let Some(row) = rows.next()? { func(row.get_ref(0)?.as_str()?); } Ok(()) } pub(crate) fn all_searched_notes(&self) -> Result> { self.db .prepare_cached(concat!( include_str!("get.sql"), " WHERE id IN (SELECT nid FROM search_nids)" ))? .query_and_then([], row_to_note)? .collect() } pub(crate) fn get_note_tags_by_predicate(&mut self, want: F) -> Result> where F: Fn(&str) -> bool, { let mut query_stmt = self.db.prepare_cached(include_str!("get_tags.sql"))?; let mut rows = query_stmt.query([])?; let mut output = vec![]; while let Some(row) = rows.next()? { let tags = row.get_ref_unwrap(3).as_str()?; if want(tags) { output.push(row_to_note_tags(row)?) } } Ok(output) } pub(crate) fn update_note_tags(&mut self, note: &NoteTags) -> Result<()> { self.db .prepare_cached(include_str!("update_tags.sql"))? .execute(params![note.mtime, note.usn, note.tags, note.id])?; Ok(()) } pub(crate) fn setup_searched_notes_table(&self) -> Result<()> { self.db .execute_batch(include_str!("search_nids_setup.sql"))?; Ok(()) } pub(crate) fn clear_searched_notes_table(&self) -> Result<()> { self.db.execute("drop table if exists search_nids", [])?; Ok(()) } /// Executes the closure with the note ids placed in the search_nids table. /// WARNING: the column name is nid, not id. pub(crate) fn with_ids_in_searched_notes_table( &self, note_ids: &[NoteId], func: impl FnOnce() -> Result, ) -> Result { self.setup_searched_notes_table()?; let mut stmt = self .db .prepare_cached("insert into search_nids values (?)")?; for nid in note_ids { stmt.execute([nid])?; } let result = func(); self.clear_searched_notes_table()?; result } /// Cards will arrive in card id order, not search order. pub(crate) fn for_each_note_in_search( &self, mut func: impl FnMut(Note) -> Result<()>, ) -> Result<()> { let mut stmt = self.db.prepare_cached(concat!( include_str!("get.sql"), " WHERE id IN (SELECT nid FROM search_nids)" ))?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { let note = row_to_note(row)?; func(note)? } Ok(()) } pub(crate) fn note_guid_map(&mut self) -> Result> { self.db .prepare("SELECT guid, id, mod, mid FROM notes")? .query_and_then([], row_to_note_meta)? .collect() } pub(crate) fn all_notes_by_guid(&mut self) -> Result> { self.db .prepare("SELECT guid, id FROM notes")? .query_and_then([], |r| Ok((r.get(0)?, r.get(1)?)))? .collect() } #[cfg(test)] pub(crate) fn get_all_notes(&mut self) -> Vec { self.db .prepare("SELECT * FROM notes") .unwrap() .query_and_then([], row_to_note) .unwrap() .collect::>() .unwrap() } #[cfg(test)] pub(crate) fn notes_table_len(&mut self) -> usize { self.db_scalar("SELECT COUNT(*) FROM notes").unwrap() } } fn row_to_note(row: &Row) -> Result { Ok(Note::new_from_storage( row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, split_tags(row.get_ref_unwrap(5).as_str()?) .map(Into::into) .collect(), split_fields(row.get_ref_unwrap(6).as_str()?), Some(row.get(7)?), Some(row.get(8).unwrap_or_default()), )) } fn row_to_note_tags(row: &Row) -> Result { Ok(NoteTags { id: row.get(0)?, mtime: row.get(1)?, usn: row.get(2)?, tags: row.get(3)?, }) } fn row_to_note_meta(row: &Row) -> Result<(String, NoteMeta)> { Ok(( row.get(0)?, NoteMeta::new(row.get(1)?, row.get(2)?, row.get(3)?), )) } ================================================ FILE: rslib/src/storage/note/notes_types_checksums_decks.sql ================================================ SELECT DISTINCT notes.id, notes.mid, notes.csum, CASE WHEN cards.odid = 0 THEN cards.did ELSE cards.odid END AS did FROM notes JOIN cards ON notes.id = cards.nid ================================================ FILE: rslib/src/storage/note/search_nids_setup.sql ================================================ DROP TABLE IF EXISTS search_nids; CREATE TEMPORARY TABLE search_nids (nid integer PRIMARY KEY NOT NULL); ================================================ FILE: rslib/src/storage/note/update.sql ================================================ UPDATE notes SET guid = ?, mid = ?, mod = ?, usn = ?, tags = ?, flds = ?, sfld = ?, csum = ? WHERE id = ? ================================================ FILE: rslib/src/storage/note/update_tags.sql ================================================ UPDATE notes SET mod = ?, usn = ?, tags = ? WHERE id = ? ================================================ FILE: rslib/src/storage/notetype/add_notetype.sql ================================================ INSERT INTO notetypes (id, name, mtime_secs, usn, config) VALUES ( ( CASE WHEN ?1 IN ( SELECT id FROM notetypes ) THEN ( SELECT max(id) + 1 FROM notetypes ) ELSE ?1 END ), ?, ?, ?, ? ); ================================================ FILE: rslib/src/storage/notetype/add_or_update.sql ================================================ INSERT OR REPLACE INTO notetypes (id, name, mtime_secs, usn, config) VALUES (?, ?, ?, ?, ?); ================================================ FILE: rslib/src/storage/notetype/existing_cards.sql ================================================ SELECT id, nid, ord, -- original deck ( CASE odid WHEN 0 THEN did ELSE odid END ), -- new position if card is empty ( CASE type WHEN 0 THEN ( CASE odue WHEN 0 THEN max(0, due) ELSE max(odue, 0) END ) ELSE NULL END ) FROM cards c ================================================ FILE: rslib/src/storage/notetype/field_names_for_notes.sql ================================================ SELECT DISTINCT name FROM fields WHERE ntid IN ( SELECT mid FROM notes WHERE id IN ================================================ FILE: rslib/src/storage/notetype/get_fields.sql ================================================ SELECT ord, name, config FROM fields WHERE ntid = ? ORDER BY ord ================================================ FILE: rslib/src/storage/notetype/get_notetype.sql ================================================ SELECT id, name, mtime_secs, usn, config FROM notetypes ================================================ FILE: rslib/src/storage/notetype/get_notetype_names.sql ================================================ SELECT id, name FROM notetypes ================================================ FILE: rslib/src/storage/notetype/get_templates.sql ================================================ SELECT ord, name, mtime_secs, usn, config FROM templates WHERE ntid = ? ORDER BY ord ================================================ FILE: rslib/src/storage/notetype/get_use_counts.sql ================================================ SELECT nt.id, nt.name, ( SELECT COUNT(*) FROM notes n WHERE nt.id = n.mid ) FROM notetypes nt ORDER BY nt.name ================================================ FILE: rslib/src/storage/notetype/highest_card_ord.sql ================================================ SELECT coalesce(max(ord), 0) FROM cards WHERE nid IN ( SELECT id FROM notes WHERE mid = ? ) ================================================ FILE: rslib/src/storage/notetype/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::collections::HashSet; use prost::Message; use rusqlite::params; use rusqlite::OptionalExtension; use rusqlite::Row; use unicase::UniCase; use super::ids_to_string; use super::SqliteStorage; use crate::error::DbErrorKind; use crate::notetype::AlreadyGeneratedCardInfo; use crate::notetype::CardTemplate; use crate::notetype::CardTemplateConfig; use crate::notetype::NoteField; use crate::notetype::NoteFieldConfig; use crate::notetype::NotetypeConfig; use crate::notetype::NotetypeSchema11; use crate::prelude::*; fn row_to_notetype_core(row: &Row) -> Result { let config = NotetypeConfig::decode(row.get_ref_unwrap(4).as_blob()?)?; Ok(Notetype { id: row.get(0)?, name: row.get(1)?, mtime_secs: row.get(2)?, usn: row.get(3)?, config, fields: vec![], templates: vec![], }) } fn row_to_existing_card(row: &Row) -> Result { Ok(AlreadyGeneratedCardInfo { id: row.get(0)?, nid: row.get(1)?, ord: row.get(2)?, original_deck_id: row.get(3)?, position_if_new: row.get(4).ok().unwrap_or_default(), }) } impl SqliteStorage { pub(crate) fn get_notetype(&self, ntid: NotetypeId) -> Result> { match self.get_notetype_core(ntid)? { Some(mut nt) => { nt.fields = self.get_notetype_fields(ntid)?; nt.templates = self.get_notetype_templates(ntid)?; Ok(Some(nt)) } None => Ok(None), } } fn get_notetype_core(&self, ntid: NotetypeId) -> Result> { self.db .prepare_cached(concat!(include_str!("get_notetype.sql"), " where id = ?"))? .query_and_then([ntid], row_to_notetype_core)? .next() .transpose() } fn get_notetype_fields(&self, ntid: NotetypeId) -> Result> { self.db .prepare_cached(include_str!("get_fields.sql"))? .query_and_then([ntid], |row| { let config = NoteFieldConfig::decode(row.get_ref_unwrap(2).as_blob()?)?; Ok(NoteField { ord: Some(row.get(0)?), name: row.get(1)?, config, }) })? .collect() } fn get_notetype_templates(&self, ntid: NotetypeId) -> Result> { self.db .prepare_cached(include_str!("get_templates.sql"))? .query_and_then([ntid], |row| { let config = CardTemplateConfig::decode(row.get_ref_unwrap(4).as_blob()?)?; Ok(CardTemplate { ord: row.get(0)?, name: row.get(1)?, mtime_secs: row.get(2)?, usn: row.get(3)?, config, }) })? .collect() } pub(crate) fn get_notetype_id(&self, name: &str) -> Result> { self.db .prepare_cached("select id from notetypes where name = ?")? .query_row(params![name], |row| row.get(0)) .optional() .map_err(Into::into) } pub(crate) fn get_notetypes_for_search_notes(&self) -> Result> { self.db .prepare_cached(concat!( include_str!("get_notetype.sql"), " WHERE id IN (SELECT DISTINCT mid FROM notes WHERE id IN", " (SELECT nid FROM search_nids))", ))? .query_and_then([], |r| { row_to_notetype_core(r).and_then(|mut nt| { nt.fields = self.get_notetype_fields(nt.id)?; nt.templates = self.get_notetype_templates(nt.id)?; Ok(nt) }) })? .collect() } pub(crate) fn all_notetypes_of_search_notes(&self) -> Result> { self.db .prepare_cached( "SELECT DISTINCT mid FROM notes WHERE id IN (SELECT nid FROM search_nids)", )? .query_and_then([], |r| Ok(r.get(0)?))? .collect() } pub(crate) fn used_notetypes(&self) -> Result> { self.db .prepare_cached("SELECT DISTINCT mid FROM notes")? .query_and_then([], |r| Ok(r.get(0)?))? .collect() } pub fn get_all_notetype_names(&self) -> Result> { self.db .prepare_cached(include_str!("get_notetype_names.sql"))? .query_and_then([], |row| Ok((row.get(0)?, row.get(1)?)))? .collect() } pub fn get_all_notetype_ids(&self) -> Result> { self.db .prepare_cached("SELECT id FROM notetypes")? .query_and_then([], |row| row.get(0).map_err(Into::into))? .collect() } /// Returns list of (id, name, use_count) pub fn get_notetype_use_counts(&self) -> Result> { self.db .prepare_cached(include_str!("get_use_counts.sql"))? .query_and_then([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))? .collect() } fn update_notetype_fields(&self, ntid: NotetypeId, fields: &[NoteField]) -> Result<()> { self.db .prepare_cached("delete from fields where ntid=?")? .execute([ntid])?; let mut stmt = self.db.prepare_cached(include_str!("update_fields.sql"))?; for (ord, field) in fields.iter().enumerate() { let mut config_bytes = vec![]; field.config.encode(&mut config_bytes)?; stmt.execute(params![ntid, ord as u32, field.name, config_bytes,])?; } Ok(()) } /// A sorted list of all field names used by provided notes, for use with /// the find&replace feature. pub(crate) fn field_names_for_notes(&self, nids: &[NoteId]) -> Result> { let mut sql = include_str!("field_names_for_notes.sql").to_string(); sql.push(' '); ids_to_string(&mut sql, nids); sql += ") order by name"; self.db .prepare(&sql)? .query_and_then([], |r| r.get(0).map_err(Into::into))? .collect() } pub(crate) fn note_ids_by_notetype( &self, nids: &[NoteId], ) -> Result> { let mut sql = String::from("select mid, id from notes where id in "); ids_to_string(&mut sql, nids); sql += " order by mid, id"; self.db .prepare(&sql)? .query_and_then([], |r| Ok((r.get(0)?, r.get(1)?)))? .collect() } pub(crate) fn all_note_ids_by_notetype(&self) -> Result> { let sql = String::from("select mid, id from notes order by mid, id"); self.db .prepare(&sql)? .query_and_then([], |r| Ok((r.get(0)?, r.get(1)?)))? .collect() } fn update_notetype_templates( &self, ntid: NotetypeId, templates: &[CardTemplate], ) -> Result<()> { self.db .prepare_cached("delete from templates where ntid=?")? .execute([ntid])?; let mut stmt = self .db .prepare_cached(include_str!("update_templates.sql"))?; for (ord, template) in templates.iter().enumerate() { let mut config_bytes = vec![]; template.config.encode(&mut config_bytes)?; stmt.execute(params![ ntid, ord as u32, template.name, template.mtime_secs, template.usn, config_bytes, ])?; } Ok(()) } /// Notetype should have an existing id, and will be added if missing. fn update_notetype_core(&self, nt: &Notetype) -> Result<()> { require!(nt.id.0 != 0, "notetype with id 0 passed in as existing"); let mut stmt = self.db.prepare_cached(include_str!("add_or_update.sql"))?; let mut config_bytes = vec![]; nt.config.encode(&mut config_bytes)?; stmt.execute(params![nt.id, nt.name, nt.mtime_secs, nt.usn, config_bytes])?; Ok(()) } pub(crate) fn add_notetype(&self, nt: &mut Notetype) -> Result<()> { assert_eq!(nt.id.0, 0); let mut stmt = self.db.prepare_cached(include_str!("add_notetype.sql"))?; let mut config_bytes = vec![]; nt.config.encode(&mut config_bytes)?; stmt.execute(params![ TimestampMillis::now(), nt.name, nt.mtime_secs, nt.usn, config_bytes ])?; nt.id.0 = self.db.last_insert_rowid(); self.update_notetype_fields(nt.id, &nt.fields)?; self.update_notetype_templates(nt.id, &nt.templates)?; Ok(()) } /// Used for both regular updates, and for syncing/import. pub(crate) fn add_or_update_notetype_with_existing_id(&self, nt: &Notetype) -> Result<()> { self.update_notetype_core(nt)?; self.update_notetype_fields(nt.id, &nt.fields)?; self.update_notetype_templates(nt.id, &nt.templates)?; Ok(()) } pub(crate) fn remove_notetype(&self, ntid: NotetypeId) -> Result<()> { self.db .prepare_cached("delete from templates where ntid=?")? .execute([ntid])?; self.db .prepare_cached("delete from fields where ntid=?")? .execute([ntid])?; self.db .prepare_cached("delete from notetypes where id=?")? .execute([ntid])?; Ok(()) } pub(crate) fn existing_cards_for_notetype( &self, ntid: NotetypeId, ) -> Result> { self.db .prepare_cached(concat!( include_str!("existing_cards.sql"), " where c.nid in (select id from notes where mid=?)" ))? .query_and_then([ntid], row_to_existing_card)? .collect() } pub(crate) fn existing_cards_for_note( &self, nid: NoteId, ) -> Result> { self.db .prepare_cached(concat!( include_str!("existing_cards.sql"), " where c.nid = ?" ))? .query_and_then([nid], row_to_existing_card)? .collect() } pub(crate) fn clear_notetype_usns(&self) -> Result<()> { self.db .prepare("update notetypes set usn = 0 where usn != 0")? .execute([])?; Ok(()) } pub(crate) fn highest_card_ordinal_for_notetype(&self, ntid: NotetypeId) -> Result { self.db .prepare(include_str!("highest_card_ord.sql"))? .query_row([ntid], |row| row.get(0)) .map_err(Into::into) } // Upgrading/downgrading/legacy pub(crate) fn get_all_notetypes_as_schema11( &self, ) -> Result> { let mut nts = HashMap::new(); for (ntid, _name) in self.get_all_notetype_names()? { let full = self.get_notetype(ntid)?.unwrap(); nts.insert(ntid, full.into()); } Ok(nts) } pub(crate) fn upgrade_notetypes_to_schema15(&self) -> Result<()> { let nts = self .get_schema11_notetypes() .map_err(|e| AnkiError::JsonError { info: format!("decoding models: {e:?}"), })?; let mut names = HashSet::new(); for (mut ntid, nt) in nts { let mut nt = Notetype::from(nt); // note types with id 0 found in the wild; assign a random ID if ntid.0 == 0 { ntid.0 = rand::random::().max(1) as i64; nt.id = ntid; } nt.normalize_names(); nt.ensure_names_unique(); loop { let name = UniCase::new(nt.name.clone()); if !names.contains(&name) { names.insert(name); break; } nt.name.push('_'); } self.update_notetype_core(&nt)?; self.update_notetype_fields(ntid, &nt.fields)?; self.update_notetype_templates(ntid, &nt.templates)?; } self.db.execute("update col set models = ''", [])?; Ok(()) } pub(crate) fn downgrade_notetypes_from_schema15(&self) -> Result<()> { let nts = self.get_all_notetypes_as_schema11()?; self.set_schema11_notetypes(nts) } fn get_schema11_notetypes(&self) -> Result> { let mut stmt = self.db.prepare("select models from col")?; let notetypes = stmt .query_and_then([], |row| -> Result> { let v: HashMap = serde_json::from_value(serde_json::from_str(row.get_ref_unwrap(0).as_str()?)?)?; Ok(v) })? .next() .ok_or_else(|| AnkiError::db_error("col table empty", DbErrorKind::MissingEntity))??; Ok(notetypes) } pub(crate) fn set_schema11_notetypes( &self, notetypes: HashMap, ) -> Result<()> { let json = serde_json::to_string(¬etypes)?; self.db.execute("update col set models = ?", [json])?; Ok(()) } pub(crate) fn get_field_names(&self, notetype_id: NotetypeId) -> Result> { self.db .prepare_cached("SELECT name FROM fields WHERE ntid = ? ORDER BY ord")? .query_and_then([notetype_id], |row| Ok(row.get(0)?))? .collect() } } ================================================ FILE: rslib/src/storage/notetype/update_fields.sql ================================================ INSERT INTO fields (ntid, ord, name, config) VALUES (?, ?, ?, ?); ================================================ FILE: rslib/src/storage/notetype/update_notetype_config.sql ================================================ INSERT OR REPLACE INTO notetype_config (ntid, config) VALUES (?, ?) ================================================ FILE: rslib/src/storage/notetype/update_templates.sql ================================================ INSERT INTO templates (ntid, ord, name, mtime_secs, usn, config) VALUES (?, ?, ?, ?, ?, ?) ================================================ FILE: rslib/src/storage/revlog/add.sql ================================================ INSERT OR IGNORE INTO revlog ( id, cid, usn, ease, ivl, lastIvl, factor, time, type ) VALUES ( ( CASE WHEN ?1 AND ?2 IN ( SELECT id FROM revlog ) THEN ( SELECT max(id) + 1 FROM revlog ) ELSE ?2 END ), ?, ?, ?, ?, ?, ?, ?, ? ) ================================================ FILE: rslib/src/storage/revlog/fix_props.sql ================================================ UPDATE revlog SET ivl = min(max(round(ivl), -2147483648), 2147483647), lastIvl = min(max(round(lastIvl), -2147483648), 2147483647), time = min(max(round(time), 0), 2147483647), type = ( CASE WHEN type = 0 AND time = 0 AND ease = 0 THEN 5 ELSE type END ) WHERE ivl != min(max(round(ivl), -2147483648), 2147483647) OR lastIvl != min(max(round(lastIvl), -2147483648), 2147483647) OR time != min(max(round(time), 0), 2147483647) OR type != ( CASE WHEN type = 0 AND time = 0 AND ease = 0 THEN 5 ELSE type END ) ================================================ FILE: rslib/src/storage/revlog/get.sql ================================================ SELECT id, cid, usn, ease, cast(ivl AS integer), cast(lastIvl AS integer), factor, time, type FROM revlog ================================================ FILE: rslib/src/storage/revlog/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::convert::TryFrom; use rusqlite::params; use rusqlite::types::FromSql; use rusqlite::types::FromSqlError; use rusqlite::types::ValueRef; use rusqlite::OptionalExtension; use rusqlite::Row; use super::SqliteStorage; use crate::error::Result; use crate::prelude::*; use crate::revlog::RevlogEntry; use crate::revlog::RevlogReviewKind; pub(crate) struct StudiedToday { pub cards: u32, pub seconds: f64, } impl FromSql for RevlogReviewKind { fn column_result(value: ValueRef<'_>) -> std::result::Result { if let ValueRef::Integer(i) = value { Ok(Self::try_from(i as u8).map_err(|_| FromSqlError::InvalidType)?) } else { Err(FromSqlError::InvalidType) } } } fn row_to_revlog_entry(row: &Row) -> Result { Ok(RevlogEntry { id: row.get(0)?, cid: row.get(1)?, usn: row.get(2)?, button_chosen: row.get(3)?, interval: row.get(4)?, last_interval: row.get(5)?, ease_factor: row.get(6)?, taken_millis: row.get(7).unwrap_or_default(), review_kind: row.get(8).unwrap_or_default(), }) } impl SqliteStorage { pub(crate) fn fix_revlog_properties(&self) -> Result { self.db .prepare(include_str!("fix_props.sql"))? .execute([]) .map_err(Into::into) } pub(crate) fn clear_pending_revlog_usns(&self) -> Result<()> { self.db .prepare("update revlog set usn = 0 where usn = -1")? .execute([])?; Ok(()) } /// Adds the entry, if its id is unique. If it is not, and `uniquify` is /// true, adds it with a new id. Returns the added id. /// (I.e., the option is safe to unwrap, if `uniquify` is true.) pub(crate) fn add_revlog_entry( &self, entry: &RevlogEntry, uniquify: bool, ) -> Result> { let added = self .db .prepare_cached(include_str!("add.sql"))? .execute(params![ uniquify, entry.id, entry.cid, entry.usn, entry.button_chosen, entry.interval, entry.last_interval, entry.ease_factor, entry.taken_millis, entry.review_kind as u8 ])?; Ok((added > 0).then(|| RevlogId(self.db.last_insert_rowid()))) } pub(crate) fn get_revlog_entry(&self, id: RevlogId) -> Result> { self.db .prepare_cached(concat!(include_str!("get.sql"), " where id=?"))? .query_and_then([id], row_to_revlog_entry)? .next() .transpose() } /// Determine the the last review time based on the revlog. pub(crate) fn time_of_last_review(&self, card_id: CardId) -> Result> { self.db .prepare_cached(include_str!("time_of_last_review.sql"))? .query_row([card_id], |row| row.get(0)) .optional() .map_err(Into::into) } /// Only intended to be used by the undo code, as Anki can not sync revlog /// deletions. pub(crate) fn remove_revlog_entry(&self, id: RevlogId) -> Result<()> { self.db .prepare_cached("delete from revlog where id = ?")? .execute([id])?; Ok(()) } pub(crate) fn get_revlog_entries_for_card(&self, cid: CardId) -> Result> { self.db .prepare_cached(concat!(include_str!("get.sql"), " where cid=?"))? .query_and_then([cid], row_to_revlog_entry)? .collect() } pub(crate) fn get_revlog_entries_for_searched_cards_after_stamp( &self, after: TimestampSecs, ) -> Result> { self.db .prepare_cached(concat!( include_str!("get.sql"), " where cid in (select cid from search_cids) and id >= ?" ))? .query_and_then([after.0 * 1000], row_to_revlog_entry)? .collect() } pub(crate) fn get_revlog_entries_for_searched_cards(&self) -> Result> { self.db .prepare_cached(concat!( include_str!("get.sql"), " where cid in (select cid from search_cids)" ))? .query_and_then([], row_to_revlog_entry)? .collect() } pub(crate) fn get_revlog_entries_for_searched_cards_in_card_order( &self, ) -> Result> { self.db .prepare_cached(concat!( include_str!("get.sql"), " where cid in (select cid from search_cids) order by cid, id" ))? .query_and_then([], row_to_revlog_entry)? .collect() } pub(crate) fn get_revlog_entries_for_export_dataset(&self) -> Result> { self.db .prepare_cached(concat!( include_str!("get.sql"), " where (ease between 1 and 4) or (ease = 0 and factor = 0)", " order by cid, id" ))? .query_and_then([], row_to_revlog_entry)? .collect() } pub(crate) fn get_all_revlog_entries_in_card_order(&self) -> Result> { self.db .prepare_cached(concat!(include_str!("get.sql"), " order by cid, id"))? .query_and_then([], row_to_revlog_entry)? .collect() } pub(crate) fn get_all_revlog_entries(&self, after: TimestampSecs) -> Result> { self.db .prepare_cached(concat!(include_str!("get.sql"), " where id >= ?"))? .query_and_then([after.0 * 1000], row_to_revlog_entry)? .collect() } pub(crate) fn studied_today(&self, day_cutoff: TimestampSecs) -> Result { let start = day_cutoff.adding_secs(-86_400).as_millis(); self.db .prepare_cached(include_str!("studied_today.sql"))? .query_map( [ start.0, RevlogReviewKind::Manual as i64, RevlogReviewKind::Rescheduled as i64, ], |row| { Ok(StudiedToday { cards: row.get(0)?, seconds: row.get(1)?, }) }, )? .next() .unwrap() .map_err(Into::into) } pub(crate) fn studied_today_by_deck( &self, day_cutoff: TimestampSecs, ) -> Result> { let start = day_cutoff.adding_secs(-86_400).as_millis(); self.db .prepare_cached(include_str!("studied_today_by_deck.sql"))? .query_and_then([start.0], |row| -> Result<_> { Ok((DeckId(row.get(0)?), row.get(1)?)) })? .collect() } pub(crate) fn upgrade_revlog_to_v2(&self) -> Result<()> { self.db .execute_batch(include_str!("v2_upgrade.sql")) .map_err(Into::into) } } ================================================ FILE: rslib/src/storage/revlog/studied_today.sql ================================================ SELECT COUNT(), coalesce(sum(time) / 1000.0, 0.0) FROM revlog WHERE id > ? AND type != ? AND type != ? ================================================ FILE: rslib/src/storage/revlog/studied_today_by_deck.sql ================================================ SELECT CASE WHEN c.odid == 0 THEN c.did ELSE c.odid END AS original_did, COUNT(DISTINCT r.cid) AS cnt FROM revlog AS r JOIN cards AS c ON r.cid = c.id WHERE r.id > ? AND r.ease > 0 AND ( r.type < 3 OR r.factor != 0 ) GROUP BY original_did ================================================ FILE: rslib/src/storage/revlog/time_of_last_review.sql ================================================ SELECT id / 1000 FROM revlog WHERE cid = $1 AND ease BETWEEN 1 AND 4 AND ( type != 3 OR factor != 0 ) ORDER BY id DESC LIMIT 1 ================================================ FILE: rslib/src/storage/revlog/v2_upgrade.sql ================================================ UPDATE revlog SET ease = ease + 1 WHERE ease IN (2, 3) AND type IN (0, 2); ================================================ FILE: rslib/src/storage/schema11.sql ================================================ CREATE TABLE col ( id integer PRIMARY KEY, crt integer NOT NULL, mod integer NOT NULL, scm integer NOT NULL, ver integer NOT NULL, dty integer NOT NULL, usn integer NOT NULL, ls integer NOT NULL, conf text NOT NULL, models text NOT NULL, decks text NOT NULL, dconf text NOT NULL, tags text NOT NULL ); CREATE TABLE notes ( id integer PRIMARY KEY, guid text NOT NULL, mid integer NOT NULL, mod integer NOT NULL, usn integer NOT NULL, tags text NOT NULL, flds text NOT NULL, -- The use of type integer for sfld is deliberate, because it means that integer values in this -- field will sort numerically. sfld integer NOT NULL, csum integer NOT NULL, flags integer NOT NULL, data text NOT NULL ); CREATE TABLE cards ( id integer PRIMARY KEY, nid integer NOT NULL, did integer NOT NULL, ord integer NOT NULL, mod integer NOT NULL, usn integer NOT NULL, type integer NOT NULL, queue integer NOT NULL, due integer NOT NULL, ivl integer NOT NULL, factor integer NOT NULL, reps integer NOT NULL, lapses integer NOT NULL, left integer NOT NULL, odue integer NOT NULL, odid integer NOT NULL, flags integer NOT NULL, data text NOT NULL ); CREATE TABLE revlog ( id integer PRIMARY KEY, cid integer NOT NULL, usn integer NOT NULL, ease integer NOT NULL, ivl integer NOT NULL, lastIvl integer NOT NULL, factor integer NOT NULL, time integer NOT NULL, type integer NOT NULL ); CREATE TABLE graves ( usn integer NOT NULL, oid integer NOT NULL, type integer NOT NULL ); -- syncing CREATE INDEX ix_notes_usn ON notes (usn); CREATE INDEX ix_cards_usn ON cards (usn); CREATE INDEX ix_revlog_usn ON revlog (usn); -- card spacing, etc CREATE INDEX ix_cards_nid ON cards (nid); -- scheduling and deck limiting CREATE INDEX ix_cards_sched ON cards (did, queue, due); -- revlog by card CREATE INDEX ix_revlog_cid ON revlog (cid); -- field uniqueness CREATE INDEX ix_notes_csum ON notes (csum); INSERT INTO col VALUES ( 1, 0, 0, 0, 0, 0, 0, 0, '{}', '{}', '{}', '{}', '{}' ); ================================================ FILE: rslib/src/storage/sqlite.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::cmp::Ordering; use std::collections::HashSet; use std::fmt::Display; use std::hash::Hasher; use std::path::Path; use std::sync::Arc; use bitflags::bitflags; use fnv::FnvHasher; use fsrs::FSRS; use fsrs::FSRS5_DEFAULT_DECAY; use regex::Regex; use rusqlite::functions::FunctionFlags; use rusqlite::params; use rusqlite::trace::TraceEvent; use rusqlite::Connection; use serde_json::Value; use unicase::UniCase; use super::upgrades::SCHEMA_MAX_VERSION; use super::upgrades::SCHEMA_MIN_VERSION; use super::upgrades::SCHEMA_STARTING_VERSION; use super::SchemaVersion; use crate::cloze::strip_clozes; use crate::config::schema11::schema11_config_as_string; use crate::error::DbErrorKind; use crate::prelude::*; use crate::scheduler::timing::local_minutes_west_for_stamp; use crate::scheduler::timing::v1_creation_date; use crate::storage::card::data::CardData; use crate::text::without_combining; use crate::text::CowMapping; fn unicase_compare(s1: &str, s2: &str) -> Ordering { UniCase::new(s1).cmp(&UniCase::new(s2)) } // fixme: rollback savepoint when tags not changed // fixme: need to drop out of wal prior to vacuuming to fix page size of older // collections // currently public for dbproxy #[derive(Debug)] pub struct SqliteStorage { // currently crate-visible for dbproxy pub(crate) db: Connection, } fn open_or_create_collection_db(path: &Path) -> Result { let db = Connection::open(path)?; if std::env::var("TRACESQL").is_ok() { db.trace_v2( rusqlite::trace::TraceEventCodes::SQLITE_TRACE_STMT, Some(trace), ); } db.busy_timeout(std::time::Duration::from_secs(0))?; db.pragma_update(None, "locking_mode", "exclusive")?; db.pragma_update(None, "page_size", 4096)?; db.pragma_update(None, "cache_size", -40 * 1024)?; db.pragma_update(None, "legacy_file_format", false)?; db.pragma_update(None, "journal_mode", "wal")?; // Android has no /tmp folder, and fails in the default config. #[cfg(target_os = "android")] db.pragma_update(None, "temp_store", &"memory")?; db.set_prepared_statement_cache_capacity(50); add_field_index_function(&db)?; add_regexp_function(&db)?; add_regexp_fields_function(&db)?; add_regexp_tags_function(&db)?; add_process_text_function(&db)?; add_fnvhash_function(&db)?; add_extract_original_position_function(&db)?; add_extract_custom_data_function(&db)?; add_extract_fsrs_variable(&db)?; add_extract_fsrs_retrievability(&db)?; add_extract_fsrs_relative_retrievability(&db)?; db.create_collation("unicase", unicase_compare)?; Ok(db) } impl SqliteStorage { /// This is provided as an escape hatch for when you need to do something /// not directly supported by this library. Please exercise caution when /// using it. pub fn db(&self) -> &Connection { &self.db } } /// Adds sql function field_at_index(flds, index) /// to split provided fields and return field at zero-based index. /// If out of range, returns empty string. fn add_field_index_function(db: &Connection) -> rusqlite::Result<()> { db.create_scalar_function( "field_at_index", 2, FunctionFlags::SQLITE_DETERMINISTIC, |ctx| { let mut fields = ctx.get_raw(0).as_str()?.split('\x1f'); let idx: u16 = ctx.get(1)?; Ok(fields.nth(idx as usize).unwrap_or("").to_string()) }, ) } bitflags! { pub(crate) struct ProcessTextFlags: u8 { const NoCombining = 1; const StripClozes = 1 << 1; } } fn add_process_text_function(db: &Connection) -> rusqlite::Result<()> { db.create_scalar_function( "process_text", 2, FunctionFlags::SQLITE_DETERMINISTIC, |ctx| { let mut text = Cow::from(ctx.get_raw(0).as_str()?); let opt = ProcessTextFlags::from_bits_truncate(ctx.get_raw(1).as_i64()? as u8); if opt.contains(ProcessTextFlags::StripClozes) { text = text.map_cow(strip_clozes); } if opt.contains(ProcessTextFlags::NoCombining) { text = text.map_cow(without_combining); } Ok(text.get_owned()) }, ) } fn add_fnvhash_function(db: &Connection) -> rusqlite::Result<()> { db.create_scalar_function("fnvhash", -1, FunctionFlags::SQLITE_DETERMINISTIC, |ctx| { let mut hasher = FnvHasher::default(); for idx in 0..ctx.len() { hasher.write_i64(ctx.get(idx)?); } Ok(hasher.finish() as i64) }) } /// Adds sql function regexp(regex, string) -> is_match /// Taken from the rusqlite docs type BoxError = Box; fn add_regexp_function(db: &Connection) -> rusqlite::Result<()> { db.create_scalar_function( "regexp", 2, FunctionFlags::SQLITE_DETERMINISTIC, move |ctx| { assert_eq!(ctx.len(), 2, "called with unexpected number of arguments"); let re: Arc = ctx .get_or_create_aux(0, |vr| -> std::result::Result<_, BoxError> { Ok(Regex::new(vr.as_str()?)?) })?; let is_match = { let text = ctx .get_raw(1) .as_str() .map_err(|e| rusqlite::Error::UserFunctionError(e.into()))?; re.is_match(text) }; Ok(is_match) }, ) } /// Adds sql function `regexp_fields(regex, note_flds, indices...) -> is_match`. /// If no indices are provided, all fields are matched against. fn add_regexp_fields_function(db: &Connection) -> rusqlite::Result<()> { db.create_scalar_function( "regexp_fields", -1, FunctionFlags::SQLITE_DETERMINISTIC, move |ctx| { assert!(ctx.len() > 1, "not enough arguments"); let re: Arc = ctx .get_or_create_aux(0, |vr| -> std::result::Result<_, BoxError> { Ok(Regex::new(vr.as_str()?)?) })?; let fields = ctx.get_raw(1).as_str()?.split('\x1f'); let indices: HashSet = (2..ctx.len()) .map(|i| ctx.get(i)) .collect::>()?; Ok(fields.enumerate().any(|(idx, field)| { (indices.is_empty() || indices.contains(&idx)) && re.is_match(field) })) }, ) } /// Adds sql function `regexp_tags(regex, tags) -> is_match`. fn add_regexp_tags_function(db: &Connection) -> rusqlite::Result<()> { db.create_scalar_function( "regexp_tags", 2, FunctionFlags::SQLITE_DETERMINISTIC, move |ctx| { assert_eq!(ctx.len(), 2, "called with unexpected number of arguments"); let re: Arc = ctx .get_or_create_aux(0, |vr| -> std::result::Result<_, BoxError> { Ok(Regex::new(vr.as_str()?)?) })?; let mut tags = ctx.get_raw(1).as_str()?.split(' '); Ok(tags.any(|tag| re.is_match(tag))) }, ) } /// eg. extract_original_position(c.data) -> number | null /// Parse original card position from c.data (this is only populated after card /// has been reviewed) fn add_extract_original_position_function(db: &Connection) -> rusqlite::Result<()> { db.create_scalar_function( "extract_original_position", 1, FunctionFlags::SQLITE_DETERMINISTIC, move |ctx| { assert_eq!(ctx.len(), 1, "called with unexpected number of arguments"); let Ok(card_data) = ctx.get_raw(0).as_str() else { return Ok(None); }; match &CardData::from_str(card_data).original_position { Some(position) => Ok(Some(*position as i64)), None => Ok(None), } }, ) } /// eg. extract_custom_data(card.data, 'r') -> string | null fn add_extract_custom_data_function(db: &Connection) -> rusqlite::Result<()> { db.create_scalar_function( "extract_custom_data", 2, FunctionFlags::SQLITE_DETERMINISTIC, move |ctx| { assert_eq!(ctx.len(), 2, "called with unexpected number of arguments"); let Ok(card_data) = ctx.get_raw(0).as_str() else { return Ok(None); }; if card_data.is_empty() { return Ok(None); } let Ok(key) = ctx.get_raw(1).as_str() else { return Ok(None); }; let custom_data = &CardData::from_str(card_data).custom_data; let Ok(value) = serde_json::from_str::(custom_data) else { return Ok(None); }; let v = value.get(key).map(|v| match v { Value::String(s) => s.to_owned(), _ => v.to_string(), }); Ok(v) }, ) } /// eg. extract_fsrs_variable(card.data, 's' | 'd' | 'dr') -> float | null fn add_extract_fsrs_variable(db: &Connection) -> rusqlite::Result<()> { db.create_scalar_function( "extract_fsrs_variable", 2, FunctionFlags::SQLITE_DETERMINISTIC, move |ctx| { assert_eq!(ctx.len(), 2, "called with unexpected number of arguments"); let Ok(card_data) = ctx.get_raw(0).as_str() else { return Ok(None); }; if card_data.is_empty() { return Ok(None); } let Ok(key) = ctx.get_raw(1).as_str() else { return Ok(None); }; let card_data = &CardData::from_str(card_data); Ok(match key { "s" => card_data.fsrs_stability, "d" => card_data.fsrs_difficulty, "dr" => card_data.fsrs_desired_retention, _ => panic!("invalid key: {key}"), }) }, ) } /// eg. extract_fsrs_retrievability(card.data, card.due, card.ivl, /// timing.days_elapsed, timing.next_day_at, timing.now) -> float | null fn add_extract_fsrs_retrievability(db: &Connection) -> rusqlite::Result<()> { db.create_scalar_function( "extract_fsrs_retrievability", 6, FunctionFlags::SQLITE_DETERMINISTIC, move |ctx| { assert_eq!(ctx.len(), 6, "called with unexpected number of arguments"); let Ok(card_data) = ctx.get_raw(0).as_str() else { return Ok(None); }; if card_data.is_empty() { return Ok(None); } let card_data = &CardData::from_str(card_data); let Ok(due) = ctx.get_raw(1).as_i64() else { return Ok(None); }; let Ok(now) = ctx.get_raw(5).as_i64() else { return Ok(None); }; let seconds_elapsed = if let Some(last_review_time) = card_data.last_review_time { // This and any following // (x as u32).saturating_sub(y as u32) // must not be changed to // x.saturating_sub(y) as u32 // as x and y are i64's and saturating_sub will therfore allow negative numbers // before converting to u32 in the latter example. (now as u32).saturating_sub(last_review_time.0 as u32) } else if due > 365_000 { // (re)learning card in seconds let Ok(ivl) = ctx.get_raw(2).as_i64() else { return Ok(None); }; let last_review_time = (due as u32).saturating_sub(ivl as u32); (now as u32).saturating_sub(last_review_time) } else { let Ok(ivl) = ctx.get_raw(2).as_i64() else { return Ok(None); }; // timing.days_elapsed let Ok(today) = ctx.get_raw(3).as_i64() else { return Ok(None); }; let review_day = (due as u32).saturating_sub(ivl as u32); (today as u32).saturating_sub(review_day) * 86_400 }; let decay = card_data.decay.unwrap_or(FSRS5_DEFAULT_DECAY); let retrievability = card_data.memory_state().map(|state| { FSRS::new(None).unwrap().current_retrievability_seconds( state.into(), seconds_elapsed, decay, ) }); Ok(retrievability) }, ) } /// eg. extract_fsrs_relative_retrievability(card.data, card.due, /// card.ivl, timing.days_elapsed, timing.next_day_at, timing.now) -> float | /// null. The higher the number, the higher the card's retrievability relative /// to the configured desired retention. fn add_extract_fsrs_relative_retrievability(db: &Connection) -> rusqlite::Result<()> { db.create_scalar_function( "extract_fsrs_relative_retrievability", 6, FunctionFlags::SQLITE_DETERMINISTIC, move |ctx| { assert_eq!(ctx.len(), 6, "called with unexpected number of arguments"); let Ok(due) = ctx.get_raw(1).as_i64() else { return Ok(None); }; let Ok(interval) = ctx.get_raw(2).as_i64() else { return Ok(None); }; /* // Unused let Ok(next_day_at) = ctx.get_raw(4).as_i64() else { return Ok(None); }; */ let Ok(now) = ctx.get_raw(5).as_i64() else { return Ok(None); }; let secs_elapsed = if due > 365_000 { // (re)learning card with due in seconds // Don't change this to now.subtracting_sub(due) as u32 // for the same reasons listed in the comment // in add_extract_fsrs_retrievability (now as u32).saturating_sub(due as u32) } else { // timing.days_elapsed let Ok(today) = ctx.get_raw(3).as_i64() else { return Ok(None); }; let review_day = due.saturating_sub(interval); (today as u32).saturating_sub(review_day as u32) * 86_400 }; if let Ok(card_data) = ctx.get_raw(0).as_str() { if !card_data.is_empty() { let card_data = &CardData::from_str(card_data); if let (Some(state), Some(mut desired_retrievability)) = (card_data.memory_state(), card_data.fsrs_desired_retention) { // avoid div by zero desired_retrievability = desired_retrievability.max(0.0001); let decay = card_data.decay.unwrap_or(FSRS5_DEFAULT_DECAY); let seconds_elapsed = if let Some(last_review_time) = card_data.last_review_time { // Don't change this to now.subtracting_sub(due) as u32 // for the same reasons listed in the comment // in add_extract_fsrs_retrievability (now as u32).saturating_sub(last_review_time.0 as u32) } else { secs_elapsed }; let current_retrievability = FSRS::new(None) .unwrap() .current_retrievability_seconds(state.into(), seconds_elapsed, decay) .max(0.0001); return Ok(Some( -(current_retrievability.powf(-1.0 / decay) - 1.) / (desired_retrievability.powf(-1.0 / decay) - 1.), )); } } } let days_elapsed = secs_elapsed / 86_400; // FSRS data missing; fall back to SM2 ordering Ok(Some( -((days_elapsed as f32) + 0.001) / (interval as f32).max(1.0), )) }, ) } /// Fetch schema version from database. /// Return (must_create, version) fn schema_version(db: &Connection) -> Result<(bool, u8)> { if !db .prepare("select null from sqlite_master where type = 'table' and name = 'col'")? .exists([])? { return Ok((true, SCHEMA_STARTING_VERSION)); } Ok(( false, db.query_row("select ver from col", [], |r| r.get(0))?, )) } fn trace(event: TraceEvent) { if let TraceEvent::Stmt(_, sql) = event { println!("sql: {}", sql.trim().replace('\n', " ")); } } impl SqliteStorage { pub(crate) fn open_or_create( path: &Path, tr: &I18n, server: bool, check_integrity: bool, ) -> Result { let db = open_or_create_collection_db(path)?; let (create, ver) = schema_version(&db)?; let err = match ver { v if v < SCHEMA_MIN_VERSION => Some(DbErrorKind::FileTooOld), v if v > SCHEMA_MAX_VERSION => Some(DbErrorKind::FileTooNew), 12 | 13 => { // as schema definition changed, user must perform clean // shutdown to return to schema 11 prior to running this version Some(DbErrorKind::FileTooNew) } _ => None, }; if let Some(kind) = err { return Err(AnkiError::db_error("", kind)); } if check_integrity { match db.pragma_query_value(None, "integrity_check", |row| row.get::<_, String>(0)) { Ok(s) => require!(s == "ok", "corrupt: {s}"), Err(e) => return Err(e.into()), }; } let upgrade = ver != SCHEMA_MAX_VERSION; if create || upgrade { db.execute("begin exclusive", [])?; } if create { db.execute_batch(include_str!("schema11.sql"))?; // start at schema 11, then upgrade below let crt = TimestampSecs(v1_creation_date()); let offset = if server { None } else { Some(local_minutes_west_for_stamp(crt)?) }; db.execute( "update col set crt=?, scm=?, ver=?, conf=?", params![ crt, TimestampMillis::now(), SCHEMA_STARTING_VERSION, &schema11_config_as_string(offset) ], )?; } let storage = Self { db }; if create || upgrade { storage.upgrade_to_latest_schema(ver, server)?; } if create { storage.add_default_deck_config(tr)?; storage.add_default_deck(tr)?; storage.add_stock_notetypes(tr)?; } if create || upgrade { storage.commit_trx()?; } Ok(storage) } pub(crate) fn close(self, desired_version: Option) -> Result<()> { if let Some(version) = desired_version { self.downgrade_to(version)?; if version.has_journal_mode_delete() { self.db.pragma_update(None, "journal_mode", "delete")?; } } Ok(()) } /// Flush data from WAL file into DB, so the DB is safe to copy. Caller must /// not call this while there is an active transaction. pub(crate) fn checkpoint(&self) -> Result<()> { if !self.db.is_autocommit() { return Err(AnkiError::db_error( "active transaction", DbErrorKind::Other, )); } self.db .query_row_and_then("pragma wal_checkpoint(truncate)", [], |row| { let error_code: i64 = row.get(0)?; if error_code != 0 { Err(AnkiError::db_error( "unable to checkpoint", DbErrorKind::Other, )) } else { Ok(()) } }) } // Standard transaction start/stop ////////////////////////////////////// pub(crate) fn begin_trx(&self) -> Result<()> { self.db.prepare_cached("begin exclusive")?.execute([])?; Ok(()) } pub(crate) fn commit_trx(&self) -> Result<()> { if !self.db.is_autocommit() { self.db.prepare_cached("commit")?.execute([])?; } Ok(()) } pub(crate) fn rollback_trx(&self) -> Result<()> { if !self.db.is_autocommit() { self.db.execute("rollback", [])?; } Ok(()) } // Savepoints ////////////////////////////////////////// // // This is necessary at the moment because Anki's current architecture uses // long-running transactions as an undo mechanism. Once a proper undo // mechanism has been added to all existing functionality, we could // transition these to standard commits. pub(crate) fn begin_rust_trx(&self) -> Result<()> { self.db.prepare_cached("savepoint rust")?.execute([])?; Ok(()) } pub(crate) fn commit_rust_trx(&self) -> Result<()> { self.db.prepare_cached("release rust")?.execute([])?; Ok(()) } pub(crate) fn rollback_rust_trx(&self) -> Result<()> { self.db.prepare_cached("rollback to rust")?.execute([])?; Ok(()) } ////////////////////////////////////////// /// true if corrupt/can't access pub(crate) fn quick_check_corrupt(&self) -> bool { match self.db.pragma_query_value(None, "quick_check", |row| { row.get(0).map(|v: String| v != "ok") }) { Ok(corrupt) => corrupt, Err(e) => { println!("error: {e:?}"); true } } } pub(crate) fn optimize(&self) -> Result<()> { self.db.execute_batch("vacuum; reindex; analyze")?; Ok(()) } #[cfg(test)] pub(crate) fn db_scalar(&self, sql: &str) -> Result { self.db.query_row(sql, [], |r| r.get(0)).map_err(Into::into) } } #[derive(Debug, Clone, Copy, PartialEq)] pub enum SqlSortOrder { Ascending, Descending, } impl Display for SqlSortOrder { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{}", match self { SqlSortOrder::Ascending => "asc", SqlSortOrder::Descending => "desc", } ) } } #[cfg(test)] mod test { use super::*; use crate::scheduler::answering::test::v3_test_collection; use crate::storage::card::ReviewOrderSubclause; #[test] fn missing_memory_state_falls_back_to_sm2() -> Result<()> { let (mut col, _cids) = v3_test_collection(1)?; col.set_config_bool(BoolKey::Fsrs, true, true)?; col.answer_easy(); let timing = col.timing_today()?; let sql_func = ReviewOrderSubclause::RelativeOverdueness { fsrs: true, timing } .to_string() .replace(" asc", ""); let sql = format!("select {sql_func} from cards"); // value from fsrs let mut pos: Option; pos = col.storage.db_scalar(&sql).unwrap(); assert_eq!(pos, Some(0.0)); // erasing the memory state should not result in None output col.storage.db.execute("update cards set data=''", [])?; pos = col.storage.db_scalar(&sql).unwrap(); assert!(pos.is_some()); // but it won't match the fsrs value assert!(pos.unwrap() < -0.0); Ok(()) } } ================================================ FILE: rslib/src/storage/sync.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use rusqlite::params; use rusqlite::types::FromSql; use rusqlite::ToSql; use super::*; use crate::prelude::*; impl SqliteStorage { pub(crate) fn usn(&self, server: bool) -> Result { if server { Ok(Usn(self .db .prepare_cached("select usn from col")? .query_row([], |row| row.get(0))?)) } else { Ok(Usn(-1)) } } pub(crate) fn set_usn(&self, usn: Usn) -> Result<()> { self.db .prepare_cached("update col set usn = ?")? .execute([usn])?; Ok(()) } pub(crate) fn increment_usn(&self) -> Result<()> { self.db .prepare_cached("update col set usn = usn + 1")? .execute([])?; Ok(()) } pub(crate) fn objects_pending_sync(&self, table: &str, usn: Usn) -> Result> { self.db .prepare_cached(&format!( "select id from {} where {}", table, usn.pending_object_clause() ))? .query_and_then([usn], |r| r.get(0).map_err(Into::into))? .collect() } pub(crate) fn maybe_update_object_usns( &self, table: &str, ids: &[I], server_usn_if_client: Option, ) -> Result<()> { if let Some(new_usn) = server_usn_if_client { let mut stmt = self .db .prepare_cached(&format!("update {table} set usn=? where id=?"))?; for id in ids { stmt.execute(params![new_usn, id])?; } } Ok(()) } } impl Usn { /// Used when gathering pending objects during sync. pub(crate) fn pending_object_clause(self) -> &'static str { if self.0 == -1 { "usn = ?" } else { "usn >= ?" } } } ================================================ FILE: rslib/src/storage/sync_check.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::*; use crate::error::SyncErrorKind; use crate::prelude::*; use crate::sync::collection::sanity::SanityCheckCounts; use crate::sync::collection::sanity::SanityCheckDueCounts; impl SqliteStorage { fn table_has_usn(&self, table: &str) -> Result { Ok(self .db .prepare(&format!("select null from {table} where usn=-1"))? .query([])? .next()? .is_some()) } fn table_count(&self, table: &str) -> Result { self.db .query_row(&format!("select count() from {table}"), [], |r| r.get(0)) .map_err(Into::into) } pub(crate) fn sanity_check_info(&self) -> Result { for table in &[ "cards", "notes", "revlog", "graves", "decks", "deck_config", "tags", "notetypes", ] { if self.table_has_usn(table)? { return Err(AnkiError::sync_error( format!("table had usn=-1: {table}"), SyncErrorKind::Other, )); } } Ok(SanityCheckCounts { counts: SanityCheckDueCounts::default(), cards: self.table_count("cards")?, notes: self.table_count("notes")?, revlog: self.table_count("revlog")?, graves: self.table_count("graves")?, notetypes: self.table_count("notetypes")?, decks: self.table_count("decks")?, deck_config: self.table_count("deck_config")?, }) } } ================================================ FILE: rslib/src/storage/tag/add.sql ================================================ INSERT OR REPLACE INTO tags (tag, usn, collapsed) VALUES (?, ?, ?) ================================================ FILE: rslib/src/storage/tag/alloc_id.sql ================================================ SELECT CASE WHEN ?1 IN ( SELECT id FROM tags ) THEN ( SELECT max(id) + 1 FROM tags ) ELSE ?1 END; ================================================ FILE: rslib/src/storage/tag/get.sql ================================================ SELECT tag, usn, collapsed FROM tags ================================================ FILE: rslib/src/storage/tag/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use rusqlite::params; use rusqlite::Row; use super::SqliteStorage; use crate::error::Result; use crate::tags::Tag; use crate::types::Usn; fn row_to_tag(row: &Row) -> Result { Ok(Tag { name: row.get(0)?, usn: row.get(1)?, expanded: !row.get(2)?, }) } impl SqliteStorage { /// All tags in the collection, in alphabetical order. pub fn all_tags(&self) -> Result> { self.db .prepare_cached(include_str!("get.sql"))? .query_and_then([], row_to_tag)? .collect() } pub(crate) fn expanded_tags(&self) -> Result> { self.db .prepare_cached("select tag from tags where collapsed = false")? .query_and_then([], |r| r.get::<_, String>(0).map_err(Into::into))? .collect::>>() } pub(crate) fn restore_expanded_tags(&self, tags: &[String]) -> Result<()> { let mut stmt = self .db .prepare_cached("update tags set collapsed = false where tag = ?")?; for tag in tags { stmt.execute([tag])?; } Ok(()) } pub(crate) fn get_tag(&self, name: &str) -> Result> { self.db .prepare_cached(&format!("{} where tag = ?", include_str!("get.sql")))? .query_and_then([name], row_to_tag)? .next() .transpose() } pub(crate) fn register_tag(&self, tag: &Tag) -> Result<()> { self.db .prepare_cached(include_str!("add.sql"))? .execute(params![tag.name, tag.usn, !tag.expanded])?; Ok(()) } pub(crate) fn preferred_tag_case(&self, tag: &str) -> Result> { self.db .prepare_cached("select tag from tags where tag = ?")? .query_and_then(params![tag], |row| row.get(0))? .next() .transpose() .map_err(Into::into) } pub(crate) fn get_tags_by_predicate(&self, mut want: F) -> Result> where F: FnMut(&str) -> bool, { let mut query_stmt = self.db.prepare_cached(include_str!("get.sql"))?; let mut rows = query_stmt.query([])?; let mut output = vec![]; while let Some(row) = rows.next()? { let tag = row.get_ref_unwrap(0).as_str()?; if want(tag) { output.push(Tag { name: tag.to_owned(), usn: row.get(1)?, expanded: !row.get(2)?, }) } } Ok(output) } pub(crate) fn remove_single_tag(&self, tag: &str) -> Result<()> { self.db .prepare_cached("delete from tags where tag = ?")? .execute([tag])?; Ok(()) } pub(crate) fn update_tag(&self, tag: &Tag) -> Result<()> { self.db .prepare_cached(include_str!("update.sql"))? .execute(params![&tag.name, tag.usn, !tag.expanded])?; Ok(()) } pub(crate) fn clear_all_tags(&self) -> Result<()> { self.db.execute("delete from tags", [])?; Ok(()) } pub(crate) fn clear_tag_usns(&self) -> Result<()> { self.db .execute("update tags set usn = 0 where usn != 0", [])?; Ok(()) } // fixme: in the future we could just register tags as part of the sync // instead of sending the tag list separately pub(crate) fn tags_pending_sync(&self, usn: Usn) -> Result> { self.db .prepare_cached(&format!( "select tag from tags where {}", usn.pending_object_clause() ))? .query_and_then([usn], |r| r.get(0).map_err(Into::into))? .collect() } pub(crate) fn update_tag_usns(&self, tags: &[String], new_usn: Usn) -> Result<()> { let mut stmt = self .db .prepare_cached("update tags set usn=? where tag=?")?; for tag in tags { stmt.execute(params![new_usn, tag])?; } Ok(()) } // Upgrading/downgrading pub(super) fn upgrade_tags_to_schema14(&self) -> Result<()> { let tags = self .db .query_row_and_then("select tags from col", [], |row| { let tags: Result> = serde_json::from_str(row.get_ref_unwrap(0).as_str()?).map_err(Into::into); tags })?; let mut stmt = self .db .prepare_cached("insert or ignore into tags (tag, usn) values (?, ?)")?; for (tag, usn) in tags.into_iter() { stmt.execute(params![tag, usn])?; } self.db.execute_batch("update col set tags=''")?; Ok(()) } pub(super) fn downgrade_tags_from_schema14(&self) -> Result<()> { let alltags = self.all_tags()?; let tagsmap: HashMap = alltags.into_iter().map(|t| (t.name, t.usn)).collect(); self.db.execute( "update col set tags=?", params![serde_json::to_string(&tagsmap)?], )?; Ok(()) } pub(super) fn upgrade_tags_to_schema17(&self) -> Result<()> { let tags = self .db .prepare_cached("select tag, usn from tags")? .query_and_then([], |r| Ok(Tag::new(r.get(0)?, r.get(1)?)))? .collect::>>()?; self.db .execute_batch(include_str!["../upgrades/schema17_upgrade.sql"])?; tags.into_iter() .try_for_each(|tag| -> Result<()> { self.register_tag(&tag) }) } } ================================================ FILE: rslib/src/storage/tag/update.sql ================================================ UPDATE tags SET tag = ?1, usn = ?, collapsed = ? WHERE tag = ?1 ================================================ FILE: rslib/src/storage/upgrades/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /// The minimum schema version we can open. pub(super) const SCHEMA_MIN_VERSION: u8 = 11; /// The version new files are initially created with. pub(super) const SCHEMA_STARTING_VERSION: u8 = 11; /// The maximum schema version we can open. pub(super) const SCHEMA_MAX_VERSION: u8 = 18; use super::SchemaVersion; use super::SqliteStorage; use crate::error::Result; impl SqliteStorage { pub(super) fn upgrade_to_latest_schema(&self, ver: u8, server: bool) -> Result<()> { if ver < 14 { self.db .execute_batch(include_str!("schema14_upgrade.sql"))?; self.upgrade_deck_conf_to_schema14()?; self.upgrade_tags_to_schema14()?; self.upgrade_config_to_schema14()?; } if ver < 15 { self.db .execute_batch(include_str!("schema15_upgrade.sql"))?; self.upgrade_notetypes_to_schema15()?; self.upgrade_decks_to_schema15(server)?; self.upgrade_deck_conf_to_schema15()?; } if ver < 16 { self.upgrade_deck_conf_to_schema16(server)?; self.db.execute_batch("update col set ver = 16")?; } if ver < 17 { self.upgrade_tags_to_schema17()?; self.db.execute_batch("update col set ver = 17")?; } if ver < 18 { self.db .execute_batch(include_str!("schema18_upgrade.sql"))?; } // in some future schema upgrade, we may want to change // _collapsed to _expanded in DeckCommon and invert existing values, so // that we can avoid serializing the values in the default case, and use // DeckCommon::default() in new_normal() and new_filtered() Ok(()) } pub(super) fn downgrade_to(&self, ver: SchemaVersion) -> Result<()> { match ver { SchemaVersion::V11 => self.downgrade_to_schema_11(), SchemaVersion::V18 => Ok(()), } } fn downgrade_to_schema_11(&self) -> Result<()> { self.begin_trx()?; self.db .execute_batch(include_str!("schema18_downgrade.sql"))?; self.downgrade_deck_conf_from_schema16()?; self.downgrade_decks_from_schema15()?; self.downgrade_notetypes_from_schema15()?; self.downgrade_config_from_schema14()?; self.downgrade_tags_from_schema14()?; self.db .execute_batch(include_str!("schema11_downgrade.sql"))?; self.commit_trx()?; Ok(()) } } #[cfg(test)] mod test { use anki_io::new_tempfile; use super::*; use crate::collection::CollectionBuilder; use crate::prelude::*; #[test] #[allow(clippy::assertions_on_constants)] fn assert_18_is_latest_schema_version() { assert_eq!( 18, SCHEMA_MAX_VERSION, "must implement SqliteStorage::downgrade_to(SchemaVersion::V18)" ); } #[test] fn valid_ease_factor_survives_upgrade_roundtrip() -> Result<()> { let tempfile = new_tempfile()?; let mut col = CollectionBuilder::default() .set_collection_path(tempfile.path()) .build()?; let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); col.add_note(&mut note, DeckId(1))?; col.storage .db .execute("update cards set factor = 1400", [])?; col.close(Some(SchemaVersion::V11))?; let col = CollectionBuilder::default() .set_collection_path(tempfile.path()) .build()?; let card = &col.storage.get_all_cards()[0]; assert_eq!(card.ease_factor, 1400); Ok(()) } } ================================================ FILE: rslib/src/storage/upgrades/schema11_downgrade.sql ================================================ DROP TABLE config; DROP TABLE deck_config; DROP TABLE tags; DROP TABLE fields; DROP TABLE templates; DROP TABLE notetypes; DROP TABLE decks; DROP INDEX idx_cards_odid; DROP INDEX idx_notes_mid; UPDATE col SET ver = 11; ================================================ FILE: rslib/src/storage/upgrades/schema14_upgrade.sql ================================================ CREATE TABLE deck_config ( id integer PRIMARY KEY NOT NULL, name text NOT NULL COLLATE unicase, mtime_secs integer NOT NULL, usn integer NOT NULL, config blob NOT NULL ); CREATE TABLE config ( KEY text NOT NULL PRIMARY KEY, usn integer NOT NULL, mtime_secs integer NOT NULL, val blob NOT NULL ) without rowid; CREATE TABLE tags ( tag text NOT NULL PRIMARY KEY COLLATE unicase, usn integer NOT NULL ) without rowid; UPDATE col SET ver = 14; ================================================ FILE: rslib/src/storage/upgrades/schema15_upgrade.sql ================================================ CREATE TABLE fields ( ntid integer NOT NULL, ord integer NOT NULL, name text NOT NULL COLLATE unicase, config blob NOT NULL, PRIMARY KEY (ntid, ord) ) without rowid; CREATE UNIQUE INDEX idx_fields_name_ntid ON fields (name, ntid); CREATE TABLE templates ( ntid integer NOT NULL, ord integer NOT NULL, name text NOT NULL COLLATE unicase, mtime_secs integer NOT NULL, usn integer NOT NULL, config blob NOT NULL, PRIMARY KEY (ntid, ord) ) without rowid; CREATE UNIQUE INDEX idx_templates_name_ntid ON templates (name, ntid); CREATE INDEX idx_templates_usn ON templates (usn); CREATE TABLE notetypes ( id integer NOT NULL PRIMARY KEY, name text NOT NULL COLLATE unicase, mtime_secs integer NOT NULL, usn integer NOT NULL, config blob NOT NULL ); CREATE UNIQUE INDEX idx_notetypes_name ON notetypes (name); CREATE INDEX idx_notetypes_usn ON notetypes (usn); CREATE TABLE decks ( id integer PRIMARY KEY NOT NULL, name text NOT NULL COLLATE unicase, mtime_secs integer NOT NULL, usn integer NOT NULL, common blob NOT NULL, kind blob NOT NULL ); CREATE UNIQUE INDEX idx_decks_name ON decks (name); CREATE INDEX idx_notes_mid ON notes (mid); CREATE INDEX idx_cards_odid ON cards (odid) WHERE odid != 0; UPDATE col SET ver = 15; ANALYZE; ================================================ FILE: rslib/src/storage/upgrades/schema17_upgrade.sql ================================================ DROP TABLE tags; CREATE TABLE tags ( tag text NOT NULL PRIMARY KEY COLLATE unicase, usn integer NOT NULL, collapsed boolean NOT NULL, config blob NULL ) without rowid; ================================================ FILE: rslib/src/storage/upgrades/schema18_downgrade.sql ================================================ ALTER TABLE graves RENAME TO graves_old; CREATE TABLE graves ( usn integer NOT NULL, oid integer NOT NULL, type integer NOT NULL ); INSERT INTO graves (usn, oid, type) SELECT usn, oid, type FROM graves_old; DROP TABLE graves_old; UPDATE col SET ver = 17; ================================================ FILE: rslib/src/storage/upgrades/schema18_upgrade.sql ================================================ ALTER TABLE graves RENAME TO graves_old; CREATE TABLE graves ( oid integer NOT NULL, type integer NOT NULL, usn integer NOT NULL, PRIMARY KEY (oid, type) ) WITHOUT ROWID; INSERT OR IGNORE INTO graves (oid, type, usn) SELECT oid, type, usn FROM graves_old; DROP TABLE graves_old; CREATE INDEX idx_graves_pending ON graves (usn); UPDATE col SET ver = 18; ================================================ FILE: rslib/src/sync/collection/changes.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html //! The current sync protocol sends changed notetypes, decks, tags and config //! all in a single request. use std::collections::HashMap; use serde::Deserialize; use serde::Serialize; use serde_json::Value; use serde_tuple::Serialize_tuple; use tracing::debug; use tracing::trace; use crate::deckconfig::DeckConfSchema11; use crate::decks::DeckSchema11; use crate::error::SyncErrorKind; use crate::notetype::NotetypeSchema11; use crate::prelude::*; use crate::sync::collection::normal::ClientSyncState; use crate::sync::collection::normal::NormalSyncer; use crate::sync::collection::protocol::SyncProtocol; use crate::sync::collection::start::ServerSyncState; use crate::sync::request::IntoSyncRequest; use crate::tags::Tag; #[derive(Serialize, Deserialize, Debug)] pub struct ApplyChangesRequest { pub changes: UnchunkedChanges, } #[derive(Serialize, Deserialize, Debug, Default)] pub struct UnchunkedChanges { #[serde(rename = "models")] notetypes: Vec, #[serde(rename = "decks")] decks_and_config: DecksAndConfig, tags: Vec, // the following are only sent if local is newer #[serde(skip_serializing_if = "Option::is_none", rename = "conf")] config: Option>, #[serde(skip_serializing_if = "Option::is_none", rename = "crt")] creation_stamp: Option, } #[derive(Serialize_tuple, Deserialize, Debug, Default)] pub struct DecksAndConfig { decks: Vec, config: Vec, } impl NormalSyncer<'_> { // This was assumed to a cheap operation when originally written - it didn't // anticipate the large deck trees and note types some users would create. // They should be chunked in the future, like other objects. Syncing tags // explicitly is also probably of limited usefulness. pub(in crate::sync) async fn process_unchunked_changes( &mut self, state: &ClientSyncState, ) -> Result<()> { debug!("gathering local changes"); let local = self.col.local_unchunked_changes( state.pending_usn, Some(state.server_usn), state.local_is_newer, )?; debug!( notetypes = local.notetypes.len(), decks = local.decks_and_config.decks.len(), deck_config = local.decks_and_config.config.len(), tags = local.tags.len(), "sending" ); self.progress.update(false, |p| { p.local_update += local.notetypes.len() + local.decks_and_config.decks.len() + local.decks_and_config.config.len() + local.tags.len(); })?; let remote = self .server .apply_changes(ApplyChangesRequest { changes: local }.try_into_sync_request()?) .await? .json()?; self.progress.check_cancelled()?; debug!( notetypes = remote.notetypes.len(), decks = remote.decks_and_config.decks.len(), deck_config = remote.decks_and_config.config.len(), tags = remote.tags.len(), "received" ); self.progress.update(false, |p| { p.remote_update += remote.notetypes.len() + remote.decks_and_config.decks.len() + remote.decks_and_config.config.len() + remote.tags.len(); })?; self.col.apply_changes(remote, state.server_usn)?; self.progress.check_cancelled()?; Ok(()) } } impl Collection { // Local->remote unchunked changes //---------------------------------------------------------------- pub(in crate::sync) fn local_unchunked_changes( &mut self, pending_usn: Usn, server_usn_if_client: Option, local_is_newer: bool, ) -> Result { let mut changes = UnchunkedChanges { notetypes: self.changed_notetypes(pending_usn, server_usn_if_client)?, decks_and_config: DecksAndConfig { decks: self.changed_decks(pending_usn, server_usn_if_client)?, config: self.changed_deck_config(pending_usn, server_usn_if_client)?, }, tags: self.changed_tags(pending_usn, server_usn_if_client)?, ..Default::default() }; if local_is_newer { changes.config = Some(self.changed_config()?); changes.creation_stamp = Some(self.storage.creation_stamp()?); } Ok(changes) } fn changed_notetypes( &mut self, pending_usn: Usn, server_usn_if_client: Option, ) -> Result> { let ids = self .storage .objects_pending_sync("notetypes", pending_usn)?; self.storage .maybe_update_object_usns("notetypes", &ids, server_usn_if_client)?; self.state.notetype_cache.clear(); ids.into_iter() .map(|id| { self.storage.get_notetype(id).map(|opt| { let mut nt: NotetypeSchema11 = opt.unwrap().into(); nt.usn = server_usn_if_client.unwrap_or(nt.usn); nt }) }) .collect() } fn changed_decks( &mut self, pending_usn: Usn, server_usn_if_client: Option, ) -> Result> { let ids = self.storage.objects_pending_sync("decks", pending_usn)?; self.storage .maybe_update_object_usns("decks", &ids, server_usn_if_client)?; self.state.deck_cache.clear(); ids.into_iter() .map(|id| { self.storage.get_deck(id).map(|opt| { let mut deck = opt.unwrap(); deck.usn = server_usn_if_client.unwrap_or(deck.usn); deck.into() }) }) .collect() } fn changed_deck_config( &self, pending_usn: Usn, server_usn_if_client: Option, ) -> Result> { let ids = self .storage .objects_pending_sync("deck_config", pending_usn)?; self.storage .maybe_update_object_usns("deck_config", &ids, server_usn_if_client)?; ids.into_iter() .map(|id| { self.storage.get_deck_config(id).map(|opt| { let mut conf: DeckConfSchema11 = opt.unwrap().into(); conf.usn = server_usn_if_client.unwrap_or(conf.usn); conf }) }) .collect() } fn changed_tags( &self, pending_usn: Usn, server_usn_if_client: Option, ) -> Result> { let changed = self.storage.tags_pending_sync(pending_usn)?; if let Some(usn) = server_usn_if_client { self.storage.update_tag_usns(&changed, usn)?; } Ok(changed) } /// Currently this is all config, as legacy clients overwrite the local /// items with the provided value. fn changed_config(&self) -> Result> { let conf = self.storage.get_all_config()?; self.storage.clear_config_usns()?; Ok(conf) } // Remote->local unchunked changes //---------------------------------------------------------------- pub(in crate::sync) fn apply_changes( &mut self, remote: UnchunkedChanges, latest_usn: Usn, ) -> Result<()> { self.merge_notetypes(remote.notetypes, latest_usn)?; self.merge_decks(remote.decks_and_config.decks, latest_usn)?; self.merge_deck_config(remote.decks_and_config.config)?; self.merge_tags(remote.tags, latest_usn)?; if let Some(crt) = remote.creation_stamp { self.set_creation_stamp(crt)?; } if let Some(config) = remote.config { self.storage .set_all_config(config, latest_usn, TimestampSecs::now())?; } Ok(()) } fn merge_notetypes(&mut self, notetypes: Vec, latest_usn: Usn) -> Result<()> { for nt in notetypes { let mut nt: Notetype = nt.into(); let proceed = if let Some(existing_nt) = self.storage.get_notetype(nt.id)? { if existing_nt.mtime_secs <= nt.mtime_secs { if (existing_nt.fields.len() != nt.fields.len()) || (existing_nt.templates.len() != nt.templates.len()) { return Err(AnkiError::sync_error( "notetype schema changed", SyncErrorKind::ResyncRequired, )); } true } else { false } } else { true }; if proceed { self.ensure_notetype_name_unique(&mut nt, latest_usn)?; self.storage.add_or_update_notetype_with_existing_id(&nt)?; self.state.notetype_cache.remove(&nt.id); } } Ok(()) } fn merge_decks(&mut self, decks: Vec, latest_usn: Usn) -> Result<()> { for deck in decks { let proceed = if let Some(existing_deck) = self.storage.get_deck(deck.id())? { existing_deck.mtime_secs <= deck.common().mtime } else { true }; if proceed { let mut deck = deck.into(); self.ensure_deck_name_unique(&mut deck, latest_usn)?; self.storage.add_or_update_deck_with_existing_id(&deck)?; self.state.deck_cache.remove(&deck.id); } } Ok(()) } fn merge_deck_config(&self, dconf: Vec) -> Result<()> { for conf in dconf { let proceed = if let Some(existing_conf) = self.storage.get_deck_config(conf.id)? { existing_conf.mtime_secs <= conf.mtime } else { true }; if proceed { let conf = conf.into(); self.storage .add_or_update_deck_config_with_existing_id(&conf)?; } } Ok(()) } fn merge_tags(&mut self, tags: Vec, latest_usn: Usn) -> Result<()> { for tag in tags { self.register_tag(&mut Tag::new(tag, latest_usn))?; } Ok(()) } } pub fn server_apply_changes( req: ApplyChangesRequest, col: &mut Collection, state: &mut ServerSyncState, ) -> Result { let server_changes = col.local_unchunked_changes(state.client_usn, None, !state.client_is_newer)?; trace!(?req.changes, ?server_changes); col.apply_changes(req.changes, state.server_usn)?; Ok(server_changes) } ================================================ FILE: rslib/src/sync/collection/chunks.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use itertools::Itertools; use serde::Deserialize; use serde::Serialize; use serde_tuple::Serialize_tuple; use tracing::debug; use crate::card::Card; use crate::card::CardQueue; use crate::card::CardType; use crate::notes::Note; use crate::prelude::*; use crate::revlog::RevlogEntry; use crate::serde::deserialize_int_from_number; use crate::storage::card::data::card_data_string; use crate::storage::card::data::CardData; use crate::sync::collection::normal::ClientSyncState; use crate::sync::collection::normal::NormalSyncer; use crate::sync::collection::protocol::EmptyInput; use crate::sync::collection::protocol::SyncProtocol; use crate::sync::collection::start::ServerSyncState; use crate::sync::request::IntoSyncRequest; use crate::tags::join_tags; use crate::tags::split_tags; pub(in crate::sync) struct ChunkableIds { revlog: Vec, cards: Vec, notes: Vec, } #[derive(Serialize, Deserialize, Debug, Default)] pub struct Chunk { #[serde(default)] pub done: bool, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub revlog: Vec, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub cards: Vec, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub notes: Vec, } #[derive(Serialize_tuple, Deserialize, Debug)] pub struct NoteEntry { pub id: NoteId, pub guid: String, #[serde(rename = "mid")] pub ntid: NotetypeId, #[serde(rename = "mod")] pub mtime: TimestampSecs, pub usn: Usn, pub tags: String, pub fields: String, pub sfld: String, // always empty pub csum: String, // always empty pub flags: u32, pub data: String, } #[derive(Serialize_tuple, Deserialize, Debug)] pub struct CardEntry { pub id: CardId, pub nid: NoteId, pub did: DeckId, pub ord: u16, #[serde(deserialize_with = "deserialize_int_from_number")] pub mtime: TimestampSecs, pub usn: Usn, pub ctype: CardType, pub queue: CardQueue, #[serde(deserialize_with = "deserialize_int_from_number")] pub due: i32, #[serde(deserialize_with = "deserialize_int_from_number")] pub ivl: u32, pub factor: u16, pub reps: u32, pub lapses: u32, pub left: u32, #[serde(deserialize_with = "deserialize_int_from_number")] pub odue: i32, pub odid: DeckId, pub flags: u8, pub data: String, } impl NormalSyncer<'_> { pub(in crate::sync) async fn process_chunks_from_server( &mut self, state: &ClientSyncState, ) -> Result<()> { loop { let chunk = self.server.chunk(EmptyInput::request()).await?.json()?; debug!( done = chunk.done, cards = chunk.cards.len(), notes = chunk.notes.len(), revlog = chunk.revlog.len(), "received" ); self.progress.update(false, |p| { p.remote_update += chunk.cards.len() + chunk.notes.len() + chunk.revlog.len() })?; let done = chunk.done; self.col.apply_chunk(chunk, state.pending_usn)?; self.progress.check_cancelled()?; if done { return Ok(()); } } } pub(in crate::sync) async fn send_chunks_to_server( &mut self, state: &ClientSyncState, ) -> Result<()> { let mut ids = self.col.get_chunkable_ids(state.pending_usn)?; loop { let chunk: Chunk = self.col.get_chunk(&mut ids, Some(state.server_usn))?; let done = chunk.done; debug!( done = chunk.done, cards = chunk.cards.len(), notes = chunk.notes.len(), revlog = chunk.revlog.len(), "sending" ); self.progress.update(false, |p| { p.local_update += chunk.cards.len() + chunk.notes.len() + chunk.revlog.len() })?; self.server .apply_chunk(ApplyChunkRequest { chunk }.try_into_sync_request()?) .await?; self.progress.check_cancelled()?; if done { return Ok(()); } } } } impl Collection { // Remote->local chunks //---------------------------------------------------------------- /// pending_usn is used to decide whether the local objects are newer. /// If the provided objects are not modified locally, the USN inside /// the individual objects is used. pub(in crate::sync) fn apply_chunk(&mut self, chunk: Chunk, pending_usn: Usn) -> Result<()> { self.merge_revlog(chunk.revlog)?; self.merge_cards(chunk.cards, pending_usn)?; self.merge_notes(chunk.notes, pending_usn) } fn merge_revlog(&self, entries: Vec) -> Result<()> { for entry in entries { self.storage.add_revlog_entry(&entry, false)?; } Ok(()) } fn merge_cards(&self, entries: Vec, pending_usn: Usn) -> Result<()> { for entry in entries { self.add_or_update_card_if_newer(entry, pending_usn)?; } Ok(()) } fn add_or_update_card_if_newer(&self, entry: CardEntry, pending_usn: Usn) -> Result<()> { let proceed = if let Some(existing_card) = self.storage.get_card(entry.id)? { !existing_card.usn.is_pending_sync(pending_usn) || existing_card.mtime < entry.mtime } else { true }; if proceed { let card = entry.into(); self.storage.add_or_update_card(&card)?; } Ok(()) } fn merge_notes(&mut self, entries: Vec, pending_usn: Usn) -> Result<()> { for entry in entries { self.add_or_update_note_if_newer(entry, pending_usn)?; } Ok(()) } fn add_or_update_note_if_newer(&mut self, entry: NoteEntry, pending_usn: Usn) -> Result<()> { let proceed = if let Some(existing_note) = self.storage.get_note(entry.id)? { !existing_note.usn.is_pending_sync(pending_usn) || existing_note.mtime < entry.mtime } else { true }; if proceed { let mut note: Note = entry.into(); let nt = self .get_notetype(note.notetype_id)? .or_invalid("note missing notetype")?; note.prepare_for_update(&nt, false)?; self.storage.add_or_update_note(¬e)?; } Ok(()) } // Local->remote chunks //---------------------------------------------------------------- pub(in crate::sync) fn get_chunkable_ids(&self, pending_usn: Usn) -> Result { Ok(ChunkableIds { revlog: self.storage.objects_pending_sync("revlog", pending_usn)?, cards: self.storage.objects_pending_sync("cards", pending_usn)?, notes: self.storage.objects_pending_sync("notes", pending_usn)?, }) } /// Fetch a chunk of ids from `ids`, returning the referenced objects. pub(in crate::sync) fn get_chunk( &self, ids: &mut ChunkableIds, server_usn_if_client: Option, ) -> Result { // get a bunch of IDs let mut limit = CHUNK_SIZE as i32; let mut revlog_ids = vec![]; let mut card_ids = vec![]; let mut note_ids = vec![]; let mut chunk = Chunk::default(); while limit > 0 { let last_limit = limit; if let Some(id) = ids.revlog.pop() { revlog_ids.push(id); limit -= 1; } if let Some(id) = ids.notes.pop() { note_ids.push(id); limit -= 1; } if let Some(id) = ids.cards.pop() { card_ids.push(id); limit -= 1; } if limit == last_limit { // all empty break; } } if limit > 0 { chunk.done = true; } // remove pending status if !self.server { self.storage .maybe_update_object_usns("revlog", &revlog_ids, server_usn_if_client)?; self.storage .maybe_update_object_usns("cards", &card_ids, server_usn_if_client)?; self.storage .maybe_update_object_usns("notes", ¬e_ids, server_usn_if_client)?; } // the fetch associated objects, and return chunk.revlog = revlog_ids .into_iter() .map(|id| { self.storage.get_revlog_entry(id).map(|e| { let mut e = e.unwrap(); e.usn = server_usn_if_client.unwrap_or(e.usn); e }) }) .collect::>()?; chunk.cards = card_ids .into_iter() .map(|id| { self.storage.get_card(id).map(|e| { let mut e: CardEntry = e.unwrap().into(); e.usn = server_usn_if_client.unwrap_or(e.usn); e }) }) .collect::>()?; chunk.notes = note_ids .into_iter() .map(|id| { self.storage.get_note(id).map(|e| { let mut e: NoteEntry = e.unwrap().into(); e.usn = server_usn_if_client.unwrap_or(e.usn); e }) }) .collect::>()?; Ok(chunk) } } impl From for Card { fn from(e: CardEntry) -> Self { let data = CardData::from_str(&e.data); Card { id: e.id, note_id: e.nid, deck_id: e.did, template_idx: e.ord, mtime: e.mtime, usn: e.usn, ctype: e.ctype, queue: e.queue, due: e.due, interval: e.ivl, ease_factor: e.factor, reps: e.reps, lapses: e.lapses, remaining_steps: e.left, original_due: e.odue, original_deck_id: e.odid, flags: e.flags, original_position: data.original_position, memory_state: data.memory_state(), desired_retention: data.fsrs_desired_retention, decay: data.decay, last_review_time: data.last_review_time, custom_data: data.custom_data, } } } impl From for CardEntry { fn from(e: Card) -> Self { CardEntry { id: e.id, nid: e.note_id, did: e.deck_id, ord: e.template_idx, mtime: e.mtime, usn: e.usn, ctype: e.ctype, queue: e.queue, due: e.due, ivl: e.interval, factor: e.ease_factor, reps: e.reps, lapses: e.lapses, left: e.remaining_steps, odue: e.original_due, odid: e.original_deck_id, flags: e.flags, data: card_data_string(&e), } } } impl From for Note { fn from(e: NoteEntry) -> Self { let fields = e.fields.split('\x1f').map(ToString::to_string).collect(); Note::new_from_storage( e.id, e.guid, e.ntid, e.mtime, e.usn, split_tags(&e.tags).map(ToString::to_string).collect(), fields, None, None, ) } } impl From for NoteEntry { fn from(e: Note) -> Self { NoteEntry { id: e.id, fields: e.fields().iter().join("\x1f"), guid: e.guid, ntid: e.notetype_id, mtime: e.mtime, usn: e.usn, tags: join_tags(&e.tags), sfld: String::new(), csum: String::new(), flags: 0, data: String::new(), } } } pub fn server_chunk(col: &mut Collection, state: &mut ServerSyncState) -> Result { if state.server_chunk_ids.is_none() { state.server_chunk_ids = Some(col.get_chunkable_ids(state.client_usn)?); } col.get_chunk(state.server_chunk_ids.as_mut().unwrap(), None) } pub fn server_apply_chunk( req: ApplyChunkRequest, col: &mut Collection, state: &mut ServerSyncState, ) -> Result<()> { col.apply_chunk(req.chunk, state.client_usn) } impl Usn { pub(crate) fn is_pending_sync(self, pending_usn: Usn) -> bool { if pending_usn.0 == -1 { self.0 == -1 } else { self.0 >= pending_usn.0 } } } pub const CHUNK_SIZE: usize = 250; #[derive(Serialize, Deserialize, Debug)] pub struct ApplyChunkRequest { pub chunk: Chunk, } ================================================ FILE: rslib/src/sync/collection/download.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_io::atomic_rename; use anki_io::new_tempfile_in_parent_of; use anki_io::read_file; use anki_io::write_file; use reqwest::Client; use crate::collection::CollectionBuilder; use crate::prelude::*; use crate::storage::SchemaVersion; use crate::sync::collection::protocol::EmptyInput; use crate::sync::error::HttpResult; use crate::sync::error::OrHttpErr; use crate::sync::http_client::HttpSyncClient; use crate::sync::login::SyncAuth; impl Collection { /// Download collection from AnkiWeb. Caller must re-open afterwards. pub async fn full_download(self, auth: SyncAuth, client: Client) -> Result<()> { self.full_download_with_server(HttpSyncClient::new(auth, client)) .await } // pub for tests pub(super) async fn full_download_with_server(self, server: HttpSyncClient) -> Result<()> { let col_path = self.col_path.clone(); let _col_folder = col_path.parent().or_invalid("couldn't get col_folder")?; let progress = self.new_progress_handler(); self.close(None)?; let out_data = server .download_with_progress(EmptyInput::request(), progress) .await? .data; // check file ok let temp_file = new_tempfile_in_parent_of(&col_path)?; write_file(temp_file.path(), out_data)?; let col = CollectionBuilder::new(temp_file.path()) .set_check_integrity(true) .build()?; col.storage.db.execute_batch("update col set ls=mod")?; col.close(None)?; atomic_rename(temp_file, &col_path, true)?; Ok(()) } } pub fn server_download( col: &mut Option, schema_version: SchemaVersion, ) -> HttpResult> { let col_path = { let mut col = col.take().or_internal_err("take col")?; let path = col.col_path.clone(); col.transact_no_undo(|col| col.storage.increment_usn()) .or_internal_err("incr usn")?; col.close(Some(schema_version)).or_internal_err("close")?; path }; let data = read_file(col_path).or_internal_err("read col")?; Ok(data) } ================================================ FILE: rslib/src/sync/collection/finish.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::prelude::*; use crate::sync::collection::normal::ClientSyncState; use crate::sync::collection::normal::NormalSyncer; use crate::sync::collection::protocol::EmptyInput; use crate::sync::collection::protocol::SyncProtocol; impl NormalSyncer<'_> { pub(in crate::sync) async fn finalize(&mut self, state: &ClientSyncState) -> Result<()> { let new_server_mtime = self.server.finish(EmptyInput::request()).await?.json()?; self.col.finalize_sync(state, new_server_mtime) } } impl Collection { fn finalize_sync( &self, state: &ClientSyncState, new_server_mtime: TimestampMillis, ) -> Result<()> { self.storage.set_last_sync(new_server_mtime)?; let mut usn = state.server_usn; usn.0 += 1; self.storage.set_usn(usn)?; self.storage.set_modified_time(new_server_mtime) } } pub fn server_finish(col: &mut Collection) -> Result { let now = TimestampMillis::now(); col.storage.set_last_sync(now)?; col.storage.increment_usn()?; col.storage.commit_rust_trx()?; col.storage.set_modified_time(now)?; Ok(now) } ================================================ FILE: rslib/src/sync/collection/graves.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use serde::Deserialize; use serde::Serialize; use crate::prelude::*; use crate::sync::collection::chunks::CHUNK_SIZE; use crate::sync::collection::start::ServerSyncState; #[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct ApplyGravesRequest { pub chunk: Graves, } #[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct Graves { pub(crate) cards: Vec, pub(crate) decks: Vec, pub(crate) notes: Vec, } impl Graves { pub(in crate::sync) fn take_chunk(&mut self) -> Option { let mut limit = CHUNK_SIZE; let mut out = Graves::default(); while limit > 0 && !self.cards.is_empty() { out.cards.push(self.cards.pop().unwrap()); limit -= 1; } while limit > 0 && !self.notes.is_empty() { out.notes.push(self.notes.pop().unwrap()); limit -= 1; } while limit > 0 && !self.decks.is_empty() { out.decks.push(self.decks.pop().unwrap()); limit -= 1; } if limit == CHUNK_SIZE { None } else { Some(out) } } } impl Collection { pub fn apply_graves(&self, graves: Graves, latest_usn: Usn) -> Result<()> { for nid in graves.notes { self.storage.remove_note(nid)?; self.storage.add_note_grave(nid, latest_usn)?; } for cid in graves.cards { self.storage.remove_card(cid)?; self.storage.add_card_grave(cid, latest_usn)?; } for did in graves.decks { self.storage.remove_deck(did)?; self.storage.add_deck_grave(did, latest_usn)?; } Ok(()) } } pub fn server_apply_graves( req: ApplyGravesRequest, col: &mut Collection, state: &mut ServerSyncState, ) -> Result<()> { col.apply_graves(req.chunk, state.server_usn) } ================================================ FILE: rslib/src/sync/collection/meta.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use ammonia::Url; use anki_io::metadata; use axum::http::StatusCode; use serde::Deserialize; use serde::Serialize; use tracing::debug; use tracing::info; use crate::config::SchedulerVersion; use crate::prelude::*; use crate::sync::collection::normal::ClientSyncState; use crate::sync::collection::normal::SyncActionRequired; use crate::sync::collection::protocol::SyncProtocol; use crate::sync::error::HttpError; use crate::sync::error::HttpResult; use crate::sync::error::OrHttpErr; use crate::sync::http_client::HttpSyncClient; use crate::sync::request::IntoSyncRequest; use crate::sync::request::SyncRequest; use crate::sync::request::MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED; use crate::sync::version::SYNC_VERSION_09_V2_SCHEDULER; use crate::sync::version::SYNC_VERSION_10_V2_TIMEZONE; use crate::sync::version::SYNC_VERSION_MAX; use crate::sync::version::SYNC_VERSION_MIN; use crate::version::sync_client_version; #[derive(Serialize, Deserialize, Debug, Default)] pub struct SyncMeta { #[serde(rename = "mod")] pub modified: TimestampMillis, #[serde(rename = "scm")] pub schema: TimestampMillis, pub usn: Usn, #[serde(rename = "ts")] pub current_time: TimestampSecs, #[serde(rename = "msg")] pub server_message: String, #[serde(rename = "cont")] pub should_continue: bool, /// Used by clients prior to sync version 11 #[serde(rename = "hostNum")] pub host_number: u32, #[serde(default)] pub empty: bool, /// This field is not set by col.sync_meta(), and must be filled in /// separately. pub media_usn: Usn, #[serde(skip)] pub v2_scheduler_or_later: bool, #[serde(skip)] pub v2_timezone: bool, #[serde(skip)] pub collection_bytes: u64, } impl SyncMeta { pub(in crate::sync) fn compared_to_remote( &self, remote: SyncMeta, new_endpoint: Option, ) -> ClientSyncState { let local = self; let required = if remote.modified == local.modified { SyncActionRequired::NoChanges } else if remote.schema != local.schema { let upload_ok = !local.empty || remote.empty; let download_ok = !remote.empty || local.empty; SyncActionRequired::FullSyncRequired { upload_ok, download_ok, } } else { SyncActionRequired::NormalSyncRequired }; ClientSyncState { required, local_is_newer: local.modified > remote.modified, usn_at_last_sync: local.usn, server_usn: remote.usn, pending_usn: Usn(-1), server_message: remote.server_message, host_number: remote.host_number, new_endpoint, server_media_usn: remote.media_usn, } } } impl HttpSyncClient { /// Fetch server meta. Returns a new endpoint if one was provided. pub(in crate::sync) async fn meta_with_redirect( &mut self, ) -> Result<(SyncMeta, Option)> { let mut new_endpoint = None; let response = match self.meta(MetaRequest::request()).await { Ok(remote) => remote, Err(HttpError { code: StatusCode::PERMANENT_REDIRECT, context, .. }) => { debug!(endpoint = context, "redirect to new location"); let url = Url::try_from(context.as_str()) .or_bad_request("couldn't parse new location")?; new_endpoint = Some(context); self.endpoint = url; self.meta(MetaRequest::request()).await? } err => err?, }; let remote = response.json()?; Ok((remote, new_endpoint)) } } #[derive(Serialize, Deserialize, Debug)] pub struct MetaRequest { #[serde(rename = "v")] pub sync_version: u8, #[serde(rename = "cv")] pub client_version: String, } impl Collection { pub fn sync_meta(&self) -> Result { let stamps = self.storage.get_collection_timestamps()?; let collection_bytes = metadata(&self.col_path)?.len(); Ok(SyncMeta { modified: stamps.collection_change, schema: stamps.schema_change, // server=true is used for the client case as well, as we // want the actual usn and not -1 usn: self.storage.usn(true)?, current_time: TimestampSecs::now(), server_message: "".into(), should_continue: true, host_number: 0, empty: !self.storage.have_at_least_one_card()?, v2_scheduler_or_later: self.scheduler_version() == SchedulerVersion::V2, v2_timezone: self.get_creation_utc_offset().is_some(), collection_bytes, // must be filled in by calling code media_usn: Usn(0), }) } } pub fn server_meta(req: MetaRequest, col: &mut Collection) -> HttpResult { if !matches!(req.sync_version, SYNC_VERSION_MIN..=SYNC_VERSION_MAX) { return Err(HttpError { // old clients expected this code code: StatusCode::NOT_IMPLEMENTED, context: "unsupported version".into(), source: None, }); } let mut meta = col.sync_meta().or_internal_err("sync meta")?; if meta.collection_bytes > *MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED { info!("collection is too large, forcing one-way sync"); meta.schema = TimestampMillis::now(); } if meta.v2_scheduler_or_later && req.sync_version < SYNC_VERSION_09_V2_SCHEDULER { meta.server_message = "Your client does not support the v2 scheduler".into(); meta.should_continue = false; } else if meta.v2_timezone && req.sync_version < SYNC_VERSION_10_V2_TIMEZONE { meta.server_message = "Your client does not support the new timezone handling.".into(); meta.should_continue = false; } Ok(meta) } impl MetaRequest { pub fn request() -> SyncRequest { MetaRequest { sync_version: SYNC_VERSION_MAX, client_version: sync_client_version().into(), } .try_into_sync_request() .expect("infallible meta request") } } ================================================ FILE: rslib/src/sync/collection/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub mod changes; pub mod chunks; pub mod download; pub mod finish; pub mod graves; pub mod meta; pub mod normal; pub mod progress; pub mod protocol; pub mod sanity; pub mod start; pub mod status; pub mod tests; pub mod upload; ================================================ FILE: rslib/src/sync/collection/normal.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use reqwest::Client; use tracing::debug; use crate::collection::Collection; use crate::error; use crate::error::AnkiError; use crate::error::SyncError; use crate::error::SyncErrorKind; use crate::prelude::Usn; use crate::progress::ThrottlingProgressHandler; use crate::sync::collection::progress::SyncStage; use crate::sync::collection::protocol::EmptyInput; use crate::sync::collection::protocol::SyncProtocol; use crate::sync::collection::status::online_sync_status_check; use crate::sync::http_client::HttpSyncClient; use crate::sync::login::SyncAuth; use crate::sync::request::MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED; pub struct NormalSyncer<'a> { pub(in crate::sync) col: &'a mut Collection, pub(in crate::sync) server: HttpSyncClient, pub(in crate::sync) progress: ThrottlingProgressHandler, } #[derive(Default, Debug, Clone, Copy)] pub struct NormalSyncProgress { pub stage: SyncStage, pub local_update: usize, pub local_remove: usize, pub remote_update: usize, pub remote_remove: usize, } #[derive(PartialEq, Eq, Debug, Clone, Copy)] pub enum SyncActionRequired { NoChanges, FullSyncRequired { upload_ok: bool, download_ok: bool }, NormalSyncRequired, } #[derive(Debug)] pub struct ClientSyncState { pub required: SyncActionRequired, pub server_message: String, pub host_number: u32, pub new_endpoint: Option, pub(in crate::sync) local_is_newer: bool, pub(in crate::sync) usn_at_last_sync: Usn, // latest server usn; local -1 entries will be rewritten to this pub(in crate::sync) server_usn: Usn, // -1 in client case; used to locate pending entries pub(in crate::sync) pending_usn: Usn, pub(in crate::sync) server_media_usn: Usn, } impl NormalSyncer<'_> { pub fn new(col: &mut Collection, server: HttpSyncClient) -> NormalSyncer<'_> { NormalSyncer { progress: col.new_progress_handler(), col, server, } } pub async fn sync(&mut self) -> error::Result { debug!("fetching meta..."); let local = self.col.sync_meta()?; let local_bytes = local.collection_bytes; let limit = *MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED; if self.server.endpoint.as_str().contains("ankiweb") && local.collection_bytes > limit { return Err(AnkiError::sync_error( format!("{local_bytes} > {limit}"), SyncErrorKind::UploadTooLarge, )); } let state = online_sync_status_check(local, &mut self.server).await?; debug!(?state, "fetched"); match state.required { SyncActionRequired::NoChanges => Ok(state.into()), SyncActionRequired::FullSyncRequired { .. } => Ok(state.into()), SyncActionRequired::NormalSyncRequired => { self.col.discard_undo_and_study_queues(); let timing = self.col.timing_today()?; self.col.unbury_if_day_rolled_over(timing)?; self.col.storage.begin_trx()?; match self.normal_sync_inner(state).await { Ok(success) => { self.col.storage.commit_trx()?; Ok(success) } Err(e) => { self.col.storage.rollback_trx()?; let _ = self.server.abort(EmptyInput::request()).await; if let AnkiError::SyncError { source: SyncError { kind: SyncErrorKind::SanityCheckFailed { client, server }, .. }, } = &e { debug!(client_counts=?client, server_counts=?server, "sanity check failed"); self.col.set_schema_modified()?; } Err(e) } } } } } /// Sync. Caller must have created a transaction, and should call /// abort on failure. async fn normal_sync_inner(&mut self, mut state: ClientSyncState) -> error::Result { self.progress .update(false, |p| p.stage = SyncStage::Syncing)?; debug!("start"); self.start_and_process_deletions(&state).await?; debug!("unchunked changes"); self.process_unchunked_changes(&state).await?; debug!("begin stream from server"); self.process_chunks_from_server(&state).await?; debug!("begin stream to server"); self.send_chunks_to_server(&state).await?; self.progress .update(false, |p| p.stage = SyncStage::Finalizing)?; debug!("sanity check"); self.sanity_check().await?; debug!("finalize"); self.finalize(&state).await?; state.required = SyncActionRequired::NoChanges; Ok(state.into()) } } #[derive(Debug)] pub struct SyncOutput { pub required: SyncActionRequired, pub server_message: String, pub host_number: u32, pub new_endpoint: Option, #[allow(unused)] pub(crate) server_media_usn: Usn, } impl From for SyncOutput { fn from(s: ClientSyncState) -> Self { SyncOutput { required: s.required, server_message: s.server_message, host_number: s.host_number, new_endpoint: s.new_endpoint, server_media_usn: s.server_media_usn, } } } impl Collection { pub async fn normal_sync( &mut self, auth: SyncAuth, client: Client, ) -> error::Result { NormalSyncer::new(self, HttpSyncClient::new(auth, client)) .sync() .await } } ================================================ FILE: rslib/src/sync/collection/progress.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use reqwest::Client; use crate::error; use crate::sync::collection::protocol::EmptyInput; use crate::sync::collection::protocol::SyncProtocol; use crate::sync::http_client::HttpSyncClient; use crate::sync::login::SyncAuth; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SyncStage { #[default] Connecting, Syncing, Finalizing, } #[derive(Debug, Default, Clone, Copy)] pub struct FullSyncProgress { pub transferred_bytes: usize, pub total_bytes: usize, } pub async fn sync_abort(auth: SyncAuth, client: Client) -> error::Result<()> { HttpSyncClient::new(auth, client) .abort(EmptyInput::request()) .await? .json() } ================================================ FILE: rslib/src/sync/collection/protocol.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::marker::PhantomData; use ammonia::Url; use async_trait::async_trait; use serde::Deserialize; use serde::Serialize; use strum::IntoStaticStr; use crate::prelude::TimestampMillis; use crate::sync::collection::changes::ApplyChangesRequest; use crate::sync::collection::changes::UnchunkedChanges; use crate::sync::collection::chunks::ApplyChunkRequest; use crate::sync::collection::chunks::Chunk; use crate::sync::collection::graves::ApplyGravesRequest; use crate::sync::collection::graves::Graves; use crate::sync::collection::meta::MetaRequest; use crate::sync::collection::meta::SyncMeta; use crate::sync::collection::sanity::SanityCheckRequest; use crate::sync::collection::sanity::SanityCheckResponse; use crate::sync::collection::start::StartRequest; use crate::sync::collection::upload::UploadResponse; use crate::sync::error::HttpResult; use crate::sync::login::HostKeyRequest; use crate::sync::login::HostKeyResponse; use crate::sync::request::IntoSyncRequest; use crate::sync::request::SyncRequest; use crate::sync::response::SyncResponse; #[derive(IntoStaticStr, Deserialize, PartialEq, Eq, Debug)] #[serde(rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] pub enum SyncMethod { HostKey, Meta, Start, ApplyGraves, ApplyChanges, Chunk, ApplyChunk, SanityCheck2, Finish, Abort, Upload, Download, } pub trait AsSyncEndpoint: Into<&'static str> { fn as_sync_endpoint(&self, base: &Url) -> Url; } impl AsSyncEndpoint for SyncMethod { fn as_sync_endpoint(&self, base: &Url) -> Url { base.join("sync/").unwrap().join(self.into()).unwrap() } } #[async_trait] pub trait SyncProtocol: Send + Sync + 'static { async fn host_key( &self, req: SyncRequest, ) -> HttpResult>; async fn meta(&self, req: SyncRequest) -> HttpResult>; async fn start(&self, req: SyncRequest) -> HttpResult>; async fn apply_graves( &self, req: SyncRequest, ) -> HttpResult>; async fn apply_changes( &self, req: SyncRequest, ) -> HttpResult>; async fn chunk(&self, req: SyncRequest) -> HttpResult>; async fn apply_chunk( &self, req: SyncRequest, ) -> HttpResult>; async fn sanity_check( &self, req: SyncRequest, ) -> HttpResult>; async fn finish( &self, req: SyncRequest, ) -> HttpResult>; async fn abort(&self, req: SyncRequest) -> HttpResult>; async fn upload(&self, req: SyncRequest>) -> HttpResult>; async fn download(&self, req: SyncRequest) -> HttpResult>>; } /// The sync protocol expects '{}' to be sent in requests without args. /// Serde serializes/deserializes empty structs as 'null', so we add an empty /// value to cause it to produce a map instead. This only applies to inputs; /// empty outputs are returned as ()/null. #[derive(Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct EmptyInput { #[serde(default)] _pad: PhantomData<()>, } impl EmptyInput { pub(crate) fn request() -> SyncRequest { Self::default() .try_into_sync_request() // should be infallible .expect("empty input into request") } } ================================================ FILE: rslib/src/sync/collection/sanity.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use serde::Deserialize; use serde::Serialize; use serde_tuple::Serialize_tuple; use tracing::debug; use tracing::info; use crate::error::SyncErrorKind; use crate::prelude::*; use crate::serde::default_on_invalid; use crate::sync::collection::normal::NormalSyncer; use crate::sync::collection::protocol::SyncProtocol; use crate::sync::request::IntoSyncRequest; #[derive(Serialize, Deserialize, Debug)] pub struct SanityCheckResponse { pub status: SanityCheckStatus, #[serde(rename = "c", default, deserialize_with = "default_on_invalid")] pub client: Option, #[serde(rename = "s", default, deserialize_with = "default_on_invalid")] pub server: Option, } #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum SanityCheckStatus { Ok, Bad, } #[derive(Serialize_tuple, Deserialize, Debug, PartialEq, Eq)] pub struct SanityCheckCounts { pub counts: SanityCheckDueCounts, pub cards: u32, pub notes: u32, pub revlog: u32, pub graves: u32, #[serde(rename = "models")] pub notetypes: u32, pub decks: u32, pub deck_config: u32, } #[derive(Serialize_tuple, Deserialize, Debug, Default, PartialEq, Eq)] pub struct SanityCheckDueCounts { pub new: u32, pub learn: u32, pub review: u32, } impl NormalSyncer<'_> { /// Caller should force full sync after rolling back. pub(in crate::sync) async fn sanity_check(&mut self) -> Result<()> { let local_counts = self.col.storage.sanity_check_info()?; debug!("gathered local counts; waiting for server reply"); let SanityCheckResponse { status, client, server, } = self .server .sanity_check( SanityCheckRequest { client: local_counts, } .try_into_sync_request()?, ) .await? .json()?; debug!("got server reply"); if status != SanityCheckStatus::Ok { Err(AnkiError::sync_error( "", SyncErrorKind::SanityCheckFailed { client, server }, )) } else { Ok(()) } } } pub fn server_sanity_check( SanityCheckRequest { mut client }: SanityCheckRequest, col: &mut Collection, ) -> Result { let mut server = match col.storage.sanity_check_info() { Ok(info) => info, Err(err) => { info!(client_counts=?client, ?err, "sanity check failed"); return Ok(SanityCheckResponse { status: SanityCheckStatus::Bad, client: Some(client), server: None, }); } }; client.counts = Default::default(); // clients on schema 17 and below may send duplicate // deletion markers, so we can't compare graves until // the minimum syncing version is schema 18. client.graves = 0; server.graves = 0; Ok(SanityCheckResponse { status: if client == server { SanityCheckStatus::Ok } else { info!(client_counts=?client, server_counts=?server, "sanity check failed"); SanityCheckStatus::Bad }, client: Some(client), server: Some(server), }) } #[derive(Serialize, Deserialize, Debug)] pub struct SanityCheckRequest { pub client: SanityCheckCounts, } ================================================ FILE: rslib/src/sync/collection/start.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use serde::Deserialize; use serde::Deserializer; use serde::Serialize; use tracing::debug; use crate::prelude::*; use crate::sync::collection::chunks::ChunkableIds; use crate::sync::collection::graves::ApplyGravesRequest; use crate::sync::collection::graves::Graves; use crate::sync::collection::normal::ClientSyncState; use crate::sync::collection::normal::NormalSyncer; use crate::sync::collection::protocol::SyncProtocol; use crate::sync::request::IntoSyncRequest; impl NormalSyncer<'_> { pub(in crate::sync) async fn start_and_process_deletions( &mut self, state: &ClientSyncState, ) -> Result<()> { let remote: Graves = self .server .start( StartRequest { client_usn: state.usn_at_last_sync, local_is_newer: state.local_is_newer, deprecated_client_graves: None, } .try_into_sync_request()?, ) .await? .json()?; debug!( cards = remote.cards.len(), notes = remote.notes.len(), decks = remote.decks.len(), "removed on remote" ); let mut local = self.col.storage.pending_graves(state.pending_usn)?; self.col .storage .update_pending_grave_usns(state.server_usn)?; debug!( cards = local.cards.len(), notes = local.notes.len(), decks = local.decks.len(), "locally removed " ); while let Some(chunk) = local.take_chunk() { debug!("sending graves chunk"); self.progress.update(false, |p| { p.local_remove += chunk.cards.len() + chunk.notes.len() + chunk.decks.len() })?; self.server .apply_graves(ApplyGravesRequest { chunk }.try_into_sync_request()?) .await?; self.progress.check_cancelled()?; } self.progress.update(false, |p| { p.remote_remove = remote.cards.len() + remote.notes.len() + remote.decks.len() })?; self.col.apply_graves(remote, state.server_usn)?; self.progress.check_cancelled()?; debug!("applied server graves"); Ok(()) } } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct StartRequest { #[serde(rename = "minUsn")] pub client_usn: Usn, #[serde(rename = "lnewer")] pub local_is_newer: bool, /// Used by old clients, and still used by AnkiDroid. #[serde(rename = "graves", default, deserialize_with = "legacy_graves")] pub deprecated_client_graves: Option, } pub fn server_start( req: StartRequest, col: &mut Collection, state: &mut ServerSyncState, ) -> Result { state.server_usn = col.usn()?; state.client_usn = req.client_usn; state.client_is_newer = req.local_is_newer; col.discard_undo_and_study_queues(); col.storage.begin_rust_trx()?; // make sure any pending cards have been unburied first if necessary let timing = col.timing_today()?; col.unbury_if_day_rolled_over(timing)?; // fetch local graves let server_graves = col.storage.pending_graves(state.client_usn)?; // handle AnkiDroid using old protocol if let Some(graves) = req.deprecated_client_graves { col.apply_graves(graves, state.server_usn)?; } Ok(server_graves) } /// The current sync protocol is stateful, so unfortunately we need to /// retain a bunch of information across requests. These are set either /// on start, or on subsequent methods. pub struct ServerSyncState { /// The session key. This is sent on every http request, but is ignored for /// methods where there is not active sync state. pub skey: String, pub(in crate::sync) server_usn: Usn, pub(in crate::sync) client_usn: Usn, /// Only used to determine whether we should send our /// config to client. pub(in crate::sync) client_is_newer: bool, /// Set on the first call to chunk() pub(in crate::sync) server_chunk_ids: Option, } impl ServerSyncState { pub fn new(skey: impl Into) -> Self { Self { skey: skey.into(), server_usn: Default::default(), client_usn: Default::default(), client_is_newer: false, server_chunk_ids: None, } } } pub(crate) fn legacy_graves<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { #[derive(Deserialize)] #[serde(untagged)] enum GraveType { Normal(Graves), Legacy(StringGraves), Null, } match GraveType::deserialize(deserializer)? { GraveType::Normal(normal) => Ok(Some(normal)), GraveType::Legacy(stringly) => Ok(Some(Graves { cards: string_list_to_ids(stringly.cards)?, decks: string_list_to_ids(stringly.decks)?, notes: string_list_to_ids(stringly.notes)?, })), GraveType::Null => Ok(None), } } // old AnkiMobile versions #[derive(Deserialize)] struct StringGraves { cards: Vec, decks: Vec, notes: Vec, } fn string_list_to_ids(list: Vec) -> Result, E> where T: From, E: serde::de::Error, { list.into_iter() .map(|s| { s.parse::() .map_err(serde::de::Error::custom) .map(Into::into) }) .collect::, E>>() } ================================================ FILE: rslib/src/sync/collection/status.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::sync::sync_status_response; use tracing::debug; use crate::error::SyncErrorKind; use crate::prelude::*; use crate::sync::collection::meta::SyncMeta; use crate::sync::collection::normal::ClientSyncState; use crate::sync::http_client::HttpSyncClient; impl Collection { /// Checks local collection only. If local collection is clean but changes /// are pending on AnkiWeb, NoChanges will be returned. pub fn sync_status_offline(&mut self) -> Result { let stamps = self.storage.get_collection_timestamps()?; let required = if stamps.schema_changed_since_sync() { sync_status_response::Required::FullSync } else if stamps.collection_changed_since_sync() { sync_status_response::Required::NormalSync } else { sync_status_response::Required::NoChanges }; Ok(required) } } /// Should be called if a call to sync_status_offline() returns NoChanges, to /// check if AnkiWeb has pending changes. Caller should persist new endpoint if /// returned. /// /// This routine is outside of the collection, as we don't want to block /// collection access for a potentially slow network request that happens in the /// background. pub async fn online_sync_status_check( local: SyncMeta, server: &mut HttpSyncClient, ) -> Result { let (remote, new_endpoint) = server.meta_with_redirect().await?; debug!(?remote, "meta"); debug!(?local, "meta"); if !remote.should_continue { debug!(remote.server_message, "server says abort"); return Err(AnkiError::sync_error( remote.server_message, SyncErrorKind::ServerMessage, )); } let delta = remote.current_time.0 - local.current_time.0; if delta.abs() > 300 { debug!(delta, "clock off"); return Err(AnkiError::sync_error("", SyncErrorKind::ClockIncorrect)); } Ok(local.compared_to_remote(remote, new_endpoint)) } ================================================ FILE: rslib/src/sync/collection/tests.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html #![cfg(test)] use std::future::Future; use std::sync::LazyLock; use axum::http::StatusCode; use reqwest::Client; use reqwest::Url; use serde_json::json; use tempfile::tempdir; use tempfile::TempDir; use tokio::sync::Mutex; use tokio::sync::MutexGuard; use tracing::Instrument; use tracing::Span; use wiremock::matchers::method; use wiremock::matchers::path; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; use crate::card::CardQueue; use crate::collection::CollectionBuilder; use crate::deckconfig::DeckConfig; use crate::decks::DeckKind; use crate::error::SyncError; use crate::error::SyncErrorKind; use crate::log::set_global_logger; use crate::notetype::all_stock_notetypes; use crate::prelude::*; use crate::revlog::RevlogEntry; use crate::search::SortMode; use crate::sync::collection::graves::ApplyGravesRequest; use crate::sync::collection::meta::MetaRequest; use crate::sync::collection::normal::NormalSyncer; use crate::sync::collection::normal::SyncActionRequired; use crate::sync::collection::normal::SyncOutput; use crate::sync::collection::protocol::EmptyInput; use crate::sync::collection::protocol::SyncProtocol; use crate::sync::collection::start::StartRequest; use crate::sync::collection::upload::UploadResponse; use crate::sync::collection::upload::CORRUPT_MESSAGE; use crate::sync::http_client::HttpSyncClient; use crate::sync::http_server::default_ip_header; use crate::sync::http_server::SimpleServer; use crate::sync::http_server::SyncServerConfig; use crate::sync::login::HostKeyRequest; use crate::sync::login::SyncAuth; use crate::sync::request::IntoSyncRequest; struct TestAuth { username: String, password: String, host_key: String, } static AUTH: LazyLock = LazyLock::new(|| { if let Ok(auth) = std::env::var("TEST_AUTH") { let mut auth = auth.split(':'); TestAuth { username: auth.next().unwrap().into(), password: auth.next().unwrap().into(), host_key: auth.next().unwrap().into(), } } else { TestAuth { username: "user".to_string(), password: "pass".to_string(), host_key: "b2619aa1529dfdc4248e6edbf3c1b2a2b014cf6d".to_string(), } } }); pub(in crate::sync) async fn with_active_server(op: F) -> Result<()> where F: FnOnce(HttpSyncClient) -> O, O: Future>, { let _ = set_global_logger(None); // start server let base_folder = tempdir()?; std::env::set_var("SYNC_USER1", "user:pass"); let (addr, server_fut) = SimpleServer::make_server(SyncServerConfig { host: "127.0.0.1".parse().unwrap(), port: 0, base_folder: base_folder.path().into(), ip_header: default_ip_header(), }) .await .unwrap(); tokio::spawn(server_fut.instrument(Span::current())); // when not using ephemeral servers, tests need to be serialized static LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); let _lock: MutexGuard<()>; // setup client to connect to it let endpoint = if let Ok(endpoint) = std::env::var("TEST_ENDPOINT") { _lock = LOCK.lock().await; endpoint } else { format!("http://{addr}/") }; let endpoint = Url::try_from(endpoint.as_str()).unwrap(); let auth = SyncAuth { hkey: AUTH.host_key.clone(), endpoint: Some(endpoint), io_timeout_secs: None, }; let client = HttpSyncClient::new(auth, Client::new()); op(client).await } fn unwrap_sync_err_kind(err: AnkiError) -> SyncErrorKind { let AnkiError::SyncError { source: SyncError { kind, .. }, } = err else { panic!("not sync err: {err:?}"); }; kind } #[tokio::test] async fn host_key() -> Result<()> { with_active_server(|mut client| async move { let err = client .host_key( HostKeyRequest { username: "bad".to_string(), password: "bad".to_string(), } .try_into_sync_request()?, ) .await .unwrap_err(); assert_eq!(err.code, StatusCode::FORBIDDEN); assert_eq!( unwrap_sync_err_kind(AnkiError::from(err)), SyncErrorKind::AuthFailed ); // hkey should be automatically set after successful login client.sync_key = String::new(); let resp = client .host_key( HostKeyRequest { username: AUTH.username.clone(), password: AUTH.password.clone(), } .try_into_sync_request()?, ) .await? .json()?; assert_eq!(resp.key, *AUTH.host_key); Ok(()) }) .await } #[tokio::test] async fn meta() -> Result<()> { with_active_server(|client| async move { // unsupported sync version assert_eq!( SyncProtocol::meta( &client, MetaRequest { sync_version: 0, client_version: "".to_string(), } .try_into_sync_request()?, ) .await .unwrap_err() .code, StatusCode::NOT_IMPLEMENTED ); Ok(()) }) .await } #[tokio::test] async fn aborting_is_idempotent() -> Result<()> { with_active_server(|mut client| async move { // abort is a no-op if no sync in progress client.abort(EmptyInput::request()).await?; // start a sync let _graves = client .start( StartRequest { client_usn: Default::default(), local_is_newer: false, deprecated_client_graves: None, } .try_into_sync_request()?, ) .await?; // an abort request with the wrong key is ignored let orig_key = client.skey().to_string(); client.set_skey("aabbccdd".into()); client.abort(EmptyInput::request()).await?; // it should succeed with the correct key client.set_skey(orig_key); client.abort(EmptyInput::request()).await?; Ok(()) }) .await } #[tokio::test] async fn new_syncs_cancel_old_ones() -> Result<()> { with_active_server(|mut client| async move { let ctx = SyncTestContext::new(client.clone()); // start a sync let req = StartRequest { client_usn: Default::default(), local_is_newer: false, deprecated_client_graves: None, } .try_into_sync_request()?; let _ = client.start(req.clone()).await?; // a new sync aborts the previous one let orig_key = client.skey().to_string(); client.set_skey("1".into()); let _ = client.start(req.clone()).await?; // old sync can no longer proceed client.set_skey(orig_key); let graves_req = ApplyGravesRequest::default().try_into_sync_request()?; assert_eq!( client .apply_graves(graves_req.clone()) .await .unwrap_err() .code, StatusCode::CONFLICT ); // with the correct key, it can continue client.set_skey("1".into()); client.apply_graves(graves_req.clone()).await?; // but a full upload will break the lock ctx.full_upload(ctx.col1()).await; assert_eq!( client .apply_graves(graves_req.clone()) .await .unwrap_err() .code, StatusCode::CONFLICT ); // likewise with download let _ = client.start(req.clone()).await?; ctx.full_download(ctx.col1()).await; assert_eq!( client .apply_graves(graves_req.clone()) .await .unwrap_err() .code, StatusCode::CONFLICT ); Ok(()) }) .await } #[tokio::test] async fn sync_roundtrip() -> Result<()> { with_active_server(|client| async move { let ctx = SyncTestContext::new(client); upload_download(&ctx).await?; regular_sync(&ctx).await?; Ok(()) }) .await } #[tokio::test] async fn sanity_check_should_roll_back_and_force_full_sync() -> Result<()> { with_active_server(|client| async move { let ctx = SyncTestContext::new(client); upload_download(&ctx).await?; let mut col1 = ctx.col1(); // add a deck but don't mark it as requiring a sync, which will trigger the // sanity check to fail let mut deck = col1.get_or_create_normal_deck("unsynced deck")?; col1.add_or_update_deck(&mut deck)?; col1.storage .db .execute("update decks set usn=0 where id=?", [deck.id])?; // the sync should fail let err = NormalSyncer::new(&mut col1, ctx.cloned_client()) .sync() .await .unwrap_err(); assert!(matches!( err, AnkiError::SyncError { source: SyncError { kind: SyncErrorKind::SanityCheckFailed { .. }, .. } } )); // the server should have rolled back let mut col2 = ctx.col2(); let out = ctx.normal_sync(&mut col2).await; assert_eq!(out.required, SyncActionRequired::NoChanges); // and the client should have forced a one-way sync let out = ctx.normal_sync(&mut col1).await; assert_eq!( out.required, SyncActionRequired::FullSyncRequired { upload_ok: true, download_ok: true, } ); Ok(()) }) .await } #[tokio::test] async fn sync_errors_should_prompt_db_check() -> Result<()> { with_active_server(|client| async move { let ctx = SyncTestContext::new(client); upload_download(&ctx).await?; let mut col1 = ctx.col1(); // Add a a new notetype, and a note that uses it, but don't mark the notetype as // requiring a sync, which will cause the sync to fail as the note is added. let mut nt = all_stock_notetypes(&col1.tr).remove(0); nt.name = "new".into(); col1.add_notetype(&mut nt, false)?; let mut note = nt.new_note(); note.set_field(0, "test")?; col1.add_note(&mut note, DeckId(1))?; col1.storage.db.execute("update notetypes set usn=0", [])?; // the sync should fail let err = NormalSyncer::new(&mut col1, ctx.cloned_client()) .sync() .await .unwrap_err(); let AnkiError::SyncError { source: SyncError { info: _, kind }, } = err else { panic!() }; assert_eq!(kind, SyncErrorKind::DatabaseCheckRequired); // the server should have rolled back let mut col2 = ctx.col2(); let out = ctx.normal_sync(&mut col2).await; assert_eq!(out.required, SyncActionRequired::NoChanges); // and the client should be able to sync again without a forced one-way sync let err = NormalSyncer::new(&mut col1, ctx.cloned_client()) .sync() .await .unwrap_err(); let AnkiError::SyncError { source: SyncError { info: _, kind }, } = err else { panic!() }; assert_eq!(kind, SyncErrorKind::DatabaseCheckRequired); Ok(()) }) .await } /// Old AnkiMobile versions sent grave ids as strings #[tokio::test] async fn string_grave_ids_are_handled() -> Result<()> { with_active_server(|client| async move { let req = json!({ "minUsn": 0, "lnewer": false, "graves": { "cards": vec!["1"], "decks": vec!["2", "3"], "notes": vec!["4"], } }); let req = serde_json::to_vec(&req) .unwrap() .try_into_sync_request() .unwrap(); // should not return err 400 client.start(req.into_output_type()).await.unwrap(); client.abort(EmptyInput::request()).await?; Ok(()) }) .await?; // a missing value should be handled with_active_server(|client| async move { let req = json!({ "minUsn": 0, "lnewer": false, }); let req = serde_json::to_vec(&req) .unwrap() .try_into_sync_request() .unwrap(); client.start(req.into_output_type()).await.unwrap(); client.abort(EmptyInput::request()).await?; Ok(()) }) .await } #[tokio::test] async fn invalid_uploads_should_be_handled() -> Result<()> { with_active_server(|client| async move { let ctx = SyncTestContext::new(client); let res = ctx .client .upload(b"fake data".to_vec().try_into_sync_request()?) .await?; assert_eq!( res.upload_response(), UploadResponse::Err(CORRUPT_MESSAGE.into()) ); Ok(()) }) .await } #[tokio::test] async fn meta_redirect_is_handled() -> Result<()> { with_active_server(|client| async move { let mock_server = MockServer::start().await; Mock::given(method("POST")) .and(path("/sync/meta")) .respond_with( ResponseTemplate::new(308).insert_header("location", client.endpoint.as_str()), ) .mount(&mock_server) .await; // starting from in-sync state let mut ctx = SyncTestContext::new(client); upload_download(&ctx).await?; // add another note to trigger a normal sync let mut col1 = ctx.col1(); col1_setup(&mut col1); // switch to bad endpoint let orig_url = ctx.client.endpoint.to_string(); ctx.client.endpoint = Url::try_from(mock_server.uri().as_str()).unwrap(); // sync should succeed let out = ctx.normal_sync(&mut col1).await; // client should have received new endpoint assert_eq!(out.new_endpoint, Some(orig_url)); // client should not have tried the old endpoint more than once assert_eq!(mock_server.received_requests().await.unwrap().len(), 1); Ok(()) }) .await } pub(in crate::sync) struct SyncTestContext { pub folder: TempDir, pub client: HttpSyncClient, } impl SyncTestContext { pub fn new(client: HttpSyncClient) -> Self { Self { folder: tempdir().expect("create temp dir"), client, } } pub fn col1(&self) -> Collection { let base = self.folder.path(); CollectionBuilder::new(base.join("col1.anki2")) .with_desktop_media_paths() .build() .unwrap() } pub fn col2(&self) -> Collection { let base = self.folder.path(); CollectionBuilder::new(base.join("col2.anki2")) .with_desktop_media_paths() .build() .unwrap() } async fn normal_sync(&self, col: &mut Collection) -> SyncOutput { NormalSyncer::new(col, self.cloned_client()) .sync() .await .unwrap() } async fn full_upload(&self, col: Collection) { col.full_upload_with_server(self.cloned_client()) .await .unwrap() } async fn full_download(&self, col: Collection) { col.full_download_with_server(self.cloned_client()) .await .unwrap() } fn cloned_client(&self) -> HttpSyncClient { self.client.clone() } } // Setup + full syncs ///////////////////// fn col1_setup(col: &mut Collection) { let nt = col.get_notetype_by_name("Basic").unwrap().unwrap(); let mut note = nt.new_note(); note.set_field(0, "1").unwrap(); col.add_note(&mut note, DeckId(1)).unwrap(); } async fn upload_download(ctx: &SyncTestContext) -> Result<()> { let mut col1 = ctx.col1(); col1_setup(&mut col1); let out = ctx.normal_sync(&mut col1).await; assert!(matches!( out.required, SyncActionRequired::FullSyncRequired { .. } )); ctx.full_upload(col1).await; // another collection let mut col2 = ctx.col2(); // won't allow ankiweb clobber let out = ctx.normal_sync(&mut col2).await; assert_eq!( out.required, SyncActionRequired::FullSyncRequired { upload_ok: false, download_ok: true, } ); // fetch so we're in sync ctx.full_download(col2).await; Ok(()) } // Regular syncs ///////////////////// async fn regular_sync(ctx: &SyncTestContext) -> Result<()> { // add a deck let mut col1 = ctx.col1(); let mut col2 = ctx.col2(); let mut deck = col1.get_or_create_normal_deck("new deck")?; // give it a new option group let mut dconf = DeckConfig { name: "new dconf".into(), ..Default::default() }; col1.add_or_update_deck_config(&mut dconf)?; if let DeckKind::Normal(deck) = &mut deck.kind { deck.config_id = dconf.id.0; } col1.add_or_update_deck(&mut deck)?; // and a new notetype let mut nt = all_stock_notetypes(&col1.tr).remove(0); nt.name = "new".into(); col1.add_notetype(&mut nt, false)?; // add another note+card+tag let mut note = nt.new_note(); note.set_field(0, "2")?; note.tags.push("tag".into()); col1.add_note(&mut note, deck.id)?; // mock revlog entry col1.storage.add_revlog_entry( &RevlogEntry { id: RevlogId(123), cid: CardId(456), usn: Usn(-1), interval: 10, ..Default::default() }, true, )?; // config + creation col1.set_config("test", &"test1")?; // bumping this will affect 'last studied at' on decks at the moment // col1.storage.set_creation_stamp(TimestampSecs(12345))?; // and sync our changes let remote_meta = ctx .client .meta(MetaRequest::request()) .await .unwrap() .json() .unwrap(); let out = col1.sync_meta()?.compared_to_remote(remote_meta, None); assert_eq!(out.required, SyncActionRequired::NormalSyncRequired); let out = ctx.normal_sync(&mut col1).await; assert_eq!(out.required, SyncActionRequired::NoChanges); // sync the other collection let out = ctx.normal_sync(&mut col2).await; assert_eq!(out.required, SyncActionRequired::NoChanges); let ntid = nt.id; let deckid = deck.id; let dconfid = dconf.id; let noteid = note.id; let cardid = col1.search_cards(note.id, SortMode::NoOrder)?[0]; let revlogid = RevlogId(123); let compare_sides = |col1: &mut Collection, col2: &mut Collection| -> Result<()> { assert_eq!( col1.get_notetype(ntid)?.unwrap(), col2.get_notetype(ntid)?.unwrap() ); assert_eq!( col1.get_deck(deckid)?.unwrap(), col2.get_deck(deckid)?.unwrap() ); assert_eq!( col1.get_deck_config(dconfid, false)?.unwrap(), col2.get_deck_config(dconfid, false)?.unwrap() ); assert_eq!( col1.storage.get_note(noteid)?.unwrap(), col2.storage.get_note(noteid)?.unwrap() ); assert_eq!( col1.storage.get_card(cardid)?.unwrap(), col2.storage.get_card(cardid)?.unwrap() ); assert_eq!( col1.storage.get_revlog_entry(revlogid)?, col2.storage.get_revlog_entry(revlogid)?, ); assert_eq!( col1.storage.get_all_config()?, col2.storage.get_all_config()? ); assert_eq!( col1.storage.creation_stamp()?, col2.storage.creation_stamp()? ); // server doesn't send tag usns, so we can only compare tags, not usns, // as the usns may not match assert_eq!( col1.storage .all_tags()? .into_iter() .map(|t| t.name) .collect::>(), col2.storage .all_tags()? .into_iter() .map(|t| t.name) .collect::>() ); std::thread::sleep(std::time::Duration::from_millis(1)); Ok(()) }; // make sure everything has been transferred across compare_sides(&mut col1, &mut col2)?; // make some modifications let mut note = col2.storage.get_note(note.id)?.unwrap(); note.set_field(1, "new")?; note.tags.push("tag2".into()); col2.update_note(&mut note)?; col2.get_and_update_card(cardid, |card| { card.queue = CardQueue::Review; Ok(()) })?; let mut deck = col2.storage.get_deck(deck.id)?.unwrap(); deck.name = NativeDeckName::from_native_str("newer"); col2.add_or_update_deck(&mut deck)?; let mut nt = col2.storage.get_notetype(nt.id)?.unwrap(); nt.name = "newer".into(); col2.update_notetype(&mut nt, false)?; // sync the changes back let out = ctx.normal_sync(&mut col2).await; assert_eq!(out.required, SyncActionRequired::NoChanges); let out = ctx.normal_sync(&mut col1).await; assert_eq!(out.required, SyncActionRequired::NoChanges); // should still match compare_sides(&mut col1, &mut col2)?; // deletions should sync too for table in &["cards", "notes", "decks"] { assert_eq!( col1.storage .db_scalar::(&format!("select count() from {table}"))?, 2 ); } // fixme: inconsistent usn arg std::thread::sleep(std::time::Duration::from_millis(1)); col1.remove_cards_and_orphaned_notes(&[cardid])?; let usn = col1.usn()?; col1.remove_note_only_undoable(noteid, usn)?; col1.remove_decks_and_child_decks(&[deckid])?; let out = ctx.normal_sync(&mut col1).await; assert_eq!(out.required, SyncActionRequired::NoChanges); let out = ctx.normal_sync(&mut col2).await; assert_eq!(out.required, SyncActionRequired::NoChanges); for table in &["cards", "notes", "decks"] { assert_eq!( col2.storage .db_scalar::(&format!("select count() from {table}"))?, 1 ); } // removing things like a notetype forces a full sync std::thread::sleep(std::time::Duration::from_millis(1)); col2.remove_notetype(ntid)?; let out = ctx.normal_sync(&mut col2).await; assert!(matches!( out.required, SyncActionRequired::FullSyncRequired { .. } )); Ok(()) } ================================================ FILE: rslib/src/sync/collection/upload.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::fs; use std::io::Write; use anki_io::atomic_rename; use anki_io::new_tempfile_in_parent_of; use anki_io::write_file; use axum::response::IntoResponse; use axum::response::Response; use flate2::write::GzEncoder; use flate2::Compression; use futures::StreamExt; use reqwest::Client; use tokio_util::io::ReaderStream; use crate::collection::CollectionBuilder; use crate::error::SyncErrorKind; use crate::prelude::*; use crate::storage::SchemaVersion; use crate::sync::error::HttpResult; use crate::sync::error::OrHttpErr; use crate::sync::http_client::HttpSyncClient; use crate::sync::login::SyncAuth; use crate::sync::request::IntoSyncRequest; use crate::sync::request::MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED; /// Old clients didn't display a useful message on HTTP 400, and were expected /// to show the error message returned by the server. pub const CORRUPT_MESSAGE: &str = "Your upload was corrupt. Please use Check Database, or restore from backup."; impl Collection { /// Upload collection to AnkiWeb. Caller must re-open afterwards. pub async fn full_upload(self, auth: SyncAuth, client: Client) -> Result<()> { self.full_upload_with_server(HttpSyncClient::new(auth, client)) .await } // pub for tests pub(super) async fn full_upload_with_server(mut self, server: HttpSyncClient) -> Result<()> { self.before_upload()?; let col_path = self.col_path.clone(); let progress = self.new_progress_handler(); self.close(Some(SchemaVersion::V18))?; let col_data = fs::read(&col_path)?; let total_bytes = col_data.len(); if server.endpoint.as_str().contains("ankiweb") { check_upload_limit( total_bytes, *MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED as usize, )?; } match server .upload_with_progress(col_data.try_into_sync_request()?, progress) .await? .upload_response() { UploadResponse::Ok => Ok(()), UploadResponse::Err(msg) => { Err(AnkiError::sync_error(msg, SyncErrorKind::ServerMessage)) } } } } /// Collection must already be open, and will be replaced on success. pub fn handle_received_upload( col: &mut Option, new_data: Vec, ) -> HttpResult { let max_bytes = *MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED as usize; if new_data.len() >= max_bytes { return Ok(UploadResponse::Err("collection exceeds size limit".into())); } let path = col .as_ref() .or_internal_err("col was closed")? .col_path .clone(); // write to temp file let temp_file = new_tempfile_in_parent_of(&path).or_internal_err("temp file")?; write_file(temp_file.path(), &new_data).or_internal_err("temp file")?; // check the collection is valid if let Err(err) = CollectionBuilder::new(temp_file.path()) .set_check_integrity(true) .build() { tracing::info!(?err, "uploaded file was corrupt/failed to open"); return Ok(UploadResponse::Err(CORRUPT_MESSAGE.into())); } // close collection and rename if let Some(col) = col.take() { col.close(None) .or_internal_err("closing current collection")?; } atomic_rename(temp_file, &path, true).or_internal_err("rename upload")?; Ok(UploadResponse::Ok) } impl IntoResponse for UploadResponse { fn into_response(self) -> Response { match self { // the legacy protocol expects this exact string UploadResponse::Ok => "OK".to_string(), UploadResponse::Err(e) => e, } .into_response() } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum UploadResponse { Ok, Err(String), } pub fn check_upload_limit(size: usize, limit: usize) -> Result<()> { let size_of_one_mb: f64 = 1024.0 * 1024.0; let collection_size_in_mb: f64 = size as f64 / size_of_one_mb; let limit_size_in_mb: f64 = limit as f64 / size_of_one_mb; if size >= limit { Err(AnkiError::sync_error( format!("{collection_size_in_mb:.2} MB > {limit_size_in_mb:.2} MB"), SyncErrorKind::UploadTooLarge, )) } else { Ok(()) } } pub async fn gzipped_data_from_vec(vec: Vec) -> Result> { let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); let mut stream = ReaderStream::new(&vec[..]); while let Some(chunk) = stream.next().await { let chunk = chunk?; encoder.write_all(&chunk)?; } encoder.finish().map_err(Into::into) } ================================================ FILE: rslib/src/sync/error.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::error::Error; use std::fmt::Display; use std::fmt::Formatter; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::response::Redirect; use axum::response::Response; pub type HttpResult = Result; #[derive(Debug)] pub struct HttpError { pub code: StatusCode, pub context: String, pub source: Option>, } impl Display for HttpError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{} (code={})", self.context, self.code.as_u16()) } } impl Error for HttpError { fn source(&self) -> Option<&(dyn Error + 'static)> { match &self.source { None => None, Some(err) => Some(err.as_ref()), } } } impl HttpError { pub fn new_without_source(code: StatusCode, context: impl Into) -> Self { Self { code, context: context.into(), source: None, } } /// Compatibility with ensure!() macro pub fn fail(self) -> Result { Err(self) } } impl IntoResponse for HttpError { fn into_response(self) -> Response { let HttpError { code, context, source, } = self; if code.is_server_error() && code != StatusCode::NOT_IMPLEMENTED { tracing::error!(context, ?source, httpstatus = code.as_u16(),); } else { tracing::info!(context, ?source, httpstatus = code.as_u16(),); } if code == StatusCode::PERMANENT_REDIRECT { Redirect::permanent(&context).into_response() } else { (code, code.as_str().to_string()).into_response() } } } pub trait OrHttpErr { type Value; fn or_http_err( self, code: StatusCode, context: impl Into, ) -> Result; fn or_bad_request(self, context: impl Into) -> Result where Self: Sized, { self.or_http_err(StatusCode::BAD_REQUEST, context) } fn or_internal_err(self, context: impl Into) -> Result where Self: Sized, { self.or_http_err(StatusCode::INTERNAL_SERVER_ERROR, context) } fn or_forbidden(self, context: impl Into) -> Result where Self: Sized, { self.or_http_err(StatusCode::FORBIDDEN, context) } fn or_conflict(self, context: impl Into) -> Result where Self: Sized, { self.or_http_err(StatusCode::CONFLICT, context) } fn or_not_found(self, context: impl Into) -> Result where Self: Sized, { self.or_http_err(StatusCode::NOT_FOUND, context) } fn or_permanent_redirect(self, context: impl Into) -> Result where Self: Sized, { self.or_http_err(StatusCode::PERMANENT_REDIRECT, context) } } impl OrHttpErr for Result where E: Into>, { type Value = T; fn or_http_err( self, code: StatusCode, context: impl Into, ) -> Result { self.map_err(|err| HttpError { code, context: context.into(), source: Some(err.into()), }) } } impl OrHttpErr for Option { type Value = T; fn or_http_err( self, code: StatusCode, context: impl Into, ) -> Result { self.ok_or_else(|| HttpError { code, context: context.into(), source: None, }) } } ================================================ FILE: rslib/src/sync/http_client/full_sync.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::future::Future; use std::time::Duration; use tokio::select; use tokio::time::interval; use crate::progress::ThrottlingProgressHandler; use crate::sync::collection::progress::FullSyncProgress; use crate::sync::collection::protocol::EmptyInput; use crate::sync::collection::protocol::SyncMethod; use crate::sync::collection::upload::UploadResponse; use crate::sync::error::HttpResult; use crate::sync::http_client::io_monitor::IoMonitor; use crate::sync::http_client::HttpSyncClient; use crate::sync::request::SyncRequest; use crate::sync::response::SyncResponse; impl HttpSyncClient { fn full_sync_progress_monitor( &self, sending: bool, mut progress: ThrottlingProgressHandler, ) -> (IoMonitor, impl Future) { let io_monitor = IoMonitor::new(); let io_monitor2 = io_monitor.clone(); let update_progress = async move { let mut interval = interval(Duration::from_millis(100)); loop { interval.tick().await; let (total_bytes, transferred_bytes) = { let guard = io_monitor2.0.lock().unwrap(); ( if sending { guard.total_bytes_to_send } else { guard.total_bytes_to_receive }, if sending { guard.bytes_sent } else { guard.bytes_received }, ) }; _ = progress.update(false, |p| { p.total_bytes = total_bytes as usize; p.transferred_bytes = transferred_bytes as usize; }) } }; (io_monitor, update_progress) } pub(in super::super) async fn download_with_progress( &self, req: SyncRequest, progress: ThrottlingProgressHandler, ) -> HttpResult>> { let (io_monitor, progress_fut) = self.full_sync_progress_monitor(false, progress); let output = self.request_ext(SyncMethod::Download, req, io_monitor); select! { _ = progress_fut => unreachable!(), out = output => out } } pub(in super::super) async fn upload_with_progress( &self, req: SyncRequest>, progress: ThrottlingProgressHandler, ) -> HttpResult> { let (io_monitor, progress_fut) = self.full_sync_progress_monitor(true, progress); let output = self.request_ext(SyncMethod::Upload, req, io_monitor); select! { _ = progress_fut => unreachable!(), out = output => out } } } ================================================ FILE: rslib/src/sync/http_client/io_monitor.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::io::Cursor; use std::io::ErrorKind; use std::sync::Arc; use std::sync::Mutex; use std::time::Duration; use bytes::Bytes; use futures::Stream; use futures::StreamExt; use futures::TryStreamExt; use reqwest::header::CONTENT_TYPE; use reqwest::header::LOCATION; use reqwest::Body; use reqwest::RequestBuilder; use reqwest::Response; use reqwest::StatusCode; use tokio::io::AsyncReadExt; use tokio::select; use tokio::time::interval; use tokio::time::Instant; use tokio_util::io::ReaderStream; use tokio_util::io::StreamReader; use crate::error::Result; use crate::sync::error::HttpError; use crate::sync::error::HttpResult; use crate::sync::error::OrHttpErr; use crate::sync::request::header_and_stream::decode_zstd_body_stream_for_client; use crate::sync::request::header_and_stream::encode_zstd_body_stream; use crate::sync::response::ORIGINAL_SIZE; /// Serves two purposes: /// - allows us to monitor data sending/receiving and abort if the transfer /// stalls /// - allows us to monitor amount of data moving, to provide progress reporting #[derive(Clone)] pub struct IoMonitor(pub Arc>); impl IoMonitor { pub fn new() -> Self { Self(Arc::new(Mutex::new(IoMonitorInner { last_activity: Instant::now(), bytes_sent: 0, total_bytes_to_send: 0, bytes_received: 0, total_bytes_to_receive: 0, }))) } pub fn wrap_stream( &self, sending: bool, total_bytes: u32, stream: S, ) -> impl Stream> + Send + Sync + 'static where S: Stream> + Send + Sync + 'static, E: std::error::Error + Send + Sync + 'static, { let inner = self.0.clone(); { let mut inner = inner.lock().unwrap(); inner.last_activity = Instant::now(); if sending { inner.total_bytes_to_send += total_bytes } else { inner.total_bytes_to_receive += total_bytes } } stream.map(move |res| match res { Ok(bytes) => { let mut inner = inner.lock().unwrap(); inner.last_activity = Instant::now(); if sending { inner.bytes_sent += bytes.len() as u32; } else { inner.bytes_received += bytes.len() as u32; } Ok(bytes) } err => err.or_http_err(StatusCode::SEE_OTHER, "stream failure"), }) } /// Returns if no I/O activity observed for `stall_time`. pub async fn timeout(&self, stall_time: Duration) { let poll_interval = Duration::from_millis(if cfg!(test) { 10 } else { 1000 }); let mut interval = interval(poll_interval); loop { let now = interval.tick().await; let last_activity = self.0.lock().unwrap().last_activity; if now.duration_since(last_activity) > stall_time { return; } } } /// Takes care of encoding provided request data and setting content type to /// binary, and returns the decompressed response body. pub async fn zstd_request_with_timeout( &self, request: RequestBuilder, request_body: Vec, stall_duration: Duration, ) -> HttpResult> { let request_total = request_body.len() as u32; let request_body_stream = encode_zstd_body_stream(self.wrap_stream( true, request_total, ReaderStream::new(Cursor::new(request_body)), )); let response_body_stream = async move { let resp = request .header(CONTENT_TYPE, "application/octet-stream") .body(Body::wrap_stream(request_body_stream)) .send() .await? .error_for_status()?; map_redirect_to_error(&resp)?; let response_total = resp .headers() .get(&ORIGINAL_SIZE) .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()) .or_bad_request("missing original size")?; let response_stream = self.wrap_stream( false, response_total, decode_zstd_body_stream_for_client(resp.bytes_stream()), ); let mut reader = StreamReader::new(response_stream.map_err(|e| { std::io::Error::new(ErrorKind::ConnectionAborted, format!("{e}")) })); let mut buf = Vec::with_capacity(response_total as usize); reader .read_to_end(&mut buf) .await .or_http_err(StatusCode::SEE_OTHER, "reading stream")?; Ok::<_, HttpError>(buf) }; select! { // happy path data = response_body_stream => Ok(data?), // timeout _ = self.timeout(stall_duration) => { Err(HttpError { code: StatusCode::REQUEST_TIMEOUT, context: "timeout monitor".into(), source: None, }) } } } } /// Reqwest can't retry a redirected request as the body has been consumed, so /// we need to bubble it up to the sync driver to retry. fn map_redirect_to_error(resp: &Response) -> HttpResult<()> { if resp.status() == StatusCode::PERMANENT_REDIRECT { let location = resp .headers() .get(LOCATION) .or_bad_request("missing location header")?; let location = String::from_utf8(location.as_bytes().to_vec()) .or_bad_request("location was not in utf8")?; None.or_permanent_redirect(location)?; } Ok(()) } #[derive(Debug)] pub struct IoMonitorInner { last_activity: Instant, pub bytes_sent: u32, pub total_bytes_to_send: u32, pub bytes_received: u32, pub total_bytes_to_receive: u32, } impl IoMonitor {} #[cfg(test)] mod test { use async_stream::stream; use futures::pin_mut; use futures::StreamExt; use tokio::select; use tokio::time::sleep; use wiremock::matchers::method; use wiremock::matchers::path; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; use super::*; use crate::sync::error::HttpError; /// The delays in the tests are aggressively short, and false positives slip /// through on a loaded system - especially on Windows. Fix by applying /// a universal multiplier. fn millis(millis: u64) -> Duration { Duration::from_millis(millis * if cfg!(windows) { 10 } else { 5 }) } #[tokio::test] async fn can_fail_before_any_bytes() { let monitor = IoMonitor::new(); let stream = monitor.wrap_stream( true, 0, stream! { sleep(millis(2000)).await; yield Ok::<_, HttpError>(Bytes::from("1")) }, ); pin_mut!(stream); select! { _ = stream.next() => panic!("expected failure"), _ = monitor.timeout(millis(100)) => () }; } #[tokio::test] async fn fails_when_data_stops_moving() { let monitor = IoMonitor::new(); let stream = monitor.wrap_stream( true, 0, stream! { for _ in 0..10 { sleep(millis(10)).await; yield Ok::<_, HttpError>(Bytes::from("1")) } sleep(millis(50)).await; yield Ok::<_, HttpError>(Bytes::from("1")) }, ); pin_mut!(stream); for _ in 0..10 { select! { _ = stream.next() => (), _ = monitor.timeout(millis(20)) => panic!("expected success") }; } select! { _ = stream.next() => panic!("expected timeout"), _ = monitor.timeout(millis(20)) => () }; } #[tokio::test] async fn connect_timeout_works() { let monitor = IoMonitor::new(); let req = monitor.zstd_request_with_timeout( reqwest::Client::new().post("http://0.0.0.1"), vec![], millis(50), ); req.await.unwrap_err(); } #[tokio::test] async fn http_success() { let mock_server = MockServer::start().await; Mock::given(method("POST")) .and(path("/")) .respond_with(ResponseTemplate::new(200).insert_header(ORIGINAL_SIZE.as_str(), "0")) .mount(&mock_server) .await; let monitor = IoMonitor::new(); let req = monitor.zstd_request_with_timeout( reqwest::Client::new().post(mock_server.uri()), vec![], millis(10), ); req.await.unwrap(); } #[tokio::test] async fn delay_before_reply_fails() { let mock_server = MockServer::start().await; Mock::given(method("POST")) .and(path("/")) .respond_with(ResponseTemplate::new(200).set_delay(millis(50))) .mount(&mock_server) .await; let monitor = IoMonitor::new(); let req = monitor.zstd_request_with_timeout( reqwest::Client::new().post(mock_server.uri()), vec![], millis(10), ); req.await.unwrap_err(); } } ================================================ FILE: rslib/src/sync/http_client/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub(crate) mod full_sync; pub(crate) mod io_monitor; mod protocol; use std::time::Duration; use reqwest::Client; use reqwest::Error; use reqwest::StatusCode; use reqwest::Url; use crate::notes; use crate::sync::collection::protocol::AsSyncEndpoint; use crate::sync::error::HttpError; use crate::sync::error::HttpResult; use crate::sync::http_client::io_monitor::IoMonitor; use crate::sync::login::SyncAuth; use crate::sync::request::header_and_stream::SyncHeader; use crate::sync::request::header_and_stream::SYNC_HEADER_NAME; use crate::sync::request::SyncRequest; use crate::sync::response::SyncResponse; #[derive(Clone)] pub struct HttpSyncClient { /// Set to the empty string for initial login pub sync_key: String, session_key: String, client: Client, pub endpoint: Url, pub io_timeout: Duration, } impl HttpSyncClient { pub fn new(auth: SyncAuth, client: Client) -> HttpSyncClient { let io_timeout = Duration::from_secs(auth.io_timeout_secs.unwrap_or(30) as u64); HttpSyncClient { sync_key: auth.hkey, session_key: simple_session_id(), client, endpoint: auth .endpoint .unwrap_or_else(|| Url::try_from("https://sync.ankiweb.net/").unwrap()), io_timeout, } } async fn request( &self, method: impl AsSyncEndpoint, request: SyncRequest, ) -> HttpResult> { self.request_ext(method, request, IoMonitor::new()).await } async fn request_ext( &self, method: impl AsSyncEndpoint, request: SyncRequest, io_monitor: IoMonitor, ) -> HttpResult> { let header = SyncHeader { sync_version: request.sync_version, sync_key: self.sync_key.clone(), client_ver: request.client_version, session_key: self.session_key.clone(), }; let data = request.data; let url = method.as_sync_endpoint(&self.endpoint); let request = self .client .post(url) .header(&SYNC_HEADER_NAME, serde_json::to_string(&header).unwrap()); io_monitor .zstd_request_with_timeout(request, data, self.io_timeout) .await .map(SyncResponse::from_vec) } #[cfg(test)] pub(crate) fn endpoint(&self) -> &Url { &self.endpoint } #[cfg(test)] pub(crate) fn set_skey(&mut self, skey: String) { self.session_key = skey; } #[cfg(test)] pub(crate) fn skey(&self) -> &str { &self.session_key } } impl From for HttpError { fn from(err: Error) -> Self { HttpError { // we should perhaps make this Optional instead code: err.status().unwrap_or(StatusCode::SEE_OTHER), context: "from reqwest".into(), source: Some(Box::new(err) as _), } } } fn simple_session_id() -> String { let table = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\ 0123456789"; notes::to_base_n(rand::random::() as u64, table) } ================================================ FILE: rslib/src/sync/http_client/protocol.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use async_trait::async_trait; use crate::prelude::TimestampMillis; use crate::progress::ThrottlingProgressHandler; use crate::sync::collection::changes::ApplyChangesRequest; use crate::sync::collection::changes::UnchunkedChanges; use crate::sync::collection::chunks::ApplyChunkRequest; use crate::sync::collection::chunks::Chunk; use crate::sync::collection::graves::ApplyGravesRequest; use crate::sync::collection::graves::Graves; use crate::sync::collection::meta::MetaRequest; use crate::sync::collection::meta::SyncMeta; use crate::sync::collection::protocol::EmptyInput; use crate::sync::collection::protocol::SyncMethod; use crate::sync::collection::protocol::SyncProtocol; use crate::sync::collection::sanity::SanityCheckRequest; use crate::sync::collection::sanity::SanityCheckResponse; use crate::sync::collection::start::StartRequest; use crate::sync::collection::upload::UploadResponse; use crate::sync::error::HttpResult; use crate::sync::http_client::HttpSyncClient; use crate::sync::login::HostKeyRequest; use crate::sync::login::HostKeyResponse; use crate::sync::media::begin::SyncBeginRequest; use crate::sync::media::begin::SyncBeginResponse; use crate::sync::media::changes::MediaChangesRequest; use crate::sync::media::changes::MediaChangesResponse; use crate::sync::media::download::DownloadFilesRequest; use crate::sync::media::protocol::JsonResult; use crate::sync::media::protocol::MediaSyncMethod; use crate::sync::media::protocol::MediaSyncProtocol; use crate::sync::media::sanity; use crate::sync::media::upload; use crate::sync::request::SyncRequest; use crate::sync::response::SyncResponse; #[async_trait] impl SyncProtocol for HttpSyncClient { async fn host_key( &self, req: SyncRequest, ) -> HttpResult> { self.request(SyncMethod::HostKey, req).await } async fn meta(&self, req: SyncRequest) -> HttpResult> { self.request(SyncMethod::Meta, req).await } async fn start(&self, req: SyncRequest) -> HttpResult> { self.request(SyncMethod::Start, req).await } async fn apply_graves( &self, req: SyncRequest, ) -> HttpResult> { self.request(SyncMethod::ApplyGraves, req).await } async fn apply_changes( &self, req: SyncRequest, ) -> HttpResult> { self.request(SyncMethod::ApplyChanges, req).await } async fn chunk(&self, req: SyncRequest) -> HttpResult> { self.request(SyncMethod::Chunk, req).await } async fn apply_chunk( &self, req: SyncRequest, ) -> HttpResult> { self.request(SyncMethod::ApplyChunk, req).await } async fn sanity_check( &self, req: SyncRequest, ) -> HttpResult> { self.request(SyncMethod::SanityCheck2, req).await } async fn finish( &self, req: SyncRequest, ) -> HttpResult> { self.request(SyncMethod::Finish, req).await } async fn abort(&self, req: SyncRequest) -> HttpResult> { self.request(SyncMethod::Abort, req).await } async fn upload(&self, req: SyncRequest>) -> HttpResult> { self.upload_with_progress(req, ThrottlingProgressHandler::default()) .await } async fn download(&self, req: SyncRequest) -> HttpResult>> { self.download_with_progress(req, ThrottlingProgressHandler::default()) .await } } #[async_trait] impl MediaSyncProtocol for HttpSyncClient { async fn begin( &self, req: SyncRequest, ) -> HttpResult>> { self.request(MediaSyncMethod::Begin, req).await } async fn media_changes( &self, req: SyncRequest, ) -> HttpResult>> { self.request(MediaSyncMethod::MediaChanges, req).await } async fn upload_changes( &self, req: SyncRequest>, ) -> HttpResult>> { self.request(MediaSyncMethod::UploadChanges, req).await } async fn download_files( &self, req: SyncRequest, ) -> HttpResult>> { self.request(MediaSyncMethod::DownloadFiles, req).await } async fn media_sanity_check( &self, req: SyncRequest, ) -> HttpResult>> { self.request(MediaSyncMethod::MediaSanity, req).await } } ================================================ FILE: rslib/src/sync/http_server/handlers.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::sync::Arc; use async_trait::async_trait; use media::sanity::MediaSanityCheckResponse; use media::upload::MediaUploadResponse; use crate::prelude::*; use crate::sync::collection::changes::server_apply_changes; use crate::sync::collection::changes::ApplyChangesRequest; use crate::sync::collection::changes::UnchunkedChanges; use crate::sync::collection::chunks::server_apply_chunk; use crate::sync::collection::chunks::server_chunk; use crate::sync::collection::chunks::ApplyChunkRequest; use crate::sync::collection::chunks::Chunk; use crate::sync::collection::download::server_download; use crate::sync::collection::finish::server_finish; use crate::sync::collection::graves::server_apply_graves; use crate::sync::collection::graves::ApplyGravesRequest; use crate::sync::collection::graves::Graves; use crate::sync::collection::meta::server_meta; use crate::sync::collection::meta::MetaRequest; use crate::sync::collection::meta::SyncMeta; use crate::sync::collection::protocol::EmptyInput; use crate::sync::collection::protocol::SyncProtocol; use crate::sync::collection::sanity::server_sanity_check; use crate::sync::collection::sanity::SanityCheckRequest; use crate::sync::collection::sanity::SanityCheckResponse; use crate::sync::collection::sanity::SanityCheckStatus; use crate::sync::collection::start::server_start; use crate::sync::collection::start::StartRequest; use crate::sync::collection::upload::handle_received_upload; use crate::sync::collection::upload::UploadResponse; use crate::sync::error::HttpResult; use crate::sync::error::OrHttpErr; use crate::sync::http_server::SimpleServer; use crate::sync::login::HostKeyRequest; use crate::sync::login::HostKeyResponse; use crate::sync::media; use crate::sync::media::begin::SyncBeginRequest; use crate::sync::media::begin::SyncBeginResponse; use crate::sync::media::changes::MediaChangesRequest; use crate::sync::media::changes::MediaChangesResponse; use crate::sync::media::download::DownloadFilesRequest; use crate::sync::media::protocol::JsonResult; use crate::sync::media::protocol::MediaSyncProtocol; use crate::sync::request::SyncRequest; use crate::sync::response::SyncResponse; #[async_trait] impl SyncProtocol for Arc { async fn host_key( &self, req: SyncRequest, ) -> HttpResult> { self.get_host_key(req.json()?) } async fn meta(&self, req: SyncRequest) -> HttpResult> { self.with_authenticated_user(req, |user, req| { let req = req.json()?; let mut meta = user.with_col(|col| server_meta(req, col))?; meta.media_usn = user.media.last_usn()?; Ok(meta) }) .await .and_then(SyncResponse::try_from_obj) } async fn start(&self, req: SyncRequest) -> HttpResult> { self.with_authenticated_user(req, |user, req| { let skey = req.skey()?; let req = req.json()?; user.start_new_sync(skey)?; user.with_sync_state(skey, |col, state| server_start(req, col, state)) .and_then(SyncResponse::try_from_obj) }) .await } async fn apply_graves( &self, req: SyncRequest, ) -> HttpResult> { self.with_authenticated_user(req, |user, req| { let skey = req.skey()?; let req = req.json()?; user.with_sync_state(skey, |col, state| server_apply_graves(req, col, state)) .and_then(SyncResponse::try_from_obj) }) .await } async fn apply_changes( &self, req: SyncRequest, ) -> HttpResult> { self.with_authenticated_user(req, |user, req| { let skey = req.skey()?; let req = req.json()?; user.with_sync_state(skey, |col, state| server_apply_changes(req, col, state)) .and_then(SyncResponse::try_from_obj) }) .await } async fn chunk(&self, req: SyncRequest) -> HttpResult> { self.with_authenticated_user(req, |user, req| { let skey = req.skey()?; let _ = req.json()?; user.with_sync_state(skey, server_chunk) .and_then(SyncResponse::try_from_obj) }) .await } async fn apply_chunk( &self, req: SyncRequest, ) -> HttpResult> { self.with_authenticated_user(req, |user, req| { let skey = req.skey()?; let req = req.json()?; user.with_sync_state(skey, |col, state| server_apply_chunk(req, col, state)) .and_then(SyncResponse::try_from_obj) }) .await } async fn sanity_check( &self, req: SyncRequest, ) -> HttpResult> { self.with_authenticated_user(req, |user, req| { let skey = req.skey()?; let req = req.json()?; let resp = user.with_sync_state(skey, |col, _state| server_sanity_check(req, col))?; if resp.status == SanityCheckStatus::Bad { // don't wait for an abort to roll back let _ = user.col.take(); } SyncResponse::try_from_obj(resp) }) .await } async fn finish( &self, req: SyncRequest, ) -> HttpResult> { self.with_authenticated_user(req, |user, req| { let _ = req.json()?; let now = user.with_sync_state(req.skey()?, |col, _state| server_finish(col))?; user.sync_state = None; SyncResponse::try_from_obj(now) }) .await } async fn abort(&self, req: SyncRequest) -> HttpResult> { self.with_authenticated_user(req, |user, req| { let _ = req.json()?; user.abort_stateful_sync_if_active(); SyncResponse::try_from_obj(()) }) .await } async fn upload(&self, req: SyncRequest>) -> HttpResult> { self.with_authenticated_user(req, |user, req| { user.abort_stateful_sync_if_active(); user.ensure_col_open()?; handle_received_upload(&mut user.col, req.data).map(SyncResponse::from_upload_response) }) .await } async fn download(&self, req: SyncRequest) -> HttpResult>> { self.with_authenticated_user(req, |user, req| { let schema_version = req.sync_version.collection_schema(); let _ = req.json()?; user.abort_stateful_sync_if_active(); user.ensure_col_open()?; server_download(&mut user.col, schema_version).map(SyncResponse::from_vec) }) .await } } #[async_trait] impl MediaSyncProtocol for Arc { async fn begin( &self, req: SyncRequest, ) -> HttpResult>> { let hkey = req.sync_key.clone(); self.with_authenticated_user(req, |user, req| { let req = req.json()?; if req.client_version.is_empty() { None.or_bad_request("missing client version")?; } SyncResponse::try_from_obj(JsonResult::ok(SyncBeginResponse { usn: user.media.last_usn()?, host_key: hkey, })) }) .await } async fn media_changes( &self, req: SyncRequest, ) -> HttpResult>> { self.with_authenticated_user(req, |user, req| { SyncResponse::try_from_obj(JsonResult::ok( user.media.media_changes_chunk(req.json()?.last_usn)?, )) }) .await } async fn upload_changes( &self, req: SyncRequest>, ) -> HttpResult>> { self.with_authenticated_user(req, |user, req| { SyncResponse::try_from_obj(JsonResult::ok( user.media.process_uploaded_changes(req.data)?, )) }) .await } async fn download_files( &self, req: SyncRequest, ) -> HttpResult>> { self.with_authenticated_user(req, |user, req| { Ok(SyncResponse::from_vec( user.media.zip_files_for_download(req.json()?.files)?, )) }) .await } async fn media_sanity_check( &self, req: SyncRequest, ) -> HttpResult>> { self.with_authenticated_user(req, |user, req| { SyncResponse::try_from_obj(JsonResult::ok(user.media.sanity_check(req.json()?.local)?)) }) .await } } ================================================ FILE: rslib/src/sync/http_server/logging.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::time::Duration; use axum::body::Body; use axum::http::Request; use axum::response::Response; use axum::Router; use tower_http::trace::TraceLayer; use tracing::info_span; use tracing::Span; pub fn with_logging_layer(router: Router) -> Router { router.layer( TraceLayer::new_for_http() .make_span_with(|request: &Request| { info_span!( "request", uri = request.uri().path(), ip = tracing::field::Empty, uid = tracing::field::Empty, client = tracing::field::Empty, session = tracing::field::Empty, ) }) .on_request(()) .on_response(|response: &Response, latency: Duration, _span: &Span| { tracing::info!( elap_ms = latency.as_millis() as u32, httpstatus = response.status().as_u16(), "finished" ); }) .on_failure(()), ) } ================================================ FILE: rslib/src/sync/http_server/media_manager/download.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::fs; use std::io::ErrorKind; use anki_io::FileIoSnafu; use anki_io::FileOp; use snafu::ResultExt; use crate::sync::error::HttpResult; use crate::sync::error::OrHttpErr; use crate::sync::http_server::media_manager::ServerMediaManager; use crate::sync::media::database::server::entry::MediaEntry; use crate::sync::media::zip::zip_files_for_download; impl ServerMediaManager { pub fn zip_files_for_download(&mut self, files: Vec) -> HttpResult> { let entries = self.db.get_entries_for_download(&files)?; let filenames_with_data = self.gather_file_data(&entries)?; zip_files_for_download(filenames_with_data).or_internal_err("zip files") } /// Mutable for the missing file case. fn gather_file_data(&mut self, entries: &[MediaEntry]) -> HttpResult)>> { let mut out = vec![]; for entry in entries { let path = self.media_folder.join(&entry.nfc_filename); match fs::read(&path) { Ok(data) => out.push((entry.nfc_filename.clone(), data)), Err(err) if err.kind() == ErrorKind::NotFound => { self.db .forget_missing_file(entry) .or_internal_err("forget missing")?; None.or_conflict(format!( "requested a file that doesn't exist: {}", entry.nfc_filename ))?; } Err(err) => Err(err) .context(FileIoSnafu { path, op: FileOp::Read, }) .or_internal_err("gather file data")?, } } Ok(out) } } ================================================ FILE: rslib/src/sync/http_server/media_manager/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub mod download; pub mod upload; use std::path::Path; use std::path::PathBuf; use anki_io::create_dir_all; use crate::prelude::*; use crate::sync::error::HttpResult; use crate::sync::error::OrHttpErr; use crate::sync::media::changes::MediaChange; use crate::sync::media::database::server::ServerMediaDatabase; use crate::sync::media::sanity::MediaSanityCheckResponse; pub(crate) struct ServerMediaManager { pub media_folder: PathBuf, pub db: ServerMediaDatabase, } impl ServerMediaManager { pub(crate) fn new(user_folder: &Path) -> HttpResult { let media_folder = user_folder.join("media"); create_dir_all(&media_folder).or_internal_err("media folder create")?; Ok(Self { media_folder, db: ServerMediaDatabase::new(&user_folder.join("media.db")) .or_internal_err("open media db")?, }) } pub fn last_usn(&self) -> HttpResult { self.db.last_usn().or_internal_err("get last usn") } pub fn media_changes_chunk(&self, after_usn: Usn) -> HttpResult> { self.db .media_changes_chunk(after_usn) .or_internal_err("changes chunk") } pub fn sanity_check(&self, client_file_count: u32) -> HttpResult { let server = self .db .nonempty_file_count() .or_internal_err("get nonempty count")?; Ok(if server == client_file_count { MediaSanityCheckResponse::Ok } else { MediaSanityCheckResponse::SanityCheckFailed }) } } ================================================ FILE: rslib/src/sync/http_server/media_manager/upload.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::fs; use std::io::ErrorKind; use std::path::Path; use anki_io::write_file; use anki_io::FileIoError; use anki_io::FileIoSnafu; use anki_io::FileOp; use snafu::ResultExt; use tracing::info; use crate::error; use crate::sync::error::HttpResult; use crate::sync::error::OrHttpErr; use crate::sync::http_server::media_manager::ServerMediaManager; use crate::sync::media::database::server::entry::upload::UploadedChangeResult; use crate::sync::media::upload::MediaUploadResponse; use crate::sync::media::zip::unzip_and_validate_files; impl ServerMediaManager { pub fn process_uploaded_changes( &mut self, zip_data: Vec, ) -> HttpResult { let extracted = unzip_and_validate_files(&zip_data).or_bad_request("unzip files")?; let folder = &self.media_folder; let mut processed = 0; let new_usn = self .db .with_transaction(|db, meta| { for change in extracted { match db.register_uploaded_change(meta, change)? { UploadedChangeResult::FileAlreadyDeleted { filename } => { info!(filename, "already deleted"); } UploadedChangeResult::FileIdentical { filename, sha1 } => { info!(filename, sha1 = hex::encode(sha1), "already have"); } UploadedChangeResult::Added { filename, data, sha1, } => { info!(filename, sha1 = hex::encode(sha1), "added"); add_or_replace_file(&folder.join(filename), data)?; } UploadedChangeResult::Replaced { filename, data, old_sha1, new_sha1, } => { info!( filename, old_sha1 = hex::encode(old_sha1), new_sha1 = hex::encode(new_sha1), "replaced" ); add_or_replace_file(&folder.join(filename), data)?; } UploadedChangeResult::Removed { filename, sha1 } => { info!(filename, sha1 = hex::encode(sha1), "removed"); remove_file(&folder.join(filename))?; } } processed += 1; } Ok(()) }) .or_internal_err("handle uploaded change")?; Ok(MediaUploadResponse { processed, current_usn: new_usn, }) } } fn add_or_replace_file(path: &Path, data: Vec) -> error::Result<(), FileIoError> { write_file(path, data) } fn remove_file(path: &Path) -> error::Result<(), FileIoError> { if let Err(err) = fs::remove_file(path) { // if transaction was previously aborted, the file may have already been deleted if err.kind() != ErrorKind::NotFound { return Err(err).context(FileIoSnafu { path, op: FileOp::Remove, }); } } Ok(()) } ================================================ FILE: rslib/src/sync/http_server/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod handlers; mod logging; mod media_manager; mod routes; mod user; use std::collections::HashMap; use std::future::Future; use std::future::IntoFuture; use std::net::IpAddr; use std::net::SocketAddr; use std::path::Path; use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; use std::sync::Mutex; use anki_io::create_dir_all; use axum::extract::DefaultBodyLimit; use axum::routing::get; use axum::Router; use axum_client_ip::ClientIpSource; use pbkdf2::password_hash::PasswordHash; use pbkdf2::password_hash::PasswordHasher; use pbkdf2::password_hash::PasswordVerifier; use pbkdf2::password_hash::SaltString; use pbkdf2::Pbkdf2; use snafu::whatever; use snafu::OptionExt; use snafu::ResultExt; use snafu::Whatever; use tokio::net::TcpListener; use tracing::Span; use crate::error; use crate::media::files::sha1_of_data; use crate::sync::error::HttpResult; use crate::sync::error::OrHttpErr; use crate::sync::http_server::logging::with_logging_layer; use crate::sync::http_server::media_manager::ServerMediaManager; use crate::sync::http_server::routes::collection_sync_router; use crate::sync::http_server::routes::health_check_handler; use crate::sync::http_server::routes::media_sync_router; use crate::sync::http_server::user::User; use crate::sync::login::HostKeyRequest; use crate::sync::login::HostKeyResponse; use crate::sync::request::SyncRequest; use crate::sync::request::MAXIMUM_SYNC_PAYLOAD_BYTES; use crate::sync::response::SyncResponse; pub struct SimpleServer { state: Mutex, } pub struct SimpleServerInner { /// hkey->user users: HashMap, } #[derive(serde::Deserialize, Debug)] pub struct SyncServerConfig { #[serde(default = "default_host")] pub host: IpAddr, #[serde(default = "default_port")] pub port: u16, #[serde(default = "default_base", rename = "base")] pub base_folder: PathBuf, #[serde(default = "default_ip_header")] pub ip_header: ClientIpSource, } fn default_host() -> IpAddr { "0.0.0.0".parse().unwrap() } fn default_port() -> u16 { 8080 } fn default_base() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| panic!("Unable to determine home folder; please set SYNC_BASE")) .join(".syncserver") } pub fn default_ip_header() -> ClientIpSource { ClientIpSource::ConnectInfo } impl SimpleServerInner { fn new_from_env(base_folder: &Path) -> error::Result { let mut idx = 1; let mut users: HashMap = Default::default(); loop { let envvar = format!("SYNC_USER{idx}"); match std::env::var(&envvar) { Ok(val) => { let hkey = derive_hkey(&val); let (name, pwhash) = { let (name, password) = val.split_once(':').with_whatever_context(|| { format!("{envvar} should be in 'username:password' format.") })?; if std::env::var("PASSWORDS_HASHED").is_ok() { (name, password.to_string()) } else { ( name, // Plain text passwords provided; hash them with a fixed salt. Pbkdf2 .hash_password( password.as_bytes(), &SaltString::from_b64("tonuvYGpksNFQBlEmm3lxg").unwrap(), ) .expect("couldn't hash password") .to_string(), ) } }; let folder = base_folder.join(name); create_dir_all(&folder).whatever_context("creating SYNC_BASE")?; let media = ServerMediaManager::new(&folder).whatever_context("opening media")?; users.insert( hkey, User { name: name.into(), password_hash: pwhash, col: None, sync_state: None, media, folder, }, ); idx += 1; } Err(_) => break, } } if users.is_empty() { whatever!("No users defined; SYNC_USER1 env var should be set."); } Ok(Self { users }) } } // This is not what AnkiWeb does, but should suffice for this use case. fn derive_hkey(user_and_pass: &str) -> String { hex::encode(sha1_of_data(user_and_pass.as_bytes())) } impl SimpleServer { pub(in crate::sync) async fn with_authenticated_user( &self, req: SyncRequest, op: F, ) -> HttpResult where F: FnOnce(&mut User, SyncRequest) -> HttpResult, { let mut state = self.state.lock().unwrap(); let user = state .users .get_mut(&req.sync_key) .or_forbidden("invalid hkey")?; Span::current().record("uid", &user.name); Span::current().record("client", &req.client_version); Span::current().record("session", &req.session_key); op(user, req) } pub(in crate::sync) fn get_host_key( &self, request: HostKeyRequest, ) -> HttpResult> { let state = self.state.lock().unwrap(); // This control structure might seem a bit crude, // its goal is to prevent a timing attack from gaining // information about whether a specific user exists. let user = { // This inner block returns Ok(hkey,user) if a user with corresponding // name is found and Err(user) with a random user if it isn't found. // The user is needed to verify against a random hash, // before returning an Error. let mut result: Result<(String, &User), &User> = Err(state.users.iter().next().unwrap().1); for (hkey, user) in state.users.iter() { if user.name == request.username { result = Ok((hkey.to_string(), user)); } } result }; match user { Ok((key, user)) => { // Verify password let pwhash = &PasswordHash::new(&user.password_hash).expect("couldn't parse password hash"); if Pbkdf2 .verify_password(request.password.as_bytes(), pwhash) .is_ok() { SyncResponse::try_from_obj(HostKeyResponse { key }) } else { None.or_forbidden("invalid user/pass in get_host_key") } } Err(user) => { // Verify random password, in order to ensure constant-timedness, // then return an error let pwhash = &PasswordHash::new(&user.password_hash).expect("couldn't parse password hash"); let _ = Pbkdf2.verify_password(request.password.as_bytes(), pwhash); None.or_forbidden("invalid user/pass in get_host_key") } } } pub fn is_running() -> bool { let config = envy::prefixed("SYNC_") .from_env::() .unwrap(); std::net::TcpStream::connect(format!("{}:{}", config.host, config.port)).is_ok() } pub fn new(base_folder: &Path) -> error::Result { let inner = SimpleServerInner::new_from_env(base_folder)?; Ok(SimpleServer { state: Mutex::new(inner), }) } pub async fn make_server( config: SyncServerConfig, ) -> error::Result<(SocketAddr, ServerFuture), Whatever> { let server = Arc::new( SimpleServer::new(&config.base_folder).whatever_context("unable to create server")?, ); let address = &format!("{}:{}", config.host, config.port); let listener = TcpListener::bind(address) .await .with_whatever_context(|_| format!("couldn't bind to {address}"))?; let addr = listener.local_addr().unwrap(); let server = with_logging_layer( Router::new() .nest("/sync", collection_sync_router()) .nest("/msync", media_sync_router()) .route("/health", get(health_check_handler)) .with_state(server) .layer(DefaultBodyLimit::max(*MAXIMUM_SYNC_PAYLOAD_BYTES)) .layer(config.ip_header.into_extension()), ); let future = axum::serve( listener, server.into_make_service_with_connect_info::(), ) .with_graceful_shutdown(async { let _ = tokio::signal::ctrl_c().await; }) .into_future(); tracing::info!(%addr, "listening"); Ok((addr, Box::pin(future))) } #[snafu::report] #[tokio::main] pub async fn run() -> error::Result<(), Whatever> { let config = envy::prefixed("SYNC_") .from_env::() .whatever_context("reading SYNC_* env vars")?; let (_addr, server_fut) = SimpleServer::make_server(config).await?; server_fut.await.whatever_context("await server")?; Ok(()) } } pub type ServerFuture = Pin> + Send>>; ================================================ FILE: rslib/src/sync/http_server/routes.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use axum::extract::Path; use axum::extract::Query; use axum::extract::State; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::response::Response; use axum::routing::get; use axum::routing::post; use axum::Router; use crate::sync::collection::protocol::SyncMethod; use crate::sync::collection::protocol::SyncProtocol; use crate::sync::error::HttpResult; use crate::sync::error::OrHttpErr; use crate::sync::media::begin::SyncBeginQuery; use crate::sync::media::begin::SyncBeginRequest; use crate::sync::media::protocol::MediaSyncMethod; use crate::sync::media::protocol::MediaSyncProtocol; use crate::sync::request::IntoSyncRequest; use crate::sync::request::SyncRequest; use crate::sync::version::SyncVersion; macro_rules! sync_method { ($server:ident, $req:ident, $method:ident) => {{ let sync_version = $req.sync_version; let obj = $server.$method($req.into_output_type()).await?; obj.make_response(sync_version) }}; } async fn sync_handler( Path(method): Path, State(server): State

, request: SyncRequest>, ) -> HttpResult { Ok(match method { SyncMethod::HostKey => sync_method!(server, request, host_key), SyncMethod::Meta => sync_method!(server, request, meta), SyncMethod::Start => sync_method!(server, request, start), SyncMethod::ApplyGraves => sync_method!(server, request, apply_graves), SyncMethod::ApplyChanges => sync_method!(server, request, apply_changes), SyncMethod::Chunk => sync_method!(server, request, chunk), SyncMethod::ApplyChunk => sync_method!(server, request, apply_chunk), SyncMethod::SanityCheck2 => sync_method!(server, request, sanity_check), SyncMethod::Finish => sync_method!(server, request, finish), SyncMethod::Abort => sync_method!(server, request, abort), SyncMethod::Upload => sync_method!(server, request, upload), SyncMethod::Download => sync_method!(server, request, download), }) } pub fn collection_sync_router() -> Router

{ Router::new().route("/{method}", post(sync_handler::

)) } /// The Rust code used to send a GET with query params, which was inconsistent /// with the rest of our code - map the request into our standard structure. async fn media_begin_get( Query(req): Query, server: State

, ) -> HttpResult { let host_key = req.host_key; let mut req = SyncBeginRequest { client_version: req.client_version, } .try_into_sync_request() .or_bad_request("convert begin")?; req.sync_key = host_key; req.sync_version = SyncVersion::multipart(); media_begin_post(server, req).await } /// Older clients would send client info in the multipart instead of the inner /// JSON; Inject it into the json if provided. async fn media_begin_post( server: State

, mut req: SyncRequest, ) -> HttpResult { if let Some(ver) = &req.media_client_version { req.data = serde_json::to_vec(&SyncBeginRequest { client_version: ver.clone(), }) .or_internal_err("serialize begin request")?; } media_sync_handler(Path(MediaSyncMethod::Begin), server, req.into_output_type()).await } pub async fn health_check_handler() -> impl IntoResponse { StatusCode::OK } async fn media_sync_handler( Path(method): Path, State(server): State

, request: SyncRequest>, ) -> HttpResult { Ok(match method { MediaSyncMethod::Begin => sync_method!(server, request, begin), MediaSyncMethod::MediaChanges => sync_method!(server, request, media_changes), MediaSyncMethod::UploadChanges => sync_method!(server, request, upload_changes), MediaSyncMethod::DownloadFiles => sync_method!(server, request, download_files), MediaSyncMethod::MediaSanity => sync_method!(server, request, media_sanity_check), }) } pub fn media_sync_router() -> Router

{ Router::new() .route( "/begin", get(media_begin_get::

).post(media_begin_post::

), ) .route("/{method}", post(media_sync_handler::

{}
{}
", tr.card_template_rendering_missing_cloze(card_ord + 1), TEMPLATE_BLANK_CLOZE_LINK, tr.card_template_rendering_more_info() )) } else if !is_cloze && !browser && !qtmpl.renders_with_fields(context.nonempty_fields) { Some(format!( "
{}
{}
", tr.card_template_rendering_empty_front(), TEMPLATE_BLANK_LINK, tr.card_template_rendering_more_info() )) } else { None }; if let Some(text) = empty_message { qnodes.push(RenderedNode::Text { text: text.clone() }); return Ok(RenderCardResponse { qnodes, anodes: vec![RenderedNode::Text { text }], is_empty: true, }); } // answer side context.frontside = if context.partial_for_python { Some("") } else { let Some(RenderedNode::Text { text }) = &qnodes.first() else { invalid_input!("should not happen: first node not text"); }; Some(text) }; let anodes = ParsedTemplate::from_text(afmt) .and_then(|tmpl| tmpl.render(&context, tr)) .map_err(|e| template_error_to_anki_error(e, false, browser, tr))?; Ok(RenderCardResponse { qnodes, anodes, is_empty: false, }) } fn cloze_is_empty(field_map: &HashMap<&str, Cow>, card_ord: u16) -> bool { !cloze_number_in_fields(field_map.values()).contains(&(card_ord + 1)) } // Field requirements //---------------------------------------- #[derive(Debug, Clone, PartialEq, Eq)] pub enum FieldRequirements { Any(HashSet), All(HashSet), None, } impl ParsedTemplate { /// Return fields required by template. /// /// This is not able to represent negated expressions or combinations of /// Any and All, but is compatible with older Anki clients. /// /// In the future, it may be feasible to calculate the requirements /// when adding cards, instead of caching them up front, which would mean /// the above restrictions could be lifted. We would probably /// want to add a cache of non-zero fields -> available cards to avoid /// slowing down bulk operations like importing too much. pub fn requirements(&self, field_map: &FieldMap) -> FieldRequirements { let mut nonempty: HashSet<_> = Default::default(); let mut ords = HashSet::new(); for (name, ord) in field_map { nonempty.clear(); nonempty.insert(*name); if self.renders_with_fields_for_reqs(&nonempty) { ords.insert(*ord); } } if !ords.is_empty() { return FieldRequirements::Any(ords); } nonempty.extend(field_map.keys()); ords.extend(field_map.values().copied()); for (name, ord) in field_map { // can we remove this field and still render? nonempty.remove(name); if self.renders_with_fields_for_reqs(&nonempty) { ords.remove(ord); } nonempty.insert(*name); } if !ords.is_empty() && self.renders_with_fields_for_reqs(&nonempty) { FieldRequirements::All(ords) } else { FieldRequirements::None } } } // Renaming & deleting fields //---------------------------------------- impl ParsedTemplate { /// Given a map of old to new field names, update references to the new /// names. Returns true if any changes made. pub(crate) fn rename_and_remove_fields(&mut self, fields: &HashMap>) { let old_nodes = std::mem::take(&mut self.0); self.0 = rename_and_remove_fields(old_nodes, fields); } pub(crate) fn contains_cloze_replacement(&self) -> bool { self.0.iter().any(|node| { matches!( node, ParsedNode::Replacement {key:_, filters} if filters.iter().any(|f| f=="cloze") ) }) } pub(crate) fn contains_field_replacement(&self) -> bool { let mut set = HashSet::new(); find_field_references(&self.0, &mut set, false, false); !set.is_empty() } pub(crate) fn add_missing_field_replacement(&mut self, field_name: &str, is_cloze: bool) { let key = String::from(field_name); let filters = match is_cloze { true => vec![String::from("cloze")], false => Vec::new(), }; self.0.push(ParsedNode::Replacement { key, filters }); } } fn rename_and_remove_fields( nodes: Vec, fields: &HashMap>, ) -> Vec { let mut out = vec![]; for node in nodes { match node { ParsedNode::Text(text) => out.push(ParsedNode::Text(text)), ParsedNode::Comment(text) => out.push(ParsedNode::Comment(text)), ParsedNode::Replacement { key, filters } => { match fields.get(&key) { // delete the field Some(None) => (), // rename it Some(Some(new_name)) => out.push(ParsedNode::Replacement { key: new_name.into(), filters, }), // or leave it alone None => out.push(ParsedNode::Replacement { key, filters }), } } ParsedNode::Conditional { key, children } => { let children = rename_and_remove_fields(children, fields); match fields.get(&key) { // remove the field, preserving children Some(None) => out.extend(children), // rename it Some(Some(new_name)) => out.push(ParsedNode::Conditional { key: new_name.into(), children, }), // or leave it alone None => out.push(ParsedNode::Conditional { key, children }), } } ParsedNode::NegatedConditional { key, children } => { let children = rename_and_remove_fields(children, fields); match fields.get(&key) { // remove the field, preserving children Some(None) => out.extend(children), // rename it Some(Some(new_name)) => out.push(ParsedNode::NegatedConditional { key: new_name.into(), children, }), // or leave it alone None => out.push(ParsedNode::NegatedConditional { key, children }), } } } } out } // Writing back to a string //---------------------------------------- impl ParsedTemplate { pub(crate) fn template_to_string(&self) -> String { let mut buf = String::new(); nodes_to_string(&mut buf, &self.0); buf } } fn nodes_to_string(buf: &mut String, nodes: &[ParsedNode]) { for node in nodes { match node { ParsedNode::Text(text) => buf.push_str(text), ParsedNode::Comment(text) => { buf.push_str(COMMENT_START); buf.push_str(text); buf.push_str(COMMENT_END); } ParsedNode::Replacement { key, filters } => { write!( buf, "{{{{{}}}}}", filters .iter() .rev() .chain(iter::once(key)) .map(|s| s.to_string()) .collect::>() .join(":") ) .unwrap(); } ParsedNode::Conditional { key, children } => { write!(buf, "{{{{#{key}}}}}").unwrap(); nodes_to_string(buf, children); write!(buf, "{{{{/{key}}}}}").unwrap(); } ParsedNode::NegatedConditional { key, children } => { write!(buf, "{{{{^{key}}}}}").unwrap(); nodes_to_string(buf, children); write!(buf, "{{{{/{key}}}}}").unwrap(); } } } } // Detecting cloze fields //---------------------------------------- impl ParsedTemplate { /// Field names may not be valid. pub(crate) fn all_referenced_field_names(&self) -> HashSet<&str> { let mut set = HashSet::new(); find_field_references(&self.0, &mut set, false, true); set } /// Field names may not be valid. pub(crate) fn all_referenced_cloze_field_names(&self) -> HashSet<&str> { let mut set = HashSet::new(); find_field_references(&self.0, &mut set, true, false); set } } fn find_field_references<'a>( nodes: &'a [ParsedNode], fields: &mut HashSet<&'a str>, cloze_only: bool, with_conditionals: bool, ) { for node in nodes { match node { ParsedNode::Text(_) => {} ParsedNode::Comment(_) => {} ParsedNode::Replacement { key, filters } => { if !cloze_only || filters.iter().any(|f| f == "cloze") { fields.insert(key); } } ParsedNode::Conditional { key, children } | ParsedNode::NegatedConditional { key, children } => { if with_conditionals && !is_cloze_conditional(key) { fields.insert(key); } find_field_references(children, fields, cloze_only, with_conditionals); } } } } fn is_cloze_conditional(key: &str) -> bool { key.strip_prefix('c') .is_some_and(|s| s.parse::().is_ok()) } // Tests //--------------------------------------- #[cfg(test)] mod test { use std::collections::HashMap; use anki_i18n::I18n; use super::FieldMap; use super::ParsedNode::*; use super::ParsedTemplate as PT; use crate::error::TemplateError; use crate::template::field_is_empty; use crate::template::nonempty_fields; use crate::template::FieldRequirements; use crate::template::RenderCardRequest; use crate::template::RenderContext; use crate::template::COMMENT_END; use crate::template::COMMENT_START; #[test] fn field_empty() { assert!(field_is_empty("")); assert!(field_is_empty(" ")); assert!(!field_is_empty("x")); assert!(field_is_empty("
")); assert!(field_is_empty("
")); assert!(field_is_empty("

\n")); assert!(!field_is_empty("
x
\n")); } #[test] fn parsing() { let orig = ""; let tmpl = PT::from_text(orig).unwrap(); assert_eq!(tmpl.0, vec![]); assert_eq!(orig, &tmpl.template_to_string()); let orig = "foo {{bar}} {{#baz}} quux {{/baz}}"; let tmpl = PT::from_text(orig).unwrap(); assert_eq!( tmpl.0, vec![ Text("foo ".into()), Replacement { key: "bar".into(), filters: vec![] }, Text(" ".into()), Conditional { key: "baz".into(), children: vec![Text(" quux ".into())] } ] ); assert_eq!(orig, &tmpl.template_to_string()); // Hardcode comment delimiters into tests to keep them concise assert_eq!(COMMENT_START, ""); let orig = "foo {{#baz}} --> \u{123}-->\u{456} ".into()), Comment(" \u{456}".into()), Comment(" 2 ".into()), Comment("".into()), Text(" ").unwrap_err(); PT::from_text("").unwrap(); PT::from_text("").unwrap(); // whitespace assert_eq!( PT::from_text("{{ tag }}").unwrap().0, vec![Replacement { key: "tag".into(), filters: vec![] }] ); // stray closing characters (like in javascript) are ignored assert_eq!( PT::from_text("text }} more").unwrap().0, vec![Text("text }} more".into())] ); // make sure filters and so on are round-tripped correctly let orig = "foo {{one:two}} {{one:two:three}} {{^baz}} {{/baz}} {{foo:}}"; let tmpl = PT::from_text(orig).unwrap(); assert_eq!(orig, &tmpl.template_to_string()); let orig = "foo {{one:two}} --> {{one:two:three}} {{^baz}} {{/baz}} {{foo:}}"; let tmpl = PT::from_text(orig).unwrap(); assert_eq!(orig, &tmpl.template_to_string()); } #[test] fn nonempty() { let fields = vec!["1", "3"].into_iter().collect(); let mut tmpl = PT::from_text("{{2}}{{1}}").unwrap(); assert!(tmpl.renders_with_fields(&fields)); tmpl = PT::from_text("{{2}}").unwrap(); assert!(!tmpl.renders_with_fields(&fields)); tmpl = PT::from_text("{{2}}{{4}}").unwrap(); assert!(!tmpl.renders_with_fields(&fields)); tmpl = PT::from_text("{{#3}}{{^2}}{{1}}{{/2}}{{/3}}").unwrap(); assert!(tmpl.renders_with_fields(&fields)); tmpl = PT::from_text("{{^1}}{{3}}{{/1}}").unwrap(); assert!(!tmpl.renders_with_fields(&fields)); assert!(tmpl.renders_with_fields_for_reqs(&fields)); } #[test] fn requirements() { let field_map: FieldMap = ["a", "b", "c"] .iter() .enumerate() .map(|(a, b)| (*b, a as u16)) .collect(); let mut tmpl = PT::from_text("{{a}}{{b}}").unwrap(); assert_eq!( tmpl.requirements(&field_map), FieldRequirements::Any(vec![0, 1].into_iter().collect()) ); tmpl = PT::from_text("{{#a}}{{b}}{{/a}}").unwrap(); assert_eq!( tmpl.requirements(&field_map), FieldRequirements::All(vec![0, 1].into_iter().collect()) ); tmpl = PT::from_text("{{z}}").unwrap(); assert_eq!(tmpl.requirements(&field_map), FieldRequirements::None); tmpl = PT::from_text("{{^a}}{{b}}{{/a}}").unwrap(); assert_eq!( tmpl.requirements(&field_map), FieldRequirements::Any(vec![1].into_iter().collect()) ); tmpl = PT::from_text("{{^a}}{{#b}}{{c}}{{/b}}{{/a}}").unwrap(); assert_eq!( tmpl.requirements(&field_map), FieldRequirements::All(vec![1, 2].into_iter().collect()) ); tmpl = PT::from_text("{{#a}}{{#b}}{{a}}{{/b}}{{/a}}").unwrap(); assert_eq!( tmpl.requirements(&field_map), FieldRequirements::All(vec![0, 1].into_iter().collect()) ); tmpl = PT::from_text( r#" {{^a}} {{b}} {{/a}} {{#a}} {{a}} {{b}} {{/a}} "#, ) .unwrap(); // Hardcode comment delimiters into tests to keep them concise assert_eq!(COMMENT_START, ""); assert_eq!( tmpl.requirements(&field_map), FieldRequirements::Any(vec![0, 1].into_iter().collect()) ); tmpl = PT::from_text( r#" {{b}} {{#c}} {{b}} {{/c}} "#, ) .unwrap(); assert_eq!( tmpl.requirements(&field_map), FieldRequirements::Any(vec![1].into_iter().collect()) ); } #[test] fn alt_syntax() { let input = " {{=<% %>=}} <%Front%> <% #Back %> <%/Back%>"; assert_eq!( PT::from_text(input).unwrap().0, vec![ Text("\n".into()), Replacement { key: "Front".into(), filters: vec![] }, Text("\n".into()), Conditional { key: "Back".into(), children: vec![Text("\n".into())] } ] ); let input = " {{=<% %>=}} {{#foo}} <%Front%> {{/foo}} "; assert_eq!( PT::from_text(input).unwrap().0, vec![ Text("\n{{#foo}}\n".into()), Replacement { key: "Front".into(), filters: vec![] }, Text("\n{{/foo}}\n".into()) ] ); } #[test] fn render_single() { let map: HashMap<_, _> = vec![("F", "f"), ("B", "b"), ("E", " "), ("c1", "1")] .into_iter() .map(|r| (r.0, r.1.into())) .collect(); let ctx = RenderContext { fields: &map, nonempty_fields: &nonempty_fields(&map), frontside: None, card_ord: 1, partial_for_python: true, }; use crate::template::RenderedNode as FN; let mut tmpl = PT::from_text("{{B}}A{{F}}").unwrap(); let tr = I18n::template_only(); assert_eq!( tmpl.render(&ctx, &tr).unwrap(), vec![FN::Text { text: "bAf".to_owned() },] ); // empty tmpl = PT::from_text("{{#E}}A{{/E}}").unwrap(); assert_eq!(tmpl.render(&ctx, &tr).unwrap(), vec![]); // missing tmpl = PT::from_text("{{#E}}}{{^M}}A{{/M}}{{/E}}}").unwrap(); assert_eq!( tmpl.render(&ctx, &tr).unwrap_err(), TemplateError::NoSuchConditional("^M".to_string()) ); // nested tmpl = PT::from_text("{{^E}}1{{#F}}2{{#B}}{{F}}{{/B}}{{/F}}{{/E}}").unwrap(); assert_eq!( tmpl.render(&ctx, &tr).unwrap(), vec![FN::Text { text: "12f".to_owned() },] ); // Hardcode comment delimiters into tests to keep them concise assert_eq!(COMMENT_START, ""); // commented tmpl = PT::from_text( "{{^E}}1\u{123}{{/E}}\u{456}", ) .unwrap(); assert_eq!( tmpl.render(&ctx, &tr).unwrap(), vec![FN::Text { text: "1\u{123}\u{456}" .to_owned() },] ); // card conditionals tmpl = PT::from_text("{{^c2}}1{{#c1}}2{{/c1}}{{/c2}}").unwrap(); assert_eq!( tmpl.render(&ctx, &tr).unwrap(), vec![FN::Text { text: "12".to_owned() },] ); // unknown filters tmpl = PT::from_text("{{one:two:B}}").unwrap(); assert_eq!( tmpl.render(&ctx, &tr).unwrap(), vec![FN::Replacement { field_name: "B".to_owned(), filters: vec!["two".to_string(), "one".to_string()], current_text: "b".to_owned() },] ); // partially unknown filters // excess colons are ignored tmpl = PT::from_text("{{one::text:B}}").unwrap(); assert_eq!( tmpl.render(&ctx, &tr).unwrap(), vec![FN::Replacement { field_name: "B".to_owned(), filters: vec!["one".to_string()], current_text: "b".to_owned() },] ); // known filter tmpl = PT::from_text("{{text:B}}").unwrap(); assert_eq!( tmpl.render(&ctx, &tr).unwrap(), vec![FN::Text { text: "b".to_owned() }] ); // unknown field tmpl = PT::from_text("{{X}}").unwrap(); assert_eq!( tmpl.render(&ctx, &tr).unwrap_err(), TemplateError::FieldNotFound { field: "X".to_owned(), filters: "".to_owned() } ); // unknown field with filters tmpl = PT::from_text("{{foo:text:X}}").unwrap(); assert_eq!( tmpl.render(&ctx, &tr).unwrap_err(), TemplateError::FieldNotFound { field: "X".to_owned(), filters: "foo:text:".to_owned() } ); // a blank field is allowed if it has filters tmpl = PT::from_text("{{filter:}}").unwrap(); assert_eq!( tmpl.render(&ctx, &tr).unwrap(), vec![FN::Replacement { field_name: "".to_string(), current_text: "".to_string(), filters: vec!["filter".to_string()] }] ); } #[test] fn render_card() { let map: HashMap<_, _> = vec![("E", ""), ("N", "N")] .into_iter() .map(|r| (r.0, r.1.into())) .collect(); let tr = I18n::template_only(); use crate::template::RenderedNode as FN; let mut req = RenderCardRequest { qfmt: "test{{E}}", afmt: "", field_map: &map, card_ord: 1, is_cloze: false, browser: false, tr: &tr, partial_render: true, }; let response = super::render_card(req.clone()).unwrap(); assert_eq!( response.qnodes[0], FN::Text { text: "test".into() } ); assert!(response.is_empty); if let FN::Text { ref text } = response.qnodes[1] { assert!(text.contains("card is blank")); } else { unreachable!(); } // a popular card template expects {{FrontSide}} to resolve to an empty // string on the front side :-( req.qfmt = "{{FrontSide}}{{N}}"; let response = super::render_card(req.clone()).unwrap(); assert_eq!( &response.qnodes, &[ FN::Replacement { field_name: "FrontSide".into(), current_text: "".into(), filters: vec![] }, FN::Text { text: "N".into() } ] ); assert!(!response.is_empty); req.partial_render = false; let response = super::render_card(req.clone()).unwrap(); assert_eq!(&response.qnodes, &[FN::Text { text: "N".into() }]); assert!(!response.is_empty); } } ================================================ FILE: rslib/src/template_filters.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::sync::LazyLock; use blake3::Hasher; use regex::Captures; use regex::Regex; use crate::cloze::cloze_filter; use crate::cloze::cloze_only_filter; use crate::template::RenderContext; use crate::text::strip_html; // Filtering //---------------------------------------- /// Applies built in filters, returning the resulting text and remaining /// filters. /// /// If [context.partial_for_python] is true, the first non-standard filter that /// is encountered will terminate processing, so non-standard filters must come /// at the end. If false, missing filters are ignored. pub(crate) fn apply_filters<'a>( text: &'a str, filters: &[&str], field_name: &str, context: &RenderContext, ) -> (Cow<'a, str>, Vec) { let mut text: Cow = text.into(); // type:cloze & type:nc are handled specially // other type: are passed as the default one let filters = match filters { ["cloze", "type"] => &["type-cloze"], ["nc", "type"] => &["type-nc"], [.., "type"] => &["type"], _ => filters, }; for (idx, &filter_name) in filters.iter().enumerate() { match apply_filter(filter_name, text.as_ref(), field_name, context) { (true, None) => { // filter did not change text } (true, Some(output)) => { // text updated text = output.into(); } (false, _) => { // unrecognized filter if context.partial_for_python { // return current text and remaining filters return ( text, filters.iter().skip(idx).map(ToString::to_string).collect(), ); } } } } // all filters processed (text, vec![]) } /// Apply one filter. /// /// Returns true if filter was valid. /// Returns string if input text changed. fn apply_filter( filter_name: &str, text: &str, field_name: &str, context: &RenderContext, ) -> (bool, Option) { let output_text = match filter_name { "text" => strip_html(text), "furigana" => furigana_filter(text), "kanji" => kanji_filter(text), "kana" => kana_filter(text), "type" => type_filter(field_name), "type-cloze" => type_cloze_filter(field_name), "type-nc" => type_nc_filter(field_name), "hint" => hint_filter(text, field_name), "cloze" => cloze_filter(text, context), "cloze-only" => cloze_only_filter(text, context), // an empty filter name (caused by using two colons) is ignored "" => text.into(), _ => { if let Some(options) = filter_name.strip_prefix("tts ") { tts_filter(options, text).into() } else { // unrecognized filter return (false, None); } } }; ( true, match output_text { Cow::Owned(o) => Some(o), _ => None, }, ) } // Ruby filters //---------------------------------------- static FURIGANA: LazyLock = LazyLock::new(|| Regex::new(r" ?([^ >]+?)\[(.+?)\]").unwrap()); /// Did furigana regex match a sound tag? fn captured_sound(caps: &Captures) -> bool { caps.get(2).unwrap().as_str().starts_with("sound:") } fn kana_filter(text: &str) -> Cow<'_, str> { FURIGANA .replace_all(&text.replace(" ", " "), |caps: &Captures| { if captured_sound(caps) { caps.get(0).unwrap().as_str().to_owned() } else { caps.get(2).unwrap().as_str().to_owned() } }) .into_owned() .into() } fn kanji_filter(text: &str) -> Cow<'_, str> { FURIGANA .replace_all(&text.replace(" ", " "), |caps: &Captures| { if captured_sound(caps) { caps.get(0).unwrap().as_str().to_owned() } else { caps.get(1).unwrap().as_str().to_owned() } }) .into_owned() .into() } fn furigana_filter(text: &str) -> Cow<'_, str> { FURIGANA .replace_all(&text.replace(" ", " "), |caps: &Captures| { if captured_sound(caps) { caps.get(0).unwrap().as_str().to_owned() } else { format!( "{}{}", caps.get(1).unwrap().as_str(), caps.get(2).unwrap().as_str() ) } }) .into_owned() .into() } // Other filters //---------------------------------------- /// convert to [[type:...]] for the gui code to process fn type_filter<'a>(field_name: &str) -> Cow<'a, str> { format!("[[type:{field_name}]]").into() } fn type_cloze_filter<'a>(field_name: &str) -> Cow<'a, str> { format!("[[type:cloze:{field_name}]]").into() } fn type_nc_filter<'a>(field_name: &str) -> Cow<'a, str> { format!("[[type:nc:{field_name}]]").into() } fn hint_filter<'a>(text: &'a str, field_name: &str) -> Cow<'a, str> { if text.trim().is_empty() { return text.into(); } // generate a unique DOM id let mut hasher = Hasher::new(); hasher.update(text.as_bytes()); hasher.update(field_name.as_bytes()); let id = hex::encode(&hasher.finalize().as_bytes()[0..8]); format!( r##" {field_name} "## ) .into() } fn tts_filter(options: &str, text: &str) -> String { format!("[anki:tts lang={options}]{text}[/anki:tts]") } // Tests //---------------------------------------- #[cfg(test)] mod test { use super::*; #[test] fn furigana() { let text = "test first[second] third[fourth]"; assert_eq!(kana_filter(text).as_ref(), "testsecondfourth"); assert_eq!(kanji_filter(text).as_ref(), "testfirstthird"); assert_eq!( furigana_filter("first[second]").as_ref(), "firstsecond" ); } #[allow(clippy::needless_raw_string_hashes)] #[test] fn hint() { assert_eq!( hint_filter("foo", "field"), r##" field "## ); } #[test] fn typing() { assert_eq!(type_filter("Front"), "[[type:Front]]"); assert_eq!(type_cloze_filter("Front"), "[[type:cloze:Front]]"); assert_eq!(type_nc_filter("Front"), "[[type:nc:Front]]"); let ctx = RenderContext { fields: &Default::default(), nonempty_fields: &Default::default(), frontside: Some(""), card_ord: 0, partial_for_python: true, }; assert_eq!( apply_filters("ignored", &["cloze", "type"], "Text", &ctx), ("[[type:cloze:Text]]".into(), vec![]) ); assert_eq!( apply_filters("ignored", &["nc", "type"], "Text", &ctx), ("[[type:nc:Text]]".into(), vec![]) ); assert_eq!( apply_filters("ignored", &["some", "unknown", "type"], "Text", &ctx), ("[[type:Text]]".into(), vec![]) ); } #[test] fn cloze() { let text = "{{c1::one}} {{c2::two::hint}}"; let mut ctx = RenderContext { fields: &Default::default(), nonempty_fields: &Default::default(), frontside: None, card_ord: 0, partial_for_python: true, }; assert_eq!(strip_html(&cloze_filter(text, &ctx)).as_ref(), "[...] two"); assert_eq!( cloze_filter(text, &ctx), r#"[...] two"# ); ctx.card_ord = 1; assert_eq!(strip_html(&cloze_filter(text, &ctx)).as_ref(), "one [hint]"); ctx.frontside = Some(""); assert_eq!(strip_html(&cloze_filter(text, &ctx)).as_ref(), "one two"); // if the provided ordinal did not match any cloze deletions, // Anki treats the string as blank, which add-ons like // cloze overlapper take advantage of. ctx.card_ord = 2; assert_eq!(cloze_filter(text, &ctx).as_ref(), ""); } #[test] fn tts() { assert_eq!( tts_filter("en_US voices=Bob,Jane", "foo"), "[anki:tts lang=en_US voices=Bob,Jane]foo[/anki:tts]" ); } } ================================================ FILE: rslib/src/tests.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html #![cfg(test)] #![allow(dead_code)] use itertools::Itertools; use tempfile::tempdir; use tempfile::TempDir; use crate::collection::CollectionBuilder; use crate::deckconfig::DeckConfigInner; use crate::media::MediaManager; use crate::prelude::*; pub(crate) fn open_fs_test_collection(name: &str) -> (Collection, TempDir) { let tempdir = tempdir().unwrap(); let dir = tempdir.path(); let col = CollectionBuilder::new(dir.join(format!("{name}.anki2"))) .with_desktop_media_paths() .build() .unwrap(); (col, tempdir) } pub(crate) fn open_test_collection_with_learning_card() -> Collection { let mut col = Collection::new(); NoteAdder::basic(&mut col).add(&mut col); col.answer_again(); col.clear_study_queues(); col } pub(crate) fn open_test_collection_with_relearning_card() -> Collection { let mut col = Collection::new(); NoteAdder::basic(&mut col).add(&mut col); col.answer_easy(); col.storage .db .execute_batch("UPDATE cards SET due = 0") .unwrap(); col.clear_study_queues(); col.answer_again(); col.clear_study_queues(); col } impl Collection { pub(crate) fn new() -> Collection { CollectionBuilder::default().build().unwrap() } pub(crate) fn add_media(&self, media: &[(&str, &[u8])]) { let mgr = MediaManager::new(&self.media_folder, &self.media_db).unwrap(); for (name, data) in media { mgr.add_file(name, data).unwrap(); } } pub(crate) fn get_all_notes(&mut self) -> Vec { self.storage.get_all_notes() } pub(crate) fn get_first_card(&self) -> Card { self.storage.get_all_cards().pop().unwrap() } pub(crate) fn set_default_learn_steps(&mut self, steps: Vec) { self.update_default_deck_config(|config| config.learn_steps = steps); } pub(crate) fn set_default_relearn_steps(&mut self, steps: Vec) { self.update_default_deck_config(|config| config.relearn_steps = steps); } /// Updates with the modified config, then resorts and adjusts remaining /// steps in the default deck. pub(crate) fn update_default_deck_config( &mut self, modifier: impl FnOnce(&mut DeckConfigInner), ) { let config = self .get_deck_config(DeckConfigId(1), false) .unwrap() .unwrap(); let mut new_config = config.clone(); modifier(&mut new_config.inner); self.update_deck_config_inner(&mut new_config, config.clone(), None) .unwrap(); self.sort_deck(DeckId(1), config.inner.new_card_insert_order(), Usn(0)) .unwrap(); self.adjust_remaining_steps_in_deck(DeckId(1), Some(&config), Some(&new_config), Usn(0)) .unwrap(); } pub(crate) fn basic_notetype(&self) -> Notetype { let ntid = self.storage.get_notetype_id("Basic").unwrap().unwrap(); self.storage.get_notetype(ntid).unwrap().unwrap() } pub(crate) fn basic_rev_notetype(&self) -> Notetype { let ntid = self .storage .get_notetype_id("Basic (and reversed card)") .unwrap() .unwrap(); self.storage.get_notetype(ntid).unwrap().unwrap() } pub(crate) fn cloze_notetype(&self) -> Notetype { let ntid = self.storage.get_notetype_id("Cloze").unwrap().unwrap(); self.storage.get_notetype(ntid).unwrap().unwrap() } } #[derive(Debug, Default, Clone)] pub(crate) struct DeckAdder { name: NativeDeckName, filtered: bool, config: Option, } impl DeckAdder { pub(crate) fn new(human_name: impl AsRef) -> Self { Self { name: NativeDeckName::from_human_name(human_name), ..Default::default() } } pub(crate) fn filtered(mut self, filtered: bool) -> Self { self.filtered = filtered; self } pub(crate) fn with_config(mut self, modifier: impl FnOnce(&mut DeckConfig)) -> Self { let mut config = DeckConfig::default(); modifier(&mut config); self.config = Some(config); self } pub(crate) fn add(mut self, col: &mut Collection) -> Deck { let config_opt = self.config.take(); let mut deck = self.deck(); if let Some(mut config) = config_opt { col.add_or_update_deck_config(&mut config).unwrap(); deck.normal_mut() .expect("can't set config for filtered deck") .config_id = config.id.0; } col.add_or_update_deck(&mut deck).unwrap(); deck } pub(crate) fn deck(self) -> Deck { let mut deck = if self.filtered { Deck::new_filtered() } else { Deck::new_normal() }; deck.name = self.name; deck } } #[derive(Debug, Clone)] pub(crate) struct NoteAdder { note: Note, deck: DeckId, } impl NoteAdder { pub(crate) fn new(notetype: &Notetype) -> Self { Self { note: notetype.new_note(), deck: DeckId(1), } } pub(crate) fn basic(col: &mut Collection) -> Self { Self::new(&col.basic_notetype()) } pub(crate) fn cloze(col: &mut Collection) -> Self { Self::new(&col.cloze_notetype()) } pub(crate) fn fields(mut self, fields: &[&str]) -> Self { *self.note.fields_mut() = fields.iter().map(ToString::to_string).collect(); self } pub(crate) fn deck(mut self, deck: DeckId) -> Self { self.deck = deck; self } pub(crate) fn add(mut self, col: &mut Collection) -> Note { col.add_note(&mut self.note, self.deck).unwrap(); self.note } pub(crate) fn note(self) -> Note { self.note } } #[derive(Debug, Clone)] pub(crate) struct CardAdder { siblings: usize, deck: DeckId, due_dates: Vec<&'static str>, } impl CardAdder { pub(crate) fn new() -> Self { Self { siblings: 1, deck: DeckId(1), due_dates: Vec::new(), } } pub(crate) fn siblings(mut self, siblings: usize) -> Self { self.siblings = siblings; self } pub(crate) fn deck(mut self, deck: DeckId) -> Self { self.deck = deck; self } /// Takes an array of strs and sets the due date of the first siblings /// accordingly, skipping siblings if a str is empty. pub(crate) fn due_dates(mut self, due_dates: impl Into>) -> Self { self.due_dates = due_dates.into(); self } pub(crate) fn add(&self, col: &mut Collection) -> Vec { let field = (1..self.siblings + 1) .map(|n| format!("{{{{c{n}::}}}}")) .join(""); let note = NoteAdder::cloze(col) .fields(&[&field, ""]) .deck(self.deck) .add(col); if !self.due_dates.is_empty() { let cids = col.storage.card_ids_of_notes(&[note.id]).unwrap(); for (ord, due_date) in self.due_dates.iter().enumerate() { if !due_date.is_empty() { col.set_due_date(&cids[ord..ord + 1], due_date, None) .unwrap(); } } } col.storage.all_cards_of_note(note.id).unwrap() } } ================================================ FILE: rslib/src/text.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::sync::LazyLock; use percent_encoding_iri::percent_decode_str; use percent_encoding_iri::utf8_percent_encode; use percent_encoding_iri::AsciiSet; use percent_encoding_iri::CONTROLS; use regex::Captures; use regex::Regex; use unicase::eq as uni_eq; use unicode_normalization::char::is_combining_mark; use unicode_normalization::is_nfc; use unicode_normalization::is_nfkd_quick; use unicode_normalization::IsNormalized; use unicode_normalization::UnicodeNormalization; pub trait Trimming { fn trim(self) -> Self; } impl Trimming for Cow<'_, str> { fn trim(self) -> Self { match self { Cow::Borrowed(text) => text.trim().into(), Cow::Owned(text) => { let trimmed = text.as_str().trim(); if trimmed.len() == text.len() { text.into() } else { trimmed.to_string().into() } } } } } pub(crate) trait CowMapping<'a, B: ?Sized + 'a + ToOwned> { /// Returns [self] /// - unchanged, if the given function returns [Cow::Borrowed] /// - with the new value, if the given function returns [Cow::Owned] fn map_cow(self, f: impl FnOnce(&B) -> Cow) -> Self; fn get_owned(self) -> Option; } impl<'a, B: ?Sized + 'a + ToOwned> CowMapping<'a, B> for Cow<'a, B> { fn map_cow(self, f: impl FnOnce(&B) -> Cow) -> Self { if let Cow::Owned(o) = f(&self) { Cow::Owned(o) } else { self } } fn get_owned(self) -> Option { match self { Cow::Borrowed(_) => None, Cow::Owned(s) => Some(s), } } } pub(crate) fn strip_utf8_bom(s: &str) -> &str { s.strip_prefix('\u{feff}').unwrap_or(s) } #[derive(Debug, PartialEq)] pub enum AvTag { SoundOrVideo(String), TextToSpeech { field_text: String, lang: String, voices: Vec, speed: f32, other_args: Vec, }, } static HTML: LazyLock = LazyLock::new(|| { Regex::new(concat!( "(?si)", // wrapped text r"()|(.*?)|(.*?)", // html tags r"|(<.*?>)", )) .unwrap() }); static HTML_LINEBREAK_TAGS: LazyLock = LazyLock::new(|| { Regex::new( r#"(?xsi) "#, ) .unwrap() }); pub static HTML_MEDIA_TAGS: LazyLock = LazyLock::new(|| { Regex::new( r#"(?xsi) # the start of the image, audio, object, or source tag <\b(?:img|audio|video|object|source)\b # any non-`>`, except inside `"` or `'` (?: [^>] | "[^"]+?" | '[^']+?' )+? # capture `src` or `data` attribute \b(?:src|data)\b= (?: # 1: double-quoted filename " ([^"]+?) " [^>]*> | # 2: single-quoted filename ' ([^']+?) ' [^>]*> | # 3: unquoted filename ([^ >]+?) (?: # then either a space and the rest \x20[^>]*> | # or the tag immediately ends > ) ) "#, ) .unwrap() }); // videos are also in sound tags static AV_TAGS: LazyLock = LazyLock::new(|| { Regex::new( r"(?xs) \[sound:(.+?)\] # 1 - the filename in a sound tag | \[anki:tts\] \[(.*?)\] # 2 - arguments to tts call (.*?) # 3 - field text \[/anki:tts\] ", ) .unwrap() }); static PERSISTENT_HTML_SPACERS: LazyLock = LazyLock::new(|| Regex::new(r"(?i)|
|\n").unwrap()); static TYPE_TAG: LazyLock = LazyLock::new(|| Regex::new(r"\[\[type:[^]]+\]\]").unwrap()); pub(crate) static SOUND_TAG: LazyLock = LazyLock::new(|| Regex::new(r"\[sound:([^]]+)\]").unwrap()); /// Files included in CSS with a leading underscore. static UNDERSCORED_CSS_IMPORTS: LazyLock = LazyLock::new(|| { Regex::new( r#"(?xi) (?:@import\s+ # import statement with a bare "(_[^"]*.css)" # double quoted | # or '(_[^']*.css)' # single quoted css filename ) | # or (?:url\(\s* # a url function with a "(_[^"]+)" # double quoted | # or '(_[^']+)' # single quoted | # or (_.+?) # unquoted filename \s*\)) "#, ) .unwrap() }); /// Strings, src and data attributes with a leading underscore. static UNDERSCORED_REFERENCES: LazyLock = LazyLock::new(|| { Regex::new( r#"(?x) \[sound:(_[^]]+)\] # a filename in an Anki sound tag | # or "(_[^"]+)" # a double quoted | # or '(_[^']+)' # single quoted string | # or \b(?:src|data) # a 'src' or 'data' attribute = # followed by (_[^ >]+) # an unquoted value "#, ) .unwrap() }); pub fn is_html(text: impl AsRef) -> bool { HTML.is_match(text.as_ref()) } pub fn html_to_text_line(html: &str, preserve_media_filenames: bool) -> Cow<'_, str> { let (html_stripper, sound_rep): (fn(&str) -> Cow<'_, str>, _) = if preserve_media_filenames { (strip_html_preserving_media_filenames, "$1") } else { (strip_html, "") }; PERSISTENT_HTML_SPACERS .replace_all(html, " ") .map_cow(|s| TYPE_TAG.replace_all(s, "")) .map_cow(|s| SOUND_TAG.replace_all(s, sound_rep)) .map_cow(html_stripper) .trim() } pub fn strip_html(html: &str) -> Cow<'_, str> { strip_html_preserving_entities(html).map_cow(decode_entities) } pub fn strip_html_preserving_entities(html: &str) -> Cow<'_, str> { HTML.replace_all(html, "") } pub fn decode_entities(html: &str) -> Cow<'_, str> { if html.contains('&') { match htmlescape::decode_html(html) { Ok(text) => text.replace('\u{a0}', " ").into(), Err(_) => html.into(), } } else { // nothing to do html.into() } } pub(crate) fn newlines_to_spaces(text: &str) -> Cow<'_, str> { if text.contains('\n') { text.replace('\n', " ").into() } else { text.into() } } pub fn strip_html_for_tts(html: &str) -> Cow<'_, str> { HTML_LINEBREAK_TAGS .replace_all(html, " ") .map_cow(strip_html) } /// Truncate a String on a valid UTF8 boundary. pub(crate) fn truncate_to_char_boundary(s: &mut String, mut max: usize) { if max >= s.len() { return; } while !s.is_char_boundary(max) { max -= 1; } s.truncate(max); } #[derive(Debug)] pub(crate) struct MediaRef<'a> { pub full_ref: &'a str, pub fname: &'a str, /// audio files may have things like & that need decoding pub fname_decoded: Cow<'a, str>, } pub(crate) fn extract_media_refs(text: &str) -> Vec> { let mut out = vec![]; for caps in HTML_MEDIA_TAGS.captures_iter(text) { let fname = caps .get(1) .or_else(|| caps.get(2)) .or_else(|| caps.get(3)) .unwrap() .as_str(); let fname_decoded = decode_entities(fname); out.push(MediaRef { full_ref: caps.get(0).unwrap().as_str(), fname, fname_decoded, }); } for caps in AV_TAGS.captures_iter(text) { if let Some(m) = caps.get(1) { let fname = m.as_str(); let fname_decoded = decode_entities(fname); out.push(MediaRef { full_ref: caps.get(0).unwrap().as_str(), fname, fname_decoded, }); } } out } /// Calls `replacer` for every media reference in `text`, and optionally /// replaces it with something else. [None] if no reference was found. pub fn replace_media_refs( text: &str, mut replacer: impl FnMut(&str) -> Option, ) -> Option { let mut rep = |caps: &Captures| { let whole_match = caps.get(0).unwrap().as_str(); let old_name = caps.iter().skip(1).find_map(|g| g).unwrap().as_str(); let old_name_decoded = decode_entities(old_name); if let Some(mut new_name) = replacer(&old_name_decoded) { if matches!(old_name_decoded, Cow::Owned(_)) { new_name = htmlescape::encode_minimal(&new_name); } whole_match.replace(old_name, &new_name) } else { whole_match.to_owned() } }; HTML_MEDIA_TAGS .replace_all(text, &mut rep) .map_cow(|s| AV_TAGS.replace_all(s, &mut rep)) .get_owned() } pub(crate) fn extract_underscored_css_imports(text: &str) -> Vec<&str> { UNDERSCORED_CSS_IMPORTS .captures_iter(text) .map(extract_match) .collect() } pub(crate) fn extract_underscored_references(text: &str) -> Vec<&str> { UNDERSCORED_REFERENCES .captures_iter(text) .map(extract_match) .collect() } /// Returns the first matching group as a str. This is intended for regexes /// where exactly one group matches, and will panic for matches without matching /// groups. fn extract_match(caps: Captures<'_>) -> &str { caps.iter().skip(1).find_map(|g| g).unwrap().as_str() } pub fn strip_html_preserving_media_filenames(html: &str) -> Cow<'_, str> { HTML_MEDIA_TAGS .replace_all(html, r" ${1}${2}${3} ") .map_cow(strip_html) } pub fn contains_media_tag(html: &str) -> bool { HTML_MEDIA_TAGS.is_match(html) } #[allow(dead_code)] pub(crate) fn sanitize_html(html: &str) -> String { ammonia::clean(html) } pub(crate) fn sanitize_html_no_images(html: &str) -> String { ammonia::Builder::default() .rm_tags(&["img"]) .clean(html) .to_string() } pub(crate) fn normalize_to_nfc(s: &str) -> Cow<'_, str> { match is_nfc(s) { false => s.chars().nfc().collect::().into(), true => s.into(), } } pub(crate) fn ensure_string_in_nfc(s: &mut String) { if !is_nfc(s) { *s = s.chars().nfc().collect() } } static EXTRA_NO_COMBINING_REPLACEMENTS: phf::Map = phf::phf_map! { '€' => "E", 'Æ' => "AE", 'Ð' => "D", 'Ø' => "O", 'Þ' => "TH", 'ß' => "s", 'æ' => "ae", 'ð' => "d", 'ø' => "o", 'þ' => "th", 'Đ' => "D", 'đ' => "d", 'Ħ' => "H", 'ħ' => "h", 'ı' => "i", 'ĸ' => "k", 'Ł' => "L", 'ł' => "l", 'Ŋ' => "N", 'ŋ' => "n", 'Œ' => "OE", 'œ' => "oe", 'Ŧ' => "T", 'ŧ' => "t", 'Ə' => "E", 'ǝ' => "e", 'ɑ' => "a", }; /// Convert provided string to NFKD form and strip combining characters. pub(crate) fn without_combining(s: &str) -> Cow<'_, str> { // if the string is already normalized if matches!(is_nfkd_quick(s.chars()), IsNormalized::Yes) { // and no combining characters found, return unchanged if !s .chars() .any(|c| is_combining_mark(c) || EXTRA_NO_COMBINING_REPLACEMENTS.contains_key(&c)) { return s.into(); } } // we need to create a new string without the combining marks let mut out = String::with_capacity(s.len()); for chr in s.chars().nfkd().filter(|c| !is_combining_mark(*c)) { if let Some(repl) = EXTRA_NO_COMBINING_REPLACEMENTS.get(&chr) { out.push_str(repl); } else { out.push(chr); } } out.into() } /// Check if string contains an unescaped wildcard. pub(crate) fn is_glob(txt: &str) -> bool { // even number of \s followed by a wildcard static RE: LazyLock = LazyLock::new(|| { Regex::new( r"(?x) (?:^|[^\\]) # not a backslash (?:\\\\)* # even number of backslashes [*_] # wildcard ", ) .unwrap() }); RE.is_match(txt) } /// Convert to a RegEx respecting Anki wildcards. pub(crate) fn to_re(txt: &str) -> Cow<'_, str> { to_custom_re(txt, ".") } /// Convert Anki style to RegEx using the provided wildcard. pub(crate) fn to_custom_re<'a>(txt: &'a str, wildcard: &str) -> Cow<'a, str> { static RE: LazyLock = LazyLock::new(|| Regex::new(r"\\?.").unwrap()); RE.replace_all(txt, |caps: &Captures| { let s = &caps[0]; match s { r"\\" | r"\*" => s.to_string(), r"\_" => "_".to_string(), "*" => format!("{wildcard}*"), "_" => wildcard.to_string(), s => regex::escape(s), } }) } /// Convert to SQL respecting Anki wildcards. pub(crate) fn to_sql(txt: &str) -> Cow<'_, str> { // escape sequences and unescaped special characters which need conversion static RE: LazyLock = LazyLock::new(|| Regex::new(r"\\[\\*]|[*%]").unwrap()); RE.replace_all(txt, |caps: &Captures| { let s = &caps[0]; match s { r"\\" => r"\\", r"\*" => "*", "*" => "%", "%" => r"\%", _ => unreachable!(), } }) } /// Unescape everything. pub(crate) fn to_text(txt: &str) -> Cow<'_, str> { static RE: LazyLock = LazyLock::new(|| Regex::new(r"\\(.)").unwrap()); RE.replace_all(txt, "$1") } /// Escape Anki wildcards and the backslash for escaping them: \*_ pub(crate) fn escape_anki_wildcards(txt: &str) -> String { static RE: LazyLock = LazyLock::new(|| Regex::new(r"[\\*_]").unwrap()); RE.replace_all(txt, r"\$0").into() } /// Escape Anki wildcards unless it's _* pub(crate) fn escape_anki_wildcards_for_search_node(txt: &str) -> String { if txt == "_*" { txt.to_string() } else { escape_anki_wildcards(txt) } } /// Return a function to match input against `search`, /// which may contain wildcards. pub(crate) fn glob_matcher(search: &str) -> impl Fn(&str) -> bool + '_ { let mut regex = None; let mut cow = None; if is_glob(search) { regex = Some(Regex::new(&format!("^(?i){}$", to_re(search))).unwrap()); } else { cow = Some(to_text(search)); } move |text| { if let Some(r) = ®ex { r.is_match(text) } else { uni_eq(text, cow.as_ref().unwrap()) } } } pub(crate) static REMOTE_FILENAME: LazyLock = LazyLock::new(|| Regex::new("(?i)^https?://").unwrap()); /// https://url.spec.whatwg.org/#fragment-percent-encode-set const FRAGMENT_QUERY_UNION: &AsciiSet = &CONTROLS .add(b' ') .add(b'"') .add(b'<') .add(b'>') .add(b'`') .add(b'#'); /// IRI-encode unescaped local paths in HTML fragment. pub(crate) fn encode_iri_paths(unescaped_html: &str) -> Cow<'_, str> { transform_html_paths(unescaped_html, |fname| { utf8_percent_encode(fname, FRAGMENT_QUERY_UNION).into() }) } /// URI-decode escaped local paths in HTML fragment. pub(crate) fn decode_iri_paths(escaped_html: &str) -> Cow<'_, str> { transform_html_paths(escaped_html, |fname| { percent_decode_str(fname).decode_utf8_lossy() }) } /// Apply a transform to local filename references in tags like IMG. /// Required at display time, as Anki unfortunately stores the references /// in unencoded form in the database. fn transform_html_paths(html: &str, transform: F) -> Cow<'_, str> where F: Fn(&str) -> Cow<'_, str>, { HTML_MEDIA_TAGS.replace_all(html, |caps: &Captures| { let fname = caps .get(1) .or_else(|| caps.get(2)) .or_else(|| caps.get(3)) .unwrap() .as_str(); let full = caps.get(0).unwrap().as_str(); if REMOTE_FILENAME.is_match(fname) { full.into() } else { full.replace(fname, &transform(fname)) } }) } #[cfg(test)] mod test { use std::borrow::Cow; use super::*; #[test] fn stripping() { assert_eq!(strip_html("test"), "test"); assert_eq!(strip_html("test"), "test"); assert_eq!(strip_html("some"), "some"); assert_eq!( strip_html_preserving_media_filenames(""), " foo.jpg " ); assert_eq!( strip_html_preserving_media_filenames(""), " foo.jpg " ); assert_eq!(strip_html_preserving_media_filenames(""), ""); } #[test] fn combining() { assert!(matches!(without_combining("test"), Cow::Borrowed(_))); assert!(matches!(without_combining("Über"), Cow::Owned(_))); } #[test] fn conversion() { assert_eq!(&to_re(r"[te\*st]"), r"\[te\*st\]"); assert_eq!(&to_custom_re("f_o*", r"\d"), r"f\do\d*"); assert_eq!(&to_sql("%f_o*"), r"\%f_o%"); assert_eq!(&to_text(r"\*\_*_"), "*_*_"); assert!(is_glob(r"\\\\_")); assert!(!is_glob(r"\\\_")); assert!(glob_matcher(r"foo\*bar*")("foo*bar123")); } #[test] fn extracting() { assert_eq!( extract_underscored_css_imports(concat!( "@IMPORT '_foo.css'\n", "@import \"_bar.css\"\n", "@import '_baz.css'\n", "@import 'nope.css'\n", "url(_foo.css)\n", "URL(\"_bar.css\")\n", "@import url('_baz.css')\n", "url('nope.css')\n", "url(_foo.woff2) format('woff2')", )), vec![ "_foo.css", "_bar.css", "_baz.css", "_foo.css", "_bar.css", "_baz.css", "_foo.woff2" ] ); assert_eq!( extract_underscored_references(concat!( "", "", "\"_baz.js\"", "\"nope.js\"", "", "", "'_baz.js'", )), vec!["_foo.jpg", "_bar", "_baz.js", "_foo.jpg", "_bar", "_baz.js",] ); } #[test] fn replacing() { assert_eq!( &replace_media_refs("[sound:bar.mp3]", |s| { (s != "baz.jpg").then(|| "spam".to_string()) }) .unwrap(), "[sound:spam]", ); } #[test] fn truncate() { let mut s = "日本語".to_string(); truncate_to_char_boundary(&mut s, 6); assert_eq!(&s, "日本"); let mut s = "日本語".to_string(); truncate_to_char_boundary(&mut s, 1); assert_eq!(&s, ""); } #[test] fn iri_encoding() { for (input, output) in [ ("foo.jpg", "foo.jpg"), ("bar baz", "bar%20baz"), ("sub/path.jpg", "sub/path.jpg"), ("日本語", "日本語"), ("a=b", "a=b"), ("a&b", "a&b"), ] { assert_eq!( &encode_iri_paths(&format!("")), &format!("") ); } } } ================================================ FILE: rslib/src/timestamp.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::time; use chrono::prelude::*; use crate::define_newtype; use crate::prelude::*; define_newtype!(TimestampSecs, i64); define_newtype!(TimestampMillis, i64); impl TimestampSecs { pub fn now() -> Self { Self(elapsed().as_secs() as i64) } pub fn zero() -> Self { Self(0) } pub fn elapsed_secs_since(self, other: TimestampSecs) -> i64 { self.0 - other.0 } pub fn elapsed_secs(self) -> u64 { (Self::now().0 - self.0).max(0) as u64 } pub fn elapsed_days_since(self, other: TimestampSecs) -> u64 { (self.0 - other.0).max(0) as u64 / 86_400 } pub fn as_millis(self) -> TimestampMillis { TimestampMillis(self.0 * 1000) } pub(crate) fn local_datetime(self) -> Result> { Local .timestamp_opt(self.0, 0) .latest() .or_invalid("invalid timestamp") } /// YYYY-mm-dd pub(crate) fn date_string(self) -> String { self.local_datetime() .map(|dt| dt.format("%Y-%m-%d").to_string()) .unwrap_or_else(|_err| "invalid date".to_string()) } /// HH-MM pub(crate) fn time_string(self) -> String { self.local_datetime() .map(|dt| dt.format("%H:%M").to_string()) .unwrap_or_else(|_err| "invalid date".to_string()) } pub(crate) fn date_and_time_string(self) -> String { format!("{} @ {}", self.date_string(), self.time_string()) } pub fn local_utc_offset(self) -> Result { Ok(*self.local_datetime()?.offset()) } pub fn datetime(self, utc_offset: FixedOffset) -> Result> { utc_offset .timestamp_opt(self.0, 0) .latest() .or_invalid("invalid timestamp") } pub fn adding_secs(self, secs: i64) -> Self { TimestampSecs(self.0 + secs) } } impl TimestampMillis { pub fn now() -> Self { Self(elapsed().as_millis() as i64) } pub fn zero() -> Self { Self(0) } pub fn as_secs(self) -> TimestampSecs { TimestampSecs(self.0 / 1000) } pub fn adding_secs(self, secs: i64) -> Self { Self(self.0 + secs * 1000) } pub fn elapsed_millis(self) -> u64 { (Self::now().0 - self.0).max(0) as u64 } } fn elapsed() -> time::Duration { if *crate::PYTHON_UNIT_TESTS { // shift clock around rollover time to accommodate Python tests that make bad // assumptions. we should update the tests in the future and remove this // hack. let mut elap = time::SystemTime::now() .duration_since(time::SystemTime::UNIX_EPOCH) .unwrap(); let now = Local::now(); if now.hour() >= 2 && now.hour() < 4 { elap -= time::Duration::from_secs(60 * 60 * 2); } elap } else { time::SystemTime::now() .duration_since(time::SystemTime::UNIX_EPOCH) .unwrap() } } ================================================ FILE: rslib/src/typeanswer.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::sync::LazyLock; use difflib::sequencematcher::SequenceMatcher; use regex::Regex; use unic_ucd_category::GeneralCategory; use unicode_normalization::char::is_combining_mark; use unicode_normalization::UnicodeNormalization; use crate::card_rendering::strip_av_tags; use crate::text::normalize_to_nfc; use crate::text::strip_html; static LINEBREAKS: LazyLock = LazyLock::new(|| { Regex::new( r"(?six) ( \n | | )+", ) .unwrap() }); macro_rules! format_typeans { ($typeans:expr) => { format!("{}", $typeans) }; } // Public API pub fn compare_answer(expected: &str, typed: &str, combining: bool) -> String { let stripped = strip_expected(expected); match typed.is_empty() { true => format_typeans!(htmlescape::encode_minimal(&stripped)), false if combining => Diff::new(&stripped, typed).to_html(), false => DiffNonCombining::new(&stripped, typed).to_html(), } } // Core Logic trait DiffTrait { fn get_typed(&self) -> &[char]; fn get_expected(&self) -> &[char]; fn get_expected_original(&self) -> Cow<'_, str>; fn new(expected: &str, typed: &str) -> Self; // Entry Point fn to_html(&self) -> String { if self.get_typed() == self.get_expected() { format_typeans!(format!( "{}", htmlescape::encode_minimal(&self.get_expected_original()) )) } else { let output = self.to_tokens(); let typed_html = render_tokens(&output.typed_tokens); let expected_html = self.render_expected_tokens(&output.expected_tokens); format_typeans!(format!( "{typed_html}

{expected_html}" )) } } fn to_tokens(&self) -> DiffTokens { let mut matcher = SequenceMatcher::new(self.get_typed(), self.get_expected()); let mut typed_tokens = Vec::new(); let mut expected_tokens = Vec::new(); for opcode in matcher.get_opcodes() { let typed_slice = slice(self.get_typed(), opcode.first_start, opcode.first_end); let expected_slice = slice(self.get_expected(), opcode.second_start, opcode.second_end); match opcode.tag.as_str() { "equal" => { typed_tokens.push(DiffToken::good(typed_slice)); expected_tokens.push(DiffToken::good(expected_slice)); } "delete" => typed_tokens.push(DiffToken::bad(typed_slice)), "insert" => { typed_tokens.push(DiffToken::missing( "-".repeat(expected_slice.chars().count()), )); expected_tokens.push(DiffToken::missing(expected_slice)); } "replace" => { typed_tokens.push(DiffToken::bad(typed_slice)); expected_tokens.push(DiffToken::missing(expected_slice)); } _ => unreachable!(), } } DiffTokens { typed_tokens, expected_tokens, } } fn render_expected_tokens(&self, tokens: &[DiffToken]) -> String; } // Utility Functions fn normalize(string: &str) -> Vec { normalize_to_nfc(string).chars().collect() } fn slice(chars: &[char], start: usize, end: usize) -> String { chars[start..end].iter().collect() } fn strip_expected(expected: &str) -> String { let no_av_tags = strip_av_tags(expected); let no_linebreaks = LINEBREAKS.replace_all(&no_av_tags, " "); strip_html(&no_linebreaks).trim().to_string() } // Render Functions fn render_tokens(tokens: &[DiffToken]) -> String { tokens.iter().fold(String::new(), |mut acc, token| { let isolated_text = isolate_leading_mark(&token.text); let encoded_text = htmlescape::encode_minimal(&isolated_text); let class = token.to_class(); acc.push_str(&format!("{encoded_text}")); acc }) } /// Prefixes a leading mark character with a non-breaking space to prevent /// it from joining the previous token. fn isolate_leading_mark(text: &str) -> Cow<'_, str> { if text .chars() .next() .is_some_and(|c| GeneralCategory::of(c).is_mark()) { Cow::Owned(format!("\u{a0}{text}")) } else { Cow::Borrowed(text) } } // Default Comparison struct Diff { typed: Vec, expected: Vec, } impl DiffTrait for Diff { fn get_typed(&self) -> &[char] { &self.typed } fn get_expected(&self) -> &[char] { &self.expected } fn get_expected_original(&self) -> Cow<'_, str> { Cow::Owned(self.get_expected().iter().collect::()) } fn new(expected: &str, typed: &str) -> Self { Self { typed: normalize(typed), expected: normalize(expected), } } fn render_expected_tokens(&self, tokens: &[DiffToken]) -> String { render_tokens(tokens) } } // Non-Combining Comparison struct DiffNonCombining { base: Diff, expected_split: Vec, expected_original: String, } impl DiffTrait for DiffNonCombining { fn get_typed(&self) -> &[char] { &self.base.typed } fn get_expected(&self) -> &[char] { &self.base.expected } fn get_expected_original(&self) -> Cow<'_, str> { Cow::Borrowed(&self.expected_original) } fn new(expected: &str, typed: &str) -> Self { // filter out combining elements let typed_stripped: Vec = typed.nfkd().filter(|&c| !is_combining_mark(c)).collect(); let mut expected_stripped: Vec = Vec::new(); // also tokenize into "char+combining" for final rendering let mut expected_split: Vec = Vec::new(); for c in expected.nfkd() { if unicode_normalization::char::is_combining_mark(c) { if let Some(last) = expected_split.last_mut() { last.push(c); } } else { expected_stripped.push(c); expected_split.push(c.to_string()); } } Self { base: Diff { typed: typed_stripped, expected: expected_stripped, }, expected_split, expected_original: expected.to_string(), } } // Combining characters are still required learning content, so use // expected_split to show them directly in the "expected" line, rather than // having to otherwise e.g. include their field twice on the note template. fn render_expected_tokens(&self, tokens: &[DiffToken]) -> String { let mut idx = 0; tokens.iter().fold(String::new(), |mut acc, token| { let end = idx + token.text.chars().count(); let txt = self.expected_split[idx..end].concat(); idx = end; let encoded_text = htmlescape::encode_minimal(&txt); let class = token.to_class(); acc.push_str(&format!("{encoded_text}")); acc }) } } // Utility Items #[derive(Debug, PartialEq, Eq)] struct DiffTokens { typed_tokens: Vec, expected_tokens: Vec, } #[derive(Debug, PartialEq, Eq)] enum DiffTokenKind { Good, Bad, Missing, } #[derive(Debug, PartialEq, Eq)] struct DiffToken { kind: DiffTokenKind, text: String, } impl DiffToken { fn new(kind: DiffTokenKind, text: String) -> Self { Self { kind, text } } fn good(text: String) -> Self { Self::new(DiffTokenKind::Good, text) } fn bad(text: String) -> Self { Self::new(DiffTokenKind::Bad, text) } fn missing(text: String) -> Self { Self::new(DiffTokenKind::Missing, text) } fn to_class(&self) -> &'static str { match self.kind { DiffTokenKind::Good => "typeGood", DiffTokenKind::Bad => "typeBad", DiffTokenKind::Missing => "typeMissed", } } } #[cfg(test)] mod test { use super::*; macro_rules! token_factory { ($name:ident) => { fn $name(text: &str) -> DiffToken { DiffToken::$name(String::from(text)) } }; } token_factory!(bad); token_factory!(good); token_factory!(missing); #[test] fn tokens() { let ctx = Diff::new("¿Y ahora qué vamos a hacer?", "y ahora qe vamosa hacer"); let output = ctx.to_tokens(); assert_eq!( output.typed_tokens, vec![ bad("y"), good(" ahora q"), bad("e"), good(" vamos"), missing("-"), good("a hacer"), missing("-"), ] ); assert_eq!( output.expected_tokens, vec![ missing("¿Y"), good(" ahora q"), missing("ué"), good(" vamos"), missing(" "), good("a hacer"), missing("?"), ] ); } #[test] fn html_and_media() { let stripped = strip_expected("[sound:foo.mp3]1  2"); let ctx = Diff::new(&stripped, "1 2"); // the spacing is handled by wrapping html output in white-space: pre-wrap assert_eq!(ctx.to_tokens().expected_tokens, &[good("1 2")]); } #[test] fn missed_chars_only_shown_in_typed_when_after_good() { let ctx = Diff::new("1", "23"); assert_eq!(ctx.to_tokens().typed_tokens, &[bad("23")]); let ctx = Diff::new("12", "1"); assert_eq!(ctx.to_tokens().typed_tokens, &[good("1"), missing("-"),]); } #[test] fn missed_chars_counted_correctly() { let ctx = Diff::new("нос", "нс"); assert_eq!( ctx.to_tokens().typed_tokens, &[good("н"), missing("-"), good("с")] ); } #[test] fn handles_certain_unicode_as_expected() { // this was not parsed as expected with dissimilar 1.0.4 let ctx = Diff::new("쓰다듬다", "스다뜸다"); assert_eq!( ctx.to_tokens().typed_tokens, &[bad("스"), good("다"), bad("뜸"), good("다"),] ); } #[test] fn does_not_panic_with_certain_unicode() { // this was causing a panic with dissimilar 1.0.4 let ctx = Diff::new( "Сущность должна быть ответственна только за одно дело", concat!( "Single responsibility Сущность выполняет только одну задачу.", "Повод для изменения сущности только один." ), ); ctx.to_tokens(); } #[test] fn tags_removed() { let stripped = strip_expected("
123
"); assert_eq!(stripped, "123"); assert_eq!( Diff::new(&stripped, "123").to_html(), "123" ); } #[test] fn empty_input_shows_as_code() { let ctx = compare_answer("
123
", "", true); assert_eq!(ctx, "123"); } #[test] fn correct_input_is_escaped() { let ctx = Diff::new("source /bin/activate", "source /bin/activate"); assert_eq!( ctx.to_html(), "source <dir>/bin/activate" ); } #[test] fn correct_input_is_collapsed() { let ctx = Diff::new("123", "123"); assert_eq!( ctx.to_html(), "123" ); } #[test] fn incorrect_input_is_not_collapsed() { let ctx = Diff::new("123", "1123"); assert_eq!( ctx.to_html(), "1123

123
" ); } #[test] fn noncombining_comparison() { assert_eq!( compare_answer("שִׁנּוּן", "שנון", false), "שִׁנּוּן" ); assert_eq!( compare_answer("חוֹף", "חופ", false), "חופ

חוֹף
" ); assert_eq!( compare_answer("ば", "は", false), "" ); } } ================================================ FILE: rslib/src/types.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html #[macro_export] macro_rules! define_newtype { ( $name:ident, $type:ident ) => { #[repr(transparent)] #[derive( Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize, )] pub struct $name(pub $type); impl std::fmt::Display for $name { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } impl std::str::FromStr for $name { type Err = std::num::ParseIntError; fn from_str(s: &std::primitive::str) -> std::result::Result { $type::from_str(s).map($name) } } impl rusqlite::types::FromSql for $name { fn column_result( value: rusqlite::types::ValueRef<'_>, ) -> std::result::Result { if let rusqlite::types::ValueRef::Integer(i) = value { Ok(Self(i as $type)) } else { Err(rusqlite::types::FromSqlError::InvalidType) } } } impl rusqlite::ToSql for $name { fn to_sql(&self) -> ::rusqlite::Result> { Ok(rusqlite::types::ToSqlOutput::Owned( rusqlite::types::Value::Integer(self.0 as i64), )) } } impl From<$type> for $name { fn from(t: $type) -> $name { $name(t) } } impl From<$name> for $type { fn from(n: $name) -> $type { n.0 } } }; } define_newtype!(Usn, i32); pub(crate) trait IntoNewtypeVec { fn into_newtype(self, func: F) -> Vec where F: FnMut(i64) -> T; } impl IntoNewtypeVec for Vec { fn into_newtype(self, func: F) -> Vec where F: FnMut(i64) -> T, { self.into_iter().map(func).collect() } } ================================================ FILE: rslib/src/undo/changes.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::card::undo::UndoableCardChange; use crate::collection::undo::UndoableCollectionChange; use crate::config::undo::UndoableConfigChange; use crate::deckconfig::undo::UndoableDeckConfigChange; use crate::decks::undo::UndoableDeckChange; use crate::notes::undo::UndoableNoteChange; use crate::notetype::undo::UndoableNotetypeChange; use crate::prelude::*; use crate::revlog::undo::UndoableRevlogChange; use crate::scheduler::queue::undo::UndoableQueueChange; use crate::tags::undo::UndoableTagChange; #[derive(Debug)] pub(crate) enum UndoableChange { Card(UndoableCardChange), Note(UndoableNoteChange), Deck(UndoableDeckChange), DeckConfig(UndoableDeckConfigChange), Tag(UndoableTagChange), Revlog(UndoableRevlogChange), Queue(UndoableQueueChange), Config(UndoableConfigChange), Collection(UndoableCollectionChange), Notetype(UndoableNotetypeChange), } impl UndoableChange { pub(super) fn undo(self, col: &mut Collection) -> Result<()> { match self { UndoableChange::Card(c) => col.undo_card_change(c), UndoableChange::Note(c) => col.undo_note_change(c), UndoableChange::Deck(c) => col.undo_deck_change(c), UndoableChange::Tag(c) => col.undo_tag_change(c), UndoableChange::Revlog(c) => col.undo_revlog_change(c), UndoableChange::Queue(c) => col.undo_queue_change(c), UndoableChange::Config(c) => col.undo_config_change(c), UndoableChange::DeckConfig(c) => col.undo_deck_config_change(c), UndoableChange::Collection(c) => col.undo_collection_change(c), UndoableChange::Notetype(c) => col.undo_notetype_change(c), } } } impl From for UndoableChange { fn from(c: UndoableCardChange) -> Self { UndoableChange::Card(c) } } impl From for UndoableChange { fn from(c: UndoableNoteChange) -> Self { UndoableChange::Note(c) } } impl From for UndoableChange { fn from(c: UndoableDeckChange) -> Self { UndoableChange::Deck(c) } } impl From for UndoableChange { fn from(c: UndoableDeckConfigChange) -> Self { UndoableChange::DeckConfig(c) } } impl From for UndoableChange { fn from(c: UndoableTagChange) -> Self { UndoableChange::Tag(c) } } impl From for UndoableChange { fn from(c: UndoableRevlogChange) -> Self { UndoableChange::Revlog(c) } } impl From for UndoableChange { fn from(c: UndoableQueueChange) -> Self { UndoableChange::Queue(c) } } impl From for UndoableChange { fn from(c: UndoableConfigChange) -> Self { UndoableChange::Config(c) } } impl From for UndoableChange { fn from(c: UndoableCollectionChange) -> Self { UndoableChange::Collection(c) } } impl From for UndoableChange { fn from(c: UndoableNotetypeChange) -> Self { UndoableChange::Notetype(c) } } ================================================ FILE: rslib/src/undo/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod changes; use std::collections::VecDeque; pub(crate) use changes::UndoableChange; pub use crate::ops::Op; use crate::ops::OpChanges; use crate::ops::StateChanges; use crate::prelude::*; const UNDO_LIMIT: usize = 30; #[derive(Debug)] pub(crate) struct UndoableOp { pub kind: Op, pub timestamp: TimestampSecs, pub changes: Vec, pub counter: usize, } impl UndoableOp { /// True if changes non-empty, or a custom undo step. fn has_changes(&self) -> bool { !self.changes.is_empty() || matches!(self.kind, Op::Custom(_)) } } #[derive(Debug, PartialEq, Eq, Default)] enum UndoMode { #[default] NormalOp, Undoing, Redoing, } pub struct UndoStatus { pub undo: Option, pub redo: Option, pub last_step: usize, } pub struct UndoOutput { pub undone_op: Op, pub reverted_to: TimestampSecs, pub new_undo_status: UndoStatus, pub counter: usize, } #[derive(Debug, Default)] pub(crate) struct UndoManager { // undo steps are added to the front of a double-ended queue, so we can // efficiently cap the number of steps we retain in memory undo_steps: VecDeque, // redo steps are added to the end redo_steps: Vec, mode: UndoMode, current_step: Option, counter: usize, } impl UndoManager { fn save(&mut self, item: UndoableChange) { if let Some(step) = self.current_step.as_mut() { step.changes.push(item) } } fn begin_step(&mut self, op: Option) { if op.is_none() { self.undo_steps.clear(); self.redo_steps.clear(); } else if self.mode == UndoMode::NormalOp { // a normal op clears the redo queue self.redo_steps.clear(); } self.current_step = op.map(|op| UndoableOp { kind: op, timestamp: TimestampSecs::now(), changes: vec![], counter: { self.counter += 1; self.counter }, }); } fn end_step(&mut self, skip_undo: bool) { if let Some(step) = self.current_step.take() { if step.has_changes() && !skip_undo { if self.mode == UndoMode::Undoing { self.redo_steps.push(step); } else { self.undo_steps.truncate(UNDO_LIMIT - 1); self.undo_steps.push_front(step); } } } } fn can_undo(&self) -> Option<&Op> { self.undo_steps.front().map(|s| &s.kind) } fn can_redo(&self) -> Option<&Op> { self.redo_steps.last().map(|s| &s.kind) } fn previous_op(&self) -> Option<&UndoableOp> { self.undo_steps.front() } fn current_op(&self) -> Option<&UndoableOp> { self.current_step.as_ref() } fn op_changes(&self) -> OpChanges { let current_op = self .current_step .as_ref() .expect("current_changes() called when no op set"); let changes = StateChanges::from(¤t_op.changes[..]); OpChanges { op: current_op.kind.clone(), changes, } } fn merge_undoable_ops(&mut self, starting_from: usize) -> Result { let target_idx = self .undo_steps .iter() .enumerate() .filter_map(|(idx, op)| { if op.counter == starting_from { Some(idx) } else { None } }) .next() .or_invalid("target undo op not found")?; let mut removed = vec![]; for _ in 0..target_idx { removed.push(self.undo_steps.pop_front().unwrap()); } let target = self.undo_steps.front_mut().unwrap(); for step in removed.into_iter().rev() { target.changes.extend(step.changes.into_iter()); } self.counter = starting_from; Ok(OpChanges { op: target.kind.clone(), changes: StateChanges::from(&target.changes[..]), }) } /// Start a new step with a custom name, and return its associated /// counter value, which can be used with `merge_undoable_ops`. fn add_custom_step(&mut self, name: String) -> usize { self.begin_step(Some(Op::Custom(name))); self.end_step(false); self.counter } } impl Collection { pub fn can_undo(&self) -> Option<&Op> { self.state.undo.can_undo() } pub fn can_redo(&self) -> Option<&Op> { self.state.undo.can_redo() } pub fn undo(&mut self) -> Result> { if let Some(step) = self.state.undo.undo_steps.pop_front() { self.undo_inner(step, UndoMode::Undoing) } else { Err(AnkiError::UndoEmpty) } } pub fn redo(&mut self) -> Result> { if let Some(step) = self.state.undo.redo_steps.pop() { self.undo_inner(step, UndoMode::Redoing) } else { Err(AnkiError::UndoEmpty) } } pub fn undo_status(&self) -> UndoStatus { UndoStatus { undo: self.can_undo().cloned(), redo: self.can_redo().cloned(), last_step: self.state.undo.counter, } } /// Merge multiple undoable operations into one, and return the union of /// their changes. pub fn merge_undoable_ops(&mut self, starting_from: usize) -> Result { self.state.undo.merge_undoable_ops(starting_from) } /// Add an empty custom undo step, which subsequent changes can be merged /// into. pub fn add_custom_undo_step(&mut self, name: String) -> usize { self.state.undo.add_custom_step(name) } } impl Collection { /// If op is None, clears the undo/redo queues. pub(crate) fn begin_undoable_operation(&mut self, op: Option) { self.state.undo.begin_step(op); } /// Called at the end of a successful transaction. /// In most instances, this will also clear the study queues. pub(crate) fn end_undoable_operation(&mut self, skip_undo: bool) { self.state.undo.end_step(skip_undo); } pub(crate) fn discard_undo_and_study_queues(&mut self) { self.state.undo.begin_step(None); self.clear_study_queues(); } pub(crate) fn update_state_after_dbproxy_modification(&mut self) { self.discard_undo_and_study_queues(); self.state.modified_by_dbproxy = true; } #[inline] pub(crate) fn save_undo(&mut self, item: impl Into) { self.state.undo.save(item.into()); } pub(crate) fn current_undo_op(&self) -> Option<&UndoableOp> { self.state.undo.current_op() } pub(crate) fn previous_undo_op(&self) -> Option<&UndoableOp> { self.state.undo.previous_op() } pub(crate) fn undoing_or_redoing(&self) -> bool { self.state.undo.mode != UndoMode::NormalOp } pub(crate) fn current_undo_step_has_changes(&self) -> bool { self.state .undo .current_op() .map(|op| op.has_changes()) .unwrap_or_default() } /// Used for coalescing successive note updates. pub(crate) fn clear_last_op(&mut self) { self.state .undo .current_step .as_mut() .expect("no operation active") .changes .clear() } /// Return changes made by the current op. Must only be called in a /// transaction, when an operation was passed to transact(). pub(crate) fn op_changes(&self) -> OpChanges { self.state.undo.op_changes() } fn undo_inner(&mut self, step: UndoableOp, mode: UndoMode) -> Result> { let undone_op = step.kind; let reverted_to = step.timestamp; let changes = step.changes; let counter = step.counter; self.state.undo.mode = mode; let res = self.transact(undone_op.clone(), |col| { for change in changes.into_iter().rev() { change.undo(col)?; } Ok(UndoOutput { undone_op, reverted_to, new_undo_status: col.undo_status(), counter, }) }); self.state.undo.mode = UndoMode::NormalOp; res } } impl From<&[UndoableChange]> for StateChanges { fn from(changes: &[UndoableChange]) -> Self { let mut out = StateChanges::default(); if !changes.is_empty() { out.mtime = true; } for change in changes { match change { UndoableChange::Card(_) => out.card = true, UndoableChange::Note(_) => out.note = true, UndoableChange::Deck(_) => out.deck = true, UndoableChange::Tag(_) => out.tag = true, UndoableChange::Revlog(_) => {} UndoableChange::Queue(_) => {} UndoableChange::Config(_) => out.config = true, UndoableChange::DeckConfig(_) => out.deck_config = true, UndoableChange::Collection(_) => {} UndoableChange::Notetype(_) => out.notetype = true, } } out } } #[cfg(test)] mod test { use super::UndoableChange; use crate::card::Card; use crate::prelude::*; #[test] fn undo() -> Result<()> { let mut col = Collection::new(); let mut card = Card { interval: 1, ..Default::default() }; col.add_card(&mut card).unwrap(); let cid = card.id; assert_eq!(col.can_undo(), None); assert_eq!(col.can_redo(), None); // outside of a transaction, no undo info recorded let card = col .get_and_update_card(cid, |card| { card.interval = 2; Ok(()) }) .unwrap(); assert_eq!(card.interval, 2); assert_eq!(col.can_undo(), None); assert_eq!(col.can_redo(), None); // record a few undo steps for i in 3..=4 { col.transact(Op::UpdateCard, |col| { col.get_and_update_card(cid, |card| { card.interval = i; Ok(()) }) .unwrap(); Ok(()) }) .unwrap(); } assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 4); assert_eq!(col.can_undo(), Some(&Op::UpdateCard)); assert_eq!(col.can_redo(), None); // undo a step col.undo().unwrap(); assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 3); assert_eq!(col.can_undo(), Some(&Op::UpdateCard)); assert_eq!(col.can_redo(), Some(&Op::UpdateCard)); // and again col.undo().unwrap(); assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 2); assert_eq!(col.can_undo(), None); assert_eq!(col.can_redo(), Some(&Op::UpdateCard)); // redo a step col.redo().unwrap(); assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 3); assert_eq!(col.can_undo(), Some(&Op::UpdateCard)); assert_eq!(col.can_redo(), Some(&Op::UpdateCard)); // and another col.redo().unwrap(); assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 4); assert_eq!(col.can_undo(), Some(&Op::UpdateCard)); assert_eq!(col.can_redo(), None); // and undo the redo col.undo().unwrap(); assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 3); assert_eq!(col.can_undo(), Some(&Op::UpdateCard)); assert_eq!(col.can_redo(), Some(&Op::UpdateCard)); // if any action is performed, it should clear the redo queue col.transact(Op::UpdateCard, |col| { col.get_and_update_card(cid, |card| { card.interval = 5; Ok(()) }) })?; assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 5); assert_eq!(col.can_undo(), Some(&Op::UpdateCard)); assert_eq!(col.can_redo(), None); // and any action that doesn't support undoing will clear both queues col.transact_no_undo(|_col| Ok(())).unwrap(); assert_eq!(col.can_undo(), None); assert_eq!(col.can_redo(), None); // if an object is mutated multiple times in one operation, // the changes should be undone in the correct order col.transact(Op::UpdateCard, |col| { col.get_and_update_card(cid, |card| { card.interval = 10; Ok(()) })?; col.get_and_update_card(cid, |card| { card.interval = 15; Ok(()) }) })?; assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 15); col.undo()?; assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 5); Ok(()) } #[test] fn custom() -> Result<()> { let mut col = Collection::new(); // perform some actions in separate steps let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); col.add_note(&mut note, DeckId(1))?; assert_eq!(col.undo_status().last_step, 1); let card = col.storage.all_cards_of_note(note.id)?.remove(0); col.transact(Op::UpdateCard, |col| { col.get_and_update_card(card.id, |card| { card.due = 10; Ok(()) }) })?; let restore_point = col.add_custom_undo_step("hello".to_string()); col.transact(Op::UpdateCard, |col| { col.get_and_update_card(card.id, |card| { card.due = 20; Ok(()) }) })?; col.transact(Op::UpdateCard, |col| { col.get_and_update_card(card.id, |card| { card.due = 30; Ok(()) }) })?; // dummy op name col.transact(Op::Bury, |col| col.set_current_notetype_id(NotetypeId(123)))?; // merge subsequent changes into our restore point let op = col.merge_undoable_ops(restore_point)?; assert!(op.changes.card); assert!(op.changes.config); // the last undo action should be at the end of the step list, // before the modtime bump assert!(matches!( col.state .undo .previous_op() .unwrap() .changes .iter() .rev() .nth(1) .unwrap(), UndoableChange::Config(_) )); // if we then undo, we'll be back to before step 3 assert_eq!(col.storage.get_card(card.id)?.unwrap().due, 30); col.undo()?; assert_eq!(col.storage.get_card(card.id)?.unwrap().due, 10); Ok(()) } #[test] fn undo_mtime_bump() -> Result<()> { let mut col = Collection::new(); col.storage.db.execute_batch("update col set mod = 0")?; // a no-op change should not bump mtime let out = col.set_config_bool(BoolKey::AddingDefaultsToCurrentDeck, true, true)?; assert_eq!( col.storage.get_collection_timestamps()?.collection_change.0, 0 ); assert!(!out.changes.had_change()); // if there is an undoable step, mtime should change let out = col.set_config_bool(BoolKey::AddingDefaultsToCurrentDeck, false, true)?; assert_ne!( col.storage.get_collection_timestamps()?.collection_change.0, 0 ); assert!(out.changes.had_change()); // when skipping undo, mtime should still only be bumped on a change col.storage.db.execute_batch("update col set mod = 0")?; let out = col.set_config_bool(BoolKey::AddingDefaultsToCurrentDeck, false, false)?; assert_eq!( col.storage.get_collection_timestamps()?.collection_change.0, 0 ); assert!(!out.changes.had_change()); // op output will reflect changes were made let out = col.set_config_bool(BoolKey::AddingDefaultsToCurrentDeck, true, false)?; assert_ne!( col.storage.get_collection_timestamps()?.collection_change.0, 0 ); assert!(out.changes.had_change()); Ok(()) } #[test] fn coalesce_note_undo_entries() -> Result<()> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); col.add_note(&mut note, DeckId(1))?; note.set_field(0, "foo")?; col.update_note(&mut note)?; note.set_field(0, "bar")?; col.update_note(&mut note)?; assert_eq!(col.state.undo.undo_steps.len(), 2); Ok(()) } } ================================================ FILE: rslib/src/version.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::env; use std::sync::LazyLock; pub fn version() -> &'static str { include_str!("../../.version").trim() } pub fn buildhash() -> &'static str { option_env!("BUILDHASH").unwrap_or("dev").trim() } pub(crate) fn sync_client_version() -> &'static str { static VER: LazyLock = LazyLock::new(|| { format!( "anki,{version} ({buildhash}),{platform}", version = version(), buildhash = buildhash(), platform = env::var("PLATFORM").unwrap_or_else(|_| env::consts::OS.to_string()) ) }); &VER } pub(crate) fn sync_client_version_short() -> &'static str { static VER: LazyLock = LazyLock::new(|| { format!( "{version},{buildhash},{platform}", version = version(), buildhash = buildhash(), platform = env::consts::OS ) }); &VER } ================================================ FILE: rslib/sync/Cargo.toml ================================================ [package] name = "anki-sync-server" version.workspace = true authors.workspace = true edition.workspace = true license.workspace = true publish = false rust-version.workspace = true description = "Standalone sync server" [[bin]] path = "main.rs" name = "anki-sync-server" [dependencies] [target.'cfg(windows)'.dependencies] anki = { workspace = true, features = ["native-tls"] } [target.'cfg(not(windows))'.dependencies] anki = { workspace = true, features = ["rustls"] } ================================================ FILE: rslib/sync/main.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::env; use std::process; use anki::log::set_global_logger; use anki::sync::http_server::SimpleServer; fn main() { if let Some(arg) = env::args().nth(1) { if arg == "--healthcheck" { run_health_check(); return; } } if env::var("RUST_LOG").is_err() { env::set_var("RUST_LOG", "anki=info") } set_global_logger(None).unwrap(); println!("{}", SimpleServer::run()); } fn run_health_check() { if SimpleServer::is_running() { process::exit(0); } else { process::exit(1); } } ================================================ FILE: run ================================================ #!/bin/bash set -e export PYTHONWARNINGS=default export PYTHONPYCACHEPREFIX=out/pycache # define these as blank before calling the script if you want to disable them export ANKIDEV=${ANKIDEV-1} export QTWEBENGINE_REMOTE_DEBUGGING=${QTWEBENGINE_REMOTE_DEBUGGING-8080} export QTWEBENGINE_CHROMIUM_FLAGS=${QTWEBENGINE_CHROMIUM_FLAGS---remote-allow-origins=http://localhost:$QTWEBENGINE_REMOTE_DEBUGGING} export PYENV=${PYENV-out/pyenv} # The pages can be accessed by, e.g. surfing to # http://localhost:40000/_anki/pages/deckconfig.html # Useful in conjunction with tools/web-watch for auto-rebuilding. export ANKI_API_PORT=${ANKI_API_PORT-40000} export ANKI_API_HOST=${ANKI_API_HOST-127.0.0.1} ./ninja pylib qt ${PYENV}/bin/python tools/run.py $* ================================================ FILE: run.bat ================================================ @echo off pushd "%~dp0" set PYTHONWARNINGS=default set PYTHONPYCACHEPREFIX=out\pycache set ANKIDEV=1 set QTWEBENGINE_REMOTE_DEBUGGING=8080 set QTWEBENGINE_CHROMIUM_FLAGS=--remote-allow-origins=http://localhost:8080 set ANKI_API_PORT=40000 set ANKI_API_HOST=127.0.0.1 @if not defined PYENV set PYENV=out\pyenv call tools\ninja pylib qt || exit /b 1 %PYENV%\Scripts\python tools\run.py %* || exit /b 1 popd ================================================ FILE: rust-toolchain.toml ================================================ [toolchain] # older versions may fail to compile; newer versions may fail the clippy tests channel = "1.92.0" ================================================ FILE: tools/build ================================================ #!/bin/bash set -eo pipefail rm -rf out/wheels/* RELEASE=2 ./ninja wheels (cd qt/release && ./build.sh) echo "wheels are in out/wheels" ================================================ FILE: tools/build-arm-lin ================================================ #!/bin/bash set -e # sudo apt install libc6-dev-arm64-cross gcc-aarch64-linux-gnu rustup target add aarch64-unknown-linux-gnu export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc export LIN_ARM64=1 RELEASE=2 ./ninja wheels:anki echo "wheels are in out/wheels" ================================================ FILE: tools/build-x64-mac ================================================ #!/bin/bash set -e rustup target add x86_64-apple-darwin export MAC_X86=1 RELEASE=2 ./ninja wheels:anki echo "wheels are in out/wheels" ================================================ FILE: tools/build.bat ================================================ @echo off pushd "%~dp0"\.. if exist out\wheels rmdir /s /q out\wheels set RELEASE=2 tools\ninja wheels || exit /b 1 echo wheels are in out/wheels popd ================================================ FILE: tools/clean ================================================ #!/bin/bash # # Remove most things from the build folder, to test a clean build. Keeps # the download folder, and optionally node_modules/pyenv. set -e shopt -s extglob if [ "$1" == "keep-env" ]; then rm -rf out/!(node_modules|pyenv|download) else rm -rf out/!(download) fi ================================================ FILE: tools/dmypy ================================================ #!/bin/bash # # Run mypy in daemon mode for fast checking ./ninja pylib qt MYPY_CACHE_DIR=out/tests/mypy out/pyenv/bin/dmypy run pylib/anki qt/aqt pylib/tests ================================================ FILE: tools/install-n2 ================================================ #!/bin/bash cargo install --git https://github.com/evmar/n2.git --rev 53ec691df749277104d1d4201a344fe4243d6d0a ================================================ FILE: tools/minilints/Cargo.toml ================================================ [package] name = "minilints" version.workspace = true authors.workspace = true edition.workspace = true license.workspace = true publish = false rust-version.workspace = true [dependencies] anki_io.workspace = true anki_process.workspace = true anyhow.workspace = true camino.workspace = true serde_json.workspace = true walkdir.workspace = true which.workspace = true ================================================ FILE: tools/minilints/src/main.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::cell::LazyCell; use std::collections::BTreeMap; use std::collections::HashSet; use std::env; use std::fs; use std::fs::File; use std::io::Read; use std::io::Write; use std::path::Path; use std::process::Command; use anki_io::read_to_string; use anki_io::write_file; use anki_process::CommandExt; use anyhow::Context; use anyhow::Result; use camino::Utf8Path; use walkdir::WalkDir; const NONSTANDARD_HEADER: &[&str] = &[ "./pylib/anki/_vendor/stringcase.py", "./pylib/anki/statsbg.py", "./qt/aqt/mpv.py", "./qt/aqt/winpaths.py", ]; const IGNORED_FOLDERS: &[&str] = &[ "./out", "./node_modules", "./qt/aqt/forms", "./tools/workspace-hack", "./target", ".mypy_cache", "./extra", "./ts/.svelte-kit", ]; fn main() -> Result<()> { let mut args = env::args(); let want_fix = args.nth(1) == Some("fix".to_string()); let stamp = args.next().unwrap(); let mut ctx = LintContext::new(want_fix); ctx.check_contributors()?; ctx.check_rust_licenses()?; ctx.walk_folders(Path::new("."))?; if ctx.found_problems { std::process::exit(1); } write_file(stamp, "")?; Ok(()) } struct LintContext { want_fix: bool, unstaged_changes: LazyCell<()>, found_problems: bool, nonstandard_headers: HashSet<&'static Utf8Path>, } impl LintContext { pub fn new(want_fix: bool) -> Self { Self { want_fix, unstaged_changes: LazyCell::new(check_for_unstaged_changes), found_problems: false, nonstandard_headers: NONSTANDARD_HEADER.iter().map(Utf8Path::new).collect(), } } pub fn walk_folders(&mut self, root: &Path) -> Result<()> { let ignored_folders: HashSet<_> = IGNORED_FOLDERS.iter().map(Utf8Path::new).collect(); let walker = WalkDir::new(root).into_iter(); for entry in walker.filter_entry(|e| { !ignored_folders.contains(&Utf8Path::from_path(e.path()).expect("utf8")) }) { let entry = entry.unwrap(); let path = Utf8Path::from_path(entry.path()).context("utf8")?; let exts: HashSet<_> = ["py", "ts", "rs", "svelte", "mjs"] .into_iter() .map(Some) .collect(); if exts.contains(&path.extension()) && !sveltekit_temp_file(path.as_str()) { self.check_copyright(path)?; self.check_triple_slash(path)?; } } Ok(()) } fn check_copyright(&mut self, path: &Utf8Path) -> Result<()> { if path.file_name().unwrap().ends_with(".d.ts") { return Ok(()); } let head = head_of_file(path)?; if head.is_empty() { return Ok(()); } if self.nonstandard_headers.contains(&path) { return Ok(()); } let missing = !head.contains("Ankitects Pty Ltd and contributors"); if missing { if self.want_fix { LazyCell::force(&self.unstaged_changes); fix_copyright(path)?; } else { println!("missing standard copyright header: {path:?}"); self.found_problems = true; } } Ok(()) } fn check_triple_slash(&mut self, path: &Utf8Path) -> Result<()> { if !matches!(path.extension(), Some("ts") | Some("svelte")) { return Ok(()); } for line in fs::read_to_string(path)?.lines() { if line.contains("///") && !line.contains("/// Result<()> { let antispam = ", at the domain "; let last_author = String::from_utf8( Command::new("git") .args(["log", "-1", "--pretty=format:%ae"]) .output()? .stdout, )?; if last_author == "49699333+dependabot[bot]@users.noreply.github.com" { println!("Dependabot whitelisted."); std::process::exit(0); } if let Ok(bypass) = std::env::var("CONTRIBUTORS_BYPASS_EMAILS") { if bypass.split(',').any(|e| e.trim() == last_author) { println!("Author allowlisted via CONTRIBUTORS_BYPASS_EMAILS."); return Ok(()); } } // Parse identifiers from the CONTRIBUTORS file instead of relying // on git history, which requires a full clone. Entries may contain an // email (user@example.com) or a GitHub profile URL (github.com/user). let contents = fs::read_to_string("CONTRIBUTORS")?; let all_contributors: HashSet<&str> = contents .lines() .filter_map(|line| { let start = line.find('<')?; let end = line.find('>')?; Some(&line[start + 1..end]) }) .collect(); if all_contributors.contains(last_author.as_str()) { return Ok(()); } // Match GitHub noreply emails (ID+user@users.noreply.github.com) // against CONTRIBUTORS entries like github.com/user or // https://github.com/user. if let Some(username) = last_author .strip_suffix("@users.noreply.github.com") .and_then(|s| s.rsplit_once('+')) .map(|(_, user)| user) { let gh_entry = format!("github.com/{username}"); if all_contributors.iter().any(|c| { let normalized = c .trim_end_matches('/') .trim_start_matches("https://") .trim_start_matches("http://"); normalized.eq_ignore_ascii_case(&gh_entry) }) { return Ok(()); } } println!("All contributors:"); println!("{}", { let mut contribs: Vec<_> = all_contributors .iter() .map(|s| s.replace('@', antispam)) .collect(); contribs.sort(); contribs.join("\n") }); println!( "Author {} NOT found in list", last_author.replace('@', antispam) ); println!( "\nPlease make sure you modify the CONTRIBUTORS file using the email address you \ are committing from. If you have GitHub configured to hide your email address, \ you may need to make a change to the CONTRIBUTORS file using the GitHub UI, \ then try again." ); std::process::exit(1); } fn check_rust_licenses(&mut self) -> Result<()> { let license_path = Path::new("cargo/licenses.json"); let licenses = generate_licences()?; let existing_licenses = read_to_string(license_path)?; if licenses != existing_licenses { if self.want_fix { check_cargo_deny()?; write_file(license_path, licenses)?; } else { println!("cargo/licenses.json is out of date; run ./ninja fix:minilints"); self.found_problems = true; } } Ok(()) } } /// Annoyingly, sveltekit writes temp files into ts/ folder when it's running. fn sveltekit_temp_file(path: &str) -> bool { path.contains("vite.config.ts.timestamp") } fn check_cargo_deny() -> Result<()> { // Used by `fix:minilints` locally. CI uses EmbarkStudios/cargo-deny-action. Command::run("cargo install cargo-deny@0.19.0")?; Command::run("cargo deny check")?; Ok(()) } fn head_of_file(path: &Utf8Path) -> Result { let mut file = File::open(path)?; let mut buffer = vec![0; 256]; let size = file.read(&mut buffer)?; buffer.truncate(size); Ok(String::from_utf8(buffer).unwrap_or_default()) } fn fix_copyright(path: &Utf8Path) -> Result<()> { let header = match path.extension().unwrap() { "py" => { r#"# Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html "# } "ts" | "rs" | "mjs" => { r#"// Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html "# } "svelte" => { r#" "# } _ => unreachable!(), }; let data = fs::read_to_string(path).with_context(|| format!("reading {path}"))?; let mut file = fs::OpenOptions::new() .write(true) .open(path) .with_context(|| format!("opening {path}"))?; write!(file, "{header}{data}").with_context(|| format!("writing {path}"))?; Ok(()) } fn check_for_unstaged_changes() { let output = Command::new("git").arg("diff").output().unwrap(); if !output.stdout.is_empty() { println!("stage any changes first"); std::process::exit(1); } } fn generate_licences() -> Result { Command::run("cargo install cargo-license@0.7.0")?; let output = Command::run_with_output([ "cargo-license", "--features", "rustls", "--features", "native-tls", "--json", "--manifest-path", "rslib/Cargo.toml", ])?; let licenses: Vec> = serde_json::from_str(&output.stdout)?; let filtered: Vec> = licenses .into_iter() .map(|mut entry| { entry.remove("version"); entry }) .collect(); Ok(serde_json::to_string_pretty(&filtered)?) } ================================================ FILE: tools/ninja.bat ================================================ @echo off set CARGO_TARGET_DIR=%~dp0..\out\rust REM separate build+run steps so build env doesn't leak into subprocesses cargo build -p runner --release || exit /b 1 out\rust\release\runner build %* || exit /b 1 ================================================ FILE: tools/profile ================================================ #!/bin/bash ANKI_PROFILE_CODE=1 ./run out/pyenv/bin/pip install snakeviz out/pyenv/bin/snakeviz out/anki.prof ================================================ FILE: tools/publish ================================================ #!/bin/bash set -e shopt -s extglob #export UV_PUBLISH_TOKEN=$(pass show w/pypi-api-test) #out/extracted/uv/uv publish --index testpypi out/wheels/* export UV_PUBLISH_TOKEN=$(pass show w/pypi-api) # Upload all wheels except anki_release*.whl first out/extracted/uv/uv publish out/wheels/!(anki_release*).whl # Then upload anki_release*.whl out/extracted/uv/uv publish out/wheels/anki_release*.whl ================================================ FILE: tools/rebuild-web ================================================ #!/bin/bash # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # Manually trigger a rebuild and reload of Anki's web stack # NOTE: This script needs to be run from the project root set -e ./ninja qt ./out/pyenv/bin/python tools/reload_webviews.py ================================================ FILE: tools/reload_webviews.py ================================================ #!/usr/bin/env python # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """ Trigger a reload of Anki's web views using QtWebEngine' Chromium Remote Debugging interface. """ import argparse import sys import PyChromeDevTools # type: ignore[import] DEFAULT_HOST = "localhost" DEFAULT_PORT = 8080 def print_error(message: str): print(f"Error: {message}", file=sys.stderr) parser = argparse.ArgumentParser("reload_webviews") parser.add_argument( "--host", help=f"Host via which the Chrome session can be reached, e.g. {DEFAULT_HOST}", type=str, default=DEFAULT_HOST, required=False, ) parser.add_argument( "--port", help=f"Port via which the Chrome session can be reached, e.g. {DEFAULT_PORT}", type=str, default=DEFAULT_PORT, required=False, ) args = parser.parse_args() try: chrome = PyChromeDevTools.ChromeInterface(host=args.host, port=args.port) except Exception as e: print_error( f"Could not establish connection to Chromium remote debugger. Is Anki Open? Exception:\n{e}" ) sys.exit(1) if chrome.tabs is None: print_error("Was unable to get active web views.") sys.exit(1) for tab_index, tab_data in enumerate(chrome.tabs): print(f"Reloading page: {tab_data['title']}") chrome.connect(tab=tab_index, update_tabs=False) chrome.Page.reload() ================================================ FILE: tools/run-qt6.6 ================================================ #!/bin/bash set -e ./ninja extract:uv export PYENV=./out/pyenv66 UV_PROJECT_ENVIRONMENT=$PYENV ./out/extracted/uv/uv sync --all-packages --extra qt66 ./run $* ================================================ FILE: tools/run-qt6.7 ================================================ #!/bin/bash set -e ./ninja extract:uv export PYENV=./out/pyenv67 UV_PROJECT_ENVIRONMENT=$PYENV ./out/extracted/uv/uv sync --all-packages --extra qt67 ./run $* ================================================ FILE: tools/run-qt6.8 ================================================ #!/bin/bash set -e ./ninja extract:uv export PYENV=./out/pyenv68 UV_PROJECT_ENVIRONMENT=$PYENV ./out/extracted/uv/uv sync --all-packages --extra qt68 ./run $* ================================================ FILE: tools/run.py ================================================ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import os import sys sys.path.extend(["pylib", "qt", "out/pylib", "out/qt"]) import aqt if not os.environ.get("SKIP_RUN"): aqt.run() ================================================ FILE: tools/runopt ================================================ #!/bin/bash set -e RELEASE=1 $(dirname $0)/../run $* ================================================ FILE: tools/unused-rust-deps ================================================ #!/bin/bash cargo install cargo-udeps@0.1.40 cargo +nightly-2023-01-24-x86_64-unknown-linux-gnu udeps --all-targets ================================================ FILE: tools/update-launcher-env ================================================ #!/bin/bash # # Install our latest anki/aqt code into the launcher venv set -e rm -rf out/wheels ./ninja wheels if [[ "$OSTYPE" == "darwin"* ]]; then export VIRTUAL_ENV=$HOME/Library/Application\ Support/AnkiProgramFiles/.venv else export VIRTUAL_ENV=$HOME/.local/share/AnkiProgramFiles/.venv fi ./out/extracted/uv/uv pip install out/wheels/* ================================================ FILE: tools/update-launcher-env.bat ================================================ @echo off rem rem Install our latest anki/aqt code into the launcher venv rmdir /s /q out\wheels 2>nul call tools\ninja wheels set VIRTUAL_ENV=%LOCALAPPDATA%\AnkiProgramFiles\.venv for %%f in (out\wheels\*.whl) do out\extracted\uv\uv pip install "%%f" ================================================ FILE: tools/web-watch ================================================ #!/bin/bash # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # Monitor all web-related folders and rebuild and reload Anki's web stack # when a change is detected. set -e MONITORED_FOLDERS=("ts/" "sass/" "qt/aqt/data/web/") MONITORED_EVENTS=("Created" "Updated" "Removed") on_change_detected="clear; ./tools/rebuild-web; echo Rebuilt at $(date +%H:%M:%S)" event_args="" for event in "${MONITORED_EVENTS[@]}"; do event_args+="--event ${event} " done bash -c "$on_change_detected" # poll_monitor comes with a slight performance penalty, but seems to more # reliably identify file system events across both macOS and Linux fswatch -r -o -m poll_monitor ${event_args[@]} \ "${MONITORED_FOLDERS[@]}" | xargs -I{} bash -c "$on_change_detected" ================================================ FILE: ts/.gitignore ================================================ node_modules yarn-error.log ================================================ FILE: ts/README.md ================================================ Anki's TypeScript and Sass dependencies. Some TS/JS code is also stored separately in ../qt/aqt/data/web/. To update all dependencies: ./update.sh To add a new dev dependency, use something like: ./add.sh -D @rollup/plugin-alias ================================================ FILE: ts/bundle_svelte.mjs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { build } from "esbuild"; import { sassPlugin } from "esbuild-sass-plugin"; import sveltePlugin from "esbuild-svelte"; import { readFileSync, writeFileSync } from "fs"; import { basename } from "path"; import { argv, env } from "process"; import sveltePreprocess from "svelte-preprocess"; import { typescript } from "svelte-preprocess-esbuild"; const [_tsx, _script, entrypoint, bundle_js, bundle_css, page_html] = argv; if (page_html != null) { const template = readFileSync("ts/page.html", { encoding: "utf8" }); writeFileSync(page_html, template.replace(/{PAGE}/g, basename(page_html, ".html"))); } // support Qt 5.14 const target = ["es2020", "chrome77"]; const inlineCss = bundle_css == null; const sourcemap = env.SOURCEMAP && true; let sveltePlugins; if (!sourcemap) { sveltePlugins = [ // use esbuild for faster typescript transpilation typescript({ target, define: { "process.browser": "true", }, tsconfig: "ts/tsconfig_legacy.json", }), sveltePreprocess({ typescript: false }), ]; } else { sveltePlugins = [ // use tsc for more accurate sourcemaps sveltePreprocess({ typescript: true, sourceMap: true }), ]; } build({ bundle: true, entryPoints: [entrypoint], globalName: "anki", outfile: bundle_js, minify: env.RELEASE && true, loader: { ".svg": "text" }, preserveSymlinks: true, sourcemap: sourcemap ? "inline" : false, plugins: [ sassPlugin({ loadPaths: ["node_modules"] }), sveltePlugin({ compilerOptions: { css: inlineCss ? "injected" : "external" }, preprocess: sveltePlugins, // let us focus on errors; we can see the warnings with svelte-check filterWarnings: (_warning) => false, }), ], target, // logLevel: "info", }).catch(() => process.exit(1)); ================================================ FILE: ts/bundle_ts.mjs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { build } from "esbuild"; import { argv, env } from "process"; const [_node, _script, entrypoint, bundle_js] = argv; // support Qt 5.14 const target = ["es6", "chrome77"]; build({ bundle: true, entryPoints: [entrypoint], outfile: bundle_js, minify: env.RELEASE && true, sourcemap: env.SOURCEMAP ? "inline" : false, preserveSymlinks: true, target, }).catch(() => process.exit(1)); ================================================ FILE: ts/editable/ContentEditable.svelte ================================================ ================================================ FILE: ts/editable/Mathjax.svelte ================================================ Mathjax ================================================ FILE: ts/editable/change-timer.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export class ChangeTimer { private value: number | null = null; private action: (() => void) | null = null; constructor() { this.fireImmediately = this.fireImmediately.bind(this); } schedule(action: () => void, delay: number): void { this.clear(); this.action = action; this.value = setTimeout(this.fireImmediately, delay) as any; } clear(): void { if (this.value) { clearTimeout(this.value); this.value = null; } } fireImmediately(): void { if (this.action) { this.action(); this.action = null; } this.clear(); } } ================================================ FILE: ts/editable/content-editable.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { bridgeCommand } from "@tslib/bridgecommand"; import { getSelection } from "@tslib/cross-browser"; import { on, preventDefault } from "@tslib/events"; import { isApplePlatform } from "@tslib/platform"; import { registerShortcut } from "@tslib/shortcuts"; import type { Callback } from "@tslib/typing"; import type { SelectionLocation } from "$lib/domlib/location"; import { restoreSelection, saveSelection } from "$lib/domlib/location"; import { placeCaretAfterContent } from "$lib/domlib/place-caret"; import { HandlerList } from "$lib/sveltelib/handler-list"; /** * Workaround: If you try to invoke an IME after calling * `placeCaretAfterContent` on a cE element, the IME will immediately * end and the input character will be duplicated */ function safePlaceCaretAfterContent(editable: HTMLElement): void { placeCaretAfterContent(editable); restoreSelection(editable, saveSelection(editable)!); } function restoreCaret(element: HTMLElement, location: SelectionLocation | null): void { if (!location) { return safePlaceCaretAfterContent(element); } try { restoreSelection(element, location); } catch { safePlaceCaretAfterContent(element); } } type SetupFocusHandlerAction = (element: HTMLElement) => { destroy(): void }; export interface FocusHandlerAPI { /** * Prevent the automatic caret restoration, that happens upon field focus */ flushCaret(): void; /** * Executed upon focus event of editable. */ focus: HandlerList<{ event: FocusEvent }>; /** * Executed upon blur event of editable. */ blur: HandlerList<{ event: FocusEvent }>; } export function useFocusHandler(): [FocusHandlerAPI, SetupFocusHandlerAction] { let latestLocation: SelectionLocation | null = null; let offFocus: Callback | null; let offPointerDown: Callback | null; let flush = false; function flushCaret(): void { flush = true; } const focus = new HandlerList<{ event: FocusEvent }>(); const blur = new HandlerList<{ event: FocusEvent }>(); function prepareFocusHandling( editable: HTMLElement, location: SelectionLocation | null = null, ): void { latestLocation = location; offFocus?.(); offFocus = on( editable, "focus", (event: FocusEvent): void => { if (flush) { flush = false; } else { restoreCaret(event.currentTarget as HTMLElement, latestLocation); } focus.dispatch({ event }); }, { once: true }, ); offPointerDown?.(); offPointerDown = on( editable, "pointerdown", () => { offFocus?.(); offFocus = null; }, { once: true }, ); } /** * Must execute before DOMMirror. */ function onBlur(this: HTMLElement, event: FocusEvent): void { prepareFocusHandling(this, saveSelection(this)); blur.dispatch({ event }); } function setupFocusHandler(editable: HTMLElement): { destroy(): void } { prepareFocusHandling(editable); const off = on(editable, "blur", onBlur); return { destroy() { off(); offFocus?.(); offPointerDown?.(); }, }; } return [ { flushCaret, focus, blur, }, setupFocusHandler, ]; } if (isApplePlatform()) { registerShortcut(() => bridgeCommand("paste"), "Control+Shift+V"); } export function preventBuiltinShortcuts(editable: HTMLElement): void { for (const keyCombination of ["Control+B", "Control+U", "Control+I"]) { registerShortcut(preventDefault, keyCombination, { target: editable }); } } declare global { interface Selection { modify(s: string, t: string, u: string): void; } } // Fix inverted Ctrl+right/left handling in RTL fields export function fixRTLKeyboardNav(editable: HTMLElement): void { editable.addEventListener("keydown", (evt: KeyboardEvent) => { if (window.getComputedStyle(editable).direction === "rtl") { const selection = getSelection(editable)!; let granularity = "character"; let alter = "move"; if (evt.ctrlKey) { granularity = "word"; } if (evt.shiftKey) { alter = "extend"; } if (evt.code === "ArrowRight") { selection.modify(alter, "right", granularity); evt.preventDefault(); return; } else if (evt.code === "ArrowLeft") { selection.modify(alter, "left", granularity); evt.preventDefault(); return; } } }); } /** API */ export interface ContentEditableAPI { /** * Can be used to turn off the caret restoring functionality of * the ContentEditable. Can be used when you want to set the caret * yourself. */ focusHandler: FocusHandlerAPI; } ================================================ FILE: ts/editable/cooldown-timer.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export class CooldownTimer { private executing = false; private queuedAction: (() => void) | null = null; private delay: number; constructor(delayMs: number) { this.delay = delayMs; } schedule(action: () => void): void { if (this.executing) { this.queuedAction = action; } else { this.executing = true; action(); setTimeout(this.#pop.bind(this), this.delay); } } #pop(): void { this.executing = false; if (this.queuedAction) { const action = this.queuedAction; this.queuedAction = null; this.schedule(action); } } } ================================================ FILE: ts/editable/decorated.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /** * decorated elements know three states: * - stored, which is stored in the DB, e.g. `\(\alpha + \beta\)` * - undecorated, which is displayed to the user in Codable, e.g. `\alpha + \beta` * - decorated, which is displayed to the user in Editable, e.g. `` */ export interface DecoratedElement extends HTMLElement { /** * Transforms itself from undecorated to decorated state. * Should be called in connectedCallback. */ decorate(): void; /** * Transforms itself from decorated to undecorated state. */ undecorate(): void; } interface WithTagName { tagName: string; } export interface DecoratedElementConstructor extends CustomElementConstructor, WithTagName { prototype: DecoratedElement; /** * Transforms elements in input HTML from undecorated to stored state. */ toStored(undecorated: string): string; /** * Transforms elements in input HTML from stored to undecorated state. */ toUndecorated(stored: string): string; } export class CustomElementArray extends Array { push(...elements: DecoratedElementConstructor[]): number { for (const element of elements) { customElements.define(element.tagName, element); } return super.push(...elements); } /** * Transforms any decorated elements in input HTML from undecorated to stored state. */ toStored(html: string): string { let result = html; for (const element of this) { result = element.toStored(result); } return result; } /** * Transforms any decorated elements in input HTML from stored to undecorated state. */ toUndecorated(html: string): string { let result = html; for (const element of this) { result = element.toUndecorated(result); } return result; } } ================================================ FILE: ts/editable/editable-base.scss ================================================ @use "../lib/sass/scrollbar"; * { max-width: 100%; } p { margin-top: 0; margin-bottom: 1rem; &:empty::after { content: "\a"; white-space: pre; } } [hidden] { display: none; } :host(body), :host(body) * { @include scrollbar.custom; } pre { white-space: pre-wrap; } // image size constraints img:not(.mathjax) { &:not([data-editor-shrink="false"]) { :host-context(.shrink-image) & { max-width: var(--editor-default-max-width); max-height: var(--editor-default-max-height); // prevent inline width/height from skewing aspect ratio width: unset; height: unset; } } &[data-editor-shrink="true"] { max-width: var(--editor-shrink-max-width); max-height: var(--editor-shrink-max-height); } } ================================================ FILE: ts/editable/frame-element.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { getSelection, isSelectionCollapsed } from "@tslib/cross-browser"; import { elementIsBlock, hasBlockAttribute, nodeIsElement, nodeIsText } from "@tslib/dom"; import { on } from "@tslib/events"; import { moveChildOutOfElement } from "$lib/domlib/move-nodes"; import { placeCaretAfter, placeCaretBefore } from "$lib/domlib/place-caret"; import type { FrameHandle } from "./frame-handle"; import { checkHandles, frameElementTagName, FrameEnd, FrameStart, isFrameHandle } from "./frame-handle"; function restoreFrameHandles(mutations: MutationRecord[]): void { let referenceNode: Node | null = null; for (const mutation of mutations) { const frameElement = mutation.target as FrameElement; const framed = frameElement.querySelector(frameElement.frames!) as HTMLElement; if (!framed) { frameElement.remove(); continue; } for (const node of mutation.addedNodes) { if (node === framed || isFrameHandle(node)) { continue; } // In some rare cases, nodes might be inserted into the frame itself. // For example after using execCommand. const placement = framed.compareDocumentPosition(node); if (placement & Node.DOCUMENT_POSITION_PRECEDING) { referenceNode = moveChildOutOfElement( frameElement, node, "beforebegin", ); } else if (placement & Node.DOCUMENT_POSITION_FOLLOWING) { referenceNode = moveChildOutOfElement(frameElement, node, "afterend"); } } for (const node of mutation.removedNodes) { if (!isFrameHandle(node)) { continue; } if ( /* avoid triggering when (un)mounting whole frame */ mutations.length === 1 && !node.partiallySelected ) { // Similar to a "movein", this could be considered a // "deletein" event and could get some special treatment, e.g. // first highlight the entire frame-element. frameElement.remove(); continue; } if (frameElement.isConnected) { frameElement.refreshHandles(); continue; } } } if (referenceNode) { placeCaretAfter(referenceNode); } } const frameObserver = new MutationObserver(restoreFrameHandles); const frameElements = new Set(); export class FrameElement extends HTMLElement { static tagName = frameElementTagName; static get observedAttributes(): string[] { return ["data-frames", "block"]; } get framedElement(): HTMLElement | null { return this.frames ? this.querySelector(this.frames) : null; } frames?: string; block: boolean; handleStart?: FrameStart; handleEnd?: FrameEnd; constructor() { super(); this.block = hasBlockAttribute(this); frameObserver.observe(this, { childList: true }); } attributeChangedCallback(name: string, old: string, newValue: string): void { if (newValue === old) { return; } switch (name) { case "data-frames": this.frames = newValue; if (!this.framedElement) { this.remove(); return; } break; case "block": this.block = newValue !== "false"; this.refreshHandles(); break; } } getHandleFrom(node: Element | null, start: boolean): FrameHandle { const handle = isFrameHandle(node) ? node : (document.createElement( start ? FrameStart.tagName : FrameEnd.tagName, ) as FrameHandle); handle.dataset.frames = this.frames; return handle; } refreshHandles(): void { customElements.upgrade(this); this.handleStart = this.getHandleFrom(this.firstElementChild, true); this.handleEnd = this.getHandleFrom(this.lastElementChild, false); if (!this.handleStart.isConnected) { this.prepend(this.handleStart); } if (!this.handleEnd.isConnected) { this.append(this.handleEnd); } } removeStart?: () => void; removeEnd?: () => void; addEventListeners(): void { this.removeStart = on( this, "moveinstart" as keyof HTMLElementEventMap, () => this.framedElement?.dispatchEvent(new Event("moveinstart")), ); this.removeEnd = on( this, "moveinend" as keyof HTMLElementEventMap, () => this.framedElement?.dispatchEvent(new Event("moveinend")), ); } removeEventListeners(): void { this.removeStart?.(); this.removeStart = undefined; this.removeEnd?.(); this.removeEnd = undefined; } connectedCallback(): void { frameElements.add(this); this.addEventListeners(); } disconnectedCallback(): void { frameElements.delete(this); this.removeEventListeners(); } insertLineBreak(offset: number): void { const lineBreak = document.createElement("br"); if (offset === 0) { const previous = this.previousSibling; const focus = previous && (nodeIsText(previous) || (nodeIsElement(previous) && !elementIsBlock(previous))) ? previous : this.insertAdjacentElement( "beforebegin", document.createElement("br"), ); placeCaretAfter(focus ?? this); } else if (offset === 1) { const next = this.nextSibling; const focus = next && (nodeIsText(next) || (nodeIsElement(next) && !elementIsBlock(next))) ? next : this.insertAdjacentElement("afterend", lineBreak); placeCaretBefore(focus ?? this); } } } function checkIfInsertingLineBreakAdjacentToBlockFrame() { for (const frame of frameElements) { if (!frame.block) { continue; } const selection = getSelection(frame)!; if ( selection.anchorNode === frame.framedElement && isSelectionCollapsed(selection) ) { frame.insertLineBreak(selection.anchorOffset); } } } function onSelectionChange() { checkHandles(); checkIfInsertingLineBreakAdjacentToBlockFrame(); } document.addEventListener("selectionchange", onSelectionChange); /** * This function wraps an element into a "frame", which looks like this: * * * * * */ export function frameElement(element: HTMLElement, block: boolean): FrameElement { const frame = document.createElement(FrameElement.tagName) as FrameElement; frame.setAttribute("block", String(block)); frame.dataset.frames = element.tagName.toLowerCase(); const range = new Range(); range.selectNode(element); range.surroundContents(frame); return frame; } ================================================ FILE: ts/editable/frame-handle.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { getSelection, isSelectionCollapsed } from "@tslib/cross-browser"; import { elementIsEmpty, nodeIsElement, nodeIsText } from "@tslib/dom"; import { on } from "@tslib/events"; import type { Unsubscriber } from "svelte/store"; import { get } from "svelte/store"; import { moveChildOutOfElement } from "$lib/domlib/move-nodes"; import { placeCaretAfter } from "$lib/domlib/place-caret"; import { isComposing } from "$lib/sveltelib/composition"; import type { FrameElement } from "./frame-element"; /** * The frame handle also needs some awareness that it's hosted below * the frame */ export const frameElementTagName = "anki-frame"; /** * I originally used a zero width space, however, in contentEditable, if * a line ends in a zero width space, and you click _after_ the line, * the caret will be placed _before_ the zero width space. * Instead I use a hairline space. */ const spaceCharacter = "\u200a"; const spaceRegex = /[\u200a]/g; export function isFrameHandle(node: unknown): node is FrameHandle { return node instanceof FrameHandle; } function skippableNode(handleElement: FrameHandle, node: Node): boolean { /** * We only want to move nodes, which are direct descendants of the FrameHandle * MutationRecords however might include nodes which were directly removed again */ return ( (nodeIsText(node) && (node.data === spaceCharacter || node.data.length === 0)) || !Array.prototype.includes.call(handleElement.childNodes, node) ); } function restoreHandleContent(mutations: MutationRecord[]): void { let referenceNode: Node | null = null; for (const mutation of mutations) { const target = mutation.target; if (mutation.type === "childList") { if (!isFrameHandle(target)) { /* nested insertion */ continue; } const handleElement = target; const frameElement = handleElement.parentElement as FrameElement; for (const node of mutation.addedNodes) { if (skippableNode(handleElement, node)) { continue; } if ( nodeIsElement(node) && !elementIsEmpty(node) && (node.textContent === spaceCharacter || node.textContent?.length === 0) ) { /** * When we surround the spaceCharacter of the frame handle */ node.replaceWith(new Text(spaceCharacter)); } else { referenceNode = moveChildOutOfElement( frameElement, node, handleElement.placement, ); } } } else if (mutation.type === "characterData") { if ( !nodeIsText(target) || !isFrameHandle(target.parentElement) || skippableNode(target.parentElement, target) || target.parentElement.unsubscribe ) { continue; } if (get(isComposing)) { target.parentElement.subscribeToCompositionEvent(); continue; } referenceNode = target.parentElement.moveTextOutOfFrame(target.data); } } if (referenceNode) { placeCaretAfter(referenceNode); } } const handleObserver = new MutationObserver(restoreHandleContent); const handles: Set = new Set(); type Placement = Extract; export abstract class FrameHandle extends HTMLElement { static get observedAttributes(): string[] { return ["data-frames"]; } /** * When a deletion is trigger with a FrameHandle selected, it will be treated * differently depending on whether it is selected: * - If partially selected, it should be restored (unless the frame element * is also selected). * - Otherwise, it should be deleted along with the frame element. */ partiallySelected = false; frames?: string; abstract placement: Placement; unsubscribe: Unsubscriber | null; constructor() { super(); handleObserver.observe(this, { childList: true, subtree: true, characterData: true, }); this.unsubscribe = null; } attributeChangedCallback(name: string, old: string, newValue: string): void { if (newValue === old) { return; } switch (name) { case "data-frames": this.frames = newValue; break; } } abstract getFrameRange(): Range; invalidSpace(): boolean { return ( !this.firstChild || !(nodeIsText(this.firstChild) && this.firstChild.data === spaceCharacter) ); } refreshSpace(): void { while (this.firstChild) { this.removeChild(this.firstChild); } this.append(new Text(spaceCharacter)); } hostedUnderFrame(): boolean { return this.parentElement!.tagName === frameElementTagName.toUpperCase(); } connectedCallback(): void { if (this.invalidSpace()) { this.refreshSpace(); } if (!this.hostedUnderFrame()) { const range = this.getFrameRange(); const frameElement = document.createElement( frameElementTagName, ) as FrameElement; frameElement.dataset.frames = this.frames; range.surroundContents(frameElement); } handles.add(this); } removeMoveIn?: () => void; disconnectedCallback(): void { handles.delete(this); this.removeMoveIn?.(); this.removeMoveIn = undefined; this.unsubscribeToCompositionEvent(); } abstract notifyMoveIn(offset: number): void; moveTextOutOfFrame(data: string): Text { const frameElement = this.parentElement! as FrameElement; const cleaned = data.replace(spaceRegex, ""); const text = new Text(cleaned); if (this.placement === "beforebegin") { frameElement.before(text); } else if (this.placement === "afterend") { frameElement.after(text); } this.refreshSpace(); return text; } /** * https://github.com/ankitects/anki/issues/2251 * * Work around the issue by not moving the input string while an IME session * is active, and moving the final output from IME only after the session ends. */ subscribeToCompositionEvent(): void { this.unsubscribe = isComposing.subscribe((composing) => { if (!composing) { if (this.firstChild && nodeIsText(this.firstChild)) { placeCaretAfter(this.moveTextOutOfFrame(this.firstChild.data)); } this.unsubscribeToCompositionEvent(); } }); } unsubscribeToCompositionEvent(): void { this.unsubscribe?.(); this.unsubscribe = null; } } export class FrameStart extends FrameHandle { static tagName = "frame-start"; placement: Placement; constructor() { super(); this.placement = "beforebegin"; } getFrameRange(): Range { const range = new Range(); range.setStartBefore(this); const maybeFramed = this.nextElementSibling; if (maybeFramed?.matches(this.frames ?? ":not(*)")) { const maybeHandleEnd = maybeFramed.nextElementSibling; range.setEndAfter( maybeHandleEnd?.tagName.toLowerCase() === FrameStart.tagName ? maybeHandleEnd : maybeFramed, ); } else { range.setEndAfter(this); } return range; } notifyMoveIn(offset: number): void { if (offset === 1) { this.dispatchEvent(new Event("movein")); } } connectedCallback(): void { super.connectedCallback(); this.removeMoveIn = on( this, "movein" as keyof HTMLElementEventMap, () => this.parentElement?.dispatchEvent(new Event("moveinstart")), ); } } export class FrameEnd extends FrameHandle { static tagName = "frame-end"; placement: Placement; constructor() { super(); this.placement = "afterend"; } getFrameRange(): Range { const range = new Range(); range.setEndAfter(this); const maybeFramed = this.previousElementSibling; if (maybeFramed?.matches(this.frames ?? ":not(*)")) { const maybeHandleStart = maybeFramed.previousElementSibling; range.setEndAfter( maybeHandleStart?.tagName.toLowerCase() === FrameEnd.tagName ? maybeHandleStart : maybeFramed, ); } else { range.setStartBefore(this); } return range; } notifyMoveIn(offset: number): void { if (offset === 0) { this.dispatchEvent(new Event("movein")); } } connectedCallback(): void { super.connectedCallback(); this.removeMoveIn = on( this, "movein" as keyof HTMLElementEventMap, () => this.parentElement?.dispatchEvent(new Event("moveinend")), ); } } function checkWhetherMovingIntoHandle(selection: Selection, handle: FrameHandle): void { if (selection.anchorNode === handle.firstChild && isSelectionCollapsed(selection)) { handle.notifyMoveIn(selection.anchorOffset); } } function checkWhetherSelectingHandle(selection: Selection, handle: FrameHandle): void { handle.partiallySelected = handle.firstChild && !isSelectionCollapsed(selection) ? selection.containsNode(handle.firstChild) : false; } export function checkHandles(): void { for (const handle of handles) { const selection = getSelection(handle)!; if (selection.rangeCount === 0) { continue; } checkWhetherMovingIntoHandle(selection, handle); checkWhetherSelectingHandle(selection, handle); } } ================================================ FILE: ts/editable/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import "./editable-base.scss"; /* only imported for the CSS */ import "./ContentEditable.svelte"; import "./Mathjax.svelte"; ================================================ FILE: ts/editable/mathjax-element.svelte.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { on } from "@tslib/events"; import { placeCaretAfter, placeCaretBefore } from "$lib/domlib/place-caret"; import { mount, tick } from "svelte"; import type { DecoratedElement, DecoratedElementConstructor } from "./decorated"; import { FrameElement, frameElement } from "./frame-element"; import Mathjax_svelte from "./Mathjax.svelte"; const mathjaxTagPattern = /]*?block="(.*?)")?[^>]*?>(.*?)<\/anki-mathjax>/gsu; const mathjaxBlockDelimiterPattern = /\\\[(.*?)\\\]/gsu; const mathjaxInlineDelimiterPattern = /\\\((.*?)\\\)/gsu; function trimBreaks(text: string): string { return text .replace(//gsu, "\n") .replace(/^\n*/, "") .replace(/\n*$/, ""); } export const mathjaxConfig = { enabled: true, }; interface MathjaxProps { mathjax: string; block: boolean; fontSize: number; } export const Mathjax: DecoratedElementConstructor = class Mathjax extends HTMLElement implements DecoratedElement { static tagName = "anki-mathjax"; static toStored(undecorated: string): string { const stored = undecorated.replace( mathjaxTagPattern, (_match: string, block: string | undefined, text: string) => { const trimmed = trimBreaks(text); return typeof block === "string" && block !== "false" ? `\\[${trimmed}\\]` : `\\(${trimmed}\\)`; }, ); return stored; } static toUndecorated(stored: string): string { if (!mathjaxConfig.enabled) { return stored; } return stored .replace(mathjaxBlockDelimiterPattern, (_match: string, text: string) => { const trimmed = trimBreaks(text); return `<${Mathjax.tagName} block="true">${trimmed}`; }) .replace(mathjaxInlineDelimiterPattern, (_match: string, text: string) => { const trimmed = trimBreaks(text); return `<${Mathjax.tagName}>${trimmed}`; }); } block = false; frame?: FrameElement; component?: Record | null; props?: MathjaxProps; static get observedAttributes(): string[] { return ["block", "data-mathjax"]; } connectedCallback(): void { this.decorate(); this.addEventListeners(); } disconnectedCallback(): void { this.removeEventListeners(); } attributeChangedCallback(name: string, old: string, newValue: string): void { if (newValue === old) { return; } switch (name) { case "block": this.block = newValue !== "false"; if (this.props) { this.props.block = this.block; } this.frame?.setAttribute("block", String(this.block)); break; case "data-mathjax": if (typeof newValue !== "string") { return; } if (this.props) { this.props.mathjax = newValue; } break; } } decorate(): void { if (this.hasAttribute("decorated")) { this.undecorate(); } if (this.parentElement?.tagName === FrameElement.tagName.toUpperCase()) { this.frame = this.parentElement as FrameElement; } else { frameElement(this, this.block); /* Framing will place this element inside of an anki-frame element, * causing the connectedCallback to be called again. * If we'd continue decorating at this point, we'd loose all the information */ return; } this.dataset.mathjax = this.innerHTML; this.innerHTML = ""; this.style.whiteSpace = "normal"; const props = $state({ mathjax: this.dataset.mathjax, block: this.block, fontSize: 20, }); const component = mount(Mathjax_svelte, { target: this, props, }); this.component = component; this.props = props; if (this.hasAttribute("focusonmount")) { let position: [number, number] | undefined = undefined; if (this.getAttribute("focusonmount")!.length > 0) { position = this.getAttribute("focusonmount")! .split(",") .map(Number) as [number, number]; } tick().then(() => { this.component?.moveCaretAfter(position); }); } this.setAttribute("contentEditable", "false"); this.setAttribute("decorated", "true"); } undecorate(): void { if (this.parentElement?.tagName === FrameElement.tagName.toUpperCase()) { this.parentElement.replaceWith(this); } this.innerHTML = this.dataset.mathjax ?? ""; delete this.dataset.mathjax; this.removeAttribute("style"); this.removeAttribute("focusonmount"); if (this.block) { this.setAttribute("block", "true"); } else { this.removeAttribute("block"); } this.removeAttribute("contentEditable"); this.removeAttribute("decorated"); } removeMoveInStart?: () => void; removeMoveInEnd?: () => void; addEventListeners(): void { this.removeMoveInStart = on( this, "moveinstart" as keyof HTMLElementEventMap, () => this.component!.selectAll(), ); this.removeMoveInEnd = on(this, "moveinend" as keyof HTMLElementEventMap, () => this.component!.selectAll()); } removeEventListeners(): void { this.removeMoveInStart?.(); this.removeMoveInStart = undefined; this.removeMoveInEnd?.(); this.removeMoveInEnd = undefined; } placeCaretBefore(): void { if (this.frame) { placeCaretBefore(this.frame); } } placeCaretAfter(): void { if (this.frame) { placeCaretAfter(this.frame); } } }; ================================================ FILE: ts/editable/mathjax.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import "mathjax/es5/tex-svg-full"; import mathIcon from "@mdi/svg/svg/math-integral-box.svg?src"; const parser = new DOMParser(); function getCSS(nightMode: boolean, fontSize: number): string { const color = nightMode ? "white" : "black"; /* color is set for Maths, fill for the empty icon */ return `svg { color: ${color}; fill: ${color}; font-size: ${fontSize}px; };`; } function getStyle(css: string): HTMLStyleElement { const style = document.createElement("style"); style.appendChild(document.createTextNode(css)); return style; } function getEmptyIcon(style: HTMLStyleElement): [string, string] { const icon = parser.parseFromString(mathIcon, "image/svg+xml"); const svg = icon.children[0]; svg.insertBefore(style, svg.children[0]); return [svg.outerHTML, "MathJax"]; } export function convertMathjax( input: string, nightMode: boolean, fontSize: number, ): [string, string] { input = revealClozeAnswers(input); const style = getStyle(getCSS(nightMode, fontSize)); if (input.trim().length === 0) { return getEmptyIcon(style); } let output: Element; try { output = globalThis.MathJax.tex2svg(input); } catch (e) { return ["Mathjax Error", String(e)]; } const svg = output.children[0] as SVGElement; if ((svg as any).viewBox.baseVal.height === 16) { return getEmptyIcon(style); } let title = ""; if (svg.innerHTML.includes("data-mjx-error")) { svg.querySelector("rect")?.setAttribute("fill", "yellow"); svg.querySelector("text")?.setAttribute("color", "red"); title = svg.querySelector("title")?.innerHTML ?? ""; } else { svg.insertBefore(style, svg.children[0]); } return [svg.outerHTML, title]; } /** * Escape characters which are technically legal in Mathjax, but confuse HTML. */ export function escapeSomeEntities(value: string): string { return value.replace(/&/g, "&").replace(//g, ">"); } export function unescapeSomeEntities(value: string): string { return value.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"); } function revealClozeAnswers(input: string): string { // one-line version of regex in cloze.rs const regex = /\{\{c(\d+)::(.*?)(?:::(.*?))?\}\}/gis; return input.replace(regex, "[$2]"); } ================================================ FILE: ts/editor/BrowserEditor.svelte ================================================ ================================================ FILE: ts/editor/ClozeButtons.svelte ================================================ ================================================ FILE: ts/editor/CodeMirror.svelte ================================================
================================================ FILE: ts/editor/CollapseBadge.svelte ================================================
================================================ FILE: ts/editor/CollapseLabel.svelte ================================================ toggle())} tabindex="-1" role="button" aria-expanded={!collapsed} > ================================================ FILE: ts/editor/DuplicateLink.svelte ================================================ bridgeCommand("dupes")}> {tr.editingShowDuplicates()} ================================================ FILE: ts/editor/EditingArea.svelte ================================================
================================================ FILE: ts/editor/EditorField.svelte ================================================ ================================================ FILE: ts/editor/FieldDescription.svelte ================================================ {#if empty}
{/if} ================================================ FILE: ts/editor/FieldState.svelte ================================================ ================================================ FILE: ts/editor/Fields.svelte ================================================
================================================ FILE: ts/editor/HandleBackground.svelte ================================================
================================================ FILE: ts/editor/HandleControl.svelte ================================================
================================================ FILE: ts/editor/HandleLabel.svelte ================================================
================================================ FILE: ts/editor/LabelContainer.svelte ================================================
================================================ FILE: ts/editor/LabelName.svelte ================================================ ================================================ FILE: ts/editor/NoteCreator.svelte ================================================ ================================================ FILE: ts/editor/NoteEditor.svelte ================================================
{#if hint} {@html hint} {/if} {#if imageOcclusionMode && ($ioMaskEditorVisible || imageOcclusionMode?.kind === "add")}
{/if} {#if $ioMaskEditorVisible && isImageOcclusion && !isIOImageLoaded} {/if} {#if !$ioMaskEditorVisible} {#each fieldsData as field, index} {@const content = fieldStores[index]} { $focusedField = fields[index]; setAddonButtonsDisabled(false); bridgeCommand(`focus:${index}`); }} on:focusout={() => { $focusedField = null; setAddonButtonsDisabled(true); bridgeCommand( `blur:${index}:${getNoteId()}:${transformContentBeforeSave( get(content), )}`, ); }} on:mouseenter={() => { $hoveredField = fields[index]; }} on:mouseleave={() => { $hoveredField = null; }} collapsed={fieldsCollapsed[index]} dupe={cols[index] === "dupe"} --description-font-size="{field.fontSize}px" --description-content={`"${field.description}"`} > toggleField(index)} --icon-align="bottom" > {field.name} {#if cols[index] === "dupe"} {/if} {#if plainTextDefaults[index]} toggleRichTextInput(index)} /> {:else} togglePlainTextInput(index)} /> {/if} { saveFieldNow(); $focusedInput = null; }} bind:this={richTextInputs[index]} isClozeField={field.isClozeField} /> { saveFieldNow(); $focusedInput = null; }} bind:this={plainTextInputs[index]} /> {/each} { updateTagsCollapsed(false); }} /> updateTagsCollapsed(!$tagsCollapsed)} > {@html `${tagAmount > 0 ? tagAmount : ""} ${tr.editingTags()}`} {/if}
================================================ FILE: ts/editor/Notification.svelte ================================================
================================================ FILE: ts/editor/PlainTextBadge.svelte ================================================ toggle())} tabindex="-1" role="button" > ================================================ FILE: ts/editor/PreviewButton.svelte ================================================ {tr.actionsPreview()} ================================================ FILE: ts/editor/ReviewerEditor.svelte ================================================ ================================================ FILE: ts/editor/RichTextBadge.svelte ================================================ toggle())} tabindex="-1" role="button" > ================================================ FILE: ts/editor/StickyBadge.svelte ================================================ toggle())} tabindex="-1" role="button" > {#if active} {:else} {/if} ================================================ FILE: ts/editor/base.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /** * Code that is shared among all entry points in /ts/editor */ import "./legacy.scss"; import "./editor-base.scss"; import "@tslib/runtime-require"; import "$lib/sveltelib/export-runtime"; import { setupI18n } from "@tslib/i18n"; import { uiResolve } from "@tslib/ui"; import * as contextKeys from "$lib/components/context-keys"; import IconButton from "$lib/components/IconButton.svelte"; import LabelButton from "$lib/components/LabelButton.svelte"; import WithContext from "$lib/components/WithContext.svelte"; import WithState from "$lib/components/WithState.svelte"; import BrowserEditor from "./BrowserEditor.svelte"; import NoteCreator from "./NoteCreator.svelte"; import * as editorContextKeys from "./NoteEditor.svelte"; import ReviewerEditor from "./ReviewerEditor.svelte"; declare global { interface Selection { addRange(r: Range): void; removeAllRanges(): void; getRangeAt(n: number): Range; } } import { ModuleName } from "@tslib/i18n"; import { mount } from "svelte"; export const editorModules = [ ModuleName.EDITING, ModuleName.KEYBOARD, ModuleName.ACTIONS, ModuleName.BROWSING, ModuleName.NOTETYPES, ModuleName.IMPORTING, ModuleName.UNDO, ]; export const components = { IconButton, LabelButton, WithContext, WithState, contextKeys: { ...contextKeys, ...editorContextKeys }, }; export { editorToolbar } from "./editor-toolbar"; async function setupBrowserEditor(): Promise { await setupI18n({ modules: editorModules }); mount(BrowserEditor, { target: document.body, props: { uiResolve } }); } async function setupNoteCreator(): Promise { await setupI18n({ modules: editorModules }); mount(NoteCreator, { target: document.body, props: { uiResolve } }); } async function setupReviewerEditor(): Promise { await setupI18n({ modules: editorModules }); mount(ReviewerEditor, { target: document.body, props: { uiResolve } }); } export function setupEditor(mode: "add" | "browse" | "review") { switch (mode) { case "add": setupNoteCreator(); break; case "browse": setupBrowserEditor(); break; case "review": setupReviewerEditor(); break; default: alert("unexpected editor type"); } } ================================================ FILE: ts/editor/code-mirror.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import "codemirror/lib/codemirror.css"; import "codemirror/addon/fold/foldgutter.css"; import "codemirror/theme/monokai.css"; import "codemirror/mode/htmlmixed/htmlmixed"; import "codemirror/mode/stex/stex"; import "codemirror/addon/fold/foldcode"; import "codemirror/addon/fold/foldgutter"; import "codemirror/addon/fold/xml-fold"; import "codemirror/addon/edit/matchtags"; import "codemirror/addon/edit/closetag"; import "codemirror/addon/display/placeholder"; import CodeMirror from "codemirror"; import type { Readable } from "svelte/store"; import storeSubscribe from "$lib/sveltelib/store-subscribe"; export { CodeMirror }; export const latex = { name: "stex", inMathMode: true, }; export const htmlanki = { name: "htmlmixed", tags: { "anki-mathjax": [[null, null, latex]], }, }; export const lightTheme = "default"; export const darkTheme = "monokai"; export const baseOptions: CodeMirror.EditorConfiguration = { theme: lightTheme, lineWrapping: true, matchTags: { bothTags: true }, extraKeys: { Tab: false, "Shift-Tab": false }, tabindex: 0, viewportMargin: Infinity, lineWiseCopyCut: false, }; export const gutterOptions: CodeMirror.EditorConfiguration = { gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"], lineNumbers: true, foldGutter: true, }; export function focusAndSetCaret( editor: CodeMirror.Editor, position: CodeMirror.Position = { line: editor.lineCount(), ch: 0 }, ): void { editor.focus(); editor.setCursor(position); } interface OpenCodeMirrorOptions { configuration: CodeMirror.EditorConfiguration; resolve(editor: CodeMirror.EditorFromTextArea): void; hidden: boolean; } export function openCodeMirror( textarea: HTMLTextAreaElement, options: Partial, ): { update: (options: Partial) => void; destroy: () => void } { let editor: CodeMirror.EditorFromTextArea | null = null; function update({ configuration, resolve, hidden, }: Partial): void { if (editor) { for (const key in configuration) { editor.setOption( key as keyof CodeMirror.EditorConfiguration, configuration[key], ); } } else if (!hidden) { editor = CodeMirror.fromTextArea(textarea, configuration); resolve?.(editor); } } update(options); return { update, destroy(): void { editor?.toTextArea(); editor = null; }, }; } /** * Sets up the contract with the code store and location restoration. */ export function setupCodeMirror( editor: CodeMirror.Editor, code: Readable, ): void { const { subscribe, unsubscribe } = storeSubscribe( code, (value: string): void => editor.setValue(value), false, ); // TODO passing in the tabindex option does not do anything: bug? editor.getInputField().tabIndex = 0; let ranges: CodeMirror.Range[] | null = null; editor.on("focus", () => { if (ranges) { try { editor.setSelections(ranges); } catch { ranges = null; editor.setCursor(editor.lineCount(), 0); } } unsubscribe(); }); editor.on("mousedown", () => { // Prevent focus restoring location ranges = null; }); editor.on("blur", () => { ranges = editor.listSelections(); subscribe(); }); subscribe(); } ================================================ FILE: ts/editor/decorated-elements.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { BLOCK_ELEMENTS } from "@tslib/dom"; import { CustomElementArray } from "../editable/decorated"; import { FrameElement } from "../editable/frame-element"; import { FrameEnd, FrameStart } from "../editable/frame-handle"; import { Mathjax } from "../editable/mathjax-element.svelte"; import { parsingInstructions } from "./plain-text-input"; const decoratedElements = new CustomElementArray(); function registerMathjax() { decoratedElements.push(Mathjax); parsingInstructions.push(""); } function registerFrameElement() { customElements.define(FrameElement.tagName, FrameElement); customElements.define(FrameStart.tagName, FrameStart); customElements.define(FrameEnd.tagName, FrameEnd); /* This will ensure that they are not targeted by surrounding algorithms */ BLOCK_ELEMENTS.push(FrameStart.tagName.toUpperCase()); BLOCK_ELEMENTS.push(FrameEnd.tagName.toUpperCase()); } registerMathjax(); registerFrameElement(); export { decoratedElements }; ================================================ FILE: ts/editor/destroyable.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export interface Destroyable { destroy(): void; } export function clearableArray(): (T & Destroyable)[] { const list: (T & Destroyable)[] = []; return new Proxy(list, { get: function(target: (T & Destroyable)[], prop: string | symbol) { if (!(typeof prop === "symbol") && !isNaN(Number(prop)) && !target[prop]) { const item = {} as T & Destroyable; const destroy = (): void => { const index = list.indexOf(item); list.splice(index, 1); }; target[prop] = new Proxy(item, { get: function(target: T & Destroyable, prop: string | symbol) { if (prop === "destroy") { return destroy; } return target[prop]; }, }); } return target[prop]; }, }); } ================================================ FILE: ts/editor/editor-base.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @import "../lib/sass/base"; $btn-disabled-opacity: 0.4; @import "bootstrap/scss/buttons"; @import "bootstrap/scss/button-group"; @import "../lib/sass/bootstrap-tooltip"; html, body { font-family: var(--bs-font-sans-serif); overflow: hidden; } ================================================ FILE: ts/editor/editor-toolbar/AddonButtons.svelte ================================================ {#each buttons as button, index}
{@html button}
{/each}
================================================ FILE: ts/editor/editor-toolbar/BlockButtons.svelte ================================================ (showFloating = false)} let:asReference > (showFloating = !showFloating)} > ================================================ FILE: ts/editor/editor-toolbar/BoldButton.svelte ================================================ ================================================ FILE: ts/editor/editor-toolbar/ColorPicker.svelte ================================================ saveCustomColours({})} /> {#if keyCombination} inputRef.click()} /> {/if} ================================================ FILE: ts/editor/editor-toolbar/CommandIconButton.svelte ================================================ {#if withoutState} {#if shortcut} {/if} {:else} queryCommandState(key)} let:state={active}> { action(); modeVariantKeys.map((key) => updateStateByKey(key, event)); }} > {#if shortcut} { action(); modeVariantKeys.map((key) => updateStateByKey(key, event)); }} /> {/if} {/if} ================================================ FILE: ts/editor/editor-toolbar/EditorToolbar.svelte ================================================
================================================ FILE: ts/editor/editor-toolbar/HighlightColorButton.svelte ================================================ { color = setColor(event); bridgeCommand(`lastHighlightColor:${color}`); }} on:change={() => { setTextColor(); saveCustomColours({}); }} /> ================================================ FILE: ts/editor/editor-toolbar/ImageOcclusionButton.svelte ================================================ { $ioMaskEditorVisible = !$ioMaskEditorVisible; }} tooltip="{tr.editingImageOcclusionToggleMaskEditor()} ({toggleMaskEditorKeyCombination})" > { $ioMaskEditorVisible = !$ioMaskEditorVisible; }} /> { if (confirm(tr.editingImageOcclusionConfirmReset())) { globalThis.resetIOImageLoaded(); } else { return; } }} tooltip={tr.editingImageOcclusionReset()} > ================================================ FILE: ts/editor/editor-toolbar/InlineButtons.svelte ================================================ ================================================ FILE: ts/editor/editor-toolbar/ItalicButton.svelte ================================================ ================================================ FILE: ts/editor/editor-toolbar/LatexButton.svelte ================================================ (showFloating = false)} > (showFloating = !showFloating)} > {#each dropdownItems as [callback, keyCombination, label]} setTimeout(callback, 100)}> {label} {getPlatformString(keyCombination)} {/each} {#each dropdownItems as [callback, keyCombination]} {/each} ================================================ FILE: ts/editor/editor-toolbar/NotetypeButtons.svelte ================================================ bridgeCommand("fields")} > {tr.editingFields()}... bridgeCommand("cards")} > {tr.editingCards()}... bridgeCommand("cards")} /> ================================================ FILE: ts/editor/editor-toolbar/OptionsButton.svelte ================================================ (showFloating = false)}> (showFloating = !showFloating)} > {tr.editingShrinkImages()} {tr.editingMathjaxPreview()} {tr.editingCloseHtmlTags()} ================================================ FILE: ts/editor/editor-toolbar/OptionsButtons.svelte ================================================ ================================================ FILE: ts/editor/editor-toolbar/RemoveFormatButton.svelte ================================================ (showFloating = false)}> (showFloating = !showFloating)} > {#each showFormats as format (format.name)} onItemClick(event, format)}> {format.name} {/each} ================================================ FILE: ts/editor/editor-toolbar/RichTextClozeButtons.svelte ================================================ ================================================ FILE: ts/editor/editor-toolbar/SubscriptButton.svelte ================================================ ================================================ FILE: ts/editor/editor-toolbar/SuperscriptButton.svelte ================================================ ================================================ FILE: ts/editor/editor-toolbar/TemplateButtons.svelte ================================================ ================================================ FILE: ts/editor/editor-toolbar/TextAttributeButton.svelte ================================================ { applyAttribute(); updateState(event); exclusiveNames.map((name) => updateStateByKey(name, event)); }} > { applyAttribute(); updateState(event); exclusiveNames.map((name) => updateStateByKey(name, event)); }} /> ================================================ FILE: ts/editor/editor-toolbar/TextColorButton.svelte ================================================ { color = setColor(event); bridgeCommand(`lastTextColor:${color}`); }} on:change={() => { // Delay added to work around intermittent failures on macOS/Qt6.5 setTimeout(() => { setTextColor(); }, 200); saveCustomColours({}); }} /> ================================================ FILE: ts/editor/editor-toolbar/UnderlineButton.svelte ================================================ ================================================ FILE: ts/editor/editor-toolbar/WithColorHelper.svelte ================================================
================================================ FILE: ts/editor/editor-toolbar/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import EditorToolbar from "./EditorToolbar.svelte"; export type { EditorToolbarAPI } from "./EditorToolbar.svelte"; export default EditorToolbar; export { editorToolbar } from "./EditorToolbar.svelte"; ================================================ FILE: ts/editor/helpers.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html function isFontElement(element: Element): element is HTMLFontElement { return element.tagName === "FONT"; } /** * Avoid both HTMLFontElement and .color, as they are both deprecated */ export function withFontColor( element: Element, callback: (color: string) => void, ): boolean { if (isFontElement(element)) { callback(element.color); return true; } return false; } export class Flag { private flag: boolean; constructor() { this.flag = false; } setFlag(on: boolean): void { this.flag = on; } /** Resets the flag to false and returns the previous value. */ checkAndReset(): boolean { const val = this.flag; this.flag = false; return val; } } ================================================ FILE: ts/editor/image-overlay/FloatButtons.svelte ================================================ { image.style.float = "left"; setTimeout(() => dispatch("update")); }} --border-left-radius="5px" > { // We shortly set to none, because simply unsetting float will not // trigger floatStyle being reset image.style.float = "none"; removeStyleProperties(image, "float"); setTimeout(() => dispatch("update")); }} > { image.style.float = "right"; setTimeout(() => dispatch("update")); }} --border-right-radius="5px" > ================================================ FILE: ts/editor/image-overlay/ImageOverlay.svelte ================================================
{#if activeImage} { const { reason, originalEvent } = detail; if (reason === "outsideClick") { // If the click is still in the overlay, we do not want // to reset the handle either if (!originalEvent?.composedPath().includes(imageOverlay)) { await resetHandle(); } } else { await resetHandle(); } }} > { positionOverlay(); positionFloating(); }} /> { toggleActualSize(); positionOverlay(); }} on:imageclear={() => { clearActualSize(); positionOverlay(); }} /> { if (shrinkingDisabled) { return; } toggleActualSize(); positionOverlay(); }} /> {#if isSizeConstrained && !shrinkingDisabled} {`(${tr.editingDoubleClickToExpand()})`} {:else} {actualWidth}×{actualHeight} {#if customDimensions} (Original: {naturalWidth}×{naturalHeight}) {/if} {/if} { if (!isSizeConstrained) { setPointerCapture(event); } }} on:pointermove={(event) => { resize(event); }} /> {/if}
================================================ FILE: ts/editor/image-overlay/SizeSelect.svelte ================================================ dispatch("imagetoggle")} --border-left-radius="5px" > dispatch("imageclear")} --border-right-radius="5px" > ================================================ FILE: ts/editor/image-overlay/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import ImageOverlay from "./ImageOverlay.svelte"; export default ImageOverlay; ================================================ FILE: ts/editor/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { globalExport } from "@tslib/globals"; import * as base from "./base"; globalExport(base); ================================================ FILE: ts/editor/legacy.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @use "sass:color"; @use "../lib/sass/button-mixins" as button; .linkb { $size: var(--buttons-size); @include button.base; @include button.border-radius; min-width: $size; height: $size; font-size: calc($size * 0.6); position: relative; img.topbut { $padding: 4px; $icon-size: calc(100% - 2 * $padding); position: absolute; height: $icon-size; width: $icon-size; // replace with inset once Qt5 support is dropped top: $padding; right: $padding; bottom: $padding; left: $padding; .nightMode & { filter: invert(1); } } } button { @include button.base($active-class: active); } ================================================ FILE: ts/editor/mathjax-overlay/MathjaxButtons.svelte ================================================ dispatch("setinline")} --border-left-radius="5px" > dispatch("setblock")} --border-right-radius="5px" > {#if isClozeField} {/if} dispatch("delete")} --border-left-radius="5px" --border-right-radius="5px" > ================================================ FILE: ts/editor/mathjax-overlay/MathjaxEditor.svelte ================================================
code.set(mathjaxText)} on:blur />
================================================ FILE: ts/editor/mathjax-overlay/MathjaxOverlay.svelte ================================================
{#if activeImage && mathjaxElement} { placeHandle(false); resetHandle(); }} on:moveoutend={() => { placeHandle(true); resetHandle(); }} on:close={() => { placeHandle(true); resetHandle(); }} let:editor={mathjaxEditor} > { placeHandle(true); resetHandle(); }} /> { isBlock = false; await updateBlockAttribute(); positionOverlay(); positionFloating(); }} on:setblock={async () => { isBlock = true; await updateBlockAttribute(); positionOverlay(); positionFloating(); }} on:delete={async () => { if (activeImage) { placeCaretAfter(activeImage); mathjaxElement?.remove(); clear(); } }} on:surround={async ({ detail }) => { const editor = await mathjaxEditor.editor; const { prefix, suffix } = detail; editor.replaceSelection( prefix + editor.getSelection() + suffix, ); }} /> {/if}
================================================ FILE: ts/editor/mathjax-overlay/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import MathjaxOverlay from "./MathjaxOverlay.svelte"; export default MathjaxOverlay; ================================================ FILE: ts/editor/old-editor-adapter.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { updateAllState } from "$lib/components/WithState.svelte"; import { execCommand } from "$lib/domlib"; import { filterHTML } from "../html-filter"; export function pasteHTML( html: string, internal: boolean, extendedMode: boolean, ): void { html = filterHTML(html, internal, extendedMode); if (html !== "") { setFormat("inserthtml", html); } } export function setFormat(cmd: string, arg?: string, _nosave = false): void { execCommand(cmd, false, arg); updateAllState(new Event(cmd)); } export function toggleEditorButton(button: HTMLButtonElement): void { button.classList.toggle("active"); } ================================================ FILE: ts/editor/plain-text-input/PlainTextInput.svelte ================================================
($focusedInput = api)} {hidden} >
================================================ FILE: ts/editor/plain-text-input/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import PlainTextInput from "./PlainTextInput.svelte"; export type { PlainTextInputAPI } from "./PlainTextInput.svelte"; export default PlainTextInput; export * from "./PlainTextInput.svelte"; ================================================ FILE: ts/editor/plain-text-input/remove-prohibited.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { createDummyDoc } from "@tslib/parsing"; const parser = new DOMParser(); function removeTag(element: HTMLElement, tagName: string): void { for (const elem of element.getElementsByTagName(tagName)) { elem.remove(); } } const prohibitedTags = ["script", "link"]; /** * The use cases for using those tags in the field html are slim to none. * We want to make it easier to possibly display cards in an iframe in the future. */ function removeProhibitedTags(html: string): string { const doc = parser.parseFromString(createDummyDoc(html), "text/html"); const body = doc.body; for (const tag of prohibitedTags) { removeTag(body, tag); } return doc.body.innerHTML; } export default removeProhibitedTags; ================================================ FILE: ts/editor/plain-text-input/transform.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { decoratedElements } from "../decorated-elements"; export function storedToUndecorated(html: string): string { return decoratedElements.toUndecorated(html); } export function undecoratedToStored(html: string): string { return decoratedElements.toStored(html); } ================================================ FILE: ts/editor/rich-text-input/CustomStyles.svelte ================================================ {#each styles as style (style.id)} {#if style.type === "link"} {:else} {/if} {/each} ================================================ FILE: ts/editor/rich-text-input/RichTextInput.svelte ================================================
{#await Promise.all([richTextPromise, stylesDidLoad]) then _}
{/await}
================================================ FILE: ts/editor/rich-text-input/RichTextStyles.svelte ================================================ ================================================ FILE: ts/editor/rich-text-input/StyleLink.svelte ================================================ ================================================ FILE: ts/editor/rich-text-input/StyleTag.svelte ================================================ {#if true} {/if} ================================================ FILE: ts/editor/rich-text-input/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { default as RichTextInput } from "./RichTextInput.svelte"; export type { RichTextInputAPI } from "./RichTextInput.svelte"; export default RichTextInput; export * from "./RichTextInput.svelte"; ================================================ FILE: ts/editor/rich-text-input/normalizing-node-store.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { NodeStore } from "$lib/sveltelib/node-store"; import { nodeStore } from "$lib/sveltelib/node-store"; import type { DecoratedElement } from "../../editable/decorated"; import { decoratedElements } from "../decorated-elements"; function normalizeFragment(fragment: DocumentFragment): void { fragment.normalize(); for (const decorated of decoratedElements) { for ( const element of fragment.querySelectorAll( decorated.tagName, ) as NodeListOf ) { element.undecorate(); } } } function getStore(): NodeStore { return nodeStore(undefined, normalizeFragment); } export default getStore; ================================================ FILE: ts/editor/rich-text-input/rich-text-resolve.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { bridgeCommand } from "@tslib/bridgecommand"; import { on } from "@tslib/events"; import { promiseWithResolver } from "@tslib/promise"; function bridgeCopyPasteCommands(input: HTMLElement): { destroy(): void } { function onPaste(event: Event): void { event.preventDefault(); bridgeCommand("paste"); } function onCutOrCopy(): void { bridgeCommand("cutOrCopy"); } const removePaste = on(input, "paste", onPaste); const removeCopy = on(input, "copy", onCutOrCopy); const removeCut = on(input, "cut", onCutOrCopy); return { destroy() { removePaste(); removeCopy(); removeCut(); }, }; } function useRichTextResolve(): [Promise, (input: HTMLElement) => void] { const [promise, resolve] = promiseWithResolver(); function richTextResolve(input: HTMLElement): { destroy(): void } { const destroy = bridgeCopyPasteCommands(input); resolve(input); return destroy; } return [promise, richTextResolve]; } export default useRichTextResolve; ================================================ FILE: ts/editor/rich-text-input/transform.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { fragmentToString, nodeContainsInlineContent, nodeIsElement } from "@tslib/dom"; import { createDummyDoc } from "@tslib/parsing"; import { decoratedElements } from "../decorated-elements"; function adjustInputHTML(html: string): string { for (const component of decoratedElements) { html = component.toUndecorated(html); } return html; } function adjustInputFragment(fragment: DocumentFragment): void { if (nodeContainsInlineContent(fragment)) { fragment.appendChild(document.createElement("br")); } } export function storedToFragment(storedHTML: string): DocumentFragment { /* We need .createContextualFragment so that customElements are initialized */ const fragment = document .createRange() .createContextualFragment(createDummyDoc(adjustInputHTML(storedHTML))); adjustInputFragment(fragment); return fragment; } function adjustOutputFragment(fragment: DocumentFragment): void { if ( fragment.hasChildNodes() && nodeIsElement(fragment.lastChild!) && nodeContainsInlineContent(fragment) && fragment.lastChild!.tagName === "BR" ) { fragment.lastChild!.remove(); } } function adjustOutputHTML(html: string): string { for (const component of decoratedElements) { html = component.toStored(html); } return html; } export function fragmentToStored(fragment: DocumentFragment): string { const clone = document.importNode(fragment, true); adjustOutputFragment(clone); const storedHTML = adjustOutputHTML(fragmentToString(clone)); return storedHTML; } ================================================ FILE: ts/editor/surround.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { getRange, getSelection } from "@tslib/cross-browser"; import { asyncNoop } from "@tslib/functional"; import { registerPackage } from "@tslib/runtime-require"; import type { Readable } from "svelte/store"; import { derived, get } from "svelte/store"; import type { Matcher } from "$lib/domlib/find-above"; import { findClosest } from "$lib/domlib/find-above"; import type { SurroundFormat } from "$lib/domlib/surround"; import { boolMatcher, reformat, surround, unsurround } from "$lib/domlib/surround"; import type { TriggerItem } from "$lib/sveltelib/handler-list"; import type { InputHandlerAPI } from "$lib/sveltelib/input-handler"; function isValid(value: T | undefined): value is T { return Boolean(value); } function isSurroundedInner( range: AbstractRange, base: HTMLElement, matcher: Matcher, ): boolean { return Boolean( findClosest(range.startContainer, base, matcher) || findClosest(range.endContainer, base, matcher), ); } function surroundAndSelect( matches: boolean, range: Range, base: HTMLElement, format: SurroundFormat, selection: Selection, ): void { const surroundedRange = matches ? unsurround(range, base, format) : surround(range, base, format); selection.removeAllRanges(); selection.addRange(surroundedRange); } function removeFormats( range: Range, base: Element, formats: SurroundFormat[], reformats: SurroundFormat[] = [], ): Range { let surroundRange = range; for (const format of formats) { surroundRange = unsurround(surroundRange, base, format); } for (const format of reformats) { surroundRange = reformat(surroundRange, base, format); } return surroundRange; } export interface SurroundedAPI { element: Promise; inputHandler: InputHandlerAPI; } /** * After calling disable, using any of the surrounding methods will throw an * exception. Make sure to set the input before trying to use them again. */ export class Surrounder { #api?: SurroundedAPI; #triggers: Map> = new Map(); #formats: Map> = new Map(); active: Readable; private constructor(apiStore: Readable) { this.active = derived(apiStore, (api) => Boolean(api)); apiStore.subscribe((api: SurroundedAPI | null): void => { if (api) { this.#api = api; for (const key of this.#formats.keys()) { this.#triggers.set( key, api.inputHandler.insertText.trigger({ once: true }), ); } } else { this.#api = undefined; for (const [key, trigger] of this.#triggers) { trigger.off(); this.#triggers.delete(key); } } }); } static make(apiStore: Readable): Surrounder { return new Surrounder(apiStore); } #getBaseElement(): Promise { if (!this.#api) { throw new Error("Surrounder: No api set"); } return this.#api.element; } #toggleTrigger( base: HTMLElement, selection: Selection, matcher: Matcher, format: SurroundFormat, trigger: TriggerItem<{ event: InputEvent; text: Text }>, exclusive: SurroundFormat[] = [], ): void { if (get(trigger.active)) { trigger.off(); } else { trigger.on(async ({ text }) => { const range = new Range(); range.selectNode(text); const matches = Boolean(findClosest(text, base, matcher)); const clearedRange = removeFormats(range, base, exclusive); surroundAndSelect(matches, clearedRange, base, format, selection); selection.collapseToEnd(); }); } } #toggleTriggerOverwrite( base: HTMLElement, selection: Selection, format: SurroundFormat, trigger: TriggerItem<{ event: InputEvent; text: Text }>, exclusive: SurroundFormat[] = [], ): void { trigger.on(async ({ text }) => { const range = new Range(); range.selectNode(text); const clearedRange = removeFormats(range, base, exclusive); const surroundedRange = surround(clearedRange, base, format); selection.removeAllRanges(); selection.addRange(surroundedRange); selection.collapseToEnd(); }); } #toggleTriggerRemove( base: HTMLElement, selection: Selection, formats: { format: SurroundFormat; trigger: TriggerItem<{ event: InputEvent; text: Text }>; }[], reformat: SurroundFormat[] = [], ): void { const remainingFormats = formats .filter(({ trigger }) => { if (get(trigger.active)) { // Deactivate active triggers for active formats. trigger.off(); return false; } // Otherwise you are within the format. This is why we activate // the trigger, so that the active button is set to inactive. // We still need to remove the format however. trigger.on(asyncNoop); return true; }) .map(({ format }) => format); // Use an anonymous insertText handler instead of some trigger associated with a name this.#api!.inputHandler.insertText.on( async ({ text }) => { const range = new Range(); range.selectNode(text); const clearedRange = removeFormats( range, base, remainingFormats, reformat, ); selection.removeAllRanges(); selection.addRange(clearedRange); selection.collapseToEnd(); }, { once: true }, ); } /** * Check if a surround format under the given key is registered. */ hasFormat(key: string): boolean { return this.#formats.has(key); } /** * Register a surround format under a certain key. * This name is then used with the surround functions to actually apply or * remove the given format. */ registerFormat(key: string, format: SurroundFormat): () => void { this.#formats.set(key, format); if (this.#api) { this.#triggers.set( key, this.#api.inputHandler.insertText.trigger({ once: true }), ); } return () => this.#formats.delete(key); } /** * Update a surround format under a specific key. */ updateFormat( key: string, update: (format: SurroundFormat) => SurroundFormat, ): void { this.#formats.set(key, update(this.#formats.get(key)!)); } /** * Use the surround command on the current range of the input. * If the range is already surrounded, it will unsurround instead. */ async surround(formatName: string, exclusiveNames: string[] = []): Promise { const base = await this.#getBaseElement(); const selection = getSelection(base)!; const range = getRange(selection); const format = this.#formats.get(formatName); const trigger = this.#triggers.get(formatName); if (!format || !range || !trigger) { return; } const matcher = boolMatcher(format); const exclusives = exclusiveNames .map((name) => this.#formats.get(name)) .filter(isValid); if (range.collapsed) { return this.#toggleTrigger( base, selection, matcher, format, trigger, exclusives, ); } const clearedRange = removeFormats(range, base, exclusives); const matches = isSurroundedInner(clearedRange, base, matcher); surroundAndSelect(matches, clearedRange, base, format, selection); } /** * Use the surround command on the current range of the input. * If the range is already surrounded, it will overwrite the format. * This might be better suited if the surrounding is parameterized (like * text color). */ async overwriteSurround( formatName: string, exclusiveNames: string[] = [], ): Promise { const base = await this.#getBaseElement(); const selection = getSelection(base)!; const range = getRange(selection); const format = this.#formats.get(formatName); const trigger = this.#triggers.get(formatName); if (!format || !range || !trigger) { return; } const exclusives = exclusiveNames .map((name) => this.#formats.get(name)) .filter(isValid); if (range.collapsed) { return this.#toggleTriggerOverwrite( base, selection, format, trigger, exclusives, ); } const clearedRange = removeFormats(range, base, exclusives); const surroundedRange = surround(clearedRange, base, format); selection.removeAllRanges(); selection.addRange(surroundedRange); } /** * Check if the current selection is surrounded. A selection will count as * provided if either the start or the end boundary point are within the * provided format, OR if a surround trigger is active (surround on next * text insert). */ async isSurrounded(formatName: string): Promise { const base = await this.#getBaseElement(); const selection = getSelection(base)!; const range = getRange(selection); const format = this.#formats.get(formatName); const trigger = this.#triggers.get(formatName); if (!range || !format || !trigger) { return false; } const isSurrounded = isSurroundedInner(range, base, boolMatcher(format)); return get(trigger.active) ? !isSurrounded : isSurrounded; } /** * Clear/Reformat the provided formats in the current range. */ async remove(formatNames: string[], reformatNames: string[] = []): Promise { const base = await this.#getBaseElement(); const selection = getSelection(base)!; const range = getRange(selection); if (!range) { return; } const activeFormats = formatNames .map((name: string) => ({ name, format: this.#formats.get(name)!, trigger: this.#triggers.get(name)!, })) .filter(({ format, trigger }): boolean => { if (!format || !trigger) { return false; } // This is confusing: when nothing is selected, we only // include currently-active buttons, as otherwise inactive // buttons get toggled on. But when something is selected, // we include everything, since we want to remove formatting // that may be in part of the selection, but not at the start/end. const isSurrounded = !range.collapsed || isSurroundedInner( range, base, boolMatcher(format), ); return get(trigger.active) ? !isSurrounded : isSurrounded; }); const reformats = reformatNames .map((name) => this.#formats.get(name)) .filter(isValid); if (range.collapsed) { return this.#toggleTriggerRemove(base, selection, activeFormats, reformats); } const surroundedRange = removeFormats( range, base, activeFormats.map(({ format }) => format), reformats, ); selection.removeAllRanges(); selection.addRange(surroundedRange); } } registerPackage("anki/surround", { Surrounder, }); ================================================ FILE: ts/editor/types.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export type EditorOptions = { fieldsCollapsed: boolean[]; fieldStates: { richTextsHidden: boolean[]; plainTextsHidden: boolean[]; plainTextDefaults: boolean[]; }; modTimeOfNotetype: number; }; export type SessionOptions = { [key: number]: EditorOptions; }; export type NotetypeIdAndModTime = { id: number; modTime: number; }; export enum EditorState { Initial = -1, Fields = 0, ImageOcclusionPicker = 1, ImageOcclusionMasks = 2, ImageOcclusionFields = 3, } ================================================ FILE: ts/html-filter/element.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { isHTMLElement, isNightMode } from "./helpers"; import { removeNode as removeElement } from "./node"; import { filterStylingInternal, filterStylingLightMode, filterStylingNightMode } from "./styling"; interface TagsAllowed { [tagName: string]: FilterMethod; } type FilterMethod = (element: Element) => void; function filterAttributes( attributePredicate: (attributeName: string) => boolean, element: Element, ): void { for (const attr of [...element.attributes]) { const attrName = attr.name.toUpperCase(); if (!attributePredicate(attrName)) { element.removeAttributeNode(attr); } } } function allowNone(element: Element): void { filterAttributes(() => false, element); } const allow = (attrs: string[]): FilterMethod => (element: Element): void => filterAttributes( (attributeName: string) => attrs.includes(attributeName), element, ); function unwrapElement(element: Element): void { element.replaceWith(...element.childNodes); } function filterSpan(element: Element): void { const filterAttrs = allow(["STYLE"]); filterAttrs(element); const filterStyle = isNightMode() ? filterStylingNightMode : filterStylingLightMode; filterStyle(element as HTMLSpanElement); } const tagsAllowedBasic: TagsAllowed = { BR: allowNone, IMG: allow(["SRC", "ALT"]), DIV: allowNone, P: allowNone, SUB: allowNone, SUP: allowNone, TITLE: removeElement, }; const tagsAllowedExtended: TagsAllowed = { ...tagsAllowedBasic, A: allow(["HREF"]), B: allowNone, BLOCKQUOTE: allowNone, CODE: allowNone, DD: allowNone, DL: allowNone, DT: allowNone, EM: allowNone, FONT: allow(["COLOR"]), H1: allowNone, H2: allowNone, H3: allowNone, I: allowNone, LI: allowNone, OL: allowNone, PRE: allowNone, RP: allowNone, RT: allowNone, RUBY: allowNone, SPAN: filterSpan, STRONG: allowNone, TABLE: allowNone, TD: allow(["COLSPAN", "ROWSPAN"]), TH: allow(["COLSPAN", "ROWSPAN"]), TR: allow(["ROWSPAN"]), U: allowNone, UL: allowNone, }; const filterElementTagsAllowed = (tagsAllowed: TagsAllowed) => (element: Element): void => { const tagName = element.tagName; if (Object.prototype.hasOwnProperty.call(tagsAllowed, tagName)) { tagsAllowed[tagName](element); } else if (element.innerHTML) { unwrapElement(element); } else { removeElement(element); } }; export const filterElementBasic = filterElementTagsAllowed(tagsAllowedBasic); export const filterElementExtended = filterElementTagsAllowed(tagsAllowedExtended); export function filterElementInternal(element: Element): void { if (isHTMLElement(element)) { filterStylingInternal(element); } } ================================================ FILE: ts/html-filter/helpers.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export function isHTMLElement(elem: Element): elem is HTMLElement { return elem instanceof HTMLElement; } export function isNightMode(): boolean { return document.body.classList.contains("nightMode"); } ================================================ FILE: ts/html-filter/index.test.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // @vitest-environment jsdom import { describe, expect, test } from "vitest"; import { filterHTML } from "."; describe("filterHTML", () => { test("zero input creates zero output", () => { expect(filterHTML("", true, false)).toBe(""); expect(filterHTML("", true, false)).toBe(""); expect(filterHTML("", false, false)).toBe(""); }); test("internal filtering", () => { // font-size is filtered, weight is not expect( filterHTML( "
", true, true, ), ).toBe("
"); }); test("background color", () => { // transparent is stripped, other colors are not expect( filterHTML( "", false, true, ), ).toBe(""); expect( filterHTML("", false, true), ).toBe(""); // except if extended mode is off expect( filterHTML("x", false, false), ).toBe("x"); // no filtering on internal paste expect( filterHTML( "", true, true, ), ).toBe(""); }); }); ================================================ FILE: ts/html-filter/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { filterElementBasic, filterElementExtended, filterElementInternal } from "./element"; import { filterNode } from "./node"; enum FilterMode { Basic, Extended, Internal, } const filters: Record void> = { [FilterMode.Basic]: filterElementBasic, [FilterMode.Extended]: filterElementExtended, [FilterMode.Internal]: filterElementInternal, }; const whitespace = /[\n\t ]+/g; function collapseWhitespace(value: string): string { return value.replace(whitespace, " "); } function trim(value: string): string { return value.trim(); } const outputHTMLProcessors: Record string> = { [FilterMode.Basic]: (outputHTML: string): string => trim(collapseWhitespace(outputHTML)), [FilterMode.Extended]: trim, [FilterMode.Internal]: trim, }; export function filterHTML(html: string, internal: boolean, extended: boolean): string { const template = document.createElement("template"); template.innerHTML = html; const mode = getFilterMode(internal, extended); const content = template.content; const filter = filterNode(filters[mode]); filter(content); return outputHTMLProcessors[mode](template.innerHTML); } function getFilterMode(internal: boolean, extended: boolean): FilterMode { if (internal) { return FilterMode.Internal; } else if (extended) { return FilterMode.Extended; } else { return FilterMode.Basic; } } ================================================ FILE: ts/html-filter/node.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export function removeNode(element: Node): void { element.parentNode?.removeChild(element); } function iterateElement( filter: (node: Node) => void, fragment: DocumentFragment | Element, ): void { for (const child of [...fragment.childNodes]) { filter(child); } } export const filterNode = (elementFilter: (element: Element) => void) => (node: Node): void => { switch (node.nodeType) { case Node.COMMENT_NODE: removeNode(node); break; case Node.DOCUMENT_FRAGMENT_NODE: iterateElement(filterNode(elementFilter), node as DocumentFragment); break; case Node.ELEMENT_NODE: iterateElement(filterNode(elementFilter), node as Element); elementFilter(node as Element); break; default: // do nothing } }; ================================================ FILE: ts/html-filter/styling.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /** Keep property if true. */ type StylingPredicate = (property: string, value: string) => boolean; const keep = (_key: string, _value: string) => true; const discard = (_key: string, _value: string) => false; /** Return a function that filters out certain styles. - If the style is listed in `exceptions`, the provided predicate is used. - If the style is not listed, the default predicate is used instead. */ function filterStyling( defaultPredicate: StylingPredicate, exceptions: Record, ): (element: HTMLElement) => void { return (element: HTMLElement): void => { // jsdom does not support @@iterator, so manually iterate const toRemove: string[] = []; for (let i = 0; i < element.style.length; i++) { const key = element.style.item(i); const value = element.style.getPropertyValue(key); const predicate = exceptions[key] ?? defaultPredicate; if (!predicate(key, value)) { toRemove.push(key); } } for (const key of toRemove) { element.style.removeProperty(key); } }; } const nightModeExceptions = { "font-weight": keep, "font-style": keep, "text-decoration-line": keep, }; export const filterStylingNightMode = filterStyling(discard, nightModeExceptions); export const filterStylingLightMode = filterStyling(discard, { color: keep, "background-color": (_key: string, value: string) => value != "transparent", ...nightModeExceptions, }); export const filterStylingInternal = filterStyling(keep, { "font-size": discard, "font-family": discard, width: discard, height: discard, "max-width": discard, "max-height": discard, }); ================================================ FILE: ts/lib/components/Absolute.svelte ================================================
================================================ FILE: ts/lib/components/BackendProgressIndicator.svelte ================================================ {#if !result}
{label}
{/if} ================================================ FILE: ts/lib/components/Badge.svelte ================================================ ================================================ FILE: ts/lib/components/ButtonGroup.svelte ================================================
================================================ FILE: ts/lib/components/ButtonGroupItem.svelte ================================================
{#if !$detach} {/if}
================================================ FILE: ts/lib/components/ButtonToolbar.svelte ================================================ ================================================ FILE: ts/lib/components/CheckBox.svelte ================================================ ================================================ FILE: ts/lib/components/Col.svelte ================================================
================================================ FILE: ts/lib/components/Collapsible.svelte ================================================
{#if animated && measuring}
{/if} ================================================ FILE: ts/lib/components/ConfigInput.svelte ================================================
================================================ FILE: ts/lib/components/Container.svelte ================================================
================================================ FILE: ts/lib/components/DropdownDivider.svelte ================================================ ================================================ FILE: ts/lib/components/DropdownItem.svelte ================================================ ================================================ FILE: ts/lib/components/DynamicallySlottable.svelte ================================================
{#each $dynamicSlotted as { component, hostProps } (component.id)} {/each}
================================================ FILE: ts/lib/components/EnumSelector.svelte ================================================ (focused = true)} on:focusout={() => (focused = false)} style:padding-left={percentage_padding} /> {#if percentage} {#each percentage_text as str} {#if str == "%"} % {:else} {/if} {/each} {/if} {#if isDesktop()}
min} tabindex="-1" title={tr.actionsDecrementValue()} role="button" on:click={() => { input.focus(); if (value > min) { change(-step); } }} on:mousedown={() => longPress(() => { if (value > min) { change(-step); } })} on:mouseup={() => { clearTimeout(pressTimer); pressed = false; }} >
{ input.focus(); if (value < max) { change(step); } }} on:mousedown={() => longPress(() => { if (value < max) { change(step); } })} on:mouseup={() => { clearTimeout(pressTimer); pressed = false; }} >
{/if} ================================================ FILE: ts/lib/components/StickyContainer.svelte ================================================
================================================ FILE: ts/lib/components/Switch.svelte ================================================
================================================ FILE: ts/lib/components/SwitchRow.svelte ================================================ ================================================ FILE: ts/lib/components/TitledContainer.svelte ================================================

{title}

================================================ FILE: ts/lib/components/VirtualTable.svelte ================================================
(scrollTop = container.scrollTop)} > {#if itemHeight * startIndex > 0} {/if} {#each slice as index (index)} {/each} {#if itemHeight * itemsCount - itemHeight * endIndex > 0} {/if}
================================================ FILE: ts/lib/components/WithContext.svelte ================================================ ================================================ FILE: ts/lib/components/WithFloating.svelte ================================================ {#if $$slots.reference} {#if inline} {:else}
{/if} {/if}
{#if show} {/if}
================================================ FILE: ts/lib/components/WithOverlay.svelte ================================================ {#if $$slots.reference} {#if inline} {:else}
{/if} {/if}
{#if show} {/if}
================================================ FILE: ts/lib/components/WithState.svelte ================================================ ================================================ FILE: ts/lib/components/WithTooltip.svelte ================================================ ================================================ FILE: ts/lib/components/context-keys.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export const touchDeviceKey = Symbol("touchDevice"); export const sectionKey = Symbol("section"); export const buttonGroupKey = Symbol("buttonGroup"); export const dropdownKey = Symbol("dropdown"); export const modalsKey = Symbol("modals"); export const floatingKey = Symbol("floating"); export const overlayKey = Symbol("overlay"); export const selectKey = Symbol("select"); export const showKey = Symbol("selectShow"); export const focusIdKey = Symbol("selectFocusId"); ================================================ FILE: ts/lib/components/helpers.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export function mergeTooltipAndShortcut( tooltip: string | undefined, shortcutLabel: string | undefined, ): string | undefined { if (!tooltip && !shortcutLabel) { return undefined; } let buf = tooltip ?? ""; if (shortcutLabel) { buf = `${buf} (${shortcutLabel})`; } return buf; } export const withButton = (f: (button: HTMLButtonElement) => void) => ({ detail }: CustomEvent): void => { f(detail.button); }; export const withSpan = (f: (span: HTMLSpanElement) => void) => ({ detail }: CustomEvent): void => { f(detail.span); }; ================================================ FILE: ts/lib/components/icons.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import Alert_ from "@mdi/svg/svg/alert.svg?component"; import alert_ from "@mdi/svg/svg/alert.svg?url"; import AlignHorizontalCenter_ from "@mdi/svg/svg/align-horizontal-center.svg?component"; import alignHorizontalCenter_ from "@mdi/svg/svg/align-horizontal-center.svg?url"; import AlignHorizontalLeft_ from "@mdi/svg/svg/align-horizontal-left.svg?component"; import alignHorizontalLeft_ from "@mdi/svg/svg/align-horizontal-left.svg?url"; import AlignHorizontalRight_ from "@mdi/svg/svg/align-horizontal-right.svg?component"; import alignHorizontalRight_ from "@mdi/svg/svg/align-horizontal-right.svg?url"; import AlignVerticalBottom_ from "@mdi/svg/svg/align-vertical-bottom.svg?component"; import alignVerticalBottom_ from "@mdi/svg/svg/align-vertical-bottom.svg?url"; import AlignVerticalCenter_ from "@mdi/svg/svg/align-vertical-center.svg?component"; import alignVerticalCenter_ from "@mdi/svg/svg/align-vertical-center.svg?url"; import AlignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?component"; import alignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?url"; import CheckCircle_ from "@mdi/svg/svg/check-circle.svg?component"; import checkCircle_ from "@mdi/svg/svg/check-circle.svg?url"; import ChevronDown_ from "@mdi/svg/svg/chevron-down.svg?component"; import chevronDown_ from "@mdi/svg/svg/chevron-down.svg?url"; import ChevronUp_ from "@mdi/svg/svg/chevron-up.svg?component"; import chevronUp_ from "@mdi/svg/svg/chevron-up.svg?url"; import CloseBox_ from "@mdi/svg/svg/close-box.svg?component"; import closeBox_ from "@mdi/svg/svg/close-box.svg?url"; import Close_ from "@mdi/svg/svg/close.svg?component"; import close_ from "@mdi/svg/svg/close.svg?url"; import CodeTags_ from "@mdi/svg/svg/code-tags.svg?component"; import PlainText_ from "@mdi/svg/svg/code-tags.svg?component"; import codeTags_ from "@mdi/svg/svg/code-tags.svg?url"; import plainText_ from "@mdi/svg/svg/code-tags.svg?url"; import Cog_ from "@mdi/svg/svg/cog.svg?component"; import cog_ from "@mdi/svg/svg/cog.svg?url"; import ColorHelper_ from "@mdi/svg/svg/color-helper.svg?component"; import colorHelper_ from "@mdi/svg/svg/color-helper.svg?url"; import Cloze_ from "@mdi/svg/svg/contain.svg?component"; import cloze_ from "@mdi/svg/svg/contain.svg?url"; import Copy_ from "@mdi/svg/svg/content-copy.svg?component"; import copy_ from "@mdi/svg/svg/content-copy.svg?url"; import CursorDefaultOutline_ from "@mdi/svg/svg/cursor-default-outline.svg?component"; import cursorDefaultOutline_ from "@mdi/svg/svg/cursor-default-outline.svg?url"; import DeleteOutline_ from "@mdi/svg/svg/delete-outline.svg?component"; import deleteOutline_ from "@mdi/svg/svg/delete-outline.svg?url"; import Delete_ from "@mdi/svg/svg/delete.svg?component"; import delete_ from "@mdi/svg/svg/delete.svg?url"; import Dots_ from "@mdi/svg/svg/dots-vertical.svg?component"; import dots_ from "@mdi/svg/svg/dots-vertical.svg?url"; import HorizontalHandle_ from "@mdi/svg/svg/drag-horizontal.svg?component"; import horizontalHandle_ from "@mdi/svg/svg/drag-horizontal.svg?url"; import VerticalHandle_ from "@mdi/svg/svg/drag-vertical.svg?component"; import verticalHandle_ from "@mdi/svg/svg/drag-vertical.svg?url"; import Earth_ from "@mdi/svg/svg/earth.svg?component"; import earth_ from "@mdi/svg/svg/earth.svg?url"; import EllipseOutline_ from "@mdi/svg/svg/ellipse-outline.svg?component"; import ellipseOutline_ from "@mdi/svg/svg/ellipse-outline.svg?url"; import Eye_ from "@mdi/svg/svg/eye.svg?component"; import eye_ from "@mdi/svg/svg/eye.svg?url"; import FormatAlignCenter_ from "@mdi/svg/svg/format-align-center.svg?component"; import formatAlignCenter_ from "@mdi/svg/svg/format-align-center.svg?url"; import FormatBold_ from "@mdi/svg/svg/format-bold.svg?component"; import formatBold_ from "@mdi/svg/svg/format-bold.svg?url"; import FormatColorFill_ from "@mdi/svg/svg/format-color-fill.svg?component"; import formatColorFill_ from "@mdi/svg/svg/format-color-fill.svg?url"; import HighlightColor_ from "@mdi/svg/svg/format-color-highlight.svg?component"; import highlightColor_ from "@mdi/svg/svg/format-color-highlight.svg?url"; import TextColor_ from "@mdi/svg/svg/format-color-text.svg?component"; import textColor_ from "@mdi/svg/svg/format-color-text.svg?url"; import FloatLeft_ from "@mdi/svg/svg/format-float-left.svg?component"; import floatLeft_ from "@mdi/svg/svg/format-float-left.svg?url"; import FloatNone_ from "@mdi/svg/svg/format-float-none.svg?component"; import floatNone_ from "@mdi/svg/svg/format-float-none.svg?url"; import FloatRight_ from "@mdi/svg/svg/format-float-right.svg?component"; import floatRight_ from "@mdi/svg/svg/format-float-right.svg?url"; import RichText_ from "@mdi/svg/svg/format-font.svg?component"; import richText_ from "@mdi/svg/svg/format-font.svg?url"; import FormatItalic_ from "@mdi/svg/svg/format-italic.svg?component"; import formatItalic_ from "@mdi/svg/svg/format-italic.svg?url"; import Subscript_ from "@mdi/svg/svg/format-subscript.svg?component"; import subscript_ from "@mdi/svg/svg/format-subscript.svg?url"; import Superscript_ from "@mdi/svg/svg/format-superscript.svg?component"; import superscript_ from "@mdi/svg/svg/format-superscript.svg?url"; import FormatUnderline_ from "@mdi/svg/svg/format-underline.svg?component"; import formatUnderline_ from "@mdi/svg/svg/format-underline.svg?url"; import Inline_ from "@mdi/svg/svg/format-wrap-square.svg?component"; import inline_ from "@mdi/svg/svg/format-wrap-square.svg?url"; import Block_ from "@mdi/svg/svg/format-wrap-top-bottom.svg?component"; import block_ from "@mdi/svg/svg/format-wrap-top-bottom.svg?url"; import Function_ from "@mdi/svg/svg/function-variant.svg?component"; import function_ from "@mdi/svg/svg/function-variant.svg?url"; import Group_ from "@mdi/svg/svg/group.svg?component"; import group_ from "@mdi/svg/svg/group.svg?url"; import InfoCircle_ from "@mdi/svg/svg/help-circle.svg?component"; import infoCircle_ from "@mdi/svg/svg/help-circle.svg?url"; import SizeClear_ from "@mdi/svg/svg/image-remove.svg?component"; import sizeClear_ from "@mdi/svg/svg/image-remove.svg?url"; import SizeActual_ from "@mdi/svg/svg/image-size-select-actual.svg?component"; import sizeActual_ from "@mdi/svg/svg/image-size-select-actual.svg?url"; import SizeMinimized_ from "@mdi/svg/svg/image-size-select-large.svg?component"; import sizeMinimized_ from "@mdi/svg/svg/image-size-select-large.svg?url"; import ZoomReset_ from "@mdi/svg/svg/magnify-expand.svg?component"; import zoomReset_ from "@mdi/svg/svg/magnify-expand.svg?url"; import ZoomOut_ from "@mdi/svg/svg/magnify-minus-outline.svg?component"; import zoomOut_ from "@mdi/svg/svg/magnify-minus-outline.svg?url"; import ZoomIn_ from "@mdi/svg/svg/magnify-plus-outline.svg?component"; import zoomIn_ from "@mdi/svg/svg/magnify-plus-outline.svg?url"; import MagnifyScan_ from "@mdi/svg/svg/magnify-scan.svg?component"; import magnifyScan_ from "@mdi/svg/svg/magnify-scan.svg?url"; import Magnify_ from "@mdi/svg/svg/magnify.svg?component"; import magnify_ from "@mdi/svg/svg/magnify.svg?url"; import Math_ from "@mdi/svg/svg/math-integral-box.svg?component"; import math_ from "@mdi/svg/svg/math-integral-box.svg?url"; import NewBox_ from "@mdi/svg/svg/new-box.svg?component"; import newBox_ from "@mdi/svg/svg/new-box.svg?url"; import Paperclip_ from "@mdi/svg/svg/paperclip.svg?component"; import paperclip_ from "@mdi/svg/svg/paperclip.svg?url"; import RectangleOutline_ from "@mdi/svg/svg/rectangle-outline.svg?component"; import rectangleOutline_ from "@mdi/svg/svg/rectangle-outline.svg?url"; import Redo_ from "@mdi/svg/svg/redo.svg?component"; import redo_ from "@mdi/svg/svg/redo.svg?url"; import Refresh_ from "@mdi/svg/svg/refresh.svg?component"; import refresh_ from "@mdi/svg/svg/refresh.svg?url"; import SelectAll_ from "@mdi/svg/svg/select-all.svg?component"; import selectAll_ from "@mdi/svg/svg/select-all.svg?url"; import Square_ from "@mdi/svg/svg/square.svg?component"; import square_ from "@mdi/svg/svg/square.svg?url"; import TableRefresh_ from "@mdi/svg/svg/table-refresh.svg?component"; import tableRefresh_ from "@mdi/svg/svg/table-refresh.svg?url"; import Tag_ from "@mdi/svg/svg/tag-outline.svg?component"; import tag_ from "@mdi/svg/svg/tag-outline.svg?url"; import AddTag_ from "@mdi/svg/svg/tag-plus-outline.svg?component"; import addTag_ from "@mdi/svg/svg/tag-plus-outline.svg?url"; import TextBox_ from "@mdi/svg/svg/text-box.svg?component"; import textBox_ from "@mdi/svg/svg/text-box.svg?url"; import Undo_ from "@mdi/svg/svg/undo.svg?component"; import undo_ from "@mdi/svg/svg/undo.svg?url"; import UnfoldMoreHorizontal_ from "@mdi/svg/svg/unfold-more-horizontal.svg?component"; import unfoldMoreHorizontal_ from "@mdi/svg/svg/unfold-more-horizontal.svg?url"; import Ungroup_ from "@mdi/svg/svg/ungroup.svg?component"; import ungroup_ from "@mdi/svg/svg/ungroup.svg?url"; import Update_ from "@mdi/svg/svg/update.svg?component"; import update_ from "@mdi/svg/svg/update.svg?url"; import VectorPolygonVariant_ from "@mdi/svg/svg/vector-polygon-variant.svg?component"; import vectorPolygonVariant_ from "@mdi/svg/svg/vector-polygon-variant.svg?url"; import ViewDashboard_ from "@mdi/svg/svg/view-dashboard.svg?component"; import viewDashboard_ from "@mdi/svg/svg/view-dashboard.svg?url"; import Revert_ from "bootstrap-icons/icons/arrow-counterclockwise.svg?component"; import revert_ from "bootstrap-icons/icons/arrow-counterclockwise.svg?url"; import ArrowLeft_ from "bootstrap-icons/icons/arrow-left.svg?component"; import arrowLeft_ from "bootstrap-icons/icons/arrow-left.svg?url"; import ArrowRight_ from "bootstrap-icons/icons/arrow-right.svg?component"; import arrowRight_ from "bootstrap-icons/icons/arrow-right.svg?url"; import Minus_ from "bootstrap-icons/icons/dash-lg.svg?component"; import minus_ from "bootstrap-icons/icons/dash-lg.svg?url"; import Eraser_ from "bootstrap-icons/icons/eraser.svg?component"; import eraser_ from "bootstrap-icons/icons/eraser.svg?url"; import Exclamation_ from "bootstrap-icons/icons/exclamation-circle.svg?component"; import exclamation_ from "bootstrap-icons/icons/exclamation-circle.svg?url"; import JustifyFull_ from "bootstrap-icons/icons/justify.svg?component"; import justifyFull_ from "bootstrap-icons/icons/justify.svg?url"; import Ol_ from "bootstrap-icons/icons/list-ol.svg?component"; import ol_ from "bootstrap-icons/icons/list-ol.svg?url"; import Ul_ from "bootstrap-icons/icons/list-ul.svg?component"; import ul_ from "bootstrap-icons/icons/list-ul.svg?url"; import Mic_ from "bootstrap-icons/icons/mic.svg?component"; import mic_ from "bootstrap-icons/icons/mic.svg?url"; import Plus_ from "bootstrap-icons/icons/plus-lg.svg?component"; import plus_ from "bootstrap-icons/icons/plus-lg.svg?url"; import JustifyCenter_ from "bootstrap-icons/icons/text-center.svg?component"; import justifyCenter_ from "bootstrap-icons/icons/text-center.svg?url"; import Indent_ from "bootstrap-icons/icons/text-indent-left.svg?component"; import indent_ from "bootstrap-icons/icons/text-indent-left.svg?url"; import Outdent_ from "bootstrap-icons/icons/text-indent-right.svg?component"; import outdent_ from "bootstrap-icons/icons/text-indent-right.svg?url"; import JustifyLeft_ from "bootstrap-icons/icons/text-left.svg?component"; import justifyLeft_ from "bootstrap-icons/icons/text-left.svg?url"; import ListOptions_ from "bootstrap-icons/icons/text-paragraph.svg?component"; import listOptions_ from "bootstrap-icons/icons/text-paragraph.svg?url"; import JustifyRight_ from "bootstrap-icons/icons/text-right.svg?component"; import justifyRight_ from "bootstrap-icons/icons/text-right.svg?url"; import Bold_ from "bootstrap-icons/icons/type-bold.svg?component"; import bold_ from "bootstrap-icons/icons/type-bold.svg?url"; import Italic_ from "bootstrap-icons/icons/type-italic.svg?component"; import italic_ from "bootstrap-icons/icons/type-italic.svg?url"; import Underline_ from "bootstrap-icons/icons/type-underline.svg?component"; import underline_ from "bootstrap-icons/icons/type-underline.svg?url"; import IncrementCloze_ from "../../icons/contain-plus.svg?component"; import incrementCloze_ from "../../icons/contain-plus.svg?url"; import StickyHollow_ from "../../icons/sticky-pin-hollow.svg?component"; import stickyHollow_ from "../../icons/sticky-pin-hollow.svg?url"; import StickySolid_ from "../../icons/sticky-pin-solid.svg?component"; import stickySolid_ from "../../icons/sticky-pin-solid.svg?url"; export const checkCircle = { url: checkCircle_, component: CheckCircle_ }; export const chevronDown = { url: chevronDown_, component: ChevronDown_ }; export const chevronUp = { url: chevronUp_, component: ChevronUp_ }; export const closeBox = { url: closeBox_, component: CloseBox_ }; export const dotsIcon = { url: dots_, component: Dots_ }; export const horizontalHandle = { url: horizontalHandle_, component: HorizontalHandle_ }; export const verticalHandle = { url: verticalHandle_, component: VerticalHandle_ }; export const infoCircle = { url: infoCircle_, component: InfoCircle_ }; export const magnifyIcon = { url: magnify_, component: Magnify_ }; export const newBox = { url: newBox_, component: NewBox_ }; export const tagIcon = { url: tag_, component: Tag_ }; export const addTagIcon = { url: addTag_, component: AddTag_ }; export const updateIcon = { url: update_, component: Update_ }; export const revertIcon = { url: revert_, component: Revert_ }; export const arrowLeftIcon = { url: arrowLeft_, component: ArrowLeft_ }; export const arrowRightIcon = { url: arrowRight_, component: ArrowRight_ }; export const minusIcon = { url: minus_, component: Minus_ }; export const exclamationIcon = { url: exclamation_, component: Exclamation_ }; export const plusIcon = { url: plus_, component: Plus_ }; export const alertIcon = { url: alert_, component: Alert_ }; export const plainTextIcon = { url: plainText_, component: PlainText_ }; export const clozeIcon = { url: cloze_, component: Cloze_ }; export const richTextIcon = { url: richText_, component: RichText_ }; export const stickyIconHollow = { url: stickyHollow_, component: StickyHollow_ }; export const stickyIconSolid = { url: stickySolid_, component: StickySolid_ }; export const mathIcon = { url: math_, component: Math_ }; export const floatLeftIcon = { url: floatLeft_, component: FloatLeft_ }; export const floatNoneIcon = { url: floatNone_, component: FloatNone_ }; export const floatRightIcon = { url: floatRight_, component: FloatRight_ }; export const sizeClear = { url: sizeClear_, component: SizeClear_ }; export const sizeActual = { url: sizeActual_, component: SizeActual_ }; export const sizeMinimized = { url: sizeMinimized_, component: SizeMinimized_ }; export const cogIcon = { url: cog_, component: Cog_ }; export const colorHelperIcon = { url: colorHelper_, component: ColorHelper_ }; export const highlightColorIcon = { url: highlightColor_, component: HighlightColor_ }; export const textColorIcon = { url: textColor_, component: TextColor_ }; export const subscriptIcon = { url: subscript_, component: Subscript_ }; export const superscriptIcon = { url: superscript_, component: Superscript_ }; export const functionIcon = { url: function_, component: Function_ }; export const paperclipIcon = { url: paperclip_, component: Paperclip_ }; export const mdiRefresh = { url: refresh_, component: Refresh_ }; export const mdiTableRefresh = { url: tableRefresh_, component: TableRefresh_ }; export const mdiViewDashboard = { url: viewDashboard_, component: ViewDashboard_ }; export const eraserIcon = { url: eraser_, component: Eraser_ }; export const justifyFullIcon = { url: justifyFull_, component: JustifyFull_ }; export const olIcon = { url: ol_, component: Ol_ }; export const ulIcon = { url: ul_, component: Ul_ }; export const micIcon = { url: mic_, component: Mic_ }; export const justifyCenterIcon = { url: justifyCenter_, component: JustifyCenter_ }; export const indentIcon = { url: indent_, component: Indent_ }; export const outdentIcon = { url: outdent_, component: Outdent_ }; export const justifyLeftIcon = { url: justifyLeft_, component: JustifyLeft_ }; export const listOptionsIcon = { url: listOptions_, component: ListOptions_ }; export const justifyRightIcon = { url: justifyRight_, component: JustifyRight_ }; export const boldIcon = { url: bold_, component: Bold_ }; export const italicIcon = { url: italic_, component: Italic_ }; export const underlineIcon = { url: underline_, component: Underline_ }; export const deleteIcon = { url: delete_, component: Delete_ }; export const inlineIcon = { url: inline_, component: Inline_ }; export const blockIcon = { url: block_, component: Block_ }; export const mdiAlignHorizontalCenter = { url: alignHorizontalCenter_, component: AlignHorizontalCenter_ }; export const mdiAlignHorizontalLeft = { url: alignHorizontalLeft_, component: AlignHorizontalLeft_ }; export const mdiAlignHorizontalRight = { url: alignHorizontalRight_, component: AlignHorizontalRight_ }; export const mdiAlignVerticalBottom = { url: alignVerticalBottom_, component: AlignVerticalBottom_ }; export const mdiAlignVerticalCenter = { url: alignVerticalCenter_, component: AlignVerticalCenter_ }; export const mdiAlignVerticalTop = { url: alignVerticalTop_, component: AlignVerticalTop_ }; export const mdiClose = { url: close_, component: Close_ }; export const mdiCodeTags = { url: codeTags_, component: CodeTags_ }; export const mdiCopy = { url: copy_, component: Copy_ }; export const mdiCursorDefaultOutline = { url: cursorDefaultOutline_, component: CursorDefaultOutline_ }; export const mdiDeleteOutline = { url: deleteOutline_, component: DeleteOutline_ }; export const mdiEllipseOutline = { url: ellipseOutline_, component: EllipseOutline_ }; export const mdiEye = { url: eye_, component: Eye_ }; export const mdiFormatAlignCenter = { url: formatAlignCenter_, component: FormatAlignCenter_ }; export const mdiFormatBold = { url: formatBold_, component: FormatBold_ }; export const mdiFormatColorFill = { url: formatColorFill_, component: FormatColorFill_ }; export const mdiFormatItalic = { url: formatItalic_, component: FormatItalic_ }; export const mdiFormatUnderline = { url: formatUnderline_, component: FormatUnderline_ }; export const mdiGroup = { url: group_, component: Group_ }; export const mdiZoomReset = { url: zoomReset_, component: ZoomReset_ }; export const mdiZoomOut = { url: zoomOut_, component: ZoomOut_ }; export const mdiZoomIn = { url: zoomIn_, component: ZoomIn_ }; export const mdiMagnifyScan = { url: magnifyScan_, component: MagnifyScan_ }; export const mdiRectangleOutline = { url: rectangleOutline_, component: RectangleOutline_ }; export const mdiRedo = { url: redo_, component: Redo_ }; export const mdiSelectAll = { url: selectAll_, component: SelectAll_ }; export const mdiSquare = { url: square_, component: Square_ }; export const mdiTextBox = { url: textBox_, component: TextBox_ }; export const mdiUndo = { url: undo_, component: Undo_ }; export const mdiUnfoldMoreHorizontal = { url: unfoldMoreHorizontal_, component: UnfoldMoreHorizontal_ }; export const mdiUngroup = { url: ungroup_, component: Ungroup_ }; export const mdiVectorPolygonVariant = { url: vectorPolygonVariant_, component: VectorPolygonVariant_ }; export const incrementClozeIcon = { url: incrementCloze_, component: IncrementCloze_ }; export const mdiEarth = { url: earth_, component: Earth_ }; ================================================ FILE: ts/lib/components/resizable.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { Writable } from "svelte/store"; import { writable } from "svelte/store"; export interface Resizer { start(): void; /** * @returns Actually applied resize. If the resizedWidth is too small, * no resize can be applied anymore. */ resize(increment: number): number; setSize(size: number): void; stop(fullWidth: number, amount: number): void; } interface ResizedStores { resizesDimension: Writable; resizedDimension: Writable; } type ResizableResult = [ ResizedStores, (element: HTMLElement, getter: (element: HTMLElement) => number) => void, Resizer, ]; export function resizable( baseSize: number, resizes: Writable, paneSize: Writable, ): ResizableResult { const resizesDimension = writable(false); const resizedDimension = writable(0); let pane: HTMLElement; let getter: (element: HTMLElement) => number; let dimension = 0; function resizeAction( element: HTMLElement, getValue: (element: HTMLElement) => number, ): void { pane = element; getter = getValue; } function start() { resizes.set(true); resizesDimension.set(true); dimension = getter(pane); resizedDimension.set(dimension); } function resize(increment = 0): number { if (dimension + increment < 0) { const applied = -dimension; dimension = 0; resizedDimension.set(dimension); return applied; } dimension += increment; resizedDimension.set(dimension); return increment; } function setSize(size = 0): void { paneSize.set(size); } function stop(fullDimension: number, amount: number): void { paneSize.set((dimension / fullDimension) * amount * baseSize); resizesDimension.set(false); resizes.set(false); } return [ { resizesDimension, resizedDimension }, resizeAction, { start, resize, setSize, stop }, ]; } ================================================ FILE: ts/lib/components/types.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export type Size = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; export type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl" | "xxl"; export type HelpItem = { title: string; help?: string; url?: string; sched?: HelpItemScheduler; global?: boolean; }; export enum HelpItemScheduler { SM2 = 0, FSRS = 1, } export type IconData = { url: string; }; ================================================ FILE: ts/lib/domlib/content-editable.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /** * Trivial wrapper to silence Svelte deprecation warnings */ export function execCommand( command: string, showUI?: boolean | undefined, value?: string | undefined, ): void { document.execCommand(command, showUI, value); } /** * Trivial wrappers to silence Svelte deprecation warnings */ export function queryCommandState(command: string): boolean { return document.queryCommandState(command); } ================================================ FILE: ts/lib/domlib/find-above.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { nodeIsElement } from "@tslib/dom"; export type Matcher = (element: Element) => boolean; function findParent(current: Node, base: Element): Element | null { if (current === base) { return null; } return current.parentElement; } /** * Similar to element.closest(), but allows you to pass in a predicate * function, instead of a selector * * @remarks * Unlike element.closest, this will not match against `node`, but will start * at `node.parentElement`. */ export function findClosest( node: Node, base: Element, matcher: Matcher, ): Element | null { if (nodeIsElement(node) && matcher(node)) { return node; } let current = findParent(node, base); while (current) { if (matcher(current)) { return current; } current = findParent(current, base); } return null; } /** * Similar to `findClosest`, but will go as far as possible. */ export function findFarthest( node: Node, base: Element, matcher: Matcher, ): Element | null { let farthest: Element | null = null; let current: Node | null = node; while (current) { const next = findClosest(current, base, matcher); if (next) { farthest = next; current = findParent(next, base); } else { break; } } return farthest; } ================================================ FILE: ts/lib/domlib/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export * from "./content-editable"; export * from "./location"; export * from "./move-nodes"; export * from "./place-caret"; export * from "./surround"; ================================================ FILE: ts/lib/domlib/location/document.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { getSelection } from "@tslib/cross-browser"; import { findNodeFromCoordinates } from "./node"; import type { SelectionLocation, SelectionLocationContent } from "./selection"; import { getSelectionLocation } from "./selection"; function unselect(selection: Selection): void { selection.empty(); } function setSelectionToLocationContent( node: Node, selection: Selection, range: Range, location: SelectionLocationContent, ) { const focusLocation = location.focus; const focusOffset = focusLocation.offset; const focusNode = findNodeFromCoordinates(node, focusLocation.coordinates); if (location.direction === "forward") { range.setEnd(focusNode!, focusOffset!); selection.addRange(range); } /* location.direction === "backward" */ else { selection.addRange(range); selection.extend(focusNode!, focusOffset!); } } export function saveSelection(base: Node): SelectionLocation | null { return getSelectionLocation(base); } export function restoreSelection(base: Node, location: SelectionLocation): void { const selection = getSelection(base)!; unselect(selection); const range = new Range(); const anchorNode = findNodeFromCoordinates(base, location.anchor.coordinates); range.setStart(anchorNode!, location.anchor.offset!); if (location.collapsed) { range.collapse(true); selection.addRange(range); } else { setSelectionToLocationContent( base, selection, range, location, ); } } ================================================ FILE: ts/lib/domlib/location/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { registerPackage } from "@tslib/runtime-require"; import { restoreSelection, saveSelection } from "./document"; import { Position } from "./location"; import { findNodeFromCoordinates, getNodeCoordinates } from "./node"; import { getRangeCoordinates } from "./range"; registerPackage("anki/location", { Position, restoreSelection, saveSelection, }); export { findNodeFromCoordinates, getNodeCoordinates, getRangeCoordinates, Position, restoreSelection, saveSelection }; export type { RangeCoordinates } from "./range"; export type { SelectionLocation } from "./selection"; ================================================ FILE: ts/lib/domlib/location/location.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export interface CaretLocation { coordinates: number[]; offset: number; } export enum Position { Before = -1, Equal, After, } /** * @returns: Whether first is positioned {before,equal to,after} second */ export function compareLocations( first: CaretLocation, second: CaretLocation, ): Position { const smallerLength = Math.min(first.coordinates.length, second.coordinates.length); for (let i = 0; i <= smallerLength; i++) { if (first.coordinates.length === i) { if (second.coordinates.length === i) { if (first.offset < second.offset) { return Position.Before; } else if (first.offset > second.offset) { return Position.After; } else { return Position.Equal; } } return Position.Before; } else if (second.coordinates.length === i) { return Position.After; } else if (first.coordinates[i] < second.coordinates[i]) { return Position.Before; } else if (first.coordinates[i] > second.coordinates[i]) { return Position.After; } } throw new Error("compareLocations: Should never happen"); } ================================================ FILE: ts/lib/domlib/location/node.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html function getNodeCoordinatesRecursive( node: Node, base: Node, coordinates: number[], ): number[] { /* parentNode: Element | Document | DocumentFragment */ if (!node.parentNode || node === base) { return coordinates; } else { const parent = node.parentNode; const newCoordinates = [ Array.prototype.indexOf.call(node.parentNode.childNodes, node), ...coordinates, ]; return getNodeCoordinatesRecursive(parent, base, newCoordinates); } } export function getNodeCoordinates(node: Node, base: Node): number[] { return getNodeCoordinatesRecursive(node, base, []); } export function findNodeFromCoordinates( base: Node, coordinates: number[], ): Node | null { if (coordinates.length === 0) { return base; } else if (!base.childNodes[coordinates[0]]) { return null; } else { const [firstCoordinate, ...restCoordinates] = coordinates; return findNodeFromCoordinates( base.childNodes[firstCoordinate], restCoordinates, ); } } ================================================ FILE: ts/lib/domlib/location/range.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { CaretLocation } from "./location"; import { getNodeCoordinates } from "./node"; interface RangeCoordinatesCollapsed { readonly start: CaretLocation; readonly collapsed: true; } export interface RangeCoordinatesContent { readonly start: CaretLocation; readonly end: CaretLocation; readonly collapsed: false; } export type RangeCoordinates = RangeCoordinatesCollapsed | RangeCoordinatesContent; export function getRangeCoordinates(range: Range, base: Node): RangeCoordinates { const startCoordinates = getNodeCoordinates(base, range.startContainer); const start = { coordinates: startCoordinates, offset: range.startOffset }; const collapsed = range.collapsed; if (collapsed) { return { start, collapsed }; } const endCoordinates = getNodeCoordinates(base, range.endContainer); const end = { coordinates: endCoordinates, offset: range.endOffset }; return { start, end, collapsed }; } ================================================ FILE: ts/lib/domlib/location/selection.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { getRange, getSelection } from "@tslib/cross-browser"; import type { CaretLocation } from "./location"; import { compareLocations, Position } from "./location"; import { getNodeCoordinates } from "./node"; export interface SelectionLocationCollapsed { readonly anchor: CaretLocation; readonly collapsed: true; } export interface SelectionLocationContent { readonly anchor: CaretLocation; readonly focus: CaretLocation; readonly collapsed: false; readonly direction: "forward" | "backward"; } export type SelectionLocation = SelectionLocationCollapsed | SelectionLocationContent; export function getSelectionLocation(base: Node): SelectionLocation | null { const selection = getSelection(base)!; const range = getRange(selection); if (!range) { return null; } const collapsed = range.collapsed; const anchorCoordinates = getNodeCoordinates(selection.anchorNode!, base); const anchor = { coordinates: anchorCoordinates, offset: selection.anchorOffset }; if (collapsed) { return { anchor, collapsed }; } const focusCoordinates = getNodeCoordinates(selection.focusNode!, base); const focus = { coordinates: focusCoordinates, offset: selection.focusOffset }; const order = compareLocations(anchor, focus); const direction = order === Position.After ? "backward" : "forward"; return { anchor, focus, collapsed, direction, }; } ================================================ FILE: ts/lib/domlib/move-nodes.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { nodeIsElement, nodeIsText } from "@tslib/dom"; import { placeCaretAfter } from "./place-caret"; export function moveChildOutOfElement( element: Element, child: Node, placement: "beforebegin" | "afterend", ): Node { if (child.isConnected) { child.parentNode!.removeChild(child); } let referenceNode: Node; if (nodeIsElement(child)) { referenceNode = element.insertAdjacentElement(placement, child)!; } else if (nodeIsText(child)) { element.insertAdjacentText(placement, child.wholeText); referenceNode = placement === "beforebegin" ? element.previousSibling! : element.nextSibling!; } else { throw "moveChildOutOfElement: unsupported"; } return referenceNode; } export function moveNodesInsertedOutside(element: Element, allowedChild: Node): void { if (element.childNodes.length === 1) { return; } const childNodes = [...element.childNodes]; const allowedIndex = childNodes.findIndex((child) => child === allowedChild); const beforeChildren = childNodes.slice(0, allowedIndex); const afterChildren = childNodes.slice(allowedIndex + 1); // Special treatment for pressing return after mathjax block if ( afterChildren.length === 2 && afterChildren.every((child) => (child as Element).tagName === "BR") ) { const first = afterChildren.pop(); element.removeChild(first!); } let lastNode: Node | null = null; for (const node of beforeChildren) { lastNode = moveChildOutOfElement(element, node, "beforebegin"); } for (const node of afterChildren) { lastNode = moveChildOutOfElement(element, node, "afterend"); } if (lastNode) { placeCaretAfter(lastNode); } } ================================================ FILE: ts/lib/domlib/place-caret.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { getSelection } from "@tslib/cross-browser"; function placeCaret(node: Node, range: Range): void { const selection = getSelection(node)!; selection.removeAllRanges(); selection.addRange(range); } export function placeCaretBefore(node: Node): void { const range = new Range(); range.setStartBefore(node); range.collapse(true); placeCaret(node, range); } export function placeCaretAfter(node: Node): void { const range = new Range(); range.setStartAfter(node); range.collapse(true); placeCaret(node, range); } export function placeCaretAfterContent(node: Node): void { const range = new Range(); range.selectNodeContents(node); range.collapse(false); placeCaret(node, range); } ================================================ FILE: ts/lib/domlib/surround/apply/format.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { SurroundFormat } from "../surround-format"; import type { FormattingNode } from "../tree"; export class ApplyFormat { constructor(protected readonly format: SurroundFormat) {} applyFormat(node: FormattingNode): boolean { if (this.format.surroundElement) { node.range .toDOMRange() .surroundContents(this.format.surroundElement.cloneNode(false)); return true; } else if (this.format.formatter) { return this.format.formatter(node); } return false; } } export class UnsurroundApplyFormat extends ApplyFormat { applyFormat(node: FormattingNode): boolean { if (node.insideRange) { return false; } return super.applyFormat(node); } } export class ReformatApplyFormat extends ApplyFormat { applyFormat(node: FormattingNode): boolean { if (!node.hasMatch) { return false; } return super.applyFormat(node); } } ================================================ FILE: ts/lib/domlib/surround/apply/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { TreeNode } from "../tree"; import { FormattingNode } from "../tree"; import type { ApplyFormat } from "./format"; function iterate(node: TreeNode, format: ApplyFormat, leftShift: number): number { let innerShift = 0; for (const child of node.children) { innerShift += iterate(child, format, innerShift); } return node instanceof FormattingNode ? applyFormat(node, format, leftShift, innerShift) : 0; } /** * @returns Inner shift. */ function applyFormat( node: FormattingNode, format: ApplyFormat, leftShift: number, innerShift: number, ): number { node.range.startIndex += leftShift; node.range.endIndex += leftShift + innerShift; return format.applyFormat(node) ? node.range.startIndex - node.range.endIndex + 1 : 0; } export function apply(nodes: TreeNode[], format: ApplyFormat): void { let innerShift = 0; for (const node of nodes) { innerShift += iterate(node, format, innerShift); } } export { ApplyFormat, ReformatApplyFormat, UnsurroundApplyFormat } from "./format"; ================================================ FILE: ts/lib/domlib/surround/build/add-merge.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { TreeNode } from "../tree"; import { FormattingNode } from "../tree"; import type { BuildFormat } from "./format"; function mergeAppendNode( initial: TreeNode[], last: FormattingNode, format: BuildFormat, ): TreeNode[] { const minimized: TreeNode[] = [last]; for (let i = initial.length - 1; i >= 0; i--) { const next = initial[i]; let merged: FormattingNode | null; if (next instanceof FormattingNode && (merged = format.tryMerge(next, last))) { minimized[0] = merged; } else { minimized.unshift(...initial.slice(0, i + 1)); break; } } return minimized; } /** * Tries to merge `last`, into the end of `initial`. */ export function appendNode( initial: TreeNode[], last: TreeNode, format: BuildFormat, ): TreeNode[] { if (last instanceof FormattingNode) { return mergeAppendNode(initial, last, format); } else { return [...initial, last]; } } function mergeInsertNode( first: FormattingNode, tail: TreeNode[], format: BuildFormat, ): TreeNode[] { const minimized: TreeNode[] = [first]; for (let i = 0; i <= tail.length; i++) { const next = tail[i]; let merged: FormattingNode | null; if (next instanceof FormattingNode && (merged = format.tryMerge(first, next))) { minimized[0] = merged; } else { minimized.push(...tail.slice(i)); break; } } return minimized; } /** * Tries to merge `first`, into the start of `tail`. */ export function insertNode( first: TreeNode, tail: TreeNode[], format: BuildFormat, ): TreeNode[] { if (first instanceof FormattingNode) { return mergeInsertNode(first, tail, format); } else { return [first, ...tail]; } } ================================================ FILE: ts/lib/domlib/surround/build/build-tree.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { elementIsEmpty, nodeIsElement, nodeIsText } from "@tslib/dom"; import type { Match } from "../match-type"; import type { TreeNode } from "../tree"; import { BlockNode, ElementNode, FormattingNode } from "../tree"; import { appendNode } from "./add-merge"; import type { BuildFormat } from "./format"; function buildFromElement( element: Element, format: BuildFormat, matchAncestors: Match[], ): TreeNode[] { const match = format.createMatch(element); if (match.matches) { matchAncestors = [...matchAncestors, match]; } let children: TreeNode[] = []; for (const child of [...element.childNodes]) { const nodes = buildFromNode(child, format, matchAncestors); for (const node of nodes) { children = appendNode(children, node, format); } } if (match.shouldRemove()) { const parent = element.parentElement!; const childIndex = Array.prototype.indexOf.call(parent.childNodes, element); for (const child of children) { if (child instanceof FormattingNode) { if (child.hasMatchHoles) { child.matchLeaves.push(match); child.hasMatchHoles = false; } child.range.parent = parent; child.range.startIndex += childIndex; child.range.endIndex += childIndex; } } element.replaceWith(...element.childNodes); return children; } const matchNode = ElementNode.make( element, children.every((node: TreeNode): boolean => node.insideRange), ); if (children.length === 0) { // This means there are no non-negligible children return []; } else if (children.length === 1) { const [only] = children; if ( // blocking only instanceof BlockNode // ascension || (only instanceof FormattingNode && format.tryAscend(only, matchNode)) ) { return [only]; } } matchNode.replaceChildren(...children); return [matchNode]; } function buildFromText( text: Text, format: BuildFormat, matchAncestors: Match[], ): FormattingNode | BlockNode { const insideRange = format.isInsideRange(text); if (!insideRange && matchAncestors.length === 0) { return BlockNode.make(); } return FormattingNode.fromText(text, insideRange, matchAncestors); } function elementIsNegligible(element: Element): boolean { return elementIsEmpty(element); } function textIsNegligible(text: Text): boolean { return text.length === 0; } /** * Builds a formatting tree starting at node. * * @returns root of the formatting tree */ export function buildFromNode( node: Node, format: BuildFormat, matchAncestors: Match[], ): TreeNode[] { if (nodeIsText(node) && !textIsNegligible(node)) { return [buildFromText(node, format, matchAncestors)]; } else if (nodeIsElement(node) && !elementIsNegligible(node)) { return buildFromElement(node, format, matchAncestors); } else { return []; } } ================================================ FILE: ts/lib/domlib/surround/build/extend-merge.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { BuildFormat } from "../build"; import type { TreeNode } from "../tree"; import { FormattingNode } from "../tree"; import { appendNode, insertNode } from "./add-merge"; import { buildFromNode } from "./build-tree"; function mergePreviousTrees(forest: TreeNode[], format: BuildFormat): TreeNode[] { const [first, ...tail] = forest; if (!(first instanceof FormattingNode)) { return forest; } let merged: TreeNode[] = [first]; let sibling = first.range.firstChild.previousSibling; while (sibling && merged.length === 1) { const nodes = buildFromNode(sibling, format, []); for (const node of nodes) { merged = insertNode(node, merged, format); } sibling = sibling.previousSibling; } return [...merged, ...tail]; } function mergeNextTrees(forest: TreeNode[], format: BuildFormat): TreeNode[] { const initial = forest.slice(0, -1); const last = forest[forest.length - 1]; if (!(last instanceof FormattingNode)) { return forest; } let merged: TreeNode[] = [last]; let sibling = last.range.lastChild.nextSibling; while (sibling && merged.length === 1) { const nodes = buildFromNode(sibling, format, []); for (const node of nodes) { merged = appendNode(merged, node, format); } sibling = sibling.nextSibling; } return [...initial, ...merged]; } export function extendAndMerge( forest: TreeNode[], format: BuildFormat, ): TreeNode[] { const merged = mergeNextTrees(mergePreviousTrees(forest, format), format); if (merged.length === 1) { const [only] = merged; if (only instanceof FormattingNode) { const elementNode = only.getExtension(); if (elementNode && format.tryAscend(only, elementNode)) { return extendAndMerge(merged, format); } } } return merged; } ================================================ FILE: ts/lib/domlib/surround/build/format.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { elementIsBlock } from "@tslib/dom"; import { Position } from "../../location"; import { Match } from "../match-type"; import type { SplitRange } from "../split-text"; import type { SurroundFormat } from "../surround-format"; import type { ElementNode } from "../tree"; import { FormattingNode } from "../tree"; function nodeWithinRange(node: Node, range: Range): boolean { const nodeRange = new Range(); nodeRange.selectNodeContents(node); return ( range.compareBoundaryPoints(Range.START_TO_START, nodeRange) !== Position.After && range.compareBoundaryPoints(Range.END_TO_END, nodeRange) !== Position.Before ); } /** * Takes user-provided functions as input, to modify certain parts of the algorithm. */ export class BuildFormat { constructor( public readonly format: SurroundFormat, public readonly base: Element, public readonly range: Range, public readonly splitRange: SplitRange, ) {} createMatch(element: Element): Match { const match = new Match(); this.format.matcher(element as HTMLElement | SVGElement, match); return match; } tryMerge( before: FormattingNode, after: FormattingNode, ): FormattingNode | null { if (!this.format.merger || this.format.merger(before, after)) { return FormattingNode.merge(before, after); } return null; } tryAscend(node: FormattingNode, elementNode: ElementNode): boolean { if (!elementIsBlock(elementNode.element) && elementNode.element !== this.base) { node.ascendAbove(elementNode); return true; } return false; } isInsideRange(node: Node): boolean { return nodeWithinRange(node, this.range); } announceElementRemoval(element: Element): void { this.splitRange.adjustRange(element); } recreateRange(): Range { return this.splitRange.toDOMRange(); } } export class UnsurroundBuildFormat extends BuildFormat { tryMerge( before: FormattingNode, after: FormattingNode, ): FormattingNode | null { if (before.insideRange !== after.insideRange) { return null; } return super.tryMerge(before, after); } } export class ReformatBuildFormat extends BuildFormat { tryMerge( before: FormattingNode, after: FormattingNode, ): FormattingNode | null { if (before.hasMatch !== after.hasMatch) { return null; } return super.tryMerge(before, after); } } ================================================ FILE: ts/lib/domlib/surround/build/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { TreeNode } from "../tree"; import { buildFromNode } from "./build-tree"; import { extendAndMerge } from "./extend-merge"; import type { BuildFormat } from "./format"; /** * Builds a TreeNode forest structure from an input node. * * @remarks * This will remove matching elements from the DOM. This is necessary to make * some normalizations. * * @param node: This node should have no matching ancestors. */ export function build(node: Node, build: BuildFormat): TreeNode[] { return extendAndMerge(buildFromNode(node, build, []), build); } export { BuildFormat, ReformatBuildFormat, UnsurroundBuildFormat } from "./format"; ================================================ FILE: ts/lib/domlib/surround/flat-range.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { nodeIsComment, nodeIsElement, nodeIsText } from "@tslib/dom"; import { ascend } from "@tslib/node"; /** * Represents a subset of DOM ranges which can be called with `.surroundContents()`. */ export class FlatRange { private constructor( public parent: Node, public startIndex: number, public endIndex: number, ) {} /** * The new flat range does not represent the range itself but * rather a possible new node that surrounds the boundary points * (node, start) till (node, end). * * @remarks * Indices should be >= 0 and startIndex <= endIndex. */ static make(node: Node, startIndex: number, endIndex = startIndex + 1): FlatRange { return new FlatRange(node, startIndex, endIndex); } /** * @remarks * Must be sibling flat ranges. */ static merge(before: FlatRange, after: FlatRange): FlatRange { return FlatRange.make(before.parent, before.startIndex, after.endIndex); } /** * @remarks */ static fromNode(node: Node): FlatRange { const parent = ascend(node); const index = Array.prototype.indexOf.call(parent.childNodes, node); return FlatRange.make(parent, index); } get firstChild(): ChildNode { return this.parent.childNodes[this.startIndex]; } get lastChild(): ChildNode { return this.parent.childNodes[this.endIndex - 1]; } /** * @see `fromNode` */ select(node: Node): void { this.parent = ascend(node); this.startIndex = Array.prototype.indexOf.call(this.parent.childNodes, node); this.endIndex = this.startIndex + 1; } toDOMRange(): Range { const range = new Range(); range.setStart(this.parent, this.startIndex); range.setEnd(this.parent, this.endIndex); if (range.collapsed) { // If the range is collapsed to a single element, move the range inside the element. // This prevents putting the surround above the base element. const selected = range.commonAncestorContainer.childNodes[range.startOffset]; if (nodeIsElement(selected)) { range.selectNode(selected); } } return range; } [Symbol.iterator](): Iterator { const parent = this.parent; const end = this.endIndex; let step = this.startIndex; return { next(): IteratorResult { if (step >= end) { return { value: null, done: true }; } return { value: parent.childNodes[step++], done: false }; }, }; } /** * @returns Amount of contained nodes */ get length(): number { return this.endIndex - this.startIndex; } toString(): string { let output = ""; for (const node of [...this]) { if (nodeIsText(node)) { output += node.data; } else if (nodeIsComment(node)) { output += ``; } else if (nodeIsElement(node)) { output += node.outerHTML; } } return output; } } ================================================ FILE: ts/lib/domlib/surround/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export type { MatchType } from "./match-type"; export { boolMatcher } from "./match-type"; export { reformat, surround, unsurround } from "./surround"; export type { SurroundFormat } from "./surround-format"; export type { FormattingNode } from "./tree"; ================================================ FILE: ts/lib/domlib/surround/match-type.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { SurroundFormat } from "./surround-format"; export interface MatchType { /** * The element represented by the match will be removed from the document. */ remove(): void; /** * If the element has some styling applied that matches the format, but * might contain some styling above that, you should use clear and do the * modifying in the callback. * * @remarks * You can still call `match.remove()` in the callback * * @example * If you want to match bold elements, ` * should match via `clear`, but should not be removed, because it still * has a class applied, even if the `style` attribute is removed. */ clear(callback: () => void): void; /** * Used to sustain a value that is needed to recreate the surrounding. * Can be retrieved from the FormattingNode interface via `.getCache`. */ setCache(value: T): void; } type Callback = () => void; export class Match implements MatchType { private _shouldRemove = false; remove(): void { this._shouldRemove = true; } private _callback: Callback | null = null; clear(callback: Callback): void { this._callback = callback; } get matches(): boolean { return Boolean(this._callback) || this._shouldRemove; } /** * @internal */ shouldRemove(): boolean { this._callback?.(); this._callback = null; return this._shouldRemove; } cache: T | null = null; setCache(value: T): void { this.cache = value; } } class FakeMatch implements MatchType { public value = false; remove(): void { this.value = true; } clear(): void { this.value = true; } setCache(): void { // noop } } /** * Turns the format.matcher into a function that can be used with `findAbove`. */ export function boolMatcher( format: SurroundFormat, ): (element: Element) => boolean { return function(element: Element): boolean { const fake = new FakeMatch(); format.matcher(element as HTMLElement | SVGElement, fake); return fake.value; }; } ================================================ FILE: ts/lib/domlib/surround/split-text.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { nodeIsText } from "@tslib/dom"; /** * @link https://dom.spec.whatwg.org/#concept-node-length */ function length(node: Node): number { if (node instanceof CharacterData) { return node.length; } else if ( node.nodeType === Node.DOCUMENT_TYPE_NODE || node.nodeType === Node.ATTRIBUTE_NODE ) { return 0; } return node.childNodes.length; } /** * Wrapper around DOM ranges that are passed into evaluation and are adjusted, * if its start or end nodes are to be removed */ export class SplitRange { constructor(protected start: Node, protected end: Node) {} private adjustStart(): void { if (this.start.firstChild) { this.start = this.start.firstChild; } else if (this.start.nextSibling) { this.start = this.start.nextSibling!; } } private adjustEnd(): void { if (this.end.lastChild) { this.end = this.end.lastChild!; } else if (this.end.previousSibling) { this.end = this.end.previousSibling; } } adjustRange(element: Element): void { if (this.start === element) { this.adjustStart(); } else if (this.end === element) { this.adjustEnd(); } } /** * Returns a range with boundary points `(start, 0)` and `(end, end.length)`. */ toDOMRange(): Range { const range = new Range(); range.setStart(this.start, 0); range.setEnd(this.end, length(this.end)); return range; } } /** * @returns Split text node to end direction or text itself if a split is * not necessary */ function splitTextIfNecessary(text: Text, offset: number): Text { if (offset === 0 || offset === text.length) { return text; } return text.splitText(offset); } export function splitPartiallySelected(range: Range): SplitRange { let start: Node; if (nodeIsText(range.startContainer)) { start = splitTextIfNecessary(range.startContainer, range.startOffset); } else { start = range.startContainer.childNodes[range.startOffset]; } let end: Node; if (nodeIsText(range.endContainer)) { end = range.endContainer; splitTextIfNecessary(range.endContainer, range.endOffset); } else { end = range.endContainer.childNodes[range.endOffset - 1]; } return new SplitRange(start, end); } ================================================ FILE: ts/lib/domlib/surround/surround-format.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { MatchType } from "./match-type"; import type { FormattingNode } from "./tree"; export interface SurroundFormat { /** * Determine whether element matches the format. Confirm by calling * `match.remove` or `match.clear`. Sustain parameters provided to the format * by calling `match.setCache`. */ matcher: (element: HTMLElement | SVGElement, match: MatchType) => void; /** * @returns Whether before or after are allowed to merge to a single * FormattingNode range */ merger?: (before: FormattingNode, after: FormattingNode) => boolean; /** * Apply according to this formatter. * * @returns Whether formatter added a new element around the range. */ formatter?: (node: FormattingNode) => boolean; /** * Surround with this node as formatting. Shorthand alternative to `formatter`. */ surroundElement?: Element; } ================================================ FILE: ts/lib/domlib/surround/surround.test.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // @vitest-environment jsdom import { beforeEach, describe, expect, test } from "vitest"; import { surround } from "./surround"; import { easyBold, easyUnderline, p } from "./test-utils"; describe("surround text", () => { let body: HTMLBodyElement; beforeEach(() => { body = p("111222"); }); test("all text", () => { const range = new Range(); range.selectNode(body.firstChild!); const surroundedRange = surround(range, body, easyBold); expect(body).toHaveProperty("innerHTML", "111222"); expect(surroundedRange.toString()).toEqual("111222"); }); test("first half", () => { const range = new Range(); range.setStart(body.firstChild!, 0); range.setEnd(body.firstChild!, 3); const surroundedRange = surround(range, body, easyBold); expect(body).toHaveProperty("innerHTML", "111222"); expect(surroundedRange.toString()).toEqual("111"); }); test("second half", () => { const range = new Range(); range.setStart(body.firstChild!, 3); range.setEnd(body.firstChild!, 6); const surroundedRange = surround(range, body, easyBold); expect(body).toHaveProperty("innerHTML", "111222"); expect(surroundedRange.toString()).toEqual("222"); }); }); describe("surround text next to nested", () => { describe("before", () => { let body: HTMLBodyElement; beforeEach(() => { body = p("beforeafter"); }); test("enlarges bottom tag of nested", () => { const range = new Range(); range.selectNode(body.firstChild!); surround(range, body, easyUnderline); expect(body).toHaveProperty("innerHTML", "beforeafter"); // expect(surroundedRange.toString()).toEqual("before"); }); test("moves nested down", () => { const range = new Range(); range.selectNode(body.firstChild!); surround(range, body, easyBold); expect(body).toHaveProperty("innerHTML", "beforeafter"); // expect(surroundedRange.toString()).toEqual("before"); }); }); describe("after", () => { let body: HTMLBodyElement; beforeEach(() => { body = p("beforeafter"); }); test("enlarges bottom tag of nested", () => { const range = new Range(); range.selectNode(body.childNodes[1]); surround(range, body, easyUnderline); expect(body).toHaveProperty("innerHTML", "beforeafter"); // expect(surroundedRange.toString()).toEqual("after"); }); test("moves nested down", () => { const range = new Range(); range.selectNode(body.childNodes[1]); surround(range, body, easyBold); expect(body).toHaveProperty("innerHTML", "beforeafter"); // expect(surroundedRange.toString()).toEqual("after"); }); }); describe("two nested", () => { let body: HTMLBodyElement; beforeEach(() => { body = p("aaabbbccc"); }); test("extends to both", () => { const range = new Range(); range.selectNode(body.firstChild!); surround(range, body, easyBold); expect(body).toHaveProperty("innerHTML", "aaabbbccc"); // expect(surroundedRange.toString()).toEqual("aaa"); }); }); }); describe("surround across block element", () => { let body: HTMLBodyElement; beforeEach(() => { body = p("Before
  • First
  • Second
"); }); test("does not insert empty elements", () => { const range = new Range(); range.setStartBefore(body.firstChild!); range.setEndAfter(body.lastChild!); const surroundedRange = surround(range, body, easyBold); expect(body).toHaveProperty( "innerHTML", "Before
  • First
  • Second
", ); expect(surroundedRange.toString()).toEqual("BeforeFirstSecond"); }); }); describe("next to nested", () => { let body: HTMLBodyElement; beforeEach(() => { body = p("111222333444555"); }); test("surround after", () => { const range = new Range(); range.selectNode(body.lastChild!); surround(range, body, easyBold); expect(body).toHaveProperty("innerHTML", "111222333444555"); // expect(surroundedRange.toString()).toEqual("555"); }); }); describe("next to element with nested non-matching", () => { let body: HTMLBodyElement; beforeEach(() => { body = p("111222333444555"); }); test("surround after", () => { const range = new Range(); range.selectNode(body.lastChild!); surround(range, body, easyBold); expect(body).toHaveProperty( "innerHTML", "111222333444555", ); // expect(surroundedRange.toString()).toEqual("555"); }); }); describe("next to element with text element text", () => { let body: HTMLBodyElement; beforeEach(() => { body = p("111222333444555"); }); test("surround after", () => { const range = new Range(); range.selectNode(body.lastChild!); surround(range, body, easyBold); expect(body).toHaveProperty("innerHTML", "111222333444555"); // expect(surroundedRange.toString()).toEqual("555"); }); }); describe("surround elements that already have nested block", () => { let body: HTMLBodyElement; beforeEach(() => { body = p("12
"); }); test("normalizes nodes", () => { const range = new Range(); range.selectNode(body.children[0]); surround(range, body, easyBold); expect(body).toHaveProperty("innerHTML", "12
"); // expect(surroundedRange.toString()).toEqual("12"); }); }); describe("surround complicated nested structure", () => { let body: HTMLBodyElement; beforeEach(() => { body = p("12345"); }); test("normalize nodes", () => { const range = new Range(); range.setStartBefore(body.firstElementChild!.firstChild!); range.setEndAfter(body.lastElementChild!.firstChild!); const surroundedRange = surround(range, body, easyBold); expect(body).toHaveProperty( "innerHTML", "12345", ); expect(surroundedRange.toString()).toEqual("12345"); }); }); describe("skips over empty elements", () => { describe("joins two newly created", () => { let body: HTMLBodyElement; beforeEach(() => { body = p("before
after"); }); test("normalize nodes", () => { const range = new Range(); range.setStartBefore(body.firstChild!); range.setEndAfter(body.childNodes[2]!); const surroundedRange = surround(range, body, easyBold); expect(body).toHaveProperty("innerHTML", "before
after
"); expect(surroundedRange.toString()).toEqual("beforeafter"); }); }); describe("joins with already existing", () => { let body: HTMLBodyElement; beforeEach(() => { body = p("before
after"); }); test("normalize nodes", () => { const range = new Range(); range.selectNode(body.firstChild!); surround(range, body, easyBold); expect(body).toHaveProperty("innerHTML", "before
after
"); // expect(surroundedRange.toString()).toEqual("before"); }); test("normalize node contents", () => { const range = new Range(); range.selectNodeContents(body.firstChild!); const surroundedRange = surround(range, body, easyBold); expect(body).toHaveProperty("innerHTML", "before
after
"); expect(surroundedRange.toString()).toEqual("before"); }); }); }); // TODO // describe("special cases when surrounding within range.commonAncestor", () => { // // these are not vital but rather define how the algorithm works in edge cases // test("does not normalize beyond level of contained text nodes", () => { // const body = p("beforenestedafter"); // const range = new Range(); // range.selectNode(body.firstChild!.childNodes[1].firstChild!); // const { addedNodes, removedNodes, surroundedRange } = surround( // range, // body, // easyBold, // ); // expect(addedNodes).toHaveLength(1); // expect(removedNodes).toHaveLength(0); // expect(body).toHaveProperty( // "innerHTML", // "beforenestedafter", // ); // expect(surroundedRange.toString()).toEqual("nested"); // }); // test("does not normalize beyond level of contained text nodes 2", () => { // const body = p("aaabbbccc"); // const range = new Range(); // range.setStartBefore(body.firstChild!.firstChild!); // range.setEndAfter(body.firstChild!.childNodes[1].firstChild!); // const { addedNodes, removedNodes } = surround(range, body, easyBold); // expect(body).toHaveProperty("innerHTML", "aaabbbccc"); // expect(addedNodes).toHaveLength(1); // expect(removedNodes).toHaveLength(2); // // expect(surroundedRange.toString()).toEqual("aaabbb"); // is aaabbbccc instead // }); // test("does normalize beyond level of contained text nodes", () => { // const body = p("aaabbbccc"); // const range = new Range(); // range.setStartBefore(body.firstChild!.childNodes[1].firstChild!.firstChild!); // range.setEndAfter(body.firstChild!.childNodes[1].childNodes[1].firstChild!); // const { addedNodes, removedNodes } = surround(range, body, easyBold); // expect(body).toHaveProperty("innerHTML", "aaabbbccc"); // expect(addedNodes).toHaveLength(1); // expect(removedNodes).toHaveLength(4); // // expect(surroundedRange.toString()).toEqual("aaabbb"); // is aaabbbccc instead // }); // test("does remove even if there is already equivalent surrounding in place", () => { // const body = p("beforenestedafter"); // const range = new Range(); // range.selectNode(body.firstChild!.childNodes[1].firstChild!.firstChild!); // const { addedNodes, removedNodes, surroundedRange } = surround( // range, // body, // easyBold, // ); // expect(addedNodes).toHaveLength(1); // expect(removedNodes).toHaveLength(1); // expect(body).toHaveProperty( // "innerHTML", // "beforenestedafter", // ); // expect(surroundedRange.toString()).toEqual("nested"); // }); // }); ================================================ FILE: ts/lib/domlib/surround/surround.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { Matcher } from "../find-above"; import { findFarthest } from "../find-above"; import { apply, ApplyFormat, ReformatApplyFormat, UnsurroundApplyFormat } from "./apply"; import { build, BuildFormat, ReformatBuildFormat, UnsurroundBuildFormat } from "./build"; import { boolMatcher } from "./match-type"; import { splitPartiallySelected } from "./split-text"; import type { SurroundFormat } from "./surround-format"; function buildAndApply( node: Node, buildFormat: BuildFormat, applyFormat: ApplyFormat, ): Range { const forest = build(node, buildFormat); apply(forest, applyFormat); return buildFormat.recreateRange(); } function surroundOnCorrectNode( range: Range, base: Element, build: BuildFormat, apply: ApplyFormat, matcher: Matcher, ): Range { const node = findFarthest( range.commonAncestorContainer, base, matcher, ) ?? range.commonAncestorContainer; return buildAndApply(node, build, apply); } /** * Will surround the entire range, removing any contained formatting nodes in the process. */ export function surround( range: Range, base: Element, format: SurroundFormat, ): Range { const splitRange = splitPartiallySelected(range); const build = new BuildFormat(format, base, range, splitRange); const apply = new ApplyFormat(format); return surroundOnCorrectNode(range, base, build, apply, boolMatcher(format)); } /** * Will not surround any unsurrounded text nodes in the range. */ export function reformat( range: Range, base: Element, format: SurroundFormat, ): Range { const splitRange = splitPartiallySelected(range); const build = new ReformatBuildFormat(format, base, range, splitRange); const apply = new ReformatApplyFormat(format); return surroundOnCorrectNode(range, base, build, apply, boolMatcher(format)); } export function unsurround( range: Range, base: Element, format: SurroundFormat, ): Range { const splitRange = splitPartiallySelected(range); const build = new UnsurroundBuildFormat(format, base, range, splitRange); const apply = new UnsurroundApplyFormat(format); return surroundOnCorrectNode(range, base, build, apply, boolMatcher(format)); } ================================================ FILE: ts/lib/domlib/surround/test-utils.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { MatchType } from "./match-type"; export const matchTagName = (tagName: string) => (element: Element, match: MatchType): void => { if (element.matches(tagName)) { match.remove(); } }; export const easyBold = { surroundElement: document.createElement("b"), matcher: matchTagName("b"), }; export const easyItalic = { surroundElement: document.createElement("i"), matcher: matchTagName("i"), }; export const easyUnderline = { surroundElement: document.createElement("u"), matcher: matchTagName("u"), }; const parser = new DOMParser(); export function p(html: string): HTMLBodyElement { const parsed = parser.parseFromString(html, "text/html"); return parsed.body as HTMLBodyElement; } export function t(data: string): Text { return document.createTextNode(data); } function element(tagName: string): (...childNodes: Node[]) => HTMLElement { return function(...childNodes: Node[]): HTMLElement { const element = document.createElement(tagName); element.append(...childNodes); return element; }; } export const b = element("b"); export const i = element("i"); export const u = element("u"); export const span = element("span"); export const div = element("div"); ================================================ FILE: ts/lib/domlib/surround/tree/block-node.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { TreeNode } from "./tree-node"; /** * Its purpose is to block adjacent FormattingNodes from merging, or single * FormattingNodes from trying to ascend. */ export class BlockNode extends TreeNode { private constructor() { super(false); } static make(): BlockNode { return new BlockNode(); } } ================================================ FILE: ts/lib/domlib/surround/tree/element-node.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { TreeNode } from "./tree-node"; export class ElementNode extends TreeNode { private constructor( public readonly element: Element, public readonly insideRange: boolean, ) { super(insideRange); } static make(element: Element, insideRange: boolean): ElementNode { return new ElementNode(element, insideRange); } } ================================================ FILE: ts/lib/domlib/surround/tree/formatting-node.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { nodeIsElement } from "@tslib/dom"; import { FlatRange } from "../flat-range"; import type { Match } from "../match-type"; import { ElementNode } from "./element-node"; import { TreeNode } from "./tree-node"; /** * Represents a potential insertion point for a tag or, more generally, a point for starting a format procedure. */ export class FormattingNode extends TreeNode { private constructor( public readonly range: FlatRange, public readonly insideRange: boolean, /** * Match ancestors are all matching matches that are direct ancestors * of `this`. This is important for deciding whether a text node is * turned into a FormattingNode or into a BlockNode, if it is outside * the initial DOM range. */ public readonly matchAncestors: Match[], ) { super(insideRange); } private static make( range: FlatRange, insideRange: boolean, matchAncestors: Match[], ): FormattingNode { return new FormattingNode(range, insideRange, matchAncestors); } static fromText( text: Text, insideRange: boolean, matchAncestors: Match[], ): FormattingNode { return FormattingNode.make( FlatRange.fromNode(text), insideRange, matchAncestors, ); } /** * A merge is combinging two formatting nodes into a single one. * The merged node will take over their children, their match leaves, and * their match holes, but will drop their extensions. * * @example * Practically speaking, it is what happens, when you combine: * `beforeafter` into `beforeafter`, or * `beforeafter` into * `beforeafter` (negligible nodes inbetween). */ static merge( before: FormattingNode, after: FormattingNode, ): FormattingNode { const node = FormattingNode.make( FlatRange.merge(before.range, after.range), before.insideRange && after.insideRange, before.matchAncestors, ); node.replaceChildren(...before.children, ...after.children); node.matchLeaves.push(...before.matchLeaves, ...after.matchLeaves); node.hasMatchHoles = before.hasMatchHoles || after.hasMatchHoles; return node; } toString(): string { return this.range.toString(); } /** * An ascent is placing a FormattingNode above an ElementNode. * This happens, when the element node is an extension to the formatting node. * * @param elementNode: Its children will be discarded in favor of `this`s * children. * * @example * Practically speaking, it is what happens, when you turn: * `inside` into `inside`, or * `inside` into `inside */ ascendAbove(elementNode: ElementNode): void { this.range.select(elementNode.element); this.extensions.push(elementNode.element as HTMLElement | SVGElement); if (!this.hasChildren()) { // Drop elementNode, as it has no effect return; } elementNode.replaceChildren(...this.replaceChildren(elementNode)); } /** * Extending only makes sense, if it is following by a FormattingNode * ascending above it. * Which is why if the match node is not ascendable, we might as well * stop extending. * * @returns Whether formatting node ascended at least one level */ getExtension(): ElementNode | null { const node = this.range.parent; if (nodeIsElement(node)) { return ElementNode.make(node, this.insideRange); } return null; } // The following methods are meant for users when specifying their surround // formats and is not vital to the algorithm itself /** * Match leaves are the matching elements that are/were descendants of * `this`. This makes them the element nodes, which actually affect text * nodes located inside `this`. * * @example * If we are surrounding with bold, then in this case: * `firstsecond * The inner b tags are match leaves, but the outer b tag is not, because * it does affect any text nodes. * * @remarks * These are important for mergers. */ matchLeaves: Match[] = []; get firstLeaf(): Match | null { if (this.matchLeaves.length === 0) { return null; } return this.matchLeaves[0]; } /** * Match holes are text nodes which are descendants of `this`, but are not * descendants of any match leaves of `this`. */ hasMatchHoles = true; get closestAncestor(): Match | null { if (this.matchAncestors.length === 0) { return null; } return this.matchAncestors[this.matchAncestors.length - 1]; } /** * Extensions of formatting nodes with a single element contained in their * range are direct exclusive descendant elements of this element. * Extensions are sorted in tree order. * * @example * When surrounding "inside" with a bold format in the following case: * `inside` * The formatting node would sit above the span (it ascends above both * the em and the span tag), and its extensions are the span tag and the * em tag (in this order). * * @example * When a format only wants to add a class, it would typically look for an * extension first. When applying class="myclass" to "inside" in the * following case: * `inside` * It should typically become: * `inside` */ extensions: (HTMLElement | SVGElement)[] = []; /** * @param insideValue: The value that should be returned, if the formatting * node is inside the original range. If the node is not inside the original * range, the cache of the first leaf, or the closest match ancestor will be * returned. */ getCache(insideValue: T): T | null { if (this.insideRange) { return insideValue; } else if (this.firstLeaf) { return this.firstLeaf.cache; } else if (this.closestAncestor) { return this.closestAncestor.cache; } // Should never happen, as a formatting node is always either // inside a range or inside a match return null; } /** * Whether the text nodes in this formatting node are affected by any match. * This can only be false, if `insideRange` is true (otherwise it would have * become a BlockNode). */ get hasMatch(): boolean { return this.matchLeaves.length > 0 || this.matchAncestors.length > 0; } } ================================================ FILE: ts/lib/domlib/surround/tree/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export { BlockNode } from "./block-node"; export { ElementNode } from "./element-node"; export { FormattingNode } from "./formatting-node"; export type { TreeNode } from "./tree-node"; ================================================ FILE: ts/lib/domlib/surround/tree/tree-node.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export abstract class TreeNode { readonly children: TreeNode[] = []; protected constructor( /** * Whether all text nodes within this node are inside the initial DOM range. */ public insideRange: boolean, ) {} /** * @returns Children which were replaced. */ replaceChildren(...newChildren: TreeNode[]): TreeNode[] { return this.children.splice(0, this.length, ...newChildren); } hasChildren(): boolean { return this.children.length > 0; } get length(): number { return this.children.length; } } ================================================ FILE: ts/lib/domlib/surround/unsurround.test.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // @vitest-environment jsdom import { beforeEach, describe, expect, test } from "vitest"; import { unsurround } from "./surround"; import { easyBold, p } from "./test-utils"; describe("unsurround text", () => { let body: HTMLBodyElement; beforeEach(() => { body = p("test"); }); test("normalizes nodes", () => { const range = new Range(); range.selectNode(body.firstChild!); unsurround(range, body, easyBold); expect(body).toHaveProperty("innerHTML", "test"); // expect(surroundedRange.toString()).toEqual("test"); }); }); // describe("unsurround element and text", () => { // let body: HTMLBodyElement; // beforeEach(() => { // body = p("beforeafter"); // }); // test("normalizes nodes", () => { // const range = new Range(); // range.setStartBefore(body.childNodes[0].firstChild!); // range.setEndAfter(body.childNodes[1]); // const surroundedRange = unsurround(range, body, easyBold); // expect(body).toHaveProperty("innerHTML", "beforeafter"); // expect(surroundedRange.toString()).toEqual("beforeafter"); // }); // }); describe("unsurround element with surrounding text", () => { let body: HTMLBodyElement; beforeEach(() => { body = p("112233"); }); test("normalizes nodes", () => { const range = new Range(); range.selectNode(body.firstElementChild!); unsurround(range, body, easyBold); expect(body).toHaveProperty("innerHTML", "112233"); // expect(surroundedRange.toString()).toEqual("22"); }); }); // describe("unsurround from one element to another", () => { // let body: HTMLBodyElement; // beforeEach(() => { // body = p("111222333"); // }); // test("unsurround whole", () => { // const range = new Range(); // range.setStartBefore(body.children[0].firstChild!); // range.setEndAfter(body.children[1].firstChild!); // unsurround(range, body, easyBold); // expect(body).toHaveProperty("innerHTML", "111222333"); // // expect(surroundedRange.toString()).toEqual("22"); // }); // }); // describe("unsurround text portion of element", () => { // let body: HTMLBodyElement; // beforeEach(() => { // body = p("112233"); // }); // test("normalizes nodes", () => { // const range = new Range(); // range.setStart(body.firstChild!, 2); // range.setEnd(body.firstChild!, 4); // const { addedNodes, removedNodes } = unsurround( // range, // document.createElement("b"), // body, // ); // expect(addedNodes).toHaveLength(2); // expect(removedNodes).toHaveLength(1); // expect(body).toHaveProperty("innerHTML", "112233"); // // expect(surroundedRange.toString()).toEqual("22"); // }); // }); describe("with bold around block item", () => { let body: HTMLBodyElement; beforeEach(() => { body = p("111
  • 222
"); }); test("unsurround list item", () => { const range = new Range(); range.selectNodeContents( body.firstChild!.childNodes[2].firstChild!.firstChild!, ); unsurround(range, body, easyBold); expect(body).toHaveProperty("innerHTML", "111
  • 222
"); // expect(surroundedRange.toString()).toEqual("222"); }); }); describe("with two double nested and one single nested", () => { // test("unsurround one double and single nested", () => { // const body = p("aaabbbccc"); // const range = new Range(); // range.setStartBefore(body.firstChild!.childNodes[1].firstChild!); // range.setEndAfter(body.firstChild!.childNodes[2]); // const surroundedRange = unsurround( // range, // body, // easyBold, // ); // expect(body).toHaveProperty("innerHTML", "aaabbbccc"); // expect(surroundedRange.toString()).toEqual("bbbccc"); // }); test("unsurround single and one double nested", () => { const body = p("aaabbbccc"); const range = new Range(); range.setStartBefore(body.firstChild!.firstChild!); range.setEndAfter(body.firstChild!.childNodes[1].firstChild!); const surroundedRange = unsurround(range, body, easyBold); expect(body).toHaveProperty("innerHTML", "aaabbbccc"); expect(surroundedRange.toString()).toEqual("aaabbb"); }); }); ================================================ FILE: ts/lib/generated/README.md ================================================ Files in this folder get combined with generated files in out/ts/lib/generated/ ================================================ FILE: ts/lib/generated/ftl-helpers.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { FluentBundle, FluentVariable } from "@fluent/bundle"; import { FluentNumber } from "@fluent/bundle"; let bundles: FluentBundle[] = []; export function setBundles(newBundles: FluentBundle[]): void { bundles = newBundles; } export function firstLanguage(): string { return bundles[0].locales[0]; } export function translate(key: string, args: Record = {}) { return getMessage(key, args) ?? `missing key: ${key}`; } function toFluentNumber(num: number): FluentNumber { return new FluentNumber(num, { maximumFractionDigits: 2, }); } function formatArgs( args: Record, ): Record { const entries: [string, FluentVariable][] = Object.entries(args).map( ([key, value]) => { return [ key, typeof value === "number" ? toFluentNumber(value) : value, ]; }, ); const out: Record = {}; for (const [key, value] of entries) { out[key] = value; } return out; } function getMessage( key: string, args: Record = {}, ): string | null { for (const bundle of bundles) { const msg = bundle.getMessage(key); if (msg && msg.value) { return bundle.formatPattern(msg.value, formatArgs(args)); } } return null; } ================================================ FILE: ts/lib/generated/post.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export interface PostProtoOptions { /** True by default. Shows a dialog with the error message, then rethrows. */ alertOnError?: boolean; } export async function postProto( method: string, input: { toBinary(): Uint8Array; getType(): { typeName: string } }, outputType: { fromBinary(arr: Uint8Array): T }, options: PostProtoOptions = {}, ): Promise { try { const inputBytes = input.toBinary(); const path = `/_anki/${method}`; const outputBytes = await postProtoInner(path, inputBytes); return outputType.fromBinary(outputBytes); } catch (err) { const { alertOnError = true } = options; if (alertOnError && !(err instanceof Error && err.message === "500: Interrupted")) { alert(err); } throw err; } } async function postProtoInner(url: string, body: Uint8Array): Promise { const result = await fetch(url, { method: "POST", headers: { "Content-Type": "application/binary", }, body, }); if (!result.ok) { let msg = "something went wrong"; try { msg = await result.text(); } catch { // ignore } throw new Error(`${result.status}: ${msg}`); } const blob = await result.blob(); const respBuf = await new Response(blob).arrayBuffer(); return new Uint8Array(respBuf); } ================================================ FILE: ts/lib/sass/_button-mixins.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @use "vars"; @use "sass:color"; @use "./elevation" as *; @import "bootstrap/scss/functions"; @import "bootstrap/scss/variables"; @mixin impressed-shadow($intensity) { box-shadow: inset 0 calc(var(--buttons-size, 10px) / 15) calc(var(--buttons-size, 10px) / 5) rgba(black, $intensity); } @mixin border-radius { border-top-left-radius: var(--border-left-radius); border-bottom-left-radius: var(--border-left-radius); border-top-right-radius: var(--border-right-radius); border-bottom-right-radius: var(--border-right-radius); } @mixin background($primary: false, $hover: true) { @if $primary { background: var(--button-primary-bg); @if $hover { &:hover { background: linear-gradient( 180deg, var(--button-primary-gradient-start) 0%, var(--button-primary-gradient-end) 100% ); } } } @else { background: var(--button-bg); @if $hover { &:hover { background: linear-gradient( 180deg, var(--button-gradient-start) 0%, var(--button-gradient-end) 100% ); /* Makes distinguishing hover state in light theme easier */ border: 1px solid var(--shadow); } } } } @mixin base( $primary: false, $border: true, $with-hover: true, $with-active: true, $active-class: "", $with-disabled: true ) { -webkit-appearance: none; cursor: pointer; @if $border { @if $primary { border: none; } @else { border: 1px solid var(--border-subtle); border-bottom-color: var(--shadow); } } @else { border: none; } @include background($primary, $hover: $with-hover); @if ($primary) { color: white; } @else { color: var(--fg); } @if ($with-active) { &:active { @include impressed-shadow(0.35); border-color: var(--border-subtle); } @if ($active-class != "") { &.#{$active-class} { @include impressed-shadow(0.35); background: var(--button-primary-bg); color: white; border-color: var(--border); } } } @if ($with-disabled) { &[disabled], &[disabled]:hover { cursor: not-allowed; color: var(--fg-disabled); box-shadow: none !important; background-color: var(--button-gradient-end); border-bottom-color: var(--border-subtle); } } } $focus-color: var(--shadow-focus); @mixin select($with-disabled: true) { width: 100%; pointer-events: all; cursor: pointer; @include base($with-disabled: $with-disabled); border-radius: var(--border-radius); &.rtl { direction: rtl; } } ================================================ FILE: ts/lib/sass/_color-palette.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later, http://www.gnu.org/licenses/agpl.html * * Anki Color Palette * custom gray, rest from Tailwind CSS v3 palette * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ $color-palette: ( lightgray: ( 0: #fcfcfc, 1: #fafafa, 2: #f5f5f5, 3: #eeeeee, 4: #e4e4e4, 5: #d6d6d6, 6: #c4c4c4, 7: #afafaf, 8: #999999, 9: #858585, ), darkgray: ( 0: #737373, 1: #636363, 2: #545454, 3: #454545, 4: #363636, 5: #2c2c2c, 6: #252525, 7: #202020, 8: #141414, 9: #020202, ), red: ( 0: #fef2f2, 1: #fee2e2, 2: #fecaca, 3: #fca5a5, 4: #f87171, 5: #ef4444, 6: #dc2626, 7: #b91c1c, 8: #991b1b, 9: #7f1d1d, ), orange: ( 0: #fff7ed, 1: #ffedd5, 2: #fed7aa, 3: #fdba74, 4: #fb923c, 5: #f97316, 6: #ea580c, 7: #c2410c, 8: #9a3412, 9: #7c2d12, ), amber: ( 0: #fffbeb, 1: #fef3c7, 2: #fde68a, 3: #fcd34d, 4: #fbbf24, 5: #f59e0b, 6: #d97706, 7: #b45309, 8: #92400e, 9: #78350f, ), yellow: ( 0: #fefce8, 1: #fef9c3, 2: #fef08a, 3: #fde047, 4: #facc15, 5: #eab308, 6: #ca8a04, 7: #a16207, 8: #854d0e, 9: #713f12, ), lime: ( 0: #f7fee7, 1: #ecfccb, 2: #d9f99d, 3: #bef264, 4: #a3e635, 5: #84cc16, 6: #65a30d, 7: #4d7c0f, 8: #3f6212, 9: #365314, ), green: ( 0: #f0fdf4, 1: #dcfce7, 2: #bbf7d0, 3: #86efac, 4: #4ade80, 5: #22c55e, 6: #16a34a, 7: #15803d, 8: #166534, 9: #14532d, ), teal: ( 0: #f0fdfa, 1: #ccfbf1, 2: #99f6e4, 3: #5eead4, 4: #2dd4bf, 5: #14b8a6, 6: #0d9488, 7: #0f766e, 8: #115e59, 9: #134e4a, ), cyan: ( 0: #ecfeff, 1: #cffafe, 2: #a5f3fc, 3: #67e8f9, 4: #22d3ee, 5: #06b6d4, 6: #0891b2, 7: #0e7490, 8: #155e75, 9: #164e63, ), sky: ( 0: #f0f9ff, 1: #e0f2fe, 2: #bae6fd, 3: #7dd3fc, 4: #38bdf8, 5: #0ea5e9, 6: #0284c7, 7: #0369a1, 8: #075985, 9: #0c4a6e, ), blue: ( 0: #eff6ff, 1: #dbeafe, 2: #bfdbfe, 3: #93c5fd, 4: #60a5fa, 5: #3b82f6, 6: #2563eb, 7: #1d4ed8, 8: #1e40af, 9: #1e3a8a, ), indigo: ( 0: #eef2ff, 1: #e0e7ff, 2: #c7d2fe, 3: #a5b4fc, 4: #818cf8, 5: #6366f1, 6: #4f46e5, 7: #4338ca, 8: #3730a3, 9: #312e81, ), violet: ( 0: #f5f3ff, 1: #ede9fe, 2: #ddd6fe, 3: #c4b5fd, 4: #a78bfa, 5: #8b5cf6, 6: #7c3aed, 7: #6d28d9, 8: #5b21b6, 9: #4c1d95, ), purple: ( 0: #faf5ff, 1: #f3e8ff, 2: #e9d5ff, 3: #d8b4fe, 4: #c084fc, 5: #a855f7, 6: #9333ea, 7: #7e22ce, 8: #6b21a8, 9: #581c87, ), fuchsia: ( 0: #fdf4ff, 1: #fae8ff, 2: #f5d0fe, 3: #f0abfc, 4: #e879f9, 5: #d946ef, 6: #c026d3, 7: #a21caf, 8: #86198f, 9: #701a75, ), pink: ( 0: #fdf2f8, 1: #fce7f3, 2: #fbcfe8, 3: #f9a8d4, 4: #f472b6, 5: #ec4899, 6: #db2777, 7: #be185d, 8: #9d174d, 9: #831843, ), ); ================================================ FILE: ts/lib/sass/_functions.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @use "sass:map"; @use "sass:list"; @function create-vars-from-map($map, $theme, $name: "-", $output: ()) { @each $key, $value in $map { @if $key == $theme or ( $key == "default" and type-of($value) != "map" and type-of($value) != "list" ) { @return map.set($output, $name, map.get($map, $key)); } @if type-of($value) == "map" { @if $key == "default" { $output: map-merge( $output, create-vars-from-map($value, $theme, #{$name}, $output) ); } @else { $output: map-merge( $output, create-vars-from-map($value, $theme, #{$name}-#{$key}, $output) ); } } @else if type-of($value) == "list" and list.length($value) > 1 { $next-name: #{$name}-#{$key}; @if $key == "default" { $next-name: $name; } $output: map-merge( $output, (#{"comment"}#{$next-name}: list.nth($value, 1)) ); $output: map-merge( $output, create-vars-from-map( list.nth($value, 2), $theme, #{$next-name}, $output ) ); } } @return $output; } @function map-deep-get($map, $keys) { @each $key in $keys { @if type-of($map) == "list" and list.length($map) > 1 { $map: map-get(list.nth($map, 2), $key); } @else { $map: map-get($map, $key); } } @return $map; } @function get-value-from-map($map, $keyword, $theme, $keys: ()) { $i: str-index($keyword, "-"); @if $i { @while $i { $sub: str-slice($keyword, 0, $i - 1); @if list.length($keys) == 0 { $keys: ($sub); } @else { $keys: list.append($keys, $sub); } $keyword: str-slice($keyword, $i + 1, -1); $i: str-index($keyword, "-"); } } $keys: list.join($keys, ($keyword, $theme)); @return map-deep-get($map, $keys); } ================================================ FILE: ts/lib/sass/_root-vars.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later, http://www.gnu.org/licenses/agpl.html */ @use "sass:map"; @use "vars" as *; @use "functions" as *; @use "scrollbar"; /*! colors */ :root { $colors: map.get($vars, colors); @each $name, $val in create-vars-from-map($colors, light) { @if str-index($name, "comment") == 1 { /*! #{$val} */ } @else { #{$name}: #{$val}; } } color-scheme: light; &.night-mode { @each $name, $val in create-vars-from-map($colors, dark) { @if str-index($name, "comment") == 1 { /*! #{$val} */ } @else { #{$name}: #{$val}; } } color-scheme: dark; } } /*! props */ :root { $props: map.get($vars, props); @each $name, $val in create-vars-from-map($props, light) { @if str-index($name, "comment") == 1 { /*! #{$val} */ } @else { #{$name}: #{$val}; } } &.night-mode { @each $name, $val in create-vars-from-map($props, dark) { @if str-index($name, "comment") == 1 { /*! #{$val} */ } @else { #{$name}: #{$val}; } } } } /*! rest */ html { font-size: prop(font-size); body { overscroll-behavior: none; &:not(.isMac), &:not(.isMac) * { @include scrollbar.custom; } } } ================================================ FILE: ts/lib/sass/_vars.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later, http://www.gnu.org/licenses/agpl.html */ @use "sass:map"; @use "sass:color"; @use "functions" as *; @use "color-palette" as *; @function palette($key, $shade) { $color: map.get($color-palette, $key); @return map.get($color, $shade); } $vars: ( props: ( font: ( size: ( default: 15px, ), ), border-radius: ( default: ( "Used to round corners of various UI elements", ( default: 5px, ), ), medium: ( "Used for container corners", ( default: 12px, ), ), large: ( "Used for pill-shaped buttons", ( default: 15px, ), ), ), transition: ( default: ( "Default duration of transitions in milliseconds", ( default: 180ms, ), ), medium: ( "Slightly longer transition duration in milliseconds", ( default: 500ms, ), ), slow: ( "Long transition duration in milliseconds", ( default: 1000ms, ), ), ), blur: ( default: ( "Default background blur value", ( default: 20px, ), ), ), ), colors: ( fg: ( default: ( "Default text/icon color", ( light: palette(darkgray, 9), dark: palette(lightgray, 0), ), ), subtle: ( "Placeholder text, icons in idle state", ( light: palette(darkgray, 0), dark: palette(lightgray, 9), ), ), disabled: ( "Foreground color of disabled UI elements", ( light: palette(lightgray, 9), dark: palette(darkgray, 0), ), ), faint: ( "Foreground color that barely stands out against canvas", ( light: palette(lightgray, 7), dark: palette(darkgray, 2), ), ), link: ( "Hyperlink foreground color", ( light: palette(blue, 7), dark: palette(blue, 2), ), ), ), canvas: ( default: ( "Window background", ( light: palette(lightgray, 2), dark: palette(darkgray, 5), ), ), elevated: ( "Background of containers", ( light: white, dark: palette(darkgray, 4), ), ), inset: ( "Background of inputs inside containers", ( light: white, dark: palette(darkgray, 5), ), ), overlay: ( "Background of floating elements (menus, tooltips)", ( light: palette(lightgray, 0), dark: palette(darkgray, 5), ), ), code: ( "Background of code editors", ( light: white, dark: palette(darkgray, 6), ), ), glass: ( "Transparent background for surfaces containing text", ( light: color.scale(white, $alpha: -60%), dark: color.scale(palette(darkgray, 4), $alpha: -60%), ), ), ), border: ( default: ( "Border color with medium contrast against window background", ( light: palette(lightgray, 6), dark: palette(darkgray, 7), ), ), subtle: ( "Border color with low contrast against window background", ( light: palette(lightgray, 4), dark: palette(darkgray, 6), ), ), strong: ( "Border color with high contrast against window background", ( light: palette(lightgray, 9), dark: palette(darkgray, 9), ), ), focus: ( "Border color of focused input elements", ( light: palette(blue, 5), dark: palette(blue, 5), ), ), ), button: ( bg: ( "Background color of buttons", ( light: palette(lightgray, 0), dark: color.scale(palette(darkgray, 4), $lightness: 5%), ), ), gradient: ( start: ( "Start value of default button gradient", ( light: white, dark: color.scale(palette(darkgray, 4), $lightness: 10%), ), ), end: ( "End value of default button gradient", ( light: palette(lightgray, 0), dark: color.scale(palette(darkgray, 4), $lightness: 5%), ), ), ), hover: ( border: ( "Border color of default button in hover state", ( light: palette(lightgray, 8), dark: palette(darkgray, 8), ), ), ), disabled: ( "Background color of disabled button", ( light: color.scale(palette(lightgray, 5), $alpha: -50%), dark: color.scale(palette(darkgray, 3), $alpha: -50%), ), ), primary: ( bg: ( "Background color of primary button", ( light: color.scale(palette(blue, 6), $lightness: 5%), dark: color.scale(palette(blue, 7), $saturation: -10%), ), ), gradient: ( start: ( "Start value of primary button gradient", ( light: palette(blue, 5), dark: color.scale(palette(blue, 6), $saturation: -10%), ), ), end: ( "End value of primary button gradient", ( light: color.scale(palette(blue, 6), $lightness: 5%), dark: color.scale(palette(blue, 7), $saturation: -10%), ), ), ), disabled: ( "Background color of primary button in disabled state", ( light: palette(blue, 3), dark: color.scale(palette(blue, 5), $saturation: -10%), ), ), ), ), scrollbar: ( bg: ( default: ( "Background of scrollbar in idle state (Win/Lin only)", ( light: palette(lightgray, 5), dark: palette(darkgray, 3), ), ), hover: ( "Background of scrollbar in hover state (Win/Lin only)", ( light: palette(lightgray, 6), dark: palette(darkgray, 2), ), ), active: ( "Background of scrollbar in pressed state (Win/Lin only)", ( light: palette(lightgray, 7), dark: palette(darkgray, 1), ), ), ), ), shadow: ( default: ( "Default box-shadow color", ( light: palette(lightgray, 6), dark: palette(darkgray, 8), ), ), inset: ( "Inset box-shadow color", ( light: palette(darkgray, 3), dark: palette(darkgray, 7), ), ), subtle: ( "Box-shadow color with lower contrast against window background", ( light: palette(darkgray, 0), dark: palette(darkgray, 4), ), ), focus: ( "Box-shadow color for elements in focused state", ( default: palette(indigo, 5), ), ), ), accent: ( card: ( "Accent color for cards", ( light: palette(blue, 4), dark: palette(blue, 3), ), ), note: ( "Accent color for notes", ( light: palette(green, 5), dark: palette(green, 4), ), ), danger: ( "Saturated accent color to grab attention", ( light: palette(red, 5), dark: palette(red, 4), ), ), ), flag: ( 1: ( "Flag 1 (red)", ( light: palette(red, 5), dark: palette(red, 4), ), ), 2: ( "Flag 2 (orange)", ( light: palette(orange, 4), dark: palette(orange, 3), ), ), 3: ( "Flag 3 (green)", ( light: palette(green, 4), dark: palette(green, 3), ), ), 4: ( "Flag 4 (blue)", ( light: palette(blue, 5), dark: palette(blue, 4), ), ), 5: ( "Flag 5 (pink)", ( light: palette(fuchsia, 4), dark: palette(fuchsia, 3), ), ), 6: ( "Flag 6 (turquoise)", ( light: palette(teal, 4), dark: palette(teal, 3), ), ), 7: ( "Flag 7 (purple)", ( light: palette(purple, 5), dark: palette(purple, 4), ), ), ), state: ( new: ( "Accent color for new cards", ( light: palette(blue, 5), dark: palette(blue, 3), ), ), learn: ( "Accent color for cards in learning state", ( light: palette(red, 6), dark: palette(red, 4), ), ), review: ( "Accent color for cards in review state", ( light: palette(green, 6), dark: palette(green, 5), ), ), buried: ( "Accent color for buried cards", ( light: palette(amber, 5), dark: palette(amber, 8), ), ), suspended: ( "Accent color for suspended cards", ( light: palette(yellow, 4), dark: palette(yellow, 1), ), ), marked: ( "Accent color for marked cards", ( light: palette(indigo, 5), dark: palette(purple, 5), ), ), ), highlight: ( bg: ( "Background color of highlighted items", ( light: color.scale(palette(blue, 6), $alpha: -50%), dark: color.scale(palette(blue, 3), $alpha: -50%), ), ), fg: ( "Foreground color of highlighted items", ( light: black, dark: white, ), ), ), selected: ( bg: ( "Background color of selected text", ( light: color.scale(palette(lightgray, 5), $alpha: -50%), dark: color.scale(palette(blue, 3), $alpha: -50%), ), ), fg: ( "Foreground color of selected text", ( light: black, dark: white, ), ), ), ), ); @function prop($keyword) { @return var(--#{$keyword}); } @function color($keyword) { @return var(--#{$keyword}); } @function palette-of($keyword, $theme: default) { $colors: map.get($vars, colors); @return get-value-from-map($colors, $keyword, $theme); } ================================================ FILE: ts/lib/sass/base.scss ================================================ @use "vars" as *; @use "root-vars"; @use "button-mixins" as button; @use "./scrollbar"; $body-color: palette(darkgray, 9); $body-color-dark: palette(lightgray, 0); $body-bg: palette(lightgray, 2); $body-bg-dark: palette(darkgray, 5); $link-hover-decoration: none; $utilities: ( "opacity": ( property: opacity, values: ( 0: 0, 25: 0.25, 50: 0.5, 75: 0.75, 100: 1, ), ), ); @import "bootstrap/scss/bootstrap-reboot"; @import "bootstrap/scss/bootstrap-utilities"; /* Bootstrap "extensions" */ .flex-basis-100 { flex-basis: 100%; } .flex-basis-75 { flex-basis: 75%; } html, body { height: 100%; } html { overscroll-behavior: none; } body { font-family: inherit; overflow-x: hidden; &:not(.isMac), &:not(.isMac) * { @include scrollbar.custom; } &.no-blur * { backdrop-filter: none !important; } } button:not(.btn, .btn-close) { /* override transition for instant hover response */ transition: color var(--transition) ease-in-out, box-shadow var(--transition) ease-in-out !important; border-radius: prop(border-radius); @include button.base; } pre, code, kbd, samp { unicode-bidi: normal !important; } label, input[type="radio"], input[type="checkbox"] { cursor: pointer; } textarea, input[type="date"], input[type="text"] { border-radius: prop(border-radius); outline: none; border: 1px solid color(border); &:focus { border-color: color(border-focus); } } ================================================ FILE: ts/lib/sass/bootstrap-dark.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @use "vars"; @mixin night-mode { input, select { background-color: var(--canvas-inset); border-color: var(--border); &:focus { background-color: var(--canvas-inset); } } } ================================================ FILE: ts/lib/sass/bootstrap-forms.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @import "bootstrap/scss/forms"; .form-control, .form-select { // the unprefixed version wasn't added until Chrome 81 -webkit-appearance: none; } ================================================ FILE: ts/lib/sass/bootstrap-tooltip.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @use "vars"; $tooltip-padding-y: 0.45rem; $tooltip-padding-x: 0.65rem; $tooltip-max-width: 300px; @import "bootstrap/scss/tooltip"; .tooltip-inner { text-align: start; // marked transpiles tooltips into multiple paragraphs // where trailing

s cause a bottom margin > p:last-child { display: inline; } // the default code color in tooltips is difficult to read; we'll probably // want to add more of our own styling in the future code { color: palette(red, 0); direction: inherit; } } ================================================ FILE: ts/lib/sass/breakpoints.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @use "sass:list"; @use "sass:map"; $bps: ("xs", "sm", "md", "lg", "xl", "xxl"); $breakpoints: ( list.nth($bps, 2): 576px, list.nth($bps, 3): 768px, list.nth($bps, 4): 992px, list.nth($bps, 5): 1200px, list.nth($bps, 6): 1400px, ); @mixin with-breakpoint($bp) { @if map.get($breakpoints, $bp) { @media (min-width: map.get($breakpoints, $bp)) { @content; } } @else { @content; } } @mixin with-breakpoints($prefix, $dict) { @each $property, $values in $dict { @each $bp, $value in $values { @if map.get($breakpoints, $bp) { @media (min-width: map.get($breakpoints, $bp)) { .#{$prefix}-#{$bp} { #{$property}: $value; } } } @else { .#{$prefix}-#{$bp} { #{$property}: $value; } } } } } @function breakpoints-upto($upto) { $result: (); @each $bp in $bps { $result: list.append($result, $bp); @if $bp == $upto { @return $result; } } @return $result; } @function breakpoint-selector-upto($prefix, $upto) { $result: (); @each $bp in breakpoints-upto($upto) { $result: list.append($result, ".#{$prefix}-#{$bp}", $separator: comma); } @return $result; } @mixin with-breakpoints-upto($prefix, $dict) { @each $property, $values in $dict { @each $bp, $value in $values { $selector: breakpoint-selector-upto($prefix, $bp); @if map.get($breakpoints, $bp) { @media (min-width: map.get($breakpoints, $bp)) { #{$selector} { #{$property}: $value; } } } @else { #{$selector} { #{$property}: $value; } } } } } ================================================ FILE: ts/lib/sass/buttons.scss ================================================ @use "vars"; @use "button-mixins" as button; @use "elevation" as *; :root { --focus-color: #{vars.palette-of(shadow-focus)}; .isMac { --focus-color: rgba(0 103 244 / 0.247); } } .isWin { button { font-size: 12px; } } .isMac { button { font-size: 13px; } } button { outline: none !important; background: var(--button-bg); border-radius: var(--border-radius); border: 1px solid var(--border-subtle); &:hover { background: var(--button-gradient-start); border: 1px solid var(--border); } font-weight: 500; padding: 8px 10px; margin: 0 4px; @include button.base; .fancy & { border-radius: var(--border-radius-large); @include elevation(1, $opacity-boost: -0.08); &:hover { @include elevation(2); transition: box-shadow var(--transition) linear; } } } ================================================ FILE: ts/lib/sass/card-counts.scss ================================================ .review-count { color: var(--state-review); } .new-count { color: var(--state-new); } .learn-count { color: var(--state-learn); } .zero-count { color: var(--fg-faint); } .bury-count { color: var(--fg-disabled); font-weight: bold; margin-inline-start: 2px; &:empty { display: none; } } ================================================ FILE: ts/lib/sass/core.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @use "vars"; * { box-sizing: border-box; } body { color: var(--fg); background: var(--canvas); margin: 1em; &.fancy { transition: opacity var(--transition-medium) ease-out; } overscroll-behavior: none; } a { color: var(--fg-link); text-decoration: none; } ================================================ FILE: ts/lib/sass/elevation.scss ================================================ // Heavily inspired by https://github.com/material-components/material-components-web/tree/master/packages/mdc-elevation @use "sass:color"; @use "sass:map"; @use "sass:list"; /** * The maps correspond to dp levels: * 0: 0dp * 1: 1dp * 2: 2dp * 3: 3dp * 4: 4dp * 5: 6dp * 6: 8dp * 7: 12dp * 8: 16dp * 9: 24dp */ $umbra-map: ( 0: "0px 0px 0px 0px", 1: "0px 2px 1px -1px", 2: "0px 3px 1px -2px", 3: "0px 3px 3px -2px", 4: "0px 2px 4px -1px", 5: "0px 3px 5px -1px", 6: "0px 5px 5px -3px", 7: "0px 7px 8px -4px", 8: "0px 8px 10px -5px", 9: "0px 11px 15px -7px", ); $penumbra-map: ( 0: "0px 0px 0px 0px", 1: "0px 1px 1px 0px", 2: "0px 2px 2px 0px", 3: "0px 3px 4px 0px", 4: "0px 4px 5px 0px", 5: "0px 6px 10px 0px", 6: "0px 8px 10px 1px", 7: "0px 12px 17px 2px", 8: "0px 16px 24px 2px", 9: "0px 24px 38px 3px", ); $ambient-map: ( 0: "0px 0px 0px 0px", 1: "0px 1px 3px 0px", 2: "0px 1px 5px 0px", 3: "0px 1px 8px 0px", 4: "0px 1px 10px 0px", 5: "0px 1px 18px 0px", 6: "0px 3px 14px 2px", 7: "0px 5px 22px 4px", 8: "0px 6px 30px 5px", 9: "0px 9px 46px 8px", ); $umbra-opacity: 0.2; $penumbra-opacity: 0.14; $ambient-opacity: 0.12; @function box-shadow($level, $opacity-boost, $color) { $umbra-z-value: map.get($umbra-map, $level); $penumbra-z-value: map.get($penumbra-map, $level); $ambient-z-value: map.get($ambient-map, $level); $umbra-color: color.adjust(rgba($color, $umbra-opacity), $alpha: $opacity-boost); $penumbra-color: color.adjust( rgba($color, $penumbra-opacity), $alpha: $opacity-boost ); $ambient-color: color.adjust( rgba($color, $ambient-opacity), $alpha: $opacity-boost ); @return ( #{$umbra-z-value} $umbra-color, #{$penumbra-z-value} $penumbra-color, #{$ambient-z-value} $ambient-color ); } @mixin elevation($level, $opacity-boost: 0, $color: #141414) { box-shadow: box-shadow($level, $opacity-boost, $color); } ================================================ FILE: ts/lib/sass/night-mode.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @mixin input { background-color: var(--canvas-inset); border-color: var(--border); &:focus { background-color: var(--canvas-inset); } } ================================================ FILE: ts/lib/sass/panes.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @mixin resizable($direction, $width-resizable, $height-resizable) { display: flex; flex-flow: #{$direction} nowrap; flex-basis: 0; flex-grow: var(--pane-size); overflow: hidden; overflow-y: auto; &.resize { flex-basis: auto; @if $width-resizable { &.resize-width { width: var(--resized-width); } } @if $height-resizable { &.resize-height { height: var(--resized-height); } } } } ================================================ FILE: ts/lib/sass/scrollbar.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @use "vars"; @mixin custom { &::-webkit-scrollbar { background-color: vars.color(canvas); &:horizontal { height: 12px; } &:vertical { width: 12px; } } &::-webkit-scrollbar-thumb { background: vars.color(scrollbar-bg); border-radius: vars.prop(border-radius); &:horizontal { min-width: 50px; } &:vertical { min-height: 50px; } &:hover { background: vars.color(scrollbar-bg-hover); } &:active { background: vars.color(scrollbar-bg-active); } } &::-webkit-scrollbar-corner { background-color: vars.color(canvas); } &::-webkit-scrollbar-track { border-radius: 5px; background-color: transparent; } } ================================================ FILE: ts/lib/sveltelib/action-list.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { truthy } from "@tslib/functional"; interface ActionReturn

{ destroy?(): void; update?(params: P): void; } type Action = ( element: E, params: P, ) => ActionReturn

| void; /** * A helper function for treating a list of Svelte actions as a single Svelte action * and use it with a single `use:` directive */ function actionList(actions: Action[]): Action { return function action(element: E, params: P): ActionReturn

| void { const results = actions.map((action) => action(element, params)).filter(truthy); return { update(params: P) { for (const { update } of results) { update?.(params); } }, destroy() { for (const { destroy } of results) { destroy?.(); } }, }; }; } export default actionList; ================================================ FILE: ts/lib/sveltelib/closing-click.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { Readable } from "svelte/store"; import { derived } from "svelte/store"; import type { EventPredicateResult } from "./event-predicate"; /** * Typically the right-sided mouse button. */ function isSecondaryButton(event: MouseEvent): boolean { return event.button === 2; } interface ClosingClickArgs { /** * Clicking on the reference element should not close. * The reference should handle this itself. */ reference: EventTarget; floating: EventTarget; inside: boolean; outside: boolean; } /** * Returns a derived store, which translates `MouseEvent`s into a boolean * indicating whether they constitute a click that should close `floating`. * * @param store: Should be an event store wrapping document.click. */ function isClosingClick( store: Readable, { reference, floating, inside, outside }: ClosingClickArgs, ): Readable { function isTriggerClick(path: EventTarget[]): string | false { // Reference element was clicked, e.g. the button. // The reference element needs to handle opening/closing itself. if (path.includes(reference)) { return false; } if (inside && path.includes(floating)) { return "insideClick"; } if (outside && !path.includes(floating)) { return "outsideClick"; } return false; } function shouldClose(event: MouseEvent): string | false { if (isSecondaryButton(event)) { return "secondaryButton"; } return isTriggerClick(event.composedPath()); } return derived( store, (event: MouseEvent, set: (value: EventPredicateResult) => void): void => { const reason = shouldClose(event); if (reason) { set({ reason, originalEvent: event }); } }, ); } export default isClosingClick; ================================================ FILE: ts/lib/sveltelib/closing-keyup.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { Readable } from "svelte/store"; import { derived } from "svelte/store"; import type { EventPredicateResult } from "./event-predicate"; interface ClosingKeyupArgs { /** * Clicking on the reference element should not close. * The reference should handle this itself. */ reference: Node; floating: Node; } /** * Returns a derived store, which translates `MouseEvent`s into a boolean * indicating whether they constitute a click that should close `floating`. * * @param store: Should be an event store wrapping document.click. */ function isClosingKeyup( store: Readable, _args: ClosingKeyupArgs, ): Readable { // TODO there needs to be special treatment, whether the keyup happens // inside the floating element or outside, but I'll defer until we actually // use this for a popover with an input field function shouldClose(event: KeyboardEvent): string | false { if (event.key === "Tab") { // Allow Tab navigation. return false; } return "keyup"; } return derived( store, (event: KeyboardEvent, set: (value: EventPredicateResult) => void): void => { const reason = shouldClose(event); if (reason) { set({ reason, originalEvent: event }); } }, ); } export default isClosingKeyup; ================================================ FILE: ts/lib/sveltelib/composition.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { writable } from "svelte/store"; /** * Indicates whether an IME composition session is currently active */ export const isComposing = writable(false); window.addEventListener("compositionstart", () => isComposing.set(true)); window.addEventListener("compositionend", () => isComposing.set(false)); ================================================ FILE: ts/lib/sveltelib/context-property.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { getContext, hasContext, setContext } from "svelte"; type SetContextPropertyAction = (value: T) => void; export interface ContextProperty { /** * Retrieves the component's context * * @remarks * The typing of the return value is a lie insofar as calling `get` outside * of the component's context will return `undefined`. * If you are uncertain if your component is actually within the context * of this component, you should check with `available` first. * * @returns The component's context */ get(): T; /** * Checks whether the component's context is available */ available(): boolean; } function contextProperty( key: symbol, ): [ContextProperty, SetContextPropertyAction] { function set(context: T): void { setContext(key, context); } const context = { get(): T { return getContext(key); }, available(): boolean { return hasContext(key); }, }; return [context, set]; } export default contextProperty; ================================================ FILE: ts/lib/sveltelib/dom-mirror.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { on } from "@tslib/events"; import type { Writable } from "svelte/store"; import { writable } from "svelte/store"; import storeSubscribe from "./store-subscribe"; const config = { childList: true, subtree: true, attributes: true, characterData: true, }; export type MirrorAction = ( element: HTMLElement, params: { store: Writable }, ) => { destroy(): void }; interface DOMMirrorAPI { mirror: MirrorAction; preventResubscription(): () => void; } function cloneNode(node: Node): DocumentFragment { /** * Creates a deep clone * This seems to be less buggy than node.cloneNode(true) */ const range = document.createRange(); range.selectNodeContents(node); return range.cloneContents(); } /** * Allows you to keep an element's inner HTML bidirectionally * in sync with a store containing a DocumentFragment. * While the element has focus, this connection is tethered. * In practice, this will sync changes from PlainTextInput to RichTextInput. */ function useDOMMirror(): DOMMirrorAPI { const allowResubscription = writable(true); function preventResubscription() { allowResubscription.set(false); return () => { allowResubscription.set(true); }; } function mirror( element: HTMLElement, { store }: { store: Writable }, ): { destroy(): void } { function saveHTMLToStore(): void { store.set(cloneNode(element)); } const observer = new MutationObserver(saveHTMLToStore); observer.observe(element, config); function mirrorToElement(node: Node): void { observer.disconnect(); // element.replaceChildren(...node.childNodes); // TODO use once available while (element.firstChild) { element.firstChild.remove(); } while (node.firstChild) { element.appendChild(node.firstChild); } observer.observe(element, config); } function mirrorFromFragment(fragment: DocumentFragment): void { mirrorToElement(cloneNode(fragment)); } const { subscribe, unsubscribe } = storeSubscribe( store, mirrorFromFragment, false, ); /* do not update when focused as it will reset caret */ const removeFocus = on(element, "focus", unsubscribe); let removeBlur: (() => void) | undefined; const unsubResubscription = allowResubscription.subscribe( (allow: boolean): void => { if (allow) { if (!removeBlur) { removeBlur = on(element, "blur", subscribe); } const root = element.getRootNode() as Document | ShadowRoot; if (root.activeElement !== element) { subscribe(); } } else if (removeBlur) { removeBlur(); removeBlur = undefined; } }, ); return { destroy() { observer.disconnect(); removeFocus(); removeBlur?.(); unsubscribe(); unsubResubscription(); }, }; } return { mirror, preventResubscription, }; } export default useDOMMirror; ================================================ FILE: ts/lib/sveltelib/dynamic-slotting.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import type { Identifier } from "@tslib/children-access"; import type { ChildrenAccess } from "@tslib/children-access"; import childrenAccess from "@tslib/children-access"; import { nodeIsElement } from "@tslib/dom"; import type { Callback } from "@tslib/helpers"; import { removeItem } from "@tslib/helpers"; import { promiseWithResolver } from "@tslib/promise"; import type { SvelteComponent } from "svelte"; import type { Readable, Writable } from "svelte/store"; import { writable } from "svelte/store"; export interface DynamicSvelteComponent { component: typeof SvelteComponent; /** * Props that are passed to the component */ props?: Record; /** * ID that will be assigned to the component that hosts * the dynamic component (slot host) */ id?: string; } /** * Props that will be passed to the slot host, e.g. ButtonGroupItem. */ export interface SlotHostProps { detach: Writable; } export interface CreateInterfaceAPI { addComponent( component: DynamicSvelteComponent, reinsert: (newElement: U, access: ChildrenAccess) => number, ): Promise<{ destroy: Callback }>; updateProps(update: (hostProps: T) => T, identifier: Identifier): Promise; } export interface GetSlotHostProps { getProps(): T; } export interface DynamicSlotted { component: DynamicSvelteComponent; hostProps: T; } export interface DynamicSlottingAPI< T extends SlotHostProps, U extends Element, X extends Record, > { /** * This should be used as an action on the element that hosts the slot hosts. */ resolveSlotContainer: (element: U) => void; /** * Contains the props for the DynamicSlot component */ dynamicSlotted: Readable[]>; slotsInterface: X; } /** * Allow add-on developers to dynamically extend/modify components our components * * @remarks * It allows to insert elements in between the components, or modify their props. * Practically speaking, we let Svelte do the initial insertion of an element, * but then immediately move it to its destination, and save a reference to it. * * @experimental */ function dynamicSlotting< T extends SlotHostProps, U extends Element, X extends Record, >( /** * A function which will create props which are passed to the dynamically * slotted component's host component, the slot host, e.g. `ButtonGroupItem` */ makeProps: () => T, /** * This is called on *all* items whenever any item updates */ updatePropsList: (propsList: T[]) => T[], /** * A function to create an interface to interact with slotted components */ setSlotHostContext: (callback: GetSlotHostProps) => void, createInterface: (api: CreateInterfaceAPI) => X, ): DynamicSlottingAPI { const slotted = writable([]); slotted.subscribe(updatePropsList); function addDynamicallySlotted(index: number, props: T): void { slotted.update((slotted: T[]): T[] => { slotted.splice(index, 0, props); return slotted; }); } const [elementPromise, resolveSlotContainer] = promiseWithResolver(); const accessPromise = elementPromise.then(childrenAccess); const dynamicSlotted = writable[]>([]); async function addComponent( component: DynamicSvelteComponent, reinsert: (newElement: U, access: ChildrenAccess) => number, ): Promise<{ destroy: Callback }> { const [dynamicallySlottedMounted, resolveDynamicallySlotted] = promiseWithResolver(); const access = await accessPromise; const hostProps = makeProps(); function elementIsDynamicComponent(element: Element): boolean { return !component.id || element.id === component.id; } async function callback( mutations: MutationRecord[], observer: MutationObserver, ): Promise { for (const mutation of mutations) { for (const addedNode of mutation.addedNodes) { if ( !nodeIsElement(addedNode) || !elementIsDynamicComponent(addedNode) ) { continue; } const theElement = addedNode as U; const index = reinsert(theElement, access); if (index >= 0) { addDynamicallySlotted(index, hostProps); } resolveDynamicallySlotted(undefined); return observer.disconnect(); } } } const observer = new MutationObserver(callback); observer.observe(access.parent, { childList: true }); const dynamicSlot = { component, hostProps, }; dynamicSlotted.update( (dynamicSlotted: DynamicSlotted[]): DynamicSlotted[] => { dynamicSlotted.push(dynamicSlot); return dynamicSlotted; }, ); await dynamicallySlottedMounted; return { destroy() { dynamicSlotted.update( (dynamicSlotted: DynamicSlotted[]): DynamicSlotted[] => { // TODO needs testing, if Svelte actually correctly removes the element removeItem(dynamicSlotted, dynamicSlot); return dynamicSlotted; }, ); }, }; } async function updateProps( update: (props: T) => T, identifier: Identifier, ): Promise { const access = await accessPromise; return access.updateElement((_element: U, index: number): void => { slotted.update((slottedProps: T[]) => { slottedProps[index] = update(slottedProps[index]); return slottedProps; }); }, identifier); } const slotsInterface = createInterface({ addComponent, updateProps }); function getSlotHostProps(): T { const props = makeProps(); slotted.update((slotted: T[]): T[] => { slotted.push(props); return slotted; }); return props; } setSlotHostContext({ getProps: getSlotHostProps }); return { dynamicSlotted, resolveSlotContainer, slotsInterface, }; } export default dynamicSlotting; /** Convenient default functions for dynamic slotting */ export function defaultProps(): SlotHostProps { return { detach: writable(false), }; } export interface DefaultSlotInterface extends Record { insert( button: DynamicSvelteComponent, position?: Identifier, ): Promise<{ destroy: Callback }>; append( button: DynamicSvelteComponent, position?: Identifier, ): Promise<{ destroy: Callback }>; show(position: Identifier): Promise; hide(position: Identifier): Promise; toggle(position: Identifier): Promise; } export function defaultInterface({ addComponent, updateProps, }: CreateInterfaceAPI): DefaultSlotInterface { function insert( component: DynamicSvelteComponent, id: Identifier = 0, ): Promise<{ destroy: Callback }> { return addComponent( component, (element: Element, access: ChildrenAccess) => access.insertElement(element, id), ); } function append( component: DynamicSvelteComponent, id: Identifier = -1, ): Promise<{ destroy: Callback }> { return addComponent( component, (element: Element, access: ChildrenAccess) => access.appendElement(element, id), ); } function show(id: Identifier): Promise { return updateProps((props: T): T => { props.detach.set(false); return props; }, id); } function hide(id: Identifier): Promise { return updateProps((props: T): T => { props.detach.set(true); return props; }, id); } function toggle(id: Identifier): Promise { return updateProps((props: T): T => { props.detach.update((detached: boolean) => !detached); return props; }, id); } return { insert, append, show, hide, toggle, }; } import contextProperty from "./context-property"; const key = Symbol("dynamicSlotting"); const [defaultSlotHostContext, setSlotHostContext] = contextProperty>(key); export { defaultSlotHostContext, setSlotHostContext }; ================================================ FILE: ts/lib/sveltelib/dynamicComponent.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import type { SvelteComponent } from "svelte"; export interface DynamicSvelteComponent< T extends typeof SvelteComponent = typeof SvelteComponent, > { component: T; [k: string]: unknown; } export const dynamicComponent = < Comp extends typeof SvelteComponent, DefaultProps = NonNullable[0]["props"]>, >( component: Comp, ) => (props: Props): DynamicSvelteComponent & Props => { return { component, ...props }; }; ================================================ FILE: ts/lib/sveltelib/event-predicate.d.ts ================================================ export interface EventPredicateResult { reason: string; originalEvent: Event; } ================================================ FILE: ts/lib/sveltelib/event-store.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { EventTargetToMap } from "@tslib/events"; import { on } from "@tslib/events"; import type { Callback } from "@tslib/typing"; import type { Readable, Subscriber } from "svelte/store"; import { readable } from "svelte/store"; type Init = { new(type: string): T; prototype: T }; /** * A store wrapping an event. Automatically adds/removes event handler upon * first/last subscriber. * * @remarks * Should probably always be used in conjunction with `subscribeToUpdates`. */ function eventStore>( target: T, eventType: Exclude, /** * Store needs an initial value. This should probably be a freshly * constructed event, e.g. `new MouseEvent("click")`. */ constructor: Init[K]>, ): Readable[K]> { const initEvent = new constructor(eventType); return readable( initEvent, (set: Subscriber[K]>): Callback => on(target, eventType, set), ); } export default eventStore; /** * A click event that fires only if the mouse has not appreciably moved since the button * was pressed down. This was added so that if the user clicks inside a floating area and * drags the mouse outside the area while selecting text, it doesn't end up closing the * floating area. */ function mouseClickWithoutDragStore(): Readable { const initEvent = new MouseEvent("click"); return readable( initEvent, (set: Subscriber): Callback => { let startingX: number; let startingY: number; function onMouseDown(evt: MouseEvent): void { startingX = evt.clientX; startingY = evt.clientY; } function onClick(evt: MouseEvent): void { if (Math.abs(startingX - evt.clientX) < 5 && Math.abs(startingY - evt.clientY) < 5) { set(evt); } } document.addEventListener("mousedown", onMouseDown); document.addEventListener("click", onClick); return () => { document.removeEventListener("click", onClick); document.removeEventListener("mousedown", onMouseDown); }; }, ); } const documentClick = mouseClickWithoutDragStore(); const documentKeyup = eventStore(document, "keyup", KeyboardEvent); export { documentClick, documentKeyup }; ================================================ FILE: ts/lib/sveltelib/export-runtime.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // Expose the Svelte runtime bundled with Anki, so that add-ons can require() it. // If they were to bundle their own runtime, things like bindings and contexts // would not work. import { registerPackageRaw } from "@tslib/runtime-require"; import * as svelteRuntime from "svelte"; // import * as svelteInternal from "svelte/internal"; // import * as svelteDiscloseVersion from "svelte/internal/disclose-version"; import * as svelteStore from "svelte/store"; registerPackageRaw("svelte", svelteRuntime); registerPackageRaw("svelte/store", svelteStore); // registerPackageRaw("svelte/internal", svelteInternal); // registerPackageRaw("svelte/internal/disclose-version", svelteDiscloseVersion); ================================================ FILE: ts/lib/sveltelib/handler-list.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { Callback } from "@tslib/typing"; import type { Readable, Writable } from "svelte/store"; import { writable } from "svelte/store"; type Handler = (args: T) => Promise; interface HandlerAccess { callback: Handler; clear(): void; } class TriggerItem { #active: Writable; constructor( private setter: (handler: Handler, clear: Callback) => void, private clear: Callback, ) { this.#active = writable(false); } /** * A store which indicates whether the trigger is currently turned on. */ get active(): Readable { return this.#active; } /** * Deactivate the trigger. Can be safely called multiple times. */ off(): void { this.#active.set(false); this.clear(); } on(handler: Handler): void { this.setter(handler, () => this.off()); this.#active.set(true); } } interface HandlerOptions { once: boolean; } export class HandlerList { #list: HandlerAccess[] = []; /** * Returns a `TriggerItem`, which can be used to attach event handlers. * This TriggerItem exposes an additional `active` store. This can be * useful, if other components need to react to the input handler being active. */ trigger(options?: Partial): TriggerItem { const once = options?.once ?? false; let handler: Handler | null = null; return new TriggerItem( (callback: Handler, doClear: Callback): void => { const handlerAccess = { callback(args: T): Promise { const result = callback(args); if (once) { doClear(); } return result; }, clear(): void { if (once) { doClear(); } }, }; this.#list.push(handlerAccess); handler = handlerAccess.callback; }, () => { if (handler) { this.off(handler); handler = null; } }, ); } /** * Attaches an event handler. * @returns a callback, which removes the event handler. Alternatively, * you can call `off` on the HandlerList. */ on(handler: Handler, options?: Partial): Callback { const once = options?.once ?? false; let offHandler: Handler | null = null; const off = (): void => { if (offHandler) { this.off(offHandler); offHandler = null; } }; const handlerAccess = { callback: (args: T): Promise => { const result = handler(args); if (once) { off(); } return result; }, clear(): void { if (once) { off(); } }, }; offHandler = handlerAccess.callback; this.#list.push(handlerAccess); return off; } private off(handler: Handler): void { const index = this.#list.findIndex( (value: HandlerAccess): boolean => value.callback === handler, ); if (index >= 0) { this.#list.splice(index, 1); } } get length(): number { return this.#list.length; } dispatch(args: T): Promise { const promises: Promise[] = []; for (const { callback } of [...this]) { promises.push(callback(args)); } return Promise.all(promises) as unknown as Promise; } clear(): void { for (const { clear } of [...this]) { clear(); } } [Symbol.iterator](): Iterator, null, unknown> { const list = this.#list; let step = 0; return { next(): IteratorResult, null> { if (step >= list.length) { return { value: null, done: true }; } return { value: list[step++], done: false }; }, }; } } export type { TriggerItem }; ================================================ FILE: ts/lib/sveltelib/input-handler.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { getRange, getSelection } from "@tslib/cross-browser"; import { on } from "@tslib/events"; import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp } from "@tslib/keys"; import { singleCallback } from "@tslib/typing"; import { HandlerList } from "./handler-list"; const nbsp = "\xa0"; export type SetupInputHandlerAction = (element: HTMLElement) => { destroy(): void }; export interface InputEventParams { event: InputEvent; } export interface EventParams { event: Event; } export interface InsertTextParams { event: InputEvent; text: Text; } type SpecialKeyAction = | "caretUp" | "caretDown" | "caretLeft" | "caretRight" | "enter" | "tab"; export interface SpecialKeyParams { event: KeyboardEvent; action: SpecialKeyAction; } export interface InputHandlerAPI { readonly beforeInput: HandlerList; readonly insertText: HandlerList; readonly afterInput: HandlerList; readonly pointerDown: HandlerList<{ event: PointerEvent }>; readonly specialKey: HandlerList; } /** * An interface that allows Svelte components to attach event listeners via triggers. * They will be attached to the component(s) that install the manager. * Prevents that too many event listeners are attached and allows for some * coordination between them. */ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] { const beforeInput = new HandlerList(); const insertText = new HandlerList(); const afterInput = new HandlerList(); async function onBeforeInput(this: Element, event: InputEvent): Promise { const selection = getSelection(this)!; const range = getRange(selection); await beforeInput.dispatch({ event }); if ( !range || !event.inputType.startsWith("insert") || insertText.length === 0 ) { return; } event.preventDefault(); const content = !event.data || event.data === " " ? nbsp : event.data; const text = new Text(content); range.deleteContents(); range.insertNode(text); range.selectNode(text); range.collapse(false); await insertText.dispatch({ event, text }); range.commonAncestorContainer.normalize(); // We emulate the after input event here, because we prevent // the default behavior earlier await afterInput.dispatch({ event }); } async function onInput(this: Element, event: Event): Promise { await afterInput.dispatch({ event }); } const pointerDown = new HandlerList<{ event: PointerEvent }>(); function clearInsertText(): void { insertText.clear(); } function onPointerDown(event: PointerEvent): void { pointerDown.dispatch({ event }); clearInsertText(); } const specialKey = new HandlerList(); async function onKeyDown(this: Element, event: KeyboardEvent): Promise { if (isArrowDown(event)) { specialKey.dispatch({ event, action: "caretDown" }); } else if (isArrowUp(event)) { specialKey.dispatch({ event, action: "caretUp" }); } else if (isArrowRight(event)) { specialKey.dispatch({ event, action: "caretRight" }); } else if (isArrowLeft(event)) { specialKey.dispatch({ event, action: "caretLeft" }); } else if (event.key === "Enter") { specialKey.dispatch({ event, action: "enter" }); } else if (event.code === "Tab") { specialKey.dispatch({ event, action: "tab" }); } } function setupHandler(element: HTMLElement): { destroy(): void } { const destroy = singleCallback( on(element, "beforeinput", onBeforeInput), on(element, "input", onInput), on(element, "blur", clearInsertText), on(element, "pointerdown", onPointerDown), on(element, "keydown", onKeyDown), on(document, "selectionchange", clearInsertText), ); return { destroy }; } return [ { beforeInput, insertText, afterInput, specialKey, pointerDown, }, setupHandler, ]; } export default useInputHandler; ================================================ FILE: ts/lib/sveltelib/lifecycle-hooks.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { Callback } from "@tslib/helpers"; import { removeItem } from "@tslib/helpers"; import { onDestroy as svelteOnDestroy, onMount as svelteOnMount } from "svelte"; type ComponentAPIMount = (api: T) => Callback | void; type ComponentAPIDestroy = (api: T) => void; type SetLifecycleHooksAction = (api: T) => void; export interface LifecycleHooks { onMount(callback: ComponentAPIMount): Callback | Promise; onDestroy(callback: ComponentAPIDestroy): Callback | Promise; } /** * Makes the Svelte lifecycle hooks accessible to add-ons. * Currently we expose onMount and onDestroy in here, but it is fully * thinkable to expose the others as well, given a good use case. */ function lifecycleHooks(): [LifecycleHooks, T[], SetLifecycleHooksAction] { const instances: T[] = []; const mountCallbacks: ComponentAPIMount[] = []; const destroyCallbacks: ComponentAPIDestroy[] = []; function setup(api: T): void { svelteOnMount(() => { const cleanups: Promise[] = []; for (const mountCallback of mountCallbacks) { // Promise.resolve doesn't care whether it's a promise or sync callback cleanups.push( Promise.resolve(mountCallback).then((callback) => { return callback(api); }), ); } // onMount seems to be called in reverse order instances.unshift(api); return async () => { for (const cleanup of await Promise.all(cleanups)) { if (cleanup) { cleanup(); } } }; }); svelteOnDestroy(() => { removeItem(instances, api); for (const destroyCallback of destroyCallbacks) { Promise.resolve(destroyCallback).then((callback) => { callback(api); }); } }); } function onMount(callback: ComponentAPIMount): Callback { mountCallbacks.push(callback); return () => removeItem(mountCallbacks, callback); } function onDestroy(callback: ComponentAPIDestroy): Callback { destroyCallbacks.push(callback); return () => removeItem(mountCallbacks, callback); } const lifecycle = { onMount, onDestroy, }; return [lifecycle, instances, setup]; } export default lifecycleHooks; ================================================ FILE: ts/lib/sveltelib/modal-closing.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { on } from "@tslib/events"; interface ModalClosingHandler { set: (value: boolean) => void; remove: () => void; } /** * Register a keydown handler on the document that can optionally stop propagation to other handlers if Escape is pressed and the associated flag is set. * Intended to override the general handler in webview.py when a modal is open. */ function registerModalClosingHandler(callback?: () => void): ModalClosingHandler { let modalIsOpen = false; function set(value: boolean) { modalIsOpen = value; } const remove = on(document, "keydown", (event) => { if (event.key === "Escape" && modalIsOpen) { event.stopImmediatePropagation(); if (callback) { callback(); } } }, { capture: true }); return { set, remove }; } export { registerModalClosingHandler }; ================================================ FILE: ts/lib/sveltelib/node-store.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { noop } from "@tslib/functional"; import type { Subscriber, Unsubscriber, Updater, Writable } from "svelte/store"; export interface NodeStore extends Writable { setUnprocessed(node: T): void; } export function nodeStore( node?: T, preprocess: (node: T) => void = noop, ): NodeStore { const subscribers: Set> = new Set(); function setUnprocessed(newNode: T): void { if (node && node.isEqualNode(newNode)) { return; } node = newNode; for (const subscriber of subscribers) { subscriber(node); } } function set(newNode: T): void { preprocess(newNode); setUnprocessed(newNode); } function update(fn: Updater): void { set(fn(node!)); } function subscribe(subscriber: Subscriber): Unsubscriber { subscribers.add(subscriber); if (node) { subscriber(node); } return () => subscribers.delete(subscriber); } return { set, setUnprocessed, update, subscribe }; } export default nodeStore; ================================================ FILE: ts/lib/sveltelib/position/auto-update.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { FloatingElement, ReferenceElement } from "@floating-ui/dom"; import { autoUpdate as floatingUiAutoUpdate } from "@floating-ui/dom"; import type { Callback } from "@tslib/typing"; import type { ActionReturn } from "svelte/action"; /** * The interface of `autoUpdate` of floating-ui. * This means PositioningCallback can be used with that, but also invoked as it is. * * @example ``` * // Invoke the positioning algorithm handily * position(myReference, (_, _, callback) => { * callback(); * })` */ export type PositioningCallback = ( reference: ReferenceElement, floating: FloatingElement, position: Callback, ) => Callback; /** * The interface of a function that calls `computePosition` of floating-ui. */ export type PositionFunc = ( reference: ReferenceElement, callback: PositioningCallback, ) => Callback; function autoUpdate( reference: ReferenceElement, /** * The method to position the floating element. */ position: PositionFunc, ): ActionReturn { let cleanup: Callback; function destroy() { cleanup?.(); } function update(position: PositionFunc): void { destroy(); cleanup = position(reference, floatingUiAutoUpdate); } update(position); return { destroy, update }; } export default autoUpdate; ================================================ FILE: ts/lib/sveltelib/position/position-algorithm.d.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { FloatingElement, Placement, ReferenceElement } from "@floating-ui/dom"; /** * The interface of a function that calls `computePosition` of floating-ui. */ export type PositionAlgorithm = ( reference: ReferenceElement, floating: FloatingElement, ) => Promise; ================================================ FILE: ts/lib/sveltelib/position/position-floating.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { ComputePositionConfig, FloatingElement, Middleware, Placement, ReferenceElement } from "@floating-ui/dom"; import { arrow, computePosition, flip, hide, inline, offset, shift } from "@floating-ui/dom"; import type { PositionAlgorithm } from "./position-algorithm"; export interface PositionFloatingArgs { placement: Placement; arrow: HTMLElement; shift: number; offset: number; inline: boolean; hideIfEscaped: boolean; hideIfReferenceHidden: boolean; hideCallback: (reason: string) => void; } function positionFloating({ placement, arrow: arrowElement, shift: shiftArg, offset: offsetArg, inline: inlineArg, hideIfEscaped, hideIfReferenceHidden, hideCallback, }: PositionFloatingArgs): PositionAlgorithm { return async function( reference: ReferenceElement, floating: FloatingElement, ): Promise { const middleware: Middleware[] = [ flip(), offset(offsetArg), shift({ padding: shiftArg }), arrow({ element: arrowElement, padding: 5 }), ]; if (inlineArg) { middleware.unshift(inline()); } const computeArgs: Partial = { middleware, placement, }; if (hideIfEscaped) { middleware.push(hide({ strategy: "escaped" })); } if (hideIfReferenceHidden) { middleware.push(hide({ strategy: "referenceHidden" })); } const { x, y, middlewareData, placement: computedPlacement, } = await computePosition(reference, floating, computeArgs); if (middlewareData.hide?.escaped) { hideCallback("escaped"); return computedPlacement; } if (middlewareData.hide?.referenceHidden) { hideCallback("referenceHidden"); return computedPlacement; } Object.assign(floating.style, { left: `${x}px`, top: `${y}px`, }); let rotation: number; let arrowX: number | undefined; let arrowY: number | undefined; if (computedPlacement.startsWith("bottom")) { rotation = 45; arrowX = middlewareData.arrow?.x; arrowY = -5; } else if (computedPlacement.startsWith("left")) { rotation = 135; arrowX = floating.offsetWidth - 5; arrowY = middlewareData.arrow?.y; } else if (computedPlacement.startsWith("top")) { rotation = 225; arrowX = middlewareData.arrow?.x; arrowY = floating.offsetHeight - 5; } /* if (computedPlacement.startsWith("right")) */ else { rotation = 315; arrowX = -5; arrowY = middlewareData.arrow?.y; } Object.assign(arrowElement.style, { left: arrowX ? `${arrowX}px` : "", top: arrowY ? `${arrowY}px` : "", transform: `rotate(${rotation}deg)`, }); return computedPlacement; }; } export default positionFloating; ================================================ FILE: ts/lib/sveltelib/position/position-overlay.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { ComputePositionConfig, FloatingElement, Middleware, Placement, ReferenceElement } from "@floating-ui/dom"; import { computePosition, inline, offset } from "@floating-ui/dom"; import type { PositionAlgorithm } from "./position-algorithm"; export interface PositionOverlayArgs { padding: number; inline: boolean; hideCallback: (reason: string) => void; } function positionOverlay({ padding, inline: inlineArg, hideCallback, }: PositionOverlayArgs): PositionAlgorithm { return async function( reference: ReferenceElement, floating: FloatingElement, ): Promise { const middleware: Middleware[] = inlineArg ? [inline()] : []; const { width, height } = reference.getBoundingClientRect(); middleware.push( offset({ mainAxis: -(height + padding), }), ); const computeArgs: Partial = { middleware, }; const { x, y, middlewareData, placement } = await computePosition( reference, floating, computeArgs, ); // console.log(x, y) if (middlewareData.hide?.escaped) { hideCallback("escaped"); } if (middlewareData.hide?.referenceHidden) { hideCallback("referenceHidden"); } Object.assign(floating.style, { left: `${x}px`, top: `${y}px`, width: `${width + 2 * padding}px`, height: `${height + 2 * padding}px`, }); return placement; }; } export default positionOverlay; ================================================ FILE: ts/lib/sveltelib/preferences.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { Writable } from "svelte/store"; import { writable } from "svelte/store"; /** Automatically saves to the backend on modification. */ export type PreferenceStore = Writable; /** Creates a store out of a preference getter, calling the setter when * changes are made. */ export async function autoSavingPrefs( getter: () => Promise, setter: (msg: T) => Promise, ): Promise> { let currentValue = await getter() as T; const { subscribe, set: origSet } = writable(currentValue); function set(value: T): void { currentValue = value; origSet(value); setter(value); } function update(updater: (value: T) => T): void { set(updater(currentValue)); } return { subscribe, set, update, }; } ================================================ FILE: ts/lib/sveltelib/resize-store.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { Callback } from "@tslib/typing"; import type { Readable, Subscriber } from "svelte/store"; import { readable } from "svelte/store"; interface ResizeObserverArgs { entries: ResizeObserverEntry[]; observer: ResizeObserver; } export type ResizeStore = Readable; /** * A store wrapping a ResizeObserver. Automatically observes the target upon * first/last subscriber. * * @remarks * Should probably always be used in conjunction with `subscribeToUpdates`. */ function resizeStore(target: Element): ResizeStore { let setter: (args: ResizeObserverArgs) => void; const observer = new ResizeObserver( (entries: ResizeObserverEntry[], observer: ResizeObserver): void => setter({ entries, observer, }), ); return readable( { entries: [], observer }, (set: Subscriber): Callback => { setter = set; observer.observe(target); return () => observer.unobserve(target); }, ); } export default resizeStore; ================================================ FILE: ts/lib/sveltelib/shortcut.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { RegisterShortcutRestParams } from "@tslib/shortcuts"; import { registerShortcut } from "@tslib/shortcuts"; interface ShortcutParams { action: (event: KeyboardEvent) => void; keyCombination: string; params?: RegisterShortcutRestParams; } export function shortcut( _node: Node, { action, keyCombination, params }: ShortcutParams, ): { destroy: () => void } { const deregister = registerShortcut(action, keyCombination, params); return { destroy: deregister, }; } export default shortcut; ================================================ FILE: ts/lib/sveltelib/store-subscribe.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { Readable, Unsubscriber } from "svelte/store"; interface StoreAccessors { subscribe: () => void; unsubscribe: () => void; } /** * Helper function to prevent double (un)subscriptions */ function storeSubscribe( store: Readable, callback: (value: T) => void, start = true, ): StoreAccessors { function subscribe(): Unsubscriber { return store.subscribe(callback); } let unsubscribe: Unsubscriber | null = start ? subscribe() : null; function resubscribe(): void { if (!unsubscribe) { unsubscribe = subscribe(); } } function doUnsubscribe() { unsubscribe?.(); unsubscribe = null; } return { subscribe: resubscribe, unsubscribe: doUnsubscribe, }; } export default storeSubscribe; ================================================ FILE: ts/lib/sveltelib/subscribe-updates.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { Readable, Subscriber, Unsubscriber } from "svelte/store"; /** * In some cases, we only care for updates, and not the initial * value of a store, e.g. when the store wraps events. * This also means, we can not use the special store syntax. */ function subscribeToUpdates( store: Readable, subscription: Subscriber, ): Unsubscriber { let first = true; return store.subscribe((value: T): void => { if (first) { first = false; } else { subscription(value); } }); } export default subscribeToUpdates; ================================================ FILE: ts/lib/sveltelib/theme.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { registerPackage } from "@tslib/runtime-require"; import { get, readable } from "svelte/store"; interface ThemeInfo { isDark: boolean; } function getThemeFromRoot(): ThemeInfo { return { isDark: document.documentElement.classList.contains("night-mode"), }; } let setPageTheme: ((theme: ThemeInfo) => void) | null = null; /** The current theme that applies to this document/shadow root. When previewing cards in the card layout screen, this may not match the theme Anki is using in its UI. */ export const pageTheme = readable(getThemeFromRoot(), (set) => { setPageTheme = set; }); // ensure setPageTheme is set immediately get(pageTheme); // Update theme when root element's class changes. const observer = new MutationObserver((_mutationsList, _observer) => { setPageTheme!(getThemeFromRoot()); }); observer.observe(document.documentElement, { attributeFilter: ["class"] }); registerPackage("anki/theme", { pageTheme, }); ================================================ FILE: ts/lib/sveltelib/toggleable.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { Writable } from "svelte/store"; import { writable } from "svelte/store"; export interface Toggleable extends Writable { toggle: () => void; on: () => void; off: () => void; } function toggleable(defaultValue: boolean): Toggleable { const store = writable(defaultValue) as Toggleable; function toggle(): void { store.update((value) => !value); } store.toggle = toggle; function on(): void { store.set(true); } store.on = on; function off(): void { store.set(false); } store.off = off; return store; } export default toggleable; ================================================ FILE: ts/lib/tag-editor/AutocompleteItem.svelte ================================================

================================================ FILE: ts/lib/tag-editor/Tag.svelte ================================================ ================================================ FILE: ts/lib/tag-editor/TagDeleteBadge.svelte ================================================ ================================================ FILE: ts/lib/tag-editor/TagEditMode.svelte ================================================ dispatch("tagedit")} let:selectMode let:hoverClass > { if (!selectMode) { deleteTag(); evt.stopPropagation(); } }} /> ================================================ FILE: ts/lib/tag-editor/TagEditor.svelte ================================================ {#if anyTagsSelected} {/if}
{#each tagTypes as tag, index (tag.id)}
{ active = index; deselect(); }} on:tagselect={() => select(index)} on:tagrange={() => selectRange(index)} on:tagdelete={() => { deselect(); deleteTagAt(index); saveTags(); }} /> {#if index === active} onAutocomplete(detail.selected)} on:choose={({ detail }) => { onAutocomplete(detail.chosen); splitTag(index, detail.chosen.length, detail.chosen.length); }} let:createAutocomplete > { dispatch("tagsFocused"); activeName = tag.name; autocomplete = createAutocomplete(); }} on:keydown={onKeydown} on:keyup={() => { if (activeName.length === 0) { show?.set(false); } }} on:taginput={() => updateTagName(tag)} on:tagsplit={({ detail }) => splitTag(index, detail.start, detail.end)} on:tagadd={() => insertTagKeepFocus(index)} on:tagdelete={() => deleteTagAt(index)} on:tagselectall={async () => { if (tagTypes.length <= 1) { // Noop if no other tags exist return; } activeInput.blur(); // Ensure blur events are processed first await tick(); selectAllTags(); }} on:tagjoinprevious={() => joinWithPreviousTag(index)} on:tagjoinnext={() => joinWithNextTag(index)} on:tagmoveprevious={() => moveToPreviousTag(index)} on:tagmovenext={() => moveToNextTag(index)} on:tagaccept={() => { deleteTagIfNotUnique(tag, index); if (tag) { updateTagName(tag); } saveTags(); decideNextActive(); }} /> {/if}
{/each}
================================================ FILE: ts/lib/tag-editor/TagInput.svelte ================================================ dispatch("taginput")} on:copy|preventDefault={onCopy} on:cut|preventDefault={onCut} on:paste|preventDefault={onPaste} use:updateCurrent /> ================================================ FILE: ts/lib/tag-editor/TagSpacer.svelte ================================================

================================================ FILE: ts/lib/tag-editor/TagWithTooltip.svelte ================================================
{#if active} {name} {:else if shorten && hasMultipleParts(name)} createTooltip(event.detail.button)} > {processTagName(name)} {:else} {name} {/if}
================================================ FILE: ts/lib/tag-editor/TagsRow.svelte ================================================ (tags = detail.tags)} {keyCombination} /> ================================================ FILE: ts/lib/tag-editor/WithAutocomplete.svelte ================================================ show.set(false)} >
{#each suggestionsItems as suggestion, index} {#if index === selected} setSelectedAndActive(index)} on:mouseup={() => { selectIndex(index); chooseSelected(); }} on:mouseenter={(event) => selectIfMousedown(event, index)} on:mouseleave={() => (active = false)} > {suggestion} {:else} setSelectedAndActive(index)} on:mouseup={() => { selectIndex(index); chooseSelected(); }} on:mouseenter={(event) => selectIfMousedown(event, index)} > {suggestion} {/if} {/each}
================================================ FILE: ts/lib/tag-editor/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export { default as TagEditor } from "./TagEditor.svelte"; ================================================ FILE: ts/lib/tag-editor/tag-options-button/TagAddButton.svelte ================================================
dispatch("tagappend")} /> ================================================ FILE: ts/lib/tag-editor/tag-options-button/TagOptionsButton.svelte ================================================
{#key tagsSelected} {#if tagsSelected} {:else} {/if} {/key}
================================================ FILE: ts/lib/tag-editor/tag-options-button/TagsSelectedButton.svelte ================================================
(show = !show)} on:keydown={onEnterOrSpace(() => (show = !show))} >
dispatch("tagselectall")}> {tr.editingTagsSelectAll()} ({getPlatformString(selectAllShortcut)}) dispatch("tagcopy")}> {tr.editingTagsCopy()} ({getPlatformString(copyShortcut)}) dispatch("tagdelete")}> {tr.editingTagsRemove()} ({getPlatformString(removeShortcut)})
================================================ FILE: ts/lib/tag-editor/tag-options-button/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export { default as TagOptionsButton } from "./TagOptionsButton.svelte"; ================================================ FILE: ts/lib/tag-editor/tags.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export const delimChar = "\u2237"; export function replaceWithUnicodeSeparator(name: string): string { return name.replace(/::/g, delimChar); } export function replaceWithColons(name: string): string { return name.replace(/\u2237/gu, "::"); } export function normalizeTagname(tagname: string): string { let trimmed = tagname.trim(); while (trimmed.startsWith(":") || trimmed.startsWith(delimChar)) { trimmed = trimmed.slice(1).trimStart(); } while (trimmed.endsWith(":") || trimmed.endsWith(delimChar)) { trimmed = trimmed.slice(0, -1).trimEnd(); } return trimmed; } export interface Tag { id: string; name: string; selected: boolean; flash: () => void; } export function attachId(name: string): Tag { return { id: Math.random().toString(36).substring(2), name, selected: false, flash: () => { /* noop */ }, }; } export function getName(tag: Tag): string { return tag.name; } ================================================ FILE: ts/lib/tslib/bridgecommand.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { registerPackage } from "./runtime-require"; declare global { interface Window { bridgeCommand(command: string, callback?: (value: T) => void): void; } } /** HTML tag pointing to a bridge command. */ export function bridgeLink(command: string, label: string): string { return `${label}`; } export function bridgeCommandsAvailable(): boolean { return !!window.bridgeCommand; } export function bridgeCommand(command: string, callback?: (value: T) => void): void { window.bridgeCommand(command, callback); } registerPackage("anki/bridgecommand", { bridgeCommand, }); ================================================ FILE: ts/lib/tslib/cards.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export enum CardType { New = 0, Learn = 1, Review = 2, Relearn = 3, } export enum CardQueue { /** due is the order cards are shown in */ New = 0, /** due is a unix timestamp */ Learn = 1, /** due is days since creation date */ Review = 2, DayLearn = 3, /** due is a unix timestamp. */ /** preview cards only placed here when failed. */ PreviewRepeat = 4, /** cards are not due in these states */ Suspended = -1, SchedBuried = -2, UserBuried = -3, } ================================================ FILE: ts/lib/tslib/children-access.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export type Identifier = Element | string | number; function findElement( collection: HTMLCollection, identifier: Identifier, ): [T, number] | null { let element: T; let index: number; if (identifier instanceof Element) { element = identifier as T; index = Array.prototype.indexOf.call(collection, element); if (index < 0) { return null; } } else if (typeof identifier === "string") { const item = collection.namedItem(identifier); if (!item) { return null; } element = item as T; index = Array.prototype.indexOf.call(collection, element); if (index < 0) { return null; } } else if (identifier < 0) { index = collection.length + identifier; const item = collection.item(index); if (!item) { return null; } element = item as T; } else { index = identifier; const item = collection.item(index); if (!item) { return null; } element = item as T; } return [element, index]; } /** * Creates a convenient access API for the children * of an element via identifiers. Identifiers can be: * - integers: signify the position * - negative integers: signify the offset from the end (-1 being the last element) * - strings: signify the id of an element * - the child directly */ class ChildrenAccess { parent: T; constructor(parent: T) { this.parent = parent; } insertElement(element: Element, identifier: Identifier): number { const match = findElement(this.parent.children, identifier); if (!match) { return -1; } const [reference, index] = match; this.parent.insertBefore(element, reference[0]); return index; } appendElement(element: Element, identifier: Identifier): number { const match = findElement(this.parent.children, identifier); if (!match) { return -1; } const [before, index] = match; const reference = before.nextElementSibling ?? null; this.parent.insertBefore(element, reference); return index + 1; } updateElement( f: (element: T, index: number) => void, identifier: Identifier, ): boolean { const match = findElement(this.parent.children, identifier); if (!match) { return false; } f(...match); return true; } } function childrenAccess(parent: T): ChildrenAccess { return new ChildrenAccess(parent); } export default childrenAccess; export type { ChildrenAccess }; ================================================ FILE: ts/lib/tslib/context-keys.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export const fontFamilyKey = Symbol("fontFamily"); export const fontSizeKey = Symbol("fontSize"); export const directionKey = Symbol("direction"); export const descriptionKey = Symbol("description"); export const collapsedKey = Symbol("collapsed"); export const tagActionsShortcutsKey = Symbol("tagActionsShortcuts"); ================================================ FILE: ts/lib/tslib/cross-browser.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/triple-slash-reference: "off", */ /// /** * Gecko has no .getSelection on ShadowRoot, only .activeElement */ export function getSelection(element: Node): Selection | null { const root = element.getRootNode(); if (root.getSelection) { return root.getSelection(); } return document.getSelection(); } /** * Browser has potential support for multiple ranges per selection built in, * but in reality only Gecko supports it. * If there are multiple ranges, the latest range is the _main_ one. */ export function getRange(selection: Selection): Range | null { const rangeCount = selection.rangeCount; return rangeCount === 0 ? null : selection.getRangeAt(rangeCount - 1); } /** * Avoid using selection.isCollapsed: it will always return * true in shadow root in Gecko * (this bug seems to also happens in Blink) */ export function isSelectionCollapsed(selection: Selection): boolean { return getRange(selection)!.collapsed; } ================================================ FILE: ts/lib/tslib/dom.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { getSelection } from "./cross-browser"; export function nodeIsElement(node: Node): node is Element { return node.nodeType === Node.ELEMENT_NODE; } /** * In the web this is probably equivalent to `nodeIsElement`, but this is * convenient to convince Typescript. */ export function nodeIsCommonElement(node: Node): node is HTMLElement | SVGElement { return node instanceof HTMLElement || node instanceof SVGElement; } export function nodeIsText(node: Node): node is Text { return node.nodeType === Node.TEXT_NODE; } export function nodeIsComment(node: Node): node is Comment { return node.nodeType === Node.COMMENT_NODE; } // https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements export const BLOCK_ELEMENTS = [ "ADDRESS", "ARTICLE", "ASIDE", "BLOCKQUOTE", "DETAILS", "DIALOG", "DD", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION", "FIGURE", "FOOTER", "FORM", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER", "HGROUP", "HR", "LI", "MAIN", "NAV", "OL", "P", "PRE", "SECTION", "TABLE", "UL", ]; export function hasBlockAttribute(element: Element): boolean { return element.hasAttribute("block") && element.getAttribute("block") !== "false"; } export function elementIsBlock(element: Element): boolean { return BLOCK_ELEMENTS.includes(element.tagName) || hasBlockAttribute(element); } export const NO_SPLIT_TAGS = ["RUBY"]; export function elementShouldNotBeSplit(element: Element): boolean { return elementIsBlock(element) || NO_SPLIT_TAGS.includes(element.tagName); } // https://developer.mozilla.org/en-US/docs/Glossary/Empty_element export const EMPTY_ELEMENTS = [ "AREA", "BASE", "BR", "COL", "EMBED", "HR", "IMG", "INPUT", "LINK", "META", "PARAM", "SOURCE", "TRACK", "WBR", ]; export function elementIsEmpty(element: Element): boolean { return EMPTY_ELEMENTS.includes(element.tagName); } export function nodeContainsInlineContent(node: Node): boolean { for (const child of node.childNodes) { if ( (nodeIsElement(child) && elementIsBlock(child)) || !nodeContainsInlineContent(child) ) { return false; } } // empty node is trivially inline return true; } /** * Consumes the input fragment. */ export function fragmentToString(fragment: DocumentFragment): string { const fragmentDiv = document.createElement("div"); fragmentDiv.appendChild(fragment); const html = fragmentDiv.innerHTML; return html; } const getAnchorParent = (predicate: (element: Element) => element is T) => (root: Node): T | null => { const anchor = getSelection(root)?.anchorNode; if (!anchor) { return null; } let anchorParent: T | null = null; let element = nodeIsElement(anchor) ? anchor : anchor.parentElement; while (element) { anchorParent = anchorParent || (predicate(element) ? element : null); element = element.parentElement; } return anchorParent; }; const isListItem = (element: Element): element is HTMLLIElement => window.getComputedStyle(element).display === "list-item"; export const getListItem = getAnchorParent(isListItem); ================================================ FILE: ts/lib/tslib/events.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export type EventTargetToMap = A extends HTMLElement ? HTMLElementEventMap : A extends Document ? DocumentEventMap : A extends Window ? WindowEventMap : A extends FileReader ? FileReaderEventMap : A extends Animation ? AnimationEventMap : A extends EventSource ? EventSourceEventMap : A extends AbortSignal ? AbortSignalEventMap : A extends AbstractWorker ? AbstractWorkerEventMap : never; export function on>( target: T, eventType: Exclude, handler: (this: T, event: EventTargetToMap[K]) => void, options?: AddEventListenerOptions, ): () => void { target.addEventListener(eventType, handler as EventListener, options); return () => target.removeEventListener(eventType, handler as EventListener, options); } export function preventDefault(event: Event): void { event.preventDefault(); } ================================================ FILE: ts/lib/tslib/functional.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export function noop(): void { /* noop */ } export async function asyncNoop(): Promise { /* noop */ } export function id(t: T): T { return t; } export function truthy(t: T | void | undefined | null): t is T { return Boolean(t); } ================================================ FILE: ts/lib/tslib/globals.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export function globalExport(globals: Record): void { for (const key in globals) { window[key] = globals[key]; } // but also export as window.anki window["anki"] = globals; } ================================================ FILE: ts/lib/tslib/help-page.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /** These links are checked in CI to ensure they are valid. */ export const HelpPage = { DeckOptions: { maximumInterval: "https://docs.ankiweb.net/deck-options.html#maximum-interval", startingEase: "https://docs.ankiweb.net/deck-options.html#starting-ease", easyBonus: "https://docs.ankiweb.net/deck-options.html#easy-bonus", intervalModifier: "https://docs.ankiweb.net/deck-options.html#interval-modifier", hardInterval: "https://docs.ankiweb.net/deck-options.html#hard-interval", newInterval: "https://docs.ankiweb.net/deck-options.html#new-interval", advanced: "https://docs.ankiweb.net/deck-options.html#advanced", timer: "https://docs.ankiweb.net/deck-options.html#timers", autoAdvance: "https://docs.ankiweb.net/deck-options.html#auto-advance", learningSteps: "https://docs.ankiweb.net/deck-options.html#learning-steps", graduatingInterval: "https://docs.ankiweb.net/deck-options.html#graduating-interval", easyInterval: "https://docs.ankiweb.net/deck-options.html#easy-interval", insertionOrder: "https://docs.ankiweb.net/deck-options.html#insertion-order", newCards: "https://docs.ankiweb.net/deck-options.html#new-cards", relearningSteps: "https://docs.ankiweb.net/deck-options.html#relearning-steps", minimumInterval: "https://docs.ankiweb.net/deck-options.html#minimum-interval", lapses: "https://docs.ankiweb.net/deck-options.html#lapses", displayOrder: "https://docs.ankiweb.net/deck-options.html#display-order", maximumReviewsday: "https://docs.ankiweb.net/deck-options.html#maximum-reviewsday", newCardsday: "https://docs.ankiweb.net/deck-options.html#new-cardsday", limitsFromTop: "https://docs.ankiweb.net/deck-options.html#limits-start-from-top", dailyLimits: "https://docs.ankiweb.net/deck-options.html#daily-limits", audio: "https://docs.ankiweb.net/deck-options.html#audio", fsrs: "https://docs.ankiweb.net/deck-options.html#fsrs", desiredRetention: "https://docs.ankiweb.net/deck-options.html#desired-retention", }, Leeches: { leeches: "https://docs.ankiweb.net/leeches.html#leeches", waiting: "https://docs.ankiweb.net/leeches.html#waiting", }, Studying: { siblingsAndBurying: "https://docs.ankiweb.net/studying.html#siblings-and-burying", }, PackageImporting: { root: "https://docs.ankiweb.net/importing/packaged-decks.html", updating: "https://docs.ankiweb.net/importing/packaged-decks.html#updating", scheduling: "https://docs.ankiweb.net/importing/packaged-decks.html#scheduling", }, TextImporting: { root: "https://docs.ankiweb.net/importing/text-files.html", updating: "https://docs.ankiweb.net/importing/text-files.html#duplicates-and-updating", html: "https://docs.ankiweb.net/importing/text-files.html#html", }, }; ================================================ FILE: ts/lib/tslib/helpers.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { marked } from "marked"; export type Callback = () => void; export function removeItem(items: T[], item: T): void { const index = items.findIndex((i: T): boolean => i === item); if (index >= 0) { items.splice(index, 1); } } export function renderMarkdown(text: string): string { return marked(text, { mangle: false, headerIds: false }); } ================================================ FILE: ts/lib/tslib/i18n/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export { ModuleName } from "@generated/ftl"; // eslint-disable export * from "./utils"; ================================================ FILE: ts/lib/tslib/i18n/utils.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import "intl-pluralrules"; import { i18nResources } from "@generated/backend"; import type { ModuleName } from "@generated/ftl"; import { FluentBundle, FluentResource } from "@generated/ftl"; import { firstLanguage, setBundles } from "@generated/ftl"; export function supportsVerticalText(): boolean { const firstLang = firstLanguage(); return ( firstLang.startsWith("ja") || firstLang.startsWith("zh") || firstLang.startsWith("ko") ); } export function direction(): "ltr" | "rtl" { const firstLang = firstLanguage(); if ( firstLang.startsWith("ar") || firstLang.startsWith("he") || firstLang.startsWith("fa") ) { return "rtl"; } else { return "ltr"; } } export function weekdayLabel(n: number): string { const firstLang = firstLanguage(); const now = new Date(); const daysFromToday = -now.getDay() + n; const desiredDay = new Date(now.getTime() + daysFromToday * 86_400_000); return desiredDay.toLocaleDateString(firstLang, { weekday: "narrow", }); } let langs: string[] = []; export function localizedDate( date: Date, options?: Intl.DateTimeFormatOptions, ): string { return date.toLocaleDateString(langs, options); } export function localizedNumber(n: number, precision = 2): string { const round = Math.pow(10, precision); const rounded = Math.round(n * round) / round; return rounded.toLocaleString(langs); } export function createLocaleNumberFormat(options?: Intl.NumberFormatOptions): Intl.NumberFormat { return new Intl.NumberFormat(langs, options); } export function localeCompare( first: string, second: string, options?: Intl.CollatorOptions, ): number { return first.localeCompare(second, langs, options); } /** Treat text like HTML, merging multiple spaces and converting newlines to spaces. */ export function withCollapsedWhitespace(s: string): string { return s.replace(/\s+/g, " "); } export function withoutUnicodeIsolation(s: string): string { return s.replace(/[\u2068-\u2069]+/g, ""); } export async function setupI18n(args: { modules: ModuleName[] }): Promise { const resources = await i18nResources(args); const json = JSON.parse(new TextDecoder().decode(resources.json)); const newBundles: FluentBundle[] = []; for (const res in json.resources) { const text = json.resources[res]; const lang = json.langs[res]; const bundle = new FluentBundle([lang, "en-US"]); const resource = new FluentResource(text); bundle.addResource(resource); newBundles.push(bundle); } setBundles(newBundles); langs = json.langs; document.dir = direction(); } let globalI18n: Promise | undefined; export async function setupGlobalI18n(): Promise { if (!globalI18n) { globalI18n = setupI18n({ modules: [], }); } await globalI18n; } ================================================ FILE: ts/lib/tslib/image-import.d.ts ================================================ declare module "*.svg"; ================================================ FILE: ts/lib/tslib/keys.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import * as tr from "@generated/ftl"; import { isApplePlatform } from "./platform"; // those are the modifiers that Anki works with export type Modifier = "Control" | "Alt" | "Shift" | "Meta"; const allModifiers: Modifier[] = ["Control", "Alt", "Shift", "Meta"]; const platformModifiers: string[] = isApplePlatform() ? ["Meta", "Alt", "Shift", "Control"] : ["Control", "Alt", "Shift", "OS"]; function translateModifierToPlatform(modifier: Modifier): string { return platformModifiers[allModifiers.indexOf(modifier)]; } export function checkIfModifierKey(event: KeyboardEvent): boolean { // At least the web view on Desktop Anki gives out the wrong values for // `event.location`, which is why we do it like this. let isInputKey = false; for (const modifier of allModifiers) { isInputKey ||= event.code.startsWith(modifier); } return isInputKey; } export function keyboardEventIsPrintableKey(event: KeyboardEvent): boolean { return event.key.length === 1; } export const checkModifiers = (required: Modifier[], optional: Modifier[] = []) => (event: KeyboardEvent): boolean => { return allModifiers.reduce( ( matches: boolean, currentModifier: Modifier, currentIndex: number, ): boolean => matches && (optional.includes(currentModifier as Modifier) || event.getModifierState(platformModifiers[currentIndex]) === required.includes(currentModifier)), true, ); }; const modifierPressed = (modifier: Modifier) => (event: MouseEvent | KeyboardEvent): boolean => { const translated = translateModifierToPlatform(modifier); const state = event.getModifierState(translated); return event.type === "keyup" ? state && (event as KeyboardEvent).key !== translated : state; }; export const controlPressed = modifierPressed("Control"); export const shiftPressed = modifierPressed("Shift"); export const altPressed = modifierPressed("Alt"); export const metaPressed = modifierPressed("Meta"); export function modifiersToPlatformString(modifiers: string[]): string { const displayModifiers = isApplePlatform() ? ["^", "⌥", "⇧", "⌘"] : [`${tr.keyboardCtrl()}+`, "Alt+", `${tr.keyboardShift()}+`, "Win+"]; let result = ""; for (const modifier of modifiers) { result += displayModifiers[platformModifiers.indexOf(modifier)]; } return result; } export function keyToPlatformString(key: string): string { switch (key) { case "Backspace": return "⌫"; case "Delete": return "⌦"; case "Escape": return "⎋"; default: return key; } } export function isArrowLeft(event: KeyboardEvent): boolean { if (event.key === "ArrowLeft") { return true; } return isApplePlatform() && metaPressed(event) && event.code === "KeyB"; } export function isArrowRight(event: KeyboardEvent): boolean { if (event.key === "ArrowRight") { return true; } return isApplePlatform() && metaPressed(event) && event.code === "KeyF"; } export function isArrowUp(event: KeyboardEvent): boolean { if (event.key === "ArrowUp") { return true; } return isApplePlatform() && metaPressed(event) && event.code === "KeyP"; } export function isArrowDown(event: KeyboardEvent): boolean { if (event.key === "ArrowDown") { return true; } return isApplePlatform() && metaPressed(event) && event.code === "KeyN"; } export function onEnterOrSpace(callback: () => void): (event: KeyboardEvent) => void { return (event: KeyboardEvent) => { switch (event.code) { case "Enter": case "Space": callback(); break; } }; } ================================================ FILE: ts/lib/tslib/nightmode.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /** Add night-mode class to documentElement if hash location is #night, and return true if added. */ export function checkNightMode(): boolean { const nightMode = window.location.hash == "#night"; if (nightMode) { document.documentElement.className = "night-mode"; document.documentElement.dataset.bsTheme = "dark"; } return nightMode; } ================================================ FILE: ts/lib/tslib/node.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export function isOnlyChild(node: Node): boolean { return node.parentNode!.childNodes.length === 1; } export function hasOnlyChild(node: Node): boolean { return node.childNodes.length === 1; } export function ascend(node: Node): Node { return node.parentNode!; } ================================================ FILE: ts/lib/tslib/parsing.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /** * Parsing with or without this dummy structure changes the output * for both `DOMParser.parseAsString` and range.createContextualFragment`. * Parsing without means that comments or meaningless html elements are dropped, * which we want to avoid. */ export function createDummyDoc(html: string): string { return `${html}`; } ================================================ FILE: ts/lib/tslib/platform.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export function isApplePlatform(): boolean { // avoid deprecation warning const platform = window.navigator["platform" + ""]; return ( platform.startsWith("Mac") || platform.startsWith("iP") ); } export function isDesktop(): boolean { return !(/iphone|ipad|ipod|android/i.test(window.navigator.userAgent)); } export function chromiumVersion(): number | null { const userAgent = window.navigator.userAgent; // Check if it's a Chromium-based browser (Chrome, Edge, Opera, etc.) // but exclude Safari which also contains "Chrome" in its user agent if (userAgent.includes("Safari") && !userAgent.includes("Chrome")) { return null; // Safari } const chromeMatch = userAgent.match(/Chrome\/(\d+)/); if (chromeMatch) { return parseInt(chromeMatch[1], 10); } return null; // Not a Chromium-based browser } ================================================ FILE: ts/lib/tslib/progress.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { Progress } from "@generated/anki/collection_pb"; import { latestProgress } from "@generated/backend"; export async function runWithBackendProgress( callback: () => Promise, onUpdate: (progress: Progress) => void, ): Promise { let done = false; async function progressCallback() { const progress = await latestProgress({}); onUpdate(progress); if (done) { return; } setTimeout(progressCallback, 100); } setTimeout(progressCallback, 100); try { return await callback(); } finally { done = true; } } ================================================ FILE: ts/lib/tslib/promise.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export function promiseWithResolver(): [Promise, (value: T) => void] { let resolve: (object: T) => void; const promise = new Promise((res) => (resolve = res)); return [promise, resolve!]; } ================================================ FILE: ts/lib/tslib/runtime-require.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /** * Names of anki packages * * @privateRemarks * Originally this was more strictly typed as a record: * ```ts * type AnkiPackages = { * "anki/NoteEditor": NoteEditorPackage, * } * ``` * This would be very useful for `require`: the result could be strictly typed. * However cross-module type imports currently don't work. */ type AnkiPackages = | "anki/NoteEditor" | "anki/EditorField" | "anki/PlainTextInput" | "anki/RichTextInput" | "anki/TemplateButtons" | "anki/packages" | "anki/bridgecommand" | "anki/shortcuts" | "anki/theme" | "anki/location" | "anki/surround" | "anki/ui" | "anki/reviewer"; type PackageDeprecation> = { [key in keyof T]?: string; }; /** This can be extended to allow require() calls at runtime, for packages that are not included at bundling time. */ const runtimePackages: Partial>> = {}; const prohibit = () => false; /** * Packages registered with this function escape the typing provided by `AnkiPackages` */ export function registerPackageRaw( name: string, entries: Record, ): void { runtimePackages[name] = entries; } export function registerPackage< T extends AnkiPackages, U extends Record, >(name: T, entries: U, deprecation?: PackageDeprecation): void { const pack = deprecation ? new Proxy(entries, { set: prohibit, defineProperty: prohibit, deleteProperty: prohibit, get: (target, name: string) => { if (name in deprecation) { console.log(`anki: ${name} is deprecated: ${deprecation[name]}`); } return target[name]; }, }) : entries; registerPackageRaw(name, pack); } function require(name: T): Record | undefined { if (!(name in runtimePackages)) { throw new Error(`Cannot require "${name}" at runtime.`); } else { return runtimePackages[name]; } } function listPackages(): string[] { return Object.keys(runtimePackages); } function hasPackages(...names: string[]): boolean { for (const name of names) { if (!(name in runtimePackages)) { return false; } } return true; } // Export require() as a global. Object.assign(globalThis, { require }); registerPackage("anki/packages", { // We also register require here, so add-ons can have a type-save variant of require (TODO, see AnkiPackages above) require, listPackages, hasPackages, }); ================================================ FILE: ts/lib/tslib/shadow-dom.d.ts ================================================ export {}; declare global { interface DocumentOrShadowRoot { getSelection(): Selection | null; } interface Node { getRootNode(options?: GetRootNodeOptions): Document | ShadowRoot; } } ================================================ FILE: ts/lib/tslib/shortcuts.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { on } from "./events"; import type { Modifier } from "./keys"; import { checkIfModifierKey, checkModifiers, keyToPlatformString, modifiersToPlatformString } from "./keys"; import { registerPackage } from "./runtime-require"; const keyCodeLookup = { Backspace: 8, Delete: 46, Tab: 9, Enter: 13, F1: 112, F2: 113, F3: 114, F4: 115, F5: 116, F6: 117, F7: 118, F8: 119, F9: 120, F10: 121, F11: 122, F12: 123, "=": 187, "-": 189, "[": 219, "]": 221, "\\": 220, ";": 186, "'": 222, ",": 188, ".": 190, "/": 191, "`": 192, }; function isRequiredModifier(modifier: string): boolean { return !modifier.endsWith("?"); } function splitKeyCombinationString(keyCombinationString: string): string[][] { return keyCombinationString.split(", ").map((segment) => segment.split("+")); } function toPlatformString(keyCombination: string[]): string { return ( modifiersToPlatformString( keyCombination.slice(0, -1).filter(isRequiredModifier), ) + keyToPlatformString(keyCombination[keyCombination.length - 1]) ); } export function getPlatformString(keyCombinationString: string): string { return splitKeyCombinationString(keyCombinationString) .map(toPlatformString) .join(", "); } function checkKey(event: KeyboardEvent, key: number): boolean { // avoid deprecation warning const which = event["which" + ""]; return which === key; } function partition(predicate: (t: T) => boolean, items: T[]): [T[], T[]] { const trueItems: T[] = []; const falseItems: T[] = []; items.forEach((t) => { const target = predicate(t) ? trueItems : falseItems; target.push(t); }); return [trueItems, falseItems]; } function removeTrailing(modifier: string): string { return modifier.substring(0, modifier.length - 1); } function separateRequiredOptionalModifiers( modifiers: string[], ): [Modifier[], Modifier[]] { const [requiredModifiers, otherModifiers] = partition( isRequiredModifier, modifiers, ); const optionalModifiers = otherModifiers.map(removeTrailing); return [requiredModifiers as Modifier[], optionalModifiers as Modifier[]]; } const check = (keyCode: number, requiredModifiers: Modifier[], optionalModifiers: Modifier[]) => (event: KeyboardEvent): boolean => { return ( checkKey(event, keyCode) && checkModifiers(requiredModifiers, optionalModifiers)(event) ); }; function keyToCode(key: string): number { return keyCodeLookup[key] || key.toUpperCase().charCodeAt(0); } function keyCombinationToCheck( keyCombination: string[], ): (event: KeyboardEvent) => boolean { const keyCode = keyToCode(keyCombination[keyCombination.length - 1]); const [required, optional] = separateRequiredOptionalModifiers( keyCombination.slice(0, -1), ); return check(keyCode, required, optional); } function innerShortcut( target: EventTarget | Document, lastEvent: KeyboardEvent, callback: (event: KeyboardEvent) => void, ...checks: ((event: KeyboardEvent) => boolean)[] ): void { if (checks.length === 0) { return callback(lastEvent); } const [nextCheck, ...restChecks] = checks; const remove = on(document, "keydown", handler, { once: true }); function handler(event: KeyboardEvent): void { if (nextCheck(event)) { innerShortcut(target, event, callback, ...restChecks); } else if (!checkIfModifierKey(event)) { // Any non-modifier key will cancel the shortcut sequence remove(); } } } export interface RegisterShortcutRestParams { target: EventTarget; /** There might be no good reason to use `keyup` other than to circumvent Qt bugs */ event: "keydown" | "keyup"; } const defaultRegisterShortcutRestParams = { target: document, event: "keydown" as const, }; export function registerShortcut( callback: (event: KeyboardEvent) => void, keyCombinationString: string, restParams: Partial = defaultRegisterShortcutRestParams, ): () => void { const { target = defaultRegisterShortcutRestParams.target, event = defaultRegisterShortcutRestParams.event, } = restParams; const [check, ...restChecks] = splitKeyCombinationString(keyCombinationString).map(keyCombinationToCheck); function handler(event: KeyboardEvent): void { if (check(event)) { innerShortcut(target, event, callback, ...restChecks); } } return on(target, event, handler); } registerPackage("anki/shortcuts", { registerShortcut, getPlatformString, }); ================================================ FILE: ts/lib/tslib/styling.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /** * @returns True, if element has no style attribute (anymore). */ function removeEmptyStyle(element: HTMLElement | SVGElement): boolean { if (element.style.cssText.length === 0) { element.removeAttribute("style"); // Calling `.hasAttribute` right after `.removeAttribute` might return true. return true; } return false; } /** * Will remove the style attribute, if all properties were removed. * * @returns True, if element has no style attributes anymore */ export function removeStyleProperties( element: HTMLElement | SVGElement, ...props: string[] ): boolean { for (const prop of props) { element.style.removeProperty(prop); } return removeEmptyStyle(element); } ================================================ FILE: ts/lib/tslib/time.test.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { expect, test } from "vitest"; import { naturalUnit, naturalWholeUnit, TimespanUnit } from "./time"; test("natural unit", () => { expect(naturalUnit(5)).toBe(TimespanUnit.Seconds); expect(naturalUnit(59)).toBe(TimespanUnit.Seconds); expect(naturalUnit(60)).toBe(TimespanUnit.Minutes); expect(naturalUnit(60 * 60 - 1)).toBe(TimespanUnit.Minutes); expect(naturalUnit(60 * 60)).toBe(TimespanUnit.Hours); expect(naturalUnit(60 * 60 * 24)).toBe(TimespanUnit.Days); expect(naturalUnit(60 * 60 * 24 * 31)).toBe(TimespanUnit.Months); }); test("natural whole unit", () => { expect(naturalWholeUnit(5)).toBe(TimespanUnit.Seconds); expect(naturalWholeUnit(59)).toBe(TimespanUnit.Seconds); expect(naturalWholeUnit(60)).toBe(TimespanUnit.Minutes); expect(naturalWholeUnit(61)).toBe(TimespanUnit.Seconds); expect(naturalWholeUnit(90)).toBe(TimespanUnit.Seconds); expect(naturalWholeUnit(60 * 60 - 1)).toBe(TimespanUnit.Seconds); expect(naturalWholeUnit(60 * 60 + 1)).toBe(TimespanUnit.Seconds); expect(naturalWholeUnit(60 * 60)).toBe(TimespanUnit.Hours); expect(naturalWholeUnit(24 * 60 * 60 - 1)).toBe(TimespanUnit.Seconds); expect(naturalWholeUnit(24 * 60 * 60 + 1)).toBe(TimespanUnit.Seconds); expect(naturalWholeUnit(24 * 60 * 60)).toBe(TimespanUnit.Days); }); ================================================ FILE: ts/lib/tslib/time.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import * as tr from "@generated/ftl"; export const SECOND = 1.0; export const MINUTE = 60.0 * SECOND; export const HOUR = 60.0 * MINUTE; export const DAY = 24.0 * HOUR; export const YEAR = 365.0 * DAY; export const MONTH = YEAR / 12; export enum TimespanUnit { Seconds, Minutes, Hours, Days, Months, Years, } export function unitName(unit: TimespanUnit): string { switch (unit) { case TimespanUnit.Seconds: return "seconds"; case TimespanUnit.Minutes: return "minutes"; case TimespanUnit.Hours: return "hours"; case TimespanUnit.Days: return "days"; case TimespanUnit.Months: return "months"; case TimespanUnit.Years: return "years"; } } export function naturalUnit(secs: number): TimespanUnit { secs = Math.abs(secs); if (secs < MINUTE) { return TimespanUnit.Seconds; } else if (secs < HOUR) { return TimespanUnit.Minutes; } else if (secs < DAY) { return TimespanUnit.Hours; } else if (secs < MONTH) { return TimespanUnit.Days; } else if (secs < YEAR) { return TimespanUnit.Months; } else { return TimespanUnit.Years; } } /** Number of seconds in a given unit. */ export function unitSeconds(unit: TimespanUnit): number { switch (unit) { case TimespanUnit.Seconds: return SECOND; case TimespanUnit.Minutes: return MINUTE; case TimespanUnit.Hours: return HOUR; case TimespanUnit.Days: return DAY; case TimespanUnit.Months: return MONTH; case TimespanUnit.Years: return YEAR; } } export function unitAmount(unit: TimespanUnit, secs: number): number { return secs / unitSeconds(unit); } /** Largest unit provided seconds can be divided by without a remainder. */ export function naturalWholeUnit(secs: number): TimespanUnit { let unit = naturalUnit(secs); while (unit != TimespanUnit.Seconds) { const amount = Math.round(unitAmount(unit, secs)); if (Math.abs(secs - amount * unitSeconds(unit)) < Number.EPSILON) { return unit; } unit -= 1; } return unit; } export function studiedToday(cards: number, secs: number): string { const unit = Math.min(naturalUnit(secs), TimespanUnit.Minutes); const amount = unitAmount(unit, secs); const name = unitName(unit); let secsPer = 0; if (cards > 0) { secsPer = secs / cards; } return tr.statisticsStudiedToday({ unit: name, secsPerCard: secsPer, cards, amount, }); } function i18nFuncForUnit( unit: TimespanUnit, short: boolean, ): (_: { amount: number }) => string { if (short) { switch (unit) { case TimespanUnit.Seconds: return tr.statisticsElapsedTimeSeconds; case TimespanUnit.Minutes: return tr.statisticsElapsedTimeMinutes; case TimespanUnit.Hours: return tr.statisticsElapsedTimeHours; case TimespanUnit.Days: return tr.statisticsElapsedTimeDays; case TimespanUnit.Months: return tr.statisticsElapsedTimeMonths; case TimespanUnit.Years: return tr.statisticsElapsedTimeYears; } } else { switch (unit) { case TimespanUnit.Seconds: return tr.schedulingTimeSpanSeconds; case TimespanUnit.Minutes: return tr.schedulingTimeSpanMinutes; case TimespanUnit.Hours: return tr.schedulingTimeSpanHours; case TimespanUnit.Days: return tr.schedulingTimeSpanDays; case TimespanUnit.Months: return tr.schedulingTimeSpanMonths; case TimespanUnit.Years: return tr.schedulingTimeSpanYears; } } } /** Describe the given seconds using the largest appropriate unit. If precise is true, show to two decimal places, eg eg 70 seconds -> "1.17 minutes" If false, seconds and days are shown without decimals. */ export function timeSpan( seconds: number, short = false, precise = true, maxUnit: TimespanUnit = TimespanUnit.Years, ): string { const unit = Math.min(naturalUnit(seconds), maxUnit); let amount = unitAmount(unit, seconds); if (!precise && unit < TimespanUnit.Months) { amount = Math.round(amount); } return i18nFuncForUnit(unit, short)({ amount }); } export function dayLabel(daysStart: number, daysEnd: number): string { const larger = Math.max(Math.abs(daysStart), Math.abs(daysEnd)); const smaller = Math.min(Math.abs(daysStart), Math.abs(daysEnd)); if (larger - smaller <= 1) { // singular if (daysStart >= 0) { return tr.statisticsInDaysSingle({ days: daysStart }); } else { return tr.statisticsDaysAgoSingle({ days: -daysStart }); } } else { // range if (daysStart >= 0) { return tr.statisticsInDaysRange({ daysStart, daysEnd: daysEnd - 1, }); } else { return tr.statisticsDaysAgoRange({ daysStart: Math.abs(daysEnd - 1), daysEnd: -daysStart, }); } } } /** Helper for converting Unix timestamps to date strings. */ export class Timestamp { private date: Date; constructor(seconds: number) { this.date = new Date(seconds * 1000); } /** YYYY-MM-DD */ dateString(): string { const year = this.date.getFullYear(); const month = ("0" + (this.date.getMonth() + 1)).slice(-2); const date = ("0" + this.date.getDate()).slice(-2); return `${year}-${month}-${date}`; } /** HH:MM */ timeString(): string { const hours = ("0" + this.date.getHours()).slice(-2); const minutes = ("0" + this.date.getMinutes()).slice(-2); return `${hours}:${minutes}`; } } ================================================ FILE: ts/lib/tslib/typing.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export function assertUnreachable(x: never): never { throw new Error(`unreachable: ${x}`); } export type Callback = () => void; export type AsyncCallback = () => Promise; export function singleCallback(...callbacks: Callback[]): Callback { return () => { for (const cb of callbacks) { cb(); } }; } ================================================ FILE: ts/lib/tslib/ui.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { promiseWithResolver } from "./promise"; import { registerPackage } from "./runtime-require"; const [loaded, uiResolve] = promiseWithResolver(); registerPackage("anki/ui", { loaded, }); export { uiResolve }; ================================================ FILE: ts/lib/tslib/wrap.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { getRange, getSelection } from "./cross-browser"; function wrappedExceptForWhitespace(text: string, front: string, back: string): string { const normalizedText = text .replace(/ /g, " ") .replace(/ /g, " ") .replace(/\u00A0/g, " "); const match = normalizedText.match(/^(\s*)([^]*?)(\s*)$/)!; return match[1] + front + match[2] + back + match[3]; } function moveCursorInside(selection: Selection, postfix: string): void { const range = getRange(selection)!; range.setEnd(range.endContainer, range.endOffset - postfix.length); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); } export function wrapInternal( base: Element, front: string, back: string, plainText: boolean, ): void { const selection = getSelection(base)!; const range = getRange(selection); if (!range) { return; } const wasCollapsed = range.collapsed; const content = range.cloneContents(); const span = document.createElement("span"); span.appendChild(content); if (plainText) { const new_ = wrappedExceptForWhitespace(span.innerText, front, back); document.execCommand("inserttext", false, new_); } else { const new_ = wrappedExceptForWhitespace(span.innerHTML, front, back); document.execCommand("inserthtml", false, new_); } if ( wasCollapsed /* ugly solution: treat differently than other wraps */ && !front.includes( " const packages = ["noerrors", "mathtools"]; function packagesForLoading(packages: string[]): string[] { return packages.map((value: string): string => `[tex]/${value}`); } window.MathJax = { tex: { displayMath: [["\\[", "\\]"]], processEscapes: false, processEnvironments: false, processRefs: false, packages: { "[+]": packages, "[-]": ["textmacros"], }, }, loader: { load: packagesForLoading(packages), paths: { mathjax: "/_anki/js/vendor/mathjax", }, }, startup: { typeset: false, }, }; ================================================ FILE: ts/mathjax/mathjax-types.d.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ export {}; declare global { interface Window { // Mathjax does not provide a full type MathJax: { [name: string]: any }; } } ================================================ FILE: ts/page.html ================================================ ================================================ FILE: ts/reviewer/answering.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { JsonValue } from "@bufbuild/protobuf"; import type { SchedulingStatesWithContext } from "@generated/anki/frontend_pb"; import type { SchedulingContext } from "@generated/anki/scheduler_pb"; import { SchedulingStates } from "@generated/anki/scheduler_pb"; import { getSchedulingStatesWithContext, setSchedulingStates } from "@generated/backend"; interface CustomDataStates { again: Record; hard: Record; good: Record; easy: Record; } function unpackCustomData(states: SchedulingStates): CustomDataStates { const toObject = (s: string): Record => { try { return JSON.parse(s); } catch { return {}; } }; return { again: toObject(states.current!.customData!), hard: toObject(states.current!.customData!), good: toObject(states.current!.customData!), easy: toObject(states.current!.customData!), }; } function packCustomData( states: SchedulingStates, customData: CustomDataStates, ) { states.again!.customData = JSON.stringify(customData.again); states.hard!.customData = JSON.stringify(customData.hard); states.good!.customData = JSON.stringify(customData.good); states.easy!.customData = JSON.stringify(customData.easy); } type StateMutatorFn = (states: JsonValue, customData: CustomDataStates, ctx: SchedulingContext) => Promise; export async function mutateNextCardStates( key: string, transform: StateMutatorFn, ): Promise { const statesWithContext = await getSchedulingStatesWithContext({}); const updatedStates = await applyStateTransform(statesWithContext, transform); await setSchedulingStates({ key, states: updatedStates }); } /** Exported only for tests */ export async function applyStateTransform( states: SchedulingStatesWithContext, transform: StateMutatorFn, ): Promise { // convert to JSON, which is the format existing transforms expect const statesJson = states.states!.toJson({ emitDefaultValues: true }); // decode customData and put it into each state const customData = unpackCustomData(states.states!); // run the user function on the JSON await transform(statesJson, customData, states.context!); // convert the JSON back into proto form, and pack the custom data in const updatedStates = SchedulingStates.fromJson(statesJson); packCustomData(updatedStates, customData); return updatedStates; } ================================================ FILE: ts/reviewer/browser_selector.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export function addBrowserClasses() { const ua = navigator.userAgent.toLowerCase(); function addClass(className: string) { document.documentElement.classList.add(className); } function test(regex: RegExp): boolean { return regex.test(ua); } if (test(/ipad/)) { addClass("ipad"); } else if (test(/iphone/)) { addClass("iphone"); } else if (test(/android/)) { addClass("android"); } if (test(/ipad|iphone|ipod/)) { addClass("ios"); } if (test(/ipad|iphone|ipod|android/)) { addClass("mobile"); } else if (test(/linux/)) { addClass("linux"); } else if (test(/windows/)) { addClass("win"); } else if (test(/mac/)) { addClass("mac"); } if (test(/firefox\//)) { addClass("firefox"); } else if (test(/chrome\//)) { addClass("chrome"); } else if (test(/safari\//)) { addClass("safari"); } } ================================================ FILE: ts/reviewer/images.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { noop } from "lodash-es"; const template = document.createElement("template"); export function allImagesLoaded(): Promise { return Promise.all( Array.from(document.getElementsByTagName("img")).map(imageLoaded), ); } function imageLoaded(img: HTMLImageElement): Promise { return img.complete ? Promise.resolve() : new Promise((resolve) => { img.addEventListener("load", () => resolve()); img.addEventListener("error", () => resolve()); }); } function extractImageSrcs(fragment: DocumentFragment): string[] { const srcs = [...fragment.querySelectorAll("img[src]")].map( (img) => img.src, ); return srcs; } function createImage(src: string): HTMLImageElement { const img = new Image(); img.decoding = "async"; img.src = src; img.decode().catch(noop); return img; } export function preloadAnswerImages(html: string): void { template.innerHTML = html; extractImageSrcs(template.content).forEach(createImage); } /** Prevent flickering & layout shift on image load */ export function preloadImages(fragment: DocumentFragment): Promise[] { return extractImageSrcs(fragment).map(createImage).map(imageLoaded); } ================================================ FILE: ts/reviewer/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ export { default as $, default as jQuery } from "jquery/dist/jquery"; import { imageOcclusionAPI } from "../routes/image-occlusion/review"; import { mutateNextCardStates } from "./answering"; import { addBrowserClasses } from "./browser_selector"; globalThis.anki = globalThis.anki || {}; globalThis.anki.mutateNextCardStates = mutateNextCardStates; globalThis.anki.imageOcclusion = imageOcclusionAPI; globalThis.anki.setupImageCloze = imageOcclusionAPI.setup; // deprecated import { bridgeCommand } from "@tslib/bridgecommand"; import { registerPackage } from "@tslib/runtime-require"; import { allImagesLoaded, preloadAnswerImages } from "./images"; import { preloadResources } from "./preload"; declare const MathJax: any; type Callback = () => void | Promise; export const onUpdateHook: Array = []; export const onShownHook: Array = []; export const ankiPlatform = "desktop"; let typeans: HTMLInputElement | undefined; export function getTypedAnswer(): string | null { return typeans?.value ?? null; } function _runHook( hooks: Array, ): Promise>[]> { const promises: (Promise | void)[] = []; for (const hook of hooks) { try { const result = hook(); promises.push(result); } catch (error) { console.log("Hook failed: ", error); } } return Promise.allSettled(promises); } let _updatingQueue: Promise = Promise.resolve(); export function _queueAction(action: Callback): void { _updatingQueue = _updatingQueue.then(action); } // Setting innerHTML does not evaluate the contents of script tags, so we need // to add them again in order to trigger the download/evaluation. Promise resolves // when download/evaluation has completed. function replaceScript(oldScript: HTMLScriptElement): Promise { return new Promise((resolve) => { const newScript = document.createElement("script"); let mustWaitForNetwork = true; if (oldScript.src) { newScript.addEventListener("load", () => resolve()); newScript.addEventListener("error", () => resolve()); } else { mustWaitForNetwork = false; } for (const attribute of oldScript.attributes) { newScript.setAttribute(attribute.name, attribute.value); } newScript.appendChild(document.createTextNode(oldScript.innerHTML)); oldScript.replaceWith(newScript); if (!mustWaitForNetwork) { resolve(); } }); } async function setInnerHTML(element: Element, html: string): Promise { for (const oldVideo of element.getElementsByTagName("video")) { oldVideo.pause(); while (oldVideo.firstChild) { oldVideo.removeChild(oldVideo.firstChild); } oldVideo.load(); } element.innerHTML = html; for (const oldScript of element.getElementsByTagName("script")) { await replaceScript(oldScript); } } const renderError = (type: string) => (error: unknown): string => { const errorMessage = String(error).substring(0, 2000); let errorStack: string; if (error instanceof Error) { errorStack = String(error.stack).substring(0, 2000); } else { errorStack = ""; } return `
Invalid ${type} on card: ${errorMessage}\n${errorStack}
`.replace( /\n/g, "
", ); }; export async function _updateQA( html: string, _unusused: unknown, onupdate: Callback, onshown: Callback, ): Promise { onUpdateHook.length = 0; onUpdateHook.push(onupdate); onShownHook.length = 0; onShownHook.push(onshown); const qa = document.getElementById("qa")!; await preloadResources(html); qa.style.opacity = "0"; try { await setInnerHTML(qa, html); } catch (error) { await setInnerHTML(qa, renderError("html")(error)); } await _runHook(onUpdateHook); // dynamic toolbar background bridgeCommand("updateToolbar"); // wait for mathjax to ready await MathJax.startup.promise .then(() => { // clear MathJax buffers from previous typesets MathJax.typesetClear(); return MathJax.typesetPromise([qa]); }) .catch(renderError("MathJax")); qa.style.opacity = "1"; await _runHook(onShownHook); } export function _showQuestion(q: string, a: string, bodyclass: string): void { _queueAction(() => _updateQA( q, null, function() { // return to top of window window.scrollTo(0, 0); document.body.className = bodyclass; }, function() { // focus typing area if visible typeans = document.getElementById("typeans") as HTMLInputElement; if (typeans) { typeans.focus(); } // preload images allImagesLoaded().then(() => preloadAnswerImages(a)); }, ) ); } function scrollToAnswer(): void { document.getElementById("answer")?.scrollIntoView(); } export function _showAnswer(a: string, bodyclass: string): void { _queueAction(() => _updateQA( a, null, function() { if (bodyclass) { // when previewing document.body.className = bodyclass; } // avoid scrolling to the answer until images load allImagesLoaded().then(scrollToAnswer); }, function() { /* noop */ }, ) ); } export function _drawFlag(flag: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7): void { const elem = document.getElementById("_flag")!; elem.toggleAttribute("hidden", flag === 0); elem.style.color = `var(--flag-${flag})`; } export function _drawMark(mark: boolean): void { document.getElementById("_mark")!.toggleAttribute("hidden", !mark); } export function _typeAnsPress(): void { const key = (window.event as KeyboardEvent).key; if (key === "Enter") { bridgeCommand("ans"); } } export function _emulateMobile(enabled: boolean): void { document.documentElement.classList.toggle("mobile", enabled); } // Block Qt's default drag & drop behavior by default export function _blockDefaultDragDropBehavior(): void { function handler(evt: DragEvent) { evt.preventDefault(); } document.ondragenter = handler; document.ondragover = handler; document.ondrop = handler; } // work around WebEngine/IME bug in Qt6 // https://github.com/ankitects/anki/issues/1952 const dummyButton = document.createElement("button"); dummyButton.style.position = "absolute"; dummyButton.style.opacity = "0"; document.addEventListener("focusout", (event) => { // Prevent type box from losing focus when switching IMEs if (!document.hasFocus()) { return; } const target = event.target; if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { dummyButton.style.left = `${window.scrollX}px`; dummyButton.style.top = `${window.scrollY}px`; document.body.appendChild(dummyButton); dummyButton.focus(); document.body.removeChild(dummyButton); } }); addBrowserClasses(); registerPackage("anki/reviewer", { // If you append a function to this each time the question or answer // is shown, it will be called before MathJax has been rendered. onUpdateHook, // If you append a function to this each time the question or answer // is shown, it will be called after images have been preloaded and // MathJax has been rendered. onShownHook, }); ================================================ FILE: ts/reviewer/index_wrapper.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // extend the global namespace with our exports - not sure if there's a better way with esbuild import * as globals from "./index"; for (const key in globals) { window[key] = globals[key]; } ================================================ FILE: ts/reviewer/lib.test.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { SchedulingStatesWithContext } from "@generated/anki/frontend_pb"; import { SchedulingContext, SchedulingStates } from "@generated/anki/scheduler_pb"; import { expect, test } from "vitest"; import { applyStateTransform } from "./answering"; /* eslint @typescript-eslint/no-explicit-any: "off", */ function exampleInput(): SchedulingStatesWithContext { return SchedulingStatesWithContext.fromJson( { "states": { "current": { "normal": { "review": { "scheduledDays": 1, "elapsedDays": 2, "easeFactor": 1.850000023841858, "lapses": 4, "leeched": false, }, }, "customData": "{\"v\":\"v3.20.0\",\"seed\":2104,\"d\":5.39,\"s\":11.06}", }, "again": { "normal": { "relearning": { "review": { "scheduledDays": 1, "elapsedDays": 0, "easeFactor": 1.649999976158142, "lapses": 5, "leeched": false, }, "learning": { "remainingSteps": 1, "scheduledSecs": 600, }, }, }, }, "hard": { "normal": { "review": { "scheduledDays": 2, "elapsedDays": 0, "easeFactor": 1.7000000476837158, "lapses": 4, "leeched": false, }, }, }, "good": { "normal": { "review": { "scheduledDays": 4, "elapsedDays": 0, "easeFactor": 1.850000023841858, "lapses": 4, "leeched": false, }, }, }, "easy": { "normal": { "review": { "scheduledDays": 6, "elapsedDays": 0, "easeFactor": 2, "lapses": 4, "leeched": false, }, }, }, }, "context": { "deckName": "hello", "seed": 123 }, }, ); } test("can change oneof", () => { let states = exampleInput().states!; const jsonStates = states.toJson({ "emitDefaultValues": true }); // again should be a relearning state const inner = states.again?.kind?.value?.kind; assert(inner?.case === "relearning"); expect(inner.value.learning?.remainingSteps).toBe(1); // change it to a review state jsonStates.again.normal = { "review": jsonStates.again.normal.relearning.review }; states = SchedulingStates.fromJson(jsonStates); const inner2 = states.again?.kind?.value?.kind; assert(inner2?.case === "review"); // however, it's not valid to have multiple oneofs set jsonStates.again.normal = { "review": jsonStates.again.normal.review, "learning": {} }; expect(() => { SchedulingStates.fromJson(jsonStates); }).toThrow(); }); test("no-op transform", async () => { const input = exampleInput(); const output = await applyStateTransform(input, async (states: any, customData, ctx) => { expect(ctx.deckName).toBe("hello"); expect(customData.easy.seed).toBe(2104); expect(states!.again!.normal!.relearning!.learning!.remainingSteps).toBe(1); }); // the input only has customData set on `current`, so we need to update it // before we compare the two as equal input.states!.again!.customData = input.states!.current!.customData; input.states!.hard!.customData = input.states!.current!.customData; input.states!.good!.customData = input.states!.current!.customData; input.states!.easy!.customData = input.states!.current!.customData; expect(output).toStrictEqual(input.states); }); test("custom data change", async () => { const output = await applyStateTransform(exampleInput(), async (_states: any, customData, _ctx) => { customData.easy = { foo: "hello world" }; }); expect(output!.hard!.customData).not.toMatch(/hello world/); expect(output!.easy!.customData).toBe("{\"foo\":\"hello world\"}"); }); test("adjust interval", async () => { const output = await applyStateTransform(exampleInput(), async (states: any, _customData, _ctx) => { states.good.normal.review.scheduledDays = 10; }); const kind = output.good?.kind?.value?.kind; assert(kind?.case === "review"); expect(kind.value.scheduledDays).toBe(10); }); test("default context values exist", async () => { const ctx = SchedulingContext.fromBinary(new Uint8Array()); expect(ctx.deckName).toBe(""); expect(ctx.seed).toBe(0n); }); function assert(condition: boolean): asserts condition { if (!condition) { throw new Error(); } } ================================================ FILE: ts/reviewer/preload.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { preloadImages } from "./images"; const template = document.createElement("template"); const htmlDoc = document.implementation.createHTMLDocument(); const fontURLPattern = /url\s*\(\s*(?["']?)(?\S.*?)\k\s*\)/g; const cachedFonts = new Set(); type CSSElement = HTMLStyleElement | HTMLLinkElement; function loadResource(element: HTMLElement): Promise { return new Promise((resolve) => { function resolveAndRemove(): void { resolve(); document.head.removeChild(element); } element.addEventListener("load", resolveAndRemove); element.addEventListener("error", resolveAndRemove); document.head.appendChild(element); }); } function createPreloadLink(href: string, as: string): HTMLLinkElement { const link = document.createElement("link"); link.rel = "preload"; link.href = href; link.as = as; if (as === "font") { link.crossOrigin = ""; } return link; } function extractExternalStyleSheets(fragment: DocumentFragment): CSSElement[] { return [...fragment.querySelectorAll("style, link")] .filter((css) => (css instanceof HTMLStyleElement && css.innerHTML.includes("@import")) || (css instanceof HTMLLinkElement && css.rel === "stylesheet") ); } /** Prevent FOUC */ function preloadStyleSheets(fragment: DocumentFragment): Promise[] { const promises = extractExternalStyleSheets(fragment).map((css) => { // prevent the CSS from affecting the page rendering css.media = "print"; return loadResource(css); }); return promises; } function extractFontFaceRules(style: HTMLStyleElement): CSSFontFaceRule[] { htmlDoc.head.innerHTML = ""; // must be attached to an HTMLDocument to access 'sheet' property htmlDoc.head.appendChild(style); const fontFaceRules: CSSFontFaceRule[] = []; if (style.sheet) { for (const rule of style.sheet.cssRules) { if (rule instanceof CSSFontFaceRule) { fontFaceRules.push(rule); } } } return fontFaceRules; } function extractFontURLs(rule: CSSFontFaceRule): string[] { const src = rule.style.getPropertyValue("src"); const matches = src.matchAll(fontURLPattern); return [...matches].map((m) => (m.groups?.url ? m.groups.url : "")).filter(Boolean); } function preloadFonts(fragment: DocumentFragment): Promise[] { const styles = fragment.querySelectorAll("style"); const fonts: string[] = []; for (const style of styles) { for (const rule of extractFontFaceRules(style)) { fonts.push(...extractFontURLs(rule)); } } const newFonts = fonts.filter((font) => !cachedFonts.has(font)); newFonts.forEach((font) => cachedFonts.add(font)); const promises = newFonts.map((font) => { const link = createPreloadLink(font, "font"); return loadResource(link); }); return promises; } export async function preloadResources(html: string): Promise { template.innerHTML = html; const fragment = template.content; const styleSheets = preloadStyleSheets(fragment.cloneNode(true) as DocumentFragment); const images = preloadImages(fragment.cloneNode(true) as DocumentFragment); const fonts = preloadFonts(fragment.cloneNode(true) as DocumentFragment); let timeout: number; if (fonts.length) { timeout = 800; } else if (styleSheets.length) { timeout = 500; } else if (images.length) { timeout = 200; } else { return; } await Promise.race([ Promise.all([...styleSheets, ...images, ...fonts]), new Promise((r) => setTimeout(r, timeout)), ]); } ================================================ FILE: ts/reviewer/reviewer.scss ================================================ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ @use "../lib/sass/vars"; @use "../routes/image-occlusion/review"; hr { background-color: vars.palette(darkgray, 0); margin: 1em 0; border: none; height: 1px; } body { margin: 20px; overflow-wrap: break-word; // default background setting to fit with toolbar background-size: cover; background-repeat: no-repeat; background-position: top; background-attachment: fixed; } // explicit nightMode definition required // to override default .card styling body.nightMode { background-color: var(--canvas); color: var(--fg); } img { max-width: 100%; max-height: 95vh; } li { text-align: start; } pre { text-align: left; } #_flag { position: fixed; [dir="ltr"] & { right: 10px; } [dir="rtl"] & { left: 10px; } top: 0; font-size: 30px; -webkit-text-stroke-width: 1px; -webkit-text-stroke-color: black; } #_mark { position: fixed; [dir="ltr"] & { left: 10px; } [dir="rtl"] & { right: 10px; } top: 0; font-size: 30px; color: yellow; -webkit-text-stroke-width: 1px; -webkit-text-stroke-color: black; } #typeans { width: 100%; // https://anki.tenderapp.com/discussions/beta-testing/1854-using-margin-auto-causes-horizontal-scrollbar-on-typesomething box-sizing: border-box; line-height: 1.75; } code#typeans { white-space: pre-wrap; font-variant-ligatures: none; } .typeGood { background: #afa; color: black; } .typeBad { color: black; background: #faa; } .typeMissed { color: black; background: #ccc; } button { margin: 1em 0.5em; } .replay-button { text-decoration: none; display: inline-flex; vertical-align: middle; margin: 3px; svg { width: 40px; height: 40px; circle { fill: #fff; stroke: #414141; } path { fill: #414141; } } } .nightMode { .latex { filter: invert(100%); } } .drawing { zoom: 50%; } .nightMode img.drawing { filter: unquote("invert(1) hue-rotate(180deg)"); } ================================================ FILE: ts/reviewer/reviewer_extras.scss ================================================ @use "ts/routes/image-occlusion/review"; ================================================ FILE: ts/reviewer/reviewer_extras.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ // A standalone bundle that adds mutateNextCardStates and the image occlusion API // to the anki namespace. When all clients are using reviewer.js directly, we // can get rid of this. import { imageOcclusionAPI } from "$lib/../routes/image-occlusion/review"; import { mutateNextCardStates } from "./answering"; import { addBrowserClasses } from "./browser_selector"; globalThis.anki = globalThis.anki || {}; globalThis.anki.mutateNextCardStates = mutateNextCardStates; globalThis.anki.imageOcclusion = imageOcclusionAPI; globalThis.anki.setupImageCloze = imageOcclusionAPI.setup; // deprecated globalThis.anki.addBrowserClasses = addBrowserClasses; ================================================ FILE: ts/routes/+error.svelte ================================================ {message} ================================================ FILE: ts/routes/+layout.svelte ================================================ ================================================ FILE: ts/routes/+layout.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { setupGlobalI18n } from "@tslib/i18n"; import { checkNightMode } from "@tslib/nightmode"; import type { LayoutLoad } from "./$types"; export const ssr = false; export const prerender = false; export const load: LayoutLoad = async () => { checkNightMode(); await setupGlobalI18n(); }; ================================================ FILE: ts/routes/base.scss ================================================ @import "$lib/sass/base"; // override Bootstrap transition duration $carousel-transition: var(--transition); @import "bootstrap/scss/buttons"; @import "bootstrap/scss/button-group"; @import "bootstrap/scss/transitions"; @import "bootstrap/scss/modal"; @import "bootstrap/scss/carousel"; @import "bootstrap/scss/close"; @import "bootstrap/scss/alert"; @import "bootstrap/scss/badge"; @import "$lib/sass/bootstrap-forms"; @import "$lib/sass/bootstrap-tooltip"; input[type="text"], input[type="date"], textarea { padding-inline: 0.5rem; background: var(--canvas-inset); } input { color: var(--fg); } // Setting 100% height causes the sticky element to hide as you scroll down on Safari. html { height: initial; } [dir="rtl"] .modal-header .btn-close { padding: 1rem 1rem !important; margin: -1rem auto -1rem -1rem !important; } ================================================ FILE: ts/routes/card-info/CardInfo.svelte ================================================ {#if stats} {#if showRevlog} {/if} {#if fsrsEnabled && showCurve} {/if} {:else} {/if} ================================================ FILE: ts/routes/card-info/CardInfoPlaceholder.svelte ================================================
{tr.cardStatsNoCard()}
================================================ FILE: ts/routes/card-info/CardStats.svelte ================================================ {#each statsRows as row} {/each}
{row.label} {row.value}
================================================ FILE: ts/routes/card-info/ForgettingCurve.svelte ================================================
{#if maxDays > 7}
{#if maxDays > 30} {/if} {#if maxDays > 365} {/if}
{/if}
================================================ FILE: ts/routes/card-info/Revlog.svelte ================================================ {#if revlog.length > 0}
{tr2.cardStatsReviewLogDate()}
{#each revlogRows as row, _index}
{row.date}
{/each}
{tr2.cardStatsReviewLogRating()}
{#each revlogRows as row, _index}
{row.rating}
{/each}
{tr2.cardStatsInterval()}
{#each revlogRows as row, _index}
{row.interval}
{/each}
{tr2.cardStatsReviewLogTimeTaken()}
{#each revlogRows as row, _index}
{row.takenSecs}
{/each}
{/if} ================================================ FILE: ts/routes/card-info/[cardId]/+page.svelte ================================================ ================================================ FILE: ts/routes/card-info/[cardId]/+page.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { cardStats } from "@generated/backend"; import type { PageLoad } from "./$types"; function optionalBigInt(x: any): bigint | null { try { return BigInt(x); } catch (e) { return null; } } export const load = (async ({ params }) => { const cid = optionalBigInt(params.cardId); const info = cid !== null ? await cardStats({ cid }) : null; return { info }; }) satisfies PageLoad; ================================================ FILE: ts/routes/card-info/[cardId]/[previousId]/+page.svelte ================================================
{#if data.currentInfo}

Current

{/if} {#if data.previousInfo}

Previous

{/if}
================================================ FILE: ts/routes/card-info/[cardId]/[previousId]/+page.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { cardStats } from "@generated/backend"; import type { PageLoad } from "./$types"; function optionalBigInt(x: any): bigint | null { try { return BigInt(x); } catch (e) { return null; } } export const load = (async ({ params }) => { const currentId = optionalBigInt(params.cardId); const currentInfo = currentId !== null ? await cardStats({ cid: currentId }) : null; const previousId = optionalBigInt(params.previousId); const previousInfo = previousId !== null ? await cardStats({ cid: previousId }) : null; return { currentInfo, previousInfo }; }) satisfies PageLoad; ================================================ FILE: ts/routes/card-info/forgetting-curve.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { type CardStatsResponse_StatsRevlogEntry as RevlogEntry, RevlogEntry_ReviewKind, } from "@generated/anki/stats_pb"; import * as tr from "@generated/ftl"; import { timeSpan } from "@tslib/time"; import { axisBottom, axisLeft, line, max, min, pointer, scaleLinear, scaleTime, select } from "d3"; import { type GraphBounds, setDataAvailable } from "../graphs/graph-helpers"; import { hideTooltip, showTooltip } from "../graphs/tooltip-utils.svelte"; const MIN_POINTS = 1000; function forgettingCurve(stability: number, daysElapsed: number, decay: number): number { const factor = Math.pow(0.9, 1 / -decay) - 1; return Math.pow((daysElapsed / stability) * factor + 1.0, -decay); } interface DataPoint { date: Date; daysSinceFirstLearn: number; elapsedDaysSinceLastReview: number; retrievability: number; stability: number; } export enum TimeRange { Week, Month, Year, AllTime, } const MAX_DAYS = { [TimeRange.Week]: 7, [TimeRange.Month]: 30, [TimeRange.Year]: 365, [TimeRange.AllTime]: Infinity, }; function filterDataByTimeRange(data: DataPoint[], maxDays: number): DataPoint[] { return data.filter((point) => point.daysSinceFirstLearn <= maxDays); } export function filterRevlogEntryByReviewKind(entry: RevlogEntry): boolean { return ( entry.reviewKind !== RevlogEntry_ReviewKind.MANUAL && entry.reviewKind !== RevlogEntry_ReviewKind.RESCHEDULED && (entry.reviewKind !== RevlogEntry_ReviewKind.FILTERED || entry.ease !== 0) ); } export function filterRevlog(revlog: RevlogEntry[]): RevlogEntry[] { const result: RevlogEntry[] = []; for (const entry of revlog) { if ( (entry.reviewKind === RevlogEntry_ReviewKind.MANUAL && entry.ease === 0) || entry.memoryState === undefined ) { break; } result.push(entry); } return result.filter((entry) => filterRevlogEntryByReviewKind(entry)); } export function prepareData(revlog: RevlogEntry[], maxDays: number, decay: number) { const data: DataPoint[] = []; let lastReviewTime = 0; let lastStability = 0; const step = Math.min(maxDays / MIN_POINTS, 1); let daysSinceFirstLearn = 0; revlog .slice() .reverse() .forEach((entry, index) => { const reviewTime = Number(entry.time); if (index === 0) { lastReviewTime = reviewTime; lastStability = entry.memoryState?.stability || 0; data.push({ date: new Date(reviewTime * 1000), daysSinceFirstLearn: 0, elapsedDaysSinceLastReview: 0, retrievability: 100, stability: lastStability, }); return; } const totalDaysElapsed = (reviewTime - lastReviewTime) / 86400; let elapsedDays = 0; while (elapsedDays < totalDaysElapsed - step) { elapsedDays += step; const retrievability = forgettingCurve(lastStability, elapsedDays, decay); data.push({ date: new Date((lastReviewTime + elapsedDays * 86400) * 1000), daysSinceFirstLearn: data[data.length - 1].daysSinceFirstLearn + step, elapsedDaysSinceLastReview: elapsedDays, retrievability: retrievability * 100, stability: lastStability, }); } daysSinceFirstLearn += totalDaysElapsed; data.push({ date: new Date((lastReviewTime + totalDaysElapsed * 86400) * 1000), daysSinceFirstLearn: daysSinceFirstLearn, retrievability: 100, elapsedDaysSinceLastReview: 0, stability: lastStability, }); lastReviewTime = reviewTime; lastStability = entry.memoryState?.stability || 0; }); if (data.length === 0) { return []; } const now = Date.now() / 1000; const totalDaysSinceLastReview = (now - lastReviewTime) / 86400; let elapsedDays = 0; while (elapsedDays < totalDaysSinceLastReview - step) { elapsedDays += step; const retrievability = forgettingCurve(lastStability, elapsedDays, decay); data.push({ date: new Date((lastReviewTime + elapsedDays * 86400) * 1000), daysSinceFirstLearn: data[data.length - 1].daysSinceFirstLearn + step, elapsedDaysSinceLastReview: elapsedDays, retrievability: retrievability * 100, stability: lastStability, }); } daysSinceFirstLearn += totalDaysSinceLastReview; const retrievability = forgettingCurve(lastStability, totalDaysSinceLastReview, decay); data.push({ date: new Date(now * 1000), daysSinceFirstLearn: daysSinceFirstLearn, elapsedDaysSinceLastReview: totalDaysSinceLastReview, retrievability: retrievability * 100, stability: lastStability, }); const previewDays = maxDays - totalDaysSinceLastReview; let previewDaysElapsed = 0; while (previewDaysElapsed < previewDays) { previewDaysElapsed += step; const retrievability = forgettingCurve(lastStability, elapsedDays + previewDaysElapsed, decay); data.push({ date: new Date((now + previewDaysElapsed * 86400) * 1000), daysSinceFirstLearn: data[data.length - 1].daysSinceFirstLearn + step, elapsedDaysSinceLastReview: totalDaysSinceLastReview + previewDaysElapsed, retrievability: retrievability * 100, stability: lastStability, }); } const filteredData = filterDataByTimeRange(data, maxDays); return filteredData; } export function calculateMaxDays(filteredRevlog: RevlogEntry[], timeRange: TimeRange): number { if (filteredRevlog.length === 0) { return 0; } const today = new Date(); const daysSinceFirstLearn = (today.getTime() / 1000 - Number(filteredRevlog[filteredRevlog.length - 1].time)) / 86400; const totalDaysSinceLastReview = (today.getTime() / 1000 - Number(filteredRevlog[0].time)) / 86400; const lastScheduledDays = filteredRevlog[0].interval / 86400; const previewDays = Math.max(lastScheduledDays * 1.5 - totalDaysSinceLastReview, lastScheduledDays * 0.5); return Math.min(daysSinceFirstLearn + previewDays, MAX_DAYS[timeRange]); } export function renderForgettingCurve( filteredRevlog: RevlogEntry[], timeRange: TimeRange, svgElem: SVGElement, bounds: GraphBounds, desiredRetention: number, decay: number, ) { const svg = select(svgElem); const trans = svg.transition().duration(600) as any; if (filteredRevlog.length === 0) { setDataAvailable(svg, false); return; } const maxDays = calculateMaxDays(filteredRevlog, timeRange); const data = prepareData(filteredRevlog, maxDays, decay); if (data.length === 0) { setDataAvailable(svg, false); return; } else { setDataAvailable(svg, true); } svg.selectAll(".forgetting-curve-line").remove(); svg.select(".hover-columns").remove(); const xMin = min(data, d => d.date); const xMax = max(data, d => d.date); const x = scaleTime() .domain([xMin!, xMax!]) .range([bounds.marginLeft, bounds.width - bounds.marginRight]); const yMin = Math.max( 0, 100 - 1.2 * (100 - Math.min(...data.map((d) => d.retrievability))), ); const y = scaleLinear() .domain([yMin, 100]) .range([bounds.height - bounds.marginBottom, bounds.marginTop]); svg.select(".x-ticks") .call((selection) => selection.transition(trans).call(axisBottom(x).ticks(5).tickSizeOuter(0))) .attr("direction", "ltr"); svg.select(".y-ticks") .attr("transform", `translate(${bounds.marginLeft},0)`) .call((selection) => selection.transition(trans).call(axisLeft(y).tickSizeOuter(0))) .attr("direction", "ltr"); svg.select(".y-ticks .y-axis-title").remove(); svg.select(".y-ticks") .append("text") .attr("class", "y-axis-title") .attr("transform", "rotate(-90)") .attr("y", 0 - bounds.marginLeft) .attr("x", 0 - (bounds.height / 2)) .attr("font-size", "1rem") .attr("dy", "1.1em") .attr("fill", "currentColor"); const lineGenerator = line() .x((d) => x(d.date)) .y((d) => y(d.retrievability)); // gradient color const desiredRetentionY = desiredRetention * 100; svg.append("linearGradient") .attr("id", "line-gradient") .attr("gradientUnits", "userSpaceOnUse") .attr("x1", 0) .attr("y1", y(0)) .attr("x2", 0) .attr("y2", y(100)) .selectAll("stop") .data([ { offset: "0%", color: "tomato" }, { offset: `${desiredRetentionY}%`, color: "steelblue" }, { offset: "100%", color: "green" }, ]) .enter().append("stop") .attr("offset", d => d.offset) .attr("stop-color", d => d.color); // Split data into past and future const today = new Date(); const pastData = data.filter(d => d.date <= today); const futureData = data.filter(d => d.date >= today); // Draw solid line for past data svg.append("path") .datum(pastData) .attr("class", "forgetting-curve-line") .attr("fill", "none") .attr("stroke", "url(#line-gradient)") .attr("stroke-width", 1.5) .attr("d", lineGenerator); // Draw dashed line for future data svg.append("path") .datum(futureData) .attr("class", "forgetting-curve-line") .attr("fill", "none") .attr("stroke", "url(#line-gradient)") .attr("stroke-width", 1.5) .attr("stroke-dasharray", "4 4") .attr("d", lineGenerator); svg.select(".desired-retention-line").remove(); if (desiredRetentionY > yMin) { svg.append("line") .attr("class", "desired-retention-line") .attr("x1", bounds.marginLeft) .attr("x2", bounds.width - bounds.marginRight) .attr("y1", y(desiredRetentionY)) .attr("y2", y(desiredRetentionY)) .attr("stroke", "steelblue") .attr("stroke-dasharray", "4 4") .attr("stroke-width", 1.2); } const focusLine = svg.append("line") .attr("class", "focus-line") .attr("y1", bounds.marginTop) .attr("y2", bounds.height - bounds.marginBottom) .attr("stroke", "black") .attr("stroke-width", 1) .style("opacity", 0); function tooltipText(d: DataPoint): string { return `${maxDays >= 365 ? "Date" : "Date Time"}: ${ maxDays >= 365 ? d.date.toLocaleDateString() : d.date.toLocaleString() }
${tr.cardStatsReviewLogElapsedTime()}: ${ timeSpan(d.elapsedDaysSinceLastReview * 86400) }
${tr.cardStatsFsrsRetrievability()}: ${d.retrievability.toFixed(2)}%
${tr.cardStatsFsrsStability()}: ${ timeSpan(d.stability * 86400) }`; } // hover/tooltip svg.append("g") .attr("class", "hover-columns") .selectAll("rect") .data(data) .join("rect") .attr("x", d => x(d.date) - 1) .attr("y", bounds.marginTop) .attr("width", 2) .attr("height", bounds.height - bounds.marginTop - bounds.marginBottom) .attr("fill", "transparent") .on("mousemove", (event: MouseEvent, d: DataPoint) => { const [x1, y1] = pointer(event, document.body); const [_, y2] = pointer(event, svg.node()); const lineY = y(desiredRetentionY); focusLine.attr("x1", x(d.date) - 1).attr("x2", x(d.date) + 1).style( "opacity", 1, ); let text = tooltipText(d); const desiredRetentionPercent = desiredRetention * 100; if (y2 >= lineY - 10 && y2 <= lineY + 10) { text += `
${tr.cardStatsFsrsForgettingCurveDesiredRetention()}: ${ desiredRetentionPercent.toFixed(0) }%`; } showTooltip(text, x1, y1); }) .on("mouseout", () => { focusLine.style("opacity", 0); hideTooltip(); }); } ================================================ FILE: ts/routes/change-notetype/Alert.svelte ================================================
{#if unused.length > maxItems}
(collapsed = !collapsed)} on:keydown={onEnterOrSpace(() => (collapsed = !collapsed))} role="button" tabindex="0" aria-expanded={!collapsed} > {collapseMsg}
{/if} {unusedMsg} {#if collapsed}
{unused.slice(0, maxItems).join(", ")} {#if unused.length > maxItems} ... (+{unused.length - maxItems}) {/if}
{:else}
    {#each unused as entry}
  • {entry}
  • {/each}
{/if}
================================================ FILE: ts/routes/change-notetype/ChangeNotetypePage.svelte ================================================ {#if $info.templates} {:else}
{@html renderMarkdown(tr.changeNotetypeToFromCloze())}
{/if}
================================================ FILE: ts/routes/change-notetype/Mapper.svelte ================================================ {#each $info.mapForContext(ctx) as _, newIndex} {/each} ================================================ FILE: ts/routes/change-notetype/MapperRow.svelte ================================================
{#if window.getComputedStyle(document.body).direction == "rtl"} {:else} {/if}
================================================ FILE: ts/routes/deck-options/ConfigSelector.svelte ================================================
================================================ FILE: ts/routes/deck-options/DeckOptionsPage.svelte ================================================
{#if $addons.length} {/if}
================================================ FILE: ts/routes/deck-options/DisplayOrder.svelte ================================================ { modal = e.detail.modal; carousel = e.detail.carousel; }} /> openHelpModal( Object.keys(settings).indexOf("newGatherPriority"), )} > {settings.newGatherPriority.title} openHelpModal( Object.keys(settings).indexOf("newCardSortOrder"), )} > {settings.newCardSortOrder.title} openHelpModal( Object.keys(settings).indexOf("newReviewPriority"), )} > {settings.newReviewPriority.title} openHelpModal( Object.keys(settings).indexOf("interdayStepPriority"), )} > {settings.interdayStepPriority.title} openHelpModal(Object.keys(settings).indexOf("reviewSortOrder"))} > {settings.reviewSortOrder.title} ================================================ FILE: ts/routes/deck-options/EasyDays.svelte ================================================ ================================================ FILE: ts/routes/deck-options/EasyDaysInput.svelte ================================================
{tr.deckConfigEasyDaysMinimum()} {tr.deckConfigEasyDaysReduced()} {tr.deckConfigEasyDaysNormal()} {#each easyDays as day, index} {day}
{/each}
================================================ FILE: ts/routes/deck-options/FsrsOptions.svelte ================================================ openHelpModal("desiredRetention")}> {tr.deckConfigDesiredRetention()}
openHelpModal("modelParams")}> {tr.deckConfigWeights()} openHelpModal("rescheduleCardsOnChange")}> {#if $fsrsReschedule} {/if} openHelpModal("healthCheck")}> {#if state.legacyEvaluate} {/if}
{#if computingParams || checkingParams} {computeParamsProgressString} {:else if totalReviews !== undefined} {tr.statisticsReviews({ reviews: totalReviews })} {/if}

================================================ FILE: ts/routes/deck-options/FsrsOptionsOuter.svelte ================================================ { modal = e.detail.modal; carousel = e.detail.carousel; }} /> openHelpModal(Object.keys(settings).indexOf("fsrs"))} > {#if $fsrs} openHelpModal(Object.keys(settings).indexOf(key))} {onPresetChange} /> {/if} ================================================ FILE: ts/routes/deck-options/GlobalLabel.svelte ================================================ {title}
================================================ FILE: ts/routes/deck-options/HtmlAddon.svelte ================================================ {@html html} ================================================ FILE: ts/routes/deck-options/LapseOptions.svelte ================================================ { modal = e.detail.modal; carousel = e.detail.carousel; }} /> openHelpModal(Object.keys(settings).indexOf("relearningSteps"))} > {settings.relearningSteps.title} {#if !$fsrs} openHelpModal( Object.keys(settings).indexOf("minimumInterval"), )} > {settings.minimumInterval.title} {/if} openHelpModal(Object.keys(settings).indexOf("leechThreshold"))} > {settings.leechThreshold.title} openHelpModal(Object.keys(settings).indexOf("leechAction"))} > {settings.leechAction.title} ================================================ FILE: ts/routes/deck-options/NewOptions.svelte ================================================ { modal = e.detail.modal; carousel = e.detail.carousel; }} /> openHelpModal(Object.keys(settings).indexOf("learningSteps"))} > {settings.learningSteps.title} {#if !$fsrs} openHelpModal( Object.keys(settings).indexOf("graduatingInterval"), )} > {settings.graduatingInterval.title} openHelpModal( Object.keys(settings).indexOf("easyInterval"), )} > {settings.easyInterval.title} {/if} openHelpModal(Object.keys(settings).indexOf("insertionOrder"))} > {settings.insertionOrder.title} ================================================ FILE: ts/routes/deck-options/ParamsInput.svelte ================================================
================================================ FILE: ts/routes/deck-options/ParamsInputRow.svelte ================================================ ================================================ FILE: ts/routes/deck-options/ParamsSearchRow.svelte ================================================ ================================================ FILE: ts/routes/deck-options/SaveButton.svelte ================================================
save(UpdateDeckConfigsMode.NORMAL)} tooltip={getPlatformString(saveKeyCombination)} --border-left-radius={!rtl ? "var(--border-radius)" : "0"} --border-right-radius={rtl ? "var(--border-radius)" : "0"} >
{tr.deckConfigSaveButton()}
save(UpdateDeckConfigsMode.NORMAL)} /> (showFloating = false)} > (showFloating = !showFloating)} --border-right-radius={!rtl ? "var(--border-radius)" : "0"} --border-left-radius={rtl ? "var(--border-radius)" : "0"} iconSize={80} > dispatch("add")}> {tr.deckConfigAddGroup()} dispatch("clone")}> {tr.deckConfigCloneGroup()} dispatch("rename")}> {tr.deckConfigRenameGroup()} {tr.deckConfigRemoveGroup()} save(UpdateDeckConfigsMode.APPLY_TO_CHILDREN)} > {tr.deckConfigSaveToAllSubdecks()}
================================================ FILE: ts/routes/deck-options/SimulatorModal.svelte ================================================ ================================================ FILE: ts/routes/deck-options/SpinBoxFloatRow.svelte ================================================ ================================================ FILE: ts/routes/deck-options/SpinBoxRow.svelte ================================================ ================================================ FILE: ts/routes/deck-options/StepsInput.svelte ================================================ ================================================ FILE: ts/routes/deck-options/StepsInputRow.svelte ================================================ ================================================ FILE: ts/routes/deck-options/TabbedValue.svelte ================================================
    {#each tabs as tab, idx}
  • {/each}
================================================ FILE: ts/routes/deck-options/TextInputModal.svelte ================================================ ================================================ FILE: ts/routes/deck-options/TimerOptions.svelte ================================================ { modal = e.detail.modal; carousel = e.detail.carousel; }} /> openHelpModal( Object.keys(settings).indexOf("maximumAnswerSecs"), )} > {settings.maximumAnswerSecs.title}
openHelpModal( Object.keys(settings).indexOf("showAnswerTimer"), )} > {settings.showAnswerTimer.title}
openHelpModal( Object.keys(settings).indexOf("stopTimerOnAnswer"), )} > {settings.stopTimerOnAnswer.title}
================================================ FILE: ts/routes/deck-options/Warning.svelte ================================================ {#if warning}
{withoutUnicodeIsolation(warning)}
{/if} ================================================ FILE: ts/routes/deck-options/[deckId]/+page.svelte ================================================ ================================================ FILE: ts/routes/deck-options/[deckId]/+page.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { getDeckConfigsForUpdate } from "@generated/backend"; import { DeckOptionsState } from "../lib"; import type { PageLoad } from "./$types"; export const load = (async ({ params }) => { const deckId = Number(params.deckId); const did = BigInt(deckId); const info = await getDeckConfigsForUpdate({ did }); const state = new DeckOptionsState(BigInt(did), info); return { state }; }) satisfies PageLoad; ================================================ FILE: ts/routes/deck-options/choices.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { DeckConfig_Config_AnswerAction, DeckConfig_Config_LeechAction, DeckConfig_Config_NewCardGatherPriority, DeckConfig_Config_NewCardInsertOrder, DeckConfig_Config_NewCardSortOrder, DeckConfig_Config_QuestionAction, DeckConfig_Config_ReviewCardOrder, DeckConfig_Config_ReviewMix, } from "@generated/anki/deck_config_pb"; import * as tr from "@generated/ftl"; import type { Choice } from "$lib/components/EnumSelector.svelte"; export function newGatherPriorityChoices(): Choice[] { return [ { label: tr.deckConfigNewGatherPriorityDeck(), value: DeckConfig_Config_NewCardGatherPriority.DECK, }, { label: tr.deckConfigNewGatherPriorityDeckThenRandomNotes(), value: DeckConfig_Config_NewCardGatherPriority.DECK_THEN_RANDOM_NOTES, }, { label: tr.deckConfigNewGatherPriorityPositionLowestFirst(), value: DeckConfig_Config_NewCardGatherPriority.LOWEST_POSITION, }, { label: tr.deckConfigNewGatherPriorityPositionHighestFirst(), value: DeckConfig_Config_NewCardGatherPriority.HIGHEST_POSITION, }, { label: tr.deckConfigNewGatherPriorityRandomNotes(), value: DeckConfig_Config_NewCardGatherPriority.RANDOM_NOTES, }, { label: tr.deckConfigNewGatherPriorityRandomCards(), value: DeckConfig_Config_NewCardGatherPriority.RANDOM_CARDS, }, ]; } export function newSortOrderChoices(): Choice[] { return [ { label: tr.deckConfigSortOrderTemplateThenGather(), value: DeckConfig_Config_NewCardSortOrder.TEMPLATE, }, { label: tr.deckConfigSortOrderGather(), value: DeckConfig_Config_NewCardSortOrder.NO_SORT, }, { label: tr.deckConfigSortOrderCardTemplateThenRandom(), value: DeckConfig_Config_NewCardSortOrder.TEMPLATE_THEN_RANDOM, }, { label: tr.deckConfigSortOrderRandomNoteThenTemplate(), value: DeckConfig_Config_NewCardSortOrder.RANDOM_NOTE_THEN_TEMPLATE, }, { label: tr.deckConfigSortOrderRandom(), value: DeckConfig_Config_NewCardSortOrder.RANDOM_CARD, }, ]; } export function reviewOrderChoices( fsrs: boolean, ): Choice[] { return [ ...[ { label: tr.deckConfigSortOrderDueDateThenRandom(), value: DeckConfig_Config_ReviewCardOrder.DAY, }, { label: tr.deckConfigSortOrderDueDateThenDeck(), value: DeckConfig_Config_ReviewCardOrder.DAY_THEN_DECK, }, { label: tr.deckConfigSortOrderDeckThenDueDate(), value: DeckConfig_Config_ReviewCardOrder.DECK_THEN_DAY, }, { label: tr.deckConfigSortOrderAscendingIntervals(), value: DeckConfig_Config_ReviewCardOrder.INTERVALS_ASCENDING, }, { label: tr.deckConfigSortOrderDescendingIntervals(), value: DeckConfig_Config_ReviewCardOrder.INTERVALS_DESCENDING, }, ], ...difficultyOrders(fsrs), ...retrievabilityOrders(fsrs), ...[ { label: tr.decksRelativeOverdueness(), value: DeckConfig_Config_ReviewCardOrder.RELATIVE_OVERDUENESS, }, { label: tr.deckConfigSortOrderRandom(), value: DeckConfig_Config_ReviewCardOrder.RANDOM, }, { label: tr.decksOrderAdded(), value: DeckConfig_Config_ReviewCardOrder.ADDED, }, { label: tr.decksLatestAddedFirst(), value: DeckConfig_Config_ReviewCardOrder.REVERSE_ADDED, }, ], ]; } export function reviewMixChoices(): Choice[] { return [ { label: tr.deckConfigReviewMixMixWithReviews(), value: DeckConfig_Config_ReviewMix.MIX_WITH_REVIEWS, }, { label: tr.deckConfigReviewMixShowAfterReviews(), value: DeckConfig_Config_ReviewMix.AFTER_REVIEWS, }, { label: tr.deckConfigReviewMixShowBeforeReviews(), value: DeckConfig_Config_ReviewMix.BEFORE_REVIEWS, }, ]; } export function leechChoices(): Choice[] { return [ { label: tr.actionsSuspendCard(), value: DeckConfig_Config_LeechAction.SUSPEND, }, { label: tr.schedulingTagOnly(), value: DeckConfig_Config_LeechAction.TAG_ONLY, }, ]; } export function newInsertOrderChoices(): Choice[] { return [ { label: tr.deckConfigNewInsertionOrderSequential(), value: DeckConfig_Config_NewCardInsertOrder.DUE, }, { label: tr.deckConfigNewInsertionOrderRandom(), value: DeckConfig_Config_NewCardInsertOrder.RANDOM, }, ]; } export function answerChoices(): Choice[] { return [ { label: tr.studyingBuryCard(), value: DeckConfig_Config_AnswerAction.BURY_CARD, }, { label: tr.deckConfigAnswerAgain(), value: DeckConfig_Config_AnswerAction.ANSWER_AGAIN, }, { label: tr.deckConfigAnswerGood(), value: DeckConfig_Config_AnswerAction.ANSWER_GOOD, }, { label: tr.deckConfigAnswerHard(), value: DeckConfig_Config_AnswerAction.ANSWER_HARD, }, { label: tr.deckConfigShowReminder(), value: DeckConfig_Config_AnswerAction.SHOW_REMINDER, }, ]; } export function questionActionChoices(): Choice[] { return [ { label: tr.deckConfigQuestionActionShowAnswer(), value: DeckConfig_Config_QuestionAction.SHOW_ANSWER, }, { label: tr.deckConfigQuestionActionShowReminder(), value: DeckConfig_Config_QuestionAction.SHOW_REMINDER, }, ]; } function difficultyOrders(fsrs: boolean): Choice[] { const order = [ { label: fsrs ? tr.deckConfigSortOrderDescendingDifficulty() : tr.deckConfigSortOrderAscendingEase(), value: DeckConfig_Config_ReviewCardOrder.EASE_ASCENDING, }, { label: fsrs ? tr.deckConfigSortOrderAscendingDifficulty() : tr.deckConfigSortOrderDescendingEase(), value: DeckConfig_Config_ReviewCardOrder.EASE_DESCENDING, }, ]; if (fsrs) { order.reverse(); } return order; } function retrievabilityOrders( fsrs: boolean, ): Choice[] { if (!fsrs) { return []; } return [ { label: tr.deckConfigSortOrderRetrievabilityAscending(), value: DeckConfig_Config_ReviewCardOrder.RETRIEVABILITY_ASCENDING, }, { label: tr.deckConfigSortOrderRetrievabilityDescending(), value: DeckConfig_Config_ReviewCardOrder.RETRIEVABILITY_DESCENDING, }, ]; } ================================================ FILE: ts/routes/deck-options/deck-options-base.scss ================================================ @import "$lib/sass/base"; // override Bootstrap transition duration $carousel-transition: var(--transition); @import "bootstrap/scss/buttons"; @import "bootstrap/scss/button-group"; @import "bootstrap/scss/transitions"; @import "bootstrap/scss/modal"; @import "bootstrap/scss/carousel"; @import "bootstrap/scss/close"; @import "bootstrap/scss/alert"; @import "bootstrap/scss/badge"; @import "$lib/sass/bootstrap-forms"; @import "$lib/sass/bootstrap-tooltip"; input[type="text"], input[type="date"], textarea { padding-inline: 0.5rem; background: var(--canvas-inset); } input { color: var(--fg); } // Setting 100% height causes the sticky element to hide as you scroll down on Safari. html { height: initial; } [dir="rtl"] .modal-header .btn-close { padding: 1rem 1rem !important; margin: -1rem auto -1rem -1rem !important; } ================================================ FILE: ts/routes/deck-options/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import "$lib/sveltelib/export-runtime"; import "./deck-options-base.scss"; import { getDeckConfigsForUpdate } from "@generated/backend"; import { ModuleName, setupI18n } from "@tslib/i18n"; import { checkNightMode } from "@tslib/nightmode"; import { modalsKey, touchDeviceKey } from "$lib/components/context-keys"; import EnumSelectorRow from "$lib/components/EnumSelectorRow.svelte"; import SwitchRow from "$lib/components/SwitchRow.svelte"; import TitledContainer from "$lib/components/TitledContainer.svelte"; import DeckOptionsPage from "./DeckOptionsPage.svelte"; import { DeckOptionsState } from "./lib"; import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte"; import SpinBoxRow from "./SpinBoxRow.svelte"; const i18n = setupI18n({ modules: [ ModuleName.HELP, ModuleName.SCHEDULING, ModuleName.ACTIONS, ModuleName.DECK_CONFIG, ModuleName.KEYBOARD, ModuleName.STUDYING, ModuleName.DECKS, ], }); export async function setupDeckOptions(did_: number): Promise { const did = BigInt(did_); const [info] = await Promise.all([getDeckConfigsForUpdate({ did }), i18n]); checkNightMode(); const context = new Map(); context.set(modalsKey, new Map()); context.set(touchDeviceKey, "ontouchstart" in document.documentElement); const state = new DeckOptionsState(BigInt(did), info); return new DeckOptionsPage({ target: document.body, props: { state }, context, }); } export const components = { TitledContainer, SpinBoxRow, SpinBoxFloatRow, EnumSelectorRow, SwitchRow, }; // if (window.location.hash.startsWith("#test")) { // setupDeckOptions(1); // } ================================================ FILE: ts/routes/deck-options/lib.test.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import { protoBase64 } from "@bufbuild/protobuf"; import { DeckConfig_Config_LeechAction, DeckConfigsForUpdate, UpdateDeckConfigsMode, } from "@generated/anki/deck_config_pb"; import { get } from "svelte/store"; import { expect, test } from "vitest"; import { DeckOptionsState } from "./lib"; const exampleData = { allConfig: [ { config: { id: 1n, name: "Default", mtimeSecs: 1618570764n, usn: -1, config: { learnSteps: [1, 10], relearnSteps: [10], newPerDay: 10, reviewsPerDay: 200, initialEase: 2.5, easyMultiplier: 1.2999999523162842, hardMultiplier: 1.2000000476837158, intervalMultiplier: 1, maximumReviewInterval: 36500, minimumLapseInterval: 1, graduatingIntervalGood: 1, graduatingIntervalEasy: 4, leechAction: DeckConfig_Config_LeechAction.TAG_ONLY, leechThreshold: 8, capAnswerTimeToSecs: 60, other: protoBase64.dec( "eyJuZXciOnsic2VwYXJhdGUiOnRydWV9LCJyZXYiOnsiZnV6eiI6MC4wNSwibWluU3BhY2UiOjF9fQ==", ), }, }, useCount: 1, }, { config: { id: 1618570764780n, name: "another one", mtimeSecs: 1618570781n, usn: -1, config: { learnSteps: [1, 10, 20, 30], relearnSteps: [10], newPerDay: 40, reviewsPerDay: 200, initialEase: 2.5, easyMultiplier: 1.2999999523162842, hardMultiplier: 1.2000000476837158, intervalMultiplier: 1, maximumReviewInterval: 36500, minimumLapseInterval: 1, graduatingIntervalGood: 1, graduatingIntervalEasy: 4, leechAction: DeckConfig_Config_LeechAction.TAG_ONLY, leechThreshold: 8, capAnswerTimeToSecs: 60, }, }, useCount: 1, }, ], currentDeck: { name: "Default::child", configId: 1618570764780n, }, defaults: { config: { learnSteps: [1, 10], relearnSteps: [10], newPerDay: 20, reviewsPerDay: 200, initialEase: 2.5, easyMultiplier: 1.2999999523162842, hardMultiplier: 1.2000000476837158, intervalMultiplier: 1, maximumReviewInterval: 36500, minimumLapseInterval: 1, graduatingIntervalGood: 1, graduatingIntervalEasy: 4, leechAction: DeckConfig_Config_LeechAction.TAG_ONLY, leechThreshold: 8, capAnswerTimeToSecs: 60, }, }, }; function startingState(): DeckOptionsState { return new DeckOptionsState( 123n, new DeckConfigsForUpdate(exampleData), ); } test("start", () => { const state = startingState(); expect(state.currentDeck.name).toBe("Default::child"); }); test("deck list", () => { const state = startingState(); expect(get(state.configList)).toStrictEqual([ { current: true, idx: 0, name: "another one", useCount: 1, }, { current: false, idx: 1, name: "Default", useCount: 1, }, ]); expect(get(state.currentConfig).newPerDay).toBe(40); // rename state.setCurrentName("zzz"); expect(get(state.configList)).toStrictEqual([ { current: false, idx: 0, name: "Default", useCount: 1, }, { current: true, idx: 1, name: "zzz", useCount: 1, }, ]); // add state.addConfig("hello"); expect(get(state.configList)).toStrictEqual([ { current: false, idx: 0, name: "Default", useCount: 1, }, { current: true, idx: 1, name: "hello", useCount: 1, }, { current: false, idx: 2, name: "zzz", useCount: 0, }, ]); expect(get(state.currentConfig).newPerDay).toBe(20); // change current state.setCurrentIndex(0); expect(get(state.configList)).toStrictEqual([ { current: true, idx: 0, name: "Default", useCount: 2, }, { current: false, idx: 1, name: "hello", useCount: 0, }, { current: false, idx: 2, name: "zzz", useCount: 0, }, ]); expect(get(state.currentConfig).newPerDay).toBe(10); // can't delete default expect(() => state.removeCurrentConfig()).toThrow(); // deleting old deck should work state.setCurrentIndex(1); state.removeCurrentConfig(); expect(get(state.currentConfig).newPerDay).toBe(10); // as should newly added one state.setCurrentIndex(1); state.removeCurrentConfig(); expect(get(state.currentConfig).newPerDay).toBe(10); // only the pre-existing deck should be listed for removal const out = state.dataForSaving(UpdateDeckConfigsMode.NORMAL); expect(out.removedConfigIds).toStrictEqual([1618570764780n]); }); test("duplicate name", () => { const state = startingState(); // duplicate will get renamed state.addConfig("another one"); expect(get(state.configList).find((e) => e.current)?.name).toMatch(/another.*\d+$/); // should handle renames too state.setCurrentName("Default"); expect(get(state.configList).find((e) => e.current)?.name).toMatch(/Default\d+$/); }); test("saving", () => { let state = startingState(); let out = state.dataForSaving(UpdateDeckConfigsMode.NORMAL); expect(out.removedConfigIds).toStrictEqual([]); expect(out.targetDeckId).toBe(123n); // in no-changes case, currently selected config should // be returned expect(out.configs!.length).toBe(1); expect(out.configs![0].name).toBe("another one"); expect(out.mode).toBe(UpdateDeckConfigsMode.NORMAL); // rename, then change current deck state.setCurrentName("zzz"); out = state.dataForSaving(UpdateDeckConfigsMode.APPLY_TO_CHILDREN); state.setCurrentIndex(0); // renamed deck should be in changes, with current deck as last element out = state.dataForSaving(UpdateDeckConfigsMode.APPLY_TO_CHILDREN); expect(out.configs!.map((c) => c.name)).toStrictEqual(["zzz", "Default"]); expect(out.mode).toBe(UpdateDeckConfigsMode.APPLY_TO_CHILDREN); // start again, adding new deck state = startingState(); state.addConfig("hello"); // deleting it should not change removedConfigs state.removeCurrentConfig(); out = state.dataForSaving(UpdateDeckConfigsMode.APPLY_TO_CHILDREN); expect(out.removedConfigIds).toStrictEqual([]); // select the other non-default deck & remove state.setCurrentIndex(0); state.removeCurrentConfig(); // should be listed in removedConfigs, and modified should // only contain Default, which is the new current deck out = state.dataForSaving(UpdateDeckConfigsMode.APPLY_TO_CHILDREN); expect(out.removedConfigIds).toStrictEqual([1618570764780n]); expect(out.configs!.map((c) => c.name)).toStrictEqual(["Default"]); }); test("aux data", () => { const state = startingState(); expect(get(state.currentAuxData)).toStrictEqual({}); state.currentAuxData.update((val) => { return { ...val, hello: "world" }; }); // check default state.setCurrentIndex(1); expect(get(state.currentAuxData)).toStrictEqual({ new: { separate: true, }, rev: { fuzz: 0.05, minSpace: 1, }, }); state.currentAuxData.update((val) => { return { ...val, defaultAddition: true }; }); // ensure changes serialize const out = state.dataForSaving(UpdateDeckConfigsMode.APPLY_TO_CHILDREN); expect(out.configs!.length).toBe(2); const json = out.configs!.map((c) => JSON.parse(new TextDecoder().decode(c.config!.other))); expect(json).toStrictEqual([ // other deck comes first { hello: "world", }, // default is selected, so comes last { defaultAddition: true, new: { separate: true, }, rev: { fuzz: 0.05, minSpace: 1, }, }, ]); }); ================================================ FILE: ts/routes/deck-options/lib.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { PlainMessage } from "@bufbuild/protobuf"; import type { DeckConfigsForUpdate, DeckConfigsForUpdate_CurrentDeck, UpdateDeckConfigsMode, UpdateDeckConfigsRequest, } from "@generated/anki/deck_config_pb"; import { DeckConfig, DeckConfig_Config, DeckConfigsForUpdate_CurrentDeck_Limits } from "@generated/anki/deck_config_pb"; import { updateDeckConfigs } from "@generated/backend"; import { localeCompare } from "@tslib/i18n"; import { promiseWithResolver } from "@tslib/promise"; import { cloneDeep, isEqual, isEqualWith } from "lodash-es"; import { tick } from "svelte"; import type { Readable, Writable } from "svelte/store"; import { get, readable, writable } from "svelte/store"; import type { DynamicSvelteComponent } from "$lib/sveltelib/dynamicComponent"; export type DeckOptionsId = bigint; export interface ConfigWithCount { config: DeckConfig; useCount: number; } /** Info for showing the top selector */ export interface ConfigListEntry { idx: number; name: string; useCount: number; current: boolean; } type AllConfigs = & Required< Pick< PlainMessage, | "configs" | "cardStateCustomizer" | "limits" | "newCardsIgnoreReviewLimit" | "applyAllParentLimits" | "fsrs" | "fsrsReschedule" > > & { currentConfig: DeckConfig_Config }; export class DeckOptionsState { readonly currentConfig: Writable; readonly currentAuxData: Writable>; readonly configList: Readable; readonly cardStateCustomizer: Writable; readonly currentDeck: DeckConfigsForUpdate_CurrentDeck; readonly deckLimits: Writable; readonly defaults: DeckConfig_Config; readonly addonComponents: Writable; readonly newCardsIgnoreReviewLimit: Writable; readonly applyAllParentLimits: Writable; readonly fsrs: Writable; readonly fsrsReschedule: Writable = writable(false); readonly fsrsHealthCheck: Writable; readonly legacyEvaluate: boolean; readonly daysSinceLastOptimization: Writable; readonly currentPresetName: Writable; /** Used to detect if there are any pending changes */ readonly originalConfigsPromise: Promise; readonly originalConfigsResolve: (value: AllConfigs) => void; private targetDeckId: DeckOptionsId; private configs: ConfigWithCount[]; private selectedIdx: number; private configListSetter!: (val: ConfigListEntry[]) => void; private modifiedConfigs: Set = new Set(); private removedConfigs: DeckOptionsId[] = []; private schemaModified: boolean; private _presetAssignmentsChanged = false; // tracks presets that have already been // selected/loaded once, via their ids. // needed for proper change detection private loadedPresets: Set = new Set(); constructor(targetDeckId: DeckOptionsId, data: DeckConfigsForUpdate) { this.targetDeckId = targetDeckId; this.currentDeck = data.currentDeck!; this.defaults = data.defaults!.config!; this.configs = data.allConfig.map((config) => { const configInner = config.config!; return { config: configInner, useCount: config.useCount!, }; }); this.selectedIdx = Math.max( 0, this.configs.findIndex((c) => c.config.id === this.currentDeck.configId), ); this.sortConfigs(); this.cardStateCustomizer = writable(data.cardStateCustomizer); this.deckLimits = writable(data.currentDeck?.limits ?? createLimits()); this.newCardsIgnoreReviewLimit = writable(data.newCardsIgnoreReviewLimit); this.applyAllParentLimits = writable(data.applyAllParentLimits); this.fsrs = writable(data.fsrs); this.fsrsHealthCheck = writable(data.fsrsHealthCheck); this.legacyEvaluate = data.fsrsLegacyEvaluate; this.daysSinceLastOptimization = writable(data.daysSinceLastFsrsOptimize); // decrement the use count of the starting item, as we'll apply +1 to currently // selected one at display time this.configs[this.selectedIdx].useCount -= 1; this.currentConfig = writable(this.getCurrentConfig()); this.currentAuxData = writable(this.getCurrentAuxData()); this.currentPresetName = writable(this.configs[this.selectedIdx].config.name); this.configList = readable(this.getConfigList(), (set) => { this.configListSetter = set; return; }); this.schemaModified = data.schemaModified; this.addonComponents = writable([]); // create a temporary subscription to force our setters to be set immediately, // so unit tests don't get stale results get(this.configList); // update our state when the current config is changed this.currentConfig.subscribe((val) => this.onCurrentConfigChanged(val)); this.currentAuxData.subscribe((val) => this.onCurrentAuxDataChanged(val)); // no need to call markCurrentPresetAsLoaded here // since any changes components make will be before // originalConfigsResolve is called on mount this.loadedPresets.add(this.configs[this.selectedIdx].config.id); // Must be resolved after all components are mounted, as some components // may modify the config during their initialization. [this.originalConfigsPromise, this.originalConfigsResolve] = promiseWithResolver(); } /** * Patch the original config if components change it after preset select * `EasyDays` and `DateInput` both do this when their settings are blank * We only need to patch when the preset is first selected. */ async markCurrentPresetAsLoaded(): Promise { const id = this.configs[this.selectedIdx].config.id; const loaded = this.loadedPresets; // ignore new presets with an id of 0 if (id && !loaded.has(id)) { // preset was loaded for the first time, patch original config loaded.add(id); const original = await this.originalConfigsPromise; // can't index into `original` with `this.selectedIdx` due to `sortConfigs` const idx = original.configs.findIndex((conf) => conf.id === id); // this should never be -1, since new presets are excluded, and removed presets aren't considered if (idx !== -1) { original.configs[idx] = cloneDeep(this.configs[this.selectedIdx].config); } } } setCurrentIndex(index: number): void { this.selectedIdx = index; this._presetAssignmentsChanged = true; this.updateCurrentConfig(); // use counts have changed this.updateConfigList(); // wait for components that modify config on preset select tick().then(() => this.markCurrentPresetAsLoaded()); } getCurrentName(): string { return this.configs[this.selectedIdx].config.name; } getCurrentNameForSearch(): string { return this.getCurrentName().replace(/([\\"])/g, "\\$1"); } setCurrentName(name: string): void { if (this.configs[this.selectedIdx].config.name === name) { return; } const uniqueName = this.ensureNewNameUnique(name); const config = this.configs[this.selectedIdx].config; config.name = uniqueName; if (config.id) { this.modifiedConfigs.add(config.id); } this.sortConfigs(); this.updateConfigList(); } /** Adds a new config, making it current. */ addConfig(name: string): void { this.addConfigFrom(name, this.defaults); } /** Clone the current config, making it current. */ cloneConfig(name: string): void { this.addConfigFrom(name, this.configs[this.selectedIdx].config.config!); } /** Clone the current config, making it current. */ private addConfigFrom(name: string, source: DeckConfig_Config): void { const uniqueName = this.ensureNewNameUnique(name); const config = new DeckConfig({ id: 0n, name: uniqueName, config: new DeckConfig_Config(cloneDeep(source)), }); const configWithCount = { config, useCount: 0 }; this.configs.push(configWithCount); this.selectedIdx = this.configs.length - 1; this._presetAssignmentsChanged = true; this.sortConfigs(); this.updateCurrentConfig(); this.updateConfigList(); } removalWilLForceFullSync(): boolean { return !this.schemaModified && this.configs[this.selectedIdx].config.id !== 0n; } defaultConfigSelected(): boolean { return this.configs[this.selectedIdx].config.id === 1n; } /** Will throw if the default deck is selected. */ removeCurrentConfig(): void { const currentId = this.configs[this.selectedIdx].config.id; if (currentId === 1n) { throw Error("can't remove default config"); } if (currentId !== 0n) { this.removedConfigs.push(currentId); this.schemaModified = true; } this.configs.splice(this.selectedIdx, 1); const newIdx = Math.max(0, this.selectedIdx - 1); this.setCurrentIndex(newIdx); } dataForSaving( mode: UpdateDeckConfigsMode, ): PlainMessage { const modifiedConfigsExcludingCurrent = this.configs .map((c) => c.config) .filter((c, idx) => { return ( idx !== this.selectedIdx && (c.id === 0n || this.modifiedConfigs.has(c.id)) ); }); const configs = [ ...modifiedConfigsExcludingCurrent, // current must come last, even if unmodified this.configs[this.selectedIdx].config, ]; return { targetDeckId: this.targetDeckId, removedConfigIds: this.removedConfigs, configs, mode, cardStateCustomizer: get(this.cardStateCustomizer), limits: get(this.deckLimits), newCardsIgnoreReviewLimit: get(this.newCardsIgnoreReviewLimit), applyAllParentLimits: get(this.applyAllParentLimits), fsrs: get(this.fsrs), fsrsReschedule: get(this.fsrsReschedule), fsrsHealthCheck: get(this.fsrsHealthCheck), }; } presetAssignmentsChanged(): boolean { return this._presetAssignmentsChanged; } async save(mode: UpdateDeckConfigsMode): Promise { await updateDeckConfigs( this.dataForSaving(mode), ); } private onCurrentConfigChanged(config: DeckConfig_Config): void { const configOuter = this.configs[this.selectedIdx].config; if (!isEqual(config, configOuter.config)) { configOuter.config = config; if (configOuter.id) { this.modifiedConfigs.add(configOuter.id); } } } private onCurrentAuxDataChanged(data: Record): void { const current = this.getCurrentAuxData(); if (!isEqual(current, data)) { this.currentConfig.update((config) => { const asBytes = new TextEncoder().encode(JSON.stringify(data)); config.other = asBytes; return config; }); } } private ensureNewNameUnique(name: string): string { const idx = this.configs.findIndex((e) => e.config.name === name); if (idx !== -1) { return name + (new Date().getTime() / 1000).toFixed(0); } else { return name; } } private updateCurrentConfig(): void { this.currentConfig.set(this.getCurrentConfig()); this.currentAuxData.set(this.getCurrentAuxData()); } private updateConfigList(): void { this.configListSetter?.(this.getConfigList()); this.currentPresetName.set(this.configs[this.selectedIdx].config.name); } /** Returns a copy of the currently selected config. */ private getCurrentConfig(): DeckConfig_Config { return cloneDeep(this.configs[this.selectedIdx].config.config!); } /** Extra data associated with current config (for add-ons) */ private getCurrentAuxData(): Record { const conf = this.configs[this.selectedIdx].config.config!; return bytesToObject(conf.other); } private sortConfigs() { const currentConfigName = this.configs[this.selectedIdx].config.name; this.configs.sort((a, b) => localeCompare(a.config.name, b.config.name, { sensitivity: "base" })); this.selectedIdx = this.configs.findIndex( (c) => c.config.name == currentConfigName, ); } private getConfigList(): ConfigListEntry[] { const list: ConfigListEntry[] = this.configs.map((c, idx) => { const useCount = c.useCount + (idx === this.selectedIdx ? 1 : 0); return { name: c.config.name, current: idx === this.selectedIdx, idx, useCount, }; }); return list; } private getAllConfigs(): AllConfigs { return cloneDeep({ configs: this.configs.map(c => c.config), cardStateCustomizer: get(this.cardStateCustomizer), limits: get(this.deckLimits), newCardsIgnoreReviewLimit: get(this.newCardsIgnoreReviewLimit), applyAllParentLimits: get(this.applyAllParentLimits), fsrs: get(this.fsrs), fsrsReschedule: get(this.fsrsReschedule), currentConfig: get(this.currentConfig), }); } async isModified(): Promise { const original = await this.originalConfigsPromise; const current = this.getAllConfigs(); return !isEqualWith(original, current, (lhs, rhs) => { if (typeof lhs === "number" && typeof rhs === "number") { // rslib hands us 32-bit floats (f32), while ts uses 64-bit floats // SpinBox and ParamsInput both round their values as f64 on blur // while the original config's corresponding value remains an f32 // so we convert both to f32 before checking for equality return Math.fround(lhs) === Math.fround(rhs); } // undefined means fallback to isEqual }); } resolveOriginalConfigs(): void { this.originalConfigsResolve(this.getAllConfigs()); } } function bytesToObject(bytes: Uint8Array): Record { if (!bytes.length) { return {}; } let obj: Record; try { obj = JSON.parse(new TextDecoder().decode(bytes)); } catch (err) { console.log(`invalid json in deck config`); return {}; } if (obj.constructor !== Object) { console.log(`invalid object in deck config`); return {}; } return obj; } export function createLimits(): DeckConfigsForUpdate_CurrentDeck_Limits { return new DeckConfigsForUpdate_CurrentDeck_Limits({}); } export class ValueTab { readonly title: string; value: number | null; private setter: (value: number | null) => void; private disabledValue: number | null; private startValue: number | null; private initialValue: number | null; constructor( title: string, value: number | null, setter: (value: number | null) => void, disabledValue: number | null, startValue: number | null, ) { this.title = title; this.value = this.initialValue = value; this.setter = setter; this.disabledValue = disabledValue; this.startValue = startValue; } reset(): void { this.setter(this.initialValue); } disable(): void { this.setter(this.disabledValue); } enable(fallbackValue: number): void { this.value = this.value ?? this.startValue ?? fallbackValue; this.setter(this.value); } setValue(value: number): void { this.value = value; this.setter(value); } } /** Ensure blur handler has fired so changes get committed. */ export async function commitEditing(): Promise { if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } await tick(); } export function fsrsParams(config: DeckConfig_Config): number[] { if (config.fsrsParams6) { return config.fsrsParams6; } else if (config.fsrsParams5) { return config.fsrsParams5; } else { return config.fsrsParams4; } } ================================================ FILE: ts/routes/deck-options/steps.test.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { expect, test } from "vitest"; import { stepsToString, stringToSteps } from "./steps"; test("whole steps", () => { const steps = [1, 10, 60, 120, 1440]; const string = "1m 10m 1h 2h 1d"; expect(stepsToString(steps)).toBe(string); expect(stringToSteps(string)).toStrictEqual(steps); }); test("fractional steps", () => { const steps = [1 / 60, 5 / 60, 1.5, 400]; const string = "1s 5s 90s 400m"; expect(stepsToString(steps)).toBe(string); expect(stringToSteps(string)).toStrictEqual(steps); }); test("rounding", () => { const steps = [0.1666666716337204]; expect(stepsToString(steps)).toBe("10s"); }); test("parsing", () => { expect(stringToSteps("")).toStrictEqual([]); expect(stringToSteps(" ")).toStrictEqual([]); expect(stringToSteps("1 hello 2")).toStrictEqual([1, 2]); }); ================================================ FILE: ts/routes/deck-options/steps.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { naturalWholeUnit, TimespanUnit, unitAmount, unitSeconds } from "@tslib/time"; function unitSuffix(unit: TimespanUnit): string { switch (unit) { case TimespanUnit.Seconds: return "s"; case TimespanUnit.Minutes: return "m"; case TimespanUnit.Hours: return "h"; case TimespanUnit.Days: return "d"; default: // should not happen return ""; } } function suffixToUnit(suffix: string): TimespanUnit { switch (suffix) { case "s": return TimespanUnit.Seconds; case "h": return TimespanUnit.Hours; case "d": return TimespanUnit.Days; default: return TimespanUnit.Minutes; } } function minutesToString(step: number): string { const secs = step * 60; let unit = naturalWholeUnit(secs); if ([TimespanUnit.Months, TimespanUnit.Years].includes(unit)) { unit = TimespanUnit.Days; } const amount = Math.round(unitAmount(unit, secs)); return `${amount}${unitSuffix(unit)}`; } function stringToMinutes(text: string): number { const match = text.match(/(\d+)(.*)/); if (match) { const [_, num, suffix] = match; const unit = suffixToUnit(suffix); const seconds = unitSeconds(unit) * parseInt(num, 10); // should be representable as negative i32 seconds in a revlog const capped_seconds = Math.min(seconds, 2 ** 31); return capped_seconds / 60; } else { return 0; } } export function stepsToString(steps: number[]): string { return steps.map(minutesToString).join(" "); } export function stringToSteps(text: string): number[] { return ( text .split(" ") .map(stringToMinutes) // remove zeros .filter((e) => e) ); } ================================================ FILE: ts/routes/graphs/+page.svelte ================================================ ================================================ FILE: ts/routes/graphs/AddedGraph.svelte ================================================ ================================================ FILE: ts/routes/graphs/AxisTicks.svelte ================================================ ================================================ FILE: ts/routes/graphs/ButtonsGraph.svelte ================================================ ================================================ FILE: ts/routes/graphs/CalendarGraph.svelte ================================================ {targetYear} ================================================ FILE: ts/routes/graphs/CardCounts.svelte ================================================
{#each tableData as d, _idx} {/each}
■  {#if $prefs.browserLinksSupported} {:else} {d.label} {/if} {d.count} {d.percent}
{total} {graphData.totalCards}
================================================ FILE: ts/routes/graphs/CumulativeOverlay.svelte ================================================ ================================================ FILE: ts/routes/graphs/DifficultyGraph.svelte ================================================ {#if sourceData?.fsrs} {/if} ================================================ FILE: ts/routes/graphs/EaseGraph.svelte ================================================ {#if !(sourceData?.fsrs ?? false)} {/if} ================================================ FILE: ts/routes/graphs/FutureDue.svelte ================================================ {#if graphData && graphData.haveBacklog} {/if} ================================================ FILE: ts/routes/graphs/Graph.svelte ================================================ {#if title == null}
{#if subtitle}
{subtitle}
{/if}
{:else}
{#if subtitle}
{subtitle}
{/if}
{/if} ================================================ FILE: ts/routes/graphs/GraphRangeRadios.svelte ================================================ {#if revlogRange === RevlogRange.All} {/if} ================================================ FILE: ts/routes/graphs/GraphsPage.svelte ================================================ {#if controller} {/if}
{#if sourceData && revlogRange} {#each graphs as graph} {/each} {/if}
================================================ FILE: ts/routes/graphs/HistogramGraph.svelte ================================================ ================================================ FILE: ts/routes/graphs/HourGraph.svelte ================================================ ================================================ FILE: ts/routes/graphs/HoverColumns.svelte ================================================ ================================================ FILE: ts/routes/graphs/InputBox.svelte ================================================
================================================ FILE: ts/routes/graphs/IntervalsGraph.svelte ================================================ ================================================ FILE: ts/routes/graphs/NoDataOverlay.svelte ================================================ {noData} ================================================ FILE: ts/routes/graphs/PercentageRange.svelte ================================================ ================================================ FILE: ts/routes/graphs/RangeBox.svelte ================================================
{ searchRange = SearchRange.Custom; }} placeholder={searchLabel} />
================================================ FILE: ts/routes/graphs/RetrievabilityGraph.svelte ================================================ {#if sourceData?.fsrs} {/if} ================================================ FILE: ts/routes/graphs/ReviewsGraph.svelte ================================================ {#each [4, 3, 2, 1, 0] as i} {/each} ================================================ FILE: ts/routes/graphs/StabilityGraph.svelte ================================================ {#if sourceData?.fsrs} {/if} ================================================ FILE: ts/routes/graphs/TableData.svelte ================================================
{#each tableData as { label, value }} {/each}
{label}: {value}
================================================ FILE: ts/routes/graphs/TodayStats.svelte ================================================ {#if todayData}
{#each todayData.lines as line}
{line}
{/each}
{/if} ================================================ FILE: ts/routes/graphs/Tooltip.svelte ================================================
{@html html}
================================================ FILE: ts/routes/graphs/TrueRetention.svelte ================================================
{ modal = e.detail.modal; carousel = e.detail.carousel; }} />
{#if retentionData === null}
{tr.statisticsNoData()}
{:else if mode === DisplayMode.Young} {:else if mode === DisplayMode.Mature} {:else if mode === DisplayMode.All} {:else if mode === DisplayMode.Summary} {:else} {assertUnreachable(mode)} {/if}
================================================ FILE: ts/routes/graphs/TrueRetentionCombined.svelte ================================================ {#each rowData as row} {@const totalPassed = row.data.youngPassed + row.data.maturePassed} {@const totalFailed = row.data.youngFailed + row.data.matureFailed} {/each}
{tr.statisticsTrueRetentionYoung()} {tr.statisticsTrueRetentionMature()} {tr.statisticsTrueRetentionTotal()} {tr.statisticsTrueRetentionCount()}
{row.title} {calculateRetentionPercentageString( row.data.youngPassed, row.data.youngFailed, )} {calculateRetentionPercentageString( row.data.maturePassed, row.data.matureFailed, )} {calculateRetentionPercentageString(totalPassed, totalFailed)} {localizedNumber(totalPassed + totalFailed)}
================================================ FILE: ts/routes/graphs/TrueRetentionSingle.svelte ================================================ {#each rowData as row} {@const passed = getPassed(row.data, scope)} {@const failed = getFailed(row.data, scope)} {/each}
{tr.statisticsTrueRetentionPass()} {tr.statisticsTrueRetentionFail()} {tr.statisticsTrueRetentionRetention()}
{row.title} {localizedNumber(passed)} {localizedNumber(failed)} {calculateRetentionPercentageString(passed, failed)}
================================================ FILE: ts/routes/graphs/WithGraphData.svelte ================================================ {#await prefsPromise then prefs} {/await} ================================================ FILE: ts/routes/graphs/_true-retention-base.scss ================================================ table { border-collapse: collapse; margin-left: auto; margin-right: auto; } tr { border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); } td, th { padding-left: 0.5em; padding-right: 0.5em; } .row-header { color: var(--fg); } ================================================ FILE: ts/routes/graphs/added.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import type { GraphsResponse } from "@generated/anki/stats_pb"; import * as tr from "@generated/ftl"; import { dayLabel } from "@tslib/time"; import type { Bin } from "d3"; import { bin, interpolateBlues, min, scaleLinear, scaleSequential, sum } from "d3"; import type { SearchDispatch, TableDatum } from "./graph-helpers"; import { getNumericMapBinValue, GraphRange, numericMap } from "./graph-helpers"; import type { HistogramData } from "./histogram-graph"; export interface GraphData { daysAdded: Map; } export function gatherData(data: GraphsResponse): GraphData { return { daysAdded: numericMap(data.added!.added) }; } function makeQuery(start: number, end: number): string { const include = `"added:${start}"`; if (start === 1) { return include; } const exclude = `-"added:${end}"`; return `${include} AND ${exclude}`; } export function buildHistogram( data: GraphData, range: GraphRange, dispatch: SearchDispatch, browserLinksSupported: boolean, ): [HistogramData | null, TableDatum[]] { // get min/max const total = data.daysAdded.size; if (!total) { return [null, []]; } let xMin: number; // cap max to selected range switch (range) { case GraphRange.Month: xMin = -31; break; case GraphRange.ThreeMonths: xMin = -90; break; case GraphRange.Year: xMin = -365; break; case GraphRange.AllTime: xMin = min(data.daysAdded.keys())!; break; } const xMax = 1; const desiredBars = Math.min(70, Math.abs(xMin!)); const scale = scaleLinear().domain([xMin!, xMax]); const bins = bin() .value((m) => { return m[0]; }) .domain(scale.domain() as any) .thresholds(scale.ticks(desiredBars))(data.daysAdded.entries() as any); // empty graph? const accessor = getNumericMapBinValue as any; if (!sum(bins, accessor)) { return [null, []]; } const adjustedRange = scaleLinear().range([0.7, 0.3]); const colourScale = scaleSequential((n) => interpolateBlues(adjustedRange(n)!)).domain([xMax!, xMin!]); const totalInPeriod = sum(bins, accessor); const periodDays = Math.abs(xMin!); const cardsPerDay = Math.round(totalInPeriod / periodDays); const tableData = [ { label: tr.statisticsTotal(), value: tr.statisticsCards({ cards: totalInPeriod }), }, { label: tr.statisticsAverage(), value: tr.statisticsCardsPerDay({ count: cardsPerDay }), }, ]; function hoverText( bin: Bin, cumulative: number, _percent: number, ): string { const day = dayLabel(bin.x0!, bin.x1!); const cards = tr.statisticsCards({ cards: accessor(bin) }); const total = tr.statisticsRunningTotal(); const totalCards = tr.statisticsCards({ cards: cumulative }); return `${day}:
${cards}
${total}: ${totalCards}`; } function onClick(bin: Bin): void { const start = Math.abs(bin.x0!) + 1; const end = Math.abs(bin.x1!) + 1; const query = makeQuery(start, end); dispatch("search", { query }); } return [ { scale, bins, total: totalInPeriod, hoverText, onClick: browserLinksSupported ? onClick : null, colourScale, binValue: getNumericMapBinValue, showArea: true, }, tableData, ]; } ================================================ FILE: ts/routes/graphs/buttons.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import type { GraphsResponse } from "@generated/anki/stats_pb"; import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; import { axisBottom, axisLeft, interpolateRdYlGn, pointer, scaleBand, scaleLinear, scaleSequential, select, sum, } from "d3"; import type { GraphBounds } from "./graph-helpers"; import { GraphRange } from "./graph-helpers"; import { setDataAvailable } from "./graph-helpers"; import { hideTooltip, showTooltip } from "./tooltip-utils.svelte"; /** 4 element array */ type ButtonCounts = number[]; export interface GraphData { learning: ButtonCounts; young: ButtonCounts; mature: ButtonCounts; } export function gatherData(data: GraphsResponse, range: GraphRange): GraphData { const buttons = data.buttons!; switch (range) { case GraphRange.Month: return buttons.oneMonth!; case GraphRange.ThreeMonths: return buttons.threeMonths!; case GraphRange.Year: return buttons.oneYear!; case GraphRange.AllTime: return buttons.allTime!; } } type GroupKind = "learning" | "young" | "mature"; interface Datum { buttonNum: number; group: GroupKind; count: number; } interface TotalCorrect { total: number; correct: number; percent: string; } export function renderButtons( svgElem: SVGElement, bounds: GraphBounds, origData: GraphsResponse, range: GraphRange, ): void { const sourceData = gatherData(origData, range); const data = [ ...sourceData.learning.map((count: number, idx: number) => { return { buttonNum: idx + 1, group: "learning", count, } satisfies Datum; }), ...sourceData.young.map((count: number, idx: number) => { return { buttonNum: idx + 1, group: "young", count, } satisfies Datum; }), ...sourceData.mature.map((count: number, idx: number) => { return { buttonNum: idx + 1, group: "mature", count, } satisfies Datum; }), ]; const totalCorrect = (kind: GroupKind): TotalCorrect => { const groupData = data.filter((d) => d.group == kind); const total = sum(groupData, (d) => d.count); const correct = sum( groupData.filter((d) => d.buttonNum > 1), (d) => d.count, ); const percent = total ? localizedNumber((correct / total) * 100) : "0"; return { total, correct, percent }; }; const totalPressedStr = (data: Datum): string => { const groupTotal = totalCorrect(data.group).total; const buttonTotal = data.count; const percent = groupTotal ? localizedNumber((buttonTotal / groupTotal) * 100) : "0"; return `${localizedNumber(buttonTotal)} (${percent}%)`; }; const yMax = Math.max(...data.map((d) => d.count)); const svg = select(svgElem); const trans = svg.transition().duration(600) as any; if (!yMax) { setDataAvailable(svg, false); return; } else { setDataAvailable(svg, true); } const xGroup = scaleBand() .domain(["learning", "young", "mature"]) .range([bounds.marginLeft, bounds.width - bounds.marginRight]); svg.select(".x-ticks") .call((selection) => selection.transition(trans).call( axisBottom(xGroup) .tickFormat( ((d: GroupKind) => { let kind: string; switch (d) { case "learning": kind = tr.statisticsCountsLearningCards(); break; case "young": kind = tr.statisticsCountsYoungCards(); break; case "mature": default: kind = tr.statisticsCountsMatureCards(); break; } return `${kind}`; }) as any, ) .tickSizeOuter(0), ) ) .attr("direction", "ltr"); const xButton = scaleBand() .domain(["1", "2", "3", "4"]) .range([0, xGroup.bandwidth()]) .paddingOuter(1) .paddingInner(0.1); const colour = scaleSequential(interpolateRdYlGn).domain([1, 4]); // y scale const yTickFormat = (n: number): string => localizedNumber(n); const y = scaleLinear() .range([bounds.height - bounds.marginBottom, bounds.marginTop]) .domain([0, yMax]); svg.select(".y-ticks") .call((selection) => selection.transition(trans).call( axisLeft(y) .ticks(bounds.height / 50) .tickSizeOuter(0) .tickFormat(yTickFormat as any), ) ) .attr("direction", "ltr"); // x bars const updateBar = (sel: any): any => { return sel .attr("width", xButton.bandwidth()) .attr("opacity", 0.8) .transition(trans) .attr( "x", (d: Datum) => xGroup(d.group)! + xButton(d.buttonNum.toString())!, ) .attr("y", (d: Datum) => y(d.count)!) .attr("height", (d: Datum) => y(0)! - y(d.count)!) .attr("fill", (d: Datum) => colour(d.buttonNum)); }; svg.select("g.bars") .selectAll("rect") .data(data) .join( (enter) => enter .append("rect") .attr("rx", 1) .attr( "x", (d: Datum) => xGroup(d.group)! + xButton(d.buttonNum.toString())!, ) .attr("y", y(0)!) .attr("height", 0) .call(updateBar), (update) => update.call(updateBar), (remove) => remove.call((remove) => remove.transition(trans).attr("height", 0).attr("y", y(0)!)), ); // hover/tooltip function tooltipText(d: Datum): string { const button = tr.statisticsAnswerButtonsButtonNumber(); const timesPressed = tr.statisticsAnswerButtonsButtonPressed(); const correctStr = tr.statisticsHoursCorrect(totalCorrect(d.group)); const correctStrInfo = tr.statisticsHoursCorrectInfo(); const pressedStr = `${timesPressed}: ${totalPressedStr(d)}`; let buttonText: string; if (d.buttonNum === 1) { buttonText = tr.studyingAgain(); } else if (d.buttonNum === 2) { buttonText = tr.studyingHard(); } else if (d.buttonNum === 3) { buttonText = tr.studyingGood(); } else if (d.buttonNum === 4) { buttonText = tr.studyingEasy(); } else { buttonText = ""; } return `${button}: ${d.buttonNum} (${buttonText})
${pressedStr}
${correctStr} ${correctStrInfo}`; } svg.select("g.hover-columns") .selectAll("rect") .data(data) .join("rect") .attr("x", (d: Datum) => xGroup(d.group)! + xButton(d.buttonNum.toString())!) .attr("y", () => y(yMax!)!) .attr("width", xButton.bandwidth()) .attr("height", () => y(0)! - y(yMax!)!) .on("mousemove", (event: MouseEvent, d: Datum) => { const [x, y] = pointer(event, document.body); showTooltip(tooltipText(d), x, y); }) .on("mouseout", hideTooltip); } ================================================ FILE: ts/routes/graphs/calendar.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { GraphsResponse } from "@generated/anki/stats_pb"; import { GraphPreferences_Weekday as Weekday } from "@generated/anki/stats_pb"; import * as tr from "@generated/ftl"; import { firstLanguage } from "@generated/ftl"; import { localizedDate, weekdayLabel } from "@tslib/i18n"; import type { CountableTimeInterval } from "d3"; import { timeHour } from "d3"; import { interpolateBlues, pointer, scaleLinear, scaleSequentialSqrt, select, timeDay, timeFriday, timeMonday, timeSaturday, timeSunday, timeYear, } from "d3"; import type { GraphBounds, SearchDispatch } from "./graph-helpers"; import { RevlogRange, setDataAvailable } from "./graph-helpers"; import { clickableClass } from "./graph-styles"; import { hideTooltip, showTooltip } from "./tooltip-utils.svelte"; export interface GraphData { // indexed by day, where day is relative to today reviewCount: Map; timeFunction: CountableTimeInterval; weekdayLabels: number[]; rolloverHour: number; } interface DayDatum { day: number; count: number; // 0-51 weekNumber: number; // 0-6 weekDay: number; date: Date; } export function gatherData( data: GraphsResponse, firstDayOfWeek: Weekday, ): GraphData { const reviewCount = new Map( Object.entries(data.reviews!.count).map(([k, v]) => { return [Number(k), v.learn + v.relearn + v.mature + v.filtered + v.young]; }), ); const timeFunction = timeFunctionForDay(firstDayOfWeek); const weekdayLabels: number[] = []; for (let i = 0; i < 7; i++) { weekdayLabels.push((firstDayOfWeek + i) % 7); } return { reviewCount, timeFunction, weekdayLabels, rolloverHour: data.rolloverHour }; } export function renderCalendar( svgElem: SVGElement, bounds: GraphBounds, sourceData: GraphData, dispatch: SearchDispatch, targetYear: number, nightMode: boolean, revlogRange: RevlogRange, setFirstDayOfWeek: (d: number) => void, ): void { const svg = select(svgElem); const now = new Date(); const nowForYear = new Date(); nowForYear.setFullYear(targetYear); const x = scaleLinear() .range([bounds.marginLeft, bounds.width - bounds.marginRight]) .domain([-1, 53]); // map of 0-365 -> day const dayMap: Map = new Map(); let maxCount = 0; for (const [day, count] of sourceData.reviewCount.entries()) { let date = timeDay.offset(now, day); // anki day does not necessarily roll over at midnight, we account for this when mapping onto calendar days date = timeHour.offset(date, -1 * sourceData.rolloverHour); if (count > maxCount) { maxCount = count; } if (date.getFullYear() != targetYear) { continue; } const weekNumber = sourceData.timeFunction.count(timeYear(date), date); const weekDay = timeDay.count(sourceData.timeFunction(date), date); const yearDay = timeDay.count(timeYear(date), date); dayMap.set(yearDay, { day, count, weekNumber, weekDay, date }); } if (!maxCount) { setDataAvailable(svg, false); return; } else { setDataAvailable(svg, true); } // fill in any blanks, including the current calendar day even if the anki day has not rolled over const startDate = timeYear(nowForYear); const oneYearAgoFromNow = new Date(now); oneYearAgoFromNow.setFullYear(now.getFullYear() - 1); for (let i = 0; i < 365; i++) { const date = timeDay.offset(startDate, i); if (date > now) { // don't fill out future dates continue; } if (revlogRange == RevlogRange.Year && date < oneYearAgoFromNow) { // don't fill out dates older than a year continue; } const yearDay = timeDay.count(timeYear(date), date); if (!dayMap.has(yearDay)) { const weekNumber = sourceData.timeFunction.count(timeYear(date), date); const weekDay = timeDay.count(sourceData.timeFunction(date), date); dayMap.set(yearDay, { day: yearDay, count: 0, weekNumber, weekDay, date, }); } } const data = Array.from(dayMap.values()); const cappedRange = scaleLinear().range([0.2, nightMode ? 0.8 : 1]); const blues = scaleSequentialSqrt() .domain([0, maxCount]) .interpolator((n) => interpolateBlues(cappedRange(n)!)); function tooltipText(d: DayDatum): string { const date = localizedDate(d.date, { weekday: "long", year: "numeric", month: "long", day: "numeric", }); const cards = tr.statisticsReviews({ reviews: d.count }); return `${date}
${cards}`; } const height = bounds.height / 10; const emptyColour = nightMode ? "#333" : "#ddd"; const firstLang = firstLanguage(); svg.select("g.weekdays") .selectAll("text") .data(sourceData.weekdayLabels) .join("text") .text((d: number) => weekdayLabel(d)) .attr("width", x(-1)! - 2) .attr("height", height - 2) .attr("x", x(1)! - 3) .attr("y", (_d, index) => bounds.marginTop + index * height) .attr("fill", nightMode ? "#ddd" : "black") .attr("dominant-baseline", "hanging") .attr("text-anchor", "end") .attr("font-size", firstLang.includes("zh") ? "xx-small" : "small") .attr("font-family", "monospace") .attr("direction", "ltr") .style("user-select", "none") .on("click", null) .filter((d: number) => [Weekday.SUNDAY, Weekday.MONDAY, Weekday.FRIDAY, Weekday.SATURDAY].includes( d, ) ) .on("click", (_event: MouseEvent, d: number) => setFirstDayOfWeek(d)); svg.select("g.days") .selectAll("rect") .data(data) .join("rect") .attr("fill", emptyColour) .attr("width", (d: DayDatum) => x(d.weekNumber + 1)! - x(d.weekNumber)! - 2) .attr("height", height - 2) .attr("x", (d: DayDatum) => x(d.weekNumber + 1)!) .attr("y", (d: DayDatum) => bounds.marginTop + d.weekDay * height) .on("mousemove", (event: MouseEvent, d: DayDatum) => { const [x, y] = pointer(event, document.body); showTooltip(tooltipText(d), x, y); }) .on("mouseout", hideTooltip) .attr("class", (d: DayDatum): string => (d.count > 0 ? clickableClass : "")) .on("click", function(_event: MouseEvent, d: DayDatum) { if (d.count > 0) { dispatch("search", { query: `"prop:rated=${d.day}"` }); } }) .transition() .duration(800) .attr("fill", (d: DayDatum) => (d.count === 0 ? emptyColour : blues(d.count)!)); } function timeFunctionForDay(firstDayOfWeek: Weekday): CountableTimeInterval { switch (firstDayOfWeek) { case Weekday.MONDAY: return timeMonday; case Weekday.FRIDAY: return timeFriday; case Weekday.SATURDAY: return timeSaturday; default: return timeSunday; } } ================================================ FILE: ts/routes/graphs/card-counts.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import type { GraphsResponse } from "@generated/anki/stats_pb"; import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; import { arc, cumsum, interpolate, pie, scaleLinear, schemeBlues, schemeGreens, schemeOranges, schemeReds, select, sum, } from "d3"; import type { GraphBounds } from "./graph-helpers"; type Count = [string, number, boolean, string]; export interface GraphData { title: string; counts: Count[]; totalCards: string; } const barColours = [ schemeBlues[5][2], /* new */ schemeOranges[5][2], /* learn */ schemeReds[5][2], /* relearn */ schemeGreens[5][2], /* young */ schemeGreens[5][3], /* mature */ "#FFDC41", /* suspended */ "grey", /* buried */ ]; function countCards(data: GraphsResponse, separateInactive: boolean): Count[] { const countData = separateInactive ? data.cardCounts!.excludingInactive! : data.cardCounts!.includingInactive!; const extraQuery = separateInactive ? "AND -(\"is:buried\" OR \"is:suspended\")" : ""; const counts: Count[] = [ [tr.statisticsCountsNewCards(), countData.newCards, true, `"is:new"${extraQuery}`], [ tr.statisticsCountsLearningCards(), countData.learn, true, `(-"is:review" AND "is:learn")${extraQuery}`, ], [ tr.statisticsCountsRelearningCards(), countData.relearn, true, `("is:review" AND "is:learn")${extraQuery}`, ], [ tr.statisticsCountsYoungCards(), countData.young, true, `("is:review" AND -"is:learn") AND "prop:ivl<21"${extraQuery}`, ], [ tr.statisticsCountsMatureCards(), countData.mature, true, `("is:review" -"is:learn") AND "prop:ivl>=21"${extraQuery}`, ], [ tr.statisticsCountsSuspendedCards(), countData.suspended, separateInactive, "\"is:suspended\"", ], [tr.statisticsCountsBuriedCards(), countData.buried, separateInactive, "\"is:buried\""], ]; return counts; } export function gatherData( data: GraphsResponse, separateInactive: boolean, ): GraphData { const counts = countCards(data, separateInactive); const totalCards = localizedNumber(sum(counts, e => e[1])); return { title: tr.statisticsCountsTitle(), counts, totalCards, }; } export interface SummedDatum { label: string; // count of this particular item count: number; // show up in the table show: boolean; query: string; // running total total: number; } export interface TableDatum { label: string; count: string; query: string; percent: string; colour: string; } export function renderCards( svgElem: SVGElement, bounds: GraphBounds, sourceData: GraphData, ): TableDatum[] { const summed = cumsum(sourceData.counts, (d: Count) => d[1]); const data = Array.from(summed).map((n, idx) => { const count = sourceData.counts[idx]; return { label: count[0], count: count[1], show: count[2], query: count[3], total: n, } satisfies SummedDatum; }); // ensuring a non-zero range makes the percentages not break // in an empty collection const xMax = Math.max(1, summed.slice(-1)[0]); const x = scaleLinear().domain([0, xMax]); const svg = select(svgElem); const paths = svg.select(".counts"); const pieData = pie()(sourceData.counts.map((d: Count) => d[1])); const radius = bounds.height / 2 - bounds.marginTop - bounds.marginBottom; const arcGen = arc().innerRadius(0).outerRadius(radius); const trans = svg.transition().duration(600) as any; paths .attr("transform", `translate(${radius},${radius + bounds.marginTop})`) .selectAll("path") .data(pieData) .join( (enter) => enter .append("path") .attr("fill", (_d, idx) => { return barColours[idx]; }) .attr("d", arcGen as any), function(update) { return update.call((d) => d.transition(trans).attrTween("d", (d) => { const interpolator = interpolate( { startAngle: 0, endAngle: 0 }, d, ); return (t): string => arcGen(interpolator(t) as any) as string; }) ); }, ); x.range([bounds.marginLeft, bounds.width - bounds.marginRight]); const tableData = data.flatMap((d: SummedDatum, idx: number) => { const percent = localizedNumber((d.count / xMax) * 100, 2); return d.show ? ({ label: d.label, count: localizedNumber(d.count), percent: `${percent}%`, colour: barColours[idx], query: d.query, } satisfies TableDatum) : []; }); return tableData; } ================================================ FILE: ts/routes/graphs/difficulty.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import type { GraphsResponse } from "@generated/anki/stats_pb"; import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; import type { Bin } from "d3"; import { bin, interpolateRdYlGn, scaleSequential, sum } from "d3"; import type { SearchDispatch, TableDatum } from "./graph-helpers"; import { getNumericMapBinValue, numericMap } from "./graph-helpers"; import type { HistogramData } from "./histogram-graph"; import { getAdjustedScaleAndTicks, percentageRangeMinMax } from "./percentageRange"; export interface GraphData { eases: Map; average: number; } export function gatherData(data: GraphsResponse): GraphData { return { eases: numericMap(data.difficulty!.eases), average: data.difficulty!.average }; } function makeQuery(start: number, end: number): string { const fromQuery = `"prop:d>=${start / 100}"`; let tillQuery = `"prop:d<${(end + 1) / 100}"`; if (end === 99) { tillQuery = tillQuery.replace("<", "<="); } return `${fromQuery} AND ${tillQuery}`; } export function prepareData( data: GraphData, dispatch: SearchDispatch, browserLinksSupported: boolean, quantile?: number, ): [HistogramData | null, TableDatum[]] { // get min/max const allEases = data.eases; if (!allEases.size) { return [null, []]; } const [xMin, xMax] = percentageRangeMinMax(allEases, quantile); const desiredBars = 20; const [scale, ticks] = getAdjustedScaleAndTicks(xMin, xMax, desiredBars); const bins = bin() .value((m) => { return m[0]; }) .domain(scale.domain() as [number, number]) .thresholds(ticks)(allEases.entries() as any); const total = sum(bins as any, getNumericMapBinValue); const colourScale = scaleSequential(interpolateRdYlGn).domain([100, 0]); function hoverText(bin: Bin, _percent: number): string { const percent = `${bin.x0}%-${bin.x1}%`; return tr.statisticsCardDifficultyTooltip({ cards: getNumericMapBinValue(bin as any), percent, }); } function onClick(bin: Bin): void { const start = bin.x0!; const end = bin.x1! - 1; const query = makeQuery(start, end); dispatch("search", { query }); } const xTickFormat = (num: number): string => localizedNumber(num, 0) + "%"; const tableData = [ { label: tr.statisticsMedianDifficulty(), value: xTickFormat(data.average), }, ]; return [ { scale, bins, total, hoverText, onClick: browserLinksSupported ? onClick : null, colourScale, showArea: false, binValue: getNumericMapBinValue, xTickFormat, }, tableData, ]; } ================================================ FILE: ts/routes/graphs/ease.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import type { GraphsResponse } from "@generated/anki/stats_pb"; import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; import type { Bin, ScaleLinear } from "d3"; import { bin, extent, interpolateRdYlGn, scaleLinear, scaleSequential, sum } from "d3"; import type { SearchDispatch, TableDatum } from "./graph-helpers"; import { getNumericMapBinValue, numericMap } from "./graph-helpers"; import type { HistogramData } from "./histogram-graph"; export interface GraphData { eases: Map; average: number; } export function gatherData(data: GraphsResponse): GraphData { return { eases: numericMap(data.eases!.eases), average: data.eases!.average }; } function makeQuery(start: number, end: number): string { if (start === end) { return `"prop:ease=${start / 100}"`; } const fromQuery = `"prop:ease>=${start / 100}"`; const tillQuery = `"prop:ease<${(end + 1) / 100}"`; return `${fromQuery} AND ${tillQuery}`; } function getAdjustedScaleAndTicks( min: number, max: number, desiredBars: number, ): [ScaleLinear, number[]] { const prescale = scaleLinear().domain([min, max]).nice(); const ticks = prescale.ticks(desiredBars); const predomain = prescale.domain() as [number, number]; const minOffset = min - predomain[0]; const tickSize = ticks[1] - ticks[0]; if (minOffset === 0 || (minOffset % tickSize !== 0 && tickSize % minOffset !== 0)) { return [prescale, ticks]; } const add = (n: number): number => n + minOffset; return [ scaleLinear().domain(predomain.map(add) as [number, number]), ticks.map(add), ]; } export function prepareData( data: GraphData, dispatch: SearchDispatch, browserLinksSupported: boolean, ): [HistogramData | null, TableDatum[]] { // get min/max const allEases = data.eases; if (!allEases.size) { return [null, []]; } const [, origXMax] = extent(allEases.keys()); const xMin = 130; const xMax = origXMax! + 1; const desiredBars = 20; const [scale, ticks] = getAdjustedScaleAndTicks(xMin, xMax, desiredBars); const bins = bin() .value((m) => { return m[0]; }) .domain(scale.domain() as [number, number]) .thresholds(ticks)(allEases.entries() as any); const total = sum(bins as any, getNumericMapBinValue); const colourScale = scaleSequential(interpolateRdYlGn).domain([xMin, 300]); function hoverText(bin: Bin, _percent: number): string { const minPct = Math.floor(bin.x0!); const maxPct = Math.floor(bin.x1!); const percent = maxPct - minPct <= 10 ? `${bin.x0}%` : `${bin.x0}%-${bin.x1}%`; return tr.statisticsCardEaseTooltip({ cards: getNumericMapBinValue(bin as any), percent, }); } function onClick(bin: Bin): void { const start = bin.x0!; const end = bin.x1! - 1; const query = makeQuery(start, end); dispatch("search", { query }); } const xTickFormat = (num: number): string => localizedNumber(num, 0) + "%"; const tableData = [ { label: tr.statisticsMedianEase(), value: xTickFormat(data.average), }, ]; return [ { scale, bins, total, hoverText, onClick: browserLinksSupported ? onClick : null, colourScale, showArea: false, binValue: getNumericMapBinValue, xTickFormat, }, tableData, ]; } ================================================ FILE: ts/routes/graphs/future-due.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import type { GraphsResponse } from "@generated/anki/stats_pb"; import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; import { dayLabel } from "@tslib/time"; import type { Bin } from "d3"; import { bin, extent, interpolateGreens, scaleLinear, scaleSequential, sum } from "d3"; import type { SearchDispatch, TableDatum } from "./graph-helpers"; import { getNumericMapBinValue, GraphRange, numericMap } from "./graph-helpers"; import type { HistogramData } from "./histogram-graph"; export interface GraphData { dueCounts: Map; haveBacklog: boolean; dailyLoad: number; } export function gatherData(data: GraphsResponse): GraphData { const msg = data.futureDue!; return { dueCounts: numericMap(msg.futureDue), haveBacklog: msg.haveBacklog, dailyLoad: msg.dailyLoad, }; } export interface FutureDueResponse { histogramData: HistogramData | null; tableData: TableDatum[]; } function makeQuery(start: number, end: number): string { if (start === end) { return `"prop:due=${start}"`; } else { const fromQuery = `"prop:due>=${start}"`; const tillQuery = `"prop:due<=${end}"`; return `${fromQuery} AND ${tillQuery}`; } } function withoutBacklog(data: Map): Map { const map = new Map(); for (const [day, count] of data.entries()) { if (day >= 0) { map.set(day, count); } } return map; } export function buildHistogram( sourceData: GraphData, range: GraphRange, includeBacklog: boolean, dispatch: SearchDispatch, browserLinksSupported: boolean, ): FutureDueResponse { const output = { histogramData: null, tableData: [] }; // get min/max const data = includeBacklog ? sourceData.dueCounts : withoutBacklog(sourceData.dueCounts); if (!data) { return output; } const [xMinOrig, origXMax] = extent(data.keys()); let xMin = xMinOrig; if (!includeBacklog) { xMin = 0; } let xMax = origXMax; // cap max to selected range switch (range) { case GraphRange.Month: xMax = 31; break; case GraphRange.ThreeMonths: xMax = 90; break; case GraphRange.Year: xMax = 365; break; case GraphRange.AllTime: break; } // cap bars to available range const desiredBars = Math.min(70, xMax! - xMin!); const x = scaleLinear().domain([xMin!, xMax!]); const bins = bin() .value((m) => { return m[0]; }) .domain(x.domain() as any) .thresholds(x.ticks(desiredBars))(data.entries() as any); // empty graph? if (!sum(bins, (bin) => bin.length)) { return output; } const xTickFormat = (n: number): string => localizedNumber(n); const adjustedRange = scaleLinear().range([0.7, 0.3]); const colourScale = scaleSequential((n) => interpolateGreens(adjustedRange(n)!)).domain([xMin!, xMax!]); const total = sum(bins as any, getNumericMapBinValue); function hoverText( bin: Bin, cumulative: number, _percent: number, ): string { const days = dayLabel(bin.x0!, bin.x1 === xMax ? bin.x1! + 1 : bin.x1!); const cards = tr.statisticsCardsDue({ cards: getNumericMapBinValue(bin as any), }); const totalLabel = tr.statisticsRunningTotal(); return `${days}:
${cards}
${totalLabel}: ${localizedNumber(cumulative)}`; } function onClick(bin: Bin): void { const start = bin.x0!; // x1 in last bin is inclusive const end = bin.x1 === xMax ? bin.x1! : bin.x1! - 1; const query = makeQuery(start, end); dispatch("search", { query }); } const periodDays = xMax! - xMin!; const tableData = [ { label: tr.statisticsTotal(), value: tr.statisticsReviews({ reviews: total }), }, { label: tr.statisticsAverage(), value: tr.statisticsReviewsPerDay({ count: Math.round(total / periodDays), }), }, { label: tr.statisticsDueTomorrow(), value: tr.statisticsReviews({ reviews: sourceData.dueCounts.get(1) ?? 0, }), }, { label: tr.statisticsDailyLoad(), value: tr.statisticsReviewsPerDay({ count: sourceData.dailyLoad, }), }, ]; return { histogramData: { scale: x, bins, total, hoverText, onClick: browserLinksSupported ? onClick : null, showArea: true, colourScale, binValue: getNumericMapBinValue, xTickFormat, }, tableData, }; } ================================================ FILE: ts/routes/graphs/graph-helpers.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", @typescript-eslint/ban-ts-comment: "off" */ import type { GraphPreferences } from "@generated/anki/stats_pb"; import type { Bin, Selection } from "d3"; import { sum } from "d3"; import type { PreferenceStore } from "$lib/sveltelib/preferences"; // amount of data to fetch from backend export enum RevlogRange { Year = 1, All = 2, } export function daysToRevlogRange(days: number): RevlogRange { return days > 365 || days === 0 ? RevlogRange.All : RevlogRange.Year; } // period a graph should cover export enum GraphRange { Month = 0, ThreeMonths = 1, Year = 2, AllTime = 3, } export interface GraphBounds { width: number; height: number; marginLeft: number; marginRight: number; marginTop: number; marginBottom: number; } export function defaultGraphBounds(): GraphBounds { return { width: 600, height: 250, marginLeft: 70, marginRight: 70, marginTop: 20, marginBottom: 25, }; } export type GraphPrefs = PreferenceStore; export function setDataAvailable( svg: Selection, available: boolean, ): void { svg.select(".no-data") .attr("pointer-events", available ? "none" : "all") .transition() .duration(600) .attr("opacity", available ? 0 : 1); } export function millisecondCutoffForRange( range: GraphRange, nextDayAtSecs: number, ): number { let days: number; switch (range) { case GraphRange.Month: days = 31; break; case GraphRange.ThreeMonths: days = 90; break; case GraphRange.Year: days = 365; break; case GraphRange.AllTime: default: return 0; } return (nextDayAtSecs - 86400 * days) * 1000; } export interface TableDatum { label: string; value: string; } export type SearchEventMap = { search: { query: string } }; export type SearchDispatch = >( type: EventKey, detail: SearchEventMap[EventKey], ) => void; /** Convert a protobuf map that protobufjs represents as an object with string keys into a Map with numeric keys. */ export function numericMap(obj: { [k: string]: T }): Map { return new Map(Object.entries(obj).map(([k, v]) => [Number(k), v])); } export function getNumericMapBinValue(d: Bin, number>): number { return sum(d, (d) => d[1]); } ================================================ FILE: ts/routes/graphs/graph-styles.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // Global css classes used by subcomponents // Graph.svelte export const oddTickClass = "tick-odd"; export const clickableClass = "graph-element-clickable"; // It would be nice to define these in the svelte file that declares them, // but currently this trips the tooling up: // https://github.com/sveltejs/svelte/issues/5817 // export { oddTickClass, clickableClass } from "./Graph.svelte"; ================================================ FILE: ts/routes/graphs/graphs-base.scss ================================================ @use "$lib/sass/root-vars"; @import "$lib/sass/base"; button { margin-bottom: 5px; } html { height: initial; } ================================================ FILE: ts/routes/graphs/histogram-graph.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import { localizedNumber } from "@tslib/i18n"; import type { Bin, ScaleLinear, ScaleSequential } from "d3"; import { area, axisBottom, axisLeft, axisRight, cumsum, curveBasis, max, pointer, scaleLinear, select } from "d3"; import type { GraphBounds } from "./graph-helpers"; import { setDataAvailable } from "./graph-helpers"; import { clickableClass } from "./graph-styles"; import { hideTooltip, showTooltip } from "./tooltip-utils.svelte"; export interface HistogramData { scale: ScaleLinear; bins: Bin[]; total: number; hoverText: ( bin: Bin, cumulative: number, percent: number, ) => string; onClick: ((data: Bin) => void) | null; showArea: boolean; colourScale: ScaleSequential; binValue?: (bin: Bin) => number; xTickFormat?: (d: any) => string; } export function histogramGraph( svgElem: SVGElement, bounds: GraphBounds, data: HistogramData | null, ): void { const svg = select(svgElem); const trans = svg.transition().duration(600) as any; const axisTickFormat = (n: number): string => localizedNumber(n); if (!data) { setDataAvailable(svg, false); return; } else { setDataAvailable(svg, true); } const binValue = data.binValue ?? ((bin: Bin) => bin.length); const x = data.scale.range([bounds.marginLeft, bounds.width - bounds.marginRight]); svg.select(".x-ticks") .call((selection) => selection.transition(trans).call( axisBottom(x) .ticks(7) .tickSizeOuter(0) .tickFormat((data.xTickFormat ?? axisTickFormat) as any), ) ) .attr("direction", "ltr"); // y scale const yMax = max(data.bins, (d) => binValue(d))!; const y = scaleLinear() .range([bounds.height - bounds.marginBottom, bounds.marginTop]) .domain([0, yMax]) .nice(); svg.select(".y-ticks") .call((selection) => selection.transition(trans).call( axisLeft(y) .ticks(bounds.height / 50) .tickSizeOuter(0) .tickFormat(axisTickFormat as any), ) ) .attr("direction", "ltr"); // x bars function barWidth(d: Bin): number { const width = Math.max(0, x(d.x1!) - x(d.x0!) - 1); return width ?? 0; } const updateBar = (sel: any): any => { return sel .attr("width", barWidth) .transition(trans) .attr("x", (d: any) => x(d.x0)) .attr("y", (d: any) => y(binValue(d))!) .attr("height", (d: any) => y(0)! - y(binValue(d))!) .attr("fill", (d: any) => data.colourScale(d.x1)); }; svg.select("g.bars") .selectAll("rect") .data(data.bins) .join( (enter) => enter .append("rect") .attr("rx", 1) .attr("x", (d: any) => x(d.x0)!) .attr("y", y(0)!) .attr("height", 0) .call(updateBar), (update) => update.call(updateBar), (remove) => remove.call((remove) => remove.transition(trans).attr("height", 0).attr("y", y(0)!)), ); // cumulative area const areaCounts = data.bins.map((d) => binValue(d)); areaCounts.unshift(0); const areaData = cumsum(areaCounts); const yAreaScale = y.copy().domain([0, data.total]).nice(); if (data.showArea && data.bins.length && areaData.slice(-1)[0]) { svg.select(".y2-ticks") .call((selection) => selection.transition(trans).call( axisRight(yAreaScale) .ticks(bounds.height / 50) .tickSizeOuter(0) .tickFormat(axisTickFormat as any), ) ) .attr("direction", "ltr"); svg.select("path.cumulative-overlay") .datum(areaData as any) .attr( "d", area() .curve(curveBasis) .x((_d, idx) => { if (idx === 0) { return x(data.bins[0].x0!)!; } else { return x(data.bins[idx - 1].x1!)!; } }) .y0(bounds.height - bounds.marginBottom) .y1((d: any) => yAreaScale(d)!) as any, ); } const hoverData: [Bin, number][] = data.bins.map( (bin: Bin, index: number) => [bin, areaData[index + 1]], ); // hover/tooltip const hoverzone = svg .select("g.hover-columns") .selectAll("rect") .data(hoverData) .join("rect") .attr("x", ([bin]) => x(bin.x0!)) .attr("y", () => y(yMax)) .attr("width", ([bin]) => barWidth(bin)) .attr("height", () => y(0) - y(yMax)) .on("mousemove", (event: MouseEvent, [bin, area]) => { const [x, y] = pointer(event, document.body); const pct = data.showArea ? (area / data.total) * 100 : 0; showTooltip(data.hoverText(bin, area, pct), x, y); }) .on("mouseout", hideTooltip); if (data.onClick) { hoverzone .filter(([bin]) => bin.length > 0) .attr("class", clickableClass) .on("click", (_event, [bin]) => data.onClick!(bin)); } } ================================================ FILE: ts/routes/graphs/hours.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import type { GraphsResponse } from "@generated/anki/stats_pb"; import type { GraphsResponse_Hours_Hour } from "@generated/anki/stats_pb"; import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; import { area, axisBottom, axisLeft, axisRight, curveBasis, interpolateBlues, pointer, scaleBand, scaleLinear, scaleSequential, select, } from "d3"; import type { GraphBounds } from "./graph-helpers"; import { GraphRange, setDataAvailable } from "./graph-helpers"; import { oddTickClass } from "./graph-styles"; import { hideTooltip, showTooltip } from "./tooltip-utils.svelte"; interface Hour { hour: number; totalCount: number; correctCount: number; } function gatherData(data: GraphsResponse, range: GraphRange): Hour[] { function convert(hours: GraphsResponse_Hours_Hour[]): Hour[] { return hours.map((e, idx) => { return { hour: idx, totalCount: e.total!, correctCount: e.correct! }; }); } switch (range) { case GraphRange.Month: return convert(data.hours!.oneMonth); case GraphRange.ThreeMonths: return convert(data.hours!.threeMonths); case GraphRange.Year: return convert(data.hours!.oneYear); case GraphRange.AllTime: return convert(data.hours!.allTime); } } export function renderHours( svgElem: SVGElement, bounds: GraphBounds, origData: GraphsResponse, range: GraphRange, ): void { const data = gatherData(origData, range); const yMax = Math.max(...data.map((d) => d.totalCount)); const svg = select(svgElem); const trans = svg.transition().duration(600) as any; if (!yMax) { setDataAvailable(svg, false); return; } else { setDataAvailable(svg, true); } const x = scaleBand() .domain(data.map((d) => d.hour.toString())) .range([bounds.marginLeft, bounds.width - bounds.marginRight]) .paddingInner(0.1); svg.select(".x-ticks") .call((selection) => selection.transition(trans).call(axisBottom(x).tickSizeOuter(0))) .selectAll(".tick") .selectAll("text") .classed(oddTickClass, (d: any): boolean => d % 2 != 0) .attr("direction", "ltr"); const cappedRange = scaleLinear().range([0.1, 0.8]); const colour = scaleSequential((n) => interpolateBlues(cappedRange(n)!)).domain([ 0, yMax, ]); // y scale const yTickFormat = (n: number): string => localizedNumber(n); const y = scaleLinear() .range([bounds.height - bounds.marginBottom, bounds.marginTop]) .domain([0, yMax]) .nice(); svg.select(".y-ticks") .call((selection) => selection.transition(trans).call( axisLeft(y) .ticks(bounds.height / 50) .tickSizeOuter(0) .tickFormat(yTickFormat as any), ) ) .attr("direction", "ltr"); const yArea = y.copy().domain([0, 1]); // x bars const updateBar = (sel: any): any => { return sel .attr("width", x.bandwidth()) .transition(trans) .attr("x", (d: Hour) => x(d.hour.toString())!) .attr("y", (d: Hour) => y(d.totalCount)!) .attr("height", (d: Hour) => y(0)! - y(d.totalCount)!) .attr("fill", (d: Hour) => colour(d.totalCount!)); }; svg.select("g.bars") .selectAll("rect") .data(data) .join( (enter) => enter .append("rect") .attr("rx", 1) .attr("x", (d: Hour) => x(d.hour.toString())!) .attr("y", y(0)!) .attr("height", 0) // .attr("opacity", 0.7) .call(updateBar), (update) => update.call(updateBar), (remove) => remove.call((remove) => remove.transition(trans).attr("height", 0).attr("y", y(0)!)), ); svg.select(".y2-ticks") .call((selection) => selection.transition(trans).call( axisRight(yArea) .ticks(bounds.height / 50) .tickFormat((n: any) => `${Math.round(n * 100)}%`) .tickSizeOuter(0), ) ) .attr("direction", "ltr"); svg.select("path.cumulative-overlay") .datum(data) .attr( "d", area() .curve(curveBasis) .defined((d) => d.totalCount > 0) .x((d: Hour) => { return x(d.hour.toString())! + x.bandwidth() / 2; }) .y0(bounds.height - bounds.marginBottom) .y1((d: Hour) => { const correctRatio = d.correctCount! / d.totalCount!; return yArea(isNaN(correctRatio) ? 0 : correctRatio)!; }), ); function tooltipText(d: Hour): string { const hour = tr.statisticsHoursRange({ hourStart: d.hour, hourEnd: d.hour + 1, }); const reviews = tr.statisticsHoursReviews({ reviews: d.totalCount }); const correct = tr.statisticsHoursCorrectReviews({ percent: d.totalCount ? (d.correctCount / d.totalCount) * 100 : 0, reviews: d.correctCount, }); return `${hour}
${reviews}
${correct}`; } // hover/tooltip svg.select("g.hover-columns") .selectAll("rect") .data(data) .join("rect") .attr("x", (d: Hour) => x(d.hour.toString())!) .attr("y", () => y(yMax)!) .attr("width", x.bandwidth()) .attr("height", () => y(0)! - y(yMax!)!) .on("mousemove", (event: MouseEvent, d: Hour) => { const [x, y] = pointer(event, document.body); showTooltip(tooltipText(d), x, y); }) .on("mouseout", hideTooltip); } ================================================ FILE: ts/routes/graphs/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import "./graphs-base.scss"; import { ModuleName, setupI18n } from "@tslib/i18n"; import { checkNightMode } from "@tslib/nightmode"; import type { Component } from "svelte"; import GraphsPage from "./GraphsPage.svelte"; const i18n = setupI18n({ modules: [ModuleName.STATISTICS, ModuleName.SCHEDULING] }); export async function setupGraphs( graphs: Component[], { search = "deck:current", days = 365, controller = null satisfies Component | null, } = {}, ): Promise { checkNightMode(); await i18n; return new GraphsPage({ target: document.body, props: { initialSearch: search, initialDays: days, graphs, controller, }, }); } import AddedGraph from "./AddedGraph.svelte"; import ButtonsGraph from "./ButtonsGraph.svelte"; import CalendarGraph from "./CalendarGraph.svelte"; import CardCounts from "./CardCounts.svelte"; import DifficultyGraph from "./DifficultyGraph.svelte"; import EaseGraph from "./EaseGraph.svelte"; import FutureDue from "./FutureDue.svelte"; import { RevlogRange } from "./graph-helpers"; import HourGraph from "./HourGraph.svelte"; import IntervalsGraph from "./IntervalsGraph.svelte"; import RangeBox from "./RangeBox.svelte"; import RetrievabilityGraph from "./RetrievabilityGraph.svelte"; import ReviewsGraph from "./ReviewsGraph.svelte"; import StabilityGraph from "./StabilityGraph.svelte"; import TodayStats from "./TodayStats.svelte"; export const graphComponents = { TodayStats, FutureDue, CalendarGraph, ReviewsGraph, CardCounts, IntervalsGraph, StabilityGraph, EaseGraph, DifficultyGraph, RetrievabilityGraph, HourGraph, ButtonsGraph, AddedGraph, RangeBox, RevlogRange, }; ================================================ FILE: ts/routes/graphs/intervals.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import type { GraphsResponse_Intervals } from "@generated/anki/stats_pb"; import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; import { timeSpan } from "@tslib/time"; import type { Bin } from "d3"; import { bin, extent, interpolateBlues, quantile, scaleLinear, scaleSequential, sum } from "d3"; import type { SearchDispatch, TableDatum } from "./graph-helpers"; import { numericMap } from "./graph-helpers"; import type { HistogramData } from "./histogram-graph"; export interface IntervalGraphData { intervals: number[]; } export enum IntervalRange { Month = 0, Percentile50 = 1, Percentile95 = 2, All = 3, } export function gatherIntervalData(data: GraphsResponse_Intervals): IntervalGraphData { // This could be made more efficient - this graph currently expects a flat list of individual intervals which it // uses to calculate a percentile and then converts into a histogram, and the percentile/histogram calculations // in JS are relatively slow. const map = numericMap(data.intervals); const totalCards = sum(map, ([_k, v]) => v); const allIntervals: number[] = Array(totalCards); let position = 0; for (const entry of map.entries()) { allIntervals.fill(entry[0], position, position + entry[1]); position += entry[1]; } allIntervals.sort((a, b) => a - b); return { intervals: allIntervals }; } export function intervalLabel( daysStart: number, daysEnd: number, cards: number, fsrs: boolean, ): string { if (daysEnd - daysStart <= 1) { // singular const fn = fsrs ? tr.statisticsStabilityDaySingle : tr.statisticsIntervalsDaySingle; return fn({ day: daysStart, cards, }); } else { // range const fn = fsrs ? tr.statisticsStabilityDayRange : tr.statisticsIntervalsDayRange; return fn({ daysStart, daysEnd: daysEnd - 1, cards, }); } } function makeSm2Query(start: number, end: number): string { if (start === end) { return `"prop:ivl=${start}"`; } const fromQuery = `"prop:ivl>=${start}"`; const tillQuery = `"prop:ivl<=${end}"`; return `${fromQuery} ${tillQuery}`; } function makeFsrsQuery(start: number, end: number): string { if (start === 0) { start = 0.5; } const fromQuery = `"prop:s>=${start - 0.5}"`; const tillQuery = `"prop:s<${end + 0.5}"`; return `${fromQuery} ${tillQuery}`; } export function prepareIntervalData( data: IntervalGraphData, range: IntervalRange, dispatch: SearchDispatch, browserLinksSupported: boolean, fsrs: boolean, ): [HistogramData | null, TableDatum[]] { // get min/max const allIntervals = data.intervals; if (!allIntervals.length) { return [null, []]; } const xMin = 0; let [, xMax] = extent(allIntervals); let niceNecessary = false; // cap max to selected range switch (range) { case IntervalRange.Month: xMax = Math.min(xMax!, 30); break; case IntervalRange.Percentile50: xMax = quantile(allIntervals, 0.5); niceNecessary = true; break; case IntervalRange.Percentile95: xMax = quantile(allIntervals, 0.95); niceNecessary = true; break; case IntervalRange.All: niceNecessary = true; break; } xMax = xMax! + 1; // do not show the zero interval for intervals const increment = fsrs ? x => x : (x: number): number => x + 1; const adjustTicks = (x: number, idx: number, ticks: number[]): number[] => idx === ticks.length - 1 ? [x - (ticks[0] - 1), x + 1] : [x - (ticks[0] - 1)]; // cap bars to available range const desiredBars = Math.min(70, xMax! - xMin!); const prescale = scaleLinear().domain([xMin!, xMax!]); const scale = scaleLinear().domain( (niceNecessary ? prescale.nice() : prescale).domain().map(increment), ); const bins = bin() .domain(scale.domain() as [number, number]) .thresholds(scale.ticks(desiredBars).flatMap(adjustTicks))(allIntervals); // empty graph? const totalInPeriod = sum(bins, (bin) => bin.length); if (!totalInPeriod) { return [null, []]; } const adjustedRange = scaleLinear().range([0.7, 0.3]); const colourScale = scaleSequential((n) => interpolateBlues(adjustedRange(n)!)).domain([xMax!, xMin!]); function hoverText( bin: Bin, _cumulative: number, percent: number, ): string { // const day = dayLabel(bin.x0!, bin.x1!); const interval = intervalLabel(bin.x0!, bin.x1!, bin.length, fsrs); const total = tr.statisticsRunningTotal(); return `${interval}
${total}: \u200e${localizedNumber(percent, 1)}%`; } function onClick(bin: Bin): void { const start = bin.x0!; const end = bin.x1! - 1; const query = (fsrs ? makeFsrsQuery : makeSm2Query)(start, end); dispatch("search", { query }); } const medianInterval = Math.round(quantile(allIntervals, 0.5) ?? 0); const medianIntervalString = timeSpan(medianInterval * 86400, false); const tableData = [ { label: fsrs ? tr.statisticsMedianStability() : tr.statisticsMedianInterval(), value: medianIntervalString, }, ]; return [ { scale, bins, total: totalInPeriod, hoverText, onClick: browserLinksSupported ? onClick : null, colourScale, showArea: true, }, tableData, ]; } ================================================ FILE: ts/routes/graphs/percentageRange.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors import { range, type ScaleLinear, scaleLinear, sum } from "d3"; // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export enum PercentageRangeEnum { All = 0, Percentile100 = 1, Percentile95 = 2, Percentile50 = 3, } export function PercentageRangeToQuantile(range: PercentageRangeEnum) { // These are halved because the quantiles are in both directions return ({ [PercentageRangeEnum.Percentile100]: 1, [PercentageRangeEnum.Percentile95]: 0.975, [PercentageRangeEnum.Percentile50]: 0.75, [PercentageRangeEnum.All]: undefined, })[range]; } export function easeQuantile(data: Map, quantile: number) { let count = sum(data.values()) * quantile; for (const [key, value] of data.entries()) { count -= value; if (count <= 0) { return key; } } } export function percentageRangeMinMax(data: Map, range: number | undefined) { const xMin = range ? easeQuantile(data, 1 - range) ?? 0 : 0; const xMax = range ? easeQuantile(data, range) ?? 0 : 100; return [xMin, xMax]; } export function getAdjustedScaleAndTicks( min: number, max: number, desiredBars: number, ): [ScaleLinear, number[]] { const prescale = scaleLinear().domain([min, max]).nice(); let ticks = prescale.ticks(desiredBars); const predomain = prescale.domain() as [number, number]; const minOffset = min - predomain[0]; let tickSize = ticks[1] - ticks[0]; const minBinSize = 1; if (tickSize < minBinSize) { ticks = range(min, max, minBinSize); tickSize = minBinSize; } if (minOffset === 0 || (minOffset % tickSize !== 0 && tickSize % minOffset !== 0)) { return [prescale, ticks]; } const add = (n: number): number => n + minOffset; return [ scaleLinear().domain(predomain.map(add) as [number, number]), ticks.map(add), ]; } ================================================ FILE: ts/routes/graphs/retrievability.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import type { GraphsResponse } from "@generated/anki/stats_pb"; import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; import type { Bin } from "d3"; import { bin, interpolateRdYlGn, scaleSequential, sum } from "d3"; import type { SearchDispatch, TableDatum } from "./graph-helpers"; import { getNumericMapBinValue, numericMap } from "./graph-helpers"; import type { HistogramData } from "./histogram-graph"; import { getAdjustedScaleAndTicks, percentageRangeMinMax } from "./percentageRange"; export interface GraphData { retrievability: Map; average: number; sumByCard: number; sumByNote: number; } export function gatherData(data: GraphsResponse): GraphData { return { retrievability: numericMap(data.retrievability!.retrievability), average: data.retrievability!.average, sumByCard: data.retrievability!.sumByCard, sumByNote: data.retrievability!.sumByNote, }; } function makeQuery(start: number, end: number): string { const fromQuery = `"prop:r>=${start / 100}"`; let tillQuery = `"prop:r<${(end + 1) / 100}"`; if (end === 99) { tillQuery = tillQuery.replace("<", "<="); } return `${fromQuery} AND ${tillQuery}`; } export function prepareData( data: GraphData, dispatch: SearchDispatch, browserLinksSupported: boolean, quantile?: number, ): [HistogramData | null, TableDatum[]] { // get min/max const allEases = data.retrievability; if (!allEases.size) { return [null, []]; } const [xMin, xMax] = percentageRangeMinMax(allEases, quantile); const desiredBars = 20; const [scale, ticks] = getAdjustedScaleAndTicks(xMin, xMax, desiredBars); const bins = bin() .value((m) => { return m[0]; }) .domain(scale.domain() as [number, number]) .thresholds(ticks)(allEases.entries() as any); const total = sum(bins as any, getNumericMapBinValue); const colourScale = scaleSequential(interpolateRdYlGn).domain([0, 100]); function hoverText(bin: Bin, _percent: number): string { const percent = `${bin.x0}%-${bin.x1}%`; return tr.statisticsRetrievabilityTooltip({ cards: getNumericMapBinValue(bin as any), percent, }); } function onClick(bin: Bin): void { const start = bin.x0!; const end = bin.x1! - 1; const query = makeQuery(start, end); dispatch("search", { query }); } const xTickFormat = (num: number): string => localizedNumber(num, 0) + "%"; const tableData = [ { label: tr.statisticsAverageRetrievability(), value: xTickFormat(data.average), }, { label: tr.statisticsEstimatedTotalKnowledge(), value: `${tr.statisticsCards({ cards: +data.sumByCard.toFixed(0) })} / ${ tr.statisticsNotes({ notes: +data.sumByNote.toFixed(0) }) }`, }, ]; return [ { scale, bins, total, hoverText, onClick: browserLinksSupported ? onClick : null, colourScale, showArea: false, binValue: getNumericMapBinValue, xTickFormat, }, tableData, ]; } ================================================ FILE: ts/routes/graphs/reviews.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import type { GraphsResponse } from "@generated/anki/stats_pb"; import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; import { dayLabel, timeSpan, TimespanUnit } from "@tslib/time"; import type { Bin, ScaleSequential } from "d3"; import { area, axisBottom, axisLeft, axisRight, bin, cumsum, curveBasis, interpolateGreens, interpolateOranges, interpolatePurples, interpolateReds, max, min, pointer, scaleLinear, scaleSequential, select, sum, } from "d3"; import type { GraphBounds, TableDatum } from "./graph-helpers"; import { GraphRange, numericMap, setDataAvailable } from "./graph-helpers"; import { hideTooltip, showTooltip } from "./tooltip-utils.svelte"; interface Reviews { learn: number; relearn: number; young: number; mature: number; filtered: number; } export interface GraphData { // indexed by day, where day is relative to today reviewCount: Map; reviewTime: Map; } type BinType = Bin, number>; export function gatherData(data: GraphsResponse): GraphData { return { reviewCount: numericMap(data.reviews!.count), reviewTime: numericMap(data.reviews!.time) }; } enum BinIndex { Mature = 0, Young = 1, Relearn = 2, Learn = 3, Filtered = 4, } function totalsForBin(bin: BinType): number[] { const total = [0, 0, 0, 0, 0]; for (const entry of bin) { total[BinIndex.Mature] += entry[1].mature; total[BinIndex.Young] += entry[1].young; total[BinIndex.Relearn] += entry[1].relearn; total[BinIndex.Learn] += entry[1].learn; total[BinIndex.Filtered] += entry[1].filtered; } return total; } /** eg idx=0 is mature count, idx=1 is mature+young count, etc */ function cumulativeBinValue(bin: BinType, idx: number): number { return sum(totalsForBin(bin).slice(0, idx + 1)); } export function renderReviews( svgElem: SVGElement, bounds: GraphBounds, sourceData: GraphData, range: GraphRange, showTime: boolean, ): TableDatum[] { const svg = select(svgElem); const trans = svg.transition().duration(600) as any; const xMax = 1; let xMin = 0; // cap max to selected range switch (range) { case GraphRange.Month: xMin = -30; break; case GraphRange.ThreeMonths: xMin = -89; break; case GraphRange.Year: xMin = -364; break; case GraphRange.AllTime: xMin = min(sourceData.reviewCount.keys())!; break; } const desiredBars = Math.min(70, Math.abs(xMin!)); const unboundRange = range == GraphRange.AllTime; const originalXMin = xMin!; // Create initial scale to determine tick spacing let x = scaleLinear().domain([xMin!, xMax]); let thresholds = x.ticks(desiredBars); // For unbound ranges, extend xMin backward so that the oldest bin has the same width as others if (unboundRange && thresholds.length >= 2) { const spacing = thresholds[1] - thresholds[0]; const partial = thresholds[0] - xMin!; if (spacing > 0 && partial > 0 && partial < spacing) { xMin = thresholds[0] - spacing; x = scaleLinear().domain([xMin, xMax]); thresholds = x.ticks(desiredBars); } } // For Year & All Time, shift thresholds forward by one day to make first bin 0-4 instead of 0-5 if (range === GraphRange.Year || range === GraphRange.AllTime) { thresholds = [...new Set(thresholds.map(t => Math.min(t + 1, 1)))].sort((a, b) => a - b); } const sourceMap = showTime ? sourceData.reviewTime : sourceData.reviewCount; const bins = bin() .value((m) => m[0]) .domain(x.domain() as any) .thresholds(thresholds)(sourceMap.entries() as any); // empty graph? const totalDays = sum(bins, (bin) => bin.length); if (!totalDays) { setDataAvailable(svg, false); return []; } else { setDataAvailable(svg, true); } x.range([bounds.marginLeft, bounds.width - bounds.marginRight]); svg.select(".x-ticks") .call((selection) => selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0))) .attr("direction", "ltr"); // y scale const yTickFormat = (n: number): string => { if (showTime) { return timeSpan(n / 1000, true, true, TimespanUnit.Hours); } else { if (Math.round(n) != n) { return ""; } else { return localizedNumber(n); } } }; const yMax = max(bins, (b: Bin) => cumulativeBinValue(b, 4))!; const y = scaleLinear() .range([bounds.height - bounds.marginBottom, bounds.marginTop]) .domain([0, yMax]) .nice(); svg.select(".y-ticks") .call((selection) => selection.transition(trans).call( axisLeft(y) .ticks(bounds.height / 50) .tickSizeOuter(0) .tickFormat(yTickFormat as any), ) ) .attr("direction", "ltr"); // x bars function barWidth(d: Bin): number { const width = Math.max(0, x(d.x1!) - x(d.x0!) - 1); return width ?? 0; } const cappedRange = scaleLinear().range([0.3, 0.5]); const shiftedRange = scaleLinear().range([0.4, 0.7]); const darkerGreens = scaleSequential((n) => interpolateGreens(shiftedRange(n)!)).domain(x.domain() as any); const lighterGreens = scaleSequential((n) => interpolateGreens(cappedRange(n)!)).domain(x.domain() as any); const reds = scaleSequential((n) => interpolateReds(cappedRange(n)!)).domain( x.domain() as any, ); const oranges = scaleSequential((n) => interpolateOranges(cappedRange(n)!)).domain( x.domain() as any, ); const purples = scaleSequential((n) => interpolatePurples(cappedRange(n)!)).domain( x.domain() as any, ); function binColor(idx: BinIndex): ScaleSequential { switch (idx) { case BinIndex.Mature: return darkerGreens; case BinIndex.Young: return lighterGreens; case BinIndex.Learn: return oranges; case BinIndex.Relearn: return reds; case BinIndex.Filtered: return purples; } } function valueLabel(n: number): string { if (showTime) { return timeSpan(n / 1000, false, true, TimespanUnit.Hours); } else { return tr.statisticsReviews({ reviews: n }); } } function tooltipText(d: BinType, cumulative: number): string { // Convert bin boundaries [x0, x1) for dayLabel // If bin ends at 0, treat it as crossing zero so day 0 is included // For the first (oldest) bin, use the original xMin to ensure labels match the intended range const isFirstBin = bins.length > 0 && d.x0 === bins[0].x0; const startDay = isFirstBin ? originalXMin : Math.floor(d.x0!); const endDay = d.x1! === 0 ? 1 : d.x1!; const day = dayLabel(startDay, endDay); const totals = totalsForBin(d); const dayTotal = valueLabel(sum(totals)); let buf = ``; const lines: [BinIndex | null, string][] = [ [BinIndex.Filtered, tr.statisticsCountsFilteredCards()], [BinIndex.Learn, tr.statisticsCountsLearningCards()], [BinIndex.Relearn, tr.statisticsCountsRelearningCards()], [BinIndex.Young, tr.statisticsCountsYoungCards()], [BinIndex.Mature, tr.statisticsCountsMatureCards()], [null, tr.statisticsRunningTotal()], ]; for (const [idx, label] of lines) { let color: string; let detail: string; if (idx == null) { color = "transparent"; detail = valueLabel(cumulative); } else { color = binColor(idx)(1); detail = valueLabel(totals[idx]); } buf += ``; } return buf; } const updateBar = (sel: any, idx: number): any => { return sel .attr("width", barWidth) .transition(trans) .attr("x", (d: any) => x(d.x0)) .attr("y", (d: any) => y(cumulativeBinValue(d, idx))!) .attr("height", (d: any) => y(0)! - y(cumulativeBinValue(d, idx))!) .attr("fill", (d: any) => binColor(idx)(d.x0)); }; for (const barNum of [0, 1, 2, 3, 4]) { svg.select(`g.bars${barNum}`) .selectAll("rect") .data(bins) .join( (enter) => enter .append("rect") .attr("rx", 1) .attr("x", (d: any) => x(d.x0)!) .attr("y", y(0)!) .attr("height", 0) .call((d) => updateBar(d, barNum)), (update) => update.call((d) => updateBar(d, barNum)), (remove) => remove.call((remove) => remove.transition(trans).attr("height", 0).attr("y", y(0)!)), ); } // cumulative area const areaCounts = bins.map((d: any) => cumulativeBinValue(d, 4)); areaCounts.unshift(0); const areaData = cumsum(areaCounts); const yCumMax = areaData.slice(-1)[0]; const yAreaScale = y.copy().domain([0, yCumMax]).nice(); if (yCumMax) { svg.select(".y2-ticks") .call((selection) => selection.transition(trans).call( axisRight(yAreaScale) .ticks(bounds.height / 50) .tickFormat(yTickFormat as any) .tickSizeOuter(0), ) ) .attr("direction", "ltr"); svg.select("path.cumulative-overlay") .datum(areaData) .attr( "d", area() .curve(curveBasis) .x((_d: [number, number], idx: number) => { if (idx === 0) { return x(bins[0].x0!)!; } else { return x(bins[idx - 1].x1!)!; } }) .y0(bounds.height - bounds.marginBottom) .y1((d: any) => yAreaScale(d)!) as any, ); } const hoverData: [Bin, number][] = bins.map( (bin: Bin, index: number) => [bin, areaData[index + 1]], ); // hover/tooltip svg.select("g.hover-columns") .selectAll("rect") .data(hoverData) .join("rect") .attr("x", ([bin]) => x(bin.x0!)) .attr("y", () => y(yMax)) .attr("width", ([bin]) => barWidth(bin)) .attr("height", () => y(0) - y(yMax)) .on("mousemove", (event: MouseEvent, [bin, area]): void => { const [x, y] = pointer(event, document.body); showTooltip(tooltipText(bin as any, area), x, y); }) .on("mouseout", hideTooltip); // The xMin might be extended for bin alignment, so use the original xMin const periodDays = -originalXMin + 1; const studiedDays = sum(bins, (bin) => bin.length); const studiedPercent = (studiedDays / periodDays) * 100; const total = yCumMax; const periodAvg = total / periodDays; const studiedAvg = total / studiedDays; let totalString: string, averageForDaysStudied: string, averageForPeriod: string, averageAnswerTime: string, averageAnswerTimeLabel: string; if (showTime) { totalString = timeSpan(total / 1000, false, true, TimespanUnit.Hours); averageForDaysStudied = tr.statisticsMinutesPerDay({ count: Math.round(studiedAvg / 1000 / 60), }); averageForPeriod = tr.statisticsMinutesPerDay({ count: Math.round(periodAvg / 1000 / 60), }); averageAnswerTimeLabel = tr.statisticsAverageAnswerTimeLabel(); // need to get total review count to calculate average time const countBins = bin() .value((m) => { return m[0]; }) .domain(x.domain() as any)(sourceData.reviewCount.entries() as any); const totalReviews = sum(countBins, (bin) => cumulativeBinValue(bin as any, 4)); const totalSecs = total / 1000; const avgSecs = totalSecs / totalReviews; const cardsPerMin = (totalReviews * 60) / totalSecs; averageAnswerTime = tr.statisticsAverageAnswerTime({ averageSeconds: avgSecs, cardsPerMinute: cardsPerMin, }); } else { totalString = tr.statisticsReviews({ reviews: total }); averageForDaysStudied = tr.statisticsReviewsPerDay({ count: Math.round(studiedAvg), }); averageForPeriod = tr.statisticsReviewsPerDay({ count: Math.round(periodAvg), }); averageAnswerTime = averageAnswerTimeLabel = ""; } const tableData: TableDatum[] = [ { label: tr.statisticsDaysStudied(), value: tr.statisticsAmountOfTotalWithPercentage({ amount: studiedDays, total: periodDays, percent: (() => { if (studiedPercent < 99.5) { return localizedNumber(studiedPercent); } else if (studiedPercent < 99.95) { return localizedNumber(studiedPercent, 1); } else if (studiedPercent < 100) { return localizedNumber(studiedPercent, 2); } else { return "100"; } })(), }), }, { label: tr.statisticsTotal(), value: totalString }, { label: tr.statisticsAverageOverPeriod(), value: averageForPeriod, }, ]; if (studiedPercent < 100) { tableData.push({ label: tr.statisticsAverageForDaysStudied(), value: averageForDaysStudied, }); } if (averageAnswerTime) { tableData.push({ label: averageAnswerTimeLabel, value: averageAnswerTime }); } return tableData; } ================================================ FILE: ts/routes/graphs/simulator.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { createLocaleNumberFormat, localizedDate } from "@tslib/i18n"; import { axisBottom, axisLeft, bisector, line, max, pointer, rollup, scaleLinear, scaleTime, schemeCategory10, select, } from "d3"; import * as tr from "@generated/ftl"; import { timeSpan } from "@tslib/time"; import { sumBy } from "lodash-es"; import type { GraphBounds, TableDatum } from "./graph-helpers"; import { setDataAvailable } from "./graph-helpers"; import { hideTooltip, showTooltip } from "./tooltip-utils.svelte"; export interface Point { x: number; timeCost: number; count: number; memorized: number; label: number; } export type WorkloadPoint = Point & { learnSpan: number; }; export enum SimulateSubgraph { time, count, memorized, } export enum SimulateWorkloadSubgraph { ratio, time, count, memorized, } export function renderWorkloadChart( svgElem: SVGElement, bounds: GraphBounds, data: WorkloadPoint[], subgraph: SimulateWorkloadSubgraph, ) { const xMin = 70; const xMax = 99; const x = scaleLinear() .domain([xMin, xMax]) .range([bounds.marginLeft, bounds.width - bounds.marginRight]); const subgraph_data = ({ [SimulateWorkloadSubgraph.ratio]: data.map(d => ({ ...d, y: d.timeCost / d.memorized })), [SimulateWorkloadSubgraph.time]: data.map(d => ({ ...d, y: d.timeCost / d.learnSpan })), [SimulateWorkloadSubgraph.count]: data.map(d => ({ ...d, y: d.count / d.learnSpan })), [SimulateWorkloadSubgraph.memorized]: data.map(d => ({ ...d, y: d.memorized })), })[subgraph]; const yTickFormat = (n: number): string => { return subgraph == SimulateWorkloadSubgraph.time || subgraph == SimulateWorkloadSubgraph.ratio ? timeSpan(n, true) : n.toString(); }; const formatter = createLocaleNumberFormat({ style: "percent", minimumFractionDigits: 0, maximumFractionDigits: 0, }); const xTickFormat = (n: number) => formatter.format(n / 100); const formatY: (value: number) => string = ({ [SimulateWorkloadSubgraph.ratio]: (value: number) => tr.deckConfigFsrsSimulatorRatioTooltip({ time: timeSpan(value) }), [SimulateWorkloadSubgraph.time]: (value: number) => tr.statisticsMinutesPerDay({ count: parseFloat((value / 60).toPrecision(2)) }), [SimulateWorkloadSubgraph.count]: (value: number) => tr.statisticsReviewsPerDay({ count: Math.round(value) }), [SimulateWorkloadSubgraph.memorized]: (value: number) => tr.statisticsMemorized({ memorized: Math.round(value).toFixed(0) }), })[subgraph]; function formatX(dr: number) { return `${tr.deckConfigDesiredRetention()}: ${xTickFormat(dr)}
`; } return _renderSimulationChart( svgElem, bounds, subgraph_data, x, formatY, formatX, (_e: MouseEvent, _d: number) => undefined, yTickFormat, xTickFormat, ); } export function renderSimulationChart( svgElem: SVGElement, bounds: GraphBounds, data: Point[], subgraph: SimulateSubgraph, ): TableDatum[] { const today = new Date(); const convertedData = data.map(d => ({ ...d, x: new Date(today.getTime() + d.x * 24 * 60 * 60 * 1000), })); const subgraph_data = ({ [SimulateSubgraph.count]: convertedData.map(d => ({ ...d, y: d.count })), [SimulateSubgraph.time]: convertedData.map(d => ({ ...d, y: d.timeCost })), [SimulateSubgraph.memorized]: convertedData.map(d => ({ ...d, y: d.memorized })), })[subgraph]; const xMin = today; const xMax = max(subgraph_data, d => d.x); const x = scaleTime() .domain([xMin, xMax!]) .range([bounds.marginLeft, bounds.width - bounds.marginRight]); const yTickFormat = (n: number): string => { return subgraph == SimulateSubgraph.time ? timeSpan(n, true) : n.toString(); }; const formatY: (value: number) => string = ({ [SimulateSubgraph.time]: timeSpan, [SimulateSubgraph.count]: (value: number) => tr.statisticsReviews({ reviews: Math.round(value) }), [SimulateSubgraph.memorized]: (value: number) => tr.statisticsMemorized({ memorized: Math.round(value).toFixed(0) }), })[subgraph]; const perDay = ({ [SimulateSubgraph.count]: tr.statisticsReviewsPerDay, [SimulateSubgraph.time]: ({ count }: { count: number }) => timeSpan(count), [SimulateSubgraph.memorized]: tr.statisticsCardsPerDay, })[subgraph]; function legendMouseMove(e: MouseEvent, d: number) { const data = subgraph_data.filter(datum => datum.label == d); const total = subgraph == SimulateSubgraph.memorized ? data[data.length - 1].memorized - data[0].memorized : sumBy(data, d => d.y); const average = total / (data?.length || 1); showTooltip( `#${d}:
${tr.statisticsAverage()}: ${perDay({ count: average })}
${tr.statisticsTotal()}: ${formatY(total)}`, e.pageX, e.pageY, ); } function formatX(date: Date) { const days = +((date.getTime() - Date.now()) / (60 * 60 * 24 * 1000)).toFixed(); return `Date: ${localizedDate(date)}
In ${days} Days
`; } return _renderSimulationChart( svgElem, bounds, subgraph_data, x, formatY, formatX, legendMouseMove, yTickFormat, undefined, ); } function _renderSimulationChart( svgElem: SVGElement, bounds: GraphBounds, subgraph_data: T[], x: any, formatY: (n: T["y"]) => string, formatX: (n: T["x"]) => string, legendMouseMove: (e: MouseEvent, d: number) => void, yTickFormat?: (n: number) => string, xTickFormat?: (n: number) => string, ): TableDatum[] { const svg = select(svgElem); svg.selectAll(".lines").remove(); svg.selectAll(".hover-columns").remove(); svg.selectAll(".focus-line").remove(); svg.selectAll(".legend").remove(); if (subgraph_data.length == 0) { setDataAvailable(svg, false); return []; } const trans = svg.transition().duration(600) as any; svg.select(".x-ticks") .call((selection) => selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0).tickFormat(xTickFormat as any)) ) .attr("direction", "ltr"); // y scale const yMax = max(subgraph_data, d => d.y)!; const y = scaleLinear() .range([bounds.height - bounds.marginBottom, bounds.marginTop]) .domain([0, yMax]) .nice(); svg.select(".y-ticks") .call((selection) => selection.transition(trans).call( axisLeft(y) .ticks(bounds.height / 50) .tickSizeOuter(0) .tickFormat(yTickFormat as any), ) ) .attr("direction", "ltr"); svg.select(".y-ticks .y-axis-title").remove(); svg.select(".y-ticks") .append("text") .attr("class", "y-axis-title") .attr("transform", "rotate(-90)") .attr("y", 0 - bounds.marginLeft) .attr("x", 0 - (bounds.height / 2)) .attr("font-size", "1rem") .attr("dy", "1.1em") .attr("fill", "currentColor"); // x lines const points = subgraph_data.map((d) => [x(d.x), y(d.y), d.label]); const groups = rollup(points, v => Object.assign(v, { z: v[0][2] }), d => d[2]); const color = schemeCategory10; svg.append("g") .attr("class", "lines") .attr("fill", "none") .attr("stroke-width", 1.5) .attr("stroke-linejoin", "round") .attr("stroke-linecap", "round") .selectAll("path") .data(Array.from(groups.entries())) .join("path") .attr("vector-effect", "non-scaling-stroke") .attr("stroke", (d, i) => color[i % color.length]) .attr("d", d => line()(d[1].map(p => [p[0], p[1]]))) .attr("data-group", d => d[0]); const focusLine = svg.append("line") .attr("class", "focus-line") .attr("y1", bounds.marginTop) .attr("y2", bounds.height - bounds.marginBottom) .attr("stroke", "black") .attr("stroke-width", 1) .style("opacity", 0); const LongestGroupData = Array.from(groups.values()).reduce((a, b) => a.length > b.length ? a : b); const barWidth = bounds.width / LongestGroupData.length; // hover/tooltip svg.append("g") .attr("class", "hover-columns") .selectAll("rect") .data(LongestGroupData) .join("rect") .attr("x", d => d[0] - barWidth / 2) .attr("y", bounds.marginTop) .attr("width", barWidth) .attr("height", bounds.height - bounds.marginTop - bounds.marginBottom) .attr("fill", "transparent") .on("mousemove", mousemove) .on("mouseout", () => { focusLine.style("opacity", 0); hideTooltip(); }); function mousemove(event: MouseEvent, d: any): void { pointer(event, document.body); const date = x.invert(d[0]); const groupData: { [key: string]: number } = {}; groups.forEach((groupPoints, key) => { const bisect = bisector((d: number[]) => x.invert(d[0])).left; const index = bisect(groupPoints, date); const dataPoint = groupPoints[index]; if (dataPoint) { groupData[key] = y.invert(dataPoint[1]); } }); focusLine.attr("x1", d[0]).attr("x2", d[0]).style("opacity", 1); let tooltipContent = formatX(date); for (const [key, value] of Object.entries(groupData)) { const path = svg.select(`path[data-group="${key}"]`); const hidden = path.classed("hidden"); if (!hidden) { tooltipContent += ` #${key}: ${ formatY(value) }
`; } } showTooltip(tooltipContent, event.pageX, event.pageY); } const legend = svg.append("g") .attr("class", "legend") .attr("font-family", "sans-serif") .attr("font-size", 10) .attr("text-anchor", "start") .selectAll("g") .data(Array.from(groups.keys())) .join("g") .attr("transform", (d, i) => `translate(0,${i * 20})`) .attr("cursor", "pointer") .on("click", (event, d) => toggleGroup(event, d)) .on("mousemove", legendMouseMove) .on("mouseout", hideTooltip); legend.append("rect") .attr("x", bounds.width - bounds.marginRight + 36) .attr("width", 12) .attr("height", 12) .attr("fill", (d, i) => color[i % color.length]); legend.append("text") .attr("x", bounds.width - bounds.marginRight + 52) .attr("y", 7) .attr("dy", "0.3em") .attr("fill", "currentColor") .text(d => `#${d}`); const toggleGroup = (event: MouseEvent, d: number) => { const group = d; const path = svg.select(`path[data-group="${group}"]`); const hidden = path.classed("hidden"); const target = event.currentTarget as HTMLElement; path.classed("hidden", !hidden); path.style("display", () => hidden ? null : "none"); select(target).select("rect") .style("opacity", hidden ? 1 : 0.5); }; setDataAvailable(svg, true); const tableData: TableDatum[] = []; return tableData; } ================================================ FILE: ts/routes/graphs/today.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { GraphsResponse } from "@generated/anki/stats_pb"; import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; import { studiedToday } from "@tslib/time"; export interface TodayData { title: string; lines: string[]; } export function gatherData(data: GraphsResponse): TodayData { let lines: string[]; const today = data.today!; if (today.answerCount) { const studiedTodayText = studiedToday(today.answerCount, today.answerMillis / 1000); const againCount = today.answerCount - today.correctCount; let againCountText = tr.statisticsTodayAgainCount(); againCountText += ` ${againCount} (${ localizedNumber( (againCount / today.answerCount) * 100, ) }%)`; const typeCounts = tr.statisticsTodayTypeCounts({ learnCount: today.learnCount, reviewCount: today.reviewCount, relearnCount: today.relearnCount, filteredCount: today.earlyReviewCount, }); let matureText: string; if (today.matureCount) { matureText = tr.statisticsTodayCorrectMature({ correct: today.matureCorrect, total: today.matureCount, percent: (today.matureCorrect / today.matureCount) * 100, }); } else { matureText = tr.statisticsTodayNoMatureCards(); } lines = [studiedTodayText, againCountText, typeCounts, matureText]; } else { lines = [tr.statisticsTodayNoCards()]; } return { title: tr.statisticsTodayTitle(), lines, }; } ================================================ FILE: ts/routes/graphs/tooltip-utils.svelte.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { DebouncedFunc } from "lodash-es"; import { throttle } from "lodash-es"; import { mount } from "svelte"; import Tooltip from "./Tooltip.svelte"; type TooltipProps = { html: string; x: number; y: number; show: boolean; }; let tooltip: Record | null = null; let props: TooltipProps = { html: "", x: 0, y: 0, show: false }; function getOrCreateTooltip(): TooltipProps { if (tooltip) { return props; } const target = document.createElement("div"); const p = $state(props); props = p; tooltip = mount(Tooltip, { target, props }); document.body.appendChild(target); return props; } function showTooltipInner(msg: string, x: number, y: number): void { const props = getOrCreateTooltip(); props.html = msg; props.x = x; props.y = y; props.show = true; } export const showTooltip: DebouncedFunc<(msg: string, x: number, y: number) => void> = throttle(showTooltipInner, 16); export function hideTooltip(): void { const props = getOrCreateTooltip(); showTooltip.cancel(); props.show = false; } ================================================ FILE: ts/routes/graphs/true-retention.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import * as tr from "@generated/ftl"; import { createLocaleNumberFormat } from "@tslib/i18n"; import { assertUnreachable } from "@tslib/typing"; import { RevlogRange } from "./graph-helpers"; export interface TrueRetentionData { youngPassed: number; youngFailed: number; maturePassed: number; matureFailed: number; } export interface PeriodTrueRetentionData { today: TrueRetentionData; yesterday: TrueRetentionData; week: TrueRetentionData; month: TrueRetentionData; year: TrueRetentionData; allTime: TrueRetentionData; } export enum DisplayMode { Young, Mature, All, Summary, } export enum Scope { Young, Mature, All, } export function getPassed(data: TrueRetentionData, scope: Scope): number { switch (scope) { case Scope.Young: return data.youngPassed; case Scope.Mature: return data.maturePassed; case Scope.All: return data.youngPassed + data.maturePassed; default: assertUnreachable(scope); } } export function getFailed(data: TrueRetentionData, scope: Scope): number { switch (scope) { case Scope.Young: return data.youngFailed; case Scope.Mature: return data.matureFailed; case Scope.All: return data.youngFailed + data.matureFailed; default: assertUnreachable(scope); } } export interface RowData { title: string; data: TrueRetentionData; } export function getRowData( allData: PeriodTrueRetentionData, revlogRange: RevlogRange, ): RowData[] { const rowData: RowData[] = [ { title: tr.statisticsTrueRetentionToday(), data: allData.today, }, { title: tr.statisticsTrueRetentionYesterday(), data: allData.yesterday, }, { title: tr.statisticsTrueRetentionWeek(), data: allData.week, }, { title: tr.statisticsTrueRetentionMonth(), data: allData.month, }, { title: tr.statisticsTrueRetentionYear(), data: allData.year, }, ]; if (revlogRange === RevlogRange.All) { rowData.push({ title: tr.statisticsTrueRetentionAllTime(), data: allData.allTime, }); } return rowData; } export function calculateRetentionPercentageString( passed: number, failed: number, ): string { const total = passed + failed; if (total === 0) { return tr.statisticsTrueRetentionNotApplicable(); } const numberFormat = createLocaleNumberFormat({ minimumFractionDigits: 1, maximumFractionDigits: 1, style: "percent", }); return numberFormat.format(passed / total); } ================================================ FILE: ts/routes/image-occlusion/ImageOcclusionPage.svelte ================================================
{#each items as item} {/each}
================================================ FILE: ts/routes/image-occlusion/ImageOcclusionPicker.svelte ================================================
{tr.notetypesIoSelectImage()}
{tr.notetypesIoPasteImageFromClipboard()}
================================================ FILE: ts/routes/image-occlusion/MaskEditor.svelte ================================================
================================================ FILE: ts/routes/image-occlusion/Notes.svelte ================================================
{#each notesFields as field}
{field.title}
{ field.textareaValue = field.divValue; }} contenteditable >
{/each}
================================================ FILE: ts/routes/image-occlusion/StickyFooter.svelte ================================================
================================================ FILE: ts/routes/image-occlusion/Tags.svelte ================================================ { globalTags = detail.tags; tagsWritable.set(globalTags); }} keyCombination={"Control+T"} /> ================================================ FILE: ts/routes/image-occlusion/Toast.svelte ================================================ {#if showToast}
{message}
{/if} ================================================ FILE: ts/routes/image-occlusion/Toolbar.svelte ================================================ {#each $customColorPickerPalette as colour} {/each} ($colour = e.currentTarget!.value)} on:change={() => saveCustomColours({})} />
{#each tools as tool} {@const active = activeTool == tool.id} { activeTool = tool.id; handleToolChanges(activeTool, true); }} > {#if $ioMaskEditorVisible && !$textEditingState} { activeTool = tool.id; handleToolChanges(activeTool, true); }} /> {/if} {/each}
(showFloating = false)} > (showFloating = !showFloating)} > changeOcclusionType("all")} > {tr.notetypesHideAllGuessOne()} changeOcclusionType("one")} > {tr.notetypesHideOneGuessOne()}
{#each undoRedoTools as tool} { tool.action(); handleToolChanges(activeTool); }} tooltip="{tool.tooltip()} ({getPlatformString(tool.shortcut)})" disabled={tool.name === "undo" ? !$undoStack.undoable : !$undoStack.redoable} > {#if $ioMaskEditorVisible && !$textEditingState} {/if} {/each}
{#each zoomTools as tool} { tool.action(canvas); }} > {#if $ioMaskEditorVisible && !$textEditingState} { tool.action(canvas); }} /> {/if} {/each}
{ maskOpacity = !maskOpacity; makeMaskTransparent(canvas, maskOpacity); }} > {#if $ioMaskEditorVisible && !$textEditingState} { maskOpacity = !maskOpacity; makeMaskTransparent(canvas, maskOpacity); }} /> {/if} {#each deleteDuplicateTools as tool} { tool.action(canvas); undoStack.onObjectModified(); }} > {#if $ioMaskEditorVisible && !$textEditingState} { tool.action(canvas); saveNeededStore.set(true); }} /> {/if} {/each}
{#each groupUngroupTools as tool} { tool.action(canvas); undoStack.onObjectModified(); }} > {#if $ioMaskEditorVisible && !$textEditingState} { tool.action(canvas); saveNeededStore.set(true); }} /> {/if} {/each} { showAlignTools = !showAlignTools; leftPos = e.pageX - 100; }} >
================================================ FILE: ts/routes/image-occlusion/[...imagePathOrNoteId]/+page.svelte ================================================ ================================================ FILE: ts/routes/image-occlusion/[...imagePathOrNoteId]/+page.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { get } from "svelte/store"; import { addOrUpdateNote } from "../add-or-update-note.svelte"; import type { IOMode } from "../lib"; import { hideAllGuessOne } from "../store"; import type { PageLoad } from "./$types"; async function save(): Promise { addOrUpdateNote(globalThis["anki"].imageOcclusion.mode, get(hideAllGuessOne)); } export const load = (async ({ params }) => { let mode: IOMode; if (/^\d+/.test(params.imagePathOrNoteId)) { mode = { kind: "edit", noteId: Number(params.imagePathOrNoteId) }; } else { mode = { kind: "add", imagePath: params.imagePathOrNoteId, notetypeId: 0 }; } // for adding note from mobile devices globalThis.anki = globalThis.anki || {}; globalThis.anki.imageOcclusion = { mode, save, }; return { mode, }; }) satisfies PageLoad; ================================================ FILE: ts/routes/image-occlusion/add-or-update-note.svelte.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { OpChanges } from "@generated/anki/collection_pb"; import { addImageOcclusionNote, updateImageOcclusionNote } from "@generated/backend"; import * as tr from "@generated/ftl"; import { get } from "svelte/store"; import { mount } from "svelte"; import type { IOAddingMode, IOMode } from "./lib"; import { exportShapesToClozeDeletions } from "./shapes/to-cloze"; import { notesDataStore, tagsWritable } from "./store"; import Toast from "./Toast.svelte"; export const addOrUpdateNote = async function( mode: IOMode, occludeInactive: boolean, ): Promise { const { clozes: occlusionCloze, noteCount } = exportShapesToClozeDeletions(occludeInactive); if (noteCount === 0) { return; } const fieldsData: { id: string; title: string; divValue: string; textareaValue: string }[] = get(notesDataStore); const tags = get(tagsWritable); let header = fieldsData[0].textareaValue; let backExtra = fieldsData[1].textareaValue; header = header ? `
${header}
` : ""; backExtra = backExtra ? `
${backExtra}
` : ""; if (mode.kind == "edit") { const result = await updateImageOcclusionNote({ noteId: BigInt(mode.noteId), occlusions: occlusionCloze, header, backExtra, tags, }); if (result.note) { showResult(mode.noteId, result, noteCount); } } else { const result = await addImageOcclusionNote({ // IOCloningMode is not used on mobile notetypeId: BigInt(( mode).notetypeId), imagePath: ( mode).imagePath, occlusions: occlusionCloze, header, backExtra, tags, }); showResult(null, result, noteCount); } }; // show toast message const showResult = (noteId: number | null, result: OpChanges, count: number) => { const props = $state({ message: noteId ? tr.browsingCardsUpdated({ count: count }) : tr.importingCardsAdded({ count: count }), type: "success" as "error" | "success", showToast: true, }); mount(Toast, { target: document.body, props, }); }; ================================================ FILE: ts/routes/image-occlusion/canvas-scale.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { Size } from "./types"; /** * - Choose an appropriate size for the canvas based on the current container, * so the masks are sharp and legible. * - Safari doesn't allow canvas elements to be over 16M (4096x4096), so we need * to ensure the canvas is smaller than that size. * - Returns the size in actual pixels, not CSS size. */ export function optimumPixelSizeForCanvas(imageSize: Size, containerSize: Size): Size { let { width, height } = imageSize; const pixelScale = window.devicePixelRatio; containerSize.width *= pixelScale; containerSize.height *= pixelScale; // Scale image dimensions to fit in container, retaining aspect ratio. // We take the minimum of width/height scales, as that's the one that is // potentially limiting the image from expanding. const containerScale = Math.min(containerSize.width / imageSize.width, containerSize.height / imageSize.height); width *= containerScale; height *= containerScale; const maximumPixels = 4096 * 4096; const requiredPixels = width * height; if (requiredPixels > maximumPixels) { const shrinkScale = Math.sqrt(maximumPixels) / Math.sqrt(requiredPixels); width *= shrinkScale; height *= shrinkScale; } return { width: Math.floor(width), height: Math.floor(height), }; } /** See {@link optimumPixelSizeForCanvas()} */ export function optimumCssSizeForCanvas(imageSize: Size, containerSize: Size): Size { const { width, height } = optimumPixelSizeForCanvas(imageSize, containerSize); return { width: width / window.devicePixelRatio, height: height / window.devicePixelRatio, }; } ================================================ FILE: ts/routes/image-occlusion/fabric.d.ts ================================================ export {}; declare global { namespace fabric { interface Object { id: string; ordinal: number; /** a custom property set on groups in the ungrouping routine to avoid adding a spurious undo entry */ destroyed: boolean; } } } ================================================ FILE: ts/routes/image-occlusion/image-occlusion-base.scss ================================================ @use "../lib/sass/vars"; @use "../lib/sass/bootstrap-dark"; @import "../lib/sass/base"; @import "bootstrap/scss/alert"; @import "bootstrap/scss/buttons"; @import "bootstrap/scss/button-group"; @import "bootstrap/scss/close"; @import "bootstrap/scss/grid"; @import "../lib/sass/bootstrap-forms"; .night-mode { @include bootstrap-dark.night-mode; } html { overflow: hidden; } /** consistent font size **/ :root { --font-size: 16px; } body { font-size: 16px; } ================================================ FILE: ts/routes/image-occlusion/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import "./image-occlusion-base.scss"; import { ModuleName, setupI18n } from "@tslib/i18n"; import { checkNightMode } from "@tslib/nightmode"; import { get } from "svelte/store"; import { addOrUpdateNote } from "./add-or-update-note.svelte"; import ImageOcclusionPage from "./ImageOcclusionPage.svelte"; import type { IOMode } from "./lib"; import { hideAllGuessOne } from "./store"; globalThis.anki = globalThis.anki || {}; const i18n = setupI18n({ modules: [ ModuleName.IMPORTING, ModuleName.DECKS, ModuleName.EDITING, ModuleName.NOTETYPES, ModuleName.ACTIONS, ModuleName.BROWSING, ModuleName.UNDO, ], }); export async function setupImageOcclusion(mode: IOMode, target = document.body): Promise { checkNightMode(); await i18n; async function addNote(): Promise { addOrUpdateNote(mode, get(hideAllGuessOne)); } // for adding note from mobile devices globalThis.anki.imageOcclusion = { mode, addNote, }; return new ImageOcclusionPage({ target: target, props: { mode, }, }); } if (window.location.hash.startsWith("#test-")) { const imagePath = window.location.hash.replace("#test-", ""); setupImageOcclusion({ kind: "add", imagePath, notetypeId: 0 }); } if (window.location.hash.startsWith("#testforedit-")) { const noteId = parseInt(window.location.hash.replace("#testforedit-", "")); setupImageOcclusion({ kind: "edit", noteId }); } ================================================ FILE: ts/routes/image-occlusion/lib.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export interface IOAddingMode { kind: "add"; notetypeId: number; imagePath: string; } export interface IOCloningMode { kind: "add"; clonedNoteId: number; } export interface IOEditingMode { kind: "edit"; noteId: number; } export type IOMode = IOAddingMode | IOEditingMode | IOCloningMode; ================================================ FILE: ts/routes/image-occlusion/mask-editor.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { protoBase64 } from "@bufbuild/protobuf"; import { getImageForOcclusion, getImageOcclusionNote } from "@generated/backend"; import * as tr from "@generated/ftl"; import { fabric } from "fabric"; import { get } from "svelte/store"; import { optimumCssSizeForCanvas } from "./canvas-scale"; import { hideAllGuessOne, notesDataStore, opacityStateStore, saveNeededStore, tagsWritable, textEditingState, } from "./store"; import Toast from "./Toast.svelte"; import { addShapesToCanvasFromCloze } from "./tools/add-from-cloze"; import { enableSelectable, makeMaskTransparent, makeShapesRemainInCanvas, moveShapeToCanvasBoundaries, } from "./tools/lib"; import { modifiedPolygon } from "./tools/tool-polygon"; import { undoStack } from "./tools/tool-undo-redo"; import { enablePinchZoom, onResize, setCanvasSize } from "./tools/tool-zoom"; import type { Size } from "./types"; export interface ImageLoadedEvent { path?: string; noteId?: bigint; } export const setupMaskEditor = async ( path: string, onImageLoaded: (event: ImageLoadedEvent) => void, ): Promise => { const imageData = await getImageForOcclusion({ path }); const canvas = initCanvas(); // get image width and height const image = document.getElementById("image") as HTMLImageElement; image.src = getImageData(imageData.data!, path); image.onload = function() { const size = optimumCssSizeForCanvas({ width: image.width, height: image.height }, containerSize()); setCanvasSize(canvas); onImageLoaded({ path }); setupBoundingBox(canvas, size); undoStack.reset(); }; return canvas; }; export const setupMaskEditorForEdit = async ( noteId: number, onImageLoaded: (event: ImageLoadedEvent) => void, ): Promise => { const clozeNoteResponse = await getImageOcclusionNote({ noteId: BigInt(noteId) }); const kind = clozeNoteResponse.value?.case; if (!kind || kind === "error") { new Toast({ target: document.body, props: { message: tr.notetypesErrorGettingImagecloze(), type: "error", }, }).$set({ showToast: true }); throw "error getting cloze"; } const clozeNote = clozeNoteResponse.value.value; const canvas = initCanvas(); hideAllGuessOne.set(clozeNote.occludeInactive); // get image width and height const image = document.getElementById("image") as HTMLImageElement; image.src = getImageData(clozeNote.imageData!, clozeNote.imageFileName!); image.onload = async function() { const size = optimumCssSizeForCanvas( { width: image.naturalWidth, height: image.naturalHeight }, containerSize(), ); setCanvasSize(canvas); const boundingBox = setupBoundingBox(canvas, size); addShapesToCanvasFromCloze(canvas, boundingBox, clozeNote.occlusions); enableSelectable(canvas, true); addClozeNotesToTextEditor(clozeNote.header, clozeNote.backExtra, clozeNote.tags); undoStack.reset(); window.requestAnimationFrame(() => { onImageLoaded({ noteId: BigInt(noteId) }); }); if (get(opacityStateStore)) { makeMaskTransparent(canvas, true); } }; return canvas; }; function initCanvas(): fabric.Canvas { const canvas = new fabric.Canvas("canvas"); tagsWritable.set([]); globalThis.canvas = canvas; undoStack.setCanvas(canvas); // find object per-pixel basis rather than according to bounding box, // allow click through transparent area fabric.Object.prototype.perPixelTargetFind = true; // Disable uniform scaling canvas.uniformScaling = false; canvas.uniScaleKey = "none"; // disable object caching fabric.Object.prototype.objectCaching = false; // add a border to corner to handle blend of control fabric.Object.prototype.transparentCorners = false; fabric.Object.prototype.cornerStyle = "circle"; fabric.Object.prototype.cornerStrokeColor = "#000000"; fabric.Object.prototype.padding = 8; // snap rotation around 0 by +-3deg fabric.Object.prototype.snapAngle = 360; fabric.Object.prototype.snapThreshold = 3; // populate canvas.targets with subtargets during mouse events fabric.Group.prototype.subTargetCheck = true; // disable rotation when selecting canvas.on("selection:created", () => { const g = canvas.getActiveObject(); if (g && g instanceof fabric.Group) { g.setControlsVisibility({ mtr: false }); } }); canvas.on("object:modified", (evt) => { if (evt.target instanceof fabric.Polygon) { modifiedPolygon(canvas, evt.target); undoStack.onObjectModified(); } }); canvas.on("text:editing:entered", function() { textEditingState.set(true); }); canvas.on("text:editing:exited", function() { textEditingState.set(false); }); canvas.on("object:removed", () => { saveNeededStore.set(true); }); return canvas; } const setupBoundingBox = (canvas: fabric.Canvas, size: Size): fabric.Rect => { const boundingBox = new fabric.Rect({ fill: "transparent", width: size.width, height: size.height, hasBorders: false, hasControls: false, lockMovementX: true, lockMovementY: true, selectable: false, evented: false, }); boundingBox["id"] = "boundingBox"; canvas.add(boundingBox); onResize(canvas); makeShapesRemainInCanvas(canvas, boundingBox); moveShapeToCanvasBoundaries(canvas, boundingBox); // enable pinch zoom for mobile devices enablePinchZoom(canvas); return boundingBox; }; const getImageData = (imageData, path): string => { const b64encoded = protoBase64.enc(imageData); const extension = path.split(".").pop(); const mimeTypes = { "jpg": "jpeg", "jpeg": "jpeg", "gif": "gif", "svg": "svg+xml", "webp": "webp", "avif": "avif", "png": "png", }; const type = mimeTypes[extension] || "png"; return `data:image/${type};base64,${b64encoded}`; }; const addClozeNotesToTextEditor = (header: string, backExtra: string, tags: string[]) => { const noteFieldsData: { id: string; title: string; divValue: string; textareaValue: string }[] = get( notesDataStore, ); noteFieldsData[0].divValue = header; noteFieldsData[1].divValue = backExtra; noteFieldsData[0].textareaValue = header; noteFieldsData[1].textareaValue = backExtra; tagsWritable.set(tags); noteFieldsData.forEach((note) => { const divId = `${note.id}--div`; const textAreaId = `${note.id}--textarea`; const divElement = document.getElementById(divId)!; const textAreaElement = document.getElementById(textAreaId)! as HTMLTextAreaElement; divElement.innerHTML = note.divValue; textAreaElement.value = note.textareaValue; }); }; function containerSize(): Size { const container = document.querySelector(".editor-main")!; return { width: container.clientWidth, height: container.clientHeight, }; } export async function resetIOImage(path: string, onImageLoaded: (event: ImageLoadedEvent) => void) { const imageData = await getImageForOcclusion({ path }); const image = document.getElementById("image") as HTMLImageElement; image.src = getImageData(imageData.data!, path); const canvas = globalThis.canvas; image.onload = async function() { const size = optimumCssSizeForCanvas( { width: image.naturalWidth, height: image.naturalHeight }, containerSize(), ); image.width = size.width; image.height = size.height; setCanvasSize(canvas); onImageLoaded({ path }); setupBoundingBox(canvas, size); }; } globalThis.resetIOImage = resetIOImage; ================================================ FILE: ts/routes/image-occlusion/notes-toolbar/MoreTools.svelte ================================================ {#each moreTools as tool} tool.action()} tooltip={tool.title} > {/each} ================================================ FILE: ts/routes/image-occlusion/notes-toolbar/NotesToolbar.svelte ================================================ ================================================ FILE: ts/routes/image-occlusion/notes-toolbar/TextFormatting.svelte ================================================ {#each textFormatting as tool} { // setActiveTool(tool); textFormat(tool); }} > {/each} ================================================ FILE: ts/routes/image-occlusion/notes-toolbar/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import NotesToolbar from "./NotesToolbar.svelte"; export default NotesToolbar; ================================================ FILE: ts/routes/image-occlusion/notes-toolbar/lib.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export const changePreviewHTMLView = (): void => { const activeElement = document.activeElement!; if (!activeElement || !activeElement.id.includes("--")) { return; } const noteId = activeElement.id.split("--")[0]; const divId = `${noteId}--div`; const textAreaId = `${noteId}--textarea`; const divElement = document.getElementById(divId)!; const textAreaElement = document.getElementById(textAreaId)! as HTMLTextAreaElement; if (divElement.style.display == "none") { divElement.style.display = "block"; textAreaElement.style.display = "none"; divElement.focus(); } else { divElement.style.display = "none"; textAreaElement.style.display = "block"; textAreaElement.focus(); } }; ================================================ FILE: ts/routes/image-occlusion/review.scss ================================================ #image-occlusion-container { position: relative; // if height-constrained, ensure container is centered margin: 0 auto; // allow for 20px margin on html element, or short windows can truncate // image max-height: calc(95vh - 40px); } #image-occlusion-container img { position: absolute; top: 0; left: 0; width: 100%; height: 100%; // remove the default image limits, as we rely on container max-width: unset; max-height: unset; } #image-occlusion-canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } ================================================ FILE: ts/routes/image-occlusion/review.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import * as tr from "@generated/ftl"; import { ModuleName, setupI18n } from "@tslib/i18n"; import { optimumPixelSizeForCanvas } from "./canvas-scale"; import { Shape } from "./shapes"; import { Ellipse, extractShapesFromRenderedClozes, Polygon, Rectangle, Text } from "./shapes"; import { SHAPE_MASK_COLOR, TEXT_BACKGROUND_COLOR, TEXT_FONT_FAMILY, TEXT_PADDING } from "./tools/lib"; import type { Size } from "./types"; export type DrawShapesData = { activeShapes: Shape[]; inactiveShapes: Shape[]; highlightShapes: Shape[]; properties: ShapeProperties; }; export type DrawShapesFilter = ( data: DrawShapesData, context: CanvasRenderingContext2D, ) => DrawShapesData | void; export type DrawShapesCallback = ( data: DrawShapesData, context: CanvasRenderingContext2D, ) => void; export const imageOcclusionAPI = { setup: setupImageOcclusion, drawShape, Ellipse, Polygon, Rectangle, Shape, Text, }; interface SetupImageOcclusionOptions { onWillDrawShapes?: DrawShapesFilter; onDidDrawShapes?: DrawShapesCallback; } async function setupImageOcclusion(setupOptions?: SetupImageOcclusionOptions): Promise { await waitForImage(); window.addEventListener("load", () => { window.addEventListener("resize", () => setupImageOcclusion(setupOptions)); }); window.requestAnimationFrame(() => setupImageOcclusionInner(setupOptions)); } /** We must make sure the image has loaded before we can access its dimensions. * This can happen if not preloading, or if preloading takes too long. */ async function waitForImage(): Promise { const image = document.querySelector( "#image-occlusion-container img", ); if (!image) { // error will be handled later return; } if (image.complete) { return; } // Wait for the image to load await new Promise(resolve => { image.addEventListener("load", () => { resolve(); }); image.addEventListener("error", () => { resolve(); }); }); } /** * Calculate the size of the container that will fit in the viewport while having * the same aspect ratio as the image. This is a workaround for Qt5 WebEngine not * supporting the `aspect-ratio` CSS property. */ function calculateContainerSize( container: HTMLDivElement, img: HTMLImageElement, ): { width: number; height: number } { const compStyle = getComputedStyle(container); const compMaxWidth = parseFloat(compStyle.getPropertyValue("max-width")); const vw = container.parentElement!.clientWidth; // respect 'max-width' if it is set narrower than the viewport const maxWidth = Number.isNaN(compMaxWidth) || compMaxWidth > vw ? vw : compMaxWidth; // see ./review.scss const defaultMaxHeight = document.documentElement.clientHeight * 0.95 - 40; const compMaxHeight = parseFloat(compStyle.getPropertyValue("max-height")); let maxHeight: number; // If 'max-height' is set to 'unset' or 'initial' and the image is taller than // the default max height, the container height is up to the image height. if (Number.isNaN(compMaxHeight)) { maxHeight = Math.max(img.naturalHeight, defaultMaxHeight); } else if (compMaxHeight < defaultMaxHeight) { maxHeight = compMaxHeight; } else { maxHeight = Math.max(defaultMaxHeight, Math.min(img.naturalHeight, compMaxHeight)); } const ratio = Math.min( maxWidth / img.naturalWidth, maxHeight / img.naturalHeight, ); return { width: img.naturalWidth * ratio, height: img.naturalHeight * ratio }; } let oneTimeSetupDone = false; async function setupImageOcclusionInner(setupOptions?: SetupImageOcclusionOptions): Promise { const canvas = document.querySelector( "#image-occlusion-canvas", ); if (canvas == null) { return; } const container = document.getElementById( "image-occlusion-container", ) as HTMLDivElement; const image = document.querySelector( "#image-occlusion-container img", ); if (image == null) { await setupI18n({ modules: [ ModuleName.NOTETYPES, ], }); container.innerText = tr.notetypeErrorNoImageToShow(); return; } // Enforce aspect ratio of image if (CSS.supports("aspect-ratio: 1")) { container.style.aspectRatio = `${image.naturalWidth / image.naturalHeight}`; } else { const containerSize = calculateContainerSize(container, image); container.style.width = `${containerSize.width}px`; container.style.height = `${containerSize.height}px`; } const size = optimumPixelSizeForCanvas( { width: image.naturalWidth, height: image.naturalHeight }, { width: canvas.clientWidth, height: canvas.clientHeight }, ); canvas.width = size.width; canvas.height = size.height; if (!oneTimeSetupDone) { window.addEventListener("keydown", (event) => { const button = document.getElementById("toggle"); if (button && button.style.display !== "none" && event.key === "M") { toggleMasks(setupOptions); } }); oneTimeSetupDone = true; } // setup button for toggle image occlusion const button = document.getElementById("toggle"); if (button) { if (document.querySelector("[data-occludeinactive=\"1\"]")) { button.addEventListener("click", () => toggleMasks(setupOptions)); } else { button.style.display = "none"; } } drawShapes(canvas, setupOptions?.onWillDrawShapes, setupOptions?.onDidDrawShapes); } function drawShapes( canvas: HTMLCanvasElement, onWillDrawShapes?: DrawShapesFilter, onDidDrawShapes?: DrawShapesCallback, allowedShapes?: Array, ): void { const context: CanvasRenderingContext2D = canvas.getContext("2d")!; const size = canvas; const filterByAllowedShapes = (el: Shape) => (allowedShapes && allowedShapes.length > 0) ? allowedShapes.some(s => el instanceof s) : true; let activeShapes = extractShapesFromRenderedClozes(".cloze").filter(filterByAllowedShapes); let inactiveShapes = extractShapesFromRenderedClozes(".cloze-inactive").filter(filterByAllowedShapes); let highlightShapes = extractShapesFromRenderedClozes(".cloze-highlight").filter(filterByAllowedShapes); let properties = getShapeProperties(); const processed = onWillDrawShapes?.({ activeShapes, inactiveShapes, highlightShapes, properties }, context); if (processed) { activeShapes = processed.activeShapes; inactiveShapes = processed.inactiveShapes; highlightShapes = processed.highlightShapes; properties = processed.properties; } for (const shape of activeShapes) { drawShape({ context, size, shape, fill: properties.activeShapeColor, stroke: properties.activeBorder.color, strokeWidth: properties.activeBorder.width, }); } for (const shape of inactiveShapes.filter((s) => s.occludeInactive)) { drawShape({ context, size, shape, fill: shape.fill !== SHAPE_MASK_COLOR ? shape.fill : properties.inActiveShapeColor, stroke: properties.inActiveBorder.color, strokeWidth: properties.inActiveBorder.width, }); } for (const shape of highlightShapes) { drawShape({ context, size, shape, fill: properties.highlightShapeColor, stroke: properties.highlightShapeBorder.color, strokeWidth: properties.highlightShapeBorder.width, }); } onDidDrawShapes?.({ activeShapes, inactiveShapes, highlightShapes, properties, }, context); } interface DrawShapeParameters { context: CanvasRenderingContext2D; size: Size; shape: Shape; fill: string; stroke: string; strokeWidth: number; } function drawShape({ context: ctx, size, shape, fill, stroke, strokeWidth, }: DrawShapeParameters): void { shape = shape.toAbsolute(size); ctx.fillStyle = fill; ctx.strokeStyle = stroke; ctx.lineWidth = strokeWidth; const angle = ((shape.angle ?? 0) * Math.PI) / 180; if (shape instanceof Rectangle) { if (angle) { ctx.save(); ctx.translate(shape.left, shape.top); ctx.rotate(angle); ctx.translate(-shape.left, -shape.top); } ctx.fillRect(shape.left, shape.top, shape.width, shape.height); // ctx stroke methods will draw a visible stroke, even if the width is 0 if (strokeWidth) { ctx.strokeRect(shape.left, shape.top, shape.width, shape.height); } if (angle) { ctx.restore(); } } else if (shape instanceof Ellipse) { const adjustedLeft = shape.left + shape.rx; const adjustedTop = shape.top + shape.ry; if (angle) { ctx.save(); ctx.translate(shape.left, shape.top); ctx.rotate(angle); ctx.translate(-shape.left, -shape.top); } ctx.beginPath(); ctx.ellipse( adjustedLeft, adjustedTop, shape.rx, shape.ry, 0, 0, Math.PI * 2, false, ); ctx.closePath(); ctx.fill(); if (strokeWidth) { ctx.stroke(); } if (angle) { ctx.restore(); } } else if (shape instanceof Polygon) { const offset = getPolygonOffset(shape); ctx.save(); ctx.translate(offset.x, offset.y); ctx.beginPath(); ctx.moveTo(shape.points[0].x, shape.points[0].y); for (let i = 0; i < shape.points.length; i++) { ctx.lineTo(shape.points[i].x, shape.points[i].y); } ctx.closePath(); ctx.fill(); if (strokeWidth) { ctx.stroke(); } ctx.restore(); } else if (shape instanceof Text) { ctx.save(); ctx.font = `${shape.fontSize}px ${TEXT_FONT_FAMILY}`; ctx.textBaseline = "top"; ctx.scale(shape.scaleX, shape.scaleY); const lines = shape.text.split("\n"); const baseMetrics = ctx.measureText("M"); const fontHeight = baseMetrics.actualBoundingBoxAscent + baseMetrics.actualBoundingBoxDescent; const lineHeight = 1.5 * fontHeight; const linePositions: { text: string; x: number; y: number; width: number; height: number }[] = []; let maxWidth = 0; let totalHeight = 0; for (let i = 0; i < lines.length; i++) { const textMetrics = ctx.measureText(lines[i]); linePositions.push({ text: lines[i], x: shape.left / shape.scaleX, y: shape.top / shape.scaleY + i * lineHeight, width: textMetrics.width, height: lineHeight, }); if (textMetrics.width > maxWidth) { maxWidth = textMetrics.width; } totalHeight += lineHeight; } const left = shape.left / shape.scaleX; const top = shape.top / shape.scaleY; if (angle) { ctx.translate(left, top); ctx.rotate(angle); ctx.translate(-left, -top); } ctx.fillStyle = TEXT_BACKGROUND_COLOR; ctx.fillRect( left, top, maxWidth + TEXT_PADDING, totalHeight + TEXT_PADDING, ); ctx.fillStyle = shape.fill ?? "#000"; for (const line of linePositions) { ctx.fillText(line.text, line.x, line.y); } ctx.restore(); } } function getPolygonOffset(polygon: Polygon): { x: number; y: number } { const topLeft = topLeftOfPoints(polygon.points); return { x: polygon.left - topLeft.x, y: polygon.top - topLeft.y }; } function topLeftOfPoints(points: { x: number; y: number }[]): { x: number; y: number; } { let top = points[0].y; let left = points[0].x; for (const point of points) { if (point.y < top) { top = point.y; } if (point.x < left) { left = point.x; } } return { x: left, y: top }; } export type ShapeProperties = { activeShapeColor: string; inActiveShapeColor: string; highlightShapeColor: string; activeBorder: { width: number; color: string }; inActiveBorder: { width: number; color: string }; highlightShapeBorder: { width: number; color: string }; }; function getShapeProperties(): ShapeProperties { const canvas = document.getElementById("image-occlusion-canvas"); const computedStyle = window.getComputedStyle(canvas!); // it may throw error if the css variable is not defined try { // shape color const activeShapeColor = computedStyle.getPropertyValue( "--active-shape-color", ); const inActiveShapeColor = computedStyle.getPropertyValue( "--inactive-shape-color", ); const highlightShapeColor = computedStyle.getPropertyValue( "--highlight-shape-color", ); // inactive shape border const inActiveShapeBorder = computedStyle.getPropertyValue( "--inactive-shape-border", ); const inActiveBorder = inActiveShapeBorder.split(" ").filter((x) => x); const inActiveShapeBorderWidth = parseFloat(inActiveBorder[0]); const inActiveShapeBorderColor = inActiveBorder[1]; // active shape border const activeShapeBorder = computedStyle.getPropertyValue( "--active-shape-border", ); const activeBorder = activeShapeBorder.split(" ").filter((x) => x); const activeShapeBorderWidth = parseFloat(activeBorder[0]); const activeShapeBorderColor = activeBorder[1]; // highlight shape border const highlightShapeBorder = computedStyle.getPropertyValue( "--highlight-shape-border", ); const highlightBorder = highlightShapeBorder.split(" ").filter((x) => x); const highlightShapeBorderWidth = parseFloat(highlightBorder[0]); const highlightShapeBorderColor = highlightBorder[1]; return { activeShapeColor: activeShapeColor ? activeShapeColor : "#ff8e8e", inActiveShapeColor: inActiveShapeColor ? inActiveShapeColor : SHAPE_MASK_COLOR, highlightShapeColor: highlightShapeColor ? highlightShapeColor : "#ff8e8e00", activeBorder: { width: !isNaN(activeShapeBorderWidth) ? activeShapeBorderWidth : 1, color: activeShapeBorderColor ? activeShapeBorderColor : "#212121", }, inActiveBorder: { width: !isNaN(inActiveShapeBorderWidth) ? inActiveShapeBorderWidth : 1, color: inActiveShapeBorderColor ? inActiveShapeBorderColor : "#212121", }, highlightShapeBorder: { width: !isNaN(highlightShapeBorderWidth) ? highlightShapeBorderWidth : 1, color: highlightShapeBorderColor ? highlightShapeBorderColor : "#ff8e8e", }, }; } catch { // return default values return { activeShapeColor: "#ff8e8e", inActiveShapeColor: "#ffeba2", highlightShapeColor: "#ff8e8e00", activeBorder: { width: 1, color: "#212121", }, inActiveBorder: { width: 1, color: "#212121", }, highlightShapeBorder: { width: 1, color: "#ff8e8e", }, }; } } let hide = false; const toggleMasks = (setupOptions?: SetupImageOcclusionOptions): void => { const canvas = document.getElementById("image-occlusion-canvas") as HTMLCanvasElement; const context = canvas.getContext("2d")!; hide = !hide; context.clearRect(0, 0, canvas.width, canvas.height); if (hide) { drawShapes(canvas, setupOptions?.onWillDrawShapes, setupOptions?.onDidDrawShapes, [Text]); return; } drawShapes(canvas, setupOptions?.onWillDrawShapes, setupOptions?.onDidDrawShapes); }; ================================================ FILE: ts/routes/image-occlusion/shapes/base.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { fabric } from "fabric"; import { SHAPE_MASK_COLOR } from "../tools/lib"; import type { ConstructorParams, Size } from "../types"; import { angleToStored, floatToDisplay } from "./lib"; import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position"; export type ShapeOrShapes = Shape | Shape[]; /** Defines a basic shape that can have its coordinates stored in either absolute pixels (relative to a containing canvas), or in normalized 0-1 form. Can be converted to a fabric object, or to a format suitable for storage in a cloze note. */ export class Shape { left: number; top: number; angle?: number; // polygons don't use it fill: string; /** Whether occlusions from other cloze numbers should be shown on the * question side. Used only in reviewer code. */ occludeInactive?: boolean; /* Cloze ordinal */ ordinal: number | undefined; id: string | undefined; constructor( { left = 0, top = 0, angle = 0, fill = SHAPE_MASK_COLOR, occludeInactive, ordinal = undefined }: ConstructorParams = {}, ) { this.left = left; this.top = top; this.angle = angle; this.fill = fill; this.occludeInactive = occludeInactive; this.ordinal = ordinal; } /** Format numbers and remove default values, for easier serialization to * text. */ toDataForCloze(): ShapeDataForCloze { const angle = angleToStored(this.angle); return { left: floatToDisplay(this.left), top: floatToDisplay(this.top), ...(!angle ? {} : { angle: angle.toString() }), }; } toFabric(size: Size): fabric.Object { const absolute = this.toAbsolute(size); return new fabric.Object(absolute); } normalPosition(size: Size) { return { left: xToNormalized(size, this.left), top: yToNormalized(size, this.top), }; } toNormal(size: Size): Shape { return new Shape({ ...this, ...this.normalPosition(size), }); } absolutePosition(size: Size) { return { left: xFromNormalized(size, this.left), top: yFromNormalized(size, this.top), }; } toAbsolute(size: Size): Shape { return new Shape({ ...this, ...this.absolutePosition(size), }); } } export interface ShapeDataForCloze { left: string; top: string; angle?: string; fill?: string; oi?: string; } ================================================ FILE: ts/routes/image-occlusion/shapes/ellipse.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { fabric } from "fabric"; import { SHAPE_MASK_COLOR } from "../tools/lib"; import type { ConstructorParams, Size } from "../types"; import type { ShapeDataForCloze } from "./base"; import { Shape } from "./base"; import { floatToDisplay } from "./lib"; import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position"; export class Ellipse extends Shape { rx: number; ry: number; constructor({ rx = 0, ry = 0, ...rest }: ConstructorParams = {}) { super(rest); this.rx = rx; this.ry = ry; this.id = "ellipse-" + new Date().getTime(); } toDataForCloze(): EllipseDataForCloze { return { ...super.toDataForCloze(), rx: floatToDisplay(this.rx), ry: floatToDisplay(this.ry), ...(this.fill === SHAPE_MASK_COLOR ? {} : { fill: this.fill }), }; } toFabric(size: Size): fabric.Ellipse { const absolute = this.toAbsolute(size); return new fabric.Ellipse(absolute); } toNormal(size: Size): Ellipse { return new Ellipse({ ...this, ...super.normalPosition(size), rx: xToNormalized(size, this.rx), ry: yToNormalized(size, this.ry), }); } toAbsolute(size: Size): Ellipse { return new Ellipse({ ...this, ...super.absolutePosition(size), rx: xFromNormalized(size, this.rx), ry: yFromNormalized(size, this.ry), }); } } interface EllipseDataForCloze extends ShapeDataForCloze { rx: string; ry: string; } ================================================ FILE: ts/routes/image-occlusion/shapes/from-cloze.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import type { GetImageOcclusionNoteResponse_ImageOcclusion } from "@generated/anki/image_occlusion_pb"; import type { Shape, ShapeOrShapes } from "./base"; import { Ellipse } from "./ellipse"; import { storedToAngle } from "./lib"; import { Point, Polygon } from "./polygon"; import { Rectangle } from "./rectangle"; import { Text } from "./text"; export function extractShapesFromClozedField( occlusions: GetImageOcclusionNoteResponse_ImageOcclusion[], ): ShapeOrShapes[] { const output: ShapeOrShapes[] = []; for (const occlusion of occlusions) { const group: Shape[] = []; for (const shape of occlusion.shapes) { if (isValidType(shape.shape)) { const props: Record = Object.fromEntries( shape.properties.map(prop => [prop.name, prop.value]), ); props.ordinal = occlusion.ordinal; group.push(buildShape(shape.shape, props)); } } if (occlusion.ordinal === 0) { output.push(...group); } else if (group.length > 1) { output.push(group); } else { output.push(group[0]); } } return output; } /** Locate all cloze divs in the review screen for the given selector, and convert them into BaseShapes. */ export function extractShapesFromRenderedClozes(selector: string): Shape[] { return Array.from(document.querySelectorAll(selector)).flatMap((cloze) => { if (cloze instanceof HTMLDivElement) { return extractShapeFromRenderedCloze(cloze) ?? []; } else { return []; } }); } function extractShapeFromRenderedCloze(cloze: HTMLDivElement): Shape | null { const type = cloze.dataset.shape!; if ( type !== "rect" && type !== "ellipse" && type !== "polygon" && type !== "text" ) { return null; } const props = { occludeInactive: cloze.dataset.occludeinactive === "1", ordinal: parseInt(cloze.dataset.ordinal!), left: cloze.dataset.left, top: cloze.dataset.top, width: cloze.dataset.width, height: cloze.dataset.height, rx: cloze.dataset.rx, ry: cloze.dataset.ry, points: cloze.dataset.points, text: cloze.dataset.text, scale: cloze.dataset.scale, fs: cloze.dataset.fontSize, angle: cloze.dataset.angle, ...(cloze.dataset.fill == null ? {} : { fill: cloze.dataset.fill }), }; return buildShape(type, props); } type ShapeType = "rect" | "ellipse" | "polygon" | "text"; function isValidType(type: string): type is ShapeType { return ["rect", "ellipse", "polygon", "text"].includes(type); } function buildShape(type: ShapeType, props: Record): Shape { props.left = parseFloat( Number.isNaN(Number(props.left)) ? ".0000" : props.left, ); props.top = parseFloat( Number.isNaN(Number(props.top)) ? ".0000" : props.top, ); props.angle = storedToAngle(props.angle) ?? 0; switch (type) { case "rect": { return new Rectangle({ ...props, width: parseFloat(props.width), height: parseFloat(props.height), }); } case "ellipse": { return new Ellipse({ ...props, rx: parseFloat(props.rx), ry: parseFloat(props.ry), }); } case "polygon": { if (props.points !== "") { props.points = props.points.split(" ").map((point) => { const [x, y] = point.split(","); return new Point({ x, y }); }); } else { props.points = [new Point({ x: 0, y: 0 })]; } return new Polygon(props); } case "text": { const textProps: Record = { ...props, scaleX: parseFloat(props.scale), scaleY: parseFloat(props.scale), }; if (props.fs) { textProps.fontSize = parseFloat(props.fs); } return new Text(textProps); } } } ================================================ FILE: ts/routes/image-occlusion/shapes/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export type { ShapeOrShapes } from "./base"; export { Shape } from "./base"; export { Ellipse } from "./ellipse"; export { extractShapesFromRenderedClozes } from "./from-cloze"; export { Polygon } from "./polygon"; export { Rectangle } from "./rectangle"; export { Text } from "./text"; ================================================ FILE: ts/routes/image-occlusion/shapes/lib.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /** Convert a float to a string with up to 4 fraction digits, * which when rounded, reproduces identical pixels to input * for up to widths/heights of 10kpx. */ export function floatToDisplay(number: number): string { if (Number.isNaN(number) || number == 0) { return ".0000"; } return number.toFixed(4).replace(/^0+|0+$/g, ""); } const ANGLE_STEPS = 10000; export function angleToStored(angle: any): number | null { const angleDeg = Number(angle) % 360; return Number.isNaN(angleDeg) ? null : Math.round((angleDeg / 360) * ANGLE_STEPS); } export function storedToAngle(x: any): number | null { const angleSteps = Number(x) % ANGLE_STEPS; return Number.isNaN(angleSteps) ? null : (angleSteps / ANGLE_STEPS) * 360; } ================================================ FILE: ts/routes/image-occlusion/shapes/polygon.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { fabric } from "fabric"; import { SHAPE_MASK_COLOR } from "../tools/lib"; import type { ConstructorParams, Size } from "../types"; import type { ShapeDataForCloze } from "./base"; import { Shape } from "./base"; import { floatToDisplay } from "./lib"; import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position"; export class Polygon extends Shape { points: Point[]; constructor({ points = [], ...rest }: ConstructorParams = {}) { super(rest); this.points = points; this.id = "polygon-" + new Date().getTime(); } toDataForCloze(): PolygonDataForCloze { return { ...super.toDataForCloze(), points: this.points.map(({ x, y }) => `${floatToDisplay(x)},${floatToDisplay(y)}`).join(" "), ...(this.fill === SHAPE_MASK_COLOR ? {} : { fill: this.fill }), }; } toFabric(size: Size): fabric.Polygon { const absolute = this.toAbsolute(size); // @ts-expect-error absolute is our own object not a fabric.Polygon return new fabric.Polygon(absolute.points, absolute); } toNormal(size: Size): Polygon { const points: Point[] = []; this.points.forEach((p) => { points.push({ x: xToNormalized(size, p.x), y: yToNormalized(size, p.y), }); }); return new Polygon({ ...this, ...super.normalPosition(size), points, }); } toAbsolute(size: Size): Polygon { const points: Point[] = []; this.points.forEach((p) => { points.push({ x: xFromNormalized(size, p.x), y: yFromNormalized(size, p.y), }); }); return new Polygon({ ...this, ...super.absolutePosition(size), points, }); } } interface PolygonDataForCloze extends ShapeDataForCloze { // "x1,y1 x2,y2 ..."" points: string; } export class Point { x = 0; y = 0; constructor({ x = 0, y = 0 }: ConstructorParams = {}) { this.x = x; this.y = y; } } ================================================ FILE: ts/routes/image-occlusion/shapes/position.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { Size } from "../types"; /** Position normalized to 0-1 range, e.g. 150px in a 600x300px canvas is 0.25 */ export function xToNormalized(size: Size, x: number): number { return x / size.width; } /** Position normalized to 0-1 range, e.g. 150px in a 600x300px canvas is 0.5 */ export function yToNormalized(size: Size, y: number): number { return y / size.height; } /** Position in pixels from normalized range, e.g 0.25 in a 600x300px canvas is 150. */ export function xFromNormalized(size: Size, x: number): number { return Math.round(x * size.width); } /** Position in pixels from normalized range, e.g 0.5 in a 600x300px canvas is 150. */ export function yFromNormalized(size: Size, y: number): number { return Math.round(y * size.height); } ================================================ FILE: ts/routes/image-occlusion/shapes/rectangle.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { fabric } from "fabric"; import { SHAPE_MASK_COLOR } from "../tools/lib"; import type { ConstructorParams, Size } from "../types"; import type { ShapeDataForCloze } from "./base"; import { Shape } from "./base"; import { floatToDisplay } from "./lib"; import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position"; export class Rectangle extends Shape { width: number; height: number; constructor({ width = 0, height = 0, ...rest }: ConstructorParams = {}) { super(rest); this.width = width; this.height = height; this.id = "rect-" + new Date().getTime(); } toDataForCloze(): RectangleDataForCloze { return { ...super.toDataForCloze(), width: floatToDisplay(this.width), height: floatToDisplay(this.height), ...(this.fill === SHAPE_MASK_COLOR ? {} : { fill: this.fill }), }; } toFabric(size: Size): fabric.Rect { const absolute = this.toAbsolute(size); return new fabric.Rect(absolute); } toNormal(size: Size): Rectangle { return new Rectangle({ ...this, ...super.normalPosition(size), width: xToNormalized(size, this.width), height: yToNormalized(size, this.height), }); } toAbsolute(size: Size): Rectangle { return new Rectangle({ ...this, ...super.absolutePosition(size), width: xFromNormalized(size, this.width), height: yFromNormalized(size, this.height), }); } } interface RectangleDataForCloze extends ShapeDataForCloze { width: string; height: string; } ================================================ FILE: ts/routes/image-occlusion/shapes/text.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { fabric } from "fabric"; import { TEXT_BACKGROUND_COLOR, TEXT_COLOR, TEXT_FONT_FAMILY, TEXT_FONT_SIZE, TEXT_PADDING } from "../tools/lib"; import type { ConstructorParams, Size } from "../types"; import type { ShapeDataForCloze } from "./base"; import { Shape } from "./base"; import { floatToDisplay } from "./lib"; export class Text extends Shape { text: string; scaleX: number; scaleY: number; fontSize: number | undefined; constructor({ text = "", scaleX = 1, scaleY = 1, fill = TEXT_COLOR, fontSize, ...rest }: ConstructorParams = {}) { super(rest); this.fill = fill; this.text = text; this.scaleX = scaleX; this.scaleY = scaleY; this.fontSize = fontSize; this.id = "text-" + new Date().getTime(); } toDataForCloze(): TextDataForCloze { return { ...super.toDataForCloze(), text: this.text, // scaleX and scaleY are guaranteed to be equal since we lock the aspect ratio scale: floatToDisplay(this.scaleX), fs: this.fontSize ? floatToDisplay(this.fontSize) : undefined, ...(this.fill === TEXT_COLOR ? {} : { fill: this.fill }), }; } toFabric(size: Size): fabric.IText { const absolute = this.toAbsolute(size); return new fabric.IText(absolute.text, { ...absolute, fontFamily: TEXT_FONT_FAMILY, backgroundColor: TEXT_BACKGROUND_COLOR, padding: TEXT_PADDING, lineHeight: 1, lockScalingFlip: true, }); } toNormal(size: Size): Text { const fontSize = this.fontSize ? this.fontSize : TEXT_FONT_SIZE; return new Text({ ...this, fontSize: fontSize / size.height, ...super.normalPosition(size), }); } toAbsolute(size: Size): Text { return new Text({ ...this, fontSize: this.fontSize ? this.fontSize * size.height : TEXT_FONT_SIZE, ...super.absolutePosition(size), }); } } interface TextDataForCloze extends ShapeDataForCloze { text: string; scale: string; fs: string | undefined; } ================================================ FILE: ts/routes/image-occlusion/shapes/to-cloze.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { fabric } from "fabric"; import { cloneDeep } from "lodash-es"; import { getBoundingBoxSize } from "../tools/lib"; import type { Size } from "../types"; import type { Shape, ShapeOrShapes } from "./base"; import { Ellipse } from "./ellipse"; import { Polygon } from "./polygon"; import { Rectangle } from "./rectangle"; import { Text } from "./text"; export function exportShapesToClozeDeletions(occludeInactive: boolean): { clozes: string; noteCount: number; } { const shapes = baseShapesFromFabric(); let clozes = ""; let noteCount = 0; // take out all ordinal values from shapes const ordinalList = shapes.map((shape) => { if (Array.isArray(shape)) { return shape[0].ordinal; } else { return shape.ordinal; } }); const filterOrdinalList: number[] = ordinalList.flatMap(v => typeof v === "number" ? [v] : []); const maxOrdinal = Math.max(...filterOrdinalList, 0); const missingOrdinals: number[] = []; for (let i = 1; i <= maxOrdinal; i++) { if (!ordinalList.includes(i)) { missingOrdinals.push(i); } } let nextOrdinal = maxOrdinal + 1; shapes.map((shapeOrShapes) => { if (shapeOrShapes === null) { return; } // Maintain existing ordinal in editing mode let ordinal: number | undefined; if (Array.isArray(shapeOrShapes)) { ordinal = shapeOrShapes[0].ordinal; } else { ordinal = shapeOrShapes.ordinal; } if (ordinal === undefined) { // if ordinal is undefined, assign a missing ordinal if available if (shapeOrShapes instanceof Text) { ordinal = 0; } else if (missingOrdinals.length > 0) { ordinal = missingOrdinals.shift()!; } else { ordinal = nextOrdinal; nextOrdinal++; } if (Array.isArray(shapeOrShapes)) { shapeOrShapes.forEach((shape) => (shape.ordinal = ordinal)); } else { shapeOrShapes.ordinal = ordinal; } } clozes += shapeOrShapesToCloze( shapeOrShapes, ordinal, occludeInactive, ); if (!(shapeOrShapes instanceof Text)) { noteCount++; } }); return { clozes, noteCount }; } /** Gather all Fabric shapes, and convert them into BaseShapes or * BaseShape[]s. */ export function baseShapesFromFabric(): ShapeOrShapes[] { const canvas = globalThis.canvas as fabric.Canvas; const activeObject = canvas.getActiveObject(); const selectionContainingMultipleObjects = activeObject instanceof fabric.ActiveSelection && (activeObject.size() > 1) ? activeObject : null; const objects = canvas.getObjects(); const boundingBox = getBoundingBoxSize(); // filter transparent rectangles return objects .map((object) => { // If the object is in the active selection containing multiple objects, // we need to calculate its x and y coordinates relative to the canvas. const parent = selectionContainingMultipleObjects?.contains(object) ? selectionContainingMultipleObjects : undefined; // shapes with width or height less than 5 are not valid // if shape is Rect and fill is transparent, skip it if (object.width! < 5 || object.height! < 5 || object.fill == "transparent") { return null; } return fabricObjectToBaseShapeOrShapes( boundingBox, object, parent, ); }) .filter((o): o is ShapeOrShapes => o !== null); } /** Convert a single Fabric object/group to one or more BaseShapes. */ function fabricObjectToBaseShapeOrShapes( size: Size, object: fabric.Object, parentObject?: fabric.Object, ): ShapeOrShapes | null { let shape: Shape; // Prevents the original fabric object from mutating when a non-primitive // property of a Shape mutates. const cloned = cloneDeep(object); if (parentObject) { const scaling = parentObject.getObjectScaling(); cloned.width = cloned.width! * scaling.scaleX; cloned.height = cloned.height! * scaling.scaleY; } switch (object.type) { case "rect": shape = new Rectangle(cloned as any); break; case "ellipse": shape = new Ellipse(cloned as any); break; case "polygon": shape = new Polygon(cloned as any); break; case "i-text": shape = new Text(cloned as any); break; case "group": return (object as fabric.Group).getObjects().flatMap((child) => { return fabricObjectToBaseShapeOrShapes( size, child, object, )!; }); default: return null; } if (parentObject) { const newPosition = fabric.util.transformPoint( new fabric.Point(shape.left, shape.top), parentObject.calcTransformMatrix(), ); shape.left = newPosition.x; shape.top = newPosition.y; } shape = shape.toNormal(size); return shape; } /** generate cloze data in form of {{c1::image-occlusion:rect:top=.1:left=.23:width=.4:height=.5}} */ function shapeOrShapesToCloze( shapeOrShapes: ShapeOrShapes, ordinal: number, occludeInactive: boolean, ): string { let text = ""; function addKeyValue(key: string, value: string) { value = value.replace(":", "\\:"); text += `:${key}=${value}`; } let type: string; if (Array.isArray(shapeOrShapes)) { return shapeOrShapes .map((shape) => shapeOrShapesToCloze(shape, ordinal, occludeInactive)) .join(""); } else if (shapeOrShapes instanceof Rectangle) { type = "rect"; } else if (shapeOrShapes instanceof Ellipse) { type = "ellipse"; } else if (shapeOrShapes instanceof Polygon) { type = "polygon"; } else if (shapeOrShapes instanceof Text) { type = "text"; } else { throw new Error("Unknown shape type"); } for (const [key, value] of Object.entries(shapeOrShapes.toDataForCloze())) { addKeyValue(key, value); } if (occludeInactive) { addKeyValue("oi", "1"); } text = `{{c${ordinal}::image-occlusion:${type}${text}}}
`; return text; } ================================================ FILE: ts/routes/image-occlusion/store.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { writable } from "svelte/store"; // it stores note's data for generate.ts, when function generate() is called it will be used to generate the note export const notesDataStore = writable({ id: "", title: "", divValue: "", textareaValue: "" }[0]); // it stores the tags for the note in note editor export const tagsWritable = writable([""]); // it stores the visibility of mask editor export const ioMaskEditorVisible = writable(true); // it store hide all or hide one mode export const hideAllGuessOne = writable(true); // ioImageLoadedStore is used to store the image loaded event export const ioImageLoadedStore = writable(false); // store opacity state of objects in canvas export const opacityStateStore = writable(false); // store state of text editing export const textEditingState = writable(false); // Stores if the canvas shapes data needs to be saved export const saveNeededStore = writable(false); ================================================ FILE: ts/routes/image-occlusion/tools/add-from-cloze.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { GetImageOcclusionNoteResponse_ImageOcclusion } from "@generated/anki/image_occlusion_pb"; import type { fabric } from "fabric"; import { extractShapesFromClozedField } from "../shapes/from-cloze"; import { addShape, addShapeGroup } from "./from-shapes"; import { redraw } from "./lib"; export const addShapesToCanvasFromCloze = ( canvas: fabric.Canvas, boundingBox: fabric.Rect, occlusions: GetImageOcclusionNoteResponse_ImageOcclusion[], ): void => { for (const shapeOrShapes of extractShapesFromClozedField(occlusions)) { if (Array.isArray(shapeOrShapes)) { addShapeGroup(canvas, boundingBox, shapeOrShapes); } else { addShape(canvas, boundingBox, shapeOrShapes); } } redraw(canvas); }; ================================================ FILE: ts/routes/image-occlusion/tools/api.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { fabric } from "fabric"; import type { ShapeOrShapes } from "../shapes"; import { Ellipse, Polygon, Rectangle, Shape, Text } from "../shapes"; import { baseShapesFromFabric, exportShapesToClozeDeletions } from "../shapes/to-cloze"; import { addShape, addShapeGroup } from "./from-shapes"; import { clear, redraw } from "./lib"; interface ClozeExportResult { clozes: string; cardCount: number; } export class MaskEditorAPI { readonly Shape = Shape; readonly Rectangle = Rectangle; readonly Ellipse = Ellipse; readonly Polygon = Polygon; readonly Text = Text; readonly canvas: fabric.Canvas; constructor(canvas) { this.canvas = canvas; } addShape(bounding, shape: Shape): void { addShape(this.canvas, bounding, shape); } addShapeGroup(bounding, shapes: Shape[]): void { addShapeGroup(this.canvas, bounding, shapes); } getClozes(occludeInactive: boolean): ClozeExportResult { const { clozes, noteCount: cardCount } = exportShapesToClozeDeletions(occludeInactive); return { clozes, cardCount }; } getShapes(): ShapeOrShapes[] { return baseShapesFromFabric(); } redraw(): void { redraw(this.canvas); } clear(): void { clear(this.canvas); } } ================================================ FILE: ts/routes/image-occlusion/tools/from-shapes.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { fabric } from "fabric"; import type { Shape } from "../shapes"; import { addBorder, enableUniformScaling } from "./lib"; export const addShape = ( canvas: fabric.Canvas, boundingBox: fabric.Rect, shape: Shape, ): void => { const fabricShape = shape.toFabric(boundingBox.getBoundingRect(true)); if (fabricShape.type === "i-text") { enableUniformScaling(canvas, fabricShape); } else { // No border around i-text shapes since it will be interpretted // as character stroke, this is supposed to create an outline // around the entire shape. addBorder(fabricShape); } canvas.add(fabricShape); }; export const addShapeGroup = ( canvas: fabric.Canvas, boundingBox: fabric.Rect, shapes: Shape[], ): void => { const group = new fabric.Group(); shapes.map((shape) => { const fabricShape = shape.toFabric(boundingBox.getBoundingRect(true)); addBorder(fabricShape); group.addWithUpdate(fabricShape); }); canvas.add(group); }; ================================================ FILE: ts/routes/image-occlusion/tools/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { drawEllipse } from "./tool-ellipse"; import { drawPolygon } from "./tool-polygon"; import { drawRectangle } from "./tool-rect"; import { drawText } from "./tool-text"; export { drawEllipse, drawPolygon, drawRectangle, drawText }; ================================================ FILE: ts/routes/image-occlusion/tools/lib.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { fabric } from "fabric"; import { get } from "svelte/store"; import { opacityStateStore, saveNeededStore } from "../store"; import type { Size } from "../types"; export const SHAPE_MASK_COLOR = "#ffeba2"; export const BORDER_COLOR = "#212121"; export const TEXT_BACKGROUND_COLOR = "#ffffff"; export const TEXT_FONT_FAMILY = "Arial"; export const TEXT_PADDING = 5; export const TEXT_FONT_SIZE = 40; export const TEXT_COLOR = "#000000"; let _clipboard; export const stopDraw = (canvas: fabric.Canvas): void => { canvas.off("mouse:down"); canvas.off("mouse:up"); canvas.off("mouse:move"); }; export const enableSelectable = ( canvas: fabric.Canvas, select: boolean, ): void => { canvas.selection = select; canvas.forEachObject(function(o) { if (o.fill === "transparent") { return; } o.selectable = select; }); canvas.renderAll(); }; export const deleteItem = (canvas: fabric.Canvas): void => { const active = canvas.getActiveObject(); if (active) { canvas.remove(active); if (active.type == "activeSelection") { (active as fabric.ActiveSelection).getObjects().forEach((x) => canvas.remove(x)); canvas.discardActiveObject().renderAll(); } } redraw(canvas); }; export const duplicateItem = (canvas: fabric.Canvas): void => { if (!canvas.getActiveObject()) { return; } copyItem(canvas); pasteItem(canvas); }; export const groupShapes = (canvas: fabric.Canvas): void => { if ( canvas.getActiveObject()?.type !== "activeSelection" ) { return; } const activeObject = canvas.getActiveObject() as fabric.ActiveSelection; const items = activeObject.getObjects(); let minOrdinal: number | undefined = Math.min(...items.map((item) => item.ordinal)); minOrdinal = Number.isNaN(minOrdinal) ? undefined : minOrdinal; items.forEach((item) => { item.set({ opacity: 1, ordinal: minOrdinal }); }); activeObject.toGroup().set({ opacity: get(opacityStateStore) ? 0.4 : 1, }).setControlsVisibility({ mtr: false }); redraw(canvas); }; export const unGroupShapes = (canvas: fabric.Canvas): void => { if ( canvas.getActiveObject()?.type !== "group" ) { return; } const group = canvas.getActiveObject() as fabric.Group; const items = group.getObjects(); group._restoreObjectsState(); group.destroyed = true; items.forEach((item) => { item.set({ opacity: get(opacityStateStore) ? 0.4 : 1, ordinal: undefined, }); canvas.add(item); }); canvas.remove(group); redraw(canvas); }; const copyItem = (canvas: fabric.Canvas): void => { const activeObject = canvas.getActiveObject(); if (!activeObject) { return; } // clone what are you copying since you // may want copy and paste on different moment. // and you do not want the changes happened // later to reflect on the copy. activeObject.clone(function(cloned) { _clipboard = cloned; }); }; const pasteItem = (canvas: fabric.Canvas): void => { // clone again, so you can do multiple copies. _clipboard.clone(function(clonedObj) { canvas.discardActiveObject(); clonedObj.set({ left: clonedObj.left + 10, top: clonedObj.top + 10, evented: true, }); if (clonedObj.type === "activeSelection") { // active selection needs a reference to the canvas. clonedObj.canvas = canvas; clonedObj.forEachObject(function(obj) { canvas.add(obj); }); // this should solve the unselectability clonedObj.setCoords(); } else { canvas.add(clonedObj); } _clipboard.top += 10; _clipboard.left += 10; canvas.setActiveObject(clonedObj); redraw(canvas); }); }; export const makeMaskTransparent = ( canvas: fabric.Canvas, opacity = false, ): void => { opacityStateStore.set(opacity); const objects = canvas.getObjects(); objects.forEach((object) => { object.set({ opacity: opacity ? 0.4 : 1, transparentCorners: false, }); }); canvas.renderAll(); }; export const moveShapeToCanvasBoundaries = (canvas: fabric.Canvas, boundingBox: fabric.Rect): void => { canvas.on("object:modified", function(o) { const activeObject = o.target; if (!activeObject) { return; } if (activeObject.type === "rect") { modifiedRectangle(boundingBox, activeObject); } if (activeObject.type === "ellipse") { modifiedEllipse(boundingBox, activeObject as unknown as fabric.Ellipse); } if (activeObject.type === "i-text") { modifiedText(boundingBox, activeObject); } }); }; const modifiedRectangle = ( boundingBox: fabric.Rect, object: fabric.Object, ): void => { const newWidth = object.width! * object.scaleX!; const newHeight = object.height! * object.scaleY!; object.set({ width: newWidth, height: newHeight, scaleX: 1, scaleY: 1, }); setShapePosition(boundingBox, object); }; const modifiedEllipse = ( boundingBox: fabric.Rect, object: fabric.Ellipse, ): void => { const newRx = object.rx! * object.scaleX!; const newRy = object.ry! * object.scaleY!; const newWidth = object.width! * object.scaleX!; const newHeight = object.height! * object.scaleY!; object.set({ rx: newRx, ry: newRy, width: newWidth, height: newHeight, scaleX: 1, scaleY: 1, }); setShapePosition(boundingBox, object); }; const modifiedText = (boundingBox: fabric.Rect, object: fabric.Object): void => { setShapePosition(boundingBox, object); }; const setShapePosition = ( boundingBox: fabric.Rect, object: fabric.Object, ): void => { const { left, top, width, height } = object.getBoundingRect(true); if (left < 0) { object.set({ left: Math.max(object.left! - left, 0) }); } if (top < 0) { object.set({ top: Math.max(object.top! - top, 0) }); } if (left > boundingBox.width!) { object.set({ left: object.left! - left - width + boundingBox.width! }); } if (top > boundingBox.height!) { object.set({ top: object.top! - top - height + boundingBox.height! }); } object.setCoords(); saveNeededStore.set(true); }; export function enableUniformScaling(canvas: fabric.Canvas, obj: fabric.Object): void { obj.setControlsVisibility({ mb: false, ml: false, mt: false, mr: false }); let timer: number; obj.on("scaling", (e) => { if (["bl", "br", "tr", "tl"].includes(e.transform!.corner)) { clearTimeout(timer); canvas.uniformScaling = true; // https://github.com/sveltejs/kit/issues/9348 timer = setTimeout(() => { canvas.uniformScaling = false; }, 500) as unknown as number; } }); } export function addBorder(obj: fabric.Object): void { obj.stroke = BORDER_COLOR; obj.strokeWidth = 1; obj.strokeUniform = true; } export const redraw = (canvas: fabric.Canvas): void => { canvas.requestRenderAll(); }; export const clear = (canvas: fabric.Canvas): void => { canvas.clear(); }; /** * Creates a canvas event listener on shape movement to restrict movement to within the `boundingBox` */ export const makeShapesRemainInCanvas = (canvas: fabric.Canvas, boundingBox: fabric.Rect) => { canvas.on("object:moving", function(e) { const obj = e.target!; const { left: objBbLeft, top: objBbTop, width: objBbWidth, height: objBbHeight } = obj.getBoundingRect( true, true, ); if (objBbWidth > boundingBox.width! || objBbHeight > boundingBox.height!) { return; } const topBound = boundingBox.top!; const bottomBound = topBound + boundingBox.height! + 5; const leftBound = boundingBox.left!; const rightBound = leftBound + boundingBox.width! + 5; const newBbLeft = Math.min(Math.max(objBbLeft, leftBound), rightBound - objBbWidth); const newBbTop = Math.min(Math.max(objBbTop, topBound), bottomBound - objBbHeight); obj.left = obj.left! + newBbLeft - objBbLeft; obj.top = obj.top! + newBbTop - objBbTop; }); }; export const selectAllShapes = (canvas: fabric.Canvas) => { canvas.discardActiveObject(); // filter out the transparent bounding box from the selection const sel = new fabric.ActiveSelection( canvas.getObjects().filter((obj) => obj.fill !== "transparent"), { canvas: canvas, }, ); canvas.setActiveObject(sel); redraw(canvas); }; export const isPointerInBoundingBox = (pointer): boolean => { const boundingBox = getBoundingBox(); if (boundingBox === undefined) { return false; } boundingBox.selectable = false; boundingBox.evented = false; if ( pointer.x < boundingBox.left! || pointer.x > boundingBox.left! + boundingBox.width! || pointer.y < boundingBox.top! || pointer.y > boundingBox.top! + boundingBox.height! ) { return false; } return true; }; export const getBoundingBox = (): fabric.Rect | undefined => { const canvas: fabric.Canvas = globalThis.canvas; return canvas.getObjects().find((obj) => obj.fill === "transparent"); }; export const getBoundingBoxSize = (): Size => { const boundingBoxSize = getBoundingBox()?.getBoundingRect(true); if (boundingBoxSize) { return { width: boundingBoxSize.width, height: boundingBoxSize.height }; } return { width: 0, height: 0 }; }; ================================================ FILE: ts/routes/image-occlusion/tools/more-tools.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import * as tr from "@generated/ftl"; import { mdiAlignHorizontalCenter, mdiAlignHorizontalLeft, mdiAlignHorizontalRight, mdiAlignVerticalBottom, mdiAlignVerticalCenter, mdiAlignVerticalTop, mdiCopy, mdiDeleteOutline, mdiGroup, mdiSelectAll, mdiUngroup, mdiZoomIn, mdiZoomOut, mdiZoomReset, } from "$lib/components/icons"; import { deleteItem, duplicateItem, groupShapes, selectAllShapes, unGroupShapes } from "./lib"; import { alignBottomKeyCombination, alignHorizontalCenterKeyCombination, alignLeftKeyCombination, alignRightKeyCombination, alignTopKeyCombination, alignVerticalCenterKeyCombination, deleteKeyCombination, duplicateKeyCombination, groupKeyCombination, selectAllKeyCombination, ungroupKeyCombination, zoomInKeyCombination, zoomOutKeyCombination, zoomResetKeyCombination, } from "./shortcuts"; import { alignBottom, alignHorizontalCenter, alignLeft, alignRight, alignTop, alignVerticalCenter, } from "./tool-aligns"; import { zoomIn, zoomOut, zoomReset } from "./tool-zoom"; export const groupUngroupTools = [ { name: "group", icon: mdiGroup, action: groupShapes, tooltip: tr.editingImageOcclusionGroup, shortcut: groupKeyCombination, }, { name: "ungroup", icon: mdiUngroup, action: unGroupShapes, tooltip: tr.editingImageOcclusionUngroup, shortcut: ungroupKeyCombination, }, { name: "select-all", icon: mdiSelectAll, action: selectAllShapes, tooltip: tr.editingImageOcclusionSelectAll, shortcut: selectAllKeyCombination, }, ]; export const deleteDuplicateTools = [ { name: "delete", icon: mdiDeleteOutline, action: deleteItem, tooltip: tr.editingImageOcclusionDelete, shortcut: deleteKeyCombination, }, { name: "duplicate", icon: mdiCopy, action: duplicateItem, tooltip: tr.editingImageOcclusionDuplicate, shortcut: duplicateKeyCombination, }, ]; export const zoomTools = [ { name: "zoomOut", icon: mdiZoomOut, action: zoomOut, tooltip: tr.editingImageOcclusionZoomOut, shortcut: zoomOutKeyCombination, }, { name: "zoomIn", icon: mdiZoomIn, action: zoomIn, tooltip: tr.editingImageOcclusionZoomIn, shortcut: zoomInKeyCombination, }, { name: "zoomReset", icon: mdiZoomReset, action: zoomReset, tooltip: tr.editingImageOcclusionZoomReset, shortcut: zoomResetKeyCombination, }, ]; export const alignTools = [ { id: 1, icon: mdiAlignHorizontalLeft, action: alignLeft, tooltip: tr.editingImageOcclusionAlignLeft, shortcut: alignLeftKeyCombination, }, { id: 2, icon: mdiAlignHorizontalCenter, action: alignHorizontalCenter, tooltip: tr.editingImageOcclusionAlignHCenter, shortcut: alignHorizontalCenterKeyCombination, }, { id: 3, icon: mdiAlignHorizontalRight, action: alignRight, tooltip: tr.editingImageOcclusionAlignRight, shortcut: alignRightKeyCombination, }, { id: 4, icon: mdiAlignVerticalTop, action: alignTop, tooltip: tr.editingImageOcclusionAlignTop, shortcut: alignTopKeyCombination, }, { id: 5, icon: mdiAlignVerticalCenter, action: alignVerticalCenter, tooltip: tr.editingImageOcclusionAlignVCenter, shortcut: alignVerticalCenterKeyCombination, }, { id: 6, icon: mdiAlignVerticalBottom, action: alignBottom, tooltip: tr.editingImageOcclusionAlignBottom, shortcut: alignBottomKeyCombination, }, ]; ================================================ FILE: ts/routes/image-occlusion/tools/shortcuts.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export const cursorKeyCombination = "S"; export const rectangleKeyCombination = "R"; export const ellipseKeyCombination = "E"; export const polygonKeyCombination = "P"; export const textKeyCombination = "T"; export const fillKeyCombination = "C"; export const magnifyKeyCombination = "M"; export const undoKeyCombination = "Control+Z"; export const redoKeyCombination = "Control+Y"; export const zoomOutKeyCombination = "["; export const zoomInKeyCombination = "]"; export const zoomResetKeyCombination = "F"; export const toggleTranslucentKeyCombination = "L"; export const deleteKeyCombination = "Delete"; export const duplicateKeyCombination = "D"; export const groupKeyCombination = "G"; export const ungroupKeyCombination = "U"; export const selectAllKeyCombination = "A"; export const alignLeftKeyCombination = "Shift+L"; export const alignHorizontalCenterKeyCombination = "Shift+H"; export const alignRightKeyCombination = "Shift+R"; export const alignTopKeyCombination = "Shift+T"; export const alignVerticalCenterKeyCombination = "Shift+V"; export const alignBottomKeyCombination = "Shift+B"; export const toggleMaskEditorKeyCombination = "Control+Alt+Shift+M"; ================================================ FILE: ts/routes/image-occlusion/tools/tool-aligns.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { fabric } from "fabric"; export const alignLeft = (canvas: fabric.Canvas): void => { const activeObject = canvas.getActiveObject(); if (!activeObject) { return; } if (activeObject.type == "activeSelection") { alignLeftGroup(canvas, activeObject as fabric.ActiveSelection); } else { activeObject.set({ left: 0 }); } activeObject.setCoords(); canvas.renderAll(); }; export const alignHorizontalCenter = (canvas: fabric.Canvas): void => { const activeObject = canvas.getActiveObject(); if (!activeObject) { return; } if (activeObject.type == "activeSelection") { alignHorizontalCenterGroup(canvas, activeObject as fabric.ActiveSelection); } else { activeObject.set({ left: (canvas.width!) / 2 - (activeObject.width!) / 2 }); } activeObject.setCoords(); canvas.renderAll(); }; export const alignRight = (canvas: fabric.Canvas): void => { const activeObject = canvas.getActiveObject(); if (!activeObject) { return; } if (activeObject.type == "activeSelection") { alignRightGroup(canvas, activeObject as fabric.ActiveSelection); } else { activeObject.set({ left: canvas.getWidth() - activeObject.width! }); } activeObject.setCoords(); canvas.renderAll(); }; export const alignTop = (canvas: fabric.Canvas): void => { const activeObject = canvas.getActiveObject(); if (!activeObject) { return; } if (activeObject.type == "activeSelection") { alignTopGroup(canvas, activeObject as fabric.ActiveSelection); } else { activeObject.set({ top: 0 }); } activeObject.setCoords(); canvas.renderAll(); }; export const alignVerticalCenter = (canvas: fabric.Canvas): void => { const activeObject = canvas.getActiveObject(); if (!activeObject) { return; } if (activeObject.type == "activeSelection") { alignVerticalCenterGroup(canvas, activeObject as fabric.ActiveSelection); } else { activeObject.set({ top: canvas.getHeight() / 2 - activeObject.height! / 2 }); } activeObject.setCoords(); canvas.renderAll(); }; export const alignBottom = (canvas: fabric.Canvas): void => { const activeObject = canvas.getActiveObject(); if (!activeObject) { return; } if (activeObject.type == "activeSelection") { alignBottomGroup(canvas, activeObject as fabric.ActiveSelection); } else { activeObject.set({ top: canvas.height! - activeObject.height! }); } activeObject.setCoords(); canvas.renderAll(); }; // group aligns const alignLeftGroup = (canvas: fabric.Canvas, group: fabric.ICollection) => { const objects = group.getObjects(); let leftmostShape = objects[0]; for (let i = 1; i < objects.length; i++) { if (objects[i].left! < leftmostShape.left!) { leftmostShape = objects[i]; } } objects.forEach((object) => { object.left = leftmostShape.left; object.setCoords(); }); }; const alignRightGroup = (canvas: fabric.Canvas, group: fabric.ICollection): void => { const objects = group.getObjects(); let rightmostShape = objects[0]; for (let i = 1; i < objects.length; i++) { if (objects[i].left! > rightmostShape.left!) { rightmostShape = objects[i]; } } objects.forEach((object) => { object.left = rightmostShape.left! + rightmostShape.width! - object.width!; object.setCoords(); }); }; const alignTopGroup = (canvas: fabric.Canvas, group: fabric.ICollection): void => { const objects = group.getObjects(); let topmostShape = objects[0]; for (let i = 1; i < objects.length; i++) { if (objects[i].top! < topmostShape.top!) { topmostShape = objects[i]; } } objects.forEach((object) => { object.top = topmostShape.top; object.setCoords(); }); }; const alignBottomGroup = (canvas: fabric.Canvas, group: fabric.ICollection): void => { const objects = group.getObjects(); let bottommostShape = objects[0]; for (let i = 1; i < objects.length; i++) { if (objects[i].top! + objects[i].height! > bottommostShape.top! + bottommostShape.height!) { bottommostShape = objects[i]; } } objects.forEach(function(object) { if (object !== bottommostShape) { object.set({ top: bottommostShape.top! + bottommostShape.height! - object.height! }); object.setCoords(); } }); }; const alignHorizontalCenterGroup = (canvas: fabric.Canvas, group: fabric.ICollection) => { const objects = group.getObjects(); let leftmostShape = objects[0]; let rightmostShape = objects[0]; for (let i = 1; i < objects.length; i++) { if (objects[i].left! < leftmostShape.left!) { leftmostShape = objects[i]; } if (objects[i].left! > rightmostShape.left!) { rightmostShape = objects[i]; } } const centerX = (leftmostShape.left! + rightmostShape.left! + rightmostShape.width!) / 2; objects.forEach((object) => { object.left = centerX - object.width! / 2; object.setCoords(); }); }; const alignVerticalCenterGroup = (canvas: fabric.Canvas, group: fabric.ICollection) => { const objects = group.getObjects(); let topmostShape = objects[0]; let bottommostShape = objects[0]; for (let i = 1; i < objects.length; i++) { const current = objects[i]; if (current.top! < topmostShape.top!) { topmostShape = current; } if (current.top! > bottommostShape.top!) { bottommostShape = objects[i]; } } const centerY = (topmostShape.top! + bottommostShape.top! + bottommostShape.height!) / 2; objects.forEach((object) => { object.top = centerY - object.height! / 2; object.setCoords(); }); }; ================================================ FILE: ts/routes/image-occlusion/tools/tool-buttons.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import * as tr from "@generated/ftl"; import { mdiCursorDefaultOutline, mdiEllipseOutline, mdiFormatColorFill, mdiRectangleOutline, mdiTextBox, mdiVectorPolygonVariant, } from "$lib/components/icons"; import { cursorKeyCombination, ellipseKeyCombination, fillKeyCombination, polygonKeyCombination, rectangleKeyCombination, textKeyCombination, } from "./shortcuts"; export const tools = [ { id: "cursor", icon: mdiCursorDefaultOutline, tooltip: tr.editingImageOcclusionSelectTool, shortcut: cursorKeyCombination, }, { id: "draw-rectangle", icon: mdiRectangleOutline, tooltip: tr.editingImageOcclusionRectangleTool, shortcut: rectangleKeyCombination, }, { id: "draw-ellipse", icon: mdiEllipseOutline, tooltip: tr.editingImageOcclusionEllipseTool, shortcut: ellipseKeyCombination, }, { id: "draw-polygon", icon: mdiVectorPolygonVariant, tooltip: tr.editingImageOcclusionPolygonTool, shortcut: polygonKeyCombination, }, { id: "draw-text", icon: mdiTextBox, tooltip: tr.editingImageOcclusionTextTool, shortcut: textKeyCombination, }, { id: "fill-mask", icon: mdiFormatColorFill, iconSizeMult: 1.4, tooltip: tr.editingImageOcclusionFillTool, shortcut: fillKeyCombination, }, ] as const; export type ActiveTool = typeof tools[number]["id"]; ================================================ FILE: ts/routes/image-occlusion/tools/tool-cursor.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { fabric } from "fabric"; import { stopDraw } from "./lib"; import { onPinchZoom } from "./tool-zoom"; export const drawCursor = (canvas: fabric.Canvas): void => { canvas.selectionColor = "rgba(100, 100, 255, 0.3)"; stopDraw(canvas); canvas.on("mouse:down", function(o) { if (o.target) { return; } }); canvas.on("mouse:move", function(o) { if (onPinchZoom(o)) { return; } }); }; ================================================ FILE: ts/routes/image-occlusion/tools/tool-ellipse.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { fabric } from "fabric"; import { get } from "svelte/store"; import { opacityStateStore } from "../store"; import { BORDER_COLOR, isPointerInBoundingBox, SHAPE_MASK_COLOR, stopDraw } from "./lib"; import { undoStack } from "./tool-undo-redo"; import { onPinchZoom } from "./tool-zoom"; export const drawEllipse = (canvas: fabric.Canvas): void => { canvas.selectionColor = "rgba(0, 0, 0, 0)"; let ellipse, isDown, origX, origY; stopDraw(canvas); canvas.on("mouse:down", function(o) { if (o.target) { return; } isDown = true; const pointer = canvas.getPointer(o.e); origX = pointer.x; origY = pointer.y; if (!isPointerInBoundingBox(pointer)) { isDown = false; return; } ellipse = new fabric.Ellipse({ left: origX, top: origY, originX: "left", originY: "top", rx: pointer.x - origX, ry: pointer.y - origY, fill: SHAPE_MASK_COLOR, transparentCorners: false, selectable: true, stroke: BORDER_COLOR, strokeWidth: 1, strokeUniform: true, noScaleCache: false, opacity: get(opacityStateStore) ? 0.4 : 1, }); ellipse["id"] = "ellipse-" + new Date().getTime(); canvas.add(ellipse); }); canvas.on("mouse:move", function(o) { if (onPinchZoom(o)) { canvas.remove(ellipse); canvas.renderAll(); return; } if (!isDown) { return; } const pointer = canvas.getPointer(o.e); let rx = Math.abs(origX - pointer.x) / 2; let ry = Math.abs(origY - pointer.y) / 2; const x = pointer.x; const y = pointer.y; if (rx > ellipse.strokeWidth) { rx -= ellipse.strokeWidth / 2; } if (ry > ellipse.strokeWidth) { ry -= ellipse.strokeWidth / 2; } if (x < origX) { ellipse.set({ originX: "right" }); } else { ellipse.set({ originX: "left" }); } if (y < origY) { ellipse.set({ originY: "bottom" }); } else { ellipse.set({ originY: "top" }); } ellipse.set({ rx: rx, ry: ry }); canvas.renderAll(); }); canvas.on("mouse:up", function() { isDown = false; // probably changed from ellipse to rectangle if (!ellipse) { return; } if (ellipse.width < 5 || ellipse.height < 5) { canvas.remove(ellipse); ellipse = undefined; return; } if (ellipse.originX === "right") { ellipse.set({ originX: "left", left: ellipse.left - ellipse.width + ellipse.strokeWidth, }); } if (ellipse.originY === "bottom") { ellipse.set({ originY: "top", top: ellipse.top - ellipse.height + ellipse.strokeWidth, }); } ellipse.setCoords(); canvas.setActiveObject(ellipse); undoStack.onObjectAdded(ellipse.id); ellipse = undefined; }); }; ================================================ FILE: ts/routes/image-occlusion/tools/tool-fill.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { fabric } from "fabric"; import { get, type Readable } from "svelte/store"; import { stopDraw } from "./lib"; import { undoStack } from "./tool-undo-redo"; export const fillMask = (canvas: fabric.Canvas, colourStore: Readable): void => { // remove selectable for shapes canvas.discardActiveObject(); canvas.forEachObject(function(o) { o.selectable = false; }); canvas.selectionColor = "rgba(0, 0, 0, 0)"; stopDraw(canvas); canvas.on("mouse:down", function(o) { const target = o.target instanceof fabric.Group ? canvas.targets[0] : o.target; const colour = get(colourStore); if (!target || target.fill === colour) { return; } target.fill = colour; undoStack.onObjectModified(); }); }; ================================================ FILE: ts/routes/image-occlusion/tools/tool-polygon.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { fabric } from "fabric"; import { get } from "svelte/store"; import { opacityStateStore } from "../store"; import { BORDER_COLOR, isPointerInBoundingBox, SHAPE_MASK_COLOR } from "./lib"; import { undoStack } from "./tool-undo-redo"; import { onPinchZoom } from "./tool-zoom"; let activeLine; let activeShape; let linesList: fabric.Line[] = []; let pointsList: fabric.Circle[] = []; let drawMode = false; export const drawPolygon = (canvas: fabric.Canvas): void => { // remove selectable for shapes canvas.discardActiveObject(); canvas.forEachObject(function(o) { o.selectable = false; }); canvas.selectionColor = "rgba(0, 0, 0, 0)"; canvas.on("mouse:down", function(options) { try { if (options.target && options.target["id"] === pointsList[0]["id"]) { generatePolygon(canvas, pointsList); } else { addPoint(canvas, options); } } catch (e) { // Cannot read properties of undefined (reading 'id') } }); canvas.on("mouse:move", function(options) { // if pinch zoom is active, remove all points and lines if (onPinchZoom(options)) { removeUnfinishedPolygon(canvas); return; } if (activeLine && activeLine.class === "line") { const pointer = canvas.getPointer(options.e); activeLine.set({ x2: pointer.x, y2: pointer.y, }); const points = activeShape.get("points"); points[pointsList.length] = { x: pointer.x, y: pointer.y, }; activeShape.set({ points }); } canvas.renderAll(); }); }; const toggleDrawPolygon = (canvas: fabric.Canvas): void => { drawMode = !drawMode; if (drawMode) { activeLine = null; activeShape = null; linesList = []; pointsList = []; drawMode = false; canvas.selection = true; } else { drawMode = true; canvas.selection = false; } }; const addPoint = (canvas: fabric.Canvas, options): void => { const pointer = canvas.getPointer(options.e); const origX = pointer.x; const origY = pointer.y; if (!isPointerInBoundingBox(pointer)) { return; } const point = new fabric.Circle({ radius: 5, fill: "transparent", stroke: "#333333", strokeWidth: 1.5, originX: "center", originY: "center", left: origX, top: origY, selectable: false, hasBorders: false, hasControls: false, objectCaching: false, perPixelTargetFind: false, }); if (pointsList.length === 0) { point.set({ stroke: "red", }); } const linePoints = [origX, origY, origX, origY]; const line = new fabric.Line(linePoints, { strokeWidth: 2, fill: "#999999", stroke: "#999999", originX: "center", originY: "center", selectable: false, hasBorders: false, hasControls: false, evented: false, objectCaching: false, }); line["class"] = "line"; if (activeShape) { const pointer = canvas.getPointer(options.e); const points = activeShape.get("points"); points.push({ x: pointer.x, y: pointer.y, }); const polygon = new fabric.Polygon(points, { stroke: "#333333", strokeWidth: 1, fill: "#cccccc", opacity: 0.3, selectable: false, hasBorders: false, hasControls: false, evented: false, objectCaching: false, }); canvas.remove(activeShape); canvas.add(polygon); activeShape = polygon; canvas.renderAll(); } else { const polyPoint = [{ x: origX, y: origY }]; const polygon = new fabric.Polygon(polyPoint, { stroke: "#333333", strokeWidth: 1, fill: "#cccccc", opacity: 0.3, selectable: false, hasBorders: false, hasControls: false, evented: false, objectCaching: false, }); activeShape = polygon; canvas.add(polygon); } activeLine = line; pointsList.push(point); linesList.push(line); canvas.add(line); canvas.add(point); canvas.renderAll(); }; const generatePolygon = (canvas: fabric.Canvas, pointsList): void => { const points: { x: number; y: number }[] = []; pointsList.forEach((point) => { points.push({ x: point.left, y: point.top, }); canvas.remove(point); }); linesList.forEach((line) => { canvas.remove(line); }); canvas.remove(activeShape).remove(activeLine); const polygon = new fabric.Polygon(points, { fill: SHAPE_MASK_COLOR, objectCaching: false, stroke: BORDER_COLOR, strokeWidth: 1, strokeUniform: true, noScaleCache: false, selectable: false, opacity: get(opacityStateStore) ? 0.4 : 1, }); polygon["id"] = "polygon-" + new Date().getTime(); if (polygon.width! > 5 && polygon.height! > 5) { canvas.add(polygon); canvas.setActiveObject(polygon); // view undo redo tools undoStack.onObjectAdded(polygon["id"]); } toggleDrawPolygon(canvas); }; // https://github.com/fabricjs/fabric.js/issues/6522 export const modifiedPolygon = (canvas: fabric.Canvas, polygon: fabric.Polygon): void => { const matrix = polygon.calcTransformMatrix(); const transformedPoints = polygon.get("points")! .map(function(p) { return new fabric.Point(p.x - polygon.pathOffset.x, p.y - polygon.pathOffset.y); }) .map(function(p) { return fabric.util.transformPoint(p, matrix); }); const polygon1 = new fabric.Polygon(transformedPoints, { fill: polygon.fill ?? SHAPE_MASK_COLOR, objectCaching: false, stroke: BORDER_COLOR, strokeWidth: 1, strokeUniform: true, noScaleCache: false, opacity: get(opacityStateStore) ? 0.4 : 1, }); polygon1["id"] = polygon["id"]; canvas.remove(polygon); canvas.add(polygon1); }; /** * Removes the currently unfinished polygon, if any, and reset internal state * @returns whether or not such a polygon was removed and state was reset */ export const removeUnfinishedPolygon = (canvas: fabric.Canvas): boolean => { if (!activeShape) { // generatePolygon should've already removed points/lines and reset state return false; } canvas.remove(activeShape).remove(activeLine); pointsList.forEach((point) => { canvas.remove(point); }); linesList.forEach((line) => { canvas.remove(line); }); activeLine = null; activeShape = null; linesList = []; pointsList = []; drawMode = false; canvas.selection = true; return true; }; ================================================ FILE: ts/routes/image-occlusion/tools/tool-rect.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { fabric } from "fabric"; import { get } from "svelte/store"; import { opacityStateStore } from "../store"; import { BORDER_COLOR, isPointerInBoundingBox, SHAPE_MASK_COLOR, stopDraw } from "./lib"; import { undoStack } from "./tool-undo-redo"; import { onPinchZoom } from "./tool-zoom"; export const drawRectangle = (canvas: fabric.Canvas): void => { canvas.selectionColor = "rgba(0, 0, 0, 0)"; let rect, isDown, origX, origY; stopDraw(canvas); canvas.on("mouse:down", function(o) { if (o.target) { return; } isDown = true; const pointer = canvas.getPointer(o.e); origX = pointer.x; origY = pointer.y; if (!isPointerInBoundingBox(pointer)) { isDown = false; return; } rect = new fabric.Rect({ left: origX, top: origY, originX: "left", originY: "top", width: pointer.x - origX, height: pointer.y - origY, angle: 0, fill: SHAPE_MASK_COLOR, transparentCorners: false, selectable: true, stroke: BORDER_COLOR, strokeWidth: 1, strokeUniform: true, noScaleCache: false, opacity: get(opacityStateStore) ? 0.4 : 1, }); rect["id"] = "rect-" + new Date().getTime(); canvas.add(rect); }); canvas.on("mouse:move", function(o) { if (onPinchZoom(o)) { canvas.remove(rect); canvas.renderAll(); return; } if (!isDown) { return; } const pointer = canvas.getPointer(o.e); const x = pointer.x; const y = pointer.y; if (x < origX) { rect.set({ originX: "right" }); } else { rect.set({ originX: "left" }); } if (y < origY) { rect.set({ originY: "bottom" }); } else { rect.set({ originY: "top" }); } rect.set({ width: Math.abs(x - rect.left), height: Math.abs(y - rect.top), }); canvas.renderAll(); }); canvas.on("mouse:up", function() { isDown = false; // probably changed from rectangle to ellipse if (!rect) { return; } if (rect.width < 5 || rect.height < 5) { canvas.remove(rect); rect = undefined; return; } if (rect.originX === "right") { rect.set({ originX: "left", left: rect.left - rect.width + rect.strokeWidth, }); } if (rect.originY === "bottom") { rect.set({ originY: "top", top: rect.top - rect.height + rect.strokeWidth, }); } rect.setCoords(); canvas.setActiveObject(rect); undoStack.onObjectAdded(rect.id); rect = undefined; }); }; ================================================ FILE: ts/routes/image-occlusion/tools/tool-text.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { fabric } from "fabric"; import { get } from "svelte/store"; import type { Callback } from "@tslib/helpers"; import { opacityStateStore } from "../store"; import { enableUniformScaling, isPointerInBoundingBox, stopDraw, TEXT_BACKGROUND_COLOR, TEXT_COLOR, TEXT_FONT_FAMILY, TEXT_PADDING, } from "./lib"; import { undoStack } from "./tool-undo-redo"; import { onPinchZoom } from "./tool-zoom"; export const drawText = (canvas: fabric.Canvas, onActivated: Callback): void => { canvas.selectionColor = "rgba(0, 0, 0, 0)"; stopDraw(canvas); let text: fabric.IText; canvas.on("mouse:down", function(o) { if (o.target) { return; } const pointer = canvas.getPointer(o.e); if (!isPointerInBoundingBox(pointer)) { return; } text = new fabric.IText("text", { left: pointer.x, top: pointer.y, originX: "left", originY: "top", selectable: true, strokeWidth: 1, noScaleCache: false, fill: TEXT_COLOR, fontFamily: TEXT_FONT_FAMILY, backgroundColor: TEXT_BACKGROUND_COLOR, padding: TEXT_PADDING, opacity: get(opacityStateStore) ? 0.4 : 1, lineHeight: 1, lockScalingFlip: true, }); text["id"] = "text-" + new Date().getTime(); enableUniformScaling(canvas, text); canvas.add(text); canvas.setActiveObject(text); undoStack.onObjectAdded(text.id); text.enterEditing(); text.selectAll(); onActivated(); }); canvas.on("mouse:move", function(o) { if (onPinchZoom(o)) { canvas.remove(text); canvas.renderAll(); return; } }); }; ================================================ FILE: ts/routes/image-occlusion/tools/tool-undo-redo.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import * as tr from "@generated/ftl"; import { fabric } from "fabric"; import { writable } from "svelte/store"; import { mdiRedo, mdiUndo } from "$lib/components/icons"; import { saveNeededStore } from "../store"; import { redoKeyCombination, undoKeyCombination } from "./shortcuts"; import { removeUnfinishedPolygon } from "./tool-polygon"; /** * Undo redo for rectangle and ellipse handled here, * view tool-polygon for handling undo redo in case of polygon */ type UndoState = { undoable: boolean; redoable: boolean; }; const shapeType = ["rect", "ellipse", "i-text", "group"]; const validShape = (shape: fabric.Object): boolean => { if (shape.width! <= 5 || shape.height! <= 5) { return false; } if (shapeType.indexOf(shape.type!) === -1) { return false; } return true; }; class UndoStack { private stack: string[] = []; private index = -1; private canvas: fabric.Canvas | undefined; private locked = false; private shapeIds = new Set(); /** used to make the toolbar buttons reactive */ private state = writable({ undoable: false, redoable: false }); subscribe: typeof this.state.subscribe; constructor() { // allows an instance of the class to act as a store this.subscribe = this.state.subscribe; } setCanvas(canvas: fabric.Canvas): void { this.canvas = canvas; this.canvas.on("object:modified", (opts) => this.maybePush(opts)); this.canvas.on("object:removed", (opts) => { if (!opts.target!.group && !opts.target!.destroyed) { this.maybePush(opts); } }); } reset(): void { this.shapeIds.clear(); this.stack.length = 0; this.index = -1; this.push(); this.updateState(); } private canUndo(): boolean { return this.index > 0; } private canRedo(): boolean { return this.index < this.stack.length - 1; } private updateState(): void { this.state.set({ undoable: this.canUndo(), redoable: this.canRedo(), }); } private updateCanvas(): void { this.locked = true; this.canvas?.loadFromJSON(this.stack[this.index], () => { this.canvas?.renderAll(); saveNeededStore.set(true); this.locked = false; }); // make bounding box unselectable this.canvas?.forEachObject((obj) => { if (obj instanceof fabric.Rect && obj.fill === "transparent") { obj.selectable = false; } }); } onObjectAdded(id: string): void { if (!this.shapeIds.has(id)) { this.push(); } this.shapeIds.add(id); saveNeededStore.set(true); } onObjectModified(): void { this.push(); saveNeededStore.set(true); } private maybePush(obj: fabric.IEvent): void { if (!this.locked && validShape(obj.target!)) { this.push(); } } private push(): void { const entry = JSON.stringify(this.canvas?.toJSON(["id"])); if (entry === this.stack[this.index]) { return; } this.stack.length = this.index + 1; this.stack.push(entry); this.index++; this.updateState(); } undo(): void { if (this.canvas && removeUnfinishedPolygon(this.canvas)) { // treat removing the unfinished polygon as an undo step return; } if (this.canUndo()) { this.index--; this.updateState(); this.updateCanvas(); } } redo(): void { if (this.canvas) { // when redoing, removing an unfinished polygon doesn't make sense as a discrete step removeUnfinishedPolygon(this.canvas); } if (this.canRedo()) { this.index++; this.updateState(); this.updateCanvas(); } } } export const undoStack = new UndoStack(); export const undoRedoTools = [ { name: "undo", icon: mdiUndo, action: () => undoStack.undo(), tooltip: tr.undoUndo, shortcut: undoKeyCombination, }, { name: "redo", icon: mdiRedo, action: () => undoStack.redo(), tooltip: tr.undoRedo, shortcut: redoKeyCombination, }, ]; ================================================ FILE: ts/routes/image-occlusion/tools/tool-zoom.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // https://codepen.io/amsunny/pen/XWGLxye // canvas.viewportTransform = [ scaleX, skewX, skewY, scaleY, translateX, translateY ] import type { fabric } from "fabric"; import Hammer from "hammerjs"; import { isDesktop } from "$lib/tslib/platform"; import type { Size } from "../types"; import { getBoundingBoxSize, redraw } from "./lib"; const minScale = 0.5; const maxScale = 5; let zoomScale = 1; export let currentScale = 1; export const enableZoom = (canvas: fabric.Canvas) => { canvas.on("mouse:wheel", onMouseWheel); }; export const enablePan = (canvas: fabric.Canvas) => { canvas.on("mouse:down", onMouseDown); canvas.on("mouse:move", onMouseMove); canvas.on("mouse:up", onMouseUp); }; export const disableZoom = (canvas: fabric.Canvas) => { canvas.off("mouse:wheel", onMouseWheel); }; export const disablePan = (canvas: fabric.Canvas) => { canvas.off("mouse:down", onMouseDown); canvas.off("mouse:move", onMouseMove); canvas.off("mouse:up", onMouseUp); }; export const zoomIn = (canvas: fabric.Canvas): void => { let zoom = canvas.getZoom(); zoom = Math.min(maxScale, zoom * 1.1); canvas.zoomToPoint({ x: canvas.width! / 2, y: canvas.height! / 2 }, zoom); constrainBoundsAroundBgImage(canvas); redraw(canvas); }; export const zoomOut = (canvas): void => { let zoom = canvas.getZoom(); zoom = Math.max(minScale, zoom / 1.1); canvas.zoomToPoint({ x: canvas.width / 2, y: canvas.height / 2 }, zoom / 1.1); constrainBoundsAroundBgImage(canvas); redraw(canvas); }; export const zoomReset = (canvas: fabric.Canvas): void => { zoomResetInner(canvas); // reset again to update the viewportTransform zoomResetInner(canvas); }; const zoomResetInner = (canvas: fabric.Canvas): void => { fitCanvasVptScale(canvas); const vpt = canvas.viewportTransform!; canvas.zoomToPoint({ x: canvas.width! / 2, y: canvas.height! / 2 }, vpt[0]); }; export const enablePinchZoom = (canvas: fabric.Canvas) => { const hammer = new Hammer(upperCanvasElement(canvas)); hammer.get("pinch").set({ enable: true }); hammer.on("pinchin pinchout", ev => { currentScale = Math.min(Math.max(minScale, ev.scale * zoomScale), maxScale); canvas.zoomToPoint({ x: canvas.width! / 2, y: canvas.height! / 2 }, currentScale); constrainBoundsAroundBgImage(canvas); redraw(canvas); }); hammer.on("pinchend pinchcancel", () => { zoomScale = currentScale; }); }; function upperCanvasElement(canvas: fabric.Canvas): HTMLElement { return canvas["upperCanvasEl"] as HTMLElement; } export const disablePinchZoom = (canvas: fabric.Canvas) => { const hammer = new Hammer(upperCanvasElement(canvas)); hammer.get("pinch").set({ enable: false }); hammer.off("pinch pinchmove pinchend pinchcancel"); }; export const onResize = (canvas: fabric.Canvas) => { setCanvasSize(canvas); zoomReset(canvas); }; const onMouseWheel = (opt) => { const canvas = globalThis.canvas; const delta = opt.e.deltaY; let zoom = canvas.getZoom(); zoom *= 0.999 ** delta; zoom = Math.max(minScale, Math.min(zoom, maxScale)); canvas.zoomToPoint({ x: opt.pointer.x, y: opt.pointer.y }, zoom); opt.e.preventDefault(); opt.e.stopPropagation(); constrainBoundsAroundBgImage(canvas); redraw(canvas); }; const onMouseDown = (opt) => { const canvas = globalThis.canvas; canvas.discardActiveObject(); const { e } = opt; const clientX = e.type === "touchstart" ? e.touches[0].clientX : e.clientX; const clientY = e.type === "touchstart" ? e.touches[0].clientY : e.clientY; canvas.lastPosX = clientX; canvas.lastPosY = clientY; redraw(canvas); }; export const onMouseMove = (opt) => { const canvas = globalThis.canvas; canvas.discardActiveObject(); if (!canvas.viewportTransform) { return; } // handle pinch zoom and pan for mobile devices if (onPinchZoom(opt)) { return; } onDrag(canvas, opt); }; export const onPinchZoom = (opt): boolean => { const { e } = opt; const canvas = globalThis.canvas; if ((e.type === "touchmove") && (e.touches.length > 1)) { onDrag(canvas, opt); return true; } return false; }; const onDrag = (canvas, opt) => { const { e } = opt; const clientX = e.type === "touchmove" ? e.touches[0].clientX : e.clientX; const clientY = e.type === "touchmove" ? e.touches[0].clientY : e.clientY; const vpt = canvas.viewportTransform; vpt[4] += clientX - canvas.lastPosX; vpt[5] += clientY - canvas.lastPosY; canvas.lastPosX += clientX - canvas.lastPosX; canvas.lastPosY += clientY - canvas.lastPosY; constrainBoundsAroundBgImage(canvas); redraw(canvas); }; export const onWheelDrag = (canvas: fabric.Canvas, event: WheelEvent) => { const deltaX = event.deltaX; const deltaY = event.deltaY; const vpt = canvas.viewportTransform!; canvas["lastPosX"] = event.clientX; canvas["lastPosY"] = event.clientY; vpt[4] -= deltaX; vpt[5] -= deltaY; canvas["lastPosX"] -= deltaX; canvas["lastPosY"] -= deltaY; canvas.setViewportTransform(vpt); constrainBoundsAroundBgImage(canvas); redraw(canvas); }; export const onWheelDragX = (canvas: fabric.Canvas, event: WheelEvent) => { const delta = event.deltaY; const vpt = canvas.viewportTransform!; (canvas as any).lastPosY = event.clientY!; vpt[4] -= delta; (canvas as any).lastPosX -= delta; canvas.setViewportTransform(vpt); constrainBoundsAroundBgImage(canvas); redraw(canvas); }; const onMouseUp = () => { const canvas = globalThis.canvas; canvas.setViewportTransform(canvas.viewportTransform); constrainBoundsAroundBgImage(canvas); redraw(canvas); }; export const constrainBoundsAroundBgImage = (canvas: fabric.Canvas) => { const boundingBox = getBoundingBoxSize(); const ioImage = document.getElementById("image") as HTMLImageElement; const width = boundingBox.width * canvas.getZoom(); const height = boundingBox.height * canvas.getZoom(); const left = canvas.viewportTransform![4]; const top = canvas.viewportTransform![5]; ioImage.width = width; ioImage.height = height; ioImage.style.left = `${left}px`; ioImage.style.top = `${top}px`; }; export const setCanvasSize = (canvas: fabric.Canvas) => { const width = window.innerWidth - 39; let height = window.innerHeight; height = isDesktop() ? height - 76 : height - 46; canvas.setHeight(height); canvas.setWidth(width); redraw(canvas); }; const fitCanvasVptScale = (canvas: fabric.Canvas) => { const boundingBox = getBoundingBoxSize(); const ratio = getScaleRatio(boundingBox); const vpt = canvas.viewportTransform!; const boundingBoxWidth = boundingBox.width * canvas.getZoom(); const boundingBoxHeight = boundingBox.height * canvas.getZoom(); const center = canvas.getCenter(); const translateX = center.left - (boundingBoxWidth / 2); const translateY = center.top - (boundingBoxHeight / 2); vpt[0] = ratio; vpt[3] = ratio; vpt[4] = Math.max(1, translateX); vpt[5] = Math.max(1, translateY); canvas.setViewportTransform(canvas.viewportTransform!); constrainBoundsAroundBgImage(canvas); redraw(canvas); }; const getScaleRatio = (boundingBox: Size) => { const h1 = boundingBox.height!; const w1 = boundingBox.width!; const w2 = innerWidth - 42; let h2 = window.innerHeight; h2 = isDesktop() ? h2 - 79 : h2 - 48; return Math.min(w2 / w1, h2 / h1); }; ================================================ FILE: ts/routes/image-occlusion/types.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export interface Size { width: number; height: number; } export type ConstructorParams = { [P in keyof T]?: T[P]; }; ================================================ FILE: ts/routes/import-anki-package/Header.svelte ================================================

{heading}

================================================ FILE: ts/routes/import-anki-package/ImportAnkiPackagePage.svelte ================================================ importAnkiPackage({ packagePath: path, options }, { alertOnError: false }), }} > { modal = e.detail.modal; carousel = e.detail.carousel; }} /> openHelpModal(Object.keys(settings).indexOf("withScheduling"))} > {settings.withScheduling.title} openHelpModal(Object.keys(settings).indexOf("withDeckConfigs"))} > {settings.withDeckConfigs.title}
{tr.importingUpdates()} openHelpModal( Object.keys(settings).indexOf("mergeNotetypes"), )} > {settings.mergeNotetypes.title} openHelpModal(Object.keys(settings).indexOf("updateNotes"))} > {settings.updateNotes.title} openHelpModal( Object.keys(settings).indexOf("updateNotetypes"), )} > {settings.updateNotetypes.title}
================================================ FILE: ts/routes/import-anki-package/[...path]/+page.svelte ================================================ ================================================ FILE: ts/routes/import-anki-package/[...path]/+page.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { getImportAnkiPackagePresets } from "@generated/backend"; import type { PageLoad } from "./$types"; export const load = (async ({ params }) => { const options = await getImportAnkiPackagePresets({}); return { path: params.path, options }; }) satisfies PageLoad; ================================================ FILE: ts/routes/import-anki-package/choices.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { ImportAnkiPackageUpdateCondition } from "@generated/anki/import_export_pb"; import * as tr from "@generated/ftl"; import type { Choice } from "$lib/components/EnumSelector.svelte"; export function updateChoices(): Choice[] { return [ { label: tr.importingUpdateIfNewer(), value: ImportAnkiPackageUpdateCondition.IF_NEWER, }, { label: tr.importingUpdateAlways(), value: ImportAnkiPackageUpdateCondition.ALWAYS, }, { label: tr.importingUpdateNever(), value: ImportAnkiPackageUpdateCondition.NEVER, }, ]; } ================================================ FILE: ts/routes/import-anki-package/import-anki-package-base.scss ================================================ @use "../lib/sass/bootstrap-dark"; @import "../lib/sass/base"; @import "bootstrap/scss/alert"; @import "bootstrap/scss/buttons"; @import "bootstrap/scss/button-group"; @import "bootstrap/scss/close"; @import "bootstrap/scss/grid"; @import "bootstrap/scss/transitions"; @import "bootstrap/scss/modal"; @import "bootstrap/scss/carousel"; @import "../lib/sass/bootstrap-forms"; @import "../lib/sass/bootstrap-tooltip"; .night-mode { @include bootstrap-dark.night-mode; } body { padding: 0 1em 1em 1em; } html { height: initial; } ================================================ FILE: ts/routes/import-anki-package/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import "./import-anki-package-base.scss"; import { getImportAnkiPackagePresets } from "@generated/backend"; import { ModuleName, setupI18n } from "@tslib/i18n"; import { checkNightMode } from "@tslib/nightmode"; import { modalsKey } from "$lib/components/context-keys"; import ImportAnkiPackagePage from "./ImportAnkiPackagePage.svelte"; const i18n = setupI18n({ modules: [ ModuleName.IMPORTING, ModuleName.ACTIONS, ModuleName.HELP, ModuleName.DECK_CONFIG, ModuleName.ADDING, ModuleName.EDITING, ModuleName.KEYBOARD, ], }); export async function setupImportAnkiPackagePage( path: string, ): Promise { const [_, options] = await Promise.all([ i18n, getImportAnkiPackagePresets({}), ]); const context = new Map(); context.set(modalsKey, new Map()); checkNightMode(); return new ImportAnkiPackagePage({ target: document.body, props: { path, options, }, context, }); } // eg http://localhost:40000/_anki/pages/import-anki-package.html#test-/home/dae/foo.apkg if (window.location.hash.startsWith("#test-")) { const apkgPath = window.location.hash.replace("#test-", ""); setupImportAnkiPackagePage(apkgPath); } ================================================ FILE: ts/routes/import-csv/FieldMapper.svelte ================================================ {#if $globalNotetype !== null} {#await $fieldNamesPromise then fieldNames} {#each fieldNames as label, idx} {/each} {/await} {/if} ================================================ FILE: ts/routes/import-csv/FileOptions.svelte ================================================ { modal = e.detail.modal; carousel = e.detail.carousel; }} /> openHelpModal(Object.keys(settings).indexOf("delimiter"))} > {$metadata.forceDelimiter ? settings.delimiter.title : tr.importingFieldSeparatorGuessed()} openHelpModal(Object.keys(settings).indexOf("isHtml"))} > {settings.isHtml.title} ================================================ FILE: ts/routes/import-csv/ImportCsvPage.svelte ================================================ ================================================ FILE: ts/routes/import-csv/ImportOptions.svelte ================================================ { modal = e.detail.modal; carousel = e.detail.carousel; }} /> {#if $globalNotetype !== null} { return { label: name, value: id }; })} > openHelpModal(Object.keys(settings).indexOf("notetype"))} > {settings.notetype.title} {/if} {#if deckName || $deckId} openHelpModal(Object.keys(settings).indexOf("deck"))} > {settings.deck.title} {/if} openHelpModal(Object.keys(settings).indexOf("dupeResolution"))} > {settings.dupeResolution.title} openHelpModal(Object.keys(settings).indexOf("matchScope"))} > {settings.matchScope.title} openHelpModal(Object.keys(settings).indexOf("globalTags"))} > {settings.globalTags.title} openHelpModal(Object.keys(settings).indexOf("updatedTags"))} > {settings.updatedTags.title} ================================================ FILE: ts/routes/import-csv/MapperRow.svelte ================================================
{rowLabel}
${day}${dayTotal}
${label} ${detail}
{#each $columnOptions.slice(1) as { label, shortLabel }} {/each} {#each rows as row} {#each row as cell} {/each} {/each}
{shortLabel || label}
{cell}
================================================ FILE: ts/routes/import-csv/[...path]/+page.svelte ================================================ ================================================ FILE: ts/routes/import-csv/[...path]/+page.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { getCsvMetadata, getDeckNames, getNotetypeNames } from "@generated/backend"; import { ImportCsvState } from "../lib"; import type { PageLoad } from "./$types"; export const load = (async ({ params }) => { const [notetypes, decks, metadata] = await Promise.all([ getNotetypeNames({}), getDeckNames({ skipEmptyDefault: false, includeFiltered: false, }), getCsvMetadata({ path: params.path }, { alertOnError: false }), ]); const state = new ImportCsvState(params.path, notetypes, decks, metadata); return { state }; }) satisfies PageLoad; ================================================ FILE: ts/routes/import-csv/choices.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { CsvMetadata_Delimiter, CsvMetadata_DupeResolution, CsvMetadata_MatchScope, } from "@generated/anki/import_export_pb"; import * as tr from "@generated/ftl"; import type { Choice } from "$lib/components/EnumSelector.svelte"; export function delimiterChoices(): Choice[] { return [ { label: tr.importingTab(), value: CsvMetadata_Delimiter.TAB, }, { label: tr.importingPipe(), value: CsvMetadata_Delimiter.PIPE, }, { label: tr.importingSemicolon(), value: CsvMetadata_Delimiter.SEMICOLON, }, { label: tr.importingColon(), value: CsvMetadata_Delimiter.COLON, }, { label: tr.importingComma(), value: CsvMetadata_Delimiter.COMMA, }, { label: tr.studyingSpace(), value: CsvMetadata_Delimiter.SPACE, }, ]; } export function dupeResolutionChoices(): Choice[] { return [ { label: tr.importingUpdate(), value: CsvMetadata_DupeResolution.UPDATE }, { label: tr.importingPreserve(), value: CsvMetadata_DupeResolution.PRESERVE }, { label: tr.importingDuplicate(), value: CsvMetadata_DupeResolution.DUPLICATE }, ]; } export function matchScopeChoices(): Choice[] { return [ { label: tr.notetypesNotetype(), value: CsvMetadata_MatchScope.NOTETYPE }, { label: tr.importingNotetypeAndDeck(), value: CsvMetadata_MatchScope.NOTETYPE_AND_DECK }, ]; } ================================================ FILE: ts/routes/import-csv/import-csv-base.scss ================================================ @use "../lib/sass/bootstrap-dark"; @import "../lib/sass/base"; @import "bootstrap/scss/alert"; @import "bootstrap/scss/buttons"; @import "bootstrap/scss/button-group"; @import "bootstrap/scss/close"; @import "bootstrap/scss/grid"; @import "bootstrap/scss/transitions"; @import "bootstrap/scss/modal"; @import "bootstrap/scss/carousel"; @import "../lib/sass/bootstrap-forms"; @import "../lib/sass/bootstrap-tooltip"; .night-mode { @include bootstrap-dark.night-mode; } body { padding: 0 1em 1em 1em; } html { height: initial; } ================================================ FILE: ts/routes/import-csv/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import "./import-csv-base.scss"; import { getCsvMetadata, getDeckNames, getNotetypeNames } from "@generated/backend"; import { ModuleName, setupI18n } from "@tslib/i18n"; import { checkNightMode } from "@tslib/nightmode"; import { modalsKey } from "$lib/components/context-keys"; import ErrorPage from "$lib/components/ErrorPage.svelte"; import ImportCsvPage from "./ImportCsvPage.svelte"; import { ImportCsvState } from "./lib"; const i18n = setupI18n({ modules: [ ModuleName.ACTIONS, ModuleName.CHANGE_NOTETYPE, ModuleName.DECKS, ModuleName.EDITING, ModuleName.IMPORTING, ModuleName.KEYBOARD, ModuleName.NOTETYPES, ModuleName.STUDYING, ModuleName.ADDING, ModuleName.HELP, ModuleName.DECK_CONFIG, ], }); export async function setupImportCsvPage(path: string): Promise { const context = new Map(); context.set(modalsKey, new Map()); checkNightMode(); return Promise.all([ getNotetypeNames({}), getDeckNames({ skipEmptyDefault: false, includeFiltered: false, }), getCsvMetadata({ path }, { alertOnError: false }), i18n, ]).then(([notetypes, decks, metadata]) => { return new ImportCsvPage({ target: document.body, props: { state: new ImportCsvState(path, notetypes, decks, metadata), }, context, }); }).catch((error) => { return new ErrorPage({ target: document.body, props: { error } }); }); } /* // use #testXXXX where XXXX is notetype ID to test if (window.location.hash.startsWith("#test")) { const ntid = parseInt(window.location.hash.substr("#test".length), 10); setupCsvImportPage(ntid, ntid); } */ ================================================ FILE: ts/routes/import-csv/lib.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { DeckNameId, DeckNames } from "@generated/anki/decks_pb"; import type { CsvMetadata, CsvMetadata_Delimiter, ImportResponse } from "@generated/anki/import_export_pb"; import { type CsvMetadata_MappedNotetype } from "@generated/anki/import_export_pb"; import type { NotetypeNameId, NotetypeNames } from "@generated/anki/notetypes_pb"; import { getCsvMetadata, getFieldNames, importCsv } from "@generated/backend"; import * as tr from "@generated/ftl"; import { cloneDeep, isEqual, noop } from "lodash-es"; import type { Readable, Writable } from "svelte/store"; import { readable, writable } from "svelte/store"; export interface ColumnOption { label: string; shortLabel?: string; value: number; disabled: boolean; } export function getGlobalNotetype(meta: CsvMetadata): CsvMetadata_MappedNotetype | null { return meta.notetype.case === "globalNotetype" ? meta.notetype.value : null; } export function getDeckId(meta: CsvMetadata): bigint { return meta.deck.case === "deckId" ? meta.deck.value : 0n; } export function getDeckName(meta: CsvMetadata): string | null { return meta.deck.case === "deckName" ? meta.deck.value : null; } export class ImportCsvState { readonly path: string; readonly deckNameIds: DeckNameId[]; readonly notetypeNameIds: NotetypeNameId[]; readonly defaultDelimiter: CsvMetadata_Delimiter; readonly defaultIsHtml: boolean; readonly defaultNotetypeId: bigint | null; readonly defaultDeckId: bigint | null; readonly newDeckName: string | null; readonly metadata: Writable; readonly globalNotetype: Writable; readonly deckId: Writable; readonly fieldNames: Readable>; readonly columnOptions: Readable; private lastMetadata: CsvMetadata; private lastGlobalNotetype: CsvMetadata_MappedNotetype | null; private lastDeckId: bigint | null; private fieldNamesSetter: (val: Promise) => void = noop; private columnOptionsSetter: (val: ColumnOption[]) => void = noop; constructor(path: string, notetypes: NotetypeNames, decks: DeckNames, metadata: CsvMetadata) { this.path = path; this.deckNameIds = decks.entries; this.notetypeNameIds = notetypes.entries; this.lastMetadata = cloneDeep(metadata); this.metadata = writable(metadata); this.metadata.subscribe(this.onMetadataChanged.bind(this)); const globalNotetype = getGlobalNotetype(metadata); this.lastGlobalNotetype = cloneDeep(getGlobalNotetype(metadata)); this.globalNotetype = writable(cloneDeep(globalNotetype)); this.globalNotetype.subscribe(this.onGlobalNotetypeChanged.bind(this)); this.lastDeckId = getDeckId(metadata); this.deckId = writable(getDeckId(metadata)); this.deckId.subscribe(this.onDeckIdChanged.bind(this)); this.fieldNames = readable( globalNotetype === null ? Promise.resolve([]) : getFieldNames({ ntid: globalNotetype.id }).then((list) => list.vals), (set) => { this.fieldNamesSetter = set; }, ); this.columnOptions = readable(getColumnOptions(metadata), (set) => { this.columnOptionsSetter = set; }); this.defaultDelimiter = metadata.delimiter; this.defaultIsHtml = metadata.isHtml; this.defaultNotetypeId = this.lastGlobalNotetype?.id || null; this.defaultDeckId = this.lastDeckId; this.newDeckName = getDeckName(metadata); } doImport(): Promise { return importCsv({ path: this.path, metadata: { ...this.lastMetadata, preview: [] }, }, { alertOnError: false }); } private async onMetadataChanged(changed: CsvMetadata) { if (isEqual(changed, this.lastMetadata)) { return; } const shouldRefetchMetadata = this.shouldRefetchMetadata(changed); if (shouldRefetchMetadata) { const { globalTags, updatedTags } = changed; changed = await getCsvMetadata({ path: this.path, delimiter: changed.delimiter, notetypeId: getGlobalNotetype(changed)?.id, deckId: getDeckId(changed) || undefined, isHtml: changed.isHtml, }); // carry over tags changed.globalTags = globalTags; changed.updatedTags = updatedTags; } const globalNotetype = getGlobalNotetype(changed); this.globalNotetype.set(globalNotetype); if (globalNotetype !== null && globalNotetype.id !== getGlobalNotetype(this.lastMetadata)?.id) { this.fieldNamesSetter(getFieldNames({ ntid: globalNotetype.id }).then((list) => list.vals)); } if (this.shouldRebuildColumnOptions(changed)) { this.columnOptionsSetter(getColumnOptions(changed)); } this.lastMetadata = cloneDeep(changed); if (shouldRefetchMetadata) { this.metadata.set(changed); } } private shouldRefetchMetadata(changed: CsvMetadata): boolean { return changed.delimiter !== this.lastMetadata.delimiter || changed.isHtml !== this.lastMetadata.isHtml || getGlobalNotetype(changed)?.id !== getGlobalNotetype(this.lastMetadata)?.id; } private shouldRebuildColumnOptions(changed: CsvMetadata): boolean { return !isEqual(changed.columnLabels, this.lastMetadata.columnLabels) || !isEqual(changed.preview[0], this.lastMetadata.preview[0]); } private onGlobalNotetypeChanged(globalNotetype: CsvMetadata_MappedNotetype | null) { if (isEqual(globalNotetype, this.lastGlobalNotetype)) { return; } this.lastGlobalNotetype = cloneDeep(globalNotetype); if (globalNotetype !== null) { this.metadata.update((metadata) => { metadata.notetype.value = globalNotetype; return metadata; }); } } private onDeckIdChanged(deckId: bigint | null) { if (deckId === this.lastDeckId) { return; } this.lastDeckId = deckId; if (deckId !== null) { this.metadata.update((metadata) => { if (deckId !== 0n) { metadata.deck.case = "deckId"; metadata.deck.value = deckId; } else { metadata.deck.case = "deckName"; metadata.deck.value = this.newDeckName!; } return metadata; }); } } } function getColumnOptions( metadata: CsvMetadata, ): ColumnOption[] { const notetypeColumn = getNotetypeColumn(metadata); const deckColumn = getDeckColumn(metadata); return [{ label: tr.changeNotetypeNothing(), value: 0, disabled: false }].concat( metadata.columnLabels.map((label, index) => { index += 1; if (index === notetypeColumn) { return columnOption(tr.notetypesNotetype(), true, index); } else if (index === deckColumn) { return columnOption(tr.decksDeck(), true, index); } else if (index === metadata.guidColumn) { return columnOption("GUID", true, index); } else if (label === "") { return columnOption(metadata.preview[0].vals[index - 1], false, index, true); } else { return columnOption(label, false, index); } }), ); } function columnOption( label: string, disabled: boolean, index: number, shortLabel?: boolean, ): ColumnOption { return { label: label ? `${index}: ${label}` : index.toString(), shortLabel: shortLabel ? index.toString() : undefined, value: index, disabled, }; } function getDeckColumn(meta: CsvMetadata): number | null { return meta.deck.case === "deckColumn" ? meta.deck.value : null; } function getNotetypeColumn(meta: CsvMetadata): number | null { return meta.notetype.case === "notetypeColumn" ? meta.notetype.value : null; } ================================================ FILE: ts/routes/import-page/DetailsTable.svelte ================================================
{#if bottom} # {tr.importingStatus()} {tr.editingFields()} {index + 1} {rows[index].summary.action} {rows[index].note.fields.join(",")} { showInBrowser([rows[index].note]); }} > {/if}
================================================ FILE: ts/routes/import-page/ImportLogPage.svelte ================================================

{tr.importingNotesFoundInFile2({ notes: foundNotes, })}

    {#each summaries as summary} {/each}
================================================ FILE: ts/routes/import-page/ImportPage.svelte ================================================ {#if error} {:else if importResponse} {:else if importing} {:else}
(importing = true)} />
{/if} ================================================ FILE: ts/routes/import-page/QueueSummary.svelte ================================================ {#if notes.length}
  • {summary.summaryTemplate({ count: notes.length })} {#if summary.canBrowse} {/if}
  • {/if} ================================================ FILE: ts/routes/import-page/StickyHeader.svelte ================================================
    {basename(path)}
    {tr.actionsImport()}
    ================================================ FILE: ts/routes/import-page/TableCell.svelte ================================================ ================================================ FILE: ts/routes/import-page/TableCellWithTooltip.svelte ================================================ createTooltip(event.detail.element)} > ================================================ FILE: ts/routes/import-page/[...path]/+page.svelte ================================================ ================================================ FILE: ts/routes/import-page/[...path]/+page.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { PageLoad } from "./$types"; export const load = (async ({ params }) => { return { path: params.path }; }) satisfies PageLoad; ================================================ FILE: ts/routes/import-page/import-page-base.scss ================================================ @use "../lib/sass/bootstrap-dark"; @import "../lib/sass/base"; @import "../lib/sass/bootstrap-tooltip"; @import "bootstrap/scss/buttons"; .night-mode { @include bootstrap-dark.night-mode; } body { padding: 0 1em 1em 1em; } html { height: initial; } ================================================ FILE: ts/routes/import-page/index.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import "./import-page-base.scss"; import { importJsonFile, importJsonString } from "@generated/backend"; import { ModuleName, setupI18n } from "@tslib/i18n"; import { checkNightMode } from "@tslib/nightmode"; import ImportPage from "./ImportPage.svelte"; import type { LogParams } from "./types"; const i18n = setupI18n({ modules: [ ModuleName.IMPORTING, ModuleName.ADDING, ModuleName.EDITING, ModuleName.ACTIONS, ModuleName.KEYBOARD, ], }); const postOptions = { alertOnError: false }; export async function setupImportPage( params: LogParams, ): Promise { await i18n; checkNightMode(); return new ImportPage({ target: document.body, props: { path: params.path, noOptions: true, importer: { doImport: () => { switch (params.type) { case "json_file": return importJsonFile({ val: params.path }, postOptions); case "json_string": return importJsonString({ val: params.json }, postOptions); } }, }, }, }); } if (window.location.hash.startsWith("#test-")) { const path = window.location.hash.replace("#test-", ""); setupImportPage({ type: "json_file", path }); } ================================================ FILE: ts/routes/import-page/lib.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { ImportResponse_Log, ImportResponse_Note } from "@generated/anki/import_export_pb"; import { CsvMetadata_DupeResolution } from "@generated/anki/import_export_pb"; import { searchInBrowser } from "@generated/backend"; import * as tr from "@generated/ftl"; import { checkCircle, closeBox, newBox, updateIcon } from "$lib/components/icons"; import type { LogQueue, NoteRow, SummarizedLogQueues } from "./types"; function getFirstFieldQueue(log: ImportResponse_Log): { action: string; queue: LogQueue; } { let reason: string; let action: string; if (log.dupeResolution === CsvMetadata_DupeResolution.DUPLICATE) { reason = tr.importingDuplicateNoteAdded(); action = tr.importingAdded(); } else if (log.dupeResolution === CsvMetadata_DupeResolution.PRESERVE) { reason = tr.importingExistingNoteSkipped(); action = tr.importingSkipped(); } else { reason = tr.importingNoteUpdatedAsFileHadNewer(); action = tr.importingUpdated(); } const queue: LogQueue = { reason, notes: log.firstFieldMatch, }; return { action, queue }; } export function getSummaries(log: ImportResponse_Log): SummarizedLogQueues[] { const summarizedQueues = [ { queues: [ { notes: log.new, reason: tr.importingAddedNewNote(), }, ], action: tr.addingAdded(), summaryTemplate: tr.importingNotesAdded, canBrowse: true, icon: newBox, }, { queues: [ { notes: log.duplicate, reason: tr.importingExistingNoteSkipped(), }, ], action: tr.importingSkipped(), summaryTemplate: tr.importingExistingNotesSkipped, canBrowse: true, icon: checkCircle, }, { queues: [ { notes: log.updated, reason: tr.importingNoteUpdatedAsFileHadNewer(), }, ], action: tr.importingUpdated(), summaryTemplate: tr.importingNotesUpdated, canBrowse: true, icon: updateIcon, }, { queues: [ { notes: log.conflicting, reason: tr.importingNoteSkippedUpdateDueToNotetype2(), }, { notes: log.missingNotetype, reason: tr.importingNoteSkippedDueToMissingNotetype(), }, { notes: log.missingDeck, reason: tr.importingNoteSkippedDueToMissingDeck(), }, { notes: log.emptyFirstField, reason: tr.importingNoteSkippedDueToEmptyFirstField(), }, ], action: tr.importingSkipped(), summaryTemplate: tr.importingNotesFailed, canBrowse: false, icon: closeBox, }, ]; const firstFieldQueue = getFirstFieldQueue(log); for (const summary of summarizedQueues) { if (summary.action === firstFieldQueue.action) { summary.queues.push(firstFieldQueue.queue); break; } } return summarizedQueues; } export function getRows(summaries: SummarizedLogQueues[]): NoteRow[] { const rows: NoteRow[] = []; for (const summary of summaries) { for (const queue of summary.queues) { if (queue.notes) { for (const note of queue.notes) { rows.push({ summary, queue, note }); } } } } return rows; } export function showInBrowser(notes: ImportResponse_Note[]): void { searchInBrowser({ filter: { case: "nids", value: { ids: notes.map((note) => note.id!.nid) }, }, }); } ================================================ FILE: ts/routes/import-page/types.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { ImportResponse_Note } from "@generated/anki/import_export_pb"; import type { IconData } from "$lib/components/types"; export type LogQueue = { notes: ImportResponse_Note[]; reason: string; }; export type SummarizedLogQueues = { queues: LogQueue[]; action: string; summaryTemplate: (args: { count: number }) => string; canBrowse: boolean; icon: IconData; }; export type NoteRow = { summary: SummarizedLogQueues; queue: LogQueue; note: ImportResponse_Note; }; type PathParams = { type: "json_file"; path: string; }; type JsonParams = { type: "json_string"; path: string; json: string; }; export type LogParams = PathParams | JsonParams; ================================================ FILE: ts/routes/tmp/_page.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // this route pulls in code that's currently bundled separately, so that // errors in it get caught by svelte-check import * as _editor from "$lib/../editor"; import * as _reviewer from "$lib/../reviewer"; ================================================ FILE: ts/src/app.d.ts ================================================ import "@poppanator/sveltekit-svg/dist/svg"; // See https://kit.svelte.dev/docs/types#app // for information about these interfaces declare global { namespace App { // interface Error {} // interface Locals {} // interface PageData {} // interface PageState {} // interface Platform {} } } export {}; ================================================ FILE: ts/src/app.html ================================================ %sveltekit.head%
    %sveltekit.body%
    ================================================ FILE: ts/src/hooks.client.js ================================================ /** @type {import('@sveltejs/kit').HandleClientError} */ export async function handleError({ error, event, status, message }) { /** @type {any} */ const anyError = error; return { message: anyError.message, }; } ================================================ FILE: ts/svelte.config.js ================================================ import adapter from "@sveltejs/adapter-static"; import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; import { dirname, join } from "path"; import preprocess from "svelte-preprocess"; import { fileURLToPath } from "url"; // This prevents errors being shown when opening VSCode on the root of the // project, instead of the ts folder. const tsFolder = dirname(fileURLToPath(import.meta.url)); /** @type {import('@sveltejs/kit').Config} */ const config = { // preprocess() slows things down by about 10%, but allows us to use :global { ... } preprocess: [vitePreprocess(), preprocess()], kit: { adapter: adapter( { pages: "../out/sveltekit", fallback: "index.html", precompress: false }, ), alias: { "@tslib": join(tsFolder, "lib/tslib"), "@generated": join(tsFolder, "../out/ts/lib/generated"), }, files: { lib: join(tsFolder, "lib"), routes: join(tsFolder, "routes"), }, // outside of out/; as things break when out/ is a symlink outDir: join(tsFolder, ".svelte-kit"), output: { preloadStrategy: "preload-mjs" }, prerender: { crawl: false, entries: [], }, paths: {}, }, }; export default config; ================================================ FILE: ts/tools/markpure.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import * as fs from "fs"; import * as path from "path"; function allFilesInDir(directory): string[] { let results: string[] = []; const list = fs.readdirSync(directory); list.forEach(function(file) { file = path.join(directory, file); const stat = fs.statSync(file); if (stat && stat.isDirectory()) { results = results.concat(allFilesInDir(file)); } else { results.push(file); } }); return results; } function adjustFiles() { const root = process.argv[2]; const typeRe = /(make(Enum|MessageType))\(\n\s+".*",/g; const jsFiles = allFilesInDir(root).filter(f => f.endsWith(".js")); for (const file of jsFiles) { const contents = fs.readFileSync(file, "utf8"); // strip out typeName info, which appears to only be required for // certain JSON functionality (though this only saves a few hundred // bytes) const newContents = contents.replace(typeRe, "$1(\"\","); if (contents != newContents) { fs.writeFileSync(file, newContents, "utf8"); } } } adjustFiles(); ================================================ FILE: ts/tools/sql_format.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import sqlFormatter from "@sqltools/formatter"; import { createPatch } from "diff"; import { readFileSync, writeFileSync } from "fs"; import { argv } from "process"; function formatText(text: string): string { let newText: string = sqlFormatter.format(text, { indent: " ", reservedWordCase: "upper", }); // downcase some keywords that Anki uses in tables/columns for (const keyword of ["type", "fields"]) { newText = newText.replace( new RegExp(`\\b${keyword.toUpperCase()}\\b`, "g"), keyword, ); } return newText; } const [_tsx, _script, mode, ...files] = argv; const wantFix = mode == "fix"; let errorFound = false; for (const path of files) { const orig = readFileSync(path).toString(); const formatted = formatText(orig); if (orig !== formatted) { if (wantFix) { writeFileSync(path, formatted); console.log(`Fixed ${path}`); } else { if (!errorFound) { errorFound = true; console.log("SQL formatting issues found:"); } console.log(createPatch(path, orig, formatted)); } } } if (errorFound) { process.exit(1); } ================================================ FILE: ts/transform_ts.mjs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { buildSync } from "esbuild"; import { argv } from "process"; const [_node, _script, entrypoint, js_out] = argv; // support Qt 5.14 const target = ["es6", "chrome77"]; buildSync({ bundle: false, entryPoints: [entrypoint], outfile: js_out, minify: true, preserveSymlinks: true, target, }); ================================================ FILE: ts/tsconfig.json ================================================ { "extends": ["./.svelte-kit/tsconfig.json"], "compilerOptions": { "allowJs": true, "checkJs": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, "strict": true, "noImplicitAny": false } } ================================================ FILE: ts/tsconfig_legacy.json ================================================ { "include": [], "exclude": [], "references": [ { "path": "components" }, { "path": "congrats" }, { "path": "deck-options" }, { "path": "editable" }, { "path": "editor" }, { "path": "graphs" }, { "path": "html-filter" }, { "path": "reviewer" }, { "path": "lib" }, { "path": "mathjax" }, { "path": "domlib" }, { "path": "sveltelib" }, { "path": "icons" } ], "compilerOptions": { "declaration": true, "isolatedModules": true, "composite": false, "target": "es2020", "module": "es2020", "lib": [ "es2017", "es2018", "es2019", "es2020", "dom", "dom.iterable" ], "outDir": "../out", "rootDir": "..", "rootDirs": [ "..", "../out" ], "baseUrl": ".", "paths": { "@generated/*": ["../out/ts/lib/generated/*"], "@tslib/*": ["lib/tslib/*"], "$lib/*": ["lib/*"] }, "types": [], "verbatimModuleSyntax": true, "strict": true, "noImplicitAny": false, "strictNullChecks": true, "strictFunctionTypes": true, "strictBindCallApply": true, "strictPropertyInitialization": true, "noImplicitThis": true, "alwaysStrict": true, "moduleResolution": "node", "allowSyntheticDefaultImports": true, "esModuleInterop": true, "jsx": "react", "noEmitHelpers": true, "importHelpers": true } } ================================================ FILE: ts/vite.config.ts ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import svg from "@poppanator/sveltekit-svg"; import { sveltekit } from "@sveltejs/kit/vite"; import { realpathSync } from "fs"; import { defineConfig as defineViteConfig, mergeConfig } from "vite"; import { defineConfig as defineVitestConfig } from "vitest/config"; const configure = (proxy: any, _options: any) => { proxy.on("error", (err: any) => { console.log("proxy error", err); }); proxy.on("proxyReq", (proxyReq: any, req: any) => { console.log("Sending Request to the Target:", req.method, req.url); }); proxy.on("proxyRes", (proxyRes: any, req: any) => { console.log("Received Response from the Target:", proxyRes.statusCode, req.url); }); }; const viteConfig = defineViteConfig({ plugins: [sveltekit(), svg({})], build: { reportCompressedSize: false, // defaults use chrome87, but we need 77 for qt 5.14 target: ["es2020", "edge88", "firefox78", "chrome77", "safari14"], }, server: { host: "127.0.0.1", fs: { // Allow serving files project root and out dir allow: [ // realpathSync(".."), // "/home/dae/Local/build/anki/node_modules", realpathSync("../out"), // realpathSync("../out/node_modules"), ], }, proxy: { "/_anki": { target: "http://127.0.0.1:40000", changeOrigin: true, autoRewrite: true, configure, }, }, }, }); const vitestConfig = defineVitestConfig({ test: { include: ["**/*.{test,spec}.{js,ts}"], cache: { // prevent vitest from creating ts/node_modules/.vitest dir: "../node_modules/.vitest", }, }, }); export default mergeConfig(viteConfig, vitestConfig); ================================================ FILE: yarn ================================================ #!/bin/bash # Execute subcommand (eg 'yarn ...') set -e export PATH="./out/extracted/node/bin:$PATH" ./out/extracted/node/bin/yarn $* ./node_modules/.bin/license-checker-rseidelsohn --production --json \ --excludePackages anki --relativeLicensePath \ --relativeModulePath > ts/licenses.json ================================================ FILE: yarn.bat ================================================ call .\out\extracted\node\yarn %*

    )) } ================================================ FILE: rslib/src/sync/http_server/user.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::path::PathBuf; use tracing::info; use crate::collection::Collection; use crate::collection::CollectionBuilder; use crate::error; use crate::sync::collection::start::ServerSyncState; use crate::sync::error::HttpResult; use crate::sync::error::OrHttpErr; use crate::sync::http_server::media_manager::ServerMediaManager; pub(in crate::sync) struct User { pub name: String, pub password_hash: String, pub col: Option, pub sync_state: Option, pub media: ServerMediaManager, pub folder: PathBuf, } impl User { /// Run op with access to the collection. If a sync is active, it's aborted. pub(crate) fn with_col(&mut self, op: F) -> HttpResult where F: FnOnce(&mut Collection) -> HttpResult, { self.abort_stateful_sync_if_active(); self.ensure_col_open()?; op(self.col.as_mut().unwrap()) } /// Run op with the existing sync state created by start_new_sync(). If /// there is no existing state, or the current state's key does not /// match, abort the request with a conflict. pub(crate) fn with_sync_state(&mut self, skey: &str, op: F) -> HttpResult where F: FnOnce(&mut Collection, &mut ServerSyncState) -> error::Result, { match &self.sync_state { None => None.or_conflict("no active sync")?, Some(state) => { if state.skey != skey { None.or_conflict("active sync with different key")?; } } }; self.ensure_col_open()?; let state = self.sync_state.as_mut().unwrap(); let col = self.col.as_mut().or_internal_err("open col")?; // Failures in a sync op are usually caused by referential integrity issues (eg // they've sent a note without sending its associated notetype). // Returning HTTP 400 will inform the client that a DB check+full sync // is required to fix the issue. op(col, state) .inspect_err(|_e| { self.col = None; self.sync_state = None; }) .or_bad_request("op failed in sync_state") } pub(crate) fn abort_stateful_sync_if_active(&mut self) { if self.sync_state.is_some() { info!("aborting active sync"); self.sync_state = None; self.col = None; } } pub(crate) fn start_new_sync(&mut self, skey: &str) -> HttpResult<()> { self.abort_stateful_sync_if_active(); self.sync_state = Some(ServerSyncState::new(skey)); Ok(()) } pub(crate) fn ensure_col_open(&mut self) -> HttpResult<()> { if self.col.is_none() { self.col = Some(self.open_collection()?); } Ok(()) } fn open_collection(&mut self) -> HttpResult { CollectionBuilder::new(self.folder.join("collection.anki2")) .set_server(true) .build() .or_internal_err("open collection") } } ================================================ FILE: rslib/src/sync/login.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use reqwest::Client; use reqwest::Url; use serde::Deserialize; use serde::Serialize; use crate::prelude::*; use crate::sync::collection::protocol::SyncProtocol; use crate::sync::http_client::HttpSyncClient; use crate::sync::request::IntoSyncRequest; #[derive(Clone, Default)] pub struct SyncAuth { pub hkey: String, pub endpoint: Option, pub io_timeout_secs: Option, } #[derive(Serialize, Deserialize, Debug)] pub struct HostKeyRequest { #[serde(rename = "u")] pub username: String, #[serde(rename = "p")] pub password: String, } #[derive(Serialize, Deserialize, Debug)] pub struct HostKeyResponse { pub key: String, } pub async fn sync_login>( username: S, password: S, endpoint: Option, client: Client, ) -> Result { let auth = anki_proto::sync::SyncAuth { endpoint, ..Default::default() } .try_into()?; let client = HttpSyncClient::new(auth, client); let resp = client .host_key( HostKeyRequest { username: username.into(), password: password.into(), } .try_into_sync_request()?, ) .await? .json()?; Ok(SyncAuth { hkey: resp.key, endpoint: None, io_timeout_secs: None, }) } ================================================ FILE: rslib/src/sync/media/begin.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use serde::Deserialize; use serde::Serialize; use crate::prelude::*; // The old Rust code sent the host key in a query string #[derive(Debug, Serialize, Deserialize)] pub struct SyncBeginQuery { #[serde(rename = "k")] pub host_key: String, #[serde(rename = "v")] pub client_version: String, } #[derive(Debug, Serialize, Deserialize)] pub struct SyncBeginRequest { /// Older clients provide this in the multipart wrapper; our router will /// inject the value in if necessary. The route handler should check that /// a value has actually been provided. #[serde(rename = "v", default)] pub client_version: String, } #[derive(Debug, Serialize, Deserialize)] pub struct SyncBeginResponse { pub usn: Usn, /// The server used to send back a session key used for following requests, /// but this is no longer required. To avoid breaking older clients, the /// host key is returned in its place. #[serde(rename = "sk")] pub host_key: String, } ================================================ FILE: rslib/src/sync/media/changes.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use serde::Deserialize; use serde::Serialize; use serde_tuple::Serialize_tuple; use tracing::debug; use crate::error; use crate::prelude::Usn; use crate::sync::media::database::client::MediaDatabase; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MediaChangesRequest { pub last_usn: Usn, } pub type MediaChangesResponse = Vec; #[derive(Debug, Serialize_tuple, Deserialize)] pub struct MediaChange { pub fname: String, pub usn: Usn, pub sha1: String, } #[derive(Debug, Clone, Copy)] pub enum LocalState { NotInDb, InDbNotPending, InDbAndPending, } #[derive(PartialEq, Eq, Debug)] pub enum RequiredChange { // none also covers the case where we'll later upload None, Download, Delete, RemovePending, } pub fn determine_required_change( local_sha1: &str, remote_sha1: &str, local_state: LocalState, ) -> RequiredChange { match (local_sha1, remote_sha1, local_state) { // both deleted, not in local DB ("", "", LocalState::NotInDb) => RequiredChange::None, // both deleted, in local DB ("", "", _) => RequiredChange::Delete, // added on server, add even if local deletion pending ("", _, _) => RequiredChange::Download, // deleted on server but added locally; upload later (_, "", LocalState::InDbAndPending) => RequiredChange::None, // deleted on server and not pending sync (_, "", _) => RequiredChange::Delete, // if pending but the same as server, don't need to upload (lsum, rsum, LocalState::InDbAndPending) if lsum == rsum => RequiredChange::RemovePending, (lsum, rsum, _) => { if lsum == rsum { // not pending and same as server, nothing to do RequiredChange::None } else { // differs from server, favour server RequiredChange::Download } } } } /// Get a list of server filenames and the actions required on them. /// Returns filenames in (to_download, to_delete). pub fn determine_required_changes( ctx: &MediaDatabase, records: Vec, ) -> error::Result<(Vec, Vec, Vec)> { let mut to_download = vec![]; let mut to_delete = vec![]; let mut to_remove_pending = vec![]; for remote in records { let (local_sha1, local_state) = match ctx.get_entry(&remote.fname)? { Some(entry) => ( match entry.sha1 { Some(arr) => hex::encode(arr), None => "".to_string(), }, if entry.sync_required { LocalState::InDbAndPending } else { LocalState::InDbNotPending }, ), None => ("".to_string(), LocalState::NotInDb), }; let req_change = determine_required_change(&local_sha1, &remote.sha1, local_state); debug!( fname = &remote.fname, lsha = local_sha1.chars().take(8).collect::(), rsha = remote.sha1.chars().take(8).collect::(), state = ?local_state, action = ?req_change, "determine action" ); match req_change { RequiredChange::Download => to_download.push(remote.fname), RequiredChange::Delete => to_delete.push(remote.fname), RequiredChange::RemovePending => to_remove_pending.push(remote.fname), RequiredChange::None => (), }; } Ok((to_download, to_delete, to_remove_pending)) } #[cfg(test)] mod test { #[test] fn required_change() { use crate::sync::media::changes::determine_required_change as d; use crate::sync::media::changes::LocalState as L; use crate::sync::media::changes::RequiredChange as R; assert_eq!(d("", "", L::NotInDb), R::None); assert_eq!(d("", "", L::InDbNotPending), R::Delete); assert_eq!(d("", "1", L::InDbAndPending), R::Download); assert_eq!(d("1", "", L::InDbAndPending), R::None); assert_eq!(d("1", "", L::InDbNotPending), R::Delete); assert_eq!(d("1", "1", L::InDbNotPending), R::None); assert_eq!(d("1", "1", L::InDbAndPending), R::RemovePending); assert_eq!(d("a", "b", L::InDbAndPending), R::Download); assert_eq!(d("a", "b", L::InDbNotPending), R::Download); } } ================================================ FILE: rslib/src/sync/media/database/client/changetracker.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::path::Path; use std::time; use anki_io::read_dir_files; use tracing::debug; use crate::media::files::filename_if_normalized; use crate::media::files::mtime_as_i64; use crate::media::files::sha1_of_file; use crate::media::files::NONSYNCABLE_FILENAME; use crate::prelude::*; use crate::sync::media::database::client::MediaDatabase; use crate::sync::media::database::client::MediaEntry; use crate::sync::media::MAX_INDIVIDUAL_MEDIA_FILE_SIZE; struct FilesystemEntry { fname: String, sha1: Option, mtime: i64, is_new: bool, } pub(crate) struct ChangeTracker<'a, F> where F: FnMut(usize) -> bool, { media_folder: &'a Path, progress_cb: F, checked: usize, } impl ChangeTracker<'_, F> where F: FnMut(usize) -> bool, { pub(crate) fn new(media_folder: &Path, progress: F) -> ChangeTracker<'_, F> { ChangeTracker { media_folder, progress_cb: progress, checked: 0, } } fn fire_progress_cb(&mut self) -> Result<()> { if (self.progress_cb)(self.checked) { Ok(()) } else { Err(AnkiError::Interrupted) } } pub(crate) fn register_changes(&mut self, ctx: &MediaDatabase) -> Result<()> { ctx.transact(|ctx| { // folder mtime unchanged? let dirmod = mtime_as_i64(self.media_folder)?; let mut meta = ctx.get_meta()?; debug!( folder_mod = dirmod, db_mod = meta.folder_mtime, "begin change check" ); if dirmod == meta.folder_mtime { debug!("skip check"); return Ok(()); } else { meta.folder_mtime = dirmod; } let mtimes = ctx.all_mtimes()?; self.checked += mtimes.len(); self.fire_progress_cb()?; let (changed, removed) = self.media_folder_changes(mtimes)?; self.add_updated_entries(ctx, changed)?; self.remove_deleted_files(ctx, removed)?; ctx.set_meta(&meta)?; // unconditional fire at end of op for accurate counts self.fire_progress_cb()?; Ok(()) }) } /// Scan through the media folder, finding changes. /// Returns (added/changed files, removed files). fn media_folder_changes( &mut self, mut mtimes: HashMap, ) -> Result<(Vec, Vec)> { let mut added_or_changed = vec![]; // loop through on-disk files for dentry in read_dir_files(self.media_folder)? { let dentry = dentry?; // if the filename is not valid unicode, skip it let fname_os = dentry.file_name(); let disk_fname = match fname_os.to_str() { Some(s) => s, None => continue, }; // make sure the filename is normalized let fname = match filename_if_normalized(disk_fname) { Some(fname) => fname, None => { // not normalized; skip it debug!(fname = disk_fname, "ignore non-normalized"); continue; } }; // ignore blacklisted files if NONSYNCABLE_FILENAME.is_match(fname.as_ref()) { continue; } // ignore large files and zero byte files let metadata = dentry.metadata()?; if metadata.len() > MAX_INDIVIDUAL_MEDIA_FILE_SIZE as u64 { continue; } if metadata.len() == 0 { continue; } // remove from mtimes for later deletion tracking let previous_mtime = mtimes.remove(fname.as_ref()); // skip files that have not been modified let mtime = metadata .modified()? .duration_since(time::UNIX_EPOCH) .unwrap() .as_secs() as i64; if let Some(previous_mtime) = previous_mtime { if previous_mtime == mtime { debug!(fname = fname.as_ref(), "mtime unchanged"); continue; } } // add entry to the list let data = sha1_of_file(&dentry.path())?; let sha1 = Some(data); added_or_changed.push(FilesystemEntry { fname: fname.to_string(), sha1, mtime, is_new: previous_mtime.is_none(), }); debug!( fname = fname.as_ref(), mtime, sha1 = sha1.as_ref().map(|s| hex::encode(&s[0..4])), "added or changed" ); self.checked += 1; if self.checked % 10 == 0 { self.fire_progress_cb()?; } } // any remaining entries from the database have been deleted let removed: Vec<_> = mtimes.into_keys().collect(); for f in &removed { debug!(fname = f, "db entry missing on disk"); } Ok((added_or_changed, removed)) } /// Add added/updated entries to the media DB. /// /// Skip files where the mod time differed, but checksums are the same. fn add_updated_entries( &mut self, ctx: &MediaDatabase, entries: Vec, ) -> Result<()> { for fentry in entries { let mut sync_required = true; if !fentry.is_new { if let Some(db_entry) = ctx.get_entry(&fentry.fname)? { if db_entry.sha1 == fentry.sha1 { // mtime bumped but file contents are the same, // so we can preserve the current updated flag. // we still need to update the mtime however. sync_required = db_entry.sync_required } } }; ctx.set_entry(&MediaEntry { fname: fentry.fname, sha1: fentry.sha1, mtime: fentry.mtime, sync_required, })?; self.checked += 1; if self.checked % 10 == 0 { self.fire_progress_cb()?; } } Ok(()) } /// Remove deleted files from the media DB. fn remove_deleted_files(&mut self, ctx: &MediaDatabase, removed: Vec) -> Result<()> { for fname in removed { ctx.set_entry(&MediaEntry { fname, sha1: None, mtime: 0, sync_required: true, })?; self.checked += 1; if self.checked % 10 == 0 { self.fire_progress_cb()?; } } Ok(()) } } #[cfg(test)] mod test { use std::fs; use std::fs::FileTimes; use std::path::Path; use std::time; use std::time::Duration; use anki_io::create_dir; use anki_io::set_file_times; use anki_io::write_file; use tempfile::tempdir; use super::*; use crate::error::Result; use crate::media::files::sha1_of_data; use crate::media::MediaManager; use crate::sync::media::database::client::MediaEntry; // helper fn change_mtime(p: &Path) { let mtime = p.metadata().unwrap().modified().unwrap(); let new_mtime = mtime - Duration::from_secs(3); let times = FileTimes::new() .set_accessed(new_mtime) .set_modified(new_mtime); set_file_times(p, times).unwrap(); } #[test] fn change_tracking() -> Result<()> { let dir = tempdir()?; let media_dir = dir.path().join("media"); create_dir(&media_dir)?; let media_db = dir.path().join("media.db"); let mgr = MediaManager::new(&media_dir, media_db)?; assert_eq!(mgr.db.count()?, 0); // add a file and check it's picked up let f1 = media_dir.join("file.jpg"); write_file(&f1, "hello")?; change_mtime(&media_dir); let mut progress_cb = |_n| true; mgr.register_changes(&mut progress_cb)?; let mut entry = mgr.db.transact(|ctx| { assert_eq!(ctx.count()?, 1); assert!(!ctx.get_pending_uploads(1)?.is_empty()); let mut entry = ctx.get_entry("file.jpg")?.unwrap(); assert_eq!( entry, MediaEntry { fname: "file.jpg".into(), sha1: Some(sha1_of_data(b"hello")), mtime: f1 .metadata()? .modified()? .duration_since(time::UNIX_EPOCH) .unwrap() .as_secs() as i64, sync_required: true, } ); // mark it as unmodified entry.sync_required = false; ctx.set_entry(&entry)?; assert!(ctx.get_pending_uploads(1)?.is_empty()); // modify it write_file(&f1, "hello1")?; change_mtime(&f1); change_mtime(&media_dir); Ok(entry) })?; ChangeTracker::new(&mgr.media_folder, progress_cb).register_changes(&mgr.db)?; mgr.db.transact(|ctx| { assert_eq!(ctx.count()?, 1); assert!(!ctx.get_pending_uploads(1)?.is_empty()); assert_eq!( ctx.get_entry("file.jpg")?.unwrap(), MediaEntry { fname: "file.jpg".into(), sha1: Some(sha1_of_data(b"hello1")), mtime: f1 .metadata()? .modified()? .duration_since(time::UNIX_EPOCH) .unwrap() .as_secs() as i64, sync_required: true, } ); // mark it as unmodified entry.sync_required = false; ctx.set_entry(&entry)?; assert!(ctx.get_pending_uploads(1)?.is_empty()); Ok(()) })?; // delete it fs::remove_file(&f1)?; change_mtime(&media_dir); ChangeTracker::new(&mgr.media_folder, progress_cb).register_changes(&mgr.db)?; assert_eq!(mgr.db.count()?, 0); assert!(!mgr.db.get_pending_uploads(1)?.is_empty()); assert_eq!( mgr.db.get_entry("file.jpg")?.unwrap(), MediaEntry { fname: "file.jpg".into(), sha1: None, mtime: 0, sync_required: true, } ); Ok(()) } } ================================================ FILE: rslib/src/sync/media/database/client/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::path::Path; use rusqlite::params; use rusqlite::Connection; use rusqlite::OptionalExtension; use rusqlite::Row; use tracing::debug; use crate::error; use crate::media::files::AddedFile; use crate::media::Sha1Hash; use crate::prelude::Usn; use crate::prelude::*; pub mod changetracker; pub struct Checksums(HashMap); impl Checksums { // case-fold filenames when checking files to be imported // to account for case-insensitive filesystems pub fn get(&self, key: impl AsRef) -> Option<&Sha1Hash> { self.0.get(key.as_ref().to_lowercase().as_str()) } pub fn contains_key(&self, key: impl AsRef) -> bool { self.get(key).is_some() } } #[derive(Debug, PartialEq, Eq)] pub struct MediaEntry { pub fname: String, /// If None, file has been deleted pub sha1: Option, // Modification time; 0 if deleted pub mtime: i64, /// True if changed since last sync pub sync_required: bool, } #[derive(Debug, PartialEq, Eq)] pub struct MediaDatabaseMetadata { pub folder_mtime: i64, pub last_sync_usn: Usn, } pub struct MediaDatabase { db: Connection, } impl MediaDatabase { pub(crate) fn new(db_path: &Path) -> error::Result { Ok(MediaDatabase { db: open_or_create(db_path)?, }) } /// Execute the provided closure in a transaction, rolling back if /// an error is returned. pub(crate) fn transact(&self, func: F) -> error::Result where F: FnOnce(&MediaDatabase) -> error::Result, { self.begin()?; let mut res = func(self); if res.is_ok() { if let Err(e) = self.commit() { res = Err(e); } } if res.is_err() { self.rollback()?; } res } fn begin(&self) -> error::Result<()> { self.db.execute_batch("begin immediate").map_err(Into::into) } fn commit(&self) -> error::Result<()> { self.db.execute_batch("commit").map_err(Into::into) } fn rollback(&self) -> error::Result<()> { self.db.execute_batch("rollback").map_err(Into::into) } pub(crate) fn get_entry(&self, fname: &str) -> error::Result> { self.db .prepare_cached( " select fname, csum, mtime, dirty from media where fname=?", )? .query_row(params![fname], row_to_entry) .optional() .map_err(Into::into) } pub(crate) fn set_entry(&self, entry: &MediaEntry) -> error::Result<()> { let sha1_str = entry.sha1.map(hex::encode); self.db .prepare_cached( " insert or replace into media (fname, csum, mtime, dirty) values (?, ?, ?, ?)", )? .execute(params![ entry.fname, sha1_str, entry.mtime, entry.sync_required ])?; Ok(()) } pub(crate) fn remove_entry(&self, fname: &str) -> error::Result<()> { self.db .prepare_cached( " delete from media where fname=?", )? .execute(params![fname])?; Ok(()) } pub(crate) fn get_meta(&self) -> error::Result { let mut stmt = self.db.prepare("select dirMod, lastUsn from meta")?; stmt.query_row([], |row| { Ok(MediaDatabaseMetadata { folder_mtime: row.get(0)?, last_sync_usn: row.get(1)?, }) }) .map_err(Into::into) } pub(crate) fn set_meta(&self, meta: &MediaDatabaseMetadata) -> error::Result<()> { let mut stmt = self.db.prepare("update meta set dirMod = ?, lastUsn = ?")?; stmt.execute(params![meta.folder_mtime, meta.last_sync_usn])?; Ok(()) } pub(crate) fn count(&self) -> error::Result { self.db .query_row( "select count(*) from media where csum is not null", [], |row| row.get(0), ) .map_err(Into::into) } pub(crate) fn get_pending_uploads(&self, max_entries: u32) -> error::Result> { let mut stmt = self .db .prepare("select fname from media where dirty=1 limit ?")?; let results: error::Result> = stmt .query_and_then(params![max_entries], |row| { let fname = row.get_ref_unwrap(0).as_str()?; Ok(self.get_entry(fname)?.unwrap()) })? .collect(); results } pub(crate) fn all_mtimes(&self) -> error::Result> { let mut stmt = self .db .prepare("select fname, mtime from media where csum is not null")?; let map: std::result::Result, rusqlite::Error> = stmt .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? .collect(); Ok(map?) } /// Returns all filenames and checksums, where the checksum is not null. pub(crate) fn all_registered_checksums(&self) -> error::Result { self.db .prepare("SELECT fname, csum FROM media WHERE csum IS NOT NULL")? .query_and_then([], row_to_name_and_checksum)? .collect::>() .map(Checksums) } pub(crate) fn force_resync(&self) -> error::Result<()> { self.db .execute_batch("delete from media; update meta set lastUsn = 0, dirMod = 0") .map_err(Into::into) } pub(crate) fn record_clean(&self, clean: &[String]) -> error::Result<()> { for fname in clean { if let Some(mut entry) = self.get_entry(fname)? { if entry.sync_required { entry.sync_required = false; debug!(fname = &entry.fname, "mark clean"); self.set_entry(&entry)?; } } } Ok(()) } pub fn record_additions(&self, additions: Vec) -> error::Result<()> { for file in additions { if let Some(renamed) = file.renamed_from { // the file AnkiWeb sent us wasn't normalized, so we need to record // the old file name as a deletion debug!("marking non-normalized file as deleted: {}", renamed); let mut entry = MediaEntry { fname: renamed, sha1: None, mtime: 0, sync_required: true, }; self.set_entry(&entry)?; // and upload the new filename to ankiweb debug!("marking renamed file as needing upload: {}", file.fname); entry = MediaEntry { fname: file.fname.to_string(), sha1: Some(file.sha1), mtime: file.mtime, sync_required: true, }; self.set_entry(&entry)?; } else { // a normal addition let entry = MediaEntry { fname: file.fname.to_string(), sha1: Some(file.sha1), mtime: file.mtime, sync_required: false, }; debug!( fname = &entry.fname, sha1 = hex::encode(&entry.sha1.as_ref().unwrap()[0..4]), "mark added" ); self.set_entry(&entry)?; } } Ok(()) } pub fn record_removals(&self, removals: &[String]) -> error::Result<()> { for fname in removals { debug!(fname, "mark removed"); self.remove_entry(fname)?; } Ok(()) } } fn row_to_entry(row: &Row) -> rusqlite::Result { // map the string checksum into bytes let sha1_str = row.get_ref(1)?.as_str_or_null()?; let sha1_array = if let Some(s) = sha1_str { let mut arr = [0; 20]; match hex::decode_to_slice(s, arr.as_mut()) { Ok(_) => Some(arr), _ => None, } } else { None }; // and return the entry Ok(MediaEntry { fname: row.get(0)?, sha1: sha1_array, mtime: row.get(2)?, sync_required: row.get(3)?, }) } fn row_to_name_and_checksum(row: &Row) -> error::Result<(String, Sha1Hash)> { let file_name = row.get(0)?; let sha1_str: String = row.get(1)?; let mut sha1 = [0; 20]; if let Err(err) = hex::decode_to_slice(sha1_str, &mut sha1) { invalid_input!(err, "bad media checksum: {file_name}"); } Ok((file_name, sha1)) } fn trace(event: rusqlite::trace::TraceEvent) { if let rusqlite::trace::TraceEvent::Stmt(_, sql) = event { println!("sql: {sql}"); } } pub(crate) fn open_or_create>(path: P) -> error::Result { let mut db = Connection::open(path)?; if std::env::var("TRACESQL").is_ok() { db.trace_v2( rusqlite::trace::TraceEventCodes::SQLITE_TRACE_STMT, Some(trace), ); } db.pragma_update(None, "page_size", 4096)?; db.pragma_update(None, "legacy_file_format", false)?; db.pragma_update_and_check(None, "journal_mode", "wal", |_| Ok(()))?; initial_db_setup(&mut db)?; Ok(db) } fn initial_db_setup(db: &mut Connection) -> error::Result<()> { // tables already exist? if db .prepare_cached("select null from sqlite_master where type = 'table' and name = 'media'")? .exists([])? { return Ok(()); } db.execute("begin", [])?; db.execute_batch(include_str!("schema.sql"))?; db.execute_batch("commit; vacuum; analyze;")?; Ok(()) } #[cfg(test)] mod test { use anki_io::new_tempfile; use tempfile::TempDir; use crate::error::Result; use crate::media::files::sha1_of_data; use crate::media::MediaManager; use crate::sync::media::database::client::MediaEntry; #[test] fn database() -> Result<()> { let folder = TempDir::new()?; let db_file = new_tempfile()?; let db_file_path = db_file.path().to_str().unwrap(); let mut mgr = MediaManager::new(folder.path(), db_file_path)?; mgr.db.transact(|ctx| { // no entry exists yet assert_eq!(ctx.get_entry("test.mp3")?, None); // add one let mut entry = MediaEntry { fname: "test.mp3".into(), sha1: None, mtime: 0, sync_required: false, }; ctx.set_entry(&entry)?; assert_eq!(ctx.get_entry("test.mp3")?.unwrap(), entry); // update it entry.sha1 = Some(sha1_of_data(b"hello")); entry.mtime = 123; entry.sync_required = true; ctx.set_entry(&entry)?; assert_eq!(ctx.get_entry("test.mp3")?.unwrap(), entry); assert_eq!(ctx.get_pending_uploads(25)?, vec![entry]); let mut meta = ctx.get_meta()?; assert_eq!(meta.folder_mtime, 0); assert_eq!(meta.last_sync_usn.0, 0); meta.folder_mtime = 123; meta.last_sync_usn.0 = 321; ctx.set_meta(&meta)?; meta = ctx.get_meta()?; assert_eq!(meta.folder_mtime, 123); assert_eq!(meta.last_sync_usn.0, 321); Ok(()) })?; // reopen database and ensure data was committed drop(mgr); mgr = MediaManager::new(folder.path(), db_file_path)?; let meta = mgr.db.get_meta()?; assert_eq!(meta.folder_mtime, 123); Ok(()) } } ================================================ FILE: rslib/src/sync/media/database/client/schema.sql ================================================ CREATE TABLE media ( fname text NOT NULL PRIMARY KEY, -- null indicates deleted file csum text, -- zero if deleted mtime int NOT NULL, dirty int NOT NULL ) without rowid; CREATE INDEX idx_media_dirty ON media (dirty) WHERE dirty = 1; CREATE TABLE meta (dirMod int, lastUsn int); INSERT INTO meta VALUES (0, 0); ================================================ FILE: rslib/src/sync/media/database/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub mod client; pub mod server; ================================================ FILE: rslib/src/sync/media/database/server/entry/changes.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use rusqlite::Row; use crate::prelude::*; use crate::sync::media::changes::MediaChange; use crate::sync::media::database::server::ServerMediaDatabase; impl MediaChange { fn from_row(row: &Row) -> Result { Ok(Self { fname: row.get(0)?, usn: row.get(1)?, sha1: row.get(2)?, }) } } impl ServerMediaDatabase { pub fn media_changes_chunk(&self, after_usn: Usn) -> Result> { Ok(self .db .prepare_cached(include_str!("changes.sql"))? .query_map([after_usn], MediaChange::from_row)? .collect::>()?) } } ================================================ FILE: rslib/src/sync/media/database/server/entry/changes.sql ================================================ SELECT fname, usn, ( CASE WHEN size > 0 THEN lower(hex(csum)) ELSE '' END ) FROM media WHERE usn > ? LIMIT 1000 ================================================ FILE: rslib/src/sync/media/database/server/entry/download.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use rusqlite::params; use crate::error; use crate::sync::error::HttpResult; use crate::sync::error::OrHttpErr; use crate::sync::media::database::server::entry::MediaEntry; use crate::sync::media::database::server::ServerMediaDatabase; use crate::sync::media::MAX_MEDIA_FILES_IN_ZIP; use crate::sync::media::MEDIA_SYNC_TARGET_ZIP_BYTES; impl ServerMediaDatabase { /// Return a list of entries in the same order as the provided files, /// truncating the list if the configured total bytes is exceeded. /// /// If any file entries were missing or deleted, we don't have any way in /// the current sync protocol to signal that they should be skipped, so /// we abort with a conflict. pub fn get_entries_for_download(&self, files: &[String]) -> HttpResult> { if files.len() > MAX_MEDIA_FILES_IN_ZIP { None.or_bad_request("too many files requested")?; } let mut entries = vec![]; let mut accumulated_size = 0; for filename in files { let Some(entry) = self .get_nonempty_entry(filename) .or_internal_err("fetching entry")? else { return None.or_conflict(format!("missing/empty file entry: {filename}")); }; accumulated_size += entry.size; entries.push(entry); if accumulated_size > MEDIA_SYNC_TARGET_ZIP_BYTES as u64 { break; } } Ok(entries) } /// Delete provided file from media DB, leaving no record of deletion. It /// was probably missing due to an interrupted deletion, but removing /// the entry errs on the side of caution, ensuring the deletion doesn't /// propagate to other clients. pub fn forget_missing_file(&mut self, entry: &MediaEntry) -> error::Result<()> { assert!(entry.size > 0); self.with_transaction(|db, meta| { meta.total_bytes = meta.total_bytes.saturating_sub(entry.size); meta.total_nonempty_files = meta.total_nonempty_files.saturating_sub(1); db.db .prepare_cached("delete from media where fname = ?")? .execute(params![&entry.nfc_filename,])?; Ok(()) })?; Ok(()) } } ================================================ FILE: rslib/src/sync/media/database/server/entry/get_entry.sql ================================================ SELECT fname, csum, size, usn, mtime FROM media WHERE fname = ?; ================================================ FILE: rslib/src/sync/media/database/server/entry/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::mem; use rusqlite::params; use rusqlite::OptionalExtension; use rusqlite::Row; use crate::error; use crate::prelude::TimestampSecs; use crate::prelude::Usn; use crate::sync::media::database::server::meta::StoreMetadata; use crate::sync::media::database::server::ServerMediaDatabase; pub mod changes; mod download; pub mod upload; impl ServerMediaDatabase { /// Does not return a deletion entry. pub fn get_nonempty_entry(&self, nfc_filename: &str) -> error::Result> { self.get_entry(nfc_filename) .map(|e| e.filter(|e| !e.is_deleted())) } pub fn get_entry(&self, nfc_filename: &str) -> error::Result> { self.db .prepare_cached(include_str!("get_entry.sql"))? .query_row([nfc_filename], MediaEntry::from_row) .optional() .map_err(Into::into) } /// Saves entry to the DB, overwriting any existing entry. Does no /// validation on its own; caller is responsible for mutating meta /// (which will update mtime as well). pub fn set_entry(&mut self, entry: &mut MediaEntry) -> error::Result<()> { self.db .prepare_cached(include_str!("set_entry.sql"))? .execute(params![ &entry.nfc_filename, &entry.sha1, &entry.size, &entry.usn, &entry.mtime.0, ])?; Ok(()) } fn add_entry( &mut self, meta: &mut StoreMetadata, filename: String, total_bytes: usize, sha1: Vec, ) -> error::Result { assert!(total_bytes > 0); let mut new_entry = MediaEntry { nfc_filename: filename, sha1, size: total_bytes as u64, // set by following call usn: Default::default(), mtime: Default::default(), }; meta.add_entry(&mut new_entry); self.set_entry(&mut new_entry)?; Ok(new_entry) } /// Returns the old sha1 fn replace_entry( &mut self, meta: &mut StoreMetadata, existing_nonempty: &mut MediaEntry, total_bytes: usize, sha1: Vec, ) -> error::Result> { assert!(total_bytes > 0); meta.replace_entry(existing_nonempty, total_bytes as u64); let old_sha1 = mem::replace(&mut existing_nonempty.sha1, sha1); self.set_entry(existing_nonempty)?; Ok(old_sha1) } fn remove_entry( &mut self, meta: &mut StoreMetadata, existing_nonempty: &mut MediaEntry, ) -> error::Result<()> { meta.remove_entry(existing_nonempty); self.set_entry(existing_nonempty)?; Ok(()) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct MediaEntry { pub nfc_filename: String, pub sha1: Vec, /// Set to 0 to indicate deletion. pub size: u64, pub usn: Usn, pub mtime: TimestampSecs, } impl MediaEntry { pub fn from_row(row: &Row) -> std::result::Result { Ok(Self { nfc_filename: row.get(0)?, sha1: row.get(1)?, size: row.get(2)?, usn: row.get(3)?, mtime: TimestampSecs(row.get(4)?), }) } fn is_deleted(&self) -> bool { self.size == 0 } } ================================================ FILE: rslib/src/sync/media/database/server/entry/set_entry.sql ================================================ INSERT OR REPLACE INTO media (fname, csum, size, usn, mtime) VALUES (?, ?, ?, ?, ?); ================================================ FILE: rslib/src/sync/media/database/server/entry/upload.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::error; use crate::sync::media::database::server::meta::StoreMetadata; use crate::sync::media::database::server::ServerMediaDatabase; use crate::sync::media::zip::UploadedChange; use crate::sync::media::zip::UploadedChangeKind; pub enum UploadedChangeResult { FileAlreadyDeleted { filename: String, }, FileIdentical { filename: String, sha1: Vec, }, Added { filename: String, data: Vec, sha1: Vec, }, Removed { filename: String, sha1: Vec, }, Replaced { filename: String, data: Vec, old_sha1: Vec, new_sha1: Vec, }, } impl ServerMediaDatabase { /// Add/modify/remove a single file. pub fn register_uploaded_change( &mut self, meta: &mut StoreMetadata, update: UploadedChange, ) -> error::Result { let existing_file = self.get_nonempty_entry(&update.nfc_filename)?; match (existing_file, update.kind) { // deletion (None, UploadedChangeKind::Delete) => Ok(UploadedChangeResult::FileAlreadyDeleted { filename: update.nfc_filename, }), (Some(mut existing_nonempty), UploadedChangeKind::Delete) => { self.remove_entry(meta, &mut existing_nonempty)?; Ok(UploadedChangeResult::Removed { filename: existing_nonempty.nfc_filename, sha1: existing_nonempty.sha1, }) } // addition ( None, UploadedChangeKind::AddOrReplace { nonempty_data, sha1, }, ) => { let entry = self.add_entry(meta, update.nfc_filename, nonempty_data.len(), sha1)?; Ok(UploadedChangeResult::Added { filename: entry.nfc_filename, data: nonempty_data, sha1: entry.sha1, }) } // replacement ( Some(mut existing_nonempty), UploadedChangeKind::AddOrReplace { nonempty_data, sha1, }, ) => { if existing_nonempty.sha1 == sha1 { Ok(UploadedChangeResult::FileIdentical { filename: existing_nonempty.nfc_filename, sha1, }) } else { let old_sha1 = self.replace_entry( meta, &mut existing_nonempty, nonempty_data.len(), sha1, )?; Ok(UploadedChangeResult::Replaced { filename: existing_nonempty.nfc_filename, data: nonempty_data, old_sha1, new_sha1: existing_nonempty.sha1, }) } } } } } ================================================ FILE: rslib/src/sync/media/database/server/meta/get_meta.sql ================================================ SELECT last_usn, total_bytes, total_nonempty_files FROM meta; ================================================ FILE: rslib/src/sync/media/database/server/meta/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use rusqlite::params; use rusqlite::Row; use crate::error; use crate::prelude::TimestampSecs; use crate::prelude::Usn; use crate::sync::media::database::server::entry::MediaEntry; use crate::sync::media::database::server::ServerMediaDatabase; #[derive(Debug, PartialEq, Eq)] pub struct StoreMetadata { pub last_usn: Usn, pub total_bytes: u64, pub total_nonempty_files: u32, } impl StoreMetadata { pub(crate) fn add_entry(&mut self, entry: &mut MediaEntry) { assert!(entry.size > 0); self.total_bytes += entry.size; self.total_nonempty_files += 1; entry.usn = self.next_usn(); entry.mtime = TimestampSecs::now(); } /// Expects entry to have its old size; the new size will be set. pub(crate) fn replace_entry(&mut self, entry: &mut MediaEntry, new_size: u64) { assert!(entry.size > 0); assert!(new_size > 0); self.total_bytes = self.total_bytes.saturating_sub(entry.size) + new_size; entry.size = new_size; entry.usn = self.next_usn(); entry.mtime = TimestampSecs::now(); } pub(crate) fn remove_entry(&mut self, entry: &mut MediaEntry) { assert!(entry.size > 0); self.total_bytes = self.total_bytes.saturating_sub(entry.size); self.total_nonempty_files = self.total_nonempty_files.saturating_sub(1); entry.size = 0; entry.usn = self.next_usn(); entry.mtime = TimestampSecs::now(); } } impl StoreMetadata { fn from_row(row: &Row) -> error::Result { Ok(Self { last_usn: row.get(0)?, total_bytes: row.get(1)?, total_nonempty_files: row.get(2)?, }) } fn next_usn(&mut self) -> Usn { self.last_usn.0 += 1; self.last_usn } } impl ServerMediaDatabase { /// Perform an exclusive transaction. Will implicitly commit if no error /// returned, after flushing the updated metadata. Returns the latest /// usn. pub fn with_transaction(&mut self, op: F) -> error::Result where F: FnOnce(&mut Self, &mut StoreMetadata) -> error::Result<()>, { self.db.execute("begin exclusive", [])?; let mut meta = self.get_meta()?; op(self, &mut meta) .and_then(|_| { self.set_meta(&meta)?; self.db.execute("commit", [])?; Ok(meta.last_usn) }) .inspect_err(|_e| { let _ = self.db.execute("rollback", []); }) } pub fn last_usn(&self) -> error::Result { Ok(self.get_meta()?.last_usn) } fn get_meta(&self) -> error::Result { self.db .prepare_cached(include_str!("get_meta.sql"))? .query_row([], StoreMetadata::from_row) .map_err(Into::into) } fn set_meta(&mut self, meta: &StoreMetadata) -> error::Result<()> { self.db .prepare_cached(include_str!("set_meta.sql"))? .execute(params![ meta.last_usn, meta.total_bytes, meta.total_nonempty_files ])?; Ok(()) } pub fn nonempty_file_count(&self) -> error::Result { Ok(self.get_meta()?.total_nonempty_files) } pub fn total_bytes(&self) -> error::Result { Ok(self.get_meta()?.total_bytes) } } ================================================ FILE: rslib/src/sync/media/database/server/meta/set_meta.sql ================================================ UPDATE meta SET last_usn = ?, total_bytes = ?, total_nonempty_files = ?; ================================================ FILE: rslib/src/sync/media/database/server/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub mod entry; pub mod meta; use std::path::Path; use rusqlite::Connection; use crate::prelude::*; pub struct ServerMediaDatabase { pub db: Connection, } impl ServerMediaDatabase { pub fn new(path: &Path) -> Result { Ok(Self { db: open_or_create_db(path)?, }) } } fn open_or_create_db(path: &Path) -> Result { let db = Connection::open(path)?; db.busy_timeout(std::time::Duration::from_secs(0))?; db.pragma_update(None, "locking_mode", "exclusive")?; db.pragma_update(None, "journal_mode", "wal")?; let ver: u32 = db.query_row("select user_version from pragma_user_version", [], |r| { r.get(0) })?; if ver < 3 { db.execute_batch(include_str!("schema_v3.sql"))?; } if ver < 4 { db.execute_batch(include_str!("schema_v4.sql"))?; } Ok(db) } ================================================ FILE: rslib/src/sync/media/database/server/schema_v3.sql ================================================ BEGIN exclusive; CREATE TABLE IF NOT EXISTS media ( fname text NOT NULL PRIMARY KEY, csum blob, sz int NOT NULL, usn int NOT NULL, deleted int NOT NULL ); CREATE INDEX IF NOT EXISTS ix_usn ON media (usn); CREATE TABLE IF NOT EXISTS meta (usn int NOT NULL, sz int NOT NULL); INSERT INTO meta (usn, sz) VALUES (0, 0); pragma user_version = 3; COMMIT; ================================================ FILE: rslib/src/sync/media/database/server/schema_v4.sql ================================================ -- csum is no longer nulled on deletion -- sz renamed to size -- deleted renamed to mtime BEGIN exclusive; ALTER TABLE media RENAME TO media_tmp; DROP INDEX ix_usn; CREATE TABLE media ( fname text NOT NULL PRIMARY KEY, csum blob NOT NULL, -- if zero, file has been deleted size int NOT NULL, usn int NOT NULL, mtime int NOT NULL ); INSERT INTO media (fname, csum, size, usn, mtime) SELECT fname, csum, sz, usn, deleted FROM media_tmp WHERE csum IS NOT NULL; DROP TABLE media_tmp; CREATE INDEX ix_usn ON media (usn); DROP TABLE meta; -- columns renamed; file count added CREATE TABLE meta ( last_usn int NOT NULL, total_bytes int NOT NULL, total_nonempty_files int NOT NULL ); INSERT INTO meta (last_usn, total_bytes, total_nonempty_files) SELECT coalesce(max(usn), 0), coalesce(sum(size), 0), 0 FROM media; UPDATE meta SET total_nonempty_files = ( SELECT COUNT(*) FROM media WHERE size > 0 ); pragma user_version = 4; COMMIT; vacuum; ================================================ FILE: rslib/src/sync/media/download.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::io; use std::io::Read; use std::path::Path; use serde::Deserialize; use serde::Serialize; use crate::error; use crate::error::AnkiError; use crate::error::SyncErrorKind; use crate::media::files::add_file_from_ankiweb; use crate::media::files::AddedFile; #[derive(Debug, Serialize, Deserialize)] pub struct DownloadFilesRequest { pub files: Vec, } pub(crate) fn extract_into_media_folder( media_folder: &Path, zip: Vec, ) -> error::Result> { let reader = io::Cursor::new(zip); let mut zip = zip::ZipArchive::new(reader)?; let meta_file = zip.by_name("_meta")?; let fmap: HashMap = serde_json::from_reader(meta_file)?; let mut output = Vec::with_capacity(fmap.len()); for i in 0..zip.len() { let mut file = zip.by_index(i)?; let name = file.name(); if name == "_meta" { continue; } let real_name = fmap .get(name) .ok_or_else(|| AnkiError::sync_error("malformed zip", SyncErrorKind::Other))?; let mut data = Vec::with_capacity(file.size() as usize); file.read_to_end(&mut data)?; let added = add_file_from_ankiweb(media_folder, real_name, &data)?; output.push(added); } Ok(output) } ================================================ FILE: rslib/src/sync/media/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub mod begin; pub mod changes; pub mod database; pub mod download; pub mod progress; pub mod protocol; pub mod sanity; pub mod syncer; mod tests; pub mod upload; pub mod zip; /// The maximum length we allow a filename to be. When combined /// with the rest of the path, the full path needs to be under ~240 chars /// on some platforms, and some filesystems like eCryptFS will increase /// the length of the filename. pub static MAX_MEDIA_FILENAME_LENGTH: usize = 120; // We can't enforce the 120 limit until all clients have shifted over to the // Rust codebase. pub const MAX_MEDIA_FILENAME_LENGTH_SERVER: usize = 255; /// Media syncing does not support files over 100MiB. pub static MAX_INDIVIDUAL_MEDIA_FILE_SIZE: usize = 100 * 1024 * 1024; pub static MAX_MEDIA_FILES_IN_ZIP: usize = 25; /// If reached, no further files are placed into the zip. pub static MEDIA_SYNC_TARGET_ZIP_BYTES: usize = (2.5 * 1024.0 * 1024.0) as usize; ================================================ FILE: rslib/src/sync/media/progress.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html #[derive(Debug, Default, Clone, Copy)] pub struct MediaSyncProgress { pub checked: usize, pub downloaded_files: usize, pub downloaded_deletions: usize, pub uploaded_files: usize, pub uploaded_deletions: usize, } #[derive(Debug, Default, Clone, Copy)] #[repr(transparent)] pub struct MediaCheckProgress { pub checked: usize, } ================================================ FILE: rslib/src/sync/media/protocol.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use async_trait::async_trait; use reqwest::Url; use serde::de::DeserializeOwned; use serde::Deserialize; use serde::Serialize; use strum::IntoStaticStr; use crate::error; use crate::error::AnkiError; use crate::sync::collection::protocol::AsSyncEndpoint; use crate::sync::error::HttpResult; use crate::sync::media::begin::SyncBeginRequest; use crate::sync::media::begin::SyncBeginResponse; use crate::sync::media::changes::MediaChangesRequest; use crate::sync::media::changes::MediaChangesResponse; use crate::sync::media::download::DownloadFilesRequest; use crate::sync::media::sanity::MediaSanityCheckResponse; use crate::sync::media::sanity::SanityCheckRequest; use crate::sync::media::upload::MediaUploadResponse; use crate::sync::request::SyncRequest; use crate::sync::response::SyncResponse; #[derive(IntoStaticStr, Deserialize, PartialEq, Eq, Debug)] #[serde(rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] pub enum MediaSyncMethod { Begin, MediaChanges, UploadChanges, DownloadFiles, MediaSanity, } impl AsSyncEndpoint for MediaSyncMethod { fn as_sync_endpoint(&self, base: &Url) -> Url { base.join("msync/").unwrap().join(self.into()).unwrap() } } #[async_trait] pub trait MediaSyncProtocol: Send + Sync + 'static { async fn begin( &self, req: SyncRequest, ) -> HttpResult>>; async fn media_changes( &self, req: SyncRequest, ) -> HttpResult>>; async fn upload_changes( &self, req: SyncRequest>, ) -> HttpResult>>; async fn download_files( &self, req: SyncRequest, ) -> HttpResult>>; async fn media_sanity_check( &self, req: SyncRequest, ) -> HttpResult>>; } /// Media endpoints wrap their returns in a JSON result, and legacy /// clients expect it to always have an err field, even if it's empty. #[derive(Debug, Serialize, Deserialize)] #[serde(untagged)] pub enum JsonResult { Ok { data: T, #[serde(default)] err: String, }, Err { err: String, }, } impl JsonResult { pub fn ok(inner: T) -> Self { Self::Ok { data: inner, err: String::new(), } } } impl SyncResponse> where T: DeserializeOwned, { pub fn json_result(&self) -> error::Result { match serde_json::from_slice(&self.data)? { JsonResult::Ok { data, .. } => Ok(data), JsonResult::Err { err } => Err(AnkiError::server_message(err)), } } } ================================================ FILE: rslib/src/sync/media/sanity.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use serde::Deserialize; use serde::Serialize; #[derive(Serialize, Deserialize)] pub struct SanityCheckRequest { pub local: u32, } #[derive(Serialize, Deserialize, Eq, PartialEq, Debug)] pub enum MediaSanityCheckResponse { #[serde(rename = "OK")] Ok, #[serde(rename = "mediaSanity")] SanityCheckFailed, } ================================================ FILE: rslib/src/sync/media/syncer.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use tracing::debug; use version::sync_client_version; use crate::error::AnkiError; use crate::error::Result; use crate::error::SyncErrorKind; use crate::media::files::mtime_as_i64; use crate::media::MediaManager; use crate::prelude::*; use crate::progress::ThrottlingProgressHandler; use crate::sync::http_client::HttpSyncClient; use crate::sync::media::begin::SyncBeginRequest; use crate::sync::media::begin::SyncBeginResponse; use crate::sync::media::changes; use crate::sync::media::changes::MediaChangesRequest; use crate::sync::media::database::client::changetracker::ChangeTracker; use crate::sync::media::database::client::MediaDatabaseMetadata; use crate::sync::media::database::client::MediaEntry; use crate::sync::media::download; use crate::sync::media::download::DownloadFilesRequest; use crate::sync::media::progress::MediaSyncProgress; use crate::sync::media::protocol::MediaSyncProtocol; use crate::sync::media::sanity::MediaSanityCheckResponse; use crate::sync::media::sanity::SanityCheckRequest; use crate::sync::media::upload::gather_zip_data_for_upload; use crate::sync::media::zip::zip_files_for_upload; use crate::sync::media::MAX_MEDIA_FILES_IN_ZIP; use crate::sync::request::IntoSyncRequest; use crate::version; pub struct MediaSyncer { mgr: MediaManager, client: HttpSyncClient, progress: ThrottlingProgressHandler, } impl MediaSyncer { pub fn new( mgr: MediaManager, progress: ThrottlingProgressHandler, client: HttpSyncClient, ) -> Result { Ok(MediaSyncer { mgr, client, progress, }) } pub async fn sync(&mut self, server_usn: Option) -> Result<()> { self.sync_inner(server_usn).await.map_err(|e| { debug!("sync error: {:?}", e); e }) } #[allow(clippy::useless_let_if_seq)] async fn sync_inner(&mut self, server_usn: Option) -> Result<()> { self.register_changes()?; let meta = self.mgr.db.get_meta()?; let client_usn = meta.last_sync_usn; let server_usn = if let Some(usn) = server_usn { usn } else { self.begin_sync().await? }; let mut actions_performed = false; // need to fetch changes from server? if client_usn != server_usn { debug!("differs from local usn {}, fetching changes", client_usn); self.fetch_changes(meta).await?; actions_performed = true; } // need to send changes to server? let changes_pending = !self.mgr.db.get_pending_uploads(1)?.is_empty(); if changes_pending { self.send_changes().await?; actions_performed = true; } if actions_performed { self.finalize_sync().await?; } debug!("media sync complete"); Ok(()) } async fn begin_sync(&mut self) -> Result { debug!("begin media sync"); let SyncBeginResponse { host_key: _, usn: server_usn, } = self .client .begin( SyncBeginRequest { client_version: sync_client_version().into(), } .try_into_sync_request()?, ) .await? .json_result()?; debug!("server usn was {}", server_usn); Ok(server_usn) } /// Make sure media DB is up to date. fn register_changes(&mut self) -> Result<()> { let progress_cb = |checked| self.progress.update(true, |p| p.checked = checked).is_ok(); ChangeTracker::new(self.mgr.media_folder.as_path(), progress_cb) .register_changes(&self.mgr.db) } async fn fetch_changes(&mut self, mut meta: MediaDatabaseMetadata) -> Result<()> { let mut last_usn = meta.last_sync_usn; loop { debug!(start_usn = ?last_usn, "fetching record batch"); let batch = self .client .media_changes(MediaChangesRequest { last_usn }.try_into_sync_request()?) .await? .json_result()?; if batch.is_empty() { debug!("empty batch, done"); break; } last_usn = batch.last().unwrap().usn; self.progress.update(false, |p| p.checked += batch.len())?; let (to_download, to_delete, to_remove_pending) = changes::determine_required_changes(&self.mgr.db, batch)?; // file removal self.mgr.remove_files(to_delete.as_slice())?; self.progress .update(false, |p| p.downloaded_deletions += to_delete.len())?; // file download let mut downloaded = vec![]; let mut dl_fnames = to_download.as_slice(); while !dl_fnames.is_empty() { let batch: Vec<_> = dl_fnames .iter() .take(MAX_MEDIA_FILES_IN_ZIP) .map(ToOwned::to_owned) .collect(); let zip_data = self .client .download_files(DownloadFilesRequest { files: batch }.try_into_sync_request()?) .await? .data; let download_batch = download::extract_into_media_folder(self.mgr.media_folder.as_path(), zip_data)? .into_iter(); let len = download_batch.len(); dl_fnames = &dl_fnames[len..]; downloaded.extend(download_batch); self.progress.update(false, |p| p.downloaded_files += len)?; } // then update the DB let dirmod = mtime_as_i64(&self.mgr.media_folder)?; self.mgr.db.transact(|ctx| { ctx.record_clean(&to_remove_pending)?; ctx.record_removals(&to_delete)?; ctx.record_additions(downloaded)?; // update usn meta.last_sync_usn = last_usn; meta.folder_mtime = dirmod; ctx.set_meta(&meta)?; Ok(()) })?; } Ok(()) } async fn send_changes(&mut self) -> Result<()> { loop { let pending: Vec = self .mgr .db .get_pending_uploads(MAX_MEDIA_FILES_IN_ZIP as u32)?; if pending.is_empty() { break; } let data_for_zip = gather_zip_data_for_upload(&self.mgr.db, &self.mgr.media_folder, &pending)?; let zip_bytes = match data_for_zip { None => { // discard zip info and retry batch - not particularly efficient, // but this is a corner case self.progress .update(false, |p| p.checked += pending.len())?; continue; } Some(data) => zip_files_for_upload(data)?, }; let reply = self .client .upload_changes(zip_bytes.try_into_sync_request()?) .await? .json_result()?; let (processed_files, processed_deletions): (Vec<_>, Vec<_>) = pending .into_iter() .take(reply.processed) .partition(|e| e.sha1.is_some()); self.progress.update(false, |p| { p.uploaded_files += processed_files.len(); p.uploaded_deletions += processed_deletions.len(); })?; let fnames: Vec<_> = processed_files .into_iter() .chain(processed_deletions.into_iter()) .map(|e| e.fname) .collect(); let fname_cnt = fnames.len() as i32; self.mgr.db.transact(|ctx| { ctx.record_clean(fnames.as_slice())?; let mut meta = ctx.get_meta()?; if meta.last_sync_usn.0 + fname_cnt == reply.current_usn.0 { meta.last_sync_usn = reply.current_usn; ctx.set_meta(&meta)?; } else { debug!( "server usn {} is not {}, skipping usn update", reply.current_usn, meta.last_sync_usn.0 + fname_cnt ); } Ok(()) })?; } Ok(()) } async fn finalize_sync(&mut self) -> Result<()> { let local = self.mgr.db.count()?; let msg = self .client .media_sanity_check(SanityCheckRequest { local }.try_into_sync_request()?) .await? .json_result()?; if msg == MediaSanityCheckResponse::Ok { Ok(()) } else { self.mgr.db.transact(|ctx| ctx.force_resync())?; Err(AnkiError::sync_error("", SyncErrorKind::ResyncRequired)) } } } ================================================ FILE: rslib/src/sync/media/tests.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html #![cfg(test)] use std::fs; use std::net::IpAddr; use std::thread::sleep; use std::time::Duration; use nom::AsBytes; use reqwest::multipart; use reqwest::Client; use crate::error::Result; use crate::media::MediaManager; use crate::prelude::AnkiError; use crate::progress::ThrottlingProgressHandler; use crate::sync::collection::protocol::AsSyncEndpoint; use crate::sync::collection::tests::with_active_server; use crate::sync::collection::tests::SyncTestContext; use crate::sync::media::begin::SyncBeginQuery; use crate::sync::media::begin::SyncBeginRequest; use crate::sync::media::progress::MediaSyncProgress; use crate::sync::media::protocol::MediaSyncMethod; use crate::sync::media::protocol::MediaSyncProtocol; use crate::sync::media::sanity::MediaSanityCheckResponse; use crate::sync::media::sanity::SanityCheckRequest; use crate::sync::media::syncer::MediaSyncer; use crate::sync::media::zip::zip_files_for_upload; use crate::sync::request::IntoSyncRequest; use crate::sync::request::SyncRequest; use crate::sync::version::SyncVersion; use crate::version::sync_client_version; /// Older Rust versions sent hkey/version in GET query string. #[tokio::test] async fn begin_supports_get() -> Result<()> { with_active_server(|client_| async move { let url = client_.endpoint().join("msync/begin").unwrap(); let client = Client::new(); client .get(url) .query(&SyncBeginQuery { host_key: client_.sync_key.clone(), client_version: sync_client_version().into(), }) .send() .await? .error_for_status()?; Ok(()) }) .await } /// Older clients used a `v` variable in the begin multipart instead of placing /// the version in the JSON payload. #[tokio::test] async fn begin_supports_version_in_form() -> Result<()> { with_active_server(|client_| async move { let url = MediaSyncMethod::Begin.as_sync_endpoint(client_.endpoint()); let client = Client::new(); let form = multipart::Form::new() .text("c", "0") .text("v", "client") .text("k", client_.sync_key.clone()); client .post(url) .multipart(form) .send() .await? .error_for_status()?; Ok(()) }) .await } /// Older clients sent key in `sk` multipart variable for non-begin requests. #[tokio::test] async fn legacy_session_key_works() -> Result<()> { with_active_server(|client_| async move { let url = MediaSyncMethod::MediaChanges.as_sync_endpoint(client_.endpoint()); let client = Client::new(); let form = multipart::Form::new() .text("c", "0") .text("v", "client") .text("sk", client_.sync_key.clone()) .part( "data", multipart::Part::bytes(b"{\"lastUsn\": 0}".as_bytes()), ); client .post(url) .multipart(form) .send() .await? .error_for_status()?; Ok(()) }) .await } #[tokio::test] async fn sanity_check() -> Result<()> { with_active_server(|client| async move { let ctx = SyncTestContext::new(client.clone()); let media1 = ctx.media1(); ctx.sync_media1().await?; // may be non-zero when testing on external endpoint let starting_file_count = fs::read_dir(&media1.media_folder).unwrap().count() as u32; let resp = client .media_sanity_check( SanityCheckRequest { local: starting_file_count, } .try_into_sync_request()?, ) .await? .json_result()?; assert_eq!(resp, MediaSanityCheckResponse::Ok); let resp = client .media_sanity_check( SanityCheckRequest { local: starting_file_count + 1, } .try_into_sync_request()?, ) .await? .json_result()?; assert_eq!(resp, MediaSanityCheckResponse::SanityCheckFailed); Ok(()) }) .await } fn ignore_progress() -> ThrottlingProgressHandler { ThrottlingProgressHandler::new(Default::default()) } impl SyncTestContext { fn media1(&self) -> MediaManager { self.col1().media().unwrap() } fn media2(&self) -> MediaManager { self.col2().media().unwrap() } async fn sync_media1(&self) -> Result<()> { let mut syncer = MediaSyncer::new(self.media1(), ignore_progress(), self.client.clone()).unwrap(); syncer.sync(None).await } async fn sync_media2(&self) -> Result<()> { let mut syncer = MediaSyncer::new(self.media2(), ignore_progress(), self.client.clone()).unwrap(); syncer.sync(None).await } /// As local change detection depends on a millisecond timestamp, /// we need to wait a little while between steps to ensure changes are /// observed. Theoretically 1ms should suffice, but I was seeing flaky /// tests on a ZFS system with the delay set to a few milliseconds. fn sleep(&self) { sleep(Duration::from_millis(10)) } } #[tokio::test] async fn media_roundtrip() -> Result<()> { with_active_server(|client| async move { let ctx = SyncTestContext::new(client.clone()); let media1 = ctx.media1(); let media2 = ctx.media2(); ctx.sync_media1().await?; ctx.sync_media2().await?; ctx.sleep(); // may be non-zero when testing on external endpoint let starting_file_count = fs::read_dir(&media1.media_folder).unwrap().count(); // add some files fs::write(media1.media_folder.join("manual1"), "manual1").unwrap(); media1.add_file("auto1", b"auto1").unwrap(); fs::write(media1.media_folder.join("manual2"), "manual2").unwrap(); // sync to server and then other client ctx.sync_media1().await?; ctx.sync_media2().await?; // modify a file and remove the other ctx.sleep(); fs::write(media2.media_folder.join("manual1"), "changed1").unwrap(); fs::remove_file(media2.media_folder.join("manual2")).unwrap(); ctx.sync_media2().await?; ctx.sync_media1().await?; assert_eq!( fs::read_to_string(media1.media_folder.join("manual1")).unwrap(), "changed1" ); // remove remaining files ctx.sleep(); fs::remove_file(media1.media_folder.join("manual1")).unwrap(); fs::remove_file(media2.media_folder.join("auto1")).unwrap(); ctx.sync_media1().await?; ctx.sync_media2().await?; ctx.sync_media1().await?; assert_eq!( fs::read_dir(media1.media_folder).unwrap().count(), starting_file_count ); assert_eq!( fs::read_dir(media2.media_folder).unwrap().count(), starting_file_count ); Ok(()) }) .await } #[tokio::test] async fn parallel_requests() -> Result<()> { with_active_server(|client| async move { let ctx = SyncTestContext::new(client.clone()); let media1 = ctx.media1(); let media2 = ctx.media2(); ctx.sleep(); // multiple clients should be able to add the same file media1.add_file("auto", b"auto").unwrap(); media2.add_file("auto", b"auto").unwrap(); ctx.sync_media1().await?; // Normally the second client would notice the addition of the file when // fetching changes from the server; here we manually upload the change to // simulate two parallel syncs going on. let get_usn = || async { Ok::<_, AnkiError>( ctx.client .begin( SyncBeginRequest { client_version: "x".into(), } .try_into_sync_request()?, ) .await? .json_result()? .usn, ) }; let start_usn = get_usn().await?; let zip_data = zip_files_for_upload(vec![("auto".into(), Some(b"auto".to_vec()))])?; client .upload_changes(SyncRequest::from_data( zip_data, ctx.client.sync_key.clone(), String::new(), IpAddr::from([0, 0, 0, 0]), SyncVersion::latest(), )) .await?; let end_usn = get_usn().await?; assert_eq!(start_usn, end_usn); // Parallel deletions should work too media1.remove_files(&["auto"])?; media2.remove_files(&["auto"])?; ctx.sync_media1().await?; let start_usn = get_usn().await?; let zip_data = zip_files_for_upload(vec![("auto".into(), None)])?; client .upload_changes(SyncRequest::from_data( zip_data, ctx.client.sync_key.clone(), String::new(), IpAddr::from([0, 0, 0, 0]), SyncVersion::latest(), )) .await?; let end_usn = get_usn().await?; assert_eq!(start_usn, end_usn); // In the case of differing content, server (first sync) content wins media1.add_file("diff", b"1").unwrap(); media2.add_file("diff", b"2").unwrap(); ctx.sync_media1().await?; ctx.sync_media2().await?; assert_eq!( fs::read_to_string(media1.media_folder.join("diff")).unwrap(), "1" ); assert_eq!( fs::read_to_string(media2.media_folder.join("diff")).unwrap(), "1" ); Ok(()) }) .await } ================================================ FILE: rslib/src/sync/media/upload.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::path::Path; use serde::Deserialize; use serde_tuple::Serialize_tuple; use tracing::debug; use crate::media::files::data_for_file; use crate::media::files::normalize_filename; use crate::prelude::*; use crate::sync::media::database::client::MediaDatabase; use crate::sync::media::database::client::MediaEntry; use crate::sync::media::MAX_INDIVIDUAL_MEDIA_FILE_SIZE; use crate::sync::media::MEDIA_SYNC_TARGET_ZIP_BYTES; #[derive(Serialize_tuple, Deserialize, Debug)] pub struct MediaUploadResponse { /// Always equal to number of uploaded files now. Old AnkiWeb versions used /// to terminate processing early if too much time had elapsed, so older /// clients will upload the same material again if this is less than the /// count they uploaded. pub processed: usize, pub current_usn: Usn, } /// Filename -> Some(Data), or None in the deleted case. type ZipDataForUpload = Vec<(String, Option>)>; /// Gather [(filename, data)] for provided entries, up to configured limit. /// Data is None if file is deleted. /// Returns None if one or more of the entries were inaccessible or in the wrong /// format. pub fn gather_zip_data_for_upload( ctx: &MediaDatabase, media_folder: &Path, files: &[MediaEntry], ) -> Result> { let mut invalid_entries = vec![]; let mut accumulated_size = 0; let mut entries = vec![]; for file in files { if accumulated_size > MEDIA_SYNC_TARGET_ZIP_BYTES { break; } #[cfg(target_vendor = "apple")] { use unicode_normalization::is_nfc; if !is_nfc(&file.fname) { // older Anki versions stored non-normalized filenames in the DB; clean them up debug!(fname = file.fname, "clean up non-nfc entry"); invalid_entries.push(&file.fname); continue; } } let file_data = if file.sha1.is_some() { match data_for_file(media_folder, &file.fname) { Ok(data) => data, Err(e) => { debug!("error accessing {}: {}", &file.fname, e); invalid_entries.push(&file.fname); continue; } } } else { // uploading deletion None }; if let Some(data) = file_data { let normalized = normalize_filename(&file.fname); if let Cow::Owned(o) = normalized { debug!("media check required: {} should be {}", &file.fname, o); invalid_entries.push(&file.fname); continue; } if data.is_empty() { invalid_entries.push(&file.fname); continue; } if data.len() > MAX_INDIVIDUAL_MEDIA_FILE_SIZE { invalid_entries.push(&file.fname); continue; } accumulated_size += data.len(); entries.push((file.fname.clone(), Some(data))); debug!(file.fname, kind = "addition", "will upload"); } else { entries.push((file.fname.clone(), None)); debug!(file.fname, kind = "removal", "will upload"); } } if !invalid_entries.is_empty() { // clean up invalid entries; we'll build a new zip ctx.transact(|ctx| { for fname in invalid_entries { ctx.remove_entry(fname)?; } Ok(()) })?; return Ok(None); } Ok(Some(entries)) } ================================================ FILE: rslib/src/sync/media/zip.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::io; use std::io::Read; use std::io::Write; use serde::Deserialize; use serde_tuple::Serialize_tuple; use unicode_normalization::is_nfc; use zip::write::FileOptions; use zip::ZipWriter; use crate::media::files::sha1_of_data; use crate::prelude::*; use crate::sync::media::MAX_INDIVIDUAL_MEDIA_FILE_SIZE; use crate::sync::media::MAX_MEDIA_FILENAME_LENGTH_SERVER; pub struct ZipFileMetadata { pub filename: String, pub total_bytes: u32, pub sha1: String, } /// Write provided `[(filename, data)]` into a zip file, returning its data. /// The metadata is in a different format to the upload case, since deletions /// don't need to be represented. pub fn zip_files_for_download(files: Vec<(String, Vec)>) -> Result> { let options: FileOptions<'_, ()> = FileOptions::default().compression_method(zip::CompressionMethod::Stored); let mut zip = ZipWriter::new(io::Cursor::new(vec![])); let mut entries = HashMap::new(); for (idx, (filename, data)) in files.into_iter().enumerate() { assert!(!data.is_empty()); let idx_str = idx.to_string(); entries.insert(idx_str.clone(), filename); zip.start_file(idx_str, options)?; zip.write_all(&data)?; } let meta = serde_json::to_vec(&entries)?; zip.start_file("_meta", options)?; zip.write_all(&meta)?; Ok(zip.finish()?.into_inner()) } pub fn zip_files_for_upload(entries_: Vec<(String, Option>)>) -> Result> { let options: FileOptions<'_, ()> = FileOptions::default().compression_method(zip::CompressionMethod::Stored); let mut zip = ZipWriter::new(io::Cursor::new(vec![])); let mut entries = vec![]; for (idx, (filename, data)) in entries_.into_iter().enumerate() { match data { None => { entries.push(UploadEntry { actual_filename: filename, filename_in_zip: None, }); } Some(data) => { let idx_str = idx.to_string(); zip.start_file(&idx_str, options)?; zip.write_all(&data)?; entries.push(UploadEntry { actual_filename: filename, filename_in_zip: Some(idx_str), }); } } } let meta = serde_json::to_vec(&entries)?; zip.start_file("_meta", options)?; zip.write_all(&meta)?; Ok(zip.finish()?.into_inner()) } pub struct UploadedChange { pub nfc_filename: String, pub kind: UploadedChangeKind, } pub enum UploadedChangeKind { AddOrReplace { nonempty_data: Vec, sha1: Vec, }, Delete, } pub fn unzip_and_validate_files(zip_data: &[u8]) -> Result> { let mut zip = zip::ZipArchive::new(io::Cursor::new(zip_data))?; // meta map first, limited to a reasonable size let meta_file = zip.by_name("_meta")?; let entries: Vec = serde_json::from_reader(meta_file.take(50 * 1024))?; if entries.len() > 25 { invalid_input!("too many files in zip"); } // extract files/deletions from zip entries .into_iter() .map(|entry| { if entry.actual_filename.len() > MAX_MEDIA_FILENAME_LENGTH_SERVER { invalid_input!("filename too long: {}", entry.actual_filename.len()); } if !is_nfc(&entry.actual_filename) { invalid_input!("filename was not not in nfc: {}", entry.actual_filename); } if entry.actual_filename.contains(std::path::is_separator) { invalid_input!("filename contained separator: {}", entry.actual_filename); } let data = if let Some(filename_in_zip) = entry.filename_in_zip.as_ref() { if filename_in_zip.is_empty() { // older clients/AnkiDroid use an empty string instead of null UploadedChangeKind::Delete } else { let file = zip.by_name(filename_in_zip)?; if file.size() > MAX_INDIVIDUAL_MEDIA_FILE_SIZE as u64 { invalid_input!("file too large"); } let mut data = vec![]; // the .take() is because we don't trust the header to be correct let bytes_read = file .take(MAX_INDIVIDUAL_MEDIA_FILE_SIZE as u64) .read_to_end(&mut data)?; if bytes_read == 0 { invalid_input!("file entry was zero bytes"); } let sha1 = sha1_of_data(&data).to_vec(); UploadedChangeKind::AddOrReplace { nonempty_data: data, sha1, } } } else { UploadedChangeKind::Delete }; Ok(UploadedChange { nfc_filename: entry.actual_filename, kind: data, }) }) .collect() } #[derive(Serialize_tuple, Deserialize)] struct UploadEntry { actual_filename: String, filename_in_zip: Option, } ================================================ FILE: rslib/src/sync/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub mod collection; pub mod error; pub mod http_client; pub mod http_server; pub mod login; pub mod media; pub mod request; pub mod response; pub mod version; ================================================ FILE: rslib/src/sync/request/header_and_stream.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::fmt::Display; use std::io::Cursor; use std::io::ErrorKind; use std::marker::PhantomData; use std::net::IpAddr; use axum::http::StatusCode; use axum_extra::headers::Header; use axum_extra::headers::HeaderName; use axum_extra::headers::HeaderValue; use bytes::Bytes; use futures::Stream; use futures::TryStreamExt; use serde::de::DeserializeOwned; use serde::Deserialize; use serde::Serialize; use tokio::io::AsyncReadExt; use tokio_util::io::ReaderStream; use crate::sync::error::HttpError; use crate::sync::error::HttpResult; use crate::sync::error::OrHttpErr; use crate::sync::request::SyncRequest; use crate::sync::request::MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED; use crate::sync::version::SyncVersion; impl SyncRequest { pub(super) async fn from_header_and_stream( sync_header: SyncHeader, body_stream: S, ip: IpAddr, ) -> HttpResult> where S: Stream> + Unpin, E: Display, T: DeserializeOwned, { sync_header.sync_version.ensure_supported()?; let data = decode_zstd_body_for_server(body_stream).await?; Ok(Self { sync_key: sync_header.sync_key, session_key: sync_header.session_key, media_client_version: None, data, ip, json_output_type: PhantomData, sync_version: sync_header.sync_version, client_version: sync_header.client_ver, }) } } /// Enforces max payload size pub async fn decode_zstd_body_for_server(data: S) -> HttpResult> where S: Stream> + Unpin, E: Display, { let reader = tokio_util::io::StreamReader::new( data.map_err(|e| std::io::Error::new(ErrorKind::ConnectionAborted, format!("{e}"))), ); let reader = async_compression::tokio::bufread::ZstdDecoder::new(reader); let mut buf: Vec = vec![]; reader .take(*MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED) .read_to_end(&mut buf) .await .or_bad_request("decoding zstd body")?; Ok(buf) } /// Does not enforce payload size pub fn decode_zstd_body_stream_for_client(data: S) -> impl Stream> where S: Stream> + Unpin, E: Display, { let reader = tokio_util::io::StreamReader::new( data.map_err(|e| std::io::Error::new(ErrorKind::ConnectionAborted, format!("{e}"))), ); let reader = async_compression::tokio::bufread::ZstdDecoder::new(reader); ReaderStream::new(reader).map_err(|err| HttpError { code: StatusCode::BAD_REQUEST, context: "decode zstd body".into(), source: Some(Box::new(err) as _), }) } pub fn encode_zstd_body(data: Vec) -> impl Stream> + Unpin { let enc = async_compression::tokio::bufread::ZstdEncoder::new(Cursor::new(data)); ReaderStream::new(enc).map_err(|err| HttpError { code: StatusCode::INTERNAL_SERVER_ERROR, context: "encode zstd body".into(), source: Some(Box::new(err) as _), }) } pub fn encode_zstd_body_stream(data: S) -> impl Stream> where S: Stream> + Unpin, E: Display, { let reader = tokio_util::io::StreamReader::new( data.map_err(|e| std::io::Error::new(ErrorKind::ConnectionAborted, format!("{e}"))), ); let reader = async_compression::tokio::bufread::ZstdEncoder::new(reader); ReaderStream::new(reader).map_err(|err| HttpError { code: StatusCode::BAD_REQUEST, context: "encode zstd body".into(), source: Some(Box::new(err) as _), }) } #[derive(Serialize, Deserialize)] pub struct SyncHeader { #[serde(rename = "v")] pub sync_version: SyncVersion, #[serde(rename = "k")] pub sync_key: String, #[serde(rename = "c")] pub client_ver: String, #[serde(rename = "s")] pub session_key: String, } pub static SYNC_HEADER_NAME: HeaderName = HeaderName::from_static("anki-sync"); impl Header for SyncHeader { fn name() -> &'static HeaderName { &SYNC_HEADER_NAME } fn decode<'i, I>(values: &mut I) -> Result where Self: Sized, I: Iterator, { values .next() .and_then(|value| value.to_str().ok()) .and_then(|s| serde_json::from_str(s).ok()) .ok_or_else(axum_extra::headers::Error::invalid) } fn encode>(&self, _values: &mut E) { todo!() } } ================================================ FILE: rslib/src/sync/request/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub mod header_and_stream; mod multipart; use std::any::Any; use std::env; use std::marker::PhantomData; use std::net::IpAddr; use std::sync::LazyLock; use axum::body::Body; use axum::extract::FromRequest; use axum::extract::Multipart; use axum::http::Request; use axum::http::StatusCode; use axum::RequestPartsExt; use axum_client_ip::ClientIp; use axum_extra::TypedHeader; use header_and_stream::SyncHeader; use serde::de::DeserializeOwned; use serde::Serialize; use serde_json::Error; use tracing::Span; use crate::sync::error::HttpError; use crate::sync::error::HttpResult; use crate::sync::error::OrHttpErr; use crate::sync::version::SyncVersion; use crate::version::sync_client_version_short; /// Stores the bytes of a sync request, the associated type they /// represent, and authentication info provided in headers/multipart /// forms. For a SyncRequest, you can call .json() to get a Foo /// struct from the bytes. #[derive(Clone)] pub struct SyncRequest { pub data: Vec, json_output_type: PhantomData, pub sync_version: SyncVersion, /// empty with older clients pub client_version: String, pub ip: IpAddr, /// Non-empty on every non-login request. pub sync_key: String, /// May not be set on some requests by legacy clients. Used by stateful sync /// methods to check for concurrent access. pub session_key: String, /// Set by legacy clients when posting to msync/begin pub media_client_version: Option, } impl SyncRequest where T: DeserializeOwned, { pub fn from_data( data: Vec, host_key: String, session_key: String, ip: IpAddr, sync_version: SyncVersion, ) -> SyncRequest { SyncRequest { data, json_output_type: Default::default(), ip, sync_key: host_key, session_key, media_client_version: None, sync_version, client_version: String::new(), } } /// Given a generic Self>, infer the actual type based on context. pub fn into_output_type(self) -> SyncRequest { SyncRequest { data: self.data, json_output_type: PhantomData, ip: self.ip, sync_key: self.sync_key, session_key: self.session_key, media_client_version: self.media_client_version, sync_version: self.sync_version, client_version: self.client_version, } } pub fn json(&self) -> HttpResult { serde_json::from_slice(&self.data).or_bad_request("invalid json") } pub fn skey(&self) -> HttpResult<&str> { if self.session_key.is_empty() { None.or_bad_request("missing skey")?; } Ok(&self.session_key) } } impl FromRequest for SyncRequest where S: Send + Sync, T: DeserializeOwned, { type Rejection = HttpError; async fn from_request(req: Request, state: &S) -> Result { let (mut parts, body) = req.into_parts(); let ip = parts .extract::() .await .map_err(|_| { HttpError::new_without_source(StatusCode::INTERNAL_SERVER_ERROR, "missing ip") })? .0; Span::current().record("ip", ip.to_string()); let sync_header: Option> = parts.extract().await.or_bad_request("bad sync header")?; let req = Request::from_parts(parts, body); if let Some(TypedHeader(sync_header)) = sync_header { let stream = Body::from_request(req, state) .await .expect("infallible") .into_data_stream(); SyncRequest::from_header_and_stream(sync_header, stream, ip).await } else { let multi = Multipart::from_request(req, state) .await .or_bad_request("multipart")?; SyncRequest::from_multipart(multi, ip).await } } } pub trait IntoSyncRequest { fn try_into_sync_request(self) -> Result, serde_json::Error> where Self: Sized + 'static; } impl IntoSyncRequest for T where T: Serialize, { fn try_into_sync_request(self) -> Result, Error> where Self: Sized + 'static, { // A not-very-elegant workaround for the fact that a separate impl for vec // would conflict with this generic one. let is_data = (&self as &dyn Any).is::>(); let data = if is_data { let boxed_self = (Box::new(self) as Box) .downcast::>() .unwrap(); *boxed_self } else { serde_json::to_vec(&self)? }; Ok(SyncRequest { data, json_output_type: PhantomData, ip: IpAddr::from([0, 0, 0, 0]), media_client_version: None, sync_version: SyncVersion::latest(), client_version: sync_client_version_short().to_string(), // injected by client.request() sync_key: String::new(), session_key: String::new(), }) } } pub static MAXIMUM_SYNC_PAYLOAD_BYTES: LazyLock = LazyLock::new(|| { env::var("MAX_SYNC_PAYLOAD_MEGS") .map(|v| v.parse().expect("invalid upload limit")) .unwrap_or(100) * 1024 * 1024 }); /// Client ignores this when a non-AnkiWeb endpoint is configured. Controls the /// maximum size of a payload after decompression, which effectively limits the /// how large a collection file can be uploaded. pub static MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED: LazyLock = LazyLock::new(|| (*MAXIMUM_SYNC_PAYLOAD_BYTES * 3) as u64); ================================================ FILE: rslib/src/sync/request/multipart.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::io::Read; use std::marker::PhantomData; use std::net::IpAddr; use axum::extract::Multipart; use bytes::Buf; use bytes::Bytes; use flate2::read::GzDecoder; use tokio::task::spawn_blocking; use crate::sync::error::HttpResult; use crate::sync::error::OrHttpErr; use crate::sync::request::SyncRequest; use crate::sync::request::MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED; use crate::sync::version::SyncVersion; use crate::sync::version::SYNC_VERSION_10_V2_TIMEZONE; impl SyncRequest { pub(super) async fn from_multipart( mut multi: Multipart, ip: IpAddr, ) -> HttpResult> { let mut host_key = String::new(); let mut session_key = String::new(); let mut media_client_version = None; let mut compressed = false; let mut data = None; while let Some(field) = multi .next_field() .await .or_bad_request("invalid multipart")? { match field.name() { Some("c") => { // normal syncs should always be compressed, but media syncs may compress the // zip instead let c = field.text().await.or_bad_request("malformed c")?; compressed = c != "0"; } Some("k") | Some("sk") => { host_key = field.text().await.or_bad_request("malformed (s)k")?; } Some("s") => session_key = field.text().await.or_bad_request("malformed s")?, Some("v") => { media_client_version = Some(field.text().await.or_bad_request("malformed v")?) } Some("data") => { data = Some( field .bytes() .await .or_bad_request("missing data for multi")?, ) } _ => {} } } let data = { let data = data.unwrap_or_default(); if data.is_empty() { // AnkiDroid omits 'data' when downloading b"{}".to_vec() } else if compressed { decode_gzipped_data(data).await? } else { data.to_vec() } }; Ok(Self { ip, sync_key: host_key, session_key, media_client_version, data, json_output_type: PhantomData, // may be lower - the old protocol didn't provide the version on every request sync_version: SyncVersion(SYNC_VERSION_10_V2_TIMEZONE), client_version: String::new(), }) } } pub async fn decode_gzipped_data(data: Bytes) -> HttpResult> { // actix uses this threshold, so presumably they've measured if data.len() < 2049 { decode_gzipped_data_inner(data) } else { spawn_blocking(move || decode_gzipped_data_inner(data)) .await .or_internal_err("decode gzip join")? } } fn decode_gzipped_data_inner(data: Bytes) -> HttpResult> { let mut gz = GzDecoder::new(data.reader()).take(*MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED); let mut data = Vec::new(); gz.read_to_end(&mut data).or_bad_request("invalid gzip")?; Ok(data) } ================================================ FILE: rslib/src/sync/response.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::marker::PhantomData; use axum::body::Body; use axum::response::IntoResponse; use axum::response::Response; use axum_extra::headers::HeaderName; use serde::de::DeserializeOwned; use serde::Serialize; use crate::prelude::*; use crate::sync::collection::upload::UploadResponse; use crate::sync::error::HttpResult; use crate::sync::error::OrHttpErr; use crate::sync::request::header_and_stream::encode_zstd_body; use crate::sync::version::SyncVersion; pub static ORIGINAL_SIZE: HeaderName = HeaderName::from_static("anki-original-size"); /// Stores the data returned from a sync request, and the type /// it represents. Given a SyncResponse, you can get a Foo /// struct via .json(), except for uploads/downloads. #[derive(Debug)] pub struct SyncResponse { pub data: Vec, json_output_type: PhantomData, } impl SyncResponse { pub fn from_vec(data: Vec) -> SyncResponse { SyncResponse { data, json_output_type: Default::default(), } } pub fn make_response(self, sync_version: SyncVersion) -> Response { if sync_version.is_zstd() { let header = (&ORIGINAL_SIZE, self.data.len().to_string()); let body = Body::from_stream(encode_zstd_body(self.data)); ([header], body).into_response() } else { self.data.into_response() } } } impl SyncResponse { // Unfortunately the sync protocol sends this as a bare string // instead of JSON. pub fn upload_response(&self) -> UploadResponse { let resp = String::from_utf8_lossy(&self.data); match resp.as_ref() { "OK" => UploadResponse::Ok, other => UploadResponse::Err(other.into()), } } pub fn from_upload_response(resp: UploadResponse) -> Self { let text = match resp { UploadResponse::Ok => "OK".into(), UploadResponse::Err(other) => other, }; SyncResponse::from_vec(text.into_bytes()) } } impl SyncResponse where T: Serialize, { pub fn try_from_obj(obj: T) -> HttpResult> { let data = serde_json::to_vec(&obj).or_internal_err("couldn't serialize object")?; Ok(SyncResponse::from_vec(data)) } } impl SyncResponse where T: DeserializeOwned, { pub fn json(&self) -> Result { serde_json::from_slice(&self.data).map_err(Into::into) } } ================================================ FILE: rslib/src/sync/version.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use serde::Deserialize; use serde::Serialize; use crate::storage::SchemaVersion; use crate::sync::error::HttpResult; use crate::sync::error::OrHttpErr; pub const SYNC_VERSION_MIN: u8 = SYNC_VERSION_08_SESSIONKEY; pub const SYNC_VERSION_MAX: u8 = SYNC_VERSION_11_DIRECT_POST; /// Added in 2013. Introduced a session key to identify parallel attempts at /// syncing. At the end of 2022, only used by 0.045% of syncers. Half are /// AnkiUniversal users, as it never added support for the V2 scheduler. pub const SYNC_VERSION_08_SESSIONKEY: u8 = 8; /// Added Jan 2018. No functional changes to protocol, but marks that the client /// supports the V2 scheduler. /// /// In July 2018 a separate chunked graves method was added, but was optional. /// At the end of 2022, AnkiDroid is still using the old approach of passing all /// graves to the start method in the legacy schema path. pub const SYNC_VERSION_09_V2_SCHEDULER: u8 = 9; /// Added Mar 2020. No functional changes to protocol, but marks that the client /// supports the V2 timezone changes. pub const SYNC_VERSION_10_V2_TIMEZONE: u8 = 10; /// Added Jan 2023. Switches from packaging messages in a multipart request with /// gzip to using headers and zstd, and stops using a separate session key for /// media syncs. Schema 18 uploads/downloads are now supported, and hostNum has /// been deprecated in favour of a redirect. pub const SYNC_VERSION_11_DIRECT_POST: u8 = 11; #[derive(Debug, Serialize, Deserialize, Clone, Copy)] #[repr(transparent)] pub struct SyncVersion(pub u8); impl SyncVersion { pub fn is_too_old(&self) -> bool { self.0 < SYNC_VERSION_MIN } pub fn is_too_new(&self) -> bool { self.0 > SYNC_VERSION_MAX } pub fn ensure_supported(&self) -> HttpResult<()> { if self.is_too_old() || self.is_too_new() { None.or_bad_request(format!("unsupported sync version: {}", self.0))?; } Ok(()) } pub fn latest() -> Self { SyncVersion(SYNC_VERSION_MAX) } pub fn multipart() -> Self { Self(SYNC_VERSION_10_V2_TIMEZONE) } pub fn is_multipart(&self) -> bool { self.0 < SYNC_VERSION_11_DIRECT_POST } pub fn is_zstd(&self) -> bool { self.0 >= SYNC_VERSION_11_DIRECT_POST } pub fn collection_schema(&self) -> SchemaVersion { if self.is_multipart() { SchemaVersion::V11 } else { SchemaVersion::V18 } } } ================================================ FILE: rslib/src/tags/bulkadd.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html //! Adding tags to selected notes in the browse screen. use std::collections::HashSet; use unicase::UniCase; use super::join_tags; use super::split_tags; use crate::notes::NoteTags; use crate::prelude::*; impl Collection { pub fn add_tags_to_notes(&mut self, nids: &[NoteId], tags: &str) -> Result> { self.transact(Op::UpdateTag, |col| col.add_tags_to_notes_inner(nids, tags)) } } impl Collection { pub(crate) fn add_tags_to_notes_inner(&mut self, nids: &[NoteId], tags: &str) -> Result { let usn = self.usn()?; // will update tag list for any new tags, and match case let tags_to_add = self.canonified_tags_as_vec(tags, usn)?; // modify notes let mut match_count = 0; let notes = self.storage.get_note_tags_by_id_list(nids)?; for original in notes { if let Some(updated_tags) = add_missing_tags(&original.tags, &tags_to_add) { match_count += 1; let mut note = NoteTags { tags: updated_tags, ..original }; note.set_modified(usn); self.update_note_tags_undoable(¬e, original)?; } } Ok(match_count) } } /// Returns the sorted new tag string if any tags were added. fn add_missing_tags(note_tags: &str, desired: &[UniCase]) -> Option { let mut note_tags: HashSet<_> = split_tags(note_tags) .map(ToOwned::to_owned) .map(UniCase::new) .collect(); let mut modified = false; for tag in desired { if !note_tags.contains(tag) { note_tags.insert(tag.clone()); modified = true; } } if !modified { return None; } // sort let mut tags: Vec<_> = note_tags.into_iter().collect::>(); tags.sort_unstable(); // turn back into a string let tags: Vec<_> = tags.into_iter().map(|s| s.into_inner()).collect(); Some(join_tags(&tags)) } #[cfg(test)] mod test { use super::*; #[test] fn add_missing() { let desired: Vec<_> = ["xyz", "abc", "DEF"] .iter() .map(|s| UniCase::new(s.to_string())) .collect(); let add_to = |text| add_missing_tags(text, &desired).unwrap(); assert_eq!(&add_to(""), " abc DEF xyz "); assert_eq!(&add_to("XYZ deF aaa"), " aaa abc deF XYZ "); assert!(add_missing_tags("def xyz abc", &desired).is_none()); } } ================================================ FILE: rslib/src/tags/complete.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use regex::Regex; use crate::prelude::*; impl Collection { pub fn complete_tag(&self, input: &str, limit: usize) -> Result> { let filters: Vec<_> = input .split("::") .map(component_to_regex) .collect::>()?; let mut tags = vec![]; let mut priority = vec![]; self.storage.get_tags_by_predicate(|tag| { if priority.len() + tags.len() <= limit { match filters_match(&filters, tag) { Some(true) => priority.push(tag.to_string()), Some(_) => tags.push(tag.to_string()), _ => {} } } // we only need the tag name false })?; priority.append(&mut tags); Ok(priority) } } fn component_to_regex(component: &str) -> Result { Regex::new(&format!("(?i){}", regex::escape(component))).map_err(Into::into) } /// Returns None if tag wasn't a match, otherwise whether it was a consecutive /// prefix match fn filters_match(filters: &[Regex], tag: &str) -> Option { let mut remaining_tag_components = tag.split("::"); let mut is_prefix = true; 'outer: for filter in filters { loop { if let Some(component) = remaining_tag_components.next() { if let Some(m) = filter.find(component) { is_prefix &= m.start() == 0; continue 'outer; } else { is_prefix = false; } } else { return None; } } } Some(is_prefix) } #[cfg(test)] mod test { use super::*; #[test] fn matching() -> Result<()> { let filters = &[component_to_regex("b")?]; assert!(filters_match(filters, "ABC").is_some()); assert!(filters_match(filters, "ABC::def").is_some()); assert!(filters_match(filters, "def::abc").is_some()); assert!(filters_match(filters, "def").is_none()); let filters = &[component_to_regex("b")?, component_to_regex("E")?]; assert!(filters_match(filters, "ABC").is_none()); assert!(filters_match(filters, "ABC::def").is_some()); assert!(filters_match(filters, "def::abc").is_none()); assert!(filters_match(filters, "def").is_none()); let filters = &[ component_to_regex("a")?, component_to_regex("c")?, component_to_regex("e")?, ]; assert!(filters_match(filters, "ace").is_none()); assert!(filters_match(filters, "a::c").is_none()); assert!(filters_match(filters, "c::e").is_none()); assert!(filters_match(filters, "a::c::e").is_some()); assert!(filters_match(filters, "a::b::c::d::e").is_some()); assert!(filters_match(filters, "1::a::b::c::d::e::f").is_some()); assert_eq!(filters_match(filters, "a1::c2::e3"), Some(true)); assert_eq!(filters_match(filters, "a1::c2::?::e4"), Some(false)); assert_eq!(filters_match(filters, "a1::c2::3e"), Some(false)); Ok(()) } } ================================================ FILE: rslib/src/tags/findreplace.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use regex::NoExpand; use regex::Regex; use regex::Replacer; use super::is_tag_separator; use super::join_tags; use super::split_tags; use crate::notes::NoteTags; use crate::prelude::*; impl Collection { /// Replace occurrences of a search with a new value in tags. pub fn find_and_replace_tag( &mut self, nids: &[NoteId], search: &str, replacement: &str, regex: bool, match_case: bool, ) -> Result> { require!( !replacement.contains(is_tag_separator), "replacement name cannot contain a space", ); let mut search = if regex { Cow::from(search) } else { Cow::from(regex::escape(search)) }; if !match_case { search = format!("(?i){search}").into(); } self.transact(Op::UpdateTag, |col| { if regex { col.replace_tags_for_notes_inner(nids, Regex::new(&search)?, replacement) } else { col.replace_tags_for_notes_inner(nids, Regex::new(&search)?, NoExpand(replacement)) } }) } } impl Collection { fn replace_tags_for_notes_inner( &mut self, nids: &[NoteId], regex: Regex, mut repl: R, ) -> Result { let usn = self.usn()?; let mut match_count = 0; let notes = self.storage.get_note_tags_by_id_list(nids)?; for original in notes { if let Some(updated_tags) = replace_tags(&original.tags, ®ex, repl.by_ref()) { let (tags, _) = self.canonify_tags(updated_tags, usn)?; match_count += 1; let mut note = NoteTags { tags: join_tags(&tags), ..original }; note.set_modified(usn); self.update_note_tags_undoable(¬e, original)?; } } Ok(match_count) } } /// If any tags are changed, return the new tags list. /// The returned tags will need to be canonified. fn replace_tags(tags: &str, regex: &Regex, mut repl: R) -> Option> where R: Replacer, { let maybe_replaced: Vec<_> = split_tags(tags) .map(|tag| regex.replace_all(tag, repl.by_ref())) .collect(); if maybe_replaced .iter() .any(|cow| matches!(cow, Cow::Owned(_))) { Some(maybe_replaced.into_iter().map(|s| s.to_string()).collect()) } else { // nothing matched None } } #[cfg(test)] mod test { use super::*; use crate::decks::DeckId; #[test] fn find_replace() -> Result<()> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); note.tags.push("test".into()); col.add_note(&mut note, DeckId(1))?; col.find_and_replace_tag(&[note.id], "foo|test", "bar", true, false)?; let note = col.storage.get_note(note.id)?.unwrap(); assert_eq!(note.tags[0], "bar"); col.find_and_replace_tag(&[note.id], "BAR", "baz", false, true)?; let note = col.storage.get_note(note.id)?.unwrap(); assert_eq!(note.tags[0], "bar"); col.find_and_replace_tag(&[note.id], "b.r", "baz", false, false)?; let note = col.storage.get_note(note.id)?.unwrap(); assert_eq!(note.tags[0], "bar"); col.find_and_replace_tag(&[note.id], "b.r", "baz", true, false)?; let note = col.storage.get_note(note.id)?.unwrap(); assert_eq!(note.tags[0], "baz"); let out = col.add_tags_to_notes(&[note.id], "cee aye")?; assert_eq!(out.output, 1); let note = col.storage.get_note(note.id)?.unwrap(); assert_eq!(¬e.tags, &["aye", "baz", "cee"]); // if all tags already on note, it doesn't get updated let out = col.add_tags_to_notes(&[note.id], "cee aye")?; assert_eq!(out.output, 0); // empty replacement deletes tag col.find_and_replace_tag(&[note.id], "b.*|.*ye", "", true, false)?; let note = col.storage.get_note(note.id)?.unwrap(); assert_eq!(¬e.tags, &["cee"]); Ok(()) } } ================================================ FILE: rslib/src/tags/matcher.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::collections::HashSet; use regex::Captures; use regex::Regex; use super::join_tags; use super::split_tags; use crate::prelude::*; pub(crate) struct TagMatcher { regex: Regex, new_tags: HashSet, } /// Helper to match any of the provided space-separated tags in a space- /// separated list of tags, and replace the prefix. /// /// Tracks seen tags during replacement, so the tag list can be updated as well. impl TagMatcher { pub fn new(space_separated_tags: &str) -> Result { // convert "fo*o bar" into "fo\*o|bar" let tags: Vec<_> = split_tags(space_separated_tags) .map(regex::escape) .collect(); let tags = tags.join("|"); let regex = Regex::new(&format!( r#"(?ix) # start of string, or a space (?:^|\ ) # 1: the tag prefix ( {tags} ) (?: # 2: an optional child separator (::) # or a space/end of string the end of the string |\ |$ ) "# ))?; Ok(Self { regex, new_tags: HashSet::new(), }) } pub fn is_match(&self, space_separated_tags: &str) -> bool { self.regex.is_match(space_separated_tags) } pub fn replace(&mut self, space_separated_tags: &str, replacement: &str) -> String { let tags: Vec<_> = split_tags(space_separated_tags) .map(|tag| { let out = self.regex.replace(tag, |caps: &Captures| { // if we captured the child separator, add it to the replacement if caps.get(2).is_some() { Cow::Owned(format!("{replacement}::")) } else { Cow::Borrowed(replacement) } }); if let Cow::Owned(out) = out { if !self.new_tags.contains(&out) { self.new_tags.insert(out.clone()); } out } else { out.to_string() } }) .collect(); join_tags(tags.as_slice()) } /// The `replacement` function should return the text to use as a /// replacement. pub fn replace_with_fn(&mut self, space_separated_tags: &str, replacer: F) -> String where F: Fn(&str) -> String, { let tags: Vec<_> = split_tags(space_separated_tags) .map(|tag| { let out = self.regex.replace(tag, |caps: &Captures| { let replacement = replacer(caps.get(1).unwrap().as_str()); // if we captured the child separator, add it to the replacement if caps.get(2).is_some() { format!("{replacement}::") } else { replacement } }); if let Cow::Owned(out) = out { if !self.new_tags.contains(&out) { self.new_tags.insert(out.clone()); } out } else { out.to_string() } }) .collect(); join_tags(tags.as_slice()) } /// Remove any matching tags. Does not update seen_tags. pub fn remove(&mut self, space_separated_tags: &str) -> String { let tags: Vec<_> = split_tags(space_separated_tags) .filter(|&tag| !self.is_match(tag)) .map(ToString::to_string) .collect(); join_tags(tags.as_slice()) } /// Returns all replaced values that were used, so they can be registered /// into the tag list. pub fn into_new_tags(self) -> HashSet { self.new_tags } } #[cfg(test)] mod test { use super::*; #[test] fn regex() -> Result<()> { let re = TagMatcher::new("one two")?; assert!(!re.is_match(" foo ")); assert!(re.is_match(" foo one ")); assert!(re.is_match(" two foo ")); let mut re = TagMatcher::new("foo")?; assert!(re.is_match("foo")); assert!(re.is_match(" foo ")); assert!(re.is_match(" bar foo baz ")); assert!(re.is_match(" bar FOO baz ")); assert!(!re.is_match(" bar foof baz ")); assert!(!re.is_match(" barfoo ")); let mut as_xxx = |text| re.replace(text, "xxx"); assert_eq!(&as_xxx(" baz FOO "), " baz xxx "); assert_eq!(&as_xxx(" x foo::bar x "), " x xxx::bar x "); assert_eq!( &as_xxx(" x foo::bar bar::foo x "), " x xxx::bar bar::foo x " ); assert_eq!( &as_xxx(" x foo::bar foo::bar::baz x "), " x xxx::bar xxx::bar::baz x " ); Ok(()) } } ================================================ FILE: rslib/src/tags/mod.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod bulkadd; mod complete; mod findreplace; mod matcher; mod notes; mod register; mod remove; mod rename; mod reparent; mod service; mod tree; pub(crate) mod undo; use unicase::UniCase; use crate::prelude::*; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Tag { pub name: String, pub usn: Usn, pub expanded: bool, } impl Tag { pub fn new(name: String, usn: Usn) -> Self { Tag { name, usn, expanded: false, } } pub(crate) fn set_modified(&mut self, usn: Usn) { self.usn = usn; } } pub(crate) fn split_tags(tags: &str) -> impl Iterator { tags.split(is_tag_separator).filter(|tag| !tag.is_empty()) } pub(crate) fn join_tags(tags: &[String]) -> String { if tags.is_empty() { "".into() } else { format!(" {} ", tags.join(" ")) } } fn is_tag_separator(c: char) -> bool { c == ' ' || c == '\u{3000}' } pub(crate) fn immediate_parent_name_unicase(tag_name: UniCase<&str>) -> Option> { tag_name.rsplit_once("::").map(|t| t.0).map(UniCase::new) } fn immediate_parent_name_str(tag_name: &str) -> Option<&str> { tag_name.rsplit_once("::").map(|t| t.0) } ================================================ FILE: rslib/src/tags/notes.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashSet; use unicase::UniCase; use super::split_tags; use crate::prelude::*; use crate::search::SearchNode; impl Collection { pub(crate) fn all_tags_in_deck(&mut self, deck_id: DeckId) -> Result>> { let guard = self.search_notes_into_table(SearchNode::DeckIdWithChildren(deck_id))?; let mut all_tags: HashSet> = HashSet::new(); guard .col .storage .for_each_note_tag_in_searched_notes(|tags| { for tag in split_tags(tags) { // A benchmark on a large deck indicates that nothing is gained by using a Cow // and skipping an allocation in the duplicate case, and // this approach is simpler. all_tags.insert(UniCase::new(tag.to_string())); } })?; Ok(all_tags) } } ================================================ FILE: rslib/src/tags/register.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::collections::HashSet; use unicase::UniCase; use super::immediate_parent_name_str; use super::is_tag_separator; use super::split_tags; use super::Tag; use crate::prelude::*; use crate::text::normalize_to_nfc; use crate::types::Usn; impl Collection { /// Given a list of tags, fix case, ordering and duplicates. /// Returns true if any new tags were added. /// Each tag is split on spaces, so if you have a &str, you /// can pass that in as a one-element vec. pub(crate) fn canonify_tags( &mut self, tags: Vec, usn: Usn, ) -> Result<(Vec, bool)> { self.canonify_tags_inner(tags, usn, true) } pub(crate) fn canonify_tags_without_registering( &mut self, tags: Vec, usn: Usn, ) -> Result> { self.canonify_tags_inner(tags, usn, false) .map(|(tags, _)| tags) } /// Like [canonify_tags()], but doesn't save new tags. As a consequence, new /// parents are not canonified. fn canonify_tags_inner( &mut self, tags: Vec, usn: Usn, register: bool, ) -> Result<(Vec, bool)> { let mut seen = HashSet::new(); let mut added = false; let tags: Vec<_> = tags.iter().flat_map(|t| split_tags(t)).collect(); for tag in tags { let mut tag = Tag::new(tag.to_string(), usn); if register { added |= self.register_tag(&mut tag)?; } else { self.prepare_tag_for_registering(&mut tag)?; } seen.insert(UniCase::new(tag.name)); } // exit early if no non-empty tags if seen.is_empty() { return Ok((vec![], added)); } // return the sorted, canonified tags let mut tags = seen.into_iter().collect::>(); tags.sort_unstable(); let tags: Vec<_> = tags.into_iter().map(|s| s.into_inner()).collect(); Ok((tags, added)) } /// Returns true if any cards were added to the tag list. pub(crate) fn canonified_tags_as_vec( &mut self, tags: &str, usn: Usn, ) -> Result>> { let mut out_tags = vec![]; for tag in split_tags(tags) { let mut tag = Tag::new(tag.to_string(), usn); self.register_tag(&mut tag)?; out_tags.push(UniCase::new(tag.name)); } Ok(out_tags) } /// Adjust tag casing to match any existing parents, and register it if it's /// not already in the tags list. True if the tag was added and not /// already in tag list. In the case the tag is already registered, tag /// will be mutated to match the existing name. pub(crate) fn register_tag(&mut self, tag: &mut Tag) -> Result { let is_new = self.prepare_tag_for_registering(tag)?; if is_new { self.register_tag_undoable(tag)?; } Ok(is_new) } /// Create a tag object, normalize text, and match parents/existing case if /// available. True if tag is new. pub(super) fn prepare_tag_for_registering(&self, tag: &mut Tag) -> Result { let normalized_name = normalize_tag_name(&tag.name)?; if let Some(existing_tag) = self.storage.get_tag(&normalized_name)? { tag.name = existing_tag.name; Ok(false) } else { if let Some(new_name) = self.adjusted_case_for_parents(&normalized_name)? { tag.name = new_name; } else if let Cow::Owned(new_name) = normalized_name { tag.name = new_name; } Ok(true) } } pub(super) fn register_tag_string(&mut self, tag: String, usn: Usn) -> Result { let mut tag = Tag::new(tag, usn); self.register_tag(&mut tag) } } impl Collection { /// If parent tag(s) exist and differ in case, return a rewritten tag. pub(super) fn adjusted_case_for_parents(&self, tag: &str) -> Result> { if let Some(parent_tag) = self.first_existing_parent_tag(tag)? { let child_split: Vec<_> = tag.split("::").collect(); let parent_count = parent_tag.matches("::").count() + 1; Ok(Some(format!( "{}::{}", parent_tag, &child_split[parent_count..].join("::") ))) } else { Ok(None) } } fn first_existing_parent_tag(&self, mut tag: &str) -> Result> { while let Some(parent_name) = immediate_parent_name_str(tag) { if let Some(parent_tag) = self.storage.preferred_tag_case(parent_name)? { return Ok(Some(parent_tag)); } tag = parent_name; } Ok(None) } } fn invalid_char_for_tag(c: char) -> bool { c.is_ascii_control() || is_tag_separator(c) } fn normalized_tag_name_component(comp: &str) -> Cow<'_, str> { let mut out = normalize_to_nfc(comp); if out.contains(invalid_char_for_tag) { out = out.replace(invalid_char_for_tag, "").into(); } let trimmed = out.trim(); if trimmed.is_empty() { "blank".to_string().into() } else if trimmed.len() != out.len() { trimmed.to_string().into() } else { out } } pub(super) fn normalize_tag_name(name: &str) -> Result> { let normalized_name: Cow = if name .split("::") .any(|comp| matches!(normalized_tag_name_component(comp), Cow::Owned(_))) { let comps: Vec<_> = name .split("::") .map(normalized_tag_name_component) .collect::>(); comps.join("::").into() } else { // no changes required name.into() }; if normalized_name.is_empty() { // this should not be possible invalid_input!("blank tag"); } else { Ok(normalized_name) } } #[cfg(test)] mod test { use super::*; use crate::decks::DeckId; #[test] fn tags() -> Result<()> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); col.add_note(&mut note, DeckId(1))?; let tags: String = col.storage.db_scalar("select tags from notes")?; assert_eq!(tags, ""); // first instance wins in case of duplicates note.tags = vec!["foo".into(), "FOO".into()]; col.update_note(&mut note)?; assert_eq!(¬e.tags, &["foo"]); let tags: String = col.storage.db_scalar("select tags from notes")?; assert_eq!(tags, " foo "); // existing case is used if in DB note.tags = vec!["FOO".into()]; col.update_note(&mut note)?; assert_eq!(¬e.tags, &["foo"]); assert_eq!(tags, " foo "); // tags are normalized to nfc note.tags = vec!["\u{fa47}".into()]; col.update_note(&mut note)?; assert_eq!(¬e.tags, &["\u{6f22}"]); // if code incorrectly adds a space to a tag, it gets split note.tags = vec!["one two".into()]; col.update_note(&mut note)?; assert_eq!(¬e.tags, &["one", "two"]); // blanks should be handled note.tags = vec![ "".into(), "foo".into(), " ".into(), "::".into(), "foo::".into(), ]; col.update_note(&mut note)?; assert_eq!(¬e.tags, &["blank::blank", "foo", "foo::blank"]); Ok(()) } } ================================================ FILE: rslib/src/tags/remove.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use unicase::UniCase; use super::matcher::TagMatcher; use crate::prelude::*; impl Collection { /// Take tags as a whitespace-separated string and remove them from all /// notes and the tag list. pub fn remove_tags(&mut self, tags: &str) -> Result> { self.transact(Op::RemoveTag, |col| col.remove_tags_inner(tags)) } /// Remove whitespace-separated tags from provided notes. pub fn remove_tags_from_notes( &mut self, nids: &[NoteId], tags: &str, ) -> Result> { self.transact(Op::RemoveTag, |col| { col.remove_tags_from_notes_inner(nids, tags) }) } /// Remove tags not referenced by notes, returning removed count. pub fn clear_unused_tags(&mut self) -> Result> { self.transact(Op::ClearUnusedTags, |col| col.clear_unused_tags_inner()) } } impl Collection { fn remove_tags_inner(&mut self, tags: &str) -> Result { let usn = self.usn()?; // gather tags that need removing let mut re = TagMatcher::new(tags)?; let matched_notes = self .storage .get_note_tags_by_predicate(|tags| re.is_match(tags))?; let match_count = matched_notes.len(); // remove from the tag list for tag in self.storage.get_tags_by_predicate(|tag| re.is_match(tag))? { self.remove_single_tag_undoable(tag)?; } // replace tags for mut note in matched_notes { let original = note.clone(); note.tags = re.remove(¬e.tags); note.set_modified(usn); self.update_note_tags_undoable(¬e, original)?; } Ok(match_count) } fn remove_tags_from_notes_inner(&mut self, nids: &[NoteId], tags: &str) -> Result { let usn = self.usn()?; let mut re = TagMatcher::new(tags)?; let mut match_count = 0; let notes = self.storage.get_note_tags_by_id_list(nids)?; for mut note in notes { if !re.is_match(¬e.tags) { continue; } match_count += 1; let original = note.clone(); note.tags = re.remove(¬e.tags); note.set_modified(usn); self.update_note_tags_undoable(¬e, original)?; } Ok(match_count) } fn clear_unused_tags_inner(&mut self) -> Result { let mut count = 0; let in_notes = self.storage.all_tags_in_notes()?; let need_remove = self .storage .all_tags()? .into_iter() .filter(|tag| !in_notes.contains(&UniCase::new(tag.name.clone()))); for tag in need_remove { self.remove_single_tag_undoable(tag)?; count += 1; } Ok(count) } } #[cfg(test)] mod test { use super::*; use crate::tags::Tag; #[test] fn clearing() -> Result<()> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); note.tags.push("one".into()); note.tags.push("two".into()); col.add_note(&mut note, DeckId(1))?; col.set_tag_collapsed("one", false)?; col.clear_unused_tags()?; assert!(col.storage.get_tag("one")?.unwrap().expanded); assert!(!col.storage.get_tag("two")?.unwrap().expanded); // tag children are also cleared when clearing their parent col.storage.clear_all_tags()?; for name in &["a", "a::b", "A::b::c"] { col.register_tag(&mut Tag::new(name.to_string(), Usn(0)))?; } col.remove_tags("a")?; assert_eq!(col.storage.all_tags()?, vec![]); Ok(()) } } ================================================ FILE: rslib/src/tags/rename.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::is_tag_separator; use super::matcher::TagMatcher; use crate::prelude::*; use crate::tags::register::normalize_tag_name; impl Collection { /// Rename a given tag and its children on all notes that reference it, /// returning changed note count. pub fn rename_tag(&mut self, old_prefix: &str, new_prefix: &str) -> Result> { self.transact(Op::RenameTag, |col| { col.rename_tag_inner(old_prefix, new_prefix) }) } } impl Collection { fn rename_tag_inner(&mut self, old_prefix: &str, new_prefix: &str) -> Result { require!( !new_prefix.contains(is_tag_separator), "replacement name can not contain a space", ); require!( !new_prefix.trim().is_empty(), "replacement name must not be empty", ); let usn = self.usn()?; // ensure normalized+matching parent case, but not case of existing tag. // The matching of parent case is mainly to be consistent with the way // decks are handled. let new_prefix = normalize_tag_name(new_prefix)?; let new_prefix = self .adjusted_case_for_parents(&new_prefix)? .map(Into::into) .unwrap_or(new_prefix); // gather tags that need replacing let mut re = TagMatcher::new(old_prefix)?; let matched_notes = self .storage .get_note_tags_by_predicate(|tags| re.is_match(tags))?; let match_count = matched_notes.len(); if match_count == 0 { // no matches; exit early so we don't clobber the empty tag entries return Ok(0); } // remove old prefix from the tag list for tag in self.storage.get_tags_by_predicate(|tag| re.is_match(tag))? { self.remove_single_tag_undoable(tag)?; } // replace tags for mut note in matched_notes { let original = note.clone(); note.tags = re.replace(¬e.tags, &new_prefix); note.set_modified(usn); self.update_note_tags_undoable(¬e, original)?; } // update tag list for tag in re.into_new_tags() { self.register_tag_string(tag, usn)?; } Ok(match_count) } } ================================================ FILE: rslib/src/tags/reparent.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use unicase::UniCase; use super::join_tags; use super::matcher::TagMatcher; use crate::prelude::*; impl Collection { /// Reparent the provided tags under a new parent. /// /// Parents of the provided tags are left alone - only the final component /// and its children are moved. If a source tag is the parent of the target /// tag, it will remain unchanged. If `new_parent` is not provided, tags /// will be reparented to the root element. When reparenting tags, any /// children they have are reparented as well. /// /// For example: /// - foo, bar -> bar::foo /// - foo::bar, baz -> baz::bar /// - foo, foo::bar -> no action /// - foo::bar, none -> bar pub fn reparent_tags( &mut self, tags_to_reparent: &[String], new_parent: Option, ) -> Result> { self.transact(Op::ReparentTag, |col| { col.reparent_tags_inner(tags_to_reparent, new_parent) }) } pub fn reparent_tags_inner( &mut self, tags_to_reparent: &[String], new_parent: Option, ) -> Result { let usn = self.usn()?; let mut matcher = TagMatcher::new(&join_tags(tags_to_reparent))?; let old_to_new_names = old_to_new_names(tags_to_reparent, new_parent); if old_to_new_names.is_empty() { return Ok(0); } let matched_notes = self .storage .get_note_tags_by_predicate(|tags| matcher.is_match(tags))?; let match_count = matched_notes.len(); if match_count == 0 { // no matches; exit early so we don't clobber the empty tag entries return Ok(0); } // remove old prefixes from the tag list for tag in self .storage .get_tags_by_predicate(|tag| matcher.is_match(tag))? { self.remove_single_tag_undoable(tag)?; } // replace tags for mut note in matched_notes { let original = note.clone(); note.tags = matcher.replace_with_fn(¬e.tags, |cap| { old_to_new_names .get(&UniCase::new(cap.to_string())) .unwrap() .clone() }); note.set_modified(usn); self.update_note_tags_undoable(¬e, original)?; } // update tag list for tag in matcher.into_new_tags() { self.register_tag_string(tag, usn)?; } Ok(match_count) } } fn old_to_new_names( tags_to_reparent: &[String], new_parent: Option, ) -> HashMap, String> { tags_to_reparent .iter() // generate resulting names and filter out invalid ones .flat_map(|source_tag| { reparented_name(source_tag, new_parent.as_deref()) .map(|output_name| (UniCase::new(source_tag.to_owned()), output_name)) }) .collect() } /// Arguments are expected in 'human' form with a :: separator. /// Returns None if new parent is a child of the tag to be reparented. fn reparented_name(existing_name: &str, new_parent: Option<&str>) -> Option { let existing_base = existing_name.rsplit("::").next().unwrap(); let existing_root = existing_name.split("::").next().unwrap(); if let Some(new_parent) = new_parent { let new_parent_root = new_parent.split("::").next().unwrap(); if new_parent.starts_with(existing_name) && new_parent_root == existing_root { // foo onto foo::bar, or foo onto itself -> no-op None } else { // foo::bar onto baz -> baz::bar let new_name = format!("{new_parent}::{existing_base}"); if new_name != existing_name { Some(new_name) } else { None } } } else { // foo::bar onto top level -> bar let new_name = existing_base.into(); if new_name != existing_name { Some(new_name) } else { None } } } #[cfg(test)] mod test { use super::*; fn alltags(col: &Collection) -> Vec { col.storage .all_tags() .unwrap() .into_iter() .map(|t| t.name) .collect() } #[test] fn dragdrop() -> Result<()> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); for tag in &[ "a", "ab", "another", "parent1::child1::grandchild1", "parent1::child1", "parent1", "parent2", "yet::another", ] { let mut note = nt.new_note(); note.tags.push(tag.to_string()); col.add_note(&mut note, DeckId(1))?; } // two decks with the same base name; they both get mapped // to parent1::another col.reparent_tags( &["another".to_string(), "yet::another".to_string()], Some("parent1".to_string()), )?; assert_eq!( alltags(&col), &[ "a", "ab", "parent1", "parent1::another", "parent1::child1", "parent1::child1::grandchild1", "parent2", ] ); // child and children moved to parent2 col.reparent_tags( &["parent1::child1".to_string()], Some("parent2".to_string()), )?; assert_eq!( alltags(&col), &[ "a", "ab", "parent1", "parent1::another", "parent2", "parent2::child1", "parent2::child1::grandchild1", ] ); // empty target reparents to root col.reparent_tags(&["parent1::another".to_string()], None)?; assert_eq!( alltags(&col), &[ "a", "ab", "another", "parent1", "parent2", "parent2::child1", "parent2::child1::grandchild1", ] ); // parent1 onto parent1::child1 -> no-op col.reparent_tags( &["parent1".to_string()], Some("parent1::child1".to_string()), )?; assert_eq!( alltags(&col), &[ "a", "ab", "another", "parent1", "parent2", "parent2::child1", "parent2::child1::grandchild1", ] ); // tags that are prefixes of the new parent are handled correctly col.reparent_tags(&["a".to_string()], Some("ab".to_string()))?; assert_eq!( alltags(&col), &[ "ab", "ab::a", "another", "parent1", "parent2", "parent2::child1", "parent2::child1::grandchild1", ] ); // grandchildren can be reparented under the same root col.reparent_tags( &["parent2::child1::grandchild1".to_string()], Some("parent2".to_string()), )?; assert_eq!( alltags(&col), &[ "ab", "ab::a", "another", "parent1", "parent2", "parent2::child1", "parent2::grandchild1", ] ); Ok(()) } } ================================================ FILE: rslib/src/tags/service.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::generic; use crate::collection::Collection; use crate::error; use crate::notes::service::to_note_ids; impl crate::services::TagsService for Collection { fn clear_unused_tags(&mut self) -> error::Result { self.clear_unused_tags().map(Into::into) } fn all_tags(&mut self) -> error::Result { Ok(generic::StringList { vals: self .storage .all_tags()? .into_iter() .map(|t| t.name) .collect(), }) } fn remove_tags( &mut self, tags: generic::String, ) -> error::Result { self.remove_tags(tags.val.as_str()).map(Into::into) } fn set_tag_collapsed( &mut self, input: anki_proto::tags::SetTagCollapsedRequest, ) -> error::Result { self.set_tag_collapsed(&input.name, input.collapsed) .map(Into::into) } fn tag_tree(&mut self) -> error::Result { self.tag_tree() } fn reparent_tags( &mut self, input: anki_proto::tags::ReparentTagsRequest, ) -> error::Result { let source_tags = input.tags; let target_tag = if input.new_parent.is_empty() { None } else { Some(input.new_parent) }; self.reparent_tags(&source_tags, target_tag).map(Into::into) } fn rename_tags( &mut self, input: anki_proto::tags::RenameTagsRequest, ) -> error::Result { self.rename_tag(&input.current_prefix, &input.new_prefix) .map(Into::into) } fn add_note_tags( &mut self, input: anki_proto::tags::NoteIdsAndTagsRequest, ) -> error::Result { self.add_tags_to_notes(&to_note_ids(input.note_ids), &input.tags) .map(Into::into) } fn remove_note_tags( &mut self, input: anki_proto::tags::NoteIdsAndTagsRequest, ) -> error::Result { self.remove_tags_from_notes(&to_note_ids(input.note_ids), &input.tags) .map(Into::into) } fn find_and_replace_tag( &mut self, input: anki_proto::tags::FindAndReplaceTagRequest, ) -> error::Result { let note_ids = if input.note_ids.is_empty() { self.search_notes_unordered("")? } else { to_note_ids(input.note_ids) }; self.find_and_replace_tag( ¬e_ids, &input.search, &input.replacement, input.regex, input.match_case, ) .map(Into::into) } fn complete_tag( &mut self, input: anki_proto::tags::CompleteTagRequest, ) -> error::Result { let tags = Collection::complete_tag(self, &input.input, input.match_limit as usize)?; Ok(anki_proto::tags::CompleteTagResponse { tags }) } } ================================================ FILE: rslib/src/tags/tree.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashSet; use std::iter::Peekable; use anki_proto::tags::TagTreeNode; use unicase::UniCase; use super::immediate_parent_name_unicase; use super::Tag; use crate::prelude::*; impl Collection { pub fn tag_tree(&mut self) -> Result { let tags = self.storage.all_tags()?; let tree = tags_to_tree(tags); Ok(tree) } pub fn set_tag_collapsed(&mut self, tag: &str, collapsed: bool) -> Result> { self.transact(Op::SkipUndo, |col| { col.set_tag_collapsed_inner(tag, collapsed, col.usn()?) }) } } impl Collection { fn set_tag_collapsed_inner(&mut self, name: &str, collapsed: bool, usn: Usn) -> Result<()> { self.register_tag_string(name.into(), usn)?; if let Some(mut tag) = self.storage.get_tag(name)? { let original = tag.clone(); tag.expanded = !collapsed; self.update_tag_inner(&mut tag, original, usn)?; } Ok(()) } fn update_tag_inner(&mut self, tag: &mut Tag, original: Tag, usn: Usn) -> Result<()> { tag.set_modified(usn); self.update_tag_undoable(tag, original) } } /// Append any missing parents. Caller must sort afterwards. fn add_missing_parents(tags: &mut Vec) { let mut all_names: HashSet> = HashSet::new(); let mut missing = vec![]; for tag in &*tags { add_tag_and_missing_parents(&mut all_names, &mut missing, UniCase::new(&tag.name)) } let mut missing: Vec<_> = missing .into_iter() .map(|n| Tag::new(n.to_string(), Usn(0))) .collect(); tags.append(&mut missing); } fn tags_to_tree(mut tags: Vec) -> TagTreeNode { add_missing_parents(&mut tags); for tag in &mut tags { tag.name = tag.name.replace("::", "\x1f"); } tags.sort_unstable_by(|a, b| UniCase::new(&a.name).cmp(&UniCase::new(&b.name))); let mut top = TagTreeNode::default(); let mut it = tags.into_iter().peekable(); add_child_nodes(&mut it, &mut top); top } fn add_child_nodes(tags: &mut Peekable>, parent: &mut TagTreeNode) { while let Some(tag) = tags.peek() { let split_name: Vec<_> = tag.name.split('\x1f').collect(); match split_name.len() as u32 { l if l <= parent.level => { // next item is at a higher level return; } l if l == parent.level + 1 => { // next item is an immediate descendent of parent parent.children.push(TagTreeNode { name: (*split_name.last().unwrap()).into(), children: vec![], level: parent.level + 1, collapsed: !tag.expanded, }); tags.next(); } _ => { // next item is at a lower level if let Some(last_child) = parent.children.last_mut() { add_child_nodes(tags, last_child) } else { // immediate parent is missing tags.next(); } } } } } /// For the given tag, check if immediate parent exists. If so, add /// tag and return. /// If the immediate parent is missing, check and add any missing parents. /// This should ensure that if an immediate parent is found, all ancestors /// are guaranteed to already exist. fn add_tag_and_missing_parents<'a, 'b>( all: &'a mut HashSet>, missing: &'a mut Vec>, tag_name: UniCase<&'b str>, ) { if let Some(parent) = immediate_parent_name_unicase(tag_name) { if !all.contains(&parent) { missing.push(parent); add_tag_and_missing_parents(all, missing, parent); } } // finally, add provided tag all.insert(tag_name); } #[cfg(test)] mod test { use super::*; fn node(name: &str, level: u32, children: Vec) -> TagTreeNode { TagTreeNode { name: name.into(), level, children, collapsed: level != 0, } } fn leaf(name: &str, level: u32) -> TagTreeNode { node(name, level, vec![]) } #[test] fn tree() -> Result<()> { let mut col = Collection::new(); let nt = col.get_notetype_by_name("Basic")?.unwrap(); let mut note = nt.new_note(); note.tags.push("foo::bar::a".into()); note.tags.push("foo::bar::b".into()); col.add_note(&mut note, DeckId(1))?; // missing parents are added assert_eq!( col.tag_tree()?, node( "", 0, vec![node( "foo", 1, vec![node("bar", 2, vec![leaf("a", 3), leaf("b", 3)])] )] ) ); // differing case should result in only one parent case being added - // the first one col.storage.clear_all_tags()?; note.tags[0] = "foo::BAR::a".into(); note.tags[1] = "FOO::bar::b".into(); col.update_note(&mut note)?; assert_eq!( col.tag_tree()?, node( "", 0, vec![node( "foo", 1, vec![node("BAR", 2, vec![leaf("a", 3), leaf("b", 3)])] )] ) ); // things should work even if the immediate parent is not missing col.storage.clear_all_tags()?; note.tags[0] = "foo::bar::baz".into(); note.tags[1] = "foo::bar::baz::quux".into(); col.update_note(&mut note)?; assert_eq!( col.tag_tree()?, node( "", 0, vec![node( "foo", 1, vec![node("bar", 2, vec![node("baz", 3, vec![leaf("quux", 4)])])] )] ) ); // numbers have a smaller ascii number than ':', so a naive sort on // '::' would result in one::two being nested under one1. col.storage.clear_all_tags()?; note.tags[0] = "one".into(); note.tags[1] = "one1".into(); note.tags.push("one::two".into()); col.update_note(&mut note)?; assert_eq!( col.tag_tree()?, node( "", 0, vec![node("one", 1, vec![leaf("two", 2)]), leaf("one1", 1)] ) ); // children should match the case of their parents col.storage.clear_all_tags()?; note.tags[0] = "FOO".into(); note.tags[1] = "foo::BAR".into(); note.tags[2] = "foo::bar::baz".into(); col.update_note(&mut note)?; assert_eq!(note.tags, vec!["FOO", "FOO::BAR", "FOO::BAR::baz"]); Ok(()) } } ================================================ FILE: rslib/src/tags/undo.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::Tag; use crate::prelude::*; #[derive(Debug)] pub(crate) enum UndoableTagChange { Added(Box), Removed(Box), Updated(Box), } impl Collection { pub(crate) fn undo_tag_change(&mut self, change: UndoableTagChange) -> Result<()> { match change { UndoableTagChange::Added(tag) => self.remove_single_tag_undoable(*tag), UndoableTagChange::Removed(tag) => self.register_tag_undoable(&tag), UndoableTagChange::Updated(tag) => { let current = self .storage .get_tag(&tag.name)? .or_invalid("tag disappeared")?; self.update_tag_undoable(&tag, current) } } } /// Updates an existing tag, saving an undo entry. Caller must update usn. pub(super) fn update_tag_undoable(&mut self, tag: &Tag, original: Tag) -> Result<()> { self.save_undo(UndoableTagChange::Updated(Box::new(original))); self.storage.update_tag(tag) } /// Adds an already-validated tag to the tag list, saving an undo entry. /// Caller is responsible for setting usn. pub(super) fn register_tag_undoable(&mut self, tag: &Tag) -> Result<()> { self.save_undo(UndoableTagChange::Added(Box::new(tag.clone()))); self.storage.register_tag(tag) } /// Remove a single tag from the tag list, saving an undo entry. Does not /// alter notes. FIXME: caller will need to update usn when we make tags /// incrementally syncable. pub(super) fn remove_single_tag_undoable(&mut self, tag: Tag) -> Result<()> { self.storage.remove_single_tag(&tag.name)?; self.save_undo(UndoableTagChange::Removed(Box::new(tag))); Ok(()) } } ================================================ FILE: rslib/src/template.rs ================================================ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; use std::fmt::Write; use std::iter; use std::sync::LazyLock; use anki_i18n::I18n; use nom::bytes::complete::tag; use nom::bytes::complete::take_until; use nom::combinator::map; use nom::sequence::delimited; use nom::Parser; use regex::Regex; use crate::cloze::cloze_number_in_fields; use crate::error::AnkiError; use crate::error::Result; use crate::error::TemplateError; use crate::invalid_input; use crate::template_filters::apply_filters; pub type FieldMap<'a> = HashMap<&'a str, u16>; type TemplateResult = std::result::Result; static TEMPLATE_ERROR_LINK: &str = "https://docs.ankiweb.net/templates/errors.html#template-syntax-error"; static TEMPLATE_BLANK_LINK: &str = "https://docs.ankiweb.net/templates/errors.html#front-of-card-is-blank"; static TEMPLATE_BLANK_CLOZE_LINK: &str = "https://docs.ankiweb.net/templates/errors.html#no-cloze-filter-on-cloze-note-type"; // Template comment delimiters static COMMENT_START: &str = ""; static ALT_HANDLEBAR_DIRECTIVE: &str = "{{=<% %>=}}"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TemplateMode { Standard, LegacyAltSyntax, } impl TemplateMode { fn start_tag(&self) -> &'static str { match self { TemplateMode::Standard => "{{", TemplateMode::LegacyAltSyntax => "<%", } } fn end_tag(&self) -> &'static str { match self { TemplateMode::Standard => "}}", TemplateMode::LegacyAltSyntax => "%>", } } fn handlebar_token<'b>(&self, s: &'b str) -> nom::IResult<&'b str, Token<'b>> { map( delimited( tag(self.start_tag()), take_until(self.end_tag()), tag(self.end_tag()), ), |out| classify_handle(out), ) .parse(s) } /// Return the next handlebar, comment or text token. fn next_token<'b>(&self, input: &'b str) -> Option<(&'b str, Token<'b>)> { if input.is_empty() { return None; } // Loop, starting from the first character for (i, _) in input.char_indices() { let remaining = &input[i..]; // Valid handlebar clause? if let Ok((after_handlebar, token)) = self.handlebar_token(remaining) { // Found at the start of string, so that's the next token return Some(if i == 0 { (after_handlebar, token) } else { // There was some text prior to this, so return it instead (remaining, Token::Text(&input[..i])) }); } // Check comments too if let Ok((after_comment, token)) = comment_token(remaining) { return Some(if i == 0 { (after_comment, token) } else { (remaining, Token::Text(&input[..i])) }); } } // If no matches, return the entire input as text, with nothing remaining Some(("", Token::Text(input))) } } // Lexing //---------------------------------------- #[derive(Debug)] pub enum Token<'a> { Text(&'a str), Comment(&'a str), Replacement(&'a str), OpenConditional(&'a str), OpenNegated(&'a str), CloseConditional(&'a str), } fn comment_token(s: &str) -> nom::IResult<&str, Token<'_>> { map( delimited( tag(COMMENT_START), take_until(COMMENT_END), tag(COMMENT_END), ), Token::Comment, ) .parse(s) } fn tokens(mut template: &str) -> impl Iterator>> { let mode = if template.trim_start().starts_with(ALT_HANDLEBAR_DIRECTIVE) { template = template .trim_start() .trim_start_matches(ALT_HANDLEBAR_DIRECTIVE); TemplateMode::LegacyAltSyntax } else { TemplateMode::Standard }; iter::from_fn(move || { let token; (template, token) = mode.next_token(template)?; Some(Ok(token)) }) } /// classify handle based on leading character fn classify_handle(s: &str) -> Token<'_> { let start = s.trim_start_matches('{').trim(); if start.len() < 2 { return Token::Replacement(start); } if let Some(stripped) = start.strip_prefix('#') { Token::OpenConditional(stripped.trim_start()) } else if let Some(stripped) = start.strip_prefix('/') { Token::CloseConditional(stripped.trim_start()) } else if let Some(stripped) = start.strip_prefix('^') { Token::OpenNegated(stripped.trim_start()) } else { Token::Replacement(start) } } // Parsing //---------------------------------------- #[derive(Debug, PartialEq, Eq)] enum ParsedNode { Text(String), Comment(String), Replacement { key: String, filters: Vec, }, Conditional { key: String, children: Vec, }, NegatedConditional { key: String, children: Vec, }, } #[derive(Debug)] pub struct ParsedTemplate(Vec); impl ParsedTemplate { /// Create a template from the provided text. pub fn from_text(template: &str) -> TemplateResult { let mut iter = tokens(template); Ok(Self(parse_inner(&mut iter, None)?)) } } fn parse_inner<'a, I: Iterator>>>( iter: &mut I, open_tag: Option<&'a str>, ) -> TemplateResult> { let mut nodes = vec![]; while let Some(token) = iter.next() { use Token::*; nodes.push(match token? { Text(t) => ParsedNode::Text(t.into()), Comment(t) => ParsedNode::Comment(t.into()), Replacement(t) => { let mut it = t.rsplit(':'); ParsedNode::Replacement { key: it.next().unwrap().into(), filters: it.map(Into::into).collect(), } } OpenConditional(t) => ParsedNode::Conditional { key: t.into(), children: parse_inner(iter, Some(t))?, }, OpenNegated(t) => ParsedNode::NegatedConditional { key: t.into(), children: parse_inner(iter, Some(t))?, }, CloseConditional(t) => { let currently_open = if let Some(open) = open_tag { if open == t { // matching closing tag, move back to parent return Ok(nodes); } else { Some(open.to_string()) } } else { None }; return Err(TemplateError::ConditionalNotOpen { closed: t.to_string(), currently_open, }); } }); } if let Some(open) = open_tag { Err(TemplateError::ConditionalNotClosed(open.to_string())) } else { Ok(nodes) } } fn template_error_to_anki_error( err: TemplateError, q_side: bool, browser: bool, tr: &I18n, ) -> AnkiError { let header = match (q_side, browser) { (true, false) => tr.card_template_rendering_front_side_problem(), (false, false) => tr.card_template_rendering_back_side_problem(), (true, true) => tr.card_template_rendering_browser_front_side_problem(), (false, true) => tr.card_template_rendering_browser_back_side_problem(), }; let details = htmlescape::encode_minimal(&localized_template_error(tr, err)); let more_info = tr.card_template_rendering_more_info(); let source = format!("{header}
    {details}
    {more_info}"); AnkiError::TemplateError { info: source } } fn localized_template_error(tr: &I18n, err: TemplateError) -> String { match err { TemplateError::NoClosingBrackets(tag) => tr .card_template_rendering_no_closing_brackets("}}", tag) .into(), TemplateError::ConditionalNotClosed(tag) => tr .card_template_rendering_conditional_not_closed(format!("{{{{/{tag}}}}}")) .into(), TemplateError::ConditionalNotOpen { closed, currently_open, } => if let Some(open) = currently_open { tr.card_template_rendering_wrong_conditional_closed( format!("{{{{/{closed}}}}}"), format!("{{{{/{open}}}}}"), ) } else { tr.card_template_rendering_conditional_not_open( format!("{{{{/{closed}}}}}"), format!("{{{{#{closed}}}}}"), format!("{{{{^{closed}}}}}"), ) } .into(), TemplateError::FieldNotFound { field, filters } => tr .card_template_rendering_no_such_field(format!("{{{{{filters}{field}}}}}"), field) .into(), TemplateError::NoSuchConditional(condition) => tr .card_template_rendering_no_such_field(format!("{{{{{condition}}}}}"), &condition[1..]) .into(), } } // Checking if template is empty //---------------------------------------- impl ParsedTemplate { /// true if provided fields are sufficient to render the template pub fn renders_with_fields(&self, nonempty_fields: &HashSet<&str>) -> bool { !template_is_empty(nonempty_fields, &self.0, true) } pub fn renders_with_fields_for_reqs(&self, nonempty_fields: &HashSet<&str>) -> bool { !template_is_empty(nonempty_fields, &self.0, false) } } /// If check_negated is false, negated conditionals resolve to their children, /// even if the referenced key is non-empty. This allows the legacy required /// field cache to generate results closer to older Anki versions. fn template_is_empty( nonempty_fields: &HashSet<&str>, nodes: &[ParsedNode], check_negated: bool, ) -> bool { use ParsedNode::*; for node in nodes { match node { // ignore normal text Text(_) | Comment(_) => (), Replacement { key, .. } => { if nonempty_fields.contains(key.as_str()) { // a single replacement is enough return false; } } Conditional { key, children } => { if !nonempty_fields.contains(key.as_str()) { continue; } if !template_is_empty(nonempty_fields, children, check_negated) { return false; } } NegatedConditional { key, children } => { if check_negated && nonempty_fields.contains(key.as_str()) { continue; } if !template_is_empty(nonempty_fields, children, check_negated) { return false; } } } } true } // Rendering //---------------------------------------- #[derive(Debug, PartialEq, Eq)] pub enum RenderedNode { Text { text: String, }, Replacement { field_name: String, current_text: String, /// Filters are in the order they should be applied. filters: Vec, }, } pub(crate) struct RenderContext<'a> { pub fields: &'a HashMap<&'a str, Cow<'a, str>>, pub nonempty_fields: &'a HashSet<&'a str>, pub card_ord: u16, /// Should be set before rendering the answer, even if `partial_for_python` /// is true. pub frontside: Option<&'a str>, /// If true, question/answer will not be fully rendered if an unknown filter /// is encountered, and the frontend code will need to complete the /// rendering. pub partial_for_python: bool, } impl ParsedTemplate { /// Render the template with the provided fields. /// /// Replacements that use only standard filters will become part of /// a text node. If a non-standard filter is encountered, a partially /// rendered Replacement is returned for the calling code to complete. fn render(&self, context: &RenderContext, _tr: &I18n) -> TemplateResult> { let mut rendered = vec![]; render_into(&mut rendered, self.0.as_ref(), context)?; Ok(rendered) } } fn render_into( rendered_nodes: &mut Vec, nodes: &[ParsedNode], context: &RenderContext, ) -> TemplateResult<()> { use ParsedNode::*; for node in nodes { match node { Text(text) => { append_str_to_nodes(rendered_nodes, text); } Comment(comment) => { append_str_to_nodes(rendered_nodes, COMMENT_START); append_str_to_nodes(rendered_nodes, comment); append_str_to_nodes(rendered_nodes, COMMENT_END); } Replacement { key, .. } if key == "FrontSide" => { let frontside = context.frontside.as_ref().copied().unwrap_or_default(); if context.partial_for_python { // defer FrontSide rendering to Python, as extra // filters may be required rendered_nodes.push(RenderedNode::Replacement { field_name: (*key).to_string(), filters: vec![], current_text: "".into(), }); } else { append_str_to_nodes(rendered_nodes, frontside); } } Replacement { key, filters } => { if key.is_empty() && !filters.is_empty() { if context.partial_for_python { // if a filter is provided, we accept an empty field name to // mean 'pass an empty string to the filter, and it will add // its own text' rendered_nodes.push(RenderedNode::Replacement { field_name: "".to_string(), current_text: "".to_string(), filters: filters.clone(), }); } else { // nothing to do } } else { // apply built in filters if field exists let (text, remaining_filters) = match context.fields.get(key.as_str()) { Some(text) => apply_filters( text, filters .iter() .map(|s| s.as_str()) .collect::>() .as_slice(), key, context, ), None => { // unknown field encountered let filters_str = filters .iter() .rev() .cloned() .chain(iter::once("".into())) .collect::>() .join(":"); return Err(TemplateError::FieldNotFound { field: (*key).to_string(), filters: filters_str, }); } }; // fully processed? if remaining_filters.is_empty() { append_str_to_nodes(rendered_nodes, text.as_ref()) } else { rendered_nodes.push(RenderedNode::Replacement { field_name: (*key).to_string(), filters: remaining_filters, current_text: text.into(), }); } } } Conditional { key, children } => { if context.evaluate_conditional(key.as_str(), false)? { render_into(rendered_nodes, children.as_ref(), context)?; } else { // keep checking for errors, but discard rendered nodes render_into(&mut vec![], children.as_ref(), context)?; } } NegatedConditional { key, children } => { if context.evaluate_conditional(key.as_str(), true)? { render_into(rendered_nodes, children.as_ref(), context)?; } else { render_into(&mut vec![], children.as_ref(), context)?; } } }; } Ok(()) } impl RenderContext<'_> { fn evaluate_conditional(&self, key: &str, negated: bool) -> TemplateResult { if self.nonempty_fields.contains(key) { Ok(true ^ negated) } else if self.fields.contains_key(key) || is_cloze_conditional(key) { Ok(false ^ negated) } else { let prefix = if negated { "^" } else { "#" }; Err(TemplateError::NoSuchConditional(format!("{prefix}{key}"))) } } } /// Append to last node if last node is a string, else add new node. fn append_str_to_nodes(nodes: &mut Vec, text: &str) { if let Some(RenderedNode::Text { text: ref mut existing_text, }) = nodes.last_mut() { // append to existing last node existing_text.push_str(text) } else { // otherwise, add a new string node nodes.push(RenderedNode::Text { text: text.to_string(), }) } } /// True if provided text contains only whitespace and/or empty BR/DIV tags. pub(crate) fn field_is_empty(text: &str) -> bool { static RE: LazyLock = LazyLock::new(|| { Regex::new( r"(?xsi) ^(?: [[:space:]] | )*$ ", ) .unwrap() }); RE.is_match(text) } fn nonempty_fields<'a, R>(fields: &'a HashMap<&str, R>) -> HashSet<&'a str> where R: AsRef, { fields .iter() .filter_map(|(name, val)| { if !field_is_empty(val.as_ref()) { Some(*name) } else { None } }) .collect() } // Rendering both sides //---------------------------------------- #[derive(Clone)] pub struct RenderCardRequest<'a> { pub qfmt: &'a str, pub afmt: &'a str, pub field_map: &'a HashMap<&'a str, Cow<'a, str>>, pub card_ord: u16, pub is_cloze: bool, pub browser: bool, pub tr: &'a I18n, pub partial_render: bool, } pub struct RenderCardResponse { pub qnodes: Vec, pub anodes: Vec, pub is_empty: bool, } /// Returns `(qnodes, anodes, is_empty)` pub fn render_card( RenderCardRequest { qfmt, afmt, field_map, card_ord, is_cloze, browser, tr, partial_render: partial_for_python, }: RenderCardRequest<'_>, ) -> Result { // prepare context let mut context = RenderContext { fields: field_map, nonempty_fields: &nonempty_fields(field_map), frontside: None, card_ord, partial_for_python, }; // question side let (mut qnodes, qtmpl) = ParsedTemplate::from_text(qfmt) .and_then(|tmpl| Ok((tmpl.render(&context, tr)?, tmpl))) .map_err(|e| template_error_to_anki_error(e, true, browser, tr))?; // check if the front side was empty let empty_message = if is_cloze && cloze_is_empty(field_map, card_ord) { Some(format!( "