Merge branch 'ankitects:main' into dock

This commit is contained in:
Emil Hamrin 2025-09-05 22:18:15 +02:00 committed by GitHub
commit aa8fee8243
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 336 additions and 105 deletions

View file

@ -1 +1 @@
25.08b5 25.09rc1

View file

@ -241,6 +241,7 @@ Thomas Rixen <thomas.rixen@student.uclouvain.be>
Siyuan Mattuwu Yan <syan4@ualberta.ca> Siyuan Mattuwu Yan <syan4@ualberta.ca>
Lee Doughty <32392044+leedoughty@users.noreply.github.com> Lee Doughty <32392044+leedoughty@users.noreply.github.com>
memchr <memchr@proton.me> memchr <memchr@proton.me>
Max Romanowski <maxr777@proton.me>
Aldlss <ayaldlss@gmail.com> Aldlss <ayaldlss@gmail.com>
******************** ********************

@ -1 +1 @@
Subproject commit 5897ef3a4589c123b7fa4c7fbd67f84d0b7ee13e Subproject commit d255301b5a815ebac73c380b48507440d2f5dcce

View file

@ -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. # cards that can be recalled or retrieved on a specific date.
deck-config-fsrs-simulator-experimental = FSRS Simulator (Experimental) deck-config-fsrs-simulator-experimental = FSRS Simulator (Experimental)
deck-config-fsrs-simulate-desired-retention-experimental = FSRS Desired Retention 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-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-additional-new-cards-to-simulate = Additional new cards to simulate
deck-config-simulate = Simulate deck-config-simulate = Simulate

View file

@ -74,10 +74,15 @@ message SearchNode {
repeated SearchNode nodes = 1; repeated SearchNode nodes = 1;
Joiner joiner = 2; Joiner joiner = 2;
} }
enum FieldSearchMode {
FIELD_SEARCH_MODE_NORMAL = 0;
FIELD_SEARCH_MODE_REGEX = 1;
FIELD_SEARCH_MODE_NOCOMBINING = 2;
}
message Field { message Field {
string field_name = 1; string field_name = 1;
string text = 2; string text = 2;
bool is_re = 3; FieldSearchMode mode = 3;
} }
oneof filter { oneof filter {

View file

@ -631,18 +631,44 @@ class QtAudioInputRecorder(Recorder):
self.mw = mw self.mw = mw
self._parent = parent self._parent = parent
from PyQt6.QtMultimedia import QAudioFormat, QAudioSource # type: ignore from PyQt6.QtMultimedia import QAudioSource, QMediaDevices # type: ignore
format = QAudioFormat() # Get the default audio input device
format.setChannelCount(2) device = QMediaDevices.defaultAudioInput()
format.setSampleRate(44100)
format.setSampleFormat(QAudioFormat.SampleFormat.Int16)
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._format = source.format()
self._audio_input = source 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: def start(self, on_done: Callable[[], None]) -> None:
self._iodevice = self._audio_input.start() self._iodevice = self._audio_input.start()
self._buffer = bytearray() self._buffer = bytearray()
@ -665,18 +691,32 @@ class QtAudioInputRecorder(Recorder):
return return
def write_file() -> None: def write_file() -> None:
# swallow the first 300ms to allow audio device to quiesce from PyQt6.QtMultimedia import QAudioFormat
wait = int(44100 * self.STARTUP_DELAY)
if len(self._buffer) <= wait:
return
self._buffer = self._buffer[wait:]
# 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 = wave.open(self.output_path, "wb")
wf.setnchannels(self._format.channelCount()) wf.setnchannels(self._format.channelCount())
wf.setsampwidth(2) wf.setsampwidth(sample_width)
wf.setframerate(self._format.sampleRate()) wf.setframerate(self._format.sampleRate())
wf.writeframes(self._buffer) wf.writeframes(audio_data)
wf.close() wf.close()
def and_then(fut: Future) -> None: def and_then(fut: Future) -> None:

View file

@ -30,6 +30,12 @@ lipo -create \
-output "$APP_LAUNCHER/Contents/MacOS/launcher" -output "$APP_LAUNCHER/Contents/MacOS/launcher"
cp "$OUTPUT_DIR/uv" "$APP_LAUNCHER/Contents/MacOS/" 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 # Copy support files
ANKI_VERSION=$(cat ../../../.version | tr -d '\n') ANKI_VERSION=$(cat ../../../.version | tr -d '\n')
sed "s/ANKI_VERSION/$ANKI_VERSION/g" Info.plist > "$APP_LAUNCHER/Contents/Info.plist" 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 # Codesign/bundle
if [ -z "$NODMG" ]; then 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:" \ codesign --force -vvvv -o runtime -s "Developer ID Application:" \
--entitlements entitlements.python.xml \ --entitlements entitlements.python.xml \
"$i" "$i"

6
qt/launcher/mac/stub.c Normal file
View file

@ -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;
}

View file

@ -51,6 +51,8 @@ struct State {
previous_version: Option<String>, previous_version: Option<String>,
resources_dir: std::path::PathBuf, resources_dir: std::path::PathBuf,
venv_folder: std::path::PathBuf, venv_folder: std::path::PathBuf,
/// system Python + PyQt6 library mode
system_qt: bool,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -88,9 +90,13 @@ fn main() {
} }
fn run() -> Result<()> { fn run() -> Result<()> {
let uv_install_root = dirs::data_local_dir() let uv_install_root = if let Ok(custom_root) = std::env::var("ANKI_LAUNCHER_VENV_ROOT") {
.context("Unable to determine data_dir")? std::path::PathBuf::from(custom_root)
.join("AnkiProgramFiles"); } else {
dirs::data_local_dir()
.context("Unable to determine data_dir")?
.join("AnkiProgramFiles")
};
let (exe_dir, resources_dir) = get_exe_and_resources_dirs()?; let (exe_dir, resources_dir) = get_exe_and_resources_dirs()?;
@ -113,6 +119,8 @@ fn run() -> Result<()> {
mirror_path: uv_install_root.join("mirror"), mirror_path: uv_install_root.join("mirror"),
pyproject_modified_by_user: false, // calculated later pyproject_modified_by_user: false, // calculated later
previous_version: None, previous_version: None,
system_qt: (cfg!(unix) && !cfg!(target_os = "macos"))
&& resources_dir.join("system_qt").exists(),
resources_dir, resources_dir,
venv_folder: uv_install_root.join(".venv"), venv_folder: uv_install_root.join(".venv"),
}; };
@ -193,8 +201,8 @@ fn extract_aqt_version(state: &State) -> Option<String> {
return None; return None;
} }
let output = Command::new(&state.uv_path) let output = uv_command(state)
.current_dir(&state.uv_install_root) .ok()?
.env("VIRTUAL_ENV", &state.venv_folder) .env("VIRTUAL_ENV", &state.venv_folder)
.args(["pip", "show", "aqt"]) .args(["pip", "show", "aqt"])
.output() .output()
@ -261,34 +269,45 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re
None 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 // Prepare to sync the venv
let mut command = Command::new(&state.uv_path); let mut command = uv_command(state)?;
command.current_dir(&state.uv_install_root);
// remove UV_* environment variables to avoid interference if cfg!(target_os = "macos") {
for (key, _) in std::env::vars() { // remove CONDA_PREFIX/bin from PATH to avoid conda interference
if key.starts_with("UV_") || key == "VIRTUAL_ENV" { if let Ok(conda_prefix) = std::env::var("CONDA_PREFIX") {
command.env_remove(key); 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 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);
}
} }
} }
// remove CONDA_PREFIX/bin from PATH to avoid conda interference // Create venv with system site packages if system Qt is enabled
#[cfg(target_os = "macos")] if state.system_qt {
if let Ok(conda_prefix) = std::env::var("CONDA_PREFIX") { let mut venv_command = uv_command(state)?;
if let Ok(current_path) = std::env::var("PATH") { venv_command.args([
let conda_bin = format!("{conda_prefix}/bin"); "venv",
let filtered_paths: Vec<&str> = current_path "--no-managed-python",
.split(':') "--system-site-packages",
.filter(|&path| path != conda_bin) "--no-config",
.collect(); ]);
let new_path = filtered_paths.join(":"); venv_command.ensure_success()?;
command.env("PATH", new_path);
}
} }
command command
@ -297,25 +316,24 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re
.env( .env(
"UV_HTTP_TIMEOUT", "UV_HTTP_TIMEOUT",
std::env::var("UV_HTTP_TIMEOUT").unwrap_or_else(|_| "180".to_string()), 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 { 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() { if state.no_cache_marker.exists() {
command.env("UV_NO_CACHE", "1"); 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() { match command.ensure_success() {
Ok(_) => { Ok(_) => {
// Sync succeeded // Sync succeeded
@ -665,9 +683,8 @@ fn filter_and_normalize_versions(
fn fetch_versions(state: &State) -> Result<Vec<String>> { fn fetch_versions(state: &State) -> Result<Vec<String>> {
let versions_script = state.resources_dir.join("versions.py"); let versions_script = state.resources_dir.join("versions.py");
let mut cmd = Command::new(&state.uv_path); let mut cmd = uv_command(state)?;
cmd.current_dir(&state.uv_install_root) cmd.args(["run", "--no-project", "--no-config", "--managed-python"])
.args(["run", "--no-project", "--no-config", "--managed-python"])
.args(["--with", "pip-system-certs,requests[socks]"]); .args(["--with", "pip-system-certs,requests[socks]"]);
let python_version = read_file(&state.dist_python_version_path)?; let python_version = read_file(&state.dist_python_version_path)?;
@ -680,12 +697,6 @@ fn fetch_versions(state: &State) -> Result<Vec<String>> {
cmd.arg(&versions_script); 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() { let output = match cmd.utf8_output() {
Ok(output) => output, Ok(output) => output,
Err(e) => { Err(e) => {
@ -738,7 +749,26 @@ fn apply_version_kind(version_kind: &VersionKind, state: &State) -> Result<()> {
&format!("anki-release=={version}\",\n \"anki=={version}\",\n \"aqt=={version}"), &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 // Update .python-version based on version kind
match version_kind { match version_kind {
@ -930,12 +960,28 @@ fn handle_uninstall(state: &State) -> Result<bool> {
Ok(true) Ok(true)
} }
fn have_developer_tools() -> bool { fn uv_command(state: &State) -> Result<Command> {
Command::new("xcode-select") let mut command = Command::new(&state.uv_path);
.args(["-p"]) command.current_dir(&state.uv_install_root);
.output()
.map(|output| output.status.success()) // remove UV_* environment variables to avoid interference
.unwrap_or(false) for (key, _) in std::env::vars() {
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)? {
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<Command> { fn build_python_command(state: &State, args: &[String]) -> Result<Command> {
@ -960,6 +1006,7 @@ fn build_python_command(state: &State, args: &[String]) -> Result<Command> {
// Set UV and Python paths for the Python code // Set UV and Python paths for the Python code
cmd.env("ANKI_LAUNCHER_UV", state.uv_path.utf8()?.as_str()); cmd.env("ANKI_LAUNCHER_UV", state.uv_path.utf8()?.as_str());
cmd.env("UV_PROJECT", state.uv_install_root.utf8()?.as_str()); cmd.env("UV_PROJECT", state.uv_install_root.utf8()?.as_str());
cmd.env_remove("SSLKEYLOGFILE");
Ok(cmd) Ok(cmd)
} }

View file

@ -62,7 +62,7 @@ pub fn prepare_for_launch_after_update(mut cmd: Command, root: &Path) -> Result<
pub fn relaunch_in_terminal() -> Result<()> { pub fn relaunch_in_terminal() -> Result<()> {
let current_exe = std::env::current_exe().context("Failed to get current executable path")?; let current_exe = std::env::current_exe().context("Failed to get current executable path")?;
Command::new("open") Command::new("open")
.args(["-a", "Terminal"]) .args(["-na", "Terminal"])
.arg(current_exe) .arg(current_exe)
.ensure_spawn()?; .ensure_spawn()?;
std::process::exit(0); std::process::exit(0);

View file

@ -6,6 +6,7 @@ use std::mem;
use itertools::Itertools; use itertools::Itertools;
use super::writer::write_nodes; use super::writer::write_nodes;
use super::FieldSearchMode;
use super::Node; use super::Node;
use super::SearchNode; use super::SearchNode;
use super::StateKind; use super::StateKind;
@ -174,7 +175,7 @@ impl SearchNode {
pub fn from_tag_name(name: &str) -> Self { pub fn from_tag_name(name: &str) -> Self {
Self::Tag { Self::Tag {
tag: escape_anki_wildcards_for_search_node(name), tag: escape_anki_wildcards_for_search_node(name),
is_re: false, mode: FieldSearchMode::Normal,
} }
} }

View file

@ -13,6 +13,7 @@ pub use builder::JoinSearches;
pub use builder::Negated; pub use builder::Negated;
pub use builder::SearchBuilder; pub use builder::SearchBuilder;
pub use parser::parse as parse_search; pub use parser::parse as parse_search;
pub use parser::FieldSearchMode;
pub use parser::Node; pub use parser::Node;
pub use parser::PropertyKind; pub use parser::PropertyKind;
pub use parser::RatingKind; pub use parser::RatingKind;

View file

@ -3,6 +3,7 @@
use std::sync::LazyLock; use std::sync::LazyLock;
use anki_proto::search::search_node::FieldSearchMode as FieldSearchModeProto;
use nom::branch::alt; use nom::branch::alt;
use nom::bytes::complete::escaped; use nom::bytes::complete::escaped;
use nom::bytes::complete::is_not; use nom::bytes::complete::is_not;
@ -27,7 +28,6 @@ use crate::error::ParseError;
use crate::error::Result; use crate::error::Result;
use crate::error::SearchErrorKind as FailKind; use crate::error::SearchErrorKind as FailKind;
use crate::prelude::*; use crate::prelude::*;
type IResult<'a, O> = std::result::Result<(&'a str, O), nom::Err<ParseError<'a>>>; type IResult<'a, O> = std::result::Result<(&'a str, O), nom::Err<ParseError<'a>>>;
type ParseResult<'a, O> = std::result::Result<O, nom::Err<ParseError<'a>>>; type ParseResult<'a, O> = std::result::Result<O, nom::Err<ParseError<'a>>>;
@ -48,6 +48,23 @@ pub enum Node {
Search(SearchNode), Search(SearchNode),
} }
#[derive(Copy, Debug, PartialEq, Eq, Clone)]
pub enum FieldSearchMode {
Normal,
Regex,
NoCombining,
}
impl From<FieldSearchModeProto> 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)] #[derive(Debug, PartialEq, Clone)]
pub enum SearchNode { pub enum SearchNode {
// text without a colon // text without a colon
@ -56,7 +73,7 @@ pub enum SearchNode {
SingleField { SingleField {
field: String, field: String,
text: String, text: String,
is_re: bool, mode: FieldSearchMode,
}, },
AddedInDays(u32), AddedInDays(u32),
EditedInDays(u32), EditedInDays(u32),
@ -77,7 +94,7 @@ pub enum SearchNode {
}, },
Tag { Tag {
tag: String, tag: String,
is_re: bool, mode: FieldSearchMode,
}, },
Duplicates { Duplicates {
notetype_id: NotetypeId, notetype_id: NotetypeId,
@ -373,12 +390,12 @@ fn parse_tag(s: &str) -> ParseResult<'_, SearchNode> {
Ok(if let Some(re) = s.strip_prefix("re:") { Ok(if let Some(re) = s.strip_prefix("re:") {
SearchNode::Tag { SearchNode::Tag {
tag: unescape_quotes(re), tag: unescape_quotes(re),
is_re: true, mode: FieldSearchMode::Regex,
} }
} else { } else {
SearchNode::Tag { SearchNode::Tag {
tag: unescape(s)?, 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 { SearchNode::SingleField {
field: unescape(key)?, field: unescape(key)?,
text: unescape_quotes(stripped), 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 { } else {
SearchNode::SingleField { SearchNode::SingleField {
field: unescape(key)?, field: unescape(key)?,
text: unescape(val)?, text: unescape(val)?,
is_re: false, mode: FieldSearchMode::Normal,
} }
}) })
} }
@ -806,7 +829,7 @@ mod test {
Search(SingleField { Search(SingleField {
field: "foo".into(), field: "foo".into(),
text: "bar baz".into(), text: "bar baz".into(),
is_re: false, mode: FieldSearchMode::Normal,
}) })
]))), ]))),
Or, Or,
@ -819,7 +842,16 @@ mod test {
vec![Search(SingleField { vec![Search(SingleField {
field: "foo".into(), field: "foo".into(),
text: "bar".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 { vec![Search(SingleField {
field: "field".into(), field: "field".into(),
text: "va\"lue".into(), text: "va\"lue".into(),
is_re: false mode: FieldSearchMode::Normal,
})] })]
); );
assert_eq!(parse(r#""field:va\"lue""#)?, parse(r#"field:"va\"lue""#)?,); assert_eq!(parse(r#""field:va\"lue""#)?, parse(r#"field:"va\"lue""#)?,);
@ -906,14 +938,14 @@ mod test {
parse("tag:hard")?, parse("tag:hard")?,
vec![Search(Tag { vec![Search(Tag {
tag: "hard".into(), tag: "hard".into(),
is_re: false mode: FieldSearchMode::Normal
})] })]
); );
assert_eq!( assert_eq!(
parse(r"tag:re:\\")?, parse(r"tag:re:\\")?,
vec![Search(Tag { vec![Search(Tag {
tag: r"\\".into(), tag: r"\\".into(),
is_re: true mode: FieldSearchMode::Regex
})] })]
); );
assert_eq!( assert_eq!(

View file

@ -6,6 +6,7 @@ use itertools::Itertools;
use crate::prelude::*; use crate::prelude::*;
use crate::search::parse_search; use crate::search::parse_search;
use crate::search::FieldSearchMode;
use crate::search::Negated; use crate::search::Negated;
use crate::search::Node; use crate::search::Node;
use crate::search::PropertyKind; use crate::search::PropertyKind;
@ -40,7 +41,7 @@ impl TryFrom<anki_proto::search::SearchNode> for Node {
Filter::FieldName(s) => Node::Search(SearchNode::SingleField { Filter::FieldName(s) => Node::Search(SearchNode::SingleField {
field: escape_anki_wildcards_for_search_node(&s), field: escape_anki_wildcards_for_search_node(&s),
text: "_*".to_string(), text: "_*".to_string(),
is_re: false, mode: FieldSearchMode::Normal,
}), }),
Filter::Rated(rated) => Node::Search(SearchNode::Rated { Filter::Rated(rated) => Node::Search(SearchNode::Rated {
days: rated.days, days: rated.days,
@ -107,7 +108,7 @@ impl TryFrom<anki_proto::search::SearchNode> for Node {
Filter::Field(field) => Node::Search(SearchNode::SingleField { Filter::Field(field) => Node::Search(SearchNode::SingleField {
field: escape_anki_wildcards(&field.field_name), field: escape_anki_wildcards(&field.field_name),
text: escape_anki_wildcards(&field.text), text: escape_anki_wildcards(&field.text),
is_re: field.is_re, mode: field.mode().into(),
}), }),
Filter::LiteralText(text) => { Filter::LiteralText(text) => {
let text = escape_anki_wildcards(&text); let text = escape_anki_wildcards(&text);

View file

@ -7,6 +7,7 @@ use std::ops::Range;
use itertools::Itertools; use itertools::Itertools;
use super::parser::FieldSearchMode;
use super::parser::Node; use super::parser::Node;
use super::parser::PropertyKind; use super::parser::PropertyKind;
use super::parser::RatingKind; use super::parser::RatingKind;
@ -138,8 +139,8 @@ impl SqlWriter<'_> {
false, false,
)? )?
} }
SearchNode::SingleField { field, text, is_re } => { SearchNode::SingleField { field, text, mode } => {
self.write_field(&norm(field), &self.norm_note(text), *is_re)? self.write_field(&norm(field), &self.norm_note(text), *mode)?
} }
SearchNode::Duplicates { notetype_id, text } => { SearchNode::Duplicates { notetype_id, text } => {
self.write_dupe(*notetype_id, &self.norm_note(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::Notetype(notetype) => self.write_notetype(&norm(notetype)),
SearchNode::Rated { days, ease } => self.write_rated(">", -i64::from(*days), ease)?, 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::State(state) => self.write_state(state)?,
SearchNode::Flag(flag) => { SearchNode::Flag(flag) => {
write!(self.sql, "(c.flags & 7) == {flag}").unwrap(); write!(self.sql, "(c.flags & 7) == {flag}").unwrap();
@ -296,8 +297,8 @@ impl SqlWriter<'_> {
Ok(()) Ok(())
} }
fn write_tag(&mut self, tag: &str, is_re: bool) { fn write_tag(&mut self, tag: &str, mode: FieldSearchMode) {
if is_re { if mode == FieldSearchMode::Regex {
self.args.push(format!("(?i){tag}")); self.args.push(format!("(?i){tag}"));
write!(self.sql, "regexp_tags(?{}, n.tags)", self.args.len()).unwrap(); write!(self.sql, "regexp_tags(?{}, n.tags)", self.args.len()).unwrap();
} else { } 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 matches!(field_name, "*" | "_*" | "*_") {
if is_re { if mode == FieldSearchMode::Regex {
self.write_all_fields_regexp(val); self.write_all_fields_regexp(val);
} else { } else {
self.write_all_fields(val); self.write_all_fields(val);
} }
Ok(()) Ok(())
} else if is_re { } else if mode == FieldSearchMode::Regex {
self.write_single_field_regexp(field_name, val) self.write_single_field_regexp(field_name, val)
} else if mode == FieldSearchMode::NoCombining {
self.write_single_field_nc(field_name, val)
} else { } else {
self.write_single_field(field_name, val) self.write_single_field(field_name, val)
} }
@ -592,6 +595,58 @@ impl SqlWriter<'_> {
write!(self.sql, "regexp_fields(?{}, n.flds)", self.args.len()).unwrap(); 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<u32>| {
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<()> { 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)?; let field_indicies_by_notetype = self.fields_indices_by_notetype(field_name)?;
if field_indicies_by_notetype.is_empty() { if field_indicies_by_notetype.is_empty() {
@ -1116,6 +1171,20 @@ mod test {
vec!["(?i)te.*st".into()] 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 // all field search
assert_eq!( assert_eq!(
s(ctx, "*:te*st"), s(ctx, "*:te*st"),

View file

@ -9,6 +9,7 @@ use regex::Regex;
use crate::notetype::NotetypeId as NotetypeIdType; use crate::notetype::NotetypeId as NotetypeIdType;
use crate::prelude::*; use crate::prelude::*;
use crate::search::parser::parse; use crate::search::parser::parse;
use crate::search::parser::FieldSearchMode;
use crate::search::parser::Node; use crate::search::parser::Node;
use crate::search::parser::PropertyKind; use crate::search::parser::PropertyKind;
use crate::search::parser::RatingKind; use crate::search::parser::RatingKind;
@ -69,7 +70,7 @@ fn write_search_node(node: &SearchNode) -> String {
use SearchNode::*; use SearchNode::*;
match node { match node {
UnqualifiedText(s) => maybe_quote(&s.replace(':', "\\:")), 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}"), AddedInDays(u) => format!("added:{u}"),
EditedInDays(u) => format!("edited:{u}"), EditedInDays(u) => format!("edited:{u}"),
IntroducedInDays(u) => format!("introduced:{u}"), IntroducedInDays(u) => format!("introduced:{u}"),
@ -81,7 +82,7 @@ fn write_search_node(node: &SearchNode) -> String {
NotetypeId(NotetypeIdType(i)) => format!("mid:{i}"), NotetypeId(NotetypeIdType(i)) => format!("mid:{i}"),
Notetype(s) => maybe_quote(&format!("note:{s}")), Notetype(s) => maybe_quote(&format!("note:{s}")),
Rated { days, ease } => write_rated(days, ease), 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), Duplicates { notetype_id, text } => write_dupe(notetype_id, text),
State(k) => write_state(k), State(k) => write_state(k),
Flag(u) => format!("flag:{u}"), 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. /// Also used by tag search, which has the same syntax.
fn write_single_field(field: &str, text: &str, is_re: bool) -> String { fn write_single_field(field: &str, text: &str, mode: FieldSearchMode) -> String {
let re = if is_re { "re:" } else { "" }; let prefix = match mode {
let text = if !is_re && text.starts_with("re:") { 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) text.replacen(':', "\\:", 1)
} else { } else {
text.to_string() text.to_string()
}; };
maybe_quote(&format!("{}:{}{}", field.replace(':', "\\:"), re, &text)) maybe_quote(&format!(
"{}:{}{}",
field.replace(':', "\\:"),
prefix,
&text
))
} }
fn write_template(template: &TemplateKind) -> String { fn write_template(template: &TemplateKind) -> String {

View file

@ -53,6 +53,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let desiredRetentionFocused = false; let desiredRetentionFocused = false;
let desiredRetentionEverFocused = false; let desiredRetentionEverFocused = false;
let optimized = false; let optimized = false;
const initialParams = [...fsrsParams($config)];
$: if (desiredRetentionFocused) { $: if (desiredRetentionFocused) {
desiredRetentionEverFocused = true; 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); 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 simulatorModal: Modal;
let workloadModal: Modal; let workloadModal: Modal;
</script> </script>
@ -368,7 +377,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
class="btn btn-primary" class="btn btn-primary"
on:click={() => { on:click={() => {
simulateFsrsRequest.reviewLimit = 9999; simulateFsrsRequest.reviewLimit = 9999;
workloadModal?.show(); showSimulatorModal(workloadModal);
}} }}
> >
{tr.deckConfigFsrsDesiredRetentionHelpMeDecideExperimental()} {tr.deckConfigFsrsDesiredRetentionHelpMeDecideExperimental()}
@ -455,7 +464,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<hr /> <hr />
<div class="m-1"> <div class="m-1">
<button class="btn btn-primary" on:click={() => simulatorModal?.show()}> <button class="btn btn-primary" on:click={() => showSimulatorModal(simulatorModal)}>
{tr.deckConfigFsrsSimulatorExperimental()} {tr.deckConfigFsrsSimulatorExperimental()}
</button> </button>
</div> </div>