mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
Compare commits
30 commits
8421a34dcb
...
03928110ba
Author | SHA1 | Date | |
---|---|---|---|
![]() |
03928110ba | ||
![]() |
3890e12c9e | ||
![]() |
80cff16250 | ||
![]() |
75d9026be5 | ||
![]() |
6854d13b88 | ||
![]() |
29072654db | ||
![]() |
ec6f09958a | ||
![]() |
c2957746f4 | ||
![]() |
9e415869b8 | ||
![]() |
7e8a1076c1 | ||
![]() |
b97fb45e06 | ||
![]() |
61094d387a | ||
![]() |
90ed4cc115 | ||
![]() |
4506ad0c97 | ||
![]() |
539054c34d | ||
![]() |
cf12c201d8 | ||
![]() |
3b0297d14d | ||
![]() |
58deb14028 | ||
![]() |
5c4d2e87a1 | ||
![]() |
6d31776c25 | ||
![]() |
dda730dfa2 | ||
![]() |
08431106da | ||
![]() |
b4b1c2013f | ||
![]() |
5280cb2f1c | ||
![]() |
b2ab0c0830 | ||
![]() |
6a985c9fb0 | ||
![]() |
db1d04f622 | ||
![]() |
2491eb0316 | ||
![]() |
06f9d41a96 | ||
![]() |
8d5c385c76 |
36 changed files with 485 additions and 150 deletions
2
.version
2
.version
|
@ -1 +1 @@
|
||||||
25.08b5
|
25.09.2
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
nodeLinker: node-modules
|
nodeLinker: node-modules
|
||||||
|
enableScripts: false
|
||||||
|
|
|
@ -49,6 +49,7 @@ Sander Santema <github.com/sandersantema/>
|
||||||
Thomas Brownback <https://github.com/brownbat/>
|
Thomas Brownback <https://github.com/brownbat/>
|
||||||
Andrew Gaul <andrew@gaul.org>
|
Andrew Gaul <andrew@gaul.org>
|
||||||
kenden
|
kenden
|
||||||
|
Emil Hamrin <github.com/e-hamrin>
|
||||||
Nickolay Yudin <kelciour@gmail.com>
|
Nickolay Yudin <kelciour@gmail.com>
|
||||||
neitrinoweb <github.com/neitrinoweb/>
|
neitrinoweb <github.com/neitrinoweb/>
|
||||||
Andreas Reis <github.com/nwwt>
|
Andreas Reis <github.com/nwwt>
|
||||||
|
@ -240,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>
|
||||||
Elias Johansson Lara <elias.johanssonlara@gmail.com>
|
Elias Johansson Lara <elias.johanssonlara@gmail.com>
|
||||||
|
|
||||||
|
|
|
@ -28,7 +28,11 @@ pub fn setup_yarn(args: YarnArgs) {
|
||||||
.arg("--ignore-scripts"),
|
.arg("--ignore-scripts"),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
run_command(Command::new(&args.yarn_bin).arg("install"));
|
run_command(
|
||||||
|
Command::new(&args.yarn_bin)
|
||||||
|
.arg("install")
|
||||||
|
.arg("--immutable"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::fs::write(args.stamp, b"").unwrap();
|
std::fs::write(args.stamp, b"").unwrap();
|
||||||
|
|
|
@ -1,35 +1,78 @@
|
||||||
# This Dockerfile uses three stages.
|
# This is a user-contributed Dockerfile. No official support is available.
|
||||||
# 1. Compile anki (and dependencies) and build python wheels.
|
|
||||||
# 2. Create a virtual environment containing anki and its dependencies.
|
|
||||||
# 3. Create a final image that only includes anki's virtual environment and required
|
|
||||||
# system packages.
|
|
||||||
|
|
||||||
ARG PYTHON_VERSION="3.9"
|
|
||||||
ARG DEBIAN_FRONTEND="noninteractive"
|
ARG DEBIAN_FRONTEND="noninteractive"
|
||||||
|
|
||||||
# Build anki.
|
FROM ubuntu:24.04 AS build
|
||||||
FROM python:$PYTHON_VERSION AS build
|
|
||||||
RUN curl -fsSL https://github.com/bazelbuild/bazelisk/releases/download/v1.7.4/bazelisk-linux-amd64 \
|
|
||||||
> /usr/local/bin/bazel \
|
|
||||||
&& chmod +x /usr/local/bin/bazel \
|
|
||||||
# Bazel expects /usr/bin/python
|
|
||||||
&& ln -s /usr/local/bin/python /usr/bin/python
|
|
||||||
WORKDIR /opt/anki
|
WORKDIR /opt/anki
|
||||||
|
ENV PYTHON_VERSION="3.13"
|
||||||
|
|
||||||
|
|
||||||
|
# System deps
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
build-essential \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
libbz2-dev \
|
||||||
|
libreadline-dev \
|
||||||
|
libsqlite3-dev \
|
||||||
|
libffi-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
liblzma-dev \
|
||||||
|
ca-certificates \
|
||||||
|
ninja-build \
|
||||||
|
rsync \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libgl1 \
|
||||||
|
libx11-6 \
|
||||||
|
libxext6 \
|
||||||
|
libxrender1 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxkbcommon-x11-0 \
|
||||||
|
libxcb1 \
|
||||||
|
libxcb-render0 \
|
||||||
|
libxcb-shm0 \
|
||||||
|
libxcb-icccm4 \
|
||||||
|
libxcb-image0 \
|
||||||
|
libxcb-keysyms1 \
|
||||||
|
libxcb-randr0 \
|
||||||
|
libxcb-shape0 \
|
||||||
|
libxcb-xfixes0 \
|
||||||
|
libxcb-xinerama0 \
|
||||||
|
libxcb-xinput0 \
|
||||||
|
libsm6 \
|
||||||
|
libice6 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# install rust with rustup
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||||
|
|
||||||
|
# Install uv and Python 3.13 with uv
|
||||||
|
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||||
|
&& ln -s /root/.local/bin/uv /usr/local/bin/uv
|
||||||
|
ENV PATH="/root/.local/bin:${PATH}"
|
||||||
|
|
||||||
|
RUN uv python install ${PYTHON_VERSION} --default
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
# Build python wheels.
|
|
||||||
RUN ./tools/build
|
RUN ./tools/build
|
||||||
|
|
||||||
|
|
||||||
# Install pre-compiled Anki.
|
# Install pre-compiled Anki.
|
||||||
FROM python:${PYTHON_VERSION}-slim as installer
|
FROM python:3.13-slim AS installer
|
||||||
WORKDIR /opt/anki/
|
WORKDIR /opt/anki/
|
||||||
COPY --from=build /opt/anki/wheels/ wheels/
|
COPY --from=build /opt/anki/out/wheels/ wheels/
|
||||||
# Use virtual environment.
|
# Use virtual environment.
|
||||||
RUN python -m venv venv \
|
RUN python -m venv venv \
|
||||||
&& ./venv/bin/python -m pip install --no-cache-dir setuptools wheel \
|
&& ./venv/bin/python -m pip install --no-cache-dir setuptools wheel \
|
||||||
&& ./venv/bin/python -m pip install --no-cache-dir /opt/anki/wheels/*.whl
|
&& ./venv/bin/python -m pip install --no-cache-dir /opt/anki/wheels/*.whl
|
||||||
|
|
||||||
|
|
||||||
# We use another build stage here so we don't include the wheels in the final image.
|
# We use another build stage here so we don't include the wheels in the final image.
|
||||||
FROM python:${PYTHON_VERSION}-slim as final
|
FROM python:3.13-slim AS final
|
||||||
COPY --from=installer /opt/anki/venv /opt/anki/venv
|
COPY --from=installer /opt/anki/venv /opt/anki/venv
|
||||||
ENV PATH=/opt/anki/venv/bin:$PATH
|
ENV PATH=/opt/anki/venv/bin:$PATH
|
||||||
# Install run-time dependencies.
|
# Install run-time dependencies.
|
||||||
|
@ -59,9 +102,9 @@ RUN apt-get update \
|
||||||
libxrender1 \
|
libxrender1 \
|
||||||
libxtst6 \
|
libxtst6 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Add non-root user.
|
# Add non-root user.
|
||||||
RUN useradd --create-home anki
|
RUN useradd --create-home anki
|
||||||
USER anki
|
USER anki
|
||||||
WORKDIR /work
|
WORKDIR /work
|
||||||
ENTRYPOINT ["/opt/anki/venv/bin/anki"]
|
ENTRYPOINT ["/opt/anki/venv/bin/anki"]
|
||||||
LABEL maintainer="Jakub Kaczmarzyk <jakub.kaczmarzyk@gmail.com>"
|
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
Subproject commit 5897ef3a4589c123b7fa4c7fbd67f84d0b7ee13e
|
Subproject commit 480ef0da728c7ea3485c58529ae7ee02be3e5dba
|
|
@ -1,10 +1,5 @@
|
||||||
adding-add-shortcut-ctrlandenter = Add (shortcut: ctrl+enter)
|
adding-add-shortcut-ctrlandenter = Add (shortcut: ctrl+enter)
|
||||||
adding-added = Added
|
adding-added = Added
|
||||||
adding-added-cards =
|
|
||||||
Added { $count ->
|
|
||||||
[one] { $count } card
|
|
||||||
*[other] { $count } cards
|
|
||||||
}
|
|
||||||
adding-discard-current-input = Discard current input?
|
adding-discard-current-input = Discard current input?
|
||||||
adding-keep-editing = Keep Editing
|
adding-keep-editing = Keep Editing
|
||||||
adding-edit = Edit "{ $val }"
|
adding-edit = Edit "{ $val }"
|
||||||
|
|
|
@ -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 deck preset 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
|
||||||
|
|
|
@ -46,6 +46,20 @@ studying-type-answer-unknown-field = Type answer: unknown field { $val }
|
||||||
studying-unbury = Unbury
|
studying-unbury = Unbury
|
||||||
studying-what-would-you-like-to-unbury = What would you like to unbury?
|
studying-what-would-you-like-to-unbury = What would you like to unbury?
|
||||||
studying-you-havent-recorded-your-voice-yet = You haven't recorded your voice yet.
|
studying-you-havent-recorded-your-voice-yet = You haven't recorded your voice yet.
|
||||||
|
studying-card-studied-in-minute =
|
||||||
|
{ $cards ->
|
||||||
|
[one] { $cards } card
|
||||||
|
*[other] { $cards } cards
|
||||||
|
} studied in
|
||||||
|
{ $minutes ->
|
||||||
|
[one] { $minutes } minute.
|
||||||
|
*[other] { $minutes } minutes.
|
||||||
|
}
|
||||||
|
studying-question-time-elapsed = Question time elapsed
|
||||||
|
studying-answer-time-elapsed = Answer time elapsed
|
||||||
|
|
||||||
|
## OBSOLETE; you do not need to translate this
|
||||||
|
|
||||||
studying-card-studied-in =
|
studying-card-studied-in =
|
||||||
{ $count ->
|
{ $count ->
|
||||||
[one] { $count } card studied in
|
[one] { $count } card studied in
|
||||||
|
@ -56,5 +70,3 @@ studying-minute =
|
||||||
[one] { $count } minute.
|
[one] { $count } minute.
|
||||||
*[other] { $count } minutes.
|
*[other] { $count } minutes.
|
||||||
}
|
}
|
||||||
studying-question-time-elapsed = Question time elapsed
|
|
||||||
studying-answer-time-elapsed = Answer time elapsed
|
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
Subproject commit dad4e2736a2b53dcdb52d79b5703dd464c05d666
|
Subproject commit fd5f984785ad07a0d3dbd893ee3d7e3671eaebd6
|
|
@ -27,6 +27,9 @@ service FrontendService {
|
||||||
rpc deckOptionsRequireClose(generic.Empty) returns (generic.Empty);
|
rpc deckOptionsRequireClose(generic.Empty) returns (generic.Empty);
|
||||||
// Warns python that the deck option web view is ready to receive requests.
|
// Warns python that the deck option web view is ready to receive requests.
|
||||||
rpc deckOptionsReady(generic.Empty) returns (generic.Empty);
|
rpc deckOptionsReady(generic.Empty) returns (generic.Empty);
|
||||||
|
|
||||||
|
// Save colour picker's custom colour palette
|
||||||
|
rpc SaveCustomColours(generic.Empty) returns (generic.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
service BackendFrontendService {}
|
service BackendFrontendService {}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -300,7 +300,7 @@ class AddCards(QMainWindow):
|
||||||
|
|
||||||
self.addHistory(note)
|
self.addHistory(note)
|
||||||
|
|
||||||
tooltip(tr.adding_added_cards(count=changes.count), period=500)
|
tooltip(tr.importing_cards_added(count=changes.count), period=500)
|
||||||
av_player.stop_and_clear_queue()
|
av_player.stop_and_clear_queue()
|
||||||
self._load_new_note(sticky_fields_from=note)
|
self._load_new_note(sticky_fields_from=note)
|
||||||
gui_hooks.add_cards_did_add_note(note)
|
gui_hooks.add_cards_did_add_note(note)
|
||||||
|
|
|
@ -13,7 +13,7 @@ import aqt.browser
|
||||||
from anki.cards import Card
|
from anki.cards import Card
|
||||||
from anki.collection import Config
|
from anki.collection import Config
|
||||||
from anki.tags import MARKED_TAG
|
from anki.tags import MARKED_TAG
|
||||||
from aqt import AnkiQt, gui_hooks
|
from aqt import AnkiQt, gui_hooks, is_mac
|
||||||
from aqt.qt import (
|
from aqt.qt import (
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
|
@ -81,10 +81,15 @@ class Previewer(QDialog):
|
||||||
qconnect(self.finished, self._on_finished)
|
qconnect(self.finished, self._on_finished)
|
||||||
self.silentlyClose = True
|
self.silentlyClose = True
|
||||||
self.vbox = QVBoxLayout()
|
self.vbox = QVBoxLayout()
|
||||||
|
spacing = 6
|
||||||
self.vbox.setContentsMargins(0, 0, 0, 0)
|
self.vbox.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.vbox.setSpacing(spacing)
|
||||||
self._web: AnkiWebView | None = AnkiWebView(kind=AnkiWebViewKind.PREVIEWER)
|
self._web: AnkiWebView | None = AnkiWebView(kind=AnkiWebViewKind.PREVIEWER)
|
||||||
self.vbox.addWidget(self._web)
|
self.vbox.addWidget(self._web)
|
||||||
self.bbox = QDialogButtonBox()
|
self.bbox = QDialogButtonBox()
|
||||||
|
self.bbox.setContentsMargins(
|
||||||
|
spacing, spacing if is_mac else 0, spacing, spacing
|
||||||
|
)
|
||||||
self.bbox.setLayoutDirection(Qt.LayoutDirection.LeftToRight)
|
self.bbox.setLayoutDirection(Qt.LayoutDirection.LeftToRight)
|
||||||
|
|
||||||
gui_hooks.card_review_webview_did_init(self._web, AnkiWebViewKind.PREVIEWER)
|
gui_hooks.card_review_webview_did_init(self._web, AnkiWebViewKind.PREVIEWER)
|
||||||
|
|
|
@ -151,6 +151,7 @@ class Editor:
|
||||||
self.add_webview()
|
self.add_webview()
|
||||||
self.setupWeb()
|
self.setupWeb()
|
||||||
self.setupShortcuts()
|
self.setupShortcuts()
|
||||||
|
self.setupColourPalette()
|
||||||
gui_hooks.editor_did_init(self)
|
gui_hooks.editor_did_init(self)
|
||||||
|
|
||||||
# Initial setup
|
# Initial setup
|
||||||
|
@ -349,6 +350,14 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
|
||||||
keys, fn, _ = row
|
keys, fn, _ = row
|
||||||
QShortcut(QKeySequence(keys), self.widget, activated=fn) # type: ignore
|
QShortcut(QKeySequence(keys), self.widget, activated=fn) # type: ignore
|
||||||
|
|
||||||
|
def setupColourPalette(self) -> None:
|
||||||
|
if not (colors := self.mw.col.get_config("customColorPickerPalette")):
|
||||||
|
return
|
||||||
|
for i, colour in enumerate(colors[: QColorDialog.customCount()]):
|
||||||
|
if not QColor.isValidColorName(colour):
|
||||||
|
continue
|
||||||
|
QColorDialog.setCustomColor(i, QColor.fromString(colour))
|
||||||
|
|
||||||
def _addFocusCheck(self, fn: Callable) -> Callable:
|
def _addFocusCheck(self, fn: Callable) -> Callable:
|
||||||
def checkFocus() -> None:
|
def checkFocus() -> None:
|
||||||
if self.currentField is None:
|
if self.currentField is None:
|
||||||
|
|
|
@ -599,6 +599,15 @@ def deck_options_ready() -> bytes:
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
def save_custom_colours() -> bytes:
|
||||||
|
colors = [
|
||||||
|
QColorDialog.customColor(i).name(QColor.NameFormat.HexArgb)
|
||||||
|
for i in range(QColorDialog.customCount())
|
||||||
|
]
|
||||||
|
aqt.mw.col.set_config("customColorPickerPalette", colors)
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
post_handler_list = [
|
post_handler_list = [
|
||||||
congrats_info,
|
congrats_info,
|
||||||
get_deck_configs_for_update,
|
get_deck_configs_for_update,
|
||||||
|
@ -614,6 +623,7 @@ post_handler_list = [
|
||||||
search_in_browser,
|
search_in_browser,
|
||||||
deck_options_require_close,
|
deck_options_require_close,
|
||||||
deck_options_ready,
|
deck_options_ready,
|
||||||
|
save_custom_colours,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@ import aqt.browser
|
||||||
import aqt.operations
|
import aqt.operations
|
||||||
from anki.cards import Card, CardId
|
from anki.cards import Card, CardId
|
||||||
from anki.collection import Config, OpChanges, OpChangesWithCount
|
from anki.collection import Config, OpChanges, OpChangesWithCount
|
||||||
|
from anki.lang import with_collapsed_whitespace
|
||||||
from anki.scheduler.base import ScheduleCardsAsNew
|
from anki.scheduler.base import ScheduleCardsAsNew
|
||||||
from anki.scheduler.v3 import (
|
from anki.scheduler.v3 import (
|
||||||
CardAnswer,
|
CardAnswer,
|
||||||
|
@ -966,11 +967,15 @@ timerStopped = false;
|
||||||
elapsed = self.mw.col.timeboxReached()
|
elapsed = self.mw.col.timeboxReached()
|
||||||
if elapsed:
|
if elapsed:
|
||||||
assert not isinstance(elapsed, bool)
|
assert not isinstance(elapsed, bool)
|
||||||
part1 = tr.studying_card_studied_in(count=elapsed[1])
|
cards_val = elapsed[1]
|
||||||
mins = int(round(elapsed[0] / 60))
|
minutes_val = int(round(elapsed[0] / 60))
|
||||||
part2 = tr.studying_minute(count=mins)
|
message = with_collapsed_whitespace(
|
||||||
|
tr.studying_card_studied_in_minute(
|
||||||
|
cards=cards_val, minutes=str(minutes_val)
|
||||||
|
)
|
||||||
|
)
|
||||||
fin = tr.studying_finish()
|
fin = tr.studying_finish()
|
||||||
diag = askUserDialog(f"{part1} {part2}", [tr.studying_continue(), fin])
|
diag = askUserDialog(message, [tr.studying_continue(), fin])
|
||||||
diag.setIcon(QMessageBox.Icon.Information)
|
diag.setIcon(QMessageBox.Icon.Information)
|
||||||
if diag.run() == fin:
|
if diag.run() == fin:
|
||||||
self.mw.moveToState("deckBrowser")
|
self.mw.moveToState("deckBrowser")
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -180,7 +180,7 @@ class CustomStyles:
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
margin: 1px;
|
margin: 1px;
|
||||||
}}
|
}}
|
||||||
QPushButton:focus {{
|
QPushButton:focus, QPushButton:default:hover {{
|
||||||
border: 2px solid {tm.var(colors.BORDER_FOCUS)};
|
border: 2px solid {tm.var(colors.BORDER_FOCUS)};
|
||||||
outline: none;
|
outline: none;
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
|
@ -199,9 +199,6 @@ class CustomStyles:
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
QPushButton:default:hover {{
|
|
||||||
border-width: 2px;
|
|
||||||
}}
|
|
||||||
QPushButton:pressed,
|
QPushButton:pressed,
|
||||||
QPushButton:checked,
|
QPushButton:checked,
|
||||||
QSpinBox::up-button:pressed,
|
QSpinBox::up-button:pressed,
|
||||||
|
|
|
@ -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
6
qt/launcher/mac/stub.c
Normal 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;
|
||||||
|
}
|
|
@ -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") {
|
||||||
|
std::path::PathBuf::from(custom_root)
|
||||||
|
} else {
|
||||||
|
dirs::data_local_dir()
|
||||||
.context("Unable to determine data_dir")?
|
.context("Unable to determine data_dir")?
|
||||||
.join("AnkiProgramFiles");
|
.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,24 +269,11 @@ 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
|
|
||||||
for (key, _) in std::env::vars() {
|
|
||||||
if key.starts_with("UV_") || key == "VIRTUAL_ENV" {
|
|
||||||
command.env_remove(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
// remove CONDA_PREFIX/bin from PATH to avoid conda interference
|
// 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 let Ok(conda_prefix) = std::env::var("CONDA_PREFIX") {
|
||||||
if let Ok(current_path) = std::env::var("PATH") {
|
if let Ok(current_path) = std::env::var("PATH") {
|
||||||
let conda_bin = format!("{conda_prefix}/bin");
|
let conda_bin = format!("{conda_prefix}/bin");
|
||||||
|
@ -290,6 +285,30 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re
|
||||||
command.env("PATH", new_path);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
command
|
||||||
.env("UV_CACHE_DIR", &state.uv_cache_dir)
|
.env("UV_CACHE_DIR", &state.uv_cache_dir)
|
||||||
|
@ -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 {
|
||||||
|
if !state.system_qt {
|
||||||
command.args(["--python", version]);
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,8 +62,9 @@ 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)
|
||||||
|
.env_remove("ANKI_LAUNCHER_WANT_TERMINAL")
|
||||||
.ensure_spawn()?;
|
.ensure_spawn()?;
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
|
|
|
@ -174,7 +174,7 @@ impl Collection {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let health_check_passed = if health_check {
|
let health_check_passed = if health_check && input.train_set.len() > 300 {
|
||||||
let fsrs = FSRS::new(None)?;
|
let fsrs = FSRS::new(None)?;
|
||||||
fsrs.evaluate_with_time_series_splits(input, |_| true)
|
fsrs.evaluate_with_time_series_splits(input, |_| true)
|
||||||
.ok()
|
.ok()
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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!(
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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"),
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -4,6 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Shortcut from "$lib/components/Shortcut.svelte";
|
import Shortcut from "$lib/components/Shortcut.svelte";
|
||||||
|
import { saveCustomColours } from "@generated/backend";
|
||||||
|
|
||||||
export let keyCombination: string | null = null;
|
export let keyCombination: string | null = null;
|
||||||
export let value: string;
|
export let value: string;
|
||||||
|
@ -11,7 +12,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
let inputRef: HTMLInputElement;
|
let inputRef: HTMLInputElement;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<input bind:this={inputRef} tabindex="-1" type="color" bind:value on:input on:change />
|
<input
|
||||||
|
bind:this={inputRef}
|
||||||
|
tabindex="-1"
|
||||||
|
type="color"
|
||||||
|
bind:value
|
||||||
|
on:input
|
||||||
|
on:change
|
||||||
|
on:click={() => saveCustomColours({})}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if keyCombination}
|
{#if keyCombination}
|
||||||
<Shortcut {keyCombination} on:action={() => inputRef.click()} />
|
<Shortcut {keyCombination} on:action={() => inputRef.click()} />
|
||||||
|
|
|
@ -19,6 +19,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import ColorPicker from "./ColorPicker.svelte";
|
import ColorPicker from "./ColorPicker.svelte";
|
||||||
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
||||||
import WithColorHelper from "./WithColorHelper.svelte";
|
import WithColorHelper from "./WithColorHelper.svelte";
|
||||||
|
import { saveCustomColours } from "@generated/backend";
|
||||||
|
|
||||||
export let color: string;
|
export let color: string;
|
||||||
|
|
||||||
|
@ -134,7 +135,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
color = setColor(event);
|
color = setColor(event);
|
||||||
bridgeCommand(`lastHighlightColor:${color}`);
|
bridgeCommand(`lastHighlightColor:${color}`);
|
||||||
}}
|
}}
|
||||||
on:change={() => setTextColor()}
|
on:change={() => {
|
||||||
|
setTextColor();
|
||||||
|
saveCustomColours({});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</WithColorHelper>
|
</WithColorHelper>
|
||||||
|
|
|
@ -22,6 +22,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import ColorPicker from "./ColorPicker.svelte";
|
import ColorPicker from "./ColorPicker.svelte";
|
||||||
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
||||||
import WithColorHelper from "./WithColorHelper.svelte";
|
import WithColorHelper from "./WithColorHelper.svelte";
|
||||||
|
import { saveCustomColours } from "@generated/backend";
|
||||||
|
|
||||||
export let color: string;
|
export let color: string;
|
||||||
|
|
||||||
|
@ -158,6 +159,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setTextColor();
|
setTextColor();
|
||||||
}, 200);
|
}, 200);
|
||||||
|
saveCustomColours({});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
|
@ -10,6 +10,9 @@ export function allImagesLoaded(): Promise<void[]> {
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageLoaded(img: HTMLImageElement): Promise<void> {
|
function imageLoaded(img: HTMLImageElement): Promise<void> {
|
||||||
|
if (!img.getAttribute("decoding")) {
|
||||||
|
img.decoding = "async";
|
||||||
|
}
|
||||||
return img.complete
|
return img.complete
|
||||||
? Promise.resolve()
|
? Promise.resolve()
|
||||||
: new Promise((resolve) => {
|
: new Promise((resolve) => {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -6939,8 +6939,8 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"vite@npm:6":
|
"vite@npm:6":
|
||||||
version: 6.3.5
|
version: 6.3.6
|
||||||
resolution: "vite@npm:6.3.5"
|
resolution: "vite@npm:6.3.6"
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: "npm:^0.25.0"
|
esbuild: "npm:^0.25.0"
|
||||||
fdir: "npm:^6.4.4"
|
fdir: "npm:^6.4.4"
|
||||||
|
@ -6989,7 +6989,7 @@ __metadata:
|
||||||
optional: true
|
optional: true
|
||||||
bin:
|
bin:
|
||||||
vite: bin/vite.js
|
vite: bin/vite.js
|
||||||
checksum: 10c0/df70201659085133abffc6b88dcdb8a57ef35f742a01311fc56a4cfcda6a404202860729cc65a2c401a724f6e25f9ab40ce4339ed4946f550541531ced6fe41c
|
checksum: 10c0/add701f1e72596c002275782e38d0389ab400c1be330c93a3009804d62db68097a936ca1c53c3301df3aaacfe5e328eab547060f31ef9c49a277ae50df6ad4fb
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue