diff --git a/CONTRIBUTORS b/CONTRIBUTORS index d90b7dbcc..2ec25da2d 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -254,6 +254,7 @@ nav1s Ranjit Odedra Eltaurus jariji +Francisco Esteva ******************** diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index 5dc688155..ca754e783 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -234,7 +234,7 @@ class DeckBrowser: if node.collapsed: prefix = "+" else: - prefix = "-" + prefix = "−" def indent() -> str: return " " * 6 * (node.level - 1) diff --git a/qt/aqt/errors.py b/qt/aqt/errors.py index a6d9251e2..89e15246e 100644 --- a/qt/aqt/errors.py +++ b/qt/aqt/errors.py @@ -14,7 +14,7 @@ from markdown import markdown import aqt from anki.collection import HelpPage -from anki.errors import BackendError, Interrupted +from anki.errors import BackendError, CardTypeError, Interrupted from anki.utils import is_win from aqt.addons import AddonManager, AddonMeta from aqt.qt import * @@ -36,6 +36,14 @@ def show_exception(*, parent: QWidget, exception: Exception) -> None: 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) @@ -51,7 +59,7 @@ def show_exception(*, parent: QWidget, exception: Exception) -> None: ) error_text = "\n".join(error_lines) print(error_lines) - _mbox = _init_message_box(str(exception), error_text, help_page) + _mbox = _init_message_box(str(exception), error_text, help_page, text_format) _mbox.show() @@ -171,7 +179,10 @@ if not os.environ.get("DEBUG"): def _init_message_box( - user_text: str, debug_text: str, help_page=HelpPage.TROUBLESHOOTING + user_text: str, + debug_text: str, + help_page=HelpPage.TROUBLESHOOTING, + text_format=Qt.TextFormat.PlainText, ): global _mbox @@ -179,7 +190,7 @@ def _init_message_box( _mbox.setWindowTitle("Anki") _mbox.setText(user_text) _mbox.setIcon(QMessageBox.Icon.Warning) - _mbox.setTextFormat(Qt.TextFormat.PlainText) + _mbox.setTextFormat(text_format) def show_help(): openHelp(help_page) diff --git a/qt/aqt/forms/finddupes.ui b/qt/aqt/forms/finddupes.ui index 9a7c44c06..9bc8be87b 100644 --- a/qt/aqt/forms/finddupes.ui +++ b/qt/aqt/forms/finddupes.ui @@ -47,6 +47,9 @@ QComboBox::NoInsert + + QComboBox::SizeAdjustPolicy::AdjustToMinimumContentsLengthWithIcon + diff --git a/qt/aqt/tts.py b/qt/aqt/tts.py index d559fb41f..f77e5c975 100644 --- a/qt/aqt/tts.py +++ b/qt/aqt/tts.py @@ -94,8 +94,15 @@ class TTSPlayer: rank -= 1 - # if no preferred voices match, we fall back on language - # with a rank of -100 + # 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) diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index 43efc513f..ae88dadcb 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -809,7 +809,7 @@ def ensureWidgetInScreenBoundaries(widget: QWidget) -> None: wsize = widget.size() cappedWidth = min(geom.width(), wsize.width()) cappedHeight = min(geom.height(), wsize.height()) - if cappedWidth > wsize.width() or cappedHeight > wsize.height(): + if cappedWidth < wsize.width() or cappedHeight < wsize.height(): widget.resize(QSize(cappedWidth, cappedHeight)) # ensure widget is inside top left diff --git a/rslib/i18n/build.rs b/rslib/i18n/build.rs index f604c9167..75bc38787 100644 --- a/rslib/i18n/build.rs +++ b/rslib/i18n/build.rs @@ -8,7 +8,6 @@ mod python; mod typescript; mod write_strings; -use std::env; use std::path::PathBuf; use anki_io::create_dir_all; @@ -32,8 +31,7 @@ fn main() -> Result<()> { python::write_py_interface(&modules)?; // write strings.json file to requested path - println!("cargo:rerun-if-env-changed=STRINGS_JSON"); - if let Ok(path) = env::var("STRINGS_JSON") { + 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(); diff --git a/rslib/i18n/python.rs b/rslib/i18n/python.rs index ca780c041..a564de48d 100644 --- a/rslib/i18n/python.rs +++ b/rslib/i18n/python.rs @@ -1,7 +1,6 @@ // 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; @@ -21,7 +20,7 @@ pub fn write_py_interface(modules: &[Module]) -> Result<()> { render_methods(modules, &mut out); render_legacy_enum(modules, &mut out); - if let Ok(path) = env::var("STRINGS_PY") { + 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)?; diff --git a/rslib/i18n/typescript.rs b/rslib/i18n/typescript.rs index ce30048e2..0f483cb58 100644 --- a/rslib/i18n/typescript.rs +++ b/rslib/i18n/typescript.rs @@ -1,7 +1,6 @@ // 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; @@ -22,7 +21,7 @@ pub fn write_ts_interface(modules: &[Module]) -> Result<()> { render_module_map(modules, &mut ts_out); render_methods(modules, &mut ts_out); - if let Ok(path) = env::var("STRINGS_TS") { + 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)?; diff --git a/rslib/io/src/lib.rs b/rslib/io/src/lib.rs index cb44467e6..0fd85e490 100644 --- a/rslib/io/src/lib.rs +++ b/rslib/io/src/lib.rs @@ -335,6 +335,15 @@ pub fn write_file_if_changed(path: impl AsRef, contents: impl AsRef<[u8]>) .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) diff --git a/rslib/src/import_export/package/apkg/import/media.rs b/rslib/src/import_export/package/apkg/import/media.rs index 32bf7c807..20543e074 100644 --- a/rslib/src/import_export/package/apkg/import/media.rs +++ b/rslib/src/import_export/package/apkg/import/media.rs @@ -17,6 +17,7 @@ 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; @@ -75,7 +76,7 @@ impl Context<'_> { fn prepare_media( media_entries: Vec, archive: &mut ZipArchive, - existing_sha1s: &HashMap, + existing_sha1s: &Checksums, progress: &mut ThrottlingProgressHandler, ) -> Result { let mut media_map = MediaUseMap::default(); diff --git a/rslib/src/import_export/text/csv/import.rs b/rslib/src/import_export/text/csv/import.rs index e45bbca1b..31dee84e4 100644 --- a/rslib/src/import_export/text/csv/import.rs +++ b/rslib/src/import_export/text/csv/import.rs @@ -1,6 +1,7 @@ // 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; @@ -106,6 +107,8 @@ struct ColumnContext { 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, @@ -119,6 +122,7 @@ impl ColumnContext { 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), }) } @@ -166,11 +170,19 @@ impl ColumnContext { } fn gather_note_fields(&self, record: &csv::StringRecord) -> Vec> { - let stringify = self.stringify; - self.field_source_columns - .iter() - .map(|opt| opt.and_then(|idx| record.get(idx - 1)).map(stringify)) - .collect() + 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() + } } } diff --git a/rslib/src/import_export/text/csv/metadata.rs b/rslib/src/import_export/text/csv/metadata.rs index d505c60d2..cd4150813 100644 --- a/rslib/src/import_export/text/csv/metadata.rs +++ b/rslib/src/import_export/text/csv/metadata.rs @@ -291,11 +291,8 @@ impl CsvMetadataHelpers for CsvMetadata { .map(|&i| (i > 0).then_some(i as usize)) .collect(), CsvNotetype::NotetypeColumn(_) => { - let meta_columns = self.meta_columns(); - (1..self.column_labels.len() + 1) - .filter(|idx| !meta_columns.contains(idx)) - .map(Some) - .collect() + // each row's notetype could have varying number of fields + vec![] } }) } diff --git a/rslib/src/media/files.rs b/rslib/src/media/files.rs index ce17b40bb..b098eb19e 100644 --- a/rslib/src/media/files.rs +++ b/rslib/src/media/files.rs @@ -173,7 +173,9 @@ pub fn add_data_to_folder_uniquely<'a, P>( where P: AsRef, { - let normalized_name = normalize_filename(desired_name); + // 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()); @@ -496,8 +498,14 @@ mod test { "test.mp3" ); - // different contents + // 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" diff --git a/rslib/src/media/mod.rs b/rslib/src/media/mod.rs index 259dd52f8..8a599fbec 100644 --- a/rslib/src/media/mod.rs +++ b/rslib/src/media/mod.rs @@ -6,7 +6,6 @@ pub mod files; mod service; use std::borrow::Cow; -use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; @@ -22,6 +21,7 @@ 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; @@ -157,7 +157,7 @@ impl MediaManager { pub fn all_checksums_after_checking( &self, progress: impl FnMut(usize) -> bool, - ) -> Result> { + ) -> Result { ChangeTracker::new(&self.media_folder, progress).register_changes(&self.db)?; self.db.all_registered_checksums() } @@ -176,7 +176,7 @@ impl MediaManager { /// All checksums without registering changes first. #[cfg(test)] - pub(crate) fn all_checksums_as_is(&self) -> HashMap { + pub(crate) fn all_checksums_as_is(&self) -> Checksums { self.db.all_registered_checksums().unwrap() } } diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index 6ff8c6e2d..a71c6330f 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -443,9 +443,20 @@ impl Collection { .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 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 = deck.effective_desired_retention(&config); + 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(); diff --git a/rslib/src/sync/media/database/client/mod.rs b/rslib/src/sync/media/database/client/mod.rs index f9c6e5ed1..fe3e7c840 100644 --- a/rslib/src/sync/media/database/client/mod.rs +++ b/rslib/src/sync/media/database/client/mod.rs @@ -18,6 +18,20 @@ 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, @@ -175,11 +189,12 @@ delete from media where fname=?", } /// Returns all filenames and checksums, where the checksum is not null. - pub(crate) fn all_registered_checksums(&self) -> error::Result> { + 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() + .collect::>() + .map(Checksums) } pub(crate) fn force_resync(&self) -> error::Result<()> { diff --git a/tools/minilints/src/main.rs b/tools/minilints/src/main.rs index 6d38278b5..ca645efe6 100644 --- a/tools/minilints/src/main.rs +++ b/tools/minilints/src/main.rs @@ -202,7 +202,7 @@ fn sveltekit_temp_file(path: &str) -> bool { } fn check_cargo_deny() -> Result<()> { - Command::run("cargo install cargo-deny@0.18.3")?; + Command::run("cargo install cargo-deny@0.18.6")?; Command::run("cargo deny check")?; Ok(()) } diff --git a/ts/lib/tslib/wrap.ts b/ts/lib/tslib/wrap.ts index 39b10e9d1..e22c3e6d8 100644 --- a/ts/lib/tslib/wrap.ts +++ b/ts/lib/tslib/wrap.ts @@ -4,7 +4,12 @@ import { getRange, getSelection } from "./cross-browser"; function wrappedExceptForWhitespace(text: string, front: string, back: string): string { - const match = text.match(/^(\s*)([^]*?)(\s*)$/)!; + 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]; } diff --git a/ts/routes/image-occlusion/add-or-update-note.svelte.ts b/ts/routes/image-occlusion/add-or-update-note.svelte.ts index ce31eaaaf..8494563b4 100644 --- a/ts/routes/image-occlusion/add-or-update-note.svelte.ts +++ b/ts/routes/image-occlusion/add-or-update-note.svelte.ts @@ -37,7 +37,9 @@ export const addOrUpdateNote = async function( backExtra, tags, }); - showResult(mode.noteId, result, noteCount); + if (result.note) { + showResult(mode.noteId, result, noteCount); + } } else { const result = await addImageOcclusionNote({ // IOCloningMode is not used on mobile @@ -55,23 +57,12 @@ export const addOrUpdateNote = async function( // show toast message const showResult = (noteId: number | null, result: OpChanges, count: number) => { const props = $state({ - message: "", - type: "error" as "error" | "success", + message: noteId ? tr.browsingCardsUpdated({ count: count }) : tr.importingCardsAdded({ count: count }), + type: "success" as "error" | "success", showToast: true, }); mount(Toast, { target: document.body, props, }); - - if (result.note) { - const msg = noteId ? tr.browsingCardsUpdated({ count: count }) : tr.importingCardsAdded({ count: count }); - props.message = msg; - props.type = "success"; - props.showToast = true; - } else { - const msg = tr.notetypesErrorGeneratingCloze(); - props.message = msg; - props.showToast = true; - } };