diff --git a/.version b/.version index 6b856e54b..280125b32 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -25.08b5 +25.09rc1 diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 93a0cdfe6..456cecaa8 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 mcostn 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 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/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/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: 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) } 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 { 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
-