Merge branch 'main' into fix/sql-retrievability-underflow

This commit is contained in:
Abdo 2025-12-16 19:49:18 +03:00 committed by GitHub
commit 71b5c9e33d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 118 additions and 51 deletions

View file

@ -254,6 +254,7 @@ nav1s <nav1s@proton.me>
Ranjit Odedra <ranjitodedra.dev@gmail.com> Ranjit Odedra <ranjitodedra.dev@gmail.com>
Eltaurus <https://github.com/Eltaurus-Lt> Eltaurus <https://github.com/Eltaurus-Lt>
jariji jariji
Francisco Esteva <fr.esteva@duocuc.cl>
******************** ********************

View file

@ -234,7 +234,7 @@ class DeckBrowser:
if node.collapsed: if node.collapsed:
prefix = "+" prefix = "+"
else: else:
prefix = "-" prefix = ""
def indent() -> str: def indent() -> str:
return "&nbsp;" * 6 * (node.level - 1) return "&nbsp;" * 6 * (node.level - 1)

View file

@ -14,7 +14,7 @@ from markdown import markdown
import aqt import aqt
from anki.collection import HelpPage 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 anki.utils import is_win
from aqt.addons import AddonManager, AddonMeta from aqt.addons import AddonManager, AddonMeta
from aqt.qt import * from aqt.qt import *
@ -36,6 +36,14 @@ def show_exception(*, parent: QWidget, exception: Exception) -> None:
global _mbox global _mbox
error_lines = [] error_lines = []
help_page = HelpPage.TROUBLESHOOTING 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 isinstance(exception, BackendError):
if exception.context: if exception.context:
error_lines.append(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) error_text = "\n".join(error_lines)
print(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() _mbox.show()
@ -171,7 +179,10 @@ if not os.environ.get("DEBUG"):
def _init_message_box( 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 global _mbox
@ -179,7 +190,7 @@ def _init_message_box(
_mbox.setWindowTitle("Anki") _mbox.setWindowTitle("Anki")
_mbox.setText(user_text) _mbox.setText(user_text)
_mbox.setIcon(QMessageBox.Icon.Warning) _mbox.setIcon(QMessageBox.Icon.Warning)
_mbox.setTextFormat(Qt.TextFormat.PlainText) _mbox.setTextFormat(text_format)
def show_help(): def show_help():
openHelp(help_page) openHelp(help_page)

View file

@ -47,6 +47,9 @@
<property name="insertPolicy"> <property name="insertPolicy">
<enum>QComboBox::NoInsert</enum> <enum>QComboBox::NoInsert</enum>
</property> </property>
<property name="sizeAdjustPolicy">
<enum>QComboBox::SizeAdjustPolicy::AdjustToMinimumContentsLengthWithIcon</enum>
</property>
</widget> </widget>
</item> </item>
</layout> </layout>

View file

@ -94,8 +94,15 @@ class TTSPlayer:
rank -= 1 rank -= 1
# if no preferred voices match, we fall back on language # if no requested voices match, use a preferred fallback voice
# with a rank of -100 # (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: for avail in avail_voices:
if avail.lang == tag.lang: if avail.lang == tag.lang:
return TTSVoiceMatch(voice=avail, rank=-100) return TTSVoiceMatch(voice=avail, rank=-100)

View file

@ -809,7 +809,7 @@ def ensureWidgetInScreenBoundaries(widget: QWidget) -> None:
wsize = widget.size() wsize = widget.size()
cappedWidth = min(geom.width(), wsize.width()) cappedWidth = min(geom.width(), wsize.width())
cappedHeight = min(geom.height(), wsize.height()) 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)) widget.resize(QSize(cappedWidth, cappedHeight))
# ensure widget is inside top left # ensure widget is inside top left

View file

@ -8,7 +8,6 @@ mod python;
mod typescript; mod typescript;
mod write_strings; mod write_strings;
use std::env;
use std::path::PathBuf; use std::path::PathBuf;
use anki_io::create_dir_all; use anki_io::create_dir_all;
@ -32,8 +31,7 @@ fn main() -> Result<()> {
python::write_py_interface(&modules)?; python::write_py_interface(&modules)?;
// write strings.json file to requested path // write strings.json file to requested path
println!("cargo:rerun-if-env-changed=STRINGS_JSON"); if let Some(path) = option_env!("STRINGS_JSON") {
if let Ok(path) = env::var("STRINGS_JSON") {
if !path.is_empty() { if !path.is_empty() {
let path = PathBuf::from(path); let path = PathBuf::from(path);
let meta_json = serde_json::to_string_pretty(&modules).unwrap(); let meta_json = serde_json::to_string_pretty(&modules).unwrap();

View file

@ -1,7 +1,6 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::env;
use std::fmt::Write; use std::fmt::Write;
use std::path::PathBuf; use std::path::PathBuf;
@ -21,7 +20,7 @@ pub fn write_py_interface(modules: &[Module]) -> Result<()> {
render_methods(modules, &mut out); render_methods(modules, &mut out);
render_legacy_enum(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); let path = PathBuf::from(path);
create_dir_all(path.parent().unwrap())?; create_dir_all(path.parent().unwrap())?;
write_file_if_changed(path, out)?; write_file_if_changed(path, out)?;

View file

@ -1,7 +1,6 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::env;
use std::fmt::Write; use std::fmt::Write;
use std::path::PathBuf; use std::path::PathBuf;
@ -22,7 +21,7 @@ pub fn write_ts_interface(modules: &[Module]) -> Result<()> {
render_module_map(modules, &mut ts_out); render_module_map(modules, &mut ts_out);
render_methods(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); let path = PathBuf::from(path);
create_dir_all(path.parent().unwrap())?; create_dir_all(path.parent().unwrap())?;
write_file_if_changed(path, ts_out)?; write_file_if_changed(path, ts_out)?;

View file

@ -335,6 +335,15 @@ pub fn write_file_if_changed(path: impl AsRef<Path>, contents: impl AsRef<[u8]>)
.map(|existing| existing != contents) .map(|existing| existing != contents)
.unwrap_or(true) .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 { if changed {
write_file(path, contents)?; write_file(path, contents)?;
Ok(true) Ok(true)

View file

@ -17,6 +17,7 @@ use crate::import_export::package::media::SafeMediaEntry;
use crate::import_export::ImportProgress; use crate::import_export::ImportProgress;
use crate::media::files::add_hash_suffix_to_file_stem; use crate::media::files::add_hash_suffix_to_file_stem;
use crate::media::files::sha1_of_reader; use crate::media::files::sha1_of_reader;
use crate::media::Checksums;
use crate::prelude::*; use crate::prelude::*;
use crate::progress::ThrottlingProgressHandler; use crate::progress::ThrottlingProgressHandler;
@ -75,7 +76,7 @@ impl Context<'_> {
fn prepare_media( fn prepare_media(
media_entries: Vec<SafeMediaEntry>, media_entries: Vec<SafeMediaEntry>,
archive: &mut ZipArchive<File>, archive: &mut ZipArchive<File>,
existing_sha1s: &HashMap<String, Sha1Hash>, existing_sha1s: &Checksums,
progress: &mut ThrottlingProgressHandler<ImportProgress>, progress: &mut ThrottlingProgressHandler<ImportProgress>,
) -> Result<MediaUseMap> { ) -> Result<MediaUseMap> {
let mut media_map = MediaUseMap::default(); let mut media_map = MediaUseMap::default();

View file

@ -1,6 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // 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::BufRead;
use std::io::BufReader; use std::io::BufReader;
use std::io::Read; use std::io::Read;
@ -106,6 +107,8 @@ struct ColumnContext {
notetype_column: Option<usize>, notetype_column: Option<usize>,
/// Source column indices for the fields of a notetype /// Source column indices for the fields of a notetype
field_source_columns: FieldSourceColumns, field_source_columns: FieldSourceColumns,
/// Metadata column indices (1-based)
meta_columns: HashSet<usize>,
/// How fields are converted to strings. Used for escaping HTML if /// How fields are converted to strings. Used for escaping HTML if
/// appropriate. /// appropriate.
stringify: fn(&str) -> String, stringify: fn(&str) -> String,
@ -119,6 +122,7 @@ impl ColumnContext {
deck_column: metadata.deck()?.column(), deck_column: metadata.deck()?.column(),
notetype_column: metadata.notetype()?.column(), notetype_column: metadata.notetype()?.column(),
field_source_columns: metadata.field_source_columns()?, field_source_columns: metadata.field_source_columns()?,
meta_columns: metadata.meta_columns(),
stringify: stringify_fn(metadata.is_html), stringify: stringify_fn(metadata.is_html),
}) })
} }
@ -166,11 +170,19 @@ impl ColumnContext {
} }
fn gather_note_fields(&self, record: &csv::StringRecord) -> Vec<Option<String>> { fn gather_note_fields(&self, record: &csv::StringRecord) -> Vec<Option<String>> {
let stringify = self.stringify; let op = |i| record.get(i - 1).map(self.stringify);
self.field_source_columns if !self.field_source_columns.is_empty() {
.iter() self.field_source_columns
.map(|opt| opt.and_then(|idx| record.get(idx - 1)).map(stringify)) .iter()
.collect() .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()
}
} }
} }

View file

@ -291,11 +291,8 @@ impl CsvMetadataHelpers for CsvMetadata {
.map(|&i| (i > 0).then_some(i as usize)) .map(|&i| (i > 0).then_some(i as usize))
.collect(), .collect(),
CsvNotetype::NotetypeColumn(_) => { CsvNotetype::NotetypeColumn(_) => {
let meta_columns = self.meta_columns(); // each row's notetype could have varying number of fields
(1..self.column_labels.len() + 1) vec![]
.filter(|idx| !meta_columns.contains(idx))
.map(Some)
.collect()
} }
}) })
} }

View file

@ -173,7 +173,9 @@ pub fn add_data_to_folder_uniquely<'a, P>(
where where
P: AsRef<Path>, P: AsRef<Path>,
{ {
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()); let mut target_path = folder.as_ref().join(normalized_name.as_ref());
@ -496,8 +498,14 @@ mod test {
"test.mp3" "test.mp3"
); );
// different contents // different contents, filenames differ only by case
let h2 = sha1_of_data(b"hello1"); 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!( assert_eq!(
add_data_to_folder_uniquely(dpath, "test.mp3", b"hello1", h2).unwrap(), add_data_to_folder_uniquely(dpath, "test.mp3", b"hello1", h2).unwrap(),
"test-88fdd585121a4ccb3d1540527aee53a77c77abb8.mp3" "test-88fdd585121a4ccb3d1540527aee53a77c77abb8.mp3"

View file

@ -6,7 +6,6 @@ pub mod files;
mod service; mod service;
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
@ -22,6 +21,7 @@ use crate::progress::ThrottlingProgressHandler;
use crate::sync::http_client::HttpSyncClient; use crate::sync::http_client::HttpSyncClient;
use crate::sync::login::SyncAuth; use crate::sync::login::SyncAuth;
use crate::sync::media::database::client::changetracker::ChangeTracker; 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::MediaDatabase;
use crate::sync::media::database::client::MediaEntry; use crate::sync::media::database::client::MediaEntry;
use crate::sync::media::progress::MediaSyncProgress; use crate::sync::media::progress::MediaSyncProgress;
@ -157,7 +157,7 @@ impl MediaManager {
pub fn all_checksums_after_checking( pub fn all_checksums_after_checking(
&self, &self,
progress: impl FnMut(usize) -> bool, progress: impl FnMut(usize) -> bool,
) -> Result<HashMap<String, Sha1Hash>> { ) -> Result<Checksums> {
ChangeTracker::new(&self.media_folder, progress).register_changes(&self.db)?; ChangeTracker::new(&self.media_folder, progress).register_changes(&self.db)?;
self.db.all_registered_checksums() self.db.all_registered_checksums()
} }
@ -176,7 +176,7 @@ impl MediaManager {
/// All checksums without registering changes first. /// All checksums without registering changes first.
#[cfg(test)] #[cfg(test)]
pub(crate) fn all_checksums_as_is(&self) -> HashMap<String, [u8; 20]> { pub(crate) fn all_checksums_as_is(&self) -> Checksums {
self.db.all_registered_checksums().unwrap() self.db.all_registered_checksums().unwrap()
} }
} }

View file

@ -443,9 +443,20 @@ impl Collection {
.storage .storage
.get_deck(card.deck_id)? .get_deck(card.deck_id)?
.or_not_found(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_enabled = self.get_config_bool(BoolKey::Fsrs);
let fsrs_next_states = if fsrs_enabled { let fsrs_next_states = if fsrs_enabled {
let params = config.fsrs_params(); let params = config.fsrs_params();

View file

@ -18,6 +18,20 @@ use crate::prelude::*;
pub mod changetracker; pub mod changetracker;
pub struct Checksums(HashMap<String, Sha1Hash>);
impl Checksums {
// case-fold filenames when checking files to be imported
// to account for case-insensitive filesystems
pub fn get(&self, key: impl AsRef<str>) -> Option<&Sha1Hash> {
self.0.get(key.as_ref().to_lowercase().as_str())
}
pub fn contains_key(&self, key: impl AsRef<str>) -> bool {
self.get(key).is_some()
}
}
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub struct MediaEntry { pub struct MediaEntry {
pub fname: String, pub fname: String,
@ -175,11 +189,12 @@ delete from media where fname=?",
} }
/// Returns all filenames and checksums, where the checksum is not null. /// Returns all filenames and checksums, where the checksum is not null.
pub(crate) fn all_registered_checksums(&self) -> error::Result<HashMap<String, Sha1Hash>> { pub(crate) fn all_registered_checksums(&self) -> error::Result<Checksums> {
self.db self.db
.prepare("SELECT fname, csum FROM media WHERE csum IS NOT NULL")? .prepare("SELECT fname, csum FROM media WHERE csum IS NOT NULL")?
.query_and_then([], row_to_name_and_checksum)? .query_and_then([], row_to_name_and_checksum)?
.collect() .collect::<error::Result<_>>()
.map(Checksums)
} }
pub(crate) fn force_resync(&self) -> error::Result<()> { pub(crate) fn force_resync(&self) -> error::Result<()> {

View file

@ -202,7 +202,7 @@ fn sveltekit_temp_file(path: &str) -> bool {
} }
fn check_cargo_deny() -> Result<()> { 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")?; Command::run("cargo deny check")?;
Ok(()) Ok(())
} }

View file

@ -4,7 +4,12 @@
import { getRange, getSelection } from "./cross-browser"; import { getRange, getSelection } from "./cross-browser";
function wrappedExceptForWhitespace(text: string, front: string, back: string): string { function wrappedExceptForWhitespace(text: string, front: string, back: string): string {
const match = text.match(/^(\s*)([^]*?)(\s*)$/)!; const normalizedText = text
.replace(/&nbsp;/g, " ")
.replace(/&#160;/g, " ")
.replace(/\u00A0/g, " ");
const match = normalizedText.match(/^(\s*)([^]*?)(\s*)$/)!;
return match[1] + front + match[2] + back + match[3]; return match[1] + front + match[2] + back + match[3];
} }

View file

@ -37,7 +37,9 @@ export const addOrUpdateNote = async function(
backExtra, backExtra,
tags, tags,
}); });
showResult(mode.noteId, result, noteCount); if (result.note) {
showResult(mode.noteId, result, noteCount);
}
} else { } else {
const result = await addImageOcclusionNote({ const result = await addImageOcclusionNote({
// IOCloningMode is not used on mobile // IOCloningMode is not used on mobile
@ -55,23 +57,12 @@ export const addOrUpdateNote = async function(
// show toast message // show toast message
const showResult = (noteId: number | null, result: OpChanges, count: number) => { const showResult = (noteId: number | null, result: OpChanges, count: number) => {
const props = $state({ const props = $state({
message: "", message: noteId ? tr.browsingCardsUpdated({ count: count }) : tr.importingCardsAdded({ count: count }),
type: "error" as "error" | "success", type: "success" as "error" | "success",
showToast: true, showToast: true,
}); });
mount(Toast, { mount(Toast, {
target: document.body, target: document.body,
props, 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;
}
}; };