diff --git a/ftl/core/editing.ftl b/ftl/core/editing.ftl index 585ec5f0f..f91dface4 100644 --- a/ftl/core/editing.ftl +++ b/ftl/core/editing.ftl @@ -8,7 +8,8 @@ editing-bold-text = Bold text editing-cards = Cards editing-center = Center editing-change-color = Change color -editing-cloze-deletion = Cloze deletion +editing-cloze-deletion = Cloze deletion (new card) +editing-cloze-deletion-repeat = Cloze deletion (same card) 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 diff --git a/proto/anki/import_export.proto b/proto/anki/import_export.proto index 94711c31a..ea8bfe8ad 100644 --- a/proto/anki/import_export.proto +++ b/proto/anki/import_export.proto @@ -46,6 +46,11 @@ message MediaEntries { 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; diff --git a/qt/aqt/about.py b/qt/aqt/about.py index aad20436a..116408b1c 100644 --- a/qt/aqt/about.py +++ b/qt/aqt/about.py @@ -224,6 +224,7 @@ def show(mw: aqt.AnkiQt) -> QDialog: "Matthias Metelka", "Sergio Quintero", "Nicholas Flint", + "Daniel Vieira Memoria10X", ) ) diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 68c1cc299..ad6094009 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -1387,7 +1387,7 @@ def set_cloze_button(editor: Editor) -> None: action = "show" if editor.note.note_type()["type"] == MODEL_CLOZE else "hide" editor.web.eval( 'require("anki/ui").loaded.then(() =>' - f'require("anki/NoteEditor").instances[0].toolbar.templateButtons.{action}("cloze")' + f'require("anki/NoteEditor").instances[0].toolbar.toolbar.{action}("cloze")' "); " ) diff --git a/repos.bzl b/repos.bzl index 39dfeaa6e..e0877a60f 100644 --- a/repos.bzl +++ b/repos.bzl @@ -115,12 +115,12 @@ def register_repos(): ################ core_i18n_repo = "anki-core-i18n" - core_i18n_commit = "bd995b3d74f37975554ebd03d3add4ea82bf663f" - core_i18n_zip_csum = "ace985f858958321d5919731981bce2b9356ea3e8fb43b0232a1dc6f55673f3d" + core_i18n_commit = "7d1954863a721e09d974c609b55fa78f0e178b0f" + core_i18n_zip_csum = "14cce5d0ecd2c00ce839d735ab7fe979439080ad9c510cc6fc2e63c97a9c745c" qtftl_i18n_repo = "anki-desktop-ftl" - qtftl_i18n_commit = "5045d3604a20b0ae8ce14be2d3597d72c03ccad8" - qtftl_i18n_zip_csum = "45058ea33cb0e5d142cae8d4e926f5eb3dab4d207e7af0baeafda2b92f765806" + qtftl_i18n_commit = "31baaae83fad4be3d8977d6053ef3032bdb90481" + qtftl_i18n_zip_csum = "96e42e0278affb2586e677b52b466e6ca8bb4f3fd080a561683c48b483202fa2" i18n_build_content = """ filegroup( diff --git a/rslib/src/import_export/package/colpkg/export.rs b/rslib/src/import_export/package/colpkg/export.rs index 1d07aa5e7..002ebff43 100644 --- a/rslib/src/import_export/package/colpkg/export.rs +++ b/rslib/src/import_export/package/colpkg/export.rs @@ -312,6 +312,7 @@ impl MediaEntry { name: name.into(), size: size.try_into().unwrap_or_default(), sha1: sha1.into(), + legacy_zip_filename: None, } } } diff --git a/rslib/src/import_export/package/colpkg/import.rs b/rslib/src/import_export/package/colpkg/import.rs index 78f923638..544db04bd 100644 --- a/rslib/src/import_export/package/colpkg/import.rs +++ b/rslib/src/import_export/package/colpkg/import.rs @@ -102,16 +102,22 @@ fn restore_media( let media_entries = extract_media_entries(meta, archive)?; std::fs::create_dir_all(media_folder)?; - for (archive_file_name, entry) in media_entries.iter().enumerate() { - if archive_file_name % 10 == 0 { - progress_fn(ImportProgress::Media(archive_file_name))?; + for (entry_idx, entry) in media_entries.iter().enumerate() { + if entry_idx % 10 == 0 { + progress_fn(ImportProgress::Media(entry_idx))?; } - if let Ok(mut zip_file) = archive.by_name(&archive_file_name.to_string()) { + let zip_filename = entry + .legacy_zip_filename + .map(|n| n as usize) + .unwrap_or(entry_idx) + .to_string(); + + if let Ok(mut zip_file) = archive.by_name(&zip_filename) { maybe_restore_media_file(meta, media_folder, entry, &mut zip_file)?; } else { return Err(AnkiError::invalid_input(&format!( - "{archive_file_name} missing from archive" + "{zip_filename} missing from archive" ))); } } @@ -191,27 +197,17 @@ fn extract_media_entries(meta: &Meta, archive: &mut ZipArchive) -> Result< } if meta.media_list_is_hashmap() { let map: HashMap<&str, String> = serde_json::from_slice(&buf)?; - let mut entries: Vec<(usize, String)> = map - .into_iter() - .map(|(k, v)| (k.parse().unwrap_or_default(), v)) - .collect(); - entries.sort_unstable(); - // any gaps in the file numbers would lead to media being imported under the wrong name - if entries - .iter() - .enumerate() - .any(|(idx1, (idx2, _))| idx1 != *idx2) - { - return Err(AnkiError::ImportError(ImportError::Corrupt)); - } - Ok(entries - .into_iter() - .map(|(_str_idx, name)| MediaEntry { - name, - size: 0, - sha1: vec![], + map.into_iter() + .map(|(idx_str, name)| { + let idx: u32 = idx_str.parse()?; + Ok(MediaEntry { + name, + size: 0, + sha1: vec![], + legacy_zip_filename: Some(idx), + }) }) - .collect()) + .collect() } else { let entries: MediaEntries = Message::decode(&*buf)?; Ok(entries.entries) diff --git a/rslib/src/scheduler/new.rs b/rslib/src/scheduler/new.rs index b40d06e8a..05a0d6999 100644 --- a/rslib/src/scheduler/new.rs +++ b/rslib/src/scheduler/new.rs @@ -18,9 +18,31 @@ use crate::{ }; impl Card { - fn schedule_as_new(&mut self, position: u32, reset_counts: bool) { + pub(crate) fn original_or_current_due(&self) -> i32 { + self.is_filtered() + .then(|| self.original_due) + .unwrap_or(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 = position as i32; + self.due = last_position.unwrap_or(position) as i32; self.ctype = CardType::New; self.queue = CardQueue::New; self.interval = 0; @@ -30,6 +52,8 @@ impl Card { self.reps = 0; self.lapses = 0; } + + last_position.is_none() } /// If the card is new, change its position, and return true. @@ -135,10 +159,7 @@ impl Collection { let cards = col.storage.all_searched_cards_in_search_order()?; for mut card in cards { let original = card.clone(); - if restore_position && card.original_position.is_some() { - card.schedule_as_new(card.original_position.unwrap(), reset_counts); - } else { - card.schedule_as_new(position, reset_counts); + if card.schedule_as_new(position, reset_counts, restore_position) { position += 1; } if log { @@ -301,6 +322,43 @@ mod test { } 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)); + } } impl From for NewCardDueOrder { diff --git a/ts/BUILD.bazel b/ts/BUILD.bazel index e2679ea7f..9fb25671a 100644 --- a/ts/BUILD.bazel +++ b/ts/BUILD.bazel @@ -35,3 +35,11 @@ alias( name = "node", actual = "@nodejs//:node", ) + +filegroup( + name = "ts", + srcs = [ + "//ts/icons", + ], + visibility = ["//ts:__subpackages__"], +) diff --git a/ts/editor/BUILD.bazel b/ts/editor/BUILD.bazel index 389b7b61b..ca17ca28c 100644 --- a/ts/editor/BUILD.bazel +++ b/ts/editor/BUILD.bazel @@ -47,6 +47,7 @@ _esbuild_deps = [ "//sass:button_mixins_lib", "@npm//@mdi", "@npm//bootstrap-icons", + "//ts/icons", "@npm//protobufjs", ] diff --git a/ts/editor/DecoratedElements.svelte b/ts/editor/DecoratedElements.svelte index 150de2585..5c28a6edc 100644 --- a/ts/editor/DecoratedElements.svelte +++ b/ts/editor/DecoratedElements.svelte @@ -4,18 +4,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> - - diff --git a/ts/editor/MathjaxElement.svelte b/ts/editor/MathjaxElement.svelte index 5dbf2441e..212f66989 100644 --- a/ts/editor/MathjaxElement.svelte +++ b/ts/editor/MathjaxElement.svelte @@ -2,11 +2,10 @@ Copyright: Ankitects Pty Ltd and contributors License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> - - - {@html ellipseIcon} - + + + {@html incrementClozeIcon} + - onCloze(event.detail.originalEvent)} -/> + + + + {@html clozeIcon} + + + + diff --git a/ts/editor/editor-toolbar/EditorToolbar.svelte b/ts/editor/editor-toolbar/EditorToolbar.svelte index 455b3f7d9..cd00895bc 100644 --- a/ts/editor/editor-toolbar/EditorToolbar.svelte +++ b/ts/editor/editor-toolbar/EditorToolbar.svelte @@ -56,6 +56,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import Item from "../../components/Item.svelte"; import StickyContainer from "../../components/StickyContainer.svelte"; import BlockButtons from "./BlockButtons.svelte"; + import ClozeButtons from "./ClozeButtons.svelte"; import InlineButtons from "./InlineButtons.svelte"; import NotetypeButtons from "./NotetypeButtons.svelte"; import TemplateButtons from "./TemplateButtons.svelte"; @@ -105,6 +106,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + + + + diff --git a/ts/editor/editor-toolbar/TemplateButtons.svelte b/ts/editor/editor-toolbar/TemplateButtons.svelte index 83d5301bd..528b9368c 100644 --- a/ts/editor/editor-toolbar/TemplateButtons.svelte +++ b/ts/editor/editor-toolbar/TemplateButtons.svelte @@ -20,7 +20,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { context } from "../NoteEditor.svelte"; import { setFormat } from "../old-editor-adapter"; import { editingInputIsRichText } from "../rich-text-input"; - import ClozeButton from "./ClozeButton.svelte"; import { micIcon, paperclipIcon } from "./icons"; import LatexButton from "./LatexButton.svelte"; @@ -41,7 +40,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } [mediaPromise, resolve] = promiseWithResolver(); - $focusedInput.focusHandler.focus.on( + $focusedInput.editable.focusHandler.focus.on( async () => setFormat("inserthtml", await mediaPromise), { once: true }, ); @@ -61,7 +60,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } [mediaPromise, resolve] = promiseWithResolver(); - $focusedInput.focusHandler.focus.on( + $focusedInput.editable.focusHandler.focus.on( async () => setFormat("inserthtml", await mediaPromise), { once: true }, ); @@ -116,10 +115,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /> - - - - diff --git a/ts/editor/editor-toolbar/icons.ts b/ts/editor/editor-toolbar/icons.ts index 70647187b..0bc292d07 100644 --- a/ts/editor/editor-toolbar/icons.ts +++ b/ts/editor/editor-toolbar/icons.ts @@ -24,7 +24,8 @@ export { default as underlineIcon } from "bootstrap-icons/icons/type-underline.s export const arrowIcon = ''; -export { default as ellipseIcon } from "@mdi/svg/svg/contain.svg"; +export { default as incrementClozeIcon } from "../../icons/contain-plus.svg"; +export { default as clozeIcon } from "@mdi/svg/svg/contain.svg"; export { default as functionIcon } from "@mdi/svg/svg/function-variant.svg"; export { default as paperclipIcon } from "@mdi/svg/svg/paperclip.svg"; export { default as micIcon } from "bootstrap-icons/icons/mic.svg"; diff --git a/ts/editor/mathjax-overlay/MathjaxHandle.svelte b/ts/editor/mathjax-overlay/MathjaxHandle.svelte index c1c274a7a..a81da07be 100644 --- a/ts/editor/mathjax-overlay/MathjaxHandle.svelte +++ b/ts/editor/mathjax-overlay/MathjaxHandle.svelte @@ -17,7 +17,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import MathjaxMenu from "./MathjaxMenu.svelte"; const { container, api } = context.get(); - const { focusHandler, preventResubscription } = api; + const { editable, preventResubscription } = api; const code = writable(""); @@ -41,7 +41,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html let selectAll = false; function placeHandle(after: boolean): void { - focusHandler.flushCaret(); + editable.focusHandler.flushCaret(); if (after) { (mathjaxElement as any).placeCaretAfter(); diff --git a/ts/editor/plain-text-input/PlainTextInput.svelte b/ts/editor/plain-text-input/PlainTextInput.svelte index 68bcab9ba..7556d9364 100644 --- a/ts/editor/plain-text-input/PlainTextInput.svelte +++ b/ts/editor/plain-text-input/PlainTextInput.svelte @@ -30,15 +30,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { onMount, tick } from "svelte"; import { writable } from "svelte/store"; + import { singleCallback } from "../../lib/typing"; import { pageTheme } from "../../sveltelib/theme"; import { baseOptions, gutterOptions, htmlanki } from "../code-mirror"; import CodeMirror from "../CodeMirror.svelte"; - import { context as decoratedElementsContext } from "../DecoratedElements.svelte"; import { context as editingAreaContext } from "../EditingArea.svelte"; import { context as noteEditorContext } from "../NoteEditor.svelte"; import removeProhibitedTags from "./remove-prohibited"; - - export let hidden = false; + import { storedToUndecorated, undecoratedToStored } from "./transform"; const configuration = { mode: htmlanki, @@ -49,31 +48,36 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html const { focusedInput } = noteEditorContext.get(); const { editingInputs, content } = editingAreaContext.get(); - const decoratedElements = decoratedElementsContext.get(); const code = writable($content); - function focus(): void { - codeMirror.editor.then((editor) => editor.focus()); + let codeMirror = {} as CodeMirrorAPI; + + async function focus(): Promise { + const editor = await codeMirror.editor; + editor.focus(); } - function moveCaretToEnd(): void { - codeMirror.editor.then((editor) => editor.setCursor(editor.lineCount(), 0)); + async function moveCaretToEnd(): Promise { + const editor = await codeMirror.editor; + editor.setCursor(editor.lineCount(), 0); } - function refocus(): void { - codeMirror.editor.then((editor) => (editor as any).display.input.blur()); + async function refocus(): Promise { + const editor = (await codeMirror.editor) as any; + editor.display.input.blur(); + focus(); moveCaretToEnd(); } + export let hidden = false; + function toggle(): boolean { hidden = !hidden; return hidden; } - let codeMirror = {} as CodeMirrorAPI; - - export const api = { + export const api: PlainTextInputAPI = { name: "plain-text", focus, focusable: !hidden, @@ -81,47 +85,46 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html refocus, toggle, codeMirror, - } as PlainTextInputAPI; + }; - function pushUpdate(): void { - api.focusable = !hidden; + /** + * Communicate to editing area that input is not focusable + */ + function pushUpdate(isFocusable: boolean): void { + api.focusable = isFocusable; $editingInputs = $editingInputs; } - function refresh(): void { - codeMirror.editor.then((editor) => editor.refresh()); + async function refresh(): Promise { + const editor = await codeMirror.editor; + editor.refresh(); } $: { - hidden; + pushUpdate(!hidden); tick().then(refresh); - pushUpdate(); } - function storedToUndecorated(html: string): string { - return decoratedElements.toUndecorated(html); - } - - function undecoratedToStored(html: string): string { - return decoratedElements.toStored(html); + function onChange({ detail: html }: CustomEvent): void { + code.set(removeProhibitedTags(html)); } onMount(() => { $editingInputs.push(api); $editingInputs = $editingInputs; - const unsubscribeFromEditingArea = content.subscribe((value: string): void => { - code.set(storedToUndecorated(value)); - }); - - const unsubscribeToEditingArea = code.subscribe((value: string): void => { - content.set(removeProhibitedTags(undecoratedToStored(value))); - }); - - return () => { - unsubscribeFromEditingArea(); - unsubscribeToEditingArea(); - }; + return singleCallback( + content.subscribe((html: string): void => + /* We call `removeProhibitedTags` here, because content might + * have been changed outside the editor, and we need to parse + * it to get the "neutral" value. Otherwise, there might be + * conflicts with other editing inputs */ + code.set(removeProhibitedTags(storedToUndecorated(html))), + ), + code.subscribe((html: string): void => + content.set(undecoratedToStored(html)), + ), + ); }); setupLifecycleHooks(api); @@ -133,12 +136,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html class:hidden on:focusin={() => ($focusedInput = api)} > - code.set(removeProhibitedTags(html))} - /> +