mirror of
https://github.com/ankitects/anki.git
synced 2025-09-24 08:46:37 -04:00
Merge branch 'main' into editor-3830
This commit is contained in:
commit
5bfae11aa9
71 changed files with 805 additions and 1407 deletions
2
.version
2
.version
|
@ -1 +1 @@
|
|||
25.06b5
|
||||
25.06b6
|
||||
|
|
|
@ -21,7 +21,7 @@ Please do this as a final step before marking a task as completed.
|
|||
During development, you can build/check subsections of our code:
|
||||
|
||||
- Rust: 'cargo check'
|
||||
- Python: './tools/dmypy'
|
||||
- Python: './tools/dmypy', and if wheel-related, './ninja wheels'
|
||||
- TypeScript/Svelte: './ninja check:svelte'
|
||||
|
||||
Be mindful that some changes (such as modifications to .proto files) may
|
||||
|
|
|
@ -63,6 +63,7 @@ Jakub Kaczmarzyk <jakub.kaczmarzyk@gmail.com>
|
|||
Akshara Balachandra <akshara.bala.18@gmail.com>
|
||||
lukkea <github.com/lukkea/>
|
||||
David Allison <davidallisongithub@gmail.com>
|
||||
David Allison <62114487+david-allison@users.noreply.github.com>
|
||||
Tsung-Han Yu <johan456789@gmail.com>
|
||||
Piotr Kubowicz <piotr.kubowicz@gmail.com>
|
||||
RumovZ <gp5glkw78@relay.firefox.com>
|
||||
|
|
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -3543,6 +3543,7 @@ dependencies = [
|
|||
"anki_io",
|
||||
"anki_process",
|
||||
"anyhow",
|
||||
"camino",
|
||||
"dirs 6.0.0",
|
||||
"embed-resource",
|
||||
"libc",
|
||||
|
|
|
@ -138,7 +138,7 @@ unic-ucd-category = "0.9.0"
|
|||
unicode-normalization = "0.1.24"
|
||||
walkdir = "2.5.0"
|
||||
which = "8.0.0"
|
||||
winapi = { version = "0.3", features = ["wincon", "errhandlingapi", "consoleapi"] }
|
||||
winapi = { version = "0.3", features = ["wincon"] }
|
||||
windows = { version = "0.61.3", features = ["Media_SpeechSynthesis", "Media_Core", "Foundation_Collections", "Storage_Streams"] }
|
||||
wiremock = "0.6.3"
|
||||
xz2 = "0.1.7"
|
||||
|
|
2
LICENSE
2
LICENSE
|
@ -6,8 +6,6 @@ The following included source code items use a license other than AGPL3:
|
|||
|
||||
In the pylib folder:
|
||||
|
||||
* The SuperMemo importer: GPL3 and 0BSD.
|
||||
* The Pauker importer: BSD-3.
|
||||
* statsbg.py: CC BY 4.0.
|
||||
|
||||
In the qt folder:
|
||||
|
|
|
@ -337,7 +337,12 @@ fn build_wheel(build: &mut Build) -> Result<()> {
|
|||
name: "aqt",
|
||||
version: anki_version(),
|
||||
platform: None,
|
||||
deps: inputs![":qt:aqt", glob!("qt/aqt/**"), "qt/pyproject.toml"],
|
||||
deps: inputs![
|
||||
":qt:aqt",
|
||||
glob!("qt/aqt/**"),
|
||||
"qt/pyproject.toml",
|
||||
"qt/hatch_build.py"
|
||||
],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
@ -68,7 +68,8 @@ pub fn build_pylib(build: &mut Build) -> Result<()> {
|
|||
deps: inputs![
|
||||
":pylib:anki",
|
||||
glob!("pylib/anki/**"),
|
||||
"pylib/pyproject.toml"
|
||||
"pylib/pyproject.toml",
|
||||
"pylib/hatch_build.py"
|
||||
],
|
||||
},
|
||||
)?;
|
||||
|
|
|
@ -159,6 +159,10 @@ impl BuildAction for PythonEnvironment {
|
|||
}
|
||||
build.add_output_stamp(format!("{}/.stamp", self.venv_folder));
|
||||
}
|
||||
|
||||
fn check_output_timestamps(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PythonTypecheck {
|
||||
|
|
|
@ -67,7 +67,10 @@ pub fn run_build(args: BuildArgs) {
|
|||
"MYPY_CACHE_DIR",
|
||||
build_root.join("tests").join("mypy").into_string(),
|
||||
)
|
||||
.env("PYTHONPYCACHEPREFIX", build_root.join("pycache"))
|
||||
.env(
|
||||
"PYTHONPYCACHEPREFIX",
|
||||
std::path::absolute(build_root.join("pycache")).unwrap(),
|
||||
)
|
||||
// commands will not show colors by default, as we do not provide a tty
|
||||
.env("FORCE_COLOR", "1")
|
||||
.env("MYPY_FORCE_COLOR", "1")
|
||||
|
|
|
@ -35,7 +35,7 @@ pub fn setup_pyenv(args: PyenvArgs) {
|
|||
run_command(
|
||||
Command::new(args.uv_bin)
|
||||
.env("UV_PROJECT_ENVIRONMENT", args.pyenv_folder.clone())
|
||||
.args(["sync", "--frozen"])
|
||||
.args(["sync", "--locked"])
|
||||
.args(args.extra_args),
|
||||
);
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 2f8c9d9566aef8b86e3326fe9ff007d594b7ec83
|
||||
Subproject commit cc56464ab6354d4f1ad87ab3cc5c071c076b662d
|
|
@ -60,7 +60,6 @@ card-templates-this-will-create-card-proceed =
|
|||
}
|
||||
card-templates-type-boxes-warning = Only one typing box per card template is supported.
|
||||
card-templates-restore-to-default = Restore to Default
|
||||
card-templates-restore-to-default-confirmation = This will reset all fields and templates in this note type to their default
|
||||
values, removing any extra fields/templates and their content, and any custom styling. Do you wish to proceed?
|
||||
card-templates-restore-to-default-confirmation = This will reset all fields and templates in this note type to their default values, removing any extra fields/templates and their content, and any custom styling. Do you wish to proceed?
|
||||
card-templates-restored-to-default = Note type has been restored to its original state.
|
||||
|
||||
|
|
|
@ -65,7 +65,6 @@ importing-with-deck-configs-help =
|
|||
If enabled, any deck options that the deck sharer included will also be imported.
|
||||
Otherwise, all decks will be assigned the default preset.
|
||||
importing-packaged-anki-deckcollection-apkg-colpkg-zip = Packaged Anki Deck/Collection (*.apkg *.colpkg *.zip)
|
||||
importing-pauker-18-lesson-paugz = Pauker 1.8 Lesson (*.pau.gz)
|
||||
# the '|' character
|
||||
importing-pipe = Pipe
|
||||
# Warning displayed when the csv import preview table is clipped (some columns were hidden)
|
||||
|
@ -78,7 +77,6 @@ importing-rows-had-num1d-fields-expected-num2d = '{ $row }' had { $found } field
|
|||
importing-selected-file-was-not-in-utf8 = Selected file was not in UTF-8 format. Please see the importing section of the manual.
|
||||
importing-semicolon = Semicolon
|
||||
importing-skipped = Skipped
|
||||
importing-supermemo-xml-export-xml = Supermemo XML export (*.xml)
|
||||
importing-tab = Tab
|
||||
importing-tag-modified-notes = Tag modified notes:
|
||||
importing-text-separated-by-tabs-or-semicolons = Text separated by tabs or semicolons (*)
|
||||
|
@ -252,3 +250,5 @@ importing-importing-collection = Importing collection...
|
|||
importing-unable-to-import-filename = Unable to import { $filename }: file type not supported
|
||||
importing-notes-that-could-not-be-imported = Notes that could not be imported as note type has changed: { $val }
|
||||
importing-added = Added
|
||||
importing-pauker-18-lesson-paugz = Pauker 1.8 Lesson (*.pau.gz)
|
||||
importing-supermemo-xml-export-xml = Supermemo XML export (*.xml)
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 69f2dbaeba6f72ac62da0b35881f320603da5124
|
||||
Subproject commit 5f9a9ceb6e8a9aade26c1ad9f1c936f5cc4d9e2a
|
|
@ -1,4 +1,5 @@
|
|||
qt-accel-about = &About
|
||||
qt-accel-about-mac = About Anki...
|
||||
qt-accel-cards = &Cards
|
||||
qt-accel-check-database = &Check Database
|
||||
qt-accel-check-media = Check &Media
|
||||
|
@ -45,3 +46,4 @@ qt-accel-zoom-editor-in = Zoom Editor &In
|
|||
qt-accel-zoom-editor-out = Zoom Editor &Out
|
||||
qt-accel-create-backup = Create &Backup
|
||||
qt-accel-load-backup = &Revert to Backup
|
||||
qt-accel-upgrade-downgrade = Upgrade/Downgrade
|
||||
|
|
|
@ -73,7 +73,7 @@ qt-misc-second =
|
|||
qt-misc-layout-auto-enabled = Responsive layout enabled
|
||||
qt-misc-layout-vertical-enabled = Vertical layout enabled
|
||||
qt-misc-layout-horizontal-enabled = Horizontal layout enabled
|
||||
qt-misc-please-restart-to-update-anki = Please restart Anki to update to the latest version.
|
||||
qt-misc-open-anki-launcher = Change to a different Anki version?
|
||||
|
||||
## deprecated- these strings will be removed in the future, and do not need
|
||||
## to be translated
|
||||
|
|
|
@ -402,6 +402,31 @@ message SimulateFsrsReviewRequest {
|
|||
repeated float easy_days_percentages = 10;
|
||||
deck_config.DeckConfig.Config.ReviewCardOrder review_order = 11;
|
||||
optional uint32 suspend_after_lapse_count = 12;
|
||||
// For CMRR
|
||||
message CMRRTarget {
|
||||
message Memorized {
|
||||
float loss_aversion = 1;
|
||||
};
|
||||
|
||||
message Stability {};
|
||||
|
||||
message FutureMemorized {
|
||||
int32 days = 1;
|
||||
};
|
||||
|
||||
message AverageFutureMemorized {
|
||||
int32 days = 1;
|
||||
};
|
||||
|
||||
oneof kind {
|
||||
Memorized memorized = 1;
|
||||
Stability stability = 2;
|
||||
FutureMemorized future_memorized = 3;
|
||||
AverageFutureMemorized average_future_memorized = 4;
|
||||
};
|
||||
};
|
||||
|
||||
optional CMRRTarget target = 13;
|
||||
}
|
||||
|
||||
message SimulateFsrsReviewResponse {
|
||||
|
|
|
@ -11,8 +11,6 @@ from anki.importing.apkg import AnkiPackageImporter
|
|||
from anki.importing.base import Importer
|
||||
from anki.importing.csvfile import TextImporter
|
||||
from anki.importing.mnemo import MnemosyneImporter
|
||||
from anki.importing.pauker import PaukerImporter
|
||||
from anki.importing.supermemo_xml import SupermemoXmlImporter # type: ignore
|
||||
from anki.lang import TR
|
||||
|
||||
|
||||
|
@ -24,8 +22,6 @@ def importers(col: Collection) -> Sequence[tuple[str, type[Importer]]]:
|
|||
AnkiPackageImporter,
|
||||
),
|
||||
(col.tr.importing_mnemosyne_20_deck_db(), MnemosyneImporter),
|
||||
(col.tr.importing_supermemo_xml_export_xml(), SupermemoXmlImporter),
|
||||
(col.tr.importing_pauker_18_lesson_paugz(), PaukerImporter),
|
||||
]
|
||||
anki.hooks.importing_importers(importers)
|
||||
return importers
|
||||
|
|
|
@ -1,94 +0,0 @@
|
|||
# Copyright: Andreas Klauer <Andreas.Klauer@metamorpher.de>
|
||||
# License: BSD-3
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
|
||||
import gzip
|
||||
import html
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from anki.importing.noteimp import ForeignCard, ForeignNote, NoteImporter
|
||||
from anki.stdmodels import _legacy_add_forward_reverse
|
||||
|
||||
ONE_DAY = 60 * 60 * 24
|
||||
|
||||
|
||||
class PaukerImporter(NoteImporter):
|
||||
"""Import Pauker 1.8 Lesson (*.pau.gz)"""
|
||||
|
||||
needMapper = False
|
||||
allowHTML = True
|
||||
|
||||
def run(self):
|
||||
model = _legacy_add_forward_reverse(self.col)
|
||||
model["name"] = "Pauker"
|
||||
self.col.models.save(model, updateReqs=False)
|
||||
self.col.models.set_current(model)
|
||||
self.model = model
|
||||
self.initMapping()
|
||||
NoteImporter.run(self)
|
||||
|
||||
def fields(self):
|
||||
"""Pauker is Front/Back"""
|
||||
return 2
|
||||
|
||||
def foreignNotes(self):
|
||||
"""Build and return a list of notes."""
|
||||
notes = []
|
||||
|
||||
try:
|
||||
f = gzip.open(self.file)
|
||||
tree = ET.parse(f) # type: ignore
|
||||
lesson = tree.getroot()
|
||||
assert lesson.tag == "Lesson"
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
index = -4
|
||||
|
||||
for batch in lesson.findall("./Batch"):
|
||||
index += 1
|
||||
|
||||
for card in batch.findall("./Card"):
|
||||
# Create a note for this card.
|
||||
front = card.findtext("./FrontSide/Text")
|
||||
back = card.findtext("./ReverseSide/Text")
|
||||
note = ForeignNote()
|
||||
assert front and back
|
||||
note.fields = [
|
||||
html.escape(x.strip())
|
||||
.replace("\n", "<br>")
|
||||
.replace(" ", " ")
|
||||
for x in [front, back]
|
||||
]
|
||||
notes.append(note)
|
||||
|
||||
# Determine due date for cards.
|
||||
frontdue = card.find("./FrontSide[@LearnedTimestamp]")
|
||||
backdue = card.find("./ReverseSide[@Batch][@LearnedTimestamp]")
|
||||
|
||||
if frontdue is not None:
|
||||
note.cards[0] = self._learnedCard(
|
||||
index, int(frontdue.attrib["LearnedTimestamp"])
|
||||
)
|
||||
|
||||
if backdue is not None:
|
||||
note.cards[1] = self._learnedCard(
|
||||
int(backdue.attrib["Batch"]),
|
||||
int(backdue.attrib["LearnedTimestamp"]),
|
||||
)
|
||||
|
||||
return notes
|
||||
|
||||
def _learnedCard(self, batch, timestamp):
|
||||
ivl = math.exp(batch)
|
||||
now = time.time()
|
||||
due = ivl - (now - timestamp / 1000.0) / ONE_DAY
|
||||
fc = ForeignCard()
|
||||
fc.due = self.col.sched.today + int(due + 0.5)
|
||||
fc.ivl = random.randint(int(ivl * 0.90), int(ivl + 0.5))
|
||||
fc.factor = random.randint(1500, 2500)
|
||||
return fc
|
|
@ -1,484 +0,0 @@
|
|||
# Copyright: petr.michalec@gmail.com
|
||||
# License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
# pytype: disable=attribute-error
|
||||
# type: ignore
|
||||
# pylint: disable=C
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import unicodedata
|
||||
from string import capwords
|
||||
from xml.dom import minidom
|
||||
from xml.dom.minidom import Element, Text
|
||||
|
||||
from anki.collection import Collection
|
||||
from anki.importing.noteimp import ForeignCard, ForeignNote, NoteImporter
|
||||
from anki.stdmodels import _legacy_add_basic_model
|
||||
|
||||
|
||||
class SmartDict(dict):
|
||||
"""
|
||||
See http://www.peterbe.com/plog/SmartDict
|
||||
Copyright 2005, Peter Bengtsson, peter@fry-it.com
|
||||
0BSD
|
||||
|
||||
A smart dict can be instantiated either from a pythonic dict
|
||||
or an instance object (eg. SQL recordsets) but it ensures that you can
|
||||
do all the convenient lookups such as x.first_name, x['first_name'] or
|
||||
x.get('first_name').
|
||||
"""
|
||||
|
||||
def __init__(self, *a, **kw) -> None:
|
||||
if a:
|
||||
if isinstance(type(a[0]), dict):
|
||||
kw.update(a[0])
|
||||
elif isinstance(type(a[0]), object):
|
||||
kw.update(a[0].__dict__)
|
||||
elif hasattr(a[0], "__class__") and a[0].__class__.__name__ == "SmartDict":
|
||||
kw.update(a[0].__dict__)
|
||||
|
||||
dict.__init__(self, **kw)
|
||||
self.__dict__ = self
|
||||
|
||||
|
||||
class SuperMemoElement(SmartDict):
|
||||
"SmartDict wrapper to store SM Element data"
|
||||
|
||||
def __init__(self, *a, **kw) -> None:
|
||||
SmartDict.__init__(self, *a, **kw)
|
||||
# default content
|
||||
self.__dict__["lTitle"] = None
|
||||
self.__dict__["Title"] = None
|
||||
self.__dict__["Question"] = None
|
||||
self.__dict__["Answer"] = None
|
||||
self.__dict__["Count"] = None
|
||||
self.__dict__["Type"] = None
|
||||
self.__dict__["ID"] = None
|
||||
self.__dict__["Interval"] = None
|
||||
self.__dict__["Lapses"] = None
|
||||
self.__dict__["Repetitions"] = None
|
||||
self.__dict__["LastRepetiton"] = None
|
||||
self.__dict__["AFactor"] = None
|
||||
self.__dict__["UFactor"] = None
|
||||
|
||||
|
||||
# This is an AnkiImporter
|
||||
class SupermemoXmlImporter(NoteImporter):
|
||||
needMapper = False
|
||||
allowHTML = True
|
||||
|
||||
"""
|
||||
Supermemo XML export's to Anki parser.
|
||||
Goes through a SM collection and fetch all elements.
|
||||
|
||||
My SM collection was a big mess where topics and items were mixed.
|
||||
I was unable to parse my content in a regular way like for loop on
|
||||
minidom.getElementsByTagName() etc. My collection had also an
|
||||
limitation, topics were splited into branches with max 100 items
|
||||
on each. Learning themes were in deep structure. I wanted to have
|
||||
full title on each element to be stored in tags.
|
||||
|
||||
Code should be upgrade to support importing of SM2006 exports.
|
||||
"""
|
||||
|
||||
def __init__(self, col: Collection, file: str) -> None:
|
||||
"""Initialize internal variables.
|
||||
Pameters to be exposed to GUI are stored in self.META"""
|
||||
NoteImporter.__init__(self, col, file)
|
||||
m = _legacy_add_basic_model(self.col)
|
||||
m["name"] = "Supermemo"
|
||||
self.col.models.save(m)
|
||||
self.initMapping()
|
||||
|
||||
self.lines = None
|
||||
self.numFields = int(2)
|
||||
|
||||
# SmXmlParse VARIABLES
|
||||
self.xmldoc = None
|
||||
self.pieces = []
|
||||
self.cntBuf = [] # to store last parsed data
|
||||
self.cntElm = [] # to store SM Elements data
|
||||
self.cntCol = [] # to store SM Colections data
|
||||
|
||||
# store some meta info related to parse algorithm
|
||||
# SmartDict works like dict / class wrapper
|
||||
self.cntMeta = SmartDict()
|
||||
self.cntMeta.popTitles = False
|
||||
self.cntMeta.title = []
|
||||
|
||||
# META stores controls of import script, should be
|
||||
# exposed to import dialog. These are default values.
|
||||
self.META = SmartDict()
|
||||
self.META.resetLearningData = False # implemented
|
||||
self.META.onlyMemorizedItems = False # implemented
|
||||
self.META.loggerLevel = 2 # implemented 0no,1info,2error,3debug
|
||||
self.META.tagAllTopics = True
|
||||
self.META.pathsToBeTagged = [
|
||||
"English for beginners",
|
||||
"Advanced English 97",
|
||||
"Phrasal Verbs",
|
||||
] # path patterns to be tagged - in gui entered like 'Advanced English 97|My Vocablary'
|
||||
self.META.tagMemorizedItems = True # implemented
|
||||
self.META.logToStdOutput = False # implemented
|
||||
|
||||
self.notes = []
|
||||
|
||||
## TOOLS
|
||||
|
||||
def _fudgeText(self, text: str) -> str:
|
||||
"Replace sm syntax to Anki syntax"
|
||||
text = text.replace("\n\r", "<br>")
|
||||
text = text.replace("\n", "<br>")
|
||||
return text
|
||||
|
||||
def _unicode2ascii(self, str: str) -> str:
|
||||
"Remove diacritic punctuation from strings (titles)"
|
||||
return "".join(
|
||||
[
|
||||
c
|
||||
for c in unicodedata.normalize("NFKD", str)
|
||||
if not unicodedata.combining(c)
|
||||
]
|
||||
)
|
||||
|
||||
def _decode_htmlescapes(self, html: str) -> str:
|
||||
"""Unescape HTML code."""
|
||||
# In case of bad formatted html you can import MinimalSoup etc.. see BeautifulSoup source code
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# my sm2004 also ecaped & char in escaped sequences.
|
||||
html = re.sub("&", "&", html)
|
||||
|
||||
# https://anki.tenderapp.com/discussions/ankidesktop/39543-anki-is-replacing-the-character-by-when-i-exit-the-html-edit-mode-ctrlshiftx
|
||||
if html.find(">") < 0:
|
||||
return html
|
||||
|
||||
# unescaped solitary chars < or > that were ok for minidom confuse btfl soup
|
||||
# html = re.sub(u'>',u'>',html)
|
||||
# html = re.sub(u'<',u'<',html)
|
||||
|
||||
return str(BeautifulSoup(html, "html.parser"))
|
||||
|
||||
def _afactor2efactor(self, af: float) -> float:
|
||||
# Adapted from <http://www.supermemo.com/beta/xml/xml-core.htm>
|
||||
|
||||
# Ranges for A-factors and E-factors
|
||||
af_min = 1.2
|
||||
af_max = 6.9
|
||||
ef_min = 1.3
|
||||
ef_max = 3.3
|
||||
|
||||
# Sanity checks for the A-factor
|
||||
if af < af_min:
|
||||
af = af_min
|
||||
elif af > af_max:
|
||||
af = af_max
|
||||
|
||||
# Scale af to the range 0..1
|
||||
af_scaled = (af - af_min) / (af_max - af_min)
|
||||
# Rescale to the interval ef_min..ef_max
|
||||
ef = ef_min + af_scaled * (ef_max - ef_min)
|
||||
|
||||
return ef
|
||||
|
||||
## DEFAULT IMPORTER METHODS
|
||||
|
||||
def foreignNotes(self) -> list[ForeignNote]:
|
||||
# Load file and parse it by minidom
|
||||
self.loadSource(self.file)
|
||||
|
||||
# Migrating content / time consuming part
|
||||
# addItemToCards is called for each sm element
|
||||
self.logger("Parsing started.")
|
||||
self.parse()
|
||||
self.logger("Parsing done.")
|
||||
|
||||
# Return imported cards
|
||||
self.total = len(self.notes)
|
||||
self.log.append("%d cards imported." % self.total)
|
||||
return self.notes
|
||||
|
||||
def fields(self) -> int:
|
||||
return 2
|
||||
|
||||
## PARSER METHODS
|
||||
|
||||
def addItemToCards(self, item: SuperMemoElement) -> None:
|
||||
"This method actually do conversion"
|
||||
|
||||
# new anki card
|
||||
note = ForeignNote()
|
||||
|
||||
# clean Q and A
|
||||
note.fields.append(self._fudgeText(self._decode_htmlescapes(item.Question)))
|
||||
note.fields.append(self._fudgeText(self._decode_htmlescapes(item.Answer)))
|
||||
note.tags = []
|
||||
|
||||
# pre-process scheduling data
|
||||
# convert learning data
|
||||
if (
|
||||
not self.META.resetLearningData
|
||||
and int(item.Interval) >= 1
|
||||
and getattr(item, "LastRepetition", None)
|
||||
):
|
||||
# migration of LearningData algorithm
|
||||
tLastrep = time.mktime(time.strptime(item.LastRepetition, "%d.%m.%Y"))
|
||||
tToday = time.time()
|
||||
card = ForeignCard()
|
||||
card.ivl = int(item.Interval)
|
||||
card.lapses = int(item.Lapses)
|
||||
card.reps = int(item.Repetitions) + int(item.Lapses)
|
||||
nextDue = tLastrep + (float(item.Interval) * 86400.0)
|
||||
remDays = int((nextDue - time.time()) / 86400)
|
||||
card.due = self.col.sched.today + remDays
|
||||
card.factor = int(
|
||||
self._afactor2efactor(float(item.AFactor.replace(",", "."))) * 1000
|
||||
)
|
||||
note.cards[0] = card
|
||||
|
||||
# categories & tags
|
||||
# it's worth to have every theme (tree structure of sm collection) stored in tags, but sometimes not
|
||||
# you can deceide if you are going to tag all toppics or just that containing some pattern
|
||||
tTaggTitle = False
|
||||
for pattern in self.META.pathsToBeTagged:
|
||||
if (
|
||||
item.lTitle is not None
|
||||
and pattern.lower() in " ".join(item.lTitle).lower()
|
||||
):
|
||||
tTaggTitle = True
|
||||
break
|
||||
if tTaggTitle or self.META.tagAllTopics:
|
||||
# normalize - remove diacritic punctuation from unicode chars to ascii
|
||||
item.lTitle = [self._unicode2ascii(topic) for topic in item.lTitle]
|
||||
|
||||
# Transform xyz / aaa / bbb / ccc on Title path to Tag xyzAaaBbbCcc
|
||||
# clean things like [999] or [111-2222] from title path, example: xyz / [1000-1200] zyx / xyz
|
||||
# clean whitespaces
|
||||
# set Capital letters for first char of the word
|
||||
tmp = list(
|
||||
{re.sub(r"(\[[0-9]+\])", " ", i).replace("_", " ") for i in item.lTitle}
|
||||
)
|
||||
tmp = list({re.sub(r"(\W)", " ", i) for i in tmp})
|
||||
tmp = list({re.sub("^[0-9 ]+$", "", i) for i in tmp})
|
||||
tmp = list({capwords(i).replace(" ", "") for i in tmp})
|
||||
tags = [j[0].lower() + j[1:] for j in tmp if j.strip() != ""]
|
||||
|
||||
note.tags += tags
|
||||
|
||||
if self.META.tagMemorizedItems and int(item.Interval) > 0:
|
||||
note.tags.append("Memorized")
|
||||
|
||||
self.logger("Element tags\t- " + repr(note.tags), level=3)
|
||||
|
||||
self.notes.append(note)
|
||||
|
||||
def logger(self, text: str, level: int = 1) -> None:
|
||||
"Wrapper for Anki logger"
|
||||
|
||||
dLevels = {0: "", 1: "Info", 2: "Verbose", 3: "Debug"}
|
||||
if level <= self.META.loggerLevel:
|
||||
# self.deck.updateProgress(_(text))
|
||||
|
||||
if self.META.logToStdOutput:
|
||||
print(
|
||||
self.__class__.__name__
|
||||
+ " - "
|
||||
+ dLevels[level].ljust(9)
|
||||
+ " -\t"
|
||||
+ text
|
||||
)
|
||||
|
||||
# OPEN AND LOAD
|
||||
def openAnything(self, source):
|
||||
"""Open any source / actually only opening of files is used
|
||||
@return an open handle which must be closed after use, i.e., handle.close()"""
|
||||
|
||||
if source == "-":
|
||||
return sys.stdin
|
||||
|
||||
# try to open with urllib (if source is http, ftp, or file URL)
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
return urllib.request.urlopen(source)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# try to open with native open function (if source is pathname)
|
||||
try:
|
||||
return open(source, encoding="utf8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# treat source as string
|
||||
import io
|
||||
|
||||
return io.StringIO(str(source))
|
||||
|
||||
def loadSource(self, source: str) -> None:
|
||||
"""Load source file and parse with xml.dom.minidom"""
|
||||
self.source = source
|
||||
self.logger("Load started...")
|
||||
sock = open(self.source, encoding="utf8")
|
||||
self.xmldoc = minidom.parse(sock).documentElement
|
||||
sock.close()
|
||||
self.logger("Load done.")
|
||||
|
||||
# PARSE
|
||||
def parse(self, node: Text | Element | None = None) -> None:
|
||||
"Parse method - parses document elements"
|
||||
|
||||
if node is None and self.xmldoc is not None:
|
||||
node = self.xmldoc
|
||||
|
||||
_method = "parse_%s" % node.__class__.__name__
|
||||
if hasattr(self, _method):
|
||||
parseMethod = getattr(self, _method)
|
||||
parseMethod(node)
|
||||
else:
|
||||
self.logger("No handler for method %s" % _method, level=3)
|
||||
|
||||
def parse_Document(self, node):
|
||||
"Parse XML document"
|
||||
|
||||
self.parse(node.documentElement)
|
||||
|
||||
def parse_Element(self, node: Element) -> None:
|
||||
"Parse XML element"
|
||||
|
||||
_method = "do_%s" % node.tagName
|
||||
if hasattr(self, _method):
|
||||
handlerMethod = getattr(self, _method)
|
||||
handlerMethod(node)
|
||||
else:
|
||||
self.logger("No handler for method %s" % _method, level=3)
|
||||
# print traceback.print_exc()
|
||||
|
||||
def parse_Text(self, node: Text) -> None:
|
||||
"Parse text inside elements. Text is stored into local buffer."
|
||||
|
||||
text = node.data
|
||||
self.cntBuf.append(text)
|
||||
|
||||
# def parse_Comment(self, node):
|
||||
# """
|
||||
# Source can contain XML comments, but we ignore them
|
||||
# """
|
||||
# pass
|
||||
|
||||
# DO
|
||||
def do_SuperMemoCollection(self, node: Element) -> None:
|
||||
"Process SM Collection"
|
||||
|
||||
for child in node.childNodes:
|
||||
self.parse(child)
|
||||
|
||||
def do_SuperMemoElement(self, node: Element) -> None:
|
||||
"Process SM Element (Type - Title,Topics)"
|
||||
|
||||
self.logger("=" * 45, level=3)
|
||||
|
||||
self.cntElm.append(SuperMemoElement())
|
||||
self.cntElm[-1]["lTitle"] = self.cntMeta["title"]
|
||||
|
||||
# parse all child elements
|
||||
for child in node.childNodes:
|
||||
self.parse(child)
|
||||
|
||||
# strip all saved strings, just for sure
|
||||
for key in list(self.cntElm[-1].keys()):
|
||||
if hasattr(self.cntElm[-1][key], "strip"):
|
||||
self.cntElm[-1][key] = self.cntElm[-1][key].strip()
|
||||
|
||||
# pop current element
|
||||
smel = self.cntElm.pop()
|
||||
|
||||
# Process cntElm if is valid Item (and not an Topic etc..)
|
||||
# if smel.Lapses != None and smel.Interval != None and smel.Question != None and smel.Answer != None:
|
||||
if smel.Title is None and smel.Question is not None and smel.Answer is not None:
|
||||
if smel.Answer.strip() != "" and smel.Question.strip() != "":
|
||||
# migrate only memorized otherway skip/continue
|
||||
if self.META.onlyMemorizedItems and not (int(smel.Interval) > 0):
|
||||
self.logger("Element skipped \t- not memorized ...", level=3)
|
||||
else:
|
||||
# import sm element data to Anki
|
||||
self.addItemToCards(smel)
|
||||
self.logger("Import element \t- " + smel["Question"], level=3)
|
||||
|
||||
# print element
|
||||
self.logger("-" * 45, level=3)
|
||||
for key in list(smel.keys()):
|
||||
self.logger(
|
||||
"\t{} {}".format((key + ":").ljust(15), smel[key]), level=3
|
||||
)
|
||||
else:
|
||||
self.logger("Element skipped \t- no valid Q and A ...", level=3)
|
||||
|
||||
else:
|
||||
# now we know that item was topic
|
||||
# parsing of whole node is now finished
|
||||
|
||||
# test if it's really topic
|
||||
if smel.Title is not None:
|
||||
# remove topic from title list
|
||||
t = self.cntMeta["title"].pop()
|
||||
self.logger("End of topic \t- %s" % (t), level=2)
|
||||
|
||||
def do_Content(self, node: Element) -> None:
|
||||
"Process SM element Content"
|
||||
|
||||
for child in node.childNodes:
|
||||
if hasattr(child, "tagName") and child.firstChild is not None:
|
||||
self.cntElm[-1][child.tagName] = child.firstChild.data
|
||||
|
||||
def do_LearningData(self, node: Element) -> None:
|
||||
"Process SM element LearningData"
|
||||
|
||||
for child in node.childNodes:
|
||||
if hasattr(child, "tagName") and child.firstChild is not None:
|
||||
self.cntElm[-1][child.tagName] = child.firstChild.data
|
||||
|
||||
# It's being processed in do_Content now
|
||||
# def do_Question(self, node):
|
||||
# for child in node.childNodes: self.parse(child)
|
||||
# self.cntElm[-1][node.tagName]=self.cntBuf.pop()
|
||||
|
||||
# It's being processed in do_Content now
|
||||
# def do_Answer(self, node):
|
||||
# for child in node.childNodes: self.parse(child)
|
||||
# self.cntElm[-1][node.tagName]=self.cntBuf.pop()
|
||||
|
||||
def do_Title(self, node: Element) -> None:
|
||||
"Process SM element Title"
|
||||
|
||||
t = self._decode_htmlescapes(node.firstChild.data)
|
||||
self.cntElm[-1][node.tagName] = t
|
||||
self.cntMeta["title"].append(t)
|
||||
self.cntElm[-1]["lTitle"] = self.cntMeta["title"]
|
||||
self.logger("Start of topic \t- " + " / ".join(self.cntMeta["title"]), level=2)
|
||||
|
||||
def do_Type(self, node: Element) -> None:
|
||||
"Process SM element Type"
|
||||
|
||||
if len(self.cntBuf) >= 1:
|
||||
self.cntElm[-1][node.tagName] = self.cntBuf.pop()
|
||||
|
||||
|
||||
# if __name__ == '__main__':
|
||||
|
||||
# for testing you can start it standalone
|
||||
|
||||
# file = u'/home/epcim/hg2g/dev/python/sm2anki/ADVENG2EXP.xxe.esc.zaloha_FINAL.xml'
|
||||
# file = u'/home/epcim/hg2g/dev/python/anki/libanki/tests/importing/supermemo/original_ENGLISHFORBEGGINERS_noOEM.xml'
|
||||
# file = u'/home/epcim/hg2g/dev/python/anki/libanki/tests/importing/supermemo/original_ENGLISHFORBEGGINERS_oem_1250.xml'
|
||||
# file = str(sys.argv[1])
|
||||
# impo = SupermemoXmlImporter(Deck(),file)
|
||||
# impo.foreignCards()
|
||||
|
||||
# sys.exit(1)
|
||||
|
||||
# vim: ts=4 sts=2 ft=python
|
|
@ -35,8 +35,16 @@ class CustomBuildHook(BuildHookInterface):
|
|||
|
||||
assert generated_root.exists(), "you should build with --wheel"
|
||||
for path in generated_root.rglob("*"):
|
||||
if path.is_file():
|
||||
if path.is_file() and not self._should_exclude(path):
|
||||
relative_path = path.relative_to(generated_root)
|
||||
# Place files under anki/ in the distribution
|
||||
dist_path = "anki" / relative_path
|
||||
force_include[str(path)] = str(dist_path)
|
||||
|
||||
def _should_exclude(self, path: Path) -> bool:
|
||||
"""Check if a file should be excluded from the wheel."""
|
||||
# Exclude __pycache__
|
||||
path_str = str(path)
|
||||
if "/__pycache__/" in path_str:
|
||||
return True
|
||||
return False
|
||||
|
|
|
@ -4,19 +4,15 @@ dynamic = ["version"]
|
|||
requires-python = ">=3.9"
|
||||
license = "AGPL-3.0-or-later"
|
||||
dependencies = [
|
||||
"beautifulsoup4",
|
||||
"decorator",
|
||||
"markdown",
|
||||
"orjson",
|
||||
"protobuf>=4.21",
|
||||
"requests[socks]",
|
||||
# remove after we update to min python 3.11+
|
||||
"typing_extensions",
|
||||
"types-protobuf",
|
||||
"types-requests",
|
||||
"types-orjson",
|
||||
# platform-specific dependencies
|
||||
"distro; sys_platform != 'darwin' and sys_platform != 'win32'",
|
||||
"psutil; sys_platform == 'win32'",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
|
|
@ -13,7 +13,6 @@ from anki.importing import (
|
|||
Anki2Importer,
|
||||
AnkiPackageImporter,
|
||||
MnemosyneImporter,
|
||||
SupermemoXmlImporter,
|
||||
TextImporter,
|
||||
)
|
||||
from tests.shared import getEmptyCol, getUpgradeDeckPath
|
||||
|
@ -306,22 +305,6 @@ def test_csv_tag_only_if_modified():
|
|||
col.close()
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore:Using or importing the ABCs")
|
||||
def test_supermemo_xml_01_unicode():
|
||||
col = getEmptyCol()
|
||||
file = str(os.path.join(testDir, "support", "supermemo1.xml"))
|
||||
i = SupermemoXmlImporter(col, file)
|
||||
# i.META.logToStdOutput = True
|
||||
i.run()
|
||||
assert i.total == 1
|
||||
cid = col.db.scalar("select id from cards")
|
||||
c = col.get_card(cid)
|
||||
# Applies A Factor-to-E Factor conversion
|
||||
assert c.factor == 2879
|
||||
assert c.reps == 7
|
||||
col.close()
|
||||
|
||||
|
||||
def test_mnemo():
|
||||
col = getEmptyCol()
|
||||
file = str(os.path.join(testDir, "support", "mnemo.db"))
|
||||
|
|
|
@ -17,6 +17,16 @@ dev = [
|
|||
"colorama", # for isort --color
|
||||
"wheel",
|
||||
"hatchling", # for type checking hatch_build.py files
|
||||
"mock",
|
||||
"types-protobuf",
|
||||
"types-requests",
|
||||
"types-orjson",
|
||||
"types-decorator",
|
||||
"types-flask",
|
||||
"types-flask-cors",
|
||||
"types-markdown",
|
||||
"types-waitress",
|
||||
"types-pywin32",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
@ -46,7 +46,7 @@
|
|||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>667</width>
|
||||
<height>24</height>
|
||||
<height>43</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuHelp">
|
||||
|
@ -93,6 +93,7 @@
|
|||
<addaction name="actionAdd_ons"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionNoteTypes"/>
|
||||
<addaction name="action_upgrade_downgrade"/>
|
||||
<addaction name="actionPreferences"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuqt_accel_view">
|
||||
|
@ -130,7 +131,7 @@
|
|||
<string notr="true">Ctrl+P</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::PreferencesRole</enum>
|
||||
<enum>QAction::MenuRole::PreferencesRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAbout">
|
||||
|
@ -138,7 +139,7 @@
|
|||
<string>qt_accel_about</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::AboutRole</enum>
|
||||
<enum>QAction::MenuRole::ApplicationSpecificRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionUndo">
|
||||
|
@ -283,6 +284,11 @@
|
|||
<string>qt_accel_load_backup</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_upgrade_downgrade">
|
||||
<property name="text">
|
||||
<string>qt_accel_upgrade_downgrade</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<resources>
|
||||
<include location="icons.qrc"/>
|
||||
|
|
|
@ -1308,6 +1308,14 @@ title="{}" {}>{}</button>""".format(
|
|||
def onPrefs(self) -> None:
|
||||
aqt.dialogs.open("Preferences", self)
|
||||
|
||||
def on_upgrade_downgrade(self) -> None:
|
||||
if not askUser(tr.qt_misc_open_anki_launcher()):
|
||||
return
|
||||
|
||||
from aqt.update import update_and_restart
|
||||
|
||||
update_and_restart()
|
||||
|
||||
def onNoteTypes(self) -> None:
|
||||
import aqt.models
|
||||
|
||||
|
@ -1389,6 +1397,8 @@ title="{}" {}>{}</button>""".format(
|
|||
##########################################################################
|
||||
|
||||
def setupMenus(self) -> None:
|
||||
from aqt.update import have_launcher
|
||||
|
||||
m = self.form
|
||||
|
||||
# File
|
||||
|
@ -1405,6 +1415,7 @@ title="{}" {}>{}</button>""".format(
|
|||
qconnect(m.actionDocumentation.triggered, self.onDocumentation)
|
||||
qconnect(m.actionDonate.triggered, self.onDonate)
|
||||
qconnect(m.actionAbout.triggered, self.onAbout)
|
||||
m.actionAbout.setText(tr.qt_accel_about_mac())
|
||||
|
||||
# Edit
|
||||
qconnect(m.actionUndo.triggered, self.undo)
|
||||
|
@ -1417,6 +1428,9 @@ title="{}" {}>{}</button>""".format(
|
|||
qconnect(m.actionCreateFiltered.triggered, self.onCram)
|
||||
qconnect(m.actionEmptyCards.triggered, self.onEmptyCards)
|
||||
qconnect(m.actionNoteTypes.triggered, self.onNoteTypes)
|
||||
qconnect(m.action_upgrade_downgrade.triggered, self.on_upgrade_downgrade)
|
||||
if not have_launcher():
|
||||
m.action_upgrade_downgrade.setVisible(False)
|
||||
qconnect(m.actionPreferences.triggered, self.onPrefs)
|
||||
|
||||
# View
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
# Copyright: Ankitects Pty Ltd and contributors
|
||||
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import aqt
|
||||
|
@ -10,7 +14,7 @@ from anki.collection import CheckForUpdateResponse, Collection
|
|||
from anki.utils import dev_mode, int_time, int_version, is_mac, is_win, plat_desc
|
||||
from aqt.operations import QueryOp
|
||||
from aqt.qt import *
|
||||
from aqt.utils import show_info, show_warning, showText, tr
|
||||
from aqt.utils import openLink, show_warning, showText, tr
|
||||
|
||||
|
||||
def check_for_update() -> None:
|
||||
|
@ -80,22 +84,56 @@ def prompt_to_update(mw: aqt.AnkiQt, ver: str) -> None:
|
|||
# ignore this update
|
||||
mw.pm.meta["suppressUpdate"] = ver
|
||||
elif ret == QMessageBox.StandardButton.Yes:
|
||||
update_and_restart()
|
||||
if have_launcher():
|
||||
update_and_restart()
|
||||
else:
|
||||
openLink(aqt.appWebsiteDownloadSection)
|
||||
|
||||
|
||||
def _anki_launcher_path() -> str | None:
|
||||
return os.getenv("ANKI_LAUNCHER")
|
||||
|
||||
|
||||
def have_launcher() -> bool:
|
||||
return _anki_launcher_path() is not None
|
||||
|
||||
|
||||
def update_and_restart() -> None:
|
||||
"""Download and install the update, then restart Anki."""
|
||||
update_on_next_run()
|
||||
# todo: do this automatically in the future
|
||||
show_info(tr.qt_misc_please_restart_to_update_anki())
|
||||
from aqt import mw
|
||||
|
||||
launcher = _anki_launcher_path()
|
||||
assert launcher
|
||||
|
||||
_trigger_launcher_run()
|
||||
|
||||
with contextlib.suppress(ResourceWarning):
|
||||
env = os.environ.copy()
|
||||
creationflags = 0
|
||||
if sys.platform == "win32":
|
||||
creationflags = (
|
||||
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
||||
)
|
||||
subprocess.Popen(
|
||||
[launcher],
|
||||
start_new_session=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=env,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
|
||||
mw.app.quit()
|
||||
|
||||
|
||||
def update_on_next_run() -> None:
|
||||
def _trigger_launcher_run() -> None:
|
||||
"""Bump the mtime on pyproject.toml in the local data directory to trigger an update on next run."""
|
||||
try:
|
||||
# Get the local data directory equivalent to Rust's dirs::data_local_dir()
|
||||
if is_win:
|
||||
data_dir = Path(os.environ.get("LOCALAPPDATA", ""))
|
||||
from .winpaths import get_local_appdata
|
||||
|
||||
data_dir = Path(get_local_appdata())
|
||||
elif is_mac:
|
||||
data_dir = Path.home() / "Library" / "Application Support"
|
||||
else: # Linux
|
||||
|
|
|
@ -67,11 +67,16 @@ class CustomBuildHook(BuildHookInterface):
|
|||
|
||||
def _should_exclude(self, path: Path) -> bool:
|
||||
"""Check if a file should be excluded from the wheel."""
|
||||
# Match the exclusions from write_wheel.py exclude_aqt function
|
||||
path_str = str(path)
|
||||
|
||||
# Exclude __pycache__
|
||||
if "/__pycache__/" in path_str:
|
||||
return True
|
||||
|
||||
if path.suffix in [".ui", ".scss", ".map", ".ts"]:
|
||||
return True
|
||||
if path.name.startswith("tsconfig"):
|
||||
return True
|
||||
if "/aqt/data" in str(path):
|
||||
if "/aqt/data" in path_str:
|
||||
return True
|
||||
return False
|
||||
|
|
|
@ -11,6 +11,7 @@ rust-version.workspace = true
|
|||
anki_io.workspace = true
|
||||
anki_process.workspace = true
|
||||
anyhow.workspace = true
|
||||
camino.workspace = true
|
||||
dirs.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
|
@ -22,5 +23,9 @@ libc-stdhandle.workspace = true
|
|||
name = "build_win"
|
||||
path = "src/bin/build_win.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "anki-console"
|
||||
path = "src/bin/anki_console.rs"
|
||||
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
embed-resource.workspace = true
|
||||
|
|
58
qt/launcher/src/bin/anki_console.rs
Normal file
58
qt/launcher/src/bin/anki_console.rs
Normal file
|
@ -0,0 +1,58 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
#![windows_subsystem = "console"]
|
||||
|
||||
use std::env;
|
||||
use std::io::stdin;
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
|
||||
fn main() {
|
||||
if let Err(e) = run() {
|
||||
eprintln!("Error: {:#}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
let current_exe = env::current_exe().context("Failed to get current executable path")?;
|
||||
let exe_dir = current_exe
|
||||
.parent()
|
||||
.context("Failed to get executable directory")?;
|
||||
|
||||
let anki_exe = exe_dir.join("anki.exe");
|
||||
|
||||
if !anki_exe.exists() {
|
||||
anyhow::bail!("anki.exe not found in the same directory");
|
||||
}
|
||||
|
||||
// Forward all command line arguments to anki.exe
|
||||
let args: Vec<String> = env::args().skip(1).collect();
|
||||
|
||||
let mut cmd = Command::new(&anki_exe);
|
||||
cmd.args(&args);
|
||||
|
||||
if std::env::var("ANKI_IMPLICIT_CONSOLE").is_err() {
|
||||
// if directly invoked by the user, signal the launcher that the
|
||||
// user wants a Python console
|
||||
std::env::set_var("ANKI_CONSOLE", "1");
|
||||
}
|
||||
|
||||
// Wait for the process to complete and forward its exit code
|
||||
let status = cmd.status().context("Failed to execute anki.exe")?;
|
||||
if !status.success() {
|
||||
println!("\nPress enter to close.");
|
||||
let mut input = String::new();
|
||||
let _ = stdin().read_line(&mut input);
|
||||
}
|
||||
|
||||
if let Some(code) = status.code() {
|
||||
std::process::exit(code);
|
||||
} else {
|
||||
// Process was terminated by a signal
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
|
@ -114,6 +114,12 @@ fn copy_files(output_dir: &Path) -> Result<()> {
|
|||
let launcher_dst = output_dir.join("anki.exe");
|
||||
copy_file(&launcher_src, &launcher_dst)?;
|
||||
|
||||
// Copy anki-console binary
|
||||
let console_src =
|
||||
PathBuf::from(CARGO_TARGET_DIR).join("x86_64-pc-windows-msvc/release/anki-console.exe");
|
||||
let console_dst = output_dir.join("anki-console.exe");
|
||||
copy_file(&console_src, &console_dst)?;
|
||||
|
||||
// Copy uv.exe and uvw.exe
|
||||
let uv_src = PathBuf::from("../../../out/extracted/uv/uv.exe");
|
||||
let uv_dst = output_dir.join("uv.exe");
|
||||
|
@ -133,14 +139,12 @@ fn copy_files(output_dir: &Path) -> Result<()> {
|
|||
output_dir.join(".python-version"),
|
||||
)?;
|
||||
|
||||
// Copy anki-console.bat
|
||||
copy_file("anki-console.bat", output_dir.join("anki-console.bat"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sign_binaries(output_dir: &Path) -> Result<()> {
|
||||
sign_file(&output_dir.join("anki.exe"))?;
|
||||
sign_file(&output_dir.join("anki-console.exe"))?;
|
||||
sign_file(&output_dir.join("uv.exe"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -16,21 +16,34 @@ use anki_io::modified_time;
|
|||
use anki_io::read_file;
|
||||
use anki_io::remove_file;
|
||||
use anki_io::write_file;
|
||||
use anki_io::ToUtf8Path;
|
||||
use anki_process::CommandExt;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::platform::ensure_terminal_shown;
|
||||
use crate::platform::exec_anki;
|
||||
use crate::platform::get_anki_binary_path;
|
||||
use crate::platform::get_exe_and_resources_dirs;
|
||||
use crate::platform::get_uv_binary_name;
|
||||
use crate::platform::handle_first_launch;
|
||||
use crate::platform::initial_terminal_setup;
|
||||
use crate::platform::launch_anki_detached;
|
||||
use crate::platform::launch_anki_after_update;
|
||||
use crate::platform::launch_anki_normally;
|
||||
|
||||
mod platform;
|
||||
|
||||
// todo: -c appearing as app name now
|
||||
|
||||
struct State {
|
||||
has_existing_install: bool,
|
||||
prerelease_marker: std::path::PathBuf,
|
||||
uv_install_root: std::path::PathBuf,
|
||||
uv_path: std::path::PathBuf,
|
||||
user_pyproject_path: std::path::PathBuf,
|
||||
user_python_version_path: std::path::PathBuf,
|
||||
dist_pyproject_path: std::path::PathBuf,
|
||||
dist_python_version_path: std::path::PathBuf,
|
||||
uv_lock_path: std::path::PathBuf,
|
||||
sync_complete_marker: std::path::PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum VersionKind {
|
||||
PyOxidizer(String),
|
||||
|
@ -46,16 +59,8 @@ pub enum MainMenuChoice {
|
|||
Quit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Config {
|
||||
pub show_console: bool,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Err(e) = run() {
|
||||
let mut config: Config = Config::default();
|
||||
initial_terminal_setup(&mut config);
|
||||
|
||||
eprintln!("Error: {:#}", e);
|
||||
eprintln!("Press enter to close...");
|
||||
let mut input = String::new();
|
||||
|
@ -66,58 +71,92 @@ fn main() {
|
|||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
let mut config: Config = Config::default();
|
||||
|
||||
let uv_install_root = dirs::data_local_dir()
|
||||
.context("Unable to determine data_dir")?
|
||||
.join("AnkiProgramFiles");
|
||||
|
||||
let sync_complete_marker = uv_install_root.join(".sync_complete");
|
||||
let prerelease_marker = uv_install_root.join("prerelease");
|
||||
let (exe_dir, resources_dir) = get_exe_and_resources_dirs()?;
|
||||
let dist_pyproject_path = resources_dir.join("pyproject.toml");
|
||||
let user_pyproject_path = uv_install_root.join("pyproject.toml");
|
||||
let dist_python_version_path = resources_dir.join(".python-version");
|
||||
let user_python_version_path = uv_install_root.join(".python-version");
|
||||
let uv_lock_path = uv_install_root.join("uv.lock");
|
||||
let uv_path: std::path::PathBuf = exe_dir.join(get_uv_binary_name());
|
||||
|
||||
let state = State {
|
||||
has_existing_install: uv_install_root.join(".sync_complete").exists(),
|
||||
prerelease_marker: uv_install_root.join("prerelease"),
|
||||
uv_install_root: uv_install_root.clone(),
|
||||
uv_path: exe_dir.join(get_uv_binary_name()),
|
||||
user_pyproject_path: uv_install_root.join("pyproject.toml"),
|
||||
user_python_version_path: uv_install_root.join(".python-version"),
|
||||
dist_pyproject_path: resources_dir.join("pyproject.toml"),
|
||||
dist_python_version_path: resources_dir.join(".python-version"),
|
||||
uv_lock_path: uv_install_root.join("uv.lock"),
|
||||
sync_complete_marker: uv_install_root.join(".sync_complete"),
|
||||
};
|
||||
|
||||
// Create install directory and copy project files in
|
||||
create_dir_all(&uv_install_root)?;
|
||||
let had_user_pyproj = user_pyproject_path.exists();
|
||||
create_dir_all(&state.uv_install_root)?;
|
||||
let had_user_pyproj = state.user_pyproject_path.exists();
|
||||
if !had_user_pyproj {
|
||||
// during initial launcher testing, enable betas by default
|
||||
write_file(&prerelease_marker, "")?;
|
||||
write_file(&state.prerelease_marker, "")?;
|
||||
}
|
||||
|
||||
copy_if_newer(&dist_pyproject_path, &user_pyproject_path)?;
|
||||
copy_if_newer(&dist_python_version_path, &user_python_version_path)?;
|
||||
copy_if_newer(&state.dist_pyproject_path, &state.user_pyproject_path)?;
|
||||
copy_if_newer(
|
||||
&state.dist_python_version_path,
|
||||
&state.user_python_version_path,
|
||||
)?;
|
||||
|
||||
let pyproject_has_changed = !sync_complete_marker.exists() || {
|
||||
let pyproject_toml_time = modified_time(&user_pyproject_path)?;
|
||||
let sync_complete_time = modified_time(&sync_complete_marker)?;
|
||||
let pyproject_has_changed = !state.sync_complete_marker.exists() || {
|
||||
let pyproject_toml_time = modified_time(&state.user_pyproject_path)?;
|
||||
let sync_complete_time = modified_time(&state.sync_complete_marker)?;
|
||||
Ok::<bool, anyhow::Error>(pyproject_toml_time > sync_complete_time)
|
||||
}
|
||||
.unwrap_or(true);
|
||||
|
||||
if !pyproject_has_changed {
|
||||
// If venv is already up to date, exec as normal
|
||||
initial_terminal_setup(&mut config);
|
||||
let anki_bin = get_anki_binary_path(&uv_install_root);
|
||||
exec_anki(&anki_bin, &config)?;
|
||||
// If venv is already up to date, launch Anki normally
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let cmd = build_python_command(&state.uv_install_root, &args)?;
|
||||
launch_anki_normally(cmd)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// we'll need to launch uv; reinvoke ourselves in a terminal so the user can see
|
||||
// If we weren't in a terminal, respawn ourselves in one
|
||||
ensure_terminal_shown()?;
|
||||
|
||||
print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top
|
||||
println!("\x1B[1mAnki Launcher\x1B[0m\n");
|
||||
|
||||
// Check if there's an existing installation before removing marker
|
||||
let has_existing_install = sync_complete_marker.exists();
|
||||
main_menu_loop(&state)?;
|
||||
|
||||
// Write marker file to indicate we've completed the sync process
|
||||
write_sync_marker(&state.sync_complete_marker)?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let cmd = build_python_command(&state.uv_install_root, &[])?;
|
||||
platform::mac::prepare_for_launch_after_update(cmd)?;
|
||||
}
|
||||
|
||||
if cfg!(unix) && !cfg!(target_os = "macos") {
|
||||
println!("\nPress enter to start Anki.");
|
||||
let mut input = String::new();
|
||||
let _ = stdin().read_line(&mut input);
|
||||
} else {
|
||||
// on Windows/macOS, the user needs to close the terminal/console
|
||||
// currently, but ideas on how we can avoid this would be good!
|
||||
println!("Anki will start shortly.");
|
||||
println!("\x1B[1mYou can close this window.\x1B[0m\n");
|
||||
}
|
||||
|
||||
let cmd = build_python_command(&state.uv_install_root, &[])?;
|
||||
launch_anki_after_update(cmd)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main_menu_loop(state: &State) -> Result<()> {
|
||||
loop {
|
||||
let menu_choice = get_main_menu_choice(has_existing_install, &prerelease_marker);
|
||||
let menu_choice =
|
||||
get_main_menu_choice(state.has_existing_install, &state.prerelease_marker);
|
||||
|
||||
match menu_choice {
|
||||
MainMenuChoice::Quit => std::process::exit(0),
|
||||
|
@ -127,40 +166,40 @@ fn run() -> Result<()> {
|
|||
}
|
||||
MainMenuChoice::ToggleBetas => {
|
||||
// Toggle beta prerelease file
|
||||
if prerelease_marker.exists() {
|
||||
let _ = remove_file(&prerelease_marker);
|
||||
if state.prerelease_marker.exists() {
|
||||
let _ = remove_file(&state.prerelease_marker);
|
||||
println!("Beta releases disabled.");
|
||||
} else {
|
||||
write_file(&prerelease_marker, "")?;
|
||||
write_file(&state.prerelease_marker, "")?;
|
||||
println!("Beta releases enabled.");
|
||||
}
|
||||
println!();
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
choice @ (MainMenuChoice::Latest | MainMenuChoice::Version(_)) => {
|
||||
// For other choices, update project files and sync
|
||||
update_pyproject_for_version(
|
||||
menu_choice.clone(),
|
||||
dist_pyproject_path.clone(),
|
||||
user_pyproject_path.clone(),
|
||||
dist_python_version_path.clone(),
|
||||
user_python_version_path.clone(),
|
||||
choice,
|
||||
state.dist_pyproject_path.clone(),
|
||||
state.user_pyproject_path.clone(),
|
||||
state.dist_python_version_path.clone(),
|
||||
state.user_python_version_path.clone(),
|
||||
)?;
|
||||
|
||||
// Remove sync marker before attempting sync
|
||||
let _ = remove_file(&sync_complete_marker);
|
||||
let _ = remove_file(&state.sync_complete_marker);
|
||||
|
||||
// Sync the venv
|
||||
let mut command = Command::new(&uv_path);
|
||||
command.current_dir(&uv_install_root).args([
|
||||
let mut command = Command::new(&state.uv_path);
|
||||
command.current_dir(&state.uv_install_root).args([
|
||||
"sync",
|
||||
"--upgrade",
|
||||
"--managed-python",
|
||||
]);
|
||||
|
||||
// Add python version if .python-version file exists
|
||||
if user_python_version_path.exists() {
|
||||
let python_version = read_file(&user_python_version_path)?;
|
||||
if state.user_python_version_path.exists() {
|
||||
let python_version = read_file(&state.user_python_version_path)?;
|
||||
let python_version_str = String::from_utf8(python_version)
|
||||
.context("Invalid UTF-8 in .python-version")?;
|
||||
let python_version_trimmed = python_version_str.trim();
|
||||
|
@ -168,7 +207,7 @@ fn run() -> Result<()> {
|
|||
}
|
||||
|
||||
// Set UV_PRERELEASE=allow if beta mode is enabled
|
||||
if prerelease_marker.exists() {
|
||||
if state.prerelease_marker.exists() {
|
||||
command.env("UV_PRERELEASE", "allow");
|
||||
}
|
||||
|
||||
|
@ -182,7 +221,7 @@ fn run() -> Result<()> {
|
|||
Err(e) => {
|
||||
// If sync fails due to things like a missing wheel on pypi,
|
||||
// we need to remove the lockfile or uv will cache the bad result.
|
||||
let _ = remove_file(&uv_lock_path);
|
||||
let _ = remove_file(&state.uv_lock_path);
|
||||
println!("Install failed: {:#}", e);
|
||||
println!();
|
||||
continue;
|
||||
|
@ -191,22 +230,6 @@ fn run() -> Result<()> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write marker file to indicate we've completed the sync process
|
||||
write_sync_marker(&sync_complete_marker)?;
|
||||
|
||||
// First launch
|
||||
let anki_bin = get_anki_binary_path(&uv_install_root);
|
||||
handle_first_launch(&anki_bin)?;
|
||||
|
||||
println!("\nPress enter to start Anki.");
|
||||
|
||||
let mut input = String::new();
|
||||
let _ = stdin().read_line(&mut input);
|
||||
|
||||
// Then launch the binary as detached subprocess so the terminal can close
|
||||
launch_anki_detached(&anki_bin, &config)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -403,3 +426,25 @@ fn parse_version_kind(version: &str) -> Option<VersionKind> {
|
|||
Some(VersionKind::Uv(version.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_python_command(uv_install_root: &std::path::Path, args: &[String]) -> Result<Command> {
|
||||
let python_exe = if cfg!(target_os = "windows") {
|
||||
let show_console = std::env::var("ANKI_CONSOLE").is_ok();
|
||||
if show_console {
|
||||
uv_install_root.join(".venv/Scripts/python.exe")
|
||||
} else {
|
||||
uv_install_root.join(".venv/Scripts/pythonw.exe")
|
||||
}
|
||||
} else {
|
||||
uv_install_root.join(".venv/bin/python")
|
||||
};
|
||||
|
||||
let mut cmd = Command::new(python_exe);
|
||||
cmd.args(["-c", "import aqt; aqt.run()"]);
|
||||
cmd.args(args);
|
||||
// tell the Python code it was invoked by the launcher, and updating is
|
||||
// available
|
||||
cmd.env("ANKI_LAUNCHER", std::env::current_exe()?.utf8()?.as_str());
|
||||
|
||||
Ok(cmd)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
@ -13,45 +14,7 @@ use anki_process::CommandExt as AnkiCommandExt;
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
|
||||
// Re-export Unix functions that macOS uses
|
||||
pub use super::unix::{
|
||||
ensure_terminal_shown,
|
||||
exec_anki,
|
||||
get_anki_binary_path,
|
||||
initial_terminal_setup,
|
||||
};
|
||||
|
||||
pub fn launch_anki_detached(anki_bin: &std::path::Path, _config: &crate::Config) -> Result<()> {
|
||||
use std::process::Stdio;
|
||||
|
||||
let child = Command::new(anki_bin)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.process_group(0)
|
||||
.ensure_spawn()?;
|
||||
std::mem::forget(child);
|
||||
|
||||
println!("Anki will start shortly.");
|
||||
println!("\x1B[1mYou can close this window.\x1B[0m\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn relaunch_in_terminal() -> Result<()> {
|
||||
let current_exe = std::env::current_exe().context("Failed to get current executable path")?;
|
||||
Command::new("open")
|
||||
.args(["-a", "Terminal"])
|
||||
.arg(current_exe)
|
||||
.ensure_spawn()?;
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
pub fn handle_first_launch(anki_bin: &std::path::Path) -> Result<()> {
|
||||
use std::io::Write;
|
||||
use std::io::{
|
||||
self,
|
||||
};
|
||||
|
||||
pub fn prepare_for_launch_after_update(mut cmd: Command) -> Result<()> {
|
||||
// Pre-validate by running --version to trigger any Gatekeeper checks
|
||||
print!("\n\x1B[1mThis may take a few minutes. Please wait\x1B[0m");
|
||||
io::stdout().flush().unwrap();
|
||||
|
@ -67,7 +30,7 @@ pub fn handle_first_launch(anki_bin: &std::path::Path) -> Result<()> {
|
|||
}
|
||||
});
|
||||
|
||||
let _ = Command::new(anki_bin)
|
||||
let _ = cmd
|
||||
.env("ANKI_FIRST_RUN", "1")
|
||||
.arg("--version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
|
@ -81,22 +44,11 @@ pub fn handle_first_launch(anki_bin: &std::path::Path) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_exe_and_resources_dirs() -> Result<(std::path::PathBuf, std::path::PathBuf)> {
|
||||
let exe_dir = std::env::current_exe()
|
||||
.context("Failed to get current executable path")?
|
||||
.parent()
|
||||
.context("Failed to get executable directory")?
|
||||
.to_owned();
|
||||
|
||||
let resources_dir = exe_dir
|
||||
.parent()
|
||||
.context("Failed to get parent directory")?
|
||||
.join("Resources");
|
||||
|
||||
Ok((exe_dir, resources_dir))
|
||||
}
|
||||
|
||||
pub fn get_uv_binary_name() -> &'static str {
|
||||
// macOS uses standard uv binary name
|
||||
"uv"
|
||||
pub fn relaunch_in_terminal() -> Result<()> {
|
||||
let current_exe = std::env::current_exe().context("Failed to get current executable path")?;
|
||||
Command::new("open")
|
||||
.args(["-a", "Terminal"])
|
||||
.arg(current_exe)
|
||||
.ensure_spawn()?;
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
|
|
@ -1,18 +1,108 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod unix;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac;
|
||||
pub mod mac;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
pub mod windows;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use mac::*;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
pub use unix::*;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use windows::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anki_process::CommandExt;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn get_exe_and_resources_dirs() -> Result<(PathBuf, PathBuf)> {
|
||||
let exe_dir = std::env::current_exe()
|
||||
.context("Failed to get current executable path")?
|
||||
.parent()
|
||||
.context("Failed to get executable directory")?
|
||||
.to_owned();
|
||||
|
||||
let resources_dir = if cfg!(target_os = "macos") {
|
||||
// On macOS, resources are in ../Resources relative to the executable
|
||||
exe_dir
|
||||
.parent()
|
||||
.context("Failed to get parent directory")?
|
||||
.join("Resources")
|
||||
} else {
|
||||
// On other platforms, resources are in the same directory as executable
|
||||
exe_dir.clone()
|
||||
};
|
||||
|
||||
Ok((exe_dir, resources_dir))
|
||||
}
|
||||
|
||||
pub fn get_uv_binary_name() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
"uv.exe"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"uv"
|
||||
} else if cfg!(target_arch = "x86_64") {
|
||||
"uv.amd64"
|
||||
} else {
|
||||
"uv.arm64"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn launch_anki_after_update(mut cmd: std::process::Command) -> Result<()> {
|
||||
use std::process::Stdio;
|
||||
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
|
||||
let child = cmd.ensure_spawn()?;
|
||||
std::mem::forget(child);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn launch_anki_normally(mut cmd: std::process::Command) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
crate::platform::windows::attach_to_parent_console();
|
||||
cmd.ensure_success()?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
cmd.ensure_exec()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub use windows::ensure_terminal_shown;
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn ensure_terminal_shown() -> Result<()> {
|
||||
use std::io::IsTerminal;
|
||||
|
||||
let stdout_is_terminal = IsTerminal::is_terminal(&std::io::stdout());
|
||||
if !stdout_is_terminal {
|
||||
#[cfg(target_os = "macos")]
|
||||
mac::relaunch_in_terminal()?;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
unix::relaunch_in_terminal()?;
|
||||
}
|
||||
|
||||
// Set terminal title to "Anki Launcher"
|
||||
print!("\x1b]2;Anki Launcher\x07");
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -1,36 +1,11 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::io::IsTerminal;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use anki_process::CommandExt as AnkiCommandExt;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::Config;
|
||||
|
||||
pub fn initial_terminal_setup(_config: &mut Config) {
|
||||
// No special terminal setup needed on Unix
|
||||
}
|
||||
|
||||
pub fn ensure_terminal_shown() -> Result<()> {
|
||||
let stdout_is_terminal = IsTerminal::is_terminal(&std::io::stdout());
|
||||
if !stdout_is_terminal {
|
||||
// If launched from GUI, try to relaunch in a terminal
|
||||
crate::platform::relaunch_in_terminal()?;
|
||||
}
|
||||
|
||||
// Set terminal title to "Anki Launcher"
|
||||
print!("\x1b]2;Anki Launcher\x07");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn relaunch_in_terminal() -> Result<()> {
|
||||
let current_exe = std::env::current_exe().context("Failed to get current executable path")?;
|
||||
|
||||
|
@ -72,52 +47,3 @@ pub fn relaunch_in_terminal() -> Result<()> {
|
|||
// If no terminal worked, continue without relaunching
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_anki_binary_path(uv_install_root: &std::path::Path) -> PathBuf {
|
||||
uv_install_root.join(".venv/bin/anki")
|
||||
}
|
||||
|
||||
pub fn launch_anki_detached(anki_bin: &std::path::Path, config: &Config) -> Result<()> {
|
||||
// On non-macOS Unix systems, we don't need to detach since we never spawned a
|
||||
// terminal
|
||||
exec_anki(anki_bin, config)
|
||||
}
|
||||
|
||||
pub fn handle_first_launch(_anki_bin: &std::path::Path) -> Result<()> {
|
||||
// No special first launch handling needed for generic Unix systems
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn exec_anki(anki_bin: &std::path::Path, _config: &Config) -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
Command::new(anki_bin)
|
||||
.args(args)
|
||||
.ensure_exec()
|
||||
.map_err(anyhow::Error::new)
|
||||
}
|
||||
|
||||
pub fn get_exe_and_resources_dirs() -> Result<(PathBuf, PathBuf)> {
|
||||
let exe_dir = std::env::current_exe()
|
||||
.context("Failed to get current executable path")?
|
||||
.parent()
|
||||
.context("Failed to get executable directory")?
|
||||
.to_owned();
|
||||
|
||||
// On generic Unix systems, assume resources are in the same directory as
|
||||
// executable
|
||||
let resources_dir = exe_dir.clone();
|
||||
|
||||
Ok((exe_dir, resources_dir))
|
||||
}
|
||||
|
||||
pub fn get_uv_binary_name() -> &'static str {
|
||||
// Use architecture-specific uv binary for non-Mac Unix systems
|
||||
if cfg!(target_arch = "x86_64") {
|
||||
"uv.amd64"
|
||||
} else if cfg!(target_arch = "aarch64") {
|
||||
"uv.arm64"
|
||||
} else {
|
||||
// Fallback to generic uv for other architectures
|
||||
"uv"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,82 +1,71 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use anki_process::CommandExt;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use winapi::um::consoleapi;
|
||||
use winapi::um::errhandlingapi;
|
||||
use winapi::um::wincon;
|
||||
|
||||
use crate::Config;
|
||||
|
||||
pub fn ensure_terminal_shown() -> Result<()> {
|
||||
ensure_console();
|
||||
// // Check if we're already relaunched to prevent infinite recursion
|
||||
// if std::env::var("ANKI_LAUNCHER_IN_TERMINAL").is_ok() {
|
||||
// println!("Recurse: Preparing to start Anki...\n");
|
||||
// return Ok(());
|
||||
// }
|
||||
|
||||
// if have_console {
|
||||
// } else {
|
||||
// relaunch_in_cmd()?;
|
||||
// }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_console() {
|
||||
unsafe {
|
||||
if !wincon::GetConsoleWindow().is_null() {
|
||||
return;
|
||||
// We already have a console, no need to spawn anki-console.exe
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if consoleapi::AllocConsole() == 0 {
|
||||
let error_code = errhandlingapi::GetLastError();
|
||||
eprintln!("unexpected AllocConsole error: {}", error_code);
|
||||
return;
|
||||
}
|
||||
|
||||
// This black magic triggers Windows to switch to the new
|
||||
// ANSI-supporting console host, which is usually only available
|
||||
// when the app is built with the console subsystem.
|
||||
let _ = Command::new("cmd").args(&["/C", ""]).status();
|
||||
}
|
||||
|
||||
if std::env::var("ANKI_IMPLICIT_CONSOLE").is_ok() && attach_to_parent_console() {
|
||||
// Successfully attached to parent console
|
||||
reconnect_stdio_to_console();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// No console available, spawn anki-console.exe and exit
|
||||
let current_exe = std::env::current_exe().context("Failed to get current executable path")?;
|
||||
let exe_dir = current_exe
|
||||
.parent()
|
||||
.context("Failed to get executable directory")?;
|
||||
|
||||
let console_exe = exe_dir.join("anki-console.exe");
|
||||
|
||||
if !console_exe.exists() {
|
||||
anyhow::bail!("anki-console.exe not found in the same directory");
|
||||
}
|
||||
|
||||
// Spawn anki-console.exe without waiting
|
||||
Command::new(&console_exe)
|
||||
.env("ANKI_IMPLICIT_CONSOLE", "1")
|
||||
.spawn()
|
||||
.context("Failed to spawn anki-console.exe")?;
|
||||
|
||||
// Exit immediately after spawning
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
fn attach_to_parent_console() -> bool {
|
||||
pub fn attach_to_parent_console() -> bool {
|
||||
unsafe {
|
||||
if !wincon::GetConsoleWindow().is_null() {
|
||||
// we have a console already
|
||||
println!("attach: already had console, false");
|
||||
return false;
|
||||
}
|
||||
|
||||
if wincon::AttachConsole(wincon::ATTACH_PARENT_PROCESS) != 0 {
|
||||
// successfully attached to parent
|
||||
println!("attach: true");
|
||||
reconnect_stdio_to_console();
|
||||
true
|
||||
} else {
|
||||
println!("attach: false");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// If parent process has a console (eg cmd.exe), redirect our output there.
|
||||
/// Sets config.show_console to true if successfully attached to console.
|
||||
pub fn initial_terminal_setup(config: &mut Config) {
|
||||
/// Reconnect stdin/stdout/stderr to the console.
|
||||
fn reconnect_stdio_to_console() {
|
||||
use std::ffi::CString;
|
||||
|
||||
use libc_stdhandle::*;
|
||||
|
||||
if !attach_to_parent_console() {
|
||||
return;
|
||||
}
|
||||
|
||||
// we launched without a console, so we'll need to open stdin/out/err
|
||||
let conin = CString::new("CONIN$").unwrap();
|
||||
let conout = CString::new("CONOUT$").unwrap();
|
||||
|
@ -89,79 +78,4 @@ pub fn initial_terminal_setup(config: &mut Config) {
|
|||
libc::freopen(conout.as_ptr(), w.as_ptr(), stdout());
|
||||
libc::freopen(conout.as_ptr(), w.as_ptr(), stderr());
|
||||
}
|
||||
|
||||
config.show_console = true;
|
||||
}
|
||||
|
||||
pub fn get_anki_binary_path(uv_install_root: &std::path::Path) -> std::path::PathBuf {
|
||||
uv_install_root.join(".venv/Scripts/anki.exe")
|
||||
}
|
||||
|
||||
fn build_python_command(
|
||||
anki_bin: &std::path::Path,
|
||||
args: &[String],
|
||||
config: &Config,
|
||||
) -> Result<Command> {
|
||||
let venv_dir = anki_bin
|
||||
.parent()
|
||||
.context("Failed to get venv Scripts directory")?
|
||||
.parent()
|
||||
.context("Failed to get venv directory")?;
|
||||
|
||||
// Use python.exe if show_console is true, otherwise pythonw.exe
|
||||
let python_exe = if config.show_console {
|
||||
venv_dir.join("Scripts/python.exe")
|
||||
} else {
|
||||
venv_dir.join("Scripts/pythonw.exe")
|
||||
};
|
||||
|
||||
let mut cmd = Command::new(python_exe);
|
||||
cmd.args(["-c", "import aqt; aqt.run()"]);
|
||||
cmd.args(args);
|
||||
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
pub fn launch_anki_detached(anki_bin: &std::path::Path, config: &Config) -> Result<()> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Stdio;
|
||||
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
|
||||
let mut cmd = build_python_command(anki_bin, &[], config)?;
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
.ensure_spawn()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn handle_first_launch(_anki_bin: &std::path::Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn exec_anki(anki_bin: &std::path::Path, config: &Config) -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut cmd = build_python_command(anki_bin, &args, config)?;
|
||||
cmd.ensure_success()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_exe_and_resources_dirs() -> Result<(PathBuf, PathBuf)> {
|
||||
let exe_dir = std::env::current_exe()
|
||||
.context("Failed to get current executable path")?
|
||||
.parent()
|
||||
.context("Failed to get executable directory")?
|
||||
.to_owned();
|
||||
|
||||
// On Windows, resources dir is the same as exe_dir
|
||||
let resources_dir = exe_dir.clone();
|
||||
|
||||
Ok((exe_dir, resources_dir))
|
||||
}
|
||||
|
||||
pub fn get_uv_binary_name() -> &'static str {
|
||||
"uv.exe"
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
@echo off
|
||||
"%~dp0"\anki %*
|
||||
pause
|
||||
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
@echo off
|
||||
|
||||
set CODESIGN=1
|
||||
REM set NO_COMPRESS=1
|
||||
if "%NOCOMP%"=="1" (
|
||||
set NO_COMPRESS=1
|
||||
set CODESIGN=0
|
||||
) else (
|
||||
set CODESIGN=1
|
||||
set NO_COMPRESS=0
|
||||
)
|
||||
cargo run --bin build_win
|
||||
|
|
|
@ -11,17 +11,9 @@ dependencies = [
|
|||
"requests",
|
||||
"send2trash",
|
||||
"waitress>=2.0.0",
|
||||
"psutil; sys.platform == 'win32'",
|
||||
"pywin32; sys.platform == 'win32'",
|
||||
"anki-mac-helper; sys.platform == 'darwin'",
|
||||
"pip-system-certs!=5.1",
|
||||
"mock",
|
||||
"types-decorator",
|
||||
"types-flask",
|
||||
"types-flask-cors",
|
||||
"types-markdown",
|
||||
"types-waitress",
|
||||
"types-pywin32",
|
||||
"pyqt6>=6.2",
|
||||
"pyqt6-webengine>=6.2",
|
||||
# anki dependency is added dynamically in hatch_build.py with exact version
|
||||
|
|
|
@ -33,6 +33,7 @@ use crate::deckconfig::LeechAction;
|
|||
use crate::decks::Deck;
|
||||
use crate::prelude::*;
|
||||
use crate::scheduler::fsrs::memory_state::fsrs_item_for_memory_state;
|
||||
use crate::scheduler::fsrs::memory_state::get_decay_from_params;
|
||||
use crate::scheduler::states::PreviewState;
|
||||
use crate::search::SearchNode;
|
||||
|
||||
|
@ -433,7 +434,9 @@ impl Collection {
|
|||
let config = self.home_deck_config(deck.config_id(), card.original_deck_id)?;
|
||||
let fsrs_enabled = self.get_config_bool(BoolKey::Fsrs);
|
||||
let fsrs_next_states = if fsrs_enabled {
|
||||
let fsrs = FSRS::new(Some(config.fsrs_params()))?;
|
||||
let params = config.fsrs_params();
|
||||
let fsrs = FSRS::new(Some(params))?;
|
||||
card.decay = Some(get_decay_from_params(params));
|
||||
if card.memory_state.is_none() && card.ctype != CardType::New {
|
||||
// Card has been moved or imported into an FSRS deck after params were set,
|
||||
// and will need its initial memory state to be calculated based on review
|
||||
|
|
|
@ -32,7 +32,7 @@ pub struct ComputeMemoryProgress {
|
|||
|
||||
/// Helper function to determine the appropriate decay value based on FSRS
|
||||
/// parameters
|
||||
fn get_decay_from_params(params: &[f32]) -> f32 {
|
||||
pub(crate) fn get_decay_from_params(params: &[f32]) -> f32 {
|
||||
if params.is_empty() {
|
||||
FSRS6_DEFAULT_DECAY // default decay for FSRS-6
|
||||
} else if params.len() < 21 {
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
use anki_proto::scheduler::simulate_fsrs_review_request::cmrr_target::Kind;
|
||||
use anki_proto::scheduler::SimulateFsrsReviewRequest;
|
||||
use fsrs::extract_simulator_config;
|
||||
use fsrs::SimulationResult;
|
||||
use fsrs::SimulatorConfig;
|
||||
use fsrs::FSRS;
|
||||
|
||||
|
@ -14,14 +16,115 @@ pub struct ComputeRetentionProgress {
|
|||
pub total: u32,
|
||||
}
|
||||
|
||||
pub fn average_r_power_forgetting_curve(
|
||||
learn_span: usize,
|
||||
cards: &[fsrs::Card],
|
||||
offset: f32,
|
||||
decay: f32,
|
||||
) -> f32 {
|
||||
let factor = 0.9_f32.powf(1.0 / decay) - 1.0;
|
||||
let exp = decay + 1.0;
|
||||
let den_factor = factor * exp;
|
||||
|
||||
// Closure equivalent to the inner integral function
|
||||
let integral_calc = |card: &fsrs::Card| -> f32 {
|
||||
// Performs element-wise: (s / den_factor) * (1.0 + factor * t / s).powf(exp)
|
||||
let t1 = learn_span as f32 - card.last_date;
|
||||
let t2 = t1 + offset;
|
||||
(card.stability / den_factor) * (1.0 + factor * t2 / card.stability).powf(exp)
|
||||
- (card.stability / den_factor) * (1.0 + factor * t1 / card.stability).powf(exp)
|
||||
};
|
||||
|
||||
// Calculate integral difference and divide by time difference element-wise
|
||||
cards.iter().map(integral_calc).sum::<f32>() / offset
|
||||
}
|
||||
|
||||
impl Collection {
|
||||
pub fn compute_optimal_retention(&mut self, req: SimulateFsrsReviewRequest) -> Result<f32> {
|
||||
// Helper macro to wrap the closure for "CMRRTargetFn"s
|
||||
macro_rules! wrap {
|
||||
($f:expr) => {
|
||||
Some(fsrs::CMRRTargetFn(std::sync::Arc::new($f)))
|
||||
};
|
||||
}
|
||||
|
||||
let target_type = req.target.unwrap().kind;
|
||||
|
||||
let days_to_simulate = req.days_to_simulate as f32;
|
||||
|
||||
let target = match target_type {
|
||||
Some(Kind::Memorized(_)) => None,
|
||||
Some(Kind::FutureMemorized(settings)) => {
|
||||
wrap!(move |SimulationResult {
|
||||
cards,
|
||||
cost_per_day,
|
||||
..
|
||||
},
|
||||
w| {
|
||||
let total_cost = cost_per_day.iter().sum::<f32>();
|
||||
total_cost
|
||||
/ cards.iter().fold(0., |p, c| {
|
||||
c.retention_on(w, days_to_simulate + settings.days as f32) + p
|
||||
})
|
||||
})
|
||||
}
|
||||
Some(Kind::AverageFutureMemorized(settings)) => {
|
||||
wrap!(move |SimulationResult {
|
||||
cards,
|
||||
cost_per_day,
|
||||
..
|
||||
},
|
||||
w| {
|
||||
let total_cost = cost_per_day.iter().sum::<f32>();
|
||||
total_cost
|
||||
/ average_r_power_forgetting_curve(
|
||||
days_to_simulate as usize,
|
||||
cards,
|
||||
settings.days as f32,
|
||||
-w[20],
|
||||
)
|
||||
})
|
||||
}
|
||||
Some(Kind::Stability(_)) => {
|
||||
wrap!(move |SimulationResult {
|
||||
cards,
|
||||
cost_per_day,
|
||||
..
|
||||
},
|
||||
w| {
|
||||
let total_cost = cost_per_day.iter().sum::<f32>();
|
||||
total_cost
|
||||
/ cards.iter().fold(0., |p, c| {
|
||||
p + (c.retention_on(w, days_to_simulate) * c.stability)
|
||||
})
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut anki_progress = self.new_progress_handler::<ComputeRetentionProgress>();
|
||||
let fsrs = FSRS::new(None)?;
|
||||
if req.days_to_simulate == 0 {
|
||||
invalid_input!("no days to simulate")
|
||||
}
|
||||
let (config, cards) = self.simulate_request_to_config(&req)?;
|
||||
let (mut config, cards) = self.simulate_request_to_config(&req)?;
|
||||
|
||||
if let Some(Kind::Memorized(settings)) = target_type {
|
||||
let loss_aversion = settings.loss_aversion;
|
||||
|
||||
config.relearning_step_transitions[0][0] *= loss_aversion;
|
||||
config.relearning_step_transitions[1][0] *= loss_aversion;
|
||||
config.relearning_step_transitions[2][0] *= loss_aversion;
|
||||
|
||||
config.learning_step_transitions[0][0] *= loss_aversion;
|
||||
config.learning_step_transitions[1][0] *= loss_aversion;
|
||||
config.learning_step_transitions[2][0] *= loss_aversion;
|
||||
|
||||
config.state_rating_costs[0][0] *= loss_aversion;
|
||||
config.state_rating_costs[1][0] *= loss_aversion;
|
||||
config.state_rating_costs[2][0] *= loss_aversion;
|
||||
}
|
||||
|
||||
Ok(fsrs
|
||||
.optimal_retention(
|
||||
&config,
|
||||
|
@ -34,7 +137,7 @@ impl Collection {
|
|||
.is_ok()
|
||||
},
|
||||
Some(cards),
|
||||
None,
|
||||
target,
|
||||
)?
|
||||
.clamp(0.7, 0.95))
|
||||
}
|
||||
|
|
|
@ -13,4 +13,9 @@ path = "main.rs"
|
|||
name = "anki-sync-server"
|
||||
|
||||
[dependencies]
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
anki = { workspace = true, features = ["native-tls"] }
|
||||
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
anki = { workspace = true, features = ["rustls"] }
|
||||
|
|
|
@ -21,12 +21,7 @@ use walkdir::WalkDir;
|
|||
|
||||
const NONSTANDARD_HEADER: &[&str] = &[
|
||||
"./pylib/anki/_vendor/stringcase.py",
|
||||
"./pylib/anki/importing/pauker.py",
|
||||
"./pylib/anki/importing/supermemo_xml.py",
|
||||
"./pylib/anki/statsbg.py",
|
||||
"./pylib/tools/protoc-gen-mypy.py",
|
||||
"./python/pyqt/install.py",
|
||||
"./python/write_wheel.py",
|
||||
"./qt/aqt/mpv.py",
|
||||
"./qt/aqt/winpaths.py",
|
||||
];
|
||||
|
|
15
tools/update-launcher-env
Executable file
15
tools/update-launcher-env
Executable file
|
@ -0,0 +1,15 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# Install our latest anki/aqt code into the launcher venv
|
||||
|
||||
set -e
|
||||
|
||||
rm -rf out/wheels
|
||||
./ninja wheels
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
export VIRTUAL_ENV=$HOME/Library/Application\ Support/AnkiProgramFiles/.venv
|
||||
else
|
||||
export VIRTUAL_ENV=$HOME/.local/share/AnkiProgramFiles/.venv
|
||||
fi
|
||||
./out/extracted/uv/uv pip install out/wheels/*
|
||||
|
8
tools/update-launcher-env.bat
Normal file
8
tools/update-launcher-env.bat
Normal file
|
@ -0,0 +1,8 @@
|
|||
@echo off
|
||||
rem
|
||||
rem Install our latest anki/aqt code into the launcher venv
|
||||
|
||||
rmdir /s /q out\wheels 2>nul
|
||||
call tools\ninja wheels
|
||||
set VIRTUAL_ENV=%LOCALAPPDATA%\AnkiProgramFiles\.venv
|
||||
for %%f in (out\wheels\*.whl) do out\extracted\uv\uv pip install "%%f"
|
|
@ -3,6 +3,8 @@ Copyright: Ankitects Pty Ltd and contributors
|
|||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import "./change-notetype-base.scss";
|
||||
|
||||
import * as tr from "@generated/ftl";
|
||||
import { renderMarkdown } from "@tslib/helpers";
|
||||
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
@use "../lib/sass/bootstrap-dark";
|
||||
@use "$lib/sass/bootstrap-dark";
|
||||
|
||||
@import "../lib/sass/base";
|
||||
@import "$lib/sass/base";
|
||||
|
||||
@import "bootstrap/scss/alert";
|
||||
@import "bootstrap/scss/buttons";
|
||||
@import "bootstrap/scss/button-group";
|
||||
@import "bootstrap/scss/close";
|
||||
@import "bootstrap/scss/grid";
|
||||
@import "../lib/sass/bootstrap-forms";
|
||||
@import "$lib/sass/bootstrap-forms";
|
||||
|
||||
.night-mode {
|
||||
@include bootstrap-dark.night-mode;
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import "./change-notetype-base.scss";
|
||||
|
||||
import { getChangeNotetypeInfo, getNotetypeNames } from "@generated/backend";
|
||||
import { ModuleName, setupI18n } from "@tslib/i18n";
|
||||
import { checkNightMode } from "@tslib/nightmode";
|
||||
|
||||
import ChangeNotetypePage from "./ChangeNotetypePage.svelte";
|
||||
import { ChangeNotetypeState } from "./lib";
|
||||
|
||||
const notetypeNames = getNotetypeNames({});
|
||||
const i18n = setupI18n({
|
||||
modules: [ModuleName.ACTIONS, ModuleName.CHANGE_NOTETYPE, ModuleName.KEYBOARD],
|
||||
});
|
||||
|
||||
export async function setupChangeNotetypePage(
|
||||
oldNotetypeId: bigint,
|
||||
newNotetypeId: bigint,
|
||||
): Promise<ChangeNotetypePage> {
|
||||
const changeNotetypeInfo = getChangeNotetypeInfo({
|
||||
oldNotetypeId,
|
||||
newNotetypeId,
|
||||
});
|
||||
const [names, info] = await Promise.all([notetypeNames, changeNotetypeInfo, i18n]);
|
||||
|
||||
checkNightMode();
|
||||
|
||||
const state = new ChangeNotetypeState(names, info);
|
||||
return new ChangeNotetypePage({
|
||||
target: document.body,
|
||||
props: { state },
|
||||
});
|
||||
}
|
||||
|
||||
// use #testXXXX where XXXX is notetype ID to test
|
||||
if (window.location.hash.startsWith("#test")) {
|
||||
const ntid = parseInt(window.location.hash.substring("#test".length), 10);
|
||||
setupChangeNotetypePage(BigInt(ntid), BigInt(ntid));
|
||||
}
|
|
@ -3,6 +3,8 @@ Copyright: Ankitects Pty Ltd and contributors
|
|||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import "./congrats-base.scss";
|
||||
|
||||
import type { CongratsInfoResponse } from "@generated/anki/scheduler_pb";
|
||||
import { congratsInfo } from "@generated/backend";
|
||||
import * as tr from "@generated/ftl";
|
||||
|
|
|
@ -6,8 +6,6 @@
|
|||
// page, and mounts into a div with 'id=congrats'. Unlike the desktop, it does not
|
||||
// auto-refresh (to reduce the load on AnkiWeb).
|
||||
|
||||
import "./congrats-base.scss";
|
||||
|
||||
import { congratsInfo } from "@generated/backend";
|
||||
import { ModuleName, setupI18n } from "@tslib/i18n";
|
||||
import { checkNightMode } from "@tslib/nightmode";
|
||||
|
|
|
@ -3,6 +3,8 @@ Copyright: Ankitects Pty Ltd and contributors
|
|||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import "./deck-options-base.scss";
|
||||
|
||||
import type { Writable } from "svelte/store";
|
||||
|
||||
import "$lib/sveltelib/export-runtime";
|
||||
|
|
|
@ -7,7 +7,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
ComputeRetentionProgress,
|
||||
type ComputeParamsProgress,
|
||||
} from "@generated/anki/collection_pb";
|
||||
import { SimulateFsrsReviewRequest } from "@generated/anki/scheduler_pb";
|
||||
import {
|
||||
SimulateFsrsReviewRequest,
|
||||
SimulateFsrsReviewRequest_CMRRTarget,
|
||||
SimulateFsrsReviewRequest_CMRRTarget_Memorized,
|
||||
} from "@generated/anki/scheduler_pb";
|
||||
import {
|
||||
computeFsrsParams,
|
||||
evaluateParams,
|
||||
|
@ -94,6 +98,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
newCardsIgnoreReviewLimit: $newCardsIgnoreReviewLimit,
|
||||
easyDaysPercentages: $config.easyDaysPercentages,
|
||||
reviewOrder: $config.reviewOrder,
|
||||
target: new SimulateFsrsReviewRequest_CMRRTarget({
|
||||
kind: {
|
||||
case: "memorized",
|
||||
value: new SimulateFsrsReviewRequest_CMRRTarget_Memorized({
|
||||
lossAversion: 1.6,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const DESIRED_RETENTION_LOW_THRESHOLD = 0.8;
|
||||
|
|
|
@ -18,21 +18,30 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
import { renderSimulationChart } from "../graphs/simulator";
|
||||
import { computeOptimalRetention, simulateFsrsReview } from "@generated/backend";
|
||||
import { runWithBackendProgress } from "@tslib/progress";
|
||||
import type {
|
||||
ComputeOptimalRetentionResponse,
|
||||
SimulateFsrsReviewRequest,
|
||||
SimulateFsrsReviewResponse,
|
||||
import {
|
||||
SimulateFsrsReviewRequest_CMRRTarget_AverageFutureMemorized,
|
||||
SimulateFsrsReviewRequest_CMRRTarget_FutureMemorized,
|
||||
SimulateFsrsReviewRequest_CMRRTarget_Memorized,
|
||||
SimulateFsrsReviewRequest_CMRRTarget_Stability,
|
||||
type ComputeOptimalRetentionResponse,
|
||||
type SimulateFsrsReviewRequest,
|
||||
type SimulateFsrsReviewResponse,
|
||||
} from "@generated/anki/scheduler_pb";
|
||||
import type { DeckOptionsState } from "./lib";
|
||||
import SwitchRow from "$lib/components/SwitchRow.svelte";
|
||||
import GlobalLabel from "./GlobalLabel.svelte";
|
||||
import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
|
||||
import { reviewOrderChoices } from "./choices";
|
||||
import {
|
||||
DEFAULT_CMRR_TARGET,
|
||||
CMRRTargetChoices,
|
||||
reviewOrderChoices,
|
||||
} from "./choices";
|
||||
import EnumSelectorRow from "$lib/components/EnumSelectorRow.svelte";
|
||||
import { DeckConfig_Config_LeechAction } from "@generated/anki/deck_config_pb";
|
||||
import EasyDaysInput from "./EasyDaysInput.svelte";
|
||||
import Warning from "./Warning.svelte";
|
||||
import type { ComputeRetentionProgress } from "@generated/anki/collection_pb";
|
||||
import Item from "$lib/components/Item.svelte";
|
||||
|
||||
export let shown = false;
|
||||
export let state: DeckOptionsState;
|
||||
|
@ -41,6 +50,45 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
export let openHelpModal: (key: string) => void;
|
||||
export let onPresetChange: () => void;
|
||||
|
||||
let cmrrTargetType = DEFAULT_CMRR_TARGET;
|
||||
// All added types must be updated in the proceeding switch statement.
|
||||
let lastCmrrTargetType = cmrrTargetType;
|
||||
$: if (simulateFsrsRequest?.target && cmrrTargetType !== lastCmrrTargetType) {
|
||||
switch (cmrrTargetType) {
|
||||
case "memorized":
|
||||
simulateFsrsRequest.target.kind = {
|
||||
case: "memorized",
|
||||
value: new SimulateFsrsReviewRequest_CMRRTarget_Memorized({
|
||||
lossAversion: 1.6,
|
||||
}),
|
||||
};
|
||||
break;
|
||||
case "stability":
|
||||
simulateFsrsRequest.target.kind = {
|
||||
case: "stability",
|
||||
value: new SimulateFsrsReviewRequest_CMRRTarget_Stability({}),
|
||||
};
|
||||
break;
|
||||
case "futureMemorized":
|
||||
simulateFsrsRequest.target.kind = {
|
||||
case: "futureMemorized",
|
||||
value: new SimulateFsrsReviewRequest_CMRRTarget_FutureMemorized({
|
||||
days: 365,
|
||||
}),
|
||||
};
|
||||
break;
|
||||
case "averageFutureMemorized":
|
||||
simulateFsrsRequest.target.kind = {
|
||||
case: "averageFutureMemorized",
|
||||
value: new SimulateFsrsReviewRequest_CMRRTarget_AverageFutureMemorized(
|
||||
{ days: 365 },
|
||||
),
|
||||
};
|
||||
break;
|
||||
}
|
||||
lastCmrrTargetType = cmrrTargetType;
|
||||
}
|
||||
|
||||
const config = state.currentConfig;
|
||||
let simulateSubgraph: SimulateSubgraph = SimulateSubgraph.count;
|
||||
let tableData: TableDatum[] = [];
|
||||
|
@ -410,6 +458,42 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
{#if computingRetention}
|
||||
<div>{computeRetentionProgressString}</div>
|
||||
{/if}
|
||||
|
||||
<Item>
|
||||
<EnumSelectorRow
|
||||
choices={CMRRTargetChoices()}
|
||||
bind:value={cmrrTargetType}
|
||||
defaultValue={DEFAULT_CMRR_TARGET}
|
||||
>
|
||||
<SettingTitle>
|
||||
{"Target: "}
|
||||
</SettingTitle>
|
||||
</EnumSelectorRow>
|
||||
</Item>
|
||||
|
||||
{#if simulateFsrsRequest.target?.kind.case === "memorized"}
|
||||
<SpinBoxFloatRow
|
||||
bind:value={simulateFsrsRequest.target.kind.value
|
||||
.lossAversion}
|
||||
defaultValue={1.6}
|
||||
>
|
||||
<SettingTitle>
|
||||
{"Fail Cost Multiplier: "}
|
||||
</SettingTitle>
|
||||
</SpinBoxFloatRow>
|
||||
{/if}
|
||||
|
||||
{#if simulateFsrsRequest.target?.kind.case === "futureMemorized" || simulateFsrsRequest.target?.kind.case === "averageFutureMemorized"}
|
||||
<SpinBoxFloatRow
|
||||
bind:value={simulateFsrsRequest.target.kind.value.days}
|
||||
defaultValue={365}
|
||||
step={1}
|
||||
>
|
||||
<SettingTitle>
|
||||
{"Days after simulation end: "}
|
||||
</SettingTitle>
|
||||
</SpinBoxFloatRow>
|
||||
{/if}
|
||||
</details>
|
||||
|
||||
<button
|
||||
|
|
|
@ -199,6 +199,29 @@ export function questionActionChoices(): Choice<DeckConfig_Config_QuestionAction
|
|||
];
|
||||
}
|
||||
|
||||
export const DEFAULT_CMRR_TARGET = "memorized";
|
||||
|
||||
export function CMRRTargetChoices(): Choice<string>[] {
|
||||
return [
|
||||
{
|
||||
label: "Memorized (Default)",
|
||||
value: "memorized",
|
||||
},
|
||||
{
|
||||
label: "Stability (Experimental)",
|
||||
value: "stability",
|
||||
},
|
||||
{
|
||||
label: "Post Abandon Memorized (Experimental)",
|
||||
value: "futureMemorized",
|
||||
},
|
||||
{
|
||||
label: "Average Post Abandon Memorized (Experimental)",
|
||||
value: "averageFutureMemorized",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function difficultyOrders(fsrs: boolean): Choice<DeckConfig_Config_ReviewCardOrder>[] {
|
||||
const order = [
|
||||
{
|
||||
|
|
|
@ -1,65 +0,0 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
/* eslint
|
||||
@typescript-eslint/no-explicit-any: "off",
|
||||
*/
|
||||
|
||||
import "$lib/sveltelib/export-runtime";
|
||||
import "./deck-options-base.scss";
|
||||
|
||||
import { getDeckConfigsForUpdate } from "@generated/backend";
|
||||
import { ModuleName, setupI18n } from "@tslib/i18n";
|
||||
import { checkNightMode } from "@tslib/nightmode";
|
||||
|
||||
import { modalsKey, touchDeviceKey } from "$lib/components/context-keys";
|
||||
import EnumSelectorRow from "$lib/components/EnumSelectorRow.svelte";
|
||||
import SwitchRow from "$lib/components/SwitchRow.svelte";
|
||||
import TitledContainer from "$lib/components/TitledContainer.svelte";
|
||||
|
||||
import DeckOptionsPage from "./DeckOptionsPage.svelte";
|
||||
import { DeckOptionsState } from "./lib";
|
||||
import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
|
||||
import SpinBoxRow from "./SpinBoxRow.svelte";
|
||||
|
||||
const i18n = setupI18n({
|
||||
modules: [
|
||||
ModuleName.HELP,
|
||||
ModuleName.SCHEDULING,
|
||||
ModuleName.ACTIONS,
|
||||
ModuleName.DECK_CONFIG,
|
||||
ModuleName.KEYBOARD,
|
||||
ModuleName.STUDYING,
|
||||
ModuleName.DECKS,
|
||||
],
|
||||
});
|
||||
|
||||
export async function setupDeckOptions(did_: number): Promise<DeckOptionsPage> {
|
||||
const did = BigInt(did_);
|
||||
const [info] = await Promise.all([getDeckConfigsForUpdate({ did }), i18n]);
|
||||
|
||||
checkNightMode();
|
||||
|
||||
const context = new Map();
|
||||
context.set(modalsKey, new Map());
|
||||
context.set(touchDeviceKey, "ontouchstart" in document.documentElement);
|
||||
|
||||
const state = new DeckOptionsState(BigInt(did), info);
|
||||
return new DeckOptionsPage({
|
||||
target: document.body,
|
||||
props: { state },
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
export const components = {
|
||||
TitledContainer,
|
||||
SpinBoxRow,
|
||||
SpinBoxFloatRow,
|
||||
EnumSelectorRow,
|
||||
SwitchRow,
|
||||
};
|
||||
|
||||
// if (window.location.hash.startsWith("#test")) {
|
||||
// setupDeckOptions(1);
|
||||
// }
|
|
@ -3,6 +3,8 @@ Copyright: Ankitects Pty Ltd and contributors
|
|||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import "./graphs-base.scss";
|
||||
|
||||
import { bridgeCommand } from "@tslib/bridgecommand";
|
||||
import type { SvelteComponent } from "svelte";
|
||||
import { writable } from "svelte/store";
|
||||
|
|
|
@ -1,72 +0,0 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
/* eslint
|
||||
@typescript-eslint/no-explicit-any: "off",
|
||||
*/
|
||||
|
||||
import "./graphs-base.scss";
|
||||
|
||||
import { ModuleName, setupI18n } from "@tslib/i18n";
|
||||
import { checkNightMode } from "@tslib/nightmode";
|
||||
import type { SvelteComponent } from "svelte";
|
||||
|
||||
import GraphsPage from "./GraphsPage.svelte";
|
||||
|
||||
const i18n = setupI18n({ modules: [ModuleName.STATISTICS, ModuleName.SCHEDULING] });
|
||||
|
||||
export async function setupGraphs(
|
||||
graphs: typeof SvelteComponent<any>[],
|
||||
{
|
||||
search = "deck:current",
|
||||
days = 365,
|
||||
controller = null satisfies typeof SvelteComponent<any> | null,
|
||||
} = {},
|
||||
): Promise<GraphsPage> {
|
||||
checkNightMode();
|
||||
await i18n;
|
||||
|
||||
return new GraphsPage({
|
||||
target: document.body,
|
||||
props: {
|
||||
initialSearch: search,
|
||||
initialDays: days,
|
||||
graphs,
|
||||
controller,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
import AddedGraph from "./AddedGraph.svelte";
|
||||
import ButtonsGraph from "./ButtonsGraph.svelte";
|
||||
import CalendarGraph from "./CalendarGraph.svelte";
|
||||
import CardCounts from "./CardCounts.svelte";
|
||||
import DifficultyGraph from "./DifficultyGraph.svelte";
|
||||
import EaseGraph from "./EaseGraph.svelte";
|
||||
import FutureDue from "./FutureDue.svelte";
|
||||
import { RevlogRange } from "./graph-helpers";
|
||||
import HourGraph from "./HourGraph.svelte";
|
||||
import IntervalsGraph from "./IntervalsGraph.svelte";
|
||||
import RangeBox from "./RangeBox.svelte";
|
||||
import RetrievabilityGraph from "./RetrievabilityGraph.svelte";
|
||||
import ReviewsGraph from "./ReviewsGraph.svelte";
|
||||
import StabilityGraph from "./StabilityGraph.svelte";
|
||||
import TodayStats from "./TodayStats.svelte";
|
||||
|
||||
export const graphComponents = {
|
||||
TodayStats,
|
||||
FutureDue,
|
||||
CalendarGraph,
|
||||
ReviewsGraph,
|
||||
CardCounts,
|
||||
IntervalsGraph,
|
||||
StabilityGraph,
|
||||
EaseGraph,
|
||||
DifficultyGraph,
|
||||
RetrievabilityGraph,
|
||||
HourGraph,
|
||||
ButtonsGraph,
|
||||
AddedGraph,
|
||||
RangeBox,
|
||||
RevlogRange,
|
||||
};
|
|
@ -3,6 +3,8 @@ Copyright: Ankitects Pty Ltd and contributors
|
|||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import "./image-occlusion-base.scss";
|
||||
|
||||
import * as tr from "@generated/ftl";
|
||||
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
@use "../lib/sass/vars";
|
||||
@use "../lib/sass/bootstrap-dark";
|
||||
@use "../../lib/sass/vars";
|
||||
@use "../../lib/sass/bootstrap-dark";
|
||||
|
||||
@import "../lib/sass/base";
|
||||
@import "../../lib/sass/base";
|
||||
|
||||
@import "bootstrap/scss/alert";
|
||||
@import "bootstrap/scss/buttons";
|
||||
@import "bootstrap/scss/button-group";
|
||||
@import "bootstrap/scss/close";
|
||||
@import "bootstrap/scss/grid";
|
||||
@import "../lib/sass/bootstrap-forms";
|
||||
@import "../../lib/sass/bootstrap-forms";
|
||||
|
||||
.night-mode {
|
||||
@include bootstrap-dark.night-mode;
|
||||
|
|
|
@ -3,6 +3,8 @@ Copyright: Ankitects Pty Ltd and contributors
|
|||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import "./import-anki-package-base.scss";
|
||||
|
||||
import type { ImportAnkiPackageOptions } from "@generated/anki/import_export_pb";
|
||||
import { importAnkiPackage } from "@generated/backend";
|
||||
import * as tr from "@generated/ftl";
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
@use "../lib/sass/bootstrap-dark";
|
||||
@use "$lib/sass/bootstrap-dark";
|
||||
|
||||
@import "../lib/sass/base";
|
||||
@import "$lib/sass/base";
|
||||
|
||||
@import "bootstrap/scss/alert";
|
||||
@import "bootstrap/scss/buttons";
|
||||
|
@ -10,8 +10,8 @@
|
|||
@import "bootstrap/scss/transitions";
|
||||
@import "bootstrap/scss/modal";
|
||||
@import "bootstrap/scss/carousel";
|
||||
@import "../lib/sass/bootstrap-forms";
|
||||
@import "../lib/sass/bootstrap-tooltip";
|
||||
@import "$lib/sass/bootstrap-forms";
|
||||
@import "$lib/sass/bootstrap-tooltip";
|
||||
|
||||
.night-mode {
|
||||
@include bootstrap-dark.night-mode;
|
||||
|
|
|
@ -1,52 +0,0 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import "./import-anki-package-base.scss";
|
||||
|
||||
import { getImportAnkiPackagePresets } from "@generated/backend";
|
||||
import { ModuleName, setupI18n } from "@tslib/i18n";
|
||||
import { checkNightMode } from "@tslib/nightmode";
|
||||
|
||||
import { modalsKey } from "$lib/components/context-keys";
|
||||
|
||||
import ImportAnkiPackagePage from "./ImportAnkiPackagePage.svelte";
|
||||
|
||||
const i18n = setupI18n({
|
||||
modules: [
|
||||
ModuleName.IMPORTING,
|
||||
ModuleName.ACTIONS,
|
||||
ModuleName.HELP,
|
||||
ModuleName.DECK_CONFIG,
|
||||
ModuleName.ADDING,
|
||||
ModuleName.EDITING,
|
||||
ModuleName.KEYBOARD,
|
||||
],
|
||||
});
|
||||
|
||||
export async function setupImportAnkiPackagePage(
|
||||
path: string,
|
||||
): Promise<ImportAnkiPackagePage> {
|
||||
const [_, options] = await Promise.all([
|
||||
i18n,
|
||||
getImportAnkiPackagePresets({}),
|
||||
]);
|
||||
|
||||
const context = new Map();
|
||||
context.set(modalsKey, new Map());
|
||||
checkNightMode();
|
||||
|
||||
return new ImportAnkiPackagePage({
|
||||
target: document.body,
|
||||
props: {
|
||||
path,
|
||||
options,
|
||||
},
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
// eg http://localhost:40000/_anki/pages/import-anki-package.html#test-/home/dae/foo.apkg
|
||||
if (window.location.hash.startsWith("#test-")) {
|
||||
const apkgPath = window.location.hash.replace("#test-", "");
|
||||
setupImportAnkiPackagePage(apkgPath);
|
||||
}
|
|
@ -3,6 +3,8 @@ Copyright: Ankitects Pty Ltd and contributors
|
|||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import "./import-csv-base.scss";
|
||||
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
|
||||
import ImportPage from "../import-page/ImportPage.svelte";
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
@use "../lib/sass/bootstrap-dark";
|
||||
@use "$lib/sass/bootstrap-dark";
|
||||
|
||||
@import "../lib/sass/base";
|
||||
@import "$lib/sass/base";
|
||||
|
||||
@import "bootstrap/scss/alert";
|
||||
@import "bootstrap/scss/buttons";
|
||||
|
@ -10,8 +10,8 @@
|
|||
@import "bootstrap/scss/transitions";
|
||||
@import "bootstrap/scss/modal";
|
||||
@import "bootstrap/scss/carousel";
|
||||
@import "../lib/sass/bootstrap-forms";
|
||||
@import "../lib/sass/bootstrap-tooltip";
|
||||
@import "$lib/sass/bootstrap-forms";
|
||||
@import "$lib/sass/bootstrap-tooltip";
|
||||
|
||||
.night-mode {
|
||||
@include bootstrap-dark.night-mode;
|
||||
|
|
|
@ -1,62 +0,0 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import "./import-csv-base.scss";
|
||||
|
||||
import { getCsvMetadata, getDeckNames, getNotetypeNames } from "@generated/backend";
|
||||
import { ModuleName, setupI18n } from "@tslib/i18n";
|
||||
import { checkNightMode } from "@tslib/nightmode";
|
||||
|
||||
import { modalsKey } from "$lib/components/context-keys";
|
||||
import ErrorPage from "$lib/components/ErrorPage.svelte";
|
||||
|
||||
import ImportCsvPage from "./ImportCsvPage.svelte";
|
||||
import { ImportCsvState } from "./lib";
|
||||
|
||||
const i18n = setupI18n({
|
||||
modules: [
|
||||
ModuleName.ACTIONS,
|
||||
ModuleName.CHANGE_NOTETYPE,
|
||||
ModuleName.DECKS,
|
||||
ModuleName.EDITING,
|
||||
ModuleName.IMPORTING,
|
||||
ModuleName.KEYBOARD,
|
||||
ModuleName.NOTETYPES,
|
||||
ModuleName.STUDYING,
|
||||
ModuleName.ADDING,
|
||||
ModuleName.HELP,
|
||||
ModuleName.DECK_CONFIG,
|
||||
],
|
||||
});
|
||||
|
||||
export async function setupImportCsvPage(path: string): Promise<ImportCsvPage | ErrorPage> {
|
||||
const context = new Map();
|
||||
context.set(modalsKey, new Map());
|
||||
checkNightMode();
|
||||
|
||||
return Promise.all([
|
||||
getNotetypeNames({}),
|
||||
getDeckNames({
|
||||
skipEmptyDefault: false,
|
||||
includeFiltered: false,
|
||||
}),
|
||||
getCsvMetadata({ path }, { alertOnError: false }),
|
||||
i18n,
|
||||
]).then(([notetypes, decks, metadata]) => {
|
||||
return new ImportCsvPage({
|
||||
target: document.body,
|
||||
props: {
|
||||
state: new ImportCsvState(path, notetypes, decks, metadata),
|
||||
},
|
||||
context,
|
||||
});
|
||||
}).catch((error) => {
|
||||
return new ErrorPage({ target: document.body, props: { error } });
|
||||
});
|
||||
}
|
||||
|
||||
/* // use #testXXXX where XXXX is notetype ID to test
|
||||
if (window.location.hash.startsWith("#test")) {
|
||||
const ntid = parseInt(window.location.hash.substr("#test".length), 10);
|
||||
setupCsvImportPage(ntid, ntid);
|
||||
} */
|
|
@ -9,6 +9,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import "./import-page-base.scss";
|
||||
|
||||
import type { ImportResponse } from "@generated/anki/import_export_pb";
|
||||
import { importDone } from "@generated/backend";
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
@use "../lib/sass/bootstrap-dark";
|
||||
@use "$lib/sass/bootstrap-dark";
|
||||
|
||||
@import "../lib/sass/base";
|
||||
@import "$lib/sass/base";
|
||||
|
||||
@import "../lib/sass/bootstrap-tooltip";
|
||||
@import "$lib/sass/bootstrap-tooltip";
|
||||
@import "bootstrap/scss/buttons";
|
||||
|
||||
.night-mode {
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import "./import-page-base.scss";
|
||||
|
||||
import { importJsonFile, importJsonString } from "@generated/backend";
|
||||
import { ModuleName, setupI18n } from "@tslib/i18n";
|
||||
import { checkNightMode } from "@tslib/nightmode";
|
||||
|
||||
import ImportPage from "./ImportPage.svelte";
|
||||
import type { LogParams } from "./types";
|
||||
|
||||
const i18n = setupI18n({
|
||||
modules: [
|
||||
ModuleName.IMPORTING,
|
||||
ModuleName.ADDING,
|
||||
ModuleName.EDITING,
|
||||
ModuleName.ACTIONS,
|
||||
ModuleName.KEYBOARD,
|
||||
],
|
||||
});
|
||||
|
||||
const postOptions = { alertOnError: false };
|
||||
|
||||
export async function setupImportPage(
|
||||
params: LogParams,
|
||||
): Promise<ImportPage> {
|
||||
await i18n;
|
||||
|
||||
checkNightMode();
|
||||
|
||||
return new ImportPage({
|
||||
target: document.body,
|
||||
props: {
|
||||
path: params.path,
|
||||
noOptions: true,
|
||||
importer: {
|
||||
doImport: () => {
|
||||
switch (params.type) {
|
||||
case "json_file":
|
||||
return importJsonFile({ val: params.path }, postOptions);
|
||||
case "json_string":
|
||||
return importJsonString({ val: params.json }, postOptions);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (window.location.hash.startsWith("#test-")) {
|
||||
const path = window.location.hash.replace("#test-", "");
|
||||
setupImportPage({ type: "json_file", path });
|
||||
}
|
63
uv.lock
63
uv.lock
|
@ -51,33 +51,23 @@ wheels = [
|
|||
name = "anki"
|
||||
source = { editable = "pylib" }
|
||||
dependencies = [
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "decorator" },
|
||||
{ name = "distro", marker = "(sys_platform != 'darwin' and sys_platform != 'win32') or (sys_platform == 'darwin' and extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt66') or (sys_platform == 'darwin' and extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt67') or (sys_platform == 'darwin' and extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt69') or (sys_platform == 'darwin' and extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt67') or (sys_platform == 'darwin' and extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt69') or (sys_platform == 'darwin' and extra == 'extra-3-aqt-qt67' and extra == 'extra-3-aqt-qt69') or (sys_platform == 'win32' and extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt66') or (sys_platform == 'win32' and extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt67') or (sys_platform == 'win32' and extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt69') or (sys_platform == 'win32' and extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt67') or (sys_platform == 'win32' and extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt69') or (sys_platform == 'win32' and extra == 'extra-3-aqt-qt67' and extra == 'extra-3-aqt-qt69')" },
|
||||
{ name = "markdown" },
|
||||
{ name = "orjson" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "psutil", marker = "sys_platform == 'win32' or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt66') or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt67') or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt69') or (extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt67') or (extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt69') or (extra == 'extra-3-aqt-qt67' and extra == 'extra-3-aqt-qt69')" },
|
||||
{ name = "requests", extra = ["socks"] },
|
||||
{ name = "types-orjson" },
|
||||
{ name = "types-protobuf" },
|
||||
{ name = "types-requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "decorator" },
|
||||
{ name = "distro", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" },
|
||||
{ name = "markdown" },
|
||||
{ name = "orjson" },
|
||||
{ name = "protobuf", specifier = ">=4.21" },
|
||||
{ name = "psutil", marker = "sys_platform == 'win32'" },
|
||||
{ name = "requests", extras = ["socks"] },
|
||||
{ name = "types-orjson" },
|
||||
{ name = "types-protobuf" },
|
||||
{ name = "types-requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
|
||||
|
@ -111,11 +101,21 @@ dev = [
|
|||
{ name = "colorama" },
|
||||
{ name = "hatchling" },
|
||||
{ name = "isort" },
|
||||
{ name = "mock" },
|
||||
{ name = "mypy" },
|
||||
{ name = "mypy-protobuf" },
|
||||
{ name = "pychromedevtools" },
|
||||
{ name = "pylint" },
|
||||
{ name = "pytest" },
|
||||
{ name = "types-decorator" },
|
||||
{ name = "types-flask" },
|
||||
{ name = "types-flask-cors" },
|
||||
{ name = "types-markdown" },
|
||||
{ name = "types-orjson" },
|
||||
{ name = "types-protobuf" },
|
||||
{ name = "types-pywin32" },
|
||||
{ name = "types-requests" },
|
||||
{ name = "types-waitress" },
|
||||
{ name = "wheel" },
|
||||
]
|
||||
|
||||
|
@ -133,11 +133,21 @@ dev = [
|
|||
{ name = "colorama" },
|
||||
{ name = "hatchling" },
|
||||
{ name = "isort" },
|
||||
{ name = "mock" },
|
||||
{ name = "mypy" },
|
||||
{ name = "mypy-protobuf" },
|
||||
{ name = "pychromedevtools" },
|
||||
{ name = "pylint" },
|
||||
{ name = "pytest" },
|
||||
{ name = "types-decorator" },
|
||||
{ name = "types-flask" },
|
||||
{ name = "types-flask-cors" },
|
||||
{ name = "types-markdown" },
|
||||
{ name = "types-orjson" },
|
||||
{ name = "types-protobuf" },
|
||||
{ name = "types-pywin32" },
|
||||
{ name = "types-requests" },
|
||||
{ name = "types-waitress" },
|
||||
{ name = "wheel" },
|
||||
]
|
||||
|
||||
|
@ -158,10 +168,8 @@ dependencies = [
|
|||
{ name = "flask" },
|
||||
{ name = "flask-cors" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "mock" },
|
||||
{ name = "pip-system-certs", version = "4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt66') or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt67') or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt69') or (extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt67') or (extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt69') or (extra == 'extra-3-aqt-qt67' and extra == 'extra-3-aqt-qt69')" },
|
||||
{ name = "pip-system-certs", version = "5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt66') or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt67') or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt69') or (extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt67') or (extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt69') or (extra == 'extra-3-aqt-qt67' and extra == 'extra-3-aqt-qt69')" },
|
||||
{ name = "psutil", marker = "sys_platform == 'win32' or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt66') or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt67') or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt69') or (extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt67') or (extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt69') or (extra == 'extra-3-aqt-qt67' and extra == 'extra-3-aqt-qt69')" },
|
||||
{ name = "pyqt6", version = "6.6.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-3-aqt-qt66' or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt67') or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt69') or (extra == 'extra-3-aqt-qt67' and extra == 'extra-3-aqt-qt69')" },
|
||||
{ name = "pyqt6", version = "6.7.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-3-aqt-qt67' or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt66') or (extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt69') or (extra == 'extra-3-aqt-qt' and extra != 'extra-3-aqt-qt67' and extra == 'extra-3-aqt-qt69')" },
|
||||
{ name = "pyqt6", version = "6.8.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-3-aqt-qt' or (extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt67') or (extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt69') or (extra == 'extra-3-aqt-qt67' and extra == 'extra-3-aqt-qt69')" },
|
||||
|
@ -173,12 +181,6 @@ dependencies = [
|
|||
{ name = "pywin32", marker = "sys_platform == 'win32' or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt66') or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt67') or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt69') or (extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt67') or (extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt69') or (extra == 'extra-3-aqt-qt67' and extra == 'extra-3-aqt-qt69')" },
|
||||
{ name = "requests" },
|
||||
{ name = "send2trash" },
|
||||
{ name = "types-decorator" },
|
||||
{ name = "types-flask" },
|
||||
{ name = "types-flask-cors" },
|
||||
{ name = "types-markdown" },
|
||||
{ name = "types-pywin32" },
|
||||
{ name = "types-waitress" },
|
||||
{ name = "waitress" },
|
||||
]
|
||||
|
||||
|
@ -223,9 +225,7 @@ requires-dist = [
|
|||
{ name = "flask" },
|
||||
{ name = "flask-cors" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "mock" },
|
||||
{ name = "pip-system-certs", specifier = "!=5.1" },
|
||||
{ name = "psutil", marker = "sys_platform == 'win32'" },
|
||||
{ name = "pyqt6", specifier = ">=6.2" },
|
||||
{ name = "pyqt6", marker = "extra == 'qt'", specifier = "==6.8.0" },
|
||||
{ name = "pyqt6", marker = "extra == 'qt66'", specifier = "==6.6.1" },
|
||||
|
@ -251,12 +251,6 @@ requires-dist = [
|
|||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
{ name = "requests" },
|
||||
{ name = "send2trash" },
|
||||
{ name = "types-decorator" },
|
||||
{ name = "types-flask" },
|
||||
{ name = "types-flask-cors" },
|
||||
{ name = "types-markdown" },
|
||||
{ name = "types-pywin32" },
|
||||
{ name = "types-waitress" },
|
||||
{ name = "waitress", specifier = ">=2.0.0" },
|
||||
]
|
||||
provides-extras = ["audio", "qt", "qt66", "qt67", "qt69"]
|
||||
|
@ -595,7 +589,7 @@ name = "importlib-metadata"
|
|||
version = "8.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "zipp" },
|
||||
{ name = "zipp", marker = "python_full_version < '3.10' or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt66') or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt67') or (extra == 'extra-3-aqt-qt' and extra == 'extra-3-aqt-qt69') or (extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt67') or (extra == 'extra-3-aqt-qt66' and extra == 'extra-3-aqt-qt69') or (extra == 'extra-3-aqt-qt67' and extra == 'extra-3-aqt-qt69')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" }
|
||||
wheels = [
|
||||
|
@ -1005,21 +999,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/f7/af/ab3c51ab7507a7325e98ffe691d9495ee3d3aa5f589afad65ec920d39821/protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e", size = 168724, upload-time = "2025-05-28T19:25:53.926Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
version = "7.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pychromedevtools"
|
||||
version = "1.0.4"
|
||||
|
|
Loading…
Reference in a new issue