From 06f9d41a96cdaabef781e266ba168d78b5c8e1c0 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 3 Sep 2025 14:46:17 +1000 Subject: [PATCH 01/11] Bypass install_name_tool invocation on macOS Not sure when https://github.com/astral-sh/uv/issues/14893 will be ready, and this seems to solve the problem for now. Closes #4227 --- qt/launcher/mac/build.sh | 8 ++++++- qt/launcher/mac/stub.c | 6 ++++++ qt/launcher/src/main.rs | 45 ++++++++++++++++++++-------------------- 3 files changed, 35 insertions(+), 24 deletions(-) create mode 100644 qt/launcher/mac/stub.c diff --git a/qt/launcher/mac/build.sh b/qt/launcher/mac/build.sh index 6143451b4..b861bc006 100755 --- a/qt/launcher/mac/build.sh +++ b/qt/launcher/mac/build.sh @@ -30,6 +30,12 @@ lipo -create \ -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" @@ -40,7 +46,7 @@ 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/launcher" "$APP_LAUNCHER"; do + 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" diff --git a/qt/launcher/mac/stub.c b/qt/launcher/mac/stub.c new file mode 100644 index 000000000..09f1479a7 --- /dev/null +++ b/qt/launcher/mac/stub.c @@ -0,0 +1,6 @@ +// 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; +} \ No newline at end of file diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index ccc4022b7..c4aba4509 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -261,11 +261,6 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re None }; - let have_venv = state.venv_folder.exists(); - if cfg!(target_os = "macos") && !have_developer_tools() && !have_venv { - println!("If you see a pop-up about 'install_name_tool', you can cancel it, and ignore the warning below.\n"); - } - // Prepare to sync the venv let mut command = Command::new(&state.uv_path); command.current_dir(&state.uv_install_root); @@ -277,17 +272,29 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re } } - // remove CONDA_PREFIX/bin from PATH to avoid conda interference - #[cfg(target_os = "macos")] - if let Ok(conda_prefix) = std::env::var("CONDA_PREFIX") { + 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 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); + 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); + } } } @@ -930,14 +937,6 @@ fn handle_uninstall(state: &State) -> Result { Ok(true) } -fn have_developer_tools() -> bool { - Command::new("xcode-select") - .args(["-p"]) - .output() - .map(|output| output.status.success()) - .unwrap_or(false) -} - 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(); From 2491eb0316283abe010a0e908b4dab17c5dba37f Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 3 Sep 2025 17:20:32 +1000 Subject: [PATCH 02/11] Don't reuse existing terminal process May possibly help with #4304 --- qt/launcher/src/platform/mac.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt/launcher/src/platform/mac.rs b/qt/launcher/src/platform/mac.rs index f97d7fd07..8662ba9f5 100644 --- a/qt/launcher/src/platform/mac.rs +++ b/qt/launcher/src/platform/mac.rs @@ -62,7 +62,7 @@ pub fn prepare_for_launch_after_update(mut cmd: Command, root: &Path) -> Result< pub fn relaunch_in_terminal() -> Result<()> { let current_exe = std::env::current_exe().context("Failed to get current executable path")?; Command::new("open") - .args(["-a", "Terminal"]) + .args(["-na", "Terminal"]) .arg(current_exe) .ensure_spawn()?; std::process::exit(0); From db1d04f622dda95a5ee8b529591392dd12e4c613 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 3 Sep 2025 19:58:45 +1000 Subject: [PATCH 03/11] Centralize uv command setup Closes #4306 --- qt/launcher/src/main.rs | 53 ++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index c4aba4509..a178f05e5 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -193,8 +193,8 @@ fn extract_aqt_version(state: &State) -> Option { return None; } - let output = Command::new(&state.uv_path) - .current_dir(&state.uv_install_root) + let output = uv_command(state) + .ok()? .env("VIRTUAL_ENV", &state.venv_folder) .args(["pip", "show", "aqt"]) .output() @@ -262,15 +262,7 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re }; // Prepare to sync the venv - 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_") || key == "VIRTUAL_ENV" { - command.env_remove(key); - } - } + let mut command = uv_command(state)?; if cfg!(target_os = "macos") { // remove CONDA_PREFIX/bin from PATH to avoid conda interference @@ -316,13 +308,6 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re command.env("UV_NO_CACHE", "1"); } - // 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); - } - match command.ensure_success() { Ok(_) => { // Sync succeeded @@ -672,9 +657,8 @@ fn filter_and_normalize_versions( fn fetch_versions(state: &State) -> Result> { let versions_script = state.resources_dir.join("versions.py"); - let mut cmd = Command::new(&state.uv_path); - cmd.current_dir(&state.uv_install_root) - .args(["run", "--no-project", "--no-config", "--managed-python"]) + 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)?; @@ -687,12 +671,6 @@ fn fetch_versions(state: &State) -> Result> { cmd.arg(&versions_script); - // Add mirror environment variable if enabled - if let Some((python_mirror, pypi_mirror)) = get_mirror_urls(state)? { - cmd.env("UV_PYTHON_INSTALL_MIRROR", &python_mirror) - .env("UV_DEFAULT_INDEX", &pypi_mirror); - } - let output = match cmd.utf8_output() { Ok(output) => output, Err(e) => { @@ -937,6 +915,27 @@ fn handle_uninstall(state: &State) -> Result { 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_") || key == "VIRTUAL_ENV" { + command.env_remove(key); + } + } + + // 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); + } + + 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(); From 6a985c9fb0b7a3df25f0c4163d1917452f52b82a Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 3 Sep 2025 20:54:16 +1000 Subject: [PATCH 04/11] Add support for custom launcher venv locations Closes #4305 when https://github.com/ankitects/anki-manual/pull/444 is merged, and makes it easier to maintain multiple Anki versions at once. --- qt/launcher/src/main.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index a178f05e5..aaa443aa0 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -88,9 +88,13 @@ fn main() { } fn run() -> Result<()> { - let uv_install_root = dirs::data_local_dir() - .context("Unable to determine data_dir")? - .join("AnkiProgramFiles"); + 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()?; From b2ab0c08303a61d345d06e1607d85ead423f4996 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 3 Sep 2025 23:50:26 +1000 Subject: [PATCH 05/11] Add an experimental new system Qt mode to the launcher Goal is to allow users to use their system Qt libraries that have things like fcitx support available. For #4313 --- qt/launcher/src/main.rs | 51 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index aaa443aa0..2bbb9cc0f 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -51,6 +51,8 @@ struct State { previous_version: Option, resources_dir: std::path::PathBuf, venv_folder: std::path::PathBuf, + /// system Python + PyQt6 library mode + system_qt: bool, } #[derive(Debug, Clone)] @@ -117,6 +119,8 @@ fn run() -> Result<()> { 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"), }; @@ -294,18 +298,36 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re } } + // 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_CACHE_DIR", &state.uv_cache_dir) .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()), - ) - .args(["sync", "--upgrade", "--managed-python", "--no-config"]); + ); - // Add python version if .python-version file exists + 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 { - command.args(["--python", version]); + if !state.system_qt { + command.args(["--python", version]); + } } if state.no_cache_marker.exists() { @@ -727,7 +749,26 @@ fn apply_version_kind(version_kind: &VersionKind, state: &State) -> Result<()> { &format!("anki-release=={version}\",\n \"anki=={version}\",\n \"aqt=={version}"), ), }; - write_file(&state.user_pyproject_path, &updated_content)?; + + 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 { From 5280cb2f1c2ee8f9b97d28e33387cf153acb3e48 Mon Sep 17 00:00:00 2001 From: maxr777 <31160014+maxr777@users.noreply.github.com> Date: Thu, 4 Sep 2025 03:52:08 +0200 Subject: [PATCH 06/11] Enable nc: to only search in a specific field (#4276) (#4312) * Enable nc: to only search in a specific field * Add FieldSearchMode enum to replace boolean fields * Avoid magic numbers in enum * Use standard naming so Prost can remove redundant text --------- Co-authored-by: Damien Elmes --- CONTRIBUTORS | 1 + proto/anki/search.proto | 7 +- rslib/src/search/builder.rs | 3 +- rslib/src/search/mod.rs | 1 + rslib/src/search/parser.rs | 56 ++++++++++++---- rslib/src/search/service/search_node.rs | 5 +- rslib/src/search/sqlwriter.rs | 85 ++++++++++++++++++++++--- rslib/src/search/writer.rs | 24 +++++-- 8 files changed, 152 insertions(+), 30 deletions(-) diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 70032a23c..b03108e16 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -240,6 +240,7 @@ Thomas Rixen Siyuan Mattuwu Yan Lee Doughty <32392044+leedoughty@users.noreply.github.com> memchr +Max Romanowski Aldlss ******************** diff --git a/proto/anki/search.proto b/proto/anki/search.proto index bb417294c..e87a063c9 100644 --- a/proto/anki/search.proto +++ b/proto/anki/search.proto @@ -74,10 +74,15 @@ message SearchNode { 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; - bool is_re = 3; + FieldSearchMode mode = 3; } oneof filter { diff --git a/rslib/src/search/builder.rs b/rslib/src/search/builder.rs index a76af0560..0c22ff1eb 100644 --- a/rslib/src/search/builder.rs +++ b/rslib/src/search/builder.rs @@ -6,6 +6,7 @@ use std::mem; use itertools::Itertools; use super::writer::write_nodes; +use super::FieldSearchMode; use super::Node; use super::SearchNode; use super::StateKind; @@ -174,7 +175,7 @@ impl SearchNode { pub fn from_tag_name(name: &str) -> Self { Self::Tag { tag: escape_anki_wildcards_for_search_node(name), - is_re: false, + mode: FieldSearchMode::Normal, } } diff --git a/rslib/src/search/mod.rs b/rslib/src/search/mod.rs index 0960fabf9..0dd52dbc3 100644 --- a/rslib/src/search/mod.rs +++ b/rslib/src/search/mod.rs @@ -13,6 +13,7 @@ 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; diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index 33c1a4622..cbdba3d9f 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -3,6 +3,7 @@ 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; @@ -27,7 +28,6 @@ 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>>; @@ -48,6 +48,23 @@ pub enum Node { 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 @@ -56,7 +73,7 @@ pub enum SearchNode { SingleField { field: String, text: String, - is_re: bool, + mode: FieldSearchMode, }, AddedInDays(u32), EditedInDays(u32), @@ -77,7 +94,7 @@ pub enum SearchNode { }, Tag { tag: String, - is_re: bool, + mode: FieldSearchMode, }, Duplicates { notetype_id: NotetypeId, @@ -373,12 +390,12 @@ fn parse_tag(s: &str) -> ParseResult<'_, SearchNode> { Ok(if let Some(re) = s.strip_prefix("re:") { SearchNode::Tag { tag: unescape_quotes(re), - is_re: true, + mode: FieldSearchMode::Regex, } } else { SearchNode::Tag { tag: unescape(s)?, - is_re: false, + mode: FieldSearchMode::Normal, } }) } @@ -670,13 +687,19 @@ fn parse_single_field<'a>(key: &'a str, val: &'a str) -> ParseResult<'a, SearchN SearchNode::SingleField { field: unescape(key)?, text: unescape_quotes(stripped), - is_re: true, + 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)?, - is_re: false, + mode: FieldSearchMode::Normal, } }) } @@ -806,7 +829,7 @@ mod test { Search(SingleField { field: "foo".into(), text: "bar baz".into(), - is_re: false, + mode: FieldSearchMode::Normal, }) ]))), Or, @@ -819,7 +842,16 @@ mod test { vec![Search(SingleField { field: "foo".into(), text: "bar".into(), - is_re: true + mode: FieldSearchMode::Regex, + })] + ); + + assert_eq!( + parse("foo:nc:bar")?, + vec![Search(SingleField { + field: "foo".into(), + text: "bar".into(), + mode: FieldSearchMode::NoCombining, })] ); @@ -829,7 +861,7 @@ mod test { vec![Search(SingleField { field: "field".into(), text: "va\"lue".into(), - is_re: false + mode: FieldSearchMode::Normal, })] ); assert_eq!(parse(r#""field:va\"lue""#)?, parse(r#"field:"va\"lue""#)?,); @@ -906,14 +938,14 @@ mod test { parse("tag:hard")?, vec![Search(Tag { tag: "hard".into(), - is_re: false + mode: FieldSearchMode::Normal })] ); assert_eq!( parse(r"tag:re:\\")?, vec![Search(Tag { tag: r"\\".into(), - is_re: true + mode: FieldSearchMode::Regex })] ); assert_eq!( diff --git a/rslib/src/search/service/search_node.rs b/rslib/src/search/service/search_node.rs index 1851a28f7..6986eef2a 100644 --- a/rslib/src/search/service/search_node.rs +++ b/rslib/src/search/service/search_node.rs @@ -6,6 +6,7 @@ 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; @@ -40,7 +41,7 @@ impl TryFrom for Node { Filter::FieldName(s) => Node::Search(SearchNode::SingleField { field: escape_anki_wildcards_for_search_node(&s), text: "_*".to_string(), - is_re: false, + mode: FieldSearchMode::Normal, }), Filter::Rated(rated) => Node::Search(SearchNode::Rated { days: rated.days, @@ -107,7 +108,7 @@ impl TryFrom for Node { Filter::Field(field) => Node::Search(SearchNode::SingleField { field: escape_anki_wildcards(&field.field_name), text: escape_anki_wildcards(&field.text), - is_re: field.is_re, + mode: field.mode().into(), }), Filter::LiteralText(text) => { let text = escape_anki_wildcards(&text); diff --git a/rslib/src/search/sqlwriter.rs b/rslib/src/search/sqlwriter.rs index 542dba4fc..95249276c 100644 --- a/rslib/src/search/sqlwriter.rs +++ b/rslib/src/search/sqlwriter.rs @@ -7,6 +7,7 @@ use std::ops::Range; use itertools::Itertools; +use super::parser::FieldSearchMode; use super::parser::Node; use super::parser::PropertyKind; use super::parser::RatingKind; @@ -138,8 +139,8 @@ impl SqlWriter<'_> { false, )? } - SearchNode::SingleField { field, text, is_re } => { - self.write_field(&norm(field), &self.norm_note(text), *is_re)? + 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))? @@ -180,7 +181,7 @@ impl SqlWriter<'_> { SearchNode::Notetype(notetype) => self.write_notetype(&norm(notetype)), SearchNode::Rated { days, ease } => self.write_rated(">", -i64::from(*days), ease)?, - SearchNode::Tag { tag, is_re } => self.write_tag(&norm(tag), *is_re), + 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(); @@ -296,8 +297,8 @@ impl SqlWriter<'_> { Ok(()) } - fn write_tag(&mut self, tag: &str, is_re: bool) { - if is_re { + 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 { @@ -567,16 +568,18 @@ impl SqlWriter<'_> { } } - fn write_field(&mut self, field_name: &str, val: &str, is_re: bool) -> Result<()> { + fn write_field(&mut self, field_name: &str, val: &str, mode: FieldSearchMode) -> Result<()> { if matches!(field_name, "*" | "_*" | "*_") { - if is_re { + if mode == FieldSearchMode::Regex { self.write_all_fields_regexp(val); } else { self.write_all_fields(val); } Ok(()) - } else if is_re { + } 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) } @@ -592,6 +595,58 @@ impl SqlWriter<'_> { 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() { @@ -1116,6 +1171,20 @@ mod test { 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"), diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index 3bbe6fd0a..68d05c66d 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -9,6 +9,7 @@ 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; @@ -69,7 +70,7 @@ fn write_search_node(node: &SearchNode) -> String { use SearchNode::*; match node { UnqualifiedText(s) => maybe_quote(&s.replace(':', "\\:")), - SingleField { field, text, is_re } => write_single_field(field, text, *is_re), + 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}"), @@ -81,7 +82,7 @@ fn write_search_node(node: &SearchNode) -> String { NotetypeId(NotetypeIdType(i)) => format!("mid:{i}"), Notetype(s) => maybe_quote(&format!("note:{s}")), Rated { days, ease } => write_rated(days, ease), - Tag { tag, is_re } => write_single_field("tag", tag, *is_re), + 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}"), @@ -116,14 +117,25 @@ fn needs_quotation(txt: &str) -> bool { } /// Also used by tag search, which has the same syntax. -fn write_single_field(field: &str, text: &str, is_re: bool) -> String { - let re = if is_re { "re:" } else { "" }; - let text = if !is_re && text.starts_with("re:") { +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(':', "\\:"), re, &text)) + maybe_quote(&format!( + "{}:{}{}", + field.replace(':', "\\:"), + prefix, + &text + )) } fn write_template(template: &TemplateKind) -> String { From b4b1c2013f8712a0fb77bc07243d6d6e03af89a7 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 4 Sep 2025 12:54:02 +1000 Subject: [PATCH 07/11] Use the audio input device's preferred format 19f9afba644c5e219e7305c87d48887d59db4a5d broke recording for devices that only support a single channel. Instead of hard-coding the values, we should be using what the device prefers. Apparently some devices may only support float formats, so conversion code has been added to handle that case as well. https://forums.ankiweb.net/t/cant-record-my-voice-after-upgrading-to-25-7-3/64453 --- qt/aqt/sound.py | 68 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/qt/aqt/sound.py b/qt/aqt/sound.py index acf531efb..f54ebd3e8 100644 --- a/qt/aqt/sound.py +++ b/qt/aqt/sound.py @@ -631,18 +631,44 @@ class QtAudioInputRecorder(Recorder): self.mw = mw self._parent = parent - from PyQt6.QtMultimedia import QAudioFormat, QAudioSource # type: ignore + from PyQt6.QtMultimedia import QAudioSource, QMediaDevices # type: ignore - format = QAudioFormat() - format.setChannelCount(2) - format.setSampleRate(44100) - format.setSampleFormat(QAudioFormat.SampleFormat.Int16) + # Get the default audio input device + device = QMediaDevices.defaultAudioInput() - source = QAudioSource(format, parent) + # 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() @@ -665,18 +691,32 @@ class QtAudioInputRecorder(Recorder): return def write_file() -> None: - # swallow the first 300ms to allow audio device to quiesce - wait = int(44100 * self.STARTUP_DELAY) - if len(self._buffer) <= wait: - return - self._buffer = self._buffer[wait:] + from PyQt6.QtMultimedia import QAudioFormat - # write out the wave file + # 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(2) + wf.setsampwidth(sample_width) wf.setframerate(self._format.sampleRate()) - wf.writeframes(self._buffer) + wf.writeframes(audio_data) wf.close() def and_then(fut: Future) -> None: From 08431106da745de3e7fc58d717494d49ea864ecb Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 4 Sep 2025 13:20:12 +1000 Subject: [PATCH 08/11] Exclude SSLKEYLOGFILE from Python Closes #4308 --- qt/launcher/src/main.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index 2bbb9cc0f..8996f9820 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -966,10 +966,13 @@ fn uv_command(state: &State) -> Result { // remove UV_* environment variables to avoid interference for (key, _) in std::env::vars() { - if key.starts_with("UV_") || key == "VIRTUAL_ENV" { + 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)? { @@ -1003,6 +1006,7 @@ fn build_python_command(state: &State, args: &[String]) -> Result { // 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) } From dda730dfa2d37e7e530e77f76df373fe4b287d26 Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Thu, 4 Sep 2025 05:35:00 +0100 Subject: [PATCH 09/11] Fix/Invalid memory states in simulator after parameters changed (#4317) * Fix/Invalid memory states after optimization for simulator * Update ts/routes/deck-options/FsrsOptions.svelte * typo * ./check --- ftl/core/deck-config.ftl | 1 + ts/routes/deck-options/FsrsOptions.svelte | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index 43d03c6bb..e365d9553 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -498,6 +498,7 @@ deck-config-desired-retention-below-optimal = Your desired retention is below op # 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 config 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 diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index a166f2081..48124771f 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -53,6 +53,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html let desiredRetentionFocused = false; let desiredRetentionEverFocused = false; let optimized = false; + const initialParams = [...fsrsParams($config)]; $: if (desiredRetentionFocused) { desiredRetentionEverFocused = true; } @@ -338,6 +339,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html state.save(UpdateDeckConfigsMode.COMPUTE_ALL_PARAMS); } + function showSimulatorModal(modal: Modal) { + if (fsrsParams($config).toString() === initialParams.toString()) { + modal?.show(); + } else { + alert(tr.deckConfigFsrsSimulateSavePreset()); + } + } + let simulatorModal: Modal; let workloadModal: Modal; @@ -368,7 +377,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html class="btn btn-primary" on:click={() => { simulateFsrsRequest.reviewLimit = 9999; - workloadModal?.show(); + showSimulatorModal(workloadModal); }} > {tr.deckConfigFsrsDesiredRetentionHelpMeDecideExperimental()} @@ -455,7 +464,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
From 6d31776c25ceb6681d6f8a311ec75f9a4d4d7329 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 4 Sep 2025 14:38:45 +1000 Subject: [PATCH 10/11] Update translations --- ftl/core-repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ftl/core-repo b/ftl/core-repo index 5897ef3a4..d255301b5 160000 --- a/ftl/core-repo +++ b/ftl/core-repo @@ -1 +1 @@ -Subproject commit 5897ef3a4589c123b7fa4c7fbd67f84d0b7ee13e +Subproject commit d255301b5a815ebac73c380b48507440d2f5dcce From 5c4d2e87a14a50601616426881609c4ee5b7b3ae Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 4 Sep 2025 14:39:29 +1000 Subject: [PATCH 11/11] Bump version --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index 6b856e54b..280125b32 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -25.08b5 +25.09rc1