2019-02-05 04:59:03 +01:00
|
|
|
# Copyright: Ankitects Pty Ltd and contributors
|
2012-12-21 08:51:59 +01:00
|
|
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
|
|
|
2020-12-22 04:01:06 +01:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2017-08-28 10:35:24 +02:00
|
|
|
import io
|
2019-12-20 10:19:03 +01:00
|
|
|
import pickle
|
|
|
|
import random
|
|
|
|
import shutil
|
2020-07-02 02:35:01 +02:00
|
|
|
import traceback
|
2020-12-18 07:52:00 +01:00
|
|
|
from enum import Enum
|
2022-03-28 06:40:31 +02:00
|
|
|
from pathlib import Path
|
2023-05-18 09:47:51 +02:00
|
|
|
from typing import TYPE_CHECKING, Any, Optional
|
2013-11-13 07:29:50 +01:00
|
|
|
|
2016-04-05 03:02:01 +02:00
|
|
|
import anki.lang
|
2019-12-20 10:19:03 +01:00
|
|
|
import aqt.forms
|
2020-01-02 10:43:19 +01:00
|
|
|
import aqt.sound
|
2022-09-26 03:06:21 +02:00
|
|
|
from anki._legacy import deprecated
|
2021-07-11 06:51:25 +02:00
|
|
|
from anki.collection import Collection
|
2019-12-20 10:19:03 +01:00
|
|
|
from anki.db import DB
|
2020-11-18 02:53:33 +01:00
|
|
|
from anki.lang import without_unicode_isolation
|
2021-01-31 06:55:08 +01:00
|
|
|
from anki.sync import SyncAuth
|
2022-01-21 12:43:54 +01:00
|
|
|
from anki.utils import int_time, is_mac, is_win, point_version
|
2023-01-18 12:24:16 +01:00
|
|
|
from aqt import appHelpSite, gui_hooks
|
2019-12-20 10:19:03 +01:00
|
|
|
from aqt.qt import *
|
2023-01-18 12:24:16 +01:00
|
|
|
from aqt.theme import Theme, WidgetStyle, theme_manager
|
|
|
|
from aqt.toolbar import HideMode
|
2022-03-28 06:40:31 +02:00
|
|
|
from aqt.utils import disable_help_button, send_to_trash, showWarning, tr
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2022-09-20 04:56:59 +02:00
|
|
|
if TYPE_CHECKING:
|
|
|
|
from aqt.browser.layout import BrowserLayout
|
2022-09-28 06:02:32 +02:00
|
|
|
from aqt.editor import EditorMode
|
2022-09-20 04:56:59 +02:00
|
|
|
|
|
|
|
|
2020-11-17 08:42:43 +01:00
|
|
|
# Profile handling
|
|
|
|
##########################################################################
|
|
|
|
# - Saves in pickles rather than json to easily store Qt window state.
|
|
|
|
# - Saves in sqlite rather than a flat file so the config can't be corrupted
|
|
|
|
|
|
|
|
|
2020-12-22 04:01:06 +01:00
|
|
|
class VideoDriver(Enum):
|
|
|
|
OpenGL = "auto"
|
|
|
|
ANGLE = "angle"
|
|
|
|
Software = "software"
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def default_for_platform() -> VideoDriver:
|
2022-04-06 03:34:57 +02:00
|
|
|
if is_mac or qtmajor > 5:
|
2020-12-22 04:01:06 +01:00
|
|
|
return VideoDriver.OpenGL
|
|
|
|
else:
|
|
|
|
return VideoDriver.Software
|
|
|
|
|
|
|
|
def constrained_to_platform(self) -> VideoDriver:
|
2022-04-03 11:57:30 +02:00
|
|
|
if self == VideoDriver.ANGLE and not VideoDriver.supports_angle():
|
2020-12-22 04:01:06 +01:00
|
|
|
return VideoDriver.Software
|
|
|
|
return self
|
|
|
|
|
|
|
|
def next(self) -> VideoDriver:
|
|
|
|
if self == VideoDriver.Software:
|
|
|
|
return VideoDriver.OpenGL
|
2022-04-03 11:57:30 +02:00
|
|
|
elif self == VideoDriver.OpenGL and VideoDriver.supports_angle():
|
2020-12-22 04:01:06 +01:00
|
|
|
return VideoDriver.ANGLE
|
|
|
|
else:
|
|
|
|
return VideoDriver.Software
|
|
|
|
|
2022-04-03 11:57:30 +02:00
|
|
|
@staticmethod
|
|
|
|
def supports_angle() -> bool:
|
|
|
|
return is_win and qtmajor < 6
|
|
|
|
|
2020-12-22 04:01:06 +01:00
|
|
|
@staticmethod
|
2021-10-03 10:59:42 +02:00
|
|
|
def all_for_platform() -> list[VideoDriver]:
|
2020-12-22 04:01:06 +01:00
|
|
|
all = [VideoDriver.OpenGL]
|
2022-04-03 11:57:30 +02:00
|
|
|
if VideoDriver.supports_angle():
|
2020-12-22 04:01:06 +01:00
|
|
|
all.append(VideoDriver.ANGLE)
|
|
|
|
all.append(VideoDriver.Software)
|
|
|
|
return all
|
|
|
|
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
metaConf = dict(
|
|
|
|
ver=0,
|
|
|
|
updates=True,
|
2021-10-25 06:50:13 +02:00
|
|
|
created=int_time(),
|
2022-02-25 06:26:06 +01:00
|
|
|
id=random.randrange(0, 2**63),
|
2012-12-21 08:51:59 +01:00
|
|
|
lastMsg=-1,
|
|
|
|
suppressUpdate=False,
|
|
|
|
firstRun=True,
|
|
|
|
defaultLang=None,
|
|
|
|
)
|
|
|
|
|
2021-10-03 10:59:42 +02:00
|
|
|
profileConf: dict[str, Any] = dict(
|
2012-12-21 08:51:59 +01:00
|
|
|
# profile
|
|
|
|
mainWindowGeom=None,
|
|
|
|
mainWindowState=None,
|
2017-10-05 06:39:47 +02:00
|
|
|
numBackups=50,
|
2021-10-25 06:50:13 +02:00
|
|
|
lastOptimize=int_time(),
|
2012-12-21 08:51:59 +01:00
|
|
|
# editing
|
|
|
|
searchHistory=[],
|
|
|
|
# syncing
|
|
|
|
syncKey=None,
|
|
|
|
syncMedia=True,
|
|
|
|
autoSync=True,
|
2012-12-22 05:18:28 +01:00
|
|
|
# importing
|
|
|
|
allowHTML=False,
|
2013-11-26 10:19:54 +01:00
|
|
|
importMode=1,
|
2021-03-10 09:20:37 +01:00
|
|
|
# these are not used, but Anki 2.1.42 and below
|
|
|
|
# expect these keys to exist
|
2021-08-02 23:12:00 +02:00
|
|
|
lastColour="#00f",
|
2021-03-10 09:20:37 +01:00
|
|
|
stripHTML=True,
|
|
|
|
deleteMedia=False,
|
2012-12-21 08:51:59 +01:00
|
|
|
)
|
|
|
|
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2019-12-19 00:38:36 +01:00
|
|
|
class LoadMetaResult:
|
|
|
|
firstTime: bool
|
|
|
|
loadError: bool
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2019-12-23 01:34:10 +01:00
|
|
|
class ProfileManager:
|
2023-05-18 09:47:51 +02:00
|
|
|
default_answer_keys = {ease_num: str(ease_num) for ease_num in range(1, 5)}
|
2023-07-18 14:26:59 +02:00
|
|
|
last_run_version: int = 0
|
2023-05-18 09:47:51 +02:00
|
|
|
|
2022-12-24 01:44:40 +01:00
|
|
|
def __init__(self, base: Path) -> None: #
|
|
|
|
"base should be retrieved via ProfileMangager.get_created_base_folder"
|
2020-06-01 17:47:46 +02:00
|
|
|
## Settings which should be forgotten each Anki restart
|
2021-10-03 10:59:42 +02:00
|
|
|
self.session: dict[str, Any] = {}
|
|
|
|
self.name: str | None = None
|
|
|
|
self.db: DB | None = None
|
|
|
|
self.profile: dict | None = None
|
2023-03-02 08:13:04 +01:00
|
|
|
self.invalid_profile_provided_on_commandline = False
|
2022-12-24 01:44:40 +01:00
|
|
|
self.base = str(base)
|
2018-08-08 15:48:25 +02:00
|
|
|
|
2019-12-19 00:38:36 +01:00
|
|
|
def setupMeta(self) -> LoadMetaResult:
|
2012-12-21 08:51:59 +01:00
|
|
|
# load metadata
|
2019-12-19 00:38:36 +01:00
|
|
|
res = self._loadMeta()
|
|
|
|
self.firstRun = res.firstTime
|
2023-07-18 14:26:59 +02:00
|
|
|
self.last_run_version = self.meta.get("last_run_version", self.last_run_version)
|
|
|
|
self.meta["last_run_version"] = point_version()
|
2019-12-19 00:38:36 +01:00
|
|
|
return res
|
2018-08-08 15:48:25 +02:00
|
|
|
|
2023-03-02 08:13:04 +01:00
|
|
|
# -p profile provided on command line.
|
2021-02-02 14:30:53 +01:00
|
|
|
def openProfile(self, profile: str) -> None:
|
2023-03-02 08:13:04 +01:00
|
|
|
if profile not in self.profiles():
|
|
|
|
self.invalid_profile_provided_on_commandline = True
|
|
|
|
else:
|
2012-12-21 08:51:59 +01:00
|
|
|
try:
|
|
|
|
self.load(profile)
|
2023-03-02 08:13:04 +01:00
|
|
|
except Exception as exc:
|
|
|
|
self.invalid_profile_provided_on_commandline = True
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
# Profile load/save
|
|
|
|
######################################################################
|
|
|
|
|
2022-07-19 09:29:46 +02:00
|
|
|
def profiles(self) -> list[str]:
|
|
|
|
def names() -> list[str]:
|
2019-03-06 06:27:13 +01:00
|
|
|
return self.db.list("select name from profiles where name != '_global'")
|
|
|
|
|
|
|
|
n = names()
|
|
|
|
if not n:
|
|
|
|
self._ensureProfile()
|
|
|
|
n = names()
|
|
|
|
|
|
|
|
return n
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def _unpickle(self, data: bytes) -> Any:
|
2017-08-28 10:35:24 +02:00
|
|
|
class Unpickler(pickle.Unpickler):
|
2021-10-07 07:36:50 +02:00
|
|
|
def find_class(self, class_module: str, name: str) -> Any:
|
|
|
|
# handle sip lookup ourselves, mapping to current Qt version
|
|
|
|
if class_module == "sip" or class_module.endswith(".sip"):
|
|
|
|
|
|
|
|
def unpickle_type(module: str, klass: str, args: Any) -> Any:
|
|
|
|
if qtmajor > 5:
|
|
|
|
module = module.replace("Qt5", "Qt6")
|
|
|
|
else:
|
|
|
|
module = module.replace("Qt6", "Qt5")
|
|
|
|
if klass == "QByteArray":
|
|
|
|
if module.startswith("PyQt4"):
|
|
|
|
# can't trust str objects from python 2
|
|
|
|
return QByteArray()
|
|
|
|
else:
|
|
|
|
# return the bytes directly
|
|
|
|
return args[0]
|
|
|
|
elif name == "_unpickle_enum":
|
|
|
|
if qtmajor == 5:
|
|
|
|
return sip._unpickle_enum(module, klass, args) # type: ignore
|
|
|
|
else:
|
|
|
|
# old style enums can't be unpickled
|
|
|
|
return None
|
|
|
|
else:
|
|
|
|
return sip._unpickle_type(module, klass, args) # type: ignore
|
|
|
|
|
|
|
|
return unpickle_type
|
2021-11-06 00:32:23 +01:00
|
|
|
else:
|
|
|
|
return super().find_class(class_module, name)
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2017-08-28 10:35:24 +02:00
|
|
|
up = Unpickler(io.BytesIO(data), errors="ignore")
|
|
|
|
return up.load()
|
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def _pickle(self, obj: Any) -> bytes:
|
2021-10-03 04:08:49 +02:00
|
|
|
for key, val in obj.items():
|
|
|
|
if isinstance(val, QByteArray):
|
|
|
|
obj[key] = bytes(val) # type: ignore
|
|
|
|
|
|
|
|
return pickle.dumps(obj, protocol=4)
|
2017-08-28 10:35:24 +02:00
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def load(self, name: str) -> bool:
|
2021-11-25 08:47:50 +01:00
|
|
|
if name == "_global":
|
|
|
|
raise Exception("_global is not a valid name")
|
2019-12-23 01:34:10 +01:00
|
|
|
data = self.db.scalar(
|
|
|
|
"select cast(data as blob) from profiles where name = ?", name
|
|
|
|
)
|
2017-08-28 10:35:24 +02:00
|
|
|
self.name = name
|
|
|
|
try:
|
|
|
|
self.profile = self._unpickle(data)
|
|
|
|
except:
|
2021-11-06 00:32:23 +01:00
|
|
|
print(traceback.format_exc())
|
2018-10-29 12:06:33 +01:00
|
|
|
QMessageBox.warning(
|
2019-12-23 01:34:10 +01:00
|
|
|
None,
|
2021-03-26 04:48:26 +01:00
|
|
|
tr.profiles_profile_corrupt(),
|
|
|
|
tr.profiles_anki_could_not_read_your_profile(),
|
2019-12-23 01:34:10 +01:00
|
|
|
)
|
2017-08-28 10:35:24 +02:00
|
|
|
print("resetting corrupt profile")
|
|
|
|
self.profile = profileConf.copy()
|
|
|
|
self.save()
|
2022-07-18 05:01:36 +02:00
|
|
|
self.set_last_loaded_profile_name(name)
|
2012-12-21 08:51:59 +01:00
|
|
|
return True
|
|
|
|
|
2020-07-30 16:56:48 +02:00
|
|
|
def save(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
sql = "update profiles set data = ? where name = ?"
|
2017-08-28 10:35:24 +02:00
|
|
|
self.db.execute(sql, self._pickle(self.profile), self.name)
|
|
|
|
self.db.execute(sql, self._pickle(self.meta), "_global")
|
2012-12-21 08:51:59 +01:00
|
|
|
self.db.commit()
|
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def create(self, name: str) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
prof = profileConf.copy()
|
2019-12-23 01:34:10 +01:00
|
|
|
self.db.execute(
|
|
|
|
"insert or ignore into profiles values (?, ?)", name, self._pickle(prof)
|
|
|
|
)
|
2012-12-21 08:51:59 +01:00
|
|
|
self.db.commit()
|
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def remove(self, name: str) -> None:
|
2022-03-28 06:40:31 +02:00
|
|
|
path = self.profileFolder(create=False)
|
|
|
|
send_to_trash(Path(path))
|
2016-05-12 06:19:16 +02:00
|
|
|
self.db.execute("delete from profiles where name = ?", name)
|
2012-12-21 08:51:59 +01:00
|
|
|
self.db.commit()
|
|
|
|
|
2020-07-30 16:56:48 +02:00
|
|
|
def trashCollection(self) -> None:
|
2022-03-28 06:40:31 +02:00
|
|
|
path = self.collectionPath()
|
|
|
|
send_to_trash(Path(path))
|
2017-08-16 11:45:39 +02:00
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def rename(self, name: str) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
oldName = self.name
|
|
|
|
oldFolder = self.profileFolder()
|
|
|
|
self.name = name
|
|
|
|
newFolder = self.profileFolder(create=False)
|
|
|
|
if os.path.exists(newFolder):
|
2019-12-23 01:34:10 +01:00
|
|
|
if (oldFolder != newFolder) and (oldFolder.lower() == newFolder.lower()):
|
2014-06-18 00:51:02 +02:00
|
|
|
# OS is telling us the folder exists because it does not take
|
|
|
|
# case into account; use a temporary folder location
|
2019-12-23 01:34:10 +01:00
|
|
|
midFolder = "".join([oldFolder, "-temp"])
|
2014-06-18 00:51:02 +02:00
|
|
|
if not os.path.exists(midFolder):
|
|
|
|
os.rename(oldFolder, midFolder)
|
|
|
|
oldFolder = midFolder
|
|
|
|
else:
|
2021-03-26 05:21:04 +01:00
|
|
|
showWarning(tr.profiles_please_remove_the_folder_and(val=midFolder))
|
2014-06-18 00:51:02 +02:00
|
|
|
self.name = oldName
|
|
|
|
return
|
|
|
|
else:
|
2021-03-26 04:48:26 +01:00
|
|
|
showWarning(tr.profiles_folder_already_exists())
|
2014-06-18 00:51:02 +02:00
|
|
|
self.name = oldName
|
|
|
|
return
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
# update name
|
2019-12-23 01:34:10 +01:00
|
|
|
self.db.execute("update profiles set name = ? where name = ?", name, oldName)
|
2012-12-21 08:51:59 +01:00
|
|
|
# rename folder
|
2014-06-28 18:22:07 +02:00
|
|
|
try:
|
|
|
|
os.rename(oldFolder, newFolder)
|
2019-03-04 03:29:55 +01:00
|
|
|
except Exception as e:
|
2014-06-28 18:22:07 +02:00
|
|
|
self.db.rollback()
|
2019-02-06 00:02:56 +01:00
|
|
|
if "WinError 5" in str(e):
|
2021-03-26 04:48:26 +01:00
|
|
|
showWarning(tr.profiles_anki_could_not_rename_your_profile())
|
2014-06-28 18:22:07 +02:00
|
|
|
else:
|
|
|
|
raise
|
|
|
|
except:
|
|
|
|
self.db.rollback()
|
|
|
|
raise
|
|
|
|
else:
|
|
|
|
self.db.commit()
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
# Folder handling
|
|
|
|
######################################################################
|
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def profileFolder(self, create: bool = True) -> str:
|
2012-12-21 08:51:59 +01:00
|
|
|
path = os.path.join(self.base, self.name)
|
|
|
|
if create:
|
|
|
|
self._ensureExists(path)
|
|
|
|
return path
|
|
|
|
|
2020-07-30 16:56:48 +02:00
|
|
|
def addonFolder(self) -> str:
|
2016-05-31 10:51:40 +02:00
|
|
|
return self._ensureExists(os.path.join(self.base, "addons21"))
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2020-07-30 16:56:48 +02:00
|
|
|
def backupFolder(self) -> str:
|
2019-12-23 01:34:10 +01:00
|
|
|
return self._ensureExists(os.path.join(self.profileFolder(), "backups"))
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2020-07-30 16:56:48 +02:00
|
|
|
def collectionPath(self) -> str:
|
2012-12-21 08:51:59 +01:00
|
|
|
return os.path.join(self.profileFolder(), "collection.anki2")
|
|
|
|
|
2020-04-16 01:00:49 +02:00
|
|
|
# Downgrade
|
|
|
|
######################################################################
|
|
|
|
|
2021-10-03 10:59:42 +02:00
|
|
|
def downgrade(self, profiles: list[str]) -> list[str]:
|
2020-04-16 01:53:29 +02:00
|
|
|
"Downgrade all profiles. Return a list of profiles that couldn't be opened."
|
|
|
|
problem_profiles = []
|
2020-04-16 01:00:49 +02:00
|
|
|
for name in profiles:
|
|
|
|
path = os.path.join(self.base, name, "collection.anki2")
|
|
|
|
if not os.path.exists(path):
|
|
|
|
continue
|
|
|
|
with DB(path) as db:
|
|
|
|
if db.scalar("select ver from col") == 11:
|
|
|
|
# nothing to do
|
|
|
|
continue
|
2020-04-16 01:53:29 +02:00
|
|
|
try:
|
|
|
|
c = Collection(path)
|
|
|
|
c.close(save=False, downgrade=True)
|
|
|
|
except Exception as e:
|
|
|
|
print(e)
|
|
|
|
problem_profiles.append(name)
|
|
|
|
return problem_profiles
|
2020-04-16 01:00:49 +02:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
# Helpers
|
|
|
|
######################################################################
|
|
|
|
|
2020-07-30 16:56:48 +02:00
|
|
|
def _ensureExists(self, path: str) -> str:
|
2012-12-21 08:51:59 +01:00
|
|
|
if not os.path.exists(path):
|
|
|
|
os.makedirs(path)
|
|
|
|
return path
|
|
|
|
|
2022-12-24 01:44:40 +01:00
|
|
|
@staticmethod
|
|
|
|
def get_created_base_folder(path_override: str | None) -> Path:
|
|
|
|
"Create the base folder and return it, using provided path or default."
|
|
|
|
path = Path(
|
|
|
|
path_override
|
|
|
|
or os.environ.get("ANKI_BASE")
|
|
|
|
or ProfileManager._default_base()
|
|
|
|
)
|
|
|
|
path.mkdir(parents=True, exist_ok=True)
|
2023-01-26 10:45:53 +01:00
|
|
|
return path.resolve()
|
2017-02-15 04:41:19 +01:00
|
|
|
|
2022-12-24 01:44:40 +01:00
|
|
|
@staticmethod
|
|
|
|
def _default_base() -> str:
|
2021-11-25 00:06:16 +01:00
|
|
|
if is_win:
|
2018-08-08 15:48:25 +02:00
|
|
|
from aqt.winpaths import get_appdata
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2018-08-08 15:48:25 +02:00
|
|
|
return os.path.join(get_appdata(), "Anki2")
|
2021-11-25 00:06:16 +01:00
|
|
|
elif is_mac:
|
2017-01-13 12:14:04 +01:00
|
|
|
return os.path.expanduser("~/Library/Application Support/Anki2")
|
2012-12-21 08:51:59 +01:00
|
|
|
else:
|
2017-02-22 09:16:19 +01:00
|
|
|
dataDir = os.environ.get(
|
2019-12-23 01:34:10 +01:00
|
|
|
"XDG_DATA_HOME", os.path.expanduser("~/.local/share")
|
|
|
|
)
|
2017-02-22 09:16:19 +01:00
|
|
|
if not os.path.exists(dataDir):
|
|
|
|
os.makedirs(dataDir)
|
|
|
|
return os.path.join(dataDir, "Anki2")
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def _loadMeta(self, retrying: bool = False) -> LoadMetaResult:
|
2019-12-19 00:38:36 +01:00
|
|
|
result = LoadMetaResult()
|
|
|
|
result.firstTime = False
|
|
|
|
result.loadError = retrying
|
|
|
|
|
2017-08-28 10:35:24 +02:00
|
|
|
opath = os.path.join(self.base, "prefs.db")
|
2016-05-31 10:51:40 +02:00
|
|
|
path = os.path.join(self.base, "prefs21.db")
|
2020-01-14 05:59:46 +01:00
|
|
|
if not retrying and os.path.exists(opath) and not os.path.exists(path):
|
2017-08-28 10:35:24 +02:00
|
|
|
shutil.copy(opath, path)
|
|
|
|
|
2019-12-19 00:38:36 +01:00
|
|
|
result.firstTime = not os.path.exists(path)
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2020-07-30 16:56:48 +02:00
|
|
|
def recover() -> None:
|
2013-07-18 11:59:14 +02:00
|
|
|
# if we can't load profile, start with a new one
|
2014-07-07 06:18:00 +02:00
|
|
|
if self.db:
|
|
|
|
try:
|
|
|
|
self.db.close()
|
|
|
|
except:
|
|
|
|
pass
|
2018-03-22 05:10:23 +01:00
|
|
|
for suffix in ("", "-journal"):
|
|
|
|
fpath = path + suffix
|
|
|
|
if os.path.exists(fpath):
|
|
|
|
os.unlink(fpath)
|
2019-12-19 00:38:36 +01:00
|
|
|
|
|
|
|
# open DB file and read data
|
2013-07-18 11:59:14 +02:00
|
|
|
try:
|
2016-05-12 06:19:16 +02:00
|
|
|
self.db = DB(path)
|
2021-11-25 08:47:50 +01:00
|
|
|
if not self.db.scalar("pragma integrity_check") == "ok":
|
|
|
|
raise Exception("corrupt db")
|
2019-12-23 01:34:10 +01:00
|
|
|
self.db.execute(
|
|
|
|
"""
|
2012-12-21 08:51:59 +01:00
|
|
|
create table if not exists profiles
|
2020-12-16 06:31:24 +01:00
|
|
|
(name text primary key, data blob not null);"""
|
2019-12-23 01:34:10 +01:00
|
|
|
)
|
2017-08-28 10:35:24 +02:00
|
|
|
data = self.db.scalar(
|
2019-12-23 01:34:10 +01:00
|
|
|
"select cast(data as blob) from profiles where name = '_global'"
|
|
|
|
)
|
2013-07-18 11:59:14 +02:00
|
|
|
except:
|
2020-07-02 02:35:01 +02:00
|
|
|
traceback.print_stack()
|
2019-12-19 00:38:36 +01:00
|
|
|
if result.loadError:
|
|
|
|
# already failed, prevent infinite loop
|
|
|
|
raise
|
|
|
|
# delete files and try again
|
2013-07-18 11:59:14 +02:00
|
|
|
recover()
|
2019-12-19 00:38:36 +01:00
|
|
|
return self._loadMeta(retrying=True)
|
|
|
|
|
|
|
|
# try to read data
|
|
|
|
if not result.firstTime:
|
2013-02-15 00:59:44 +01:00
|
|
|
try:
|
2017-08-28 10:35:24 +02:00
|
|
|
self.meta = self._unpickle(data)
|
2019-12-19 00:38:36 +01:00
|
|
|
return result
|
2013-02-15 00:59:44 +01:00
|
|
|
except:
|
2020-07-02 02:35:01 +02:00
|
|
|
traceback.print_stack()
|
2017-08-28 10:35:24 +02:00
|
|
|
print("resetting corrupt _global")
|
2019-12-19 00:38:36 +01:00
|
|
|
result.loadError = True
|
2020-01-14 06:18:07 +01:00
|
|
|
result.firstTime = True
|
2019-12-19 00:38:36 +01:00
|
|
|
|
|
|
|
# if new or read failed, create a default global profile
|
2013-02-15 00:59:44 +01:00
|
|
|
self.meta = metaConf.copy()
|
2019-12-23 01:34:10 +01:00
|
|
|
self.db.execute(
|
|
|
|
"insert or replace into profiles values ('_global', ?)",
|
|
|
|
self._pickle(metaConf),
|
|
|
|
)
|
2019-12-19 00:38:36 +01:00
|
|
|
return result
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2020-07-30 16:56:48 +02:00
|
|
|
def _ensureProfile(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
"Create a new profile if none exists."
|
2021-03-26 04:48:26 +01:00
|
|
|
self.create(tr.profiles_user_1())
|
2019-03-06 06:27:13 +01:00
|
|
|
p = os.path.join(self.base, "README.txt")
|
2020-05-10 02:58:42 +02:00
|
|
|
with open(p, "w", encoding="utf8") as file:
|
|
|
|
file.write(
|
|
|
|
without_unicode_isolation(
|
2021-03-26 05:38:15 +01:00
|
|
|
tr.profiles_folder_readme(
|
2021-06-03 08:48:20 +02:00
|
|
|
link=f"{appHelpSite}files#startup-options",
|
2020-10-12 04:17:02 +02:00
|
|
|
)
|
2020-05-10 02:58:42 +02:00
|
|
|
)
|
2021-10-07 05:31:44 +02:00
|
|
|
+ "\n"
|
2019-12-23 01:34:10 +01:00
|
|
|
)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
# Default language
|
|
|
|
######################################################################
|
|
|
|
# On first run, allow the user to choose the default language
|
|
|
|
|
2020-11-21 03:16:26 +01:00
|
|
|
def setDefaultLang(self, idx: int) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
# create dialog
|
|
|
|
class NoCloseDiag(QDialog):
|
2021-02-01 14:28:21 +01:00
|
|
|
def reject(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
pass
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
d = self.langDiag = NoCloseDiag()
|
|
|
|
f = self.langForm = aqt.forms.setlang.Ui_Dialog()
|
2020-11-21 03:16:26 +01:00
|
|
|
f.setupUi(d)
|
2021-01-07 05:24:49 +01:00
|
|
|
disable_help_button(d)
|
2020-05-04 05:23:08 +02:00
|
|
|
qconnect(d.accepted, self._onLangSelected)
|
|
|
|
qconnect(d.rejected, lambda: True)
|
2012-12-21 08:51:59 +01:00
|
|
|
# update list
|
2016-04-05 03:02:01 +02:00
|
|
|
f.lang.addItems([x[0] for x in anki.lang.langs])
|
2012-12-21 08:51:59 +01:00
|
|
|
f.lang.setCurrentRow(idx)
|
2021-10-05 02:01:45 +02:00
|
|
|
d.exec()
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2020-07-30 16:56:48 +02:00
|
|
|
def _onLangSelected(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
f = self.langForm
|
2016-04-05 03:02:01 +02:00
|
|
|
obj = anki.lang.langs[f.lang.currentRow()]
|
2014-01-14 06:40:45 +01:00
|
|
|
code = obj[1]
|
|
|
|
name = obj[0]
|
|
|
|
r = QMessageBox.question(
|
2021-10-05 05:53:01 +02:00
|
|
|
None, "Anki", tr.profiles_confirm_lang_choice(lang=name), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No # type: ignore
|
2019-12-23 01:34:10 +01:00
|
|
|
)
|
2021-10-05 05:53:01 +02:00
|
|
|
if r != QMessageBox.StandardButton.Yes:
|
2020-11-21 03:16:26 +01:00
|
|
|
return self.setDefaultLang(f.lang.currentRow())
|
2016-04-05 03:02:01 +02:00
|
|
|
self.setLang(code)
|
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def setLang(self, code: str) -> None:
|
2019-12-23 01:34:10 +01:00
|
|
|
self.meta["defaultLang"] = code
|
2012-12-21 08:51:59 +01:00
|
|
|
sql = "update profiles set data = ? where name = ?"
|
2017-08-28 10:35:24 +02:00
|
|
|
self.db.execute(sql, self._pickle(self.meta), "_global")
|
2012-12-21 08:51:59 +01:00
|
|
|
self.db.commit()
|
2021-03-26 00:40:41 +01:00
|
|
|
anki.lang.set_lang(code)
|
2018-08-08 15:48:25 +02:00
|
|
|
|
|
|
|
# OpenGL
|
|
|
|
######################################################################
|
|
|
|
|
2020-12-22 04:01:06 +01:00
|
|
|
def _gldriver_path(self) -> str:
|
2022-04-06 03:34:57 +02:00
|
|
|
if qtmajor < 6:
|
|
|
|
fname = "gldriver"
|
|
|
|
else:
|
|
|
|
fname = "gldriver6"
|
|
|
|
return os.path.join(self.base, fname)
|
2018-08-08 15:48:25 +02:00
|
|
|
|
2020-12-22 04:01:06 +01:00
|
|
|
def video_driver(self) -> VideoDriver:
|
|
|
|
path = self._gldriver_path()
|
|
|
|
try:
|
2021-10-02 15:20:27 +02:00
|
|
|
with open(path, encoding="utf8") as file:
|
2020-12-22 04:01:06 +01:00
|
|
|
text = file.read().strip()
|
|
|
|
return VideoDriver(text).constrained_to_platform()
|
|
|
|
except (ValueError, OSError):
|
|
|
|
return VideoDriver.default_for_platform()
|
|
|
|
|
|
|
|
def set_video_driver(self, driver: VideoDriver) -> None:
|
2021-10-02 15:20:27 +02:00
|
|
|
with open(self._gldriver_path(), "w", encoding="utf8") as file:
|
2020-12-22 04:01:06 +01:00
|
|
|
file.write(driver.value)
|
|
|
|
|
|
|
|
def set_next_video_driver(self) -> None:
|
|
|
|
self.set_video_driver(self.video_driver().next())
|
2019-12-19 00:58:16 +01:00
|
|
|
|
2020-02-02 23:32:07 +01:00
|
|
|
# Shared options
|
2019-12-19 00:58:16 +01:00
|
|
|
######################################################################
|
|
|
|
|
|
|
|
def uiScale(self) -> float:
|
2020-02-25 07:20:14 +01:00
|
|
|
scale = self.meta.get("uiScale", 1.0)
|
|
|
|
return max(scale, 1)
|
2019-12-19 00:58:16 +01:00
|
|
|
|
|
|
|
def setUiScale(self, scale: float) -> None:
|
|
|
|
self.meta["uiScale"] = scale
|
2020-01-19 02:31:09 +01:00
|
|
|
|
2023-01-18 12:24:16 +01:00
|
|
|
def reduce_motion(self) -> bool:
|
2023-03-19 03:05:53 +01:00
|
|
|
return self.meta.get("reduce_motion", True)
|
2022-09-03 04:14:47 +02:00
|
|
|
|
2023-01-18 12:24:16 +01:00
|
|
|
def set_reduce_motion(self, on: bool) -> None:
|
|
|
|
self.meta["reduce_motion"] = on
|
|
|
|
gui_hooks.body_classes_need_update()
|
2022-09-03 04:14:47 +02:00
|
|
|
|
2023-01-18 12:24:16 +01:00
|
|
|
def minimalist_mode(self) -> bool:
|
|
|
|
return self.meta.get("minimalist_mode", False)
|
2023-01-09 05:39:31 +01:00
|
|
|
|
2023-01-18 12:24:16 +01:00
|
|
|
def set_minimalist_mode(self, on: bool) -> None:
|
|
|
|
self.meta["minimalist_mode"] = on
|
|
|
|
gui_hooks.body_classes_need_update()
|
|
|
|
|
2023-05-08 03:04:18 +02:00
|
|
|
def spacebar_rates_card(self) -> bool:
|
|
|
|
return self.meta.get("spacebar_rates_card", True)
|
|
|
|
|
|
|
|
def set_spacebar_rates_card(self, on: bool) -> None:
|
|
|
|
self.meta["spacebar_rates_card"] = on
|
|
|
|
|
2023-05-18 09:47:51 +02:00
|
|
|
def get_answer_key(self, ease: int) -> Optional[str]:
|
|
|
|
return self.meta.setdefault("answer_keys", self.default_answer_keys).get(ease)
|
|
|
|
|
|
|
|
def set_answer_key(self, ease: int, key: str):
|
|
|
|
self.meta.setdefault("answer_keys", self.default_answer_keys)[ease] = key
|
|
|
|
|
2023-01-18 12:24:16 +01:00
|
|
|
def hide_top_bar(self) -> bool:
|
|
|
|
return self.meta.get("hide_top_bar", False)
|
|
|
|
|
|
|
|
def set_hide_top_bar(self, on: bool) -> None:
|
|
|
|
self.meta["hide_top_bar"] = on
|
|
|
|
gui_hooks.body_classes_need_update()
|
|
|
|
|
|
|
|
def top_bar_hide_mode(self) -> HideMode:
|
|
|
|
return self.meta.get("top_bar_hide_mode", HideMode.FULLSCREEN)
|
|
|
|
|
|
|
|
def set_top_bar_hide_mode(self, mode: HideMode) -> None:
|
|
|
|
self.meta["top_bar_hide_mode"] = mode
|
|
|
|
gui_hooks.body_classes_need_update()
|
|
|
|
|
|
|
|
def hide_bottom_bar(self) -> bool:
|
|
|
|
return self.meta.get("hide_bottom_bar", False)
|
|
|
|
|
|
|
|
def set_hide_bottom_bar(self, on: bool) -> None:
|
|
|
|
self.meta["hide_bottom_bar"] = on
|
|
|
|
gui_hooks.body_classes_need_update()
|
|
|
|
|
|
|
|
def bottom_bar_hide_mode(self) -> HideMode:
|
|
|
|
return self.meta.get("bottom_bar_hide_mode", HideMode.FULLSCREEN)
|
|
|
|
|
|
|
|
def set_bottom_bar_hide_mode(self, mode: HideMode) -> None:
|
|
|
|
self.meta["bottom_bar_hide_mode"] = mode
|
|
|
|
gui_hooks.body_classes_need_update()
|
2023-01-09 05:39:31 +01:00
|
|
|
|
2020-01-19 02:31:09 +01:00
|
|
|
def last_addon_update_check(self) -> int:
|
|
|
|
return self.meta.get("last_addon_update_check", 0)
|
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def set_last_addon_update_check(self, secs: int) -> None:
|
2020-01-19 02:31:09 +01:00
|
|
|
self.meta["last_addon_update_check"] = secs
|
2020-01-23 06:08:10 +01:00
|
|
|
|
2022-09-26 03:06:21 +02:00
|
|
|
@deprecated(info="use theme_manager.night_mode")
|
2020-01-23 06:08:10 +01:00
|
|
|
def night_mode(self) -> bool:
|
2022-09-26 03:06:21 +02:00
|
|
|
return theme_manager.night_mode
|
2020-02-02 23:32:07 +01:00
|
|
|
|
2021-11-24 22:17:41 +01:00
|
|
|
def theme(self) -> Theme:
|
|
|
|
return Theme(self.meta.get("theme", 0))
|
|
|
|
|
|
|
|
def set_theme(self, theme: Theme) -> None:
|
|
|
|
self.meta["theme"] = theme.value
|
|
|
|
|
2023-01-18 12:24:16 +01:00
|
|
|
def set_widget_style(self, style: WidgetStyle) -> None:
|
|
|
|
self.meta["widget_style"] = style
|
|
|
|
theme_manager.apply_style()
|
2022-12-04 03:54:39 +01:00
|
|
|
|
2023-01-18 12:24:16 +01:00
|
|
|
def get_widget_style(self) -> WidgetStyle:
|
|
|
|
return self.meta.get(
|
|
|
|
"widget_style", WidgetStyle.NATIVE if is_mac else WidgetStyle.ANKI
|
|
|
|
)
|
2022-12-04 03:54:39 +01:00
|
|
|
|
2022-09-20 04:56:59 +02:00
|
|
|
def browser_layout(self) -> BrowserLayout:
|
|
|
|
from aqt.browser.layout import BrowserLayout
|
|
|
|
|
|
|
|
return BrowserLayout(self.meta.get("browser_layout", "auto"))
|
|
|
|
|
|
|
|
def set_browser_layout(self, layout: BrowserLayout) -> None:
|
|
|
|
self.meta["browser_layout"] = layout.value
|
|
|
|
|
2022-09-28 06:02:32 +02:00
|
|
|
def editor_key(self, mode: EditorMode) -> str:
|
|
|
|
from aqt.editor import EditorMode
|
|
|
|
|
|
|
|
return {
|
|
|
|
EditorMode.ADD_CARDS: "add",
|
|
|
|
EditorMode.BROWSER: "browser",
|
|
|
|
EditorMode.EDIT_CURRENT: "current",
|
|
|
|
}[mode]
|
|
|
|
|
|
|
|
def tags_collapsed(self, mode: EditorMode) -> bool:
|
|
|
|
return self.meta.get(f"{self.editor_key(mode)}TagsCollapsed", False)
|
|
|
|
|
|
|
|
def set_tags_collapsed(self, mode: EditorMode, collapsed: bool) -> None:
|
|
|
|
self.meta[f"{self.editor_key(mode)}TagsCollapsed"] = collapsed
|
|
|
|
|
2022-07-18 05:31:24 +02:00
|
|
|
def legacy_import_export(self) -> bool:
|
|
|
|
return self.meta.get("legacy_import", False)
|
Add apkg import/export on backend (#1743)
* Add apkg export on backend
* Filter out missing media-paths at write time
* Make TagMatcher::new() infallible
* Gather export data instead of copying directly
* Revert changes to rslib/src/tags/
* Reuse filename_is_safe/check_filename_safe()
* Accept func to produce MediaIter in export_apkg()
* Only store file folder once in MediaIter
* Use temporary tables for gathering
export_apkg() now accepts a search instead of a deck id. Decks are
gathered according to the matched notes' cards.
* Use schedule_as_new() to reset cards
* ExportData → ExchangeData
* Ignore ascii case when filtering system tags
* search_notes_cards_into_table →
search_cards_of_notes_into_table
* Start on apkg importing on backend
* Fix due dates in days for apkg export
* Refactor import-export/package
- Move media and meta code into appropriate modules.
- Normalize/check for normalization when deserializing media entries.
* Add SafeMediaEntry for deserialized MediaEntries
* Prepare media based on checksums
- Ensure all existing media files are hashed.
- Hash incoming files during preparation to detect conflicts.
- Uniquify names of conflicting files with hash (not notetype id).
- Mark media files as used while importing notes.
- Finally copy used media.
* Handle encoding in `replace_media_refs()`
* Add trait to keep down cow boilerplate
* Add notetypes immediately instaed of preparing
* Move target_col into Context
* Add notes immediately instaed of preparing
* Note id, not guid of conflicting notes
* Add import_decks()
* decks_configs → deck_configs
* Add import_deck_configs()
* Add import_cards(), import_revlog()
* Use dyn instead of generic for media_fn
Otherwise, would have to pass None with type annotation in the default
case.
* Fix signature of import_apkg()
* Fix search_cards_of_notes_into_table()
* Test new functions in text.rs
* Add roundtrip test for apkg (stub)
* Keep source id of imported cards (or skip)
* Keep source ids of imported revlog (or skip)
* Try to keep source ids of imported notes
* Make adding notetype with id undoable
* Wrap apkg import in transaction
* Keep source ids of imported deck configs (or skip)
* Handle card due dates and original due/did
* Fix importing cards/revlog
Card ids are manually uniquified.
* Factor out card importing
* Refactor card and revlog importing
* Factor out card importing
Also handle missing parents .
* Factor out note importing
* Factor out media importing
* Maybe upgrade scheduler of apkg
* Fix parent deck gathering
* Unconditionally import static media
* Fix deck importing edge cases
Test those edge cases, and add some global test helpers.
* Test note importing
* Let import_apkg() take a progress func
* Expand roundtrip apkg test
* Use fat pointer to avoid propogating generics
* Fix progress_fn type
* Expose apkg export/import on backend
* Return note log when importing apkg
* Fix archived collection name on apkg import
* Add CollectionOpWithBackendProgress
* Fix wrong Interrupted Exception being checked
* Add ClosedCollectionOp
* Add note ids to log and strip HTML
* Update progress when checking incoming media too
* Conditionally enable new importing in GUI
* Fix all_checksums() for media import
Entries of deleted files are nulled, not removed.
* Make apkg exporting on backend abortable
* Return number of notes imported from apkg
* Fix exception printing for QueryOp as well
* Add QueryOpWithBackendProgress
Also support backend exporting progress.
* Expose new apkg and colpkg exporting
* Open transaction in insert_data()
Was slowing down exporting by several orders of magnitude.
* Handle zstd-compressed apkg
* Add legacy arg to ExportAnkiPackage
Currently not exposed on the frontend
* Remove unused import in proto file
* Add symlink for typechecking of import_export_pb2
* Avoid kwargs in pb message creation, so typechecking is not lost
Protobuf's behaviour is rather subtle and I had to dig through the docs
to figure it out: set a field on a submessage to automatically assign
the submessage to the parent, or call SetInParent() to persist a default
version of the field you specified.
* Avoid re-exporting protobuf msgs we only use internally
* Stop after one test failure
mypy often fails much faster than pylint
* Avoid an extra allocation when extracting media checksums
* Update progress after prepare_media() finishes
Otherwise the bulk of the import ends up being shown as "Checked: 0"
in the progress window.
* Show progress of note imports
Note import is the slowest part, so showing progress here makes the UI
feel more responsive.
* Reset filtered decks at import time
Before this change, filtered decks exported with scheduling remained
filtered on import, and maybe_remove_from_filtered_deck() moved cards
into them as their home deck, leading to errors during review.
We may still want to provide a way to preserve filtered decks on import,
but to do that we'll need to ensure we don't rewrite the home decks of
cards, and we'll need to ensure the home decks are included as part of
the import (or give an error if they're not).
https://github.com/ankitects/anki/pull/1743/files#r839346423
* Fix a corner-case where due dates were shifted by a day
This issue existed in the old Python code as well. We need to include
the user's UTC offset in the exported file, or days_elapsed falls back
on the v1 cutoff calculation, which may be a day earlier or later than
the v2 calculation.
* Log conflicting note in remapped nt case
* take_fields() → into_fields()
* Alias `[u8; 20]` with `Sha1Hash`
* Truncate logged fields
* Rework apkg note import tests
- Use macros for more helpful errors.
- Split monolith into unit tests.
- Fix some unknown error with the previous test along the way.
(Was failing after 969484de4388d225c9f17d94534b3ba0094c3568.)
* Fix sorting of imported decks
Also adjust the test, so it fails without the patch. It was only passing
before, because the parent deck happened to come before the
inconsistently capitalised child alphabetically. But we want all parent
decks to be imported before their child decks, so their children can
adopt their capitalisation.
* target[_id]s → existing_card[_id]s
* export_collection_extracting_media() → ...
export_into_collection_file()
* target_already_exists→card_ordinal_already_exists
* Add search_cards_of_notes_into_table.sql
* Imrove type of apkg export selector/limit
* Remove redundant call to mod_schema()
* Parent tooltips to mw
* Fix a crash when truncating note text
String::truncate() is a bit of a footgun, and I've hit this before
too :-)
* Remove ExportLimit in favour of separate classes
* Remove OpWithBackendProgress and ClosedCollectionOp
Backend progress logic is now in ProgressManager. QueryOp can be used
for running on closed collection.
Also fix aborting of colpkg exports, which slipped through in #1817.
* Tidy up import log
* Avoid QDialog.exec()
* Default to excluding scheuling for deck list deck
* Use IncrementalProgress in whole import_export code
* Compare checksums when importing colpkgs
* Avoid registering changes if hashes are not needed
* ImportProgress::Collection → ImportProgress::File
* Make downgrading apkgs depend on meta version
* Generalise IncrementableProgress
And use it in entire import_export code instead.
* Fix type complexity lint
* Take count_map for IncrementableProgress::get_inner
* Replace import/export env with Shift click
* Accept all args from update() for backend progress
* Pass fields of ProgressUpdate explicitly
* Move update_interval into IncrementableProgress
* Outsource incrementing into Incrementor
* Mutate ProgressUpdate in progress_update callback
* Switch import/export legacy toggle to profile setting
Shift would have been nice, but the existing shortcuts complicate things.
If the user triggers an import with ctrl+shift+i, shift is unlikely to
have been released by the time our code runs, meaning the user accidentally
triggers the new code. We could potentially wait a while before bringing
up the dialog, but then we're forced to guess at how long it will take the
user to release the key.
One alternative would be to use alt instead of shift, but then we need to
trigger our shortcut when that key is pressed as well, and it could
potentially cause a conflict with an add-on that already uses that
combination.
* Show extension in export dialog
* Continue to provide separate options for schema 11+18 colpkg export
* Default to colpkg export when using File>Export
* Improve appearance of combo boxes when switching between apkg/colpkg
+ Deal with long deck names
* Convert newlines to spaces when showing fields from import
Ensures each imported note appears on a separate line
* Don't separate total note count from the other summary lines
This may come down to personal preference, but I feel the other counts
are equally as important, and separating them feels like it makes it
a bit easier to ignore them.
* Fix 'deck not normal' error when importing a filtered deck for the 2nd time
* Fix [Identical] being shown on first import
* Revert "Continue to provide separate options for schema 11+18 colpkg export"
This reverts commit 8f0b2c175f4794d642823b60414d142a12768441.
Will use a different approach
* Move legacy support into a separate exporter option; add to apkg export
* Adjust 'too new' message to also apply to .apkg import case
* Show a better message when attempting to import new apkg into old code
Previously the user could end seeing a message like:
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb5 in position 1: invalid start byte
Unfortunately we can't retroactively fix this for older clients.
* Hide legacy support option in older exporting screen
* Reflect change from paths to fnames in type & name
* Make imported decks normal at once
Then skip special casing in update_deck(). Also skip updating
description if new one is empty.
Co-authored-by: Damien Elmes <gpg@ankiweb.net>
2022-05-02 13:12:46 +02:00
|
|
|
|
2022-07-18 05:31:24 +02:00
|
|
|
def set_legacy_import_export(self, enabled: bool) -> None:
|
2022-07-18 20:23:21 +02:00
|
|
|
self.meta["legacy_import"] = enabled
|
Add apkg import/export on backend (#1743)
* Add apkg export on backend
* Filter out missing media-paths at write time
* Make TagMatcher::new() infallible
* Gather export data instead of copying directly
* Revert changes to rslib/src/tags/
* Reuse filename_is_safe/check_filename_safe()
* Accept func to produce MediaIter in export_apkg()
* Only store file folder once in MediaIter
* Use temporary tables for gathering
export_apkg() now accepts a search instead of a deck id. Decks are
gathered according to the matched notes' cards.
* Use schedule_as_new() to reset cards
* ExportData → ExchangeData
* Ignore ascii case when filtering system tags
* search_notes_cards_into_table →
search_cards_of_notes_into_table
* Start on apkg importing on backend
* Fix due dates in days for apkg export
* Refactor import-export/package
- Move media and meta code into appropriate modules.
- Normalize/check for normalization when deserializing media entries.
* Add SafeMediaEntry for deserialized MediaEntries
* Prepare media based on checksums
- Ensure all existing media files are hashed.
- Hash incoming files during preparation to detect conflicts.
- Uniquify names of conflicting files with hash (not notetype id).
- Mark media files as used while importing notes.
- Finally copy used media.
* Handle encoding in `replace_media_refs()`
* Add trait to keep down cow boilerplate
* Add notetypes immediately instaed of preparing
* Move target_col into Context
* Add notes immediately instaed of preparing
* Note id, not guid of conflicting notes
* Add import_decks()
* decks_configs → deck_configs
* Add import_deck_configs()
* Add import_cards(), import_revlog()
* Use dyn instead of generic for media_fn
Otherwise, would have to pass None with type annotation in the default
case.
* Fix signature of import_apkg()
* Fix search_cards_of_notes_into_table()
* Test new functions in text.rs
* Add roundtrip test for apkg (stub)
* Keep source id of imported cards (or skip)
* Keep source ids of imported revlog (or skip)
* Try to keep source ids of imported notes
* Make adding notetype with id undoable
* Wrap apkg import in transaction
* Keep source ids of imported deck configs (or skip)
* Handle card due dates and original due/did
* Fix importing cards/revlog
Card ids are manually uniquified.
* Factor out card importing
* Refactor card and revlog importing
* Factor out card importing
Also handle missing parents .
* Factor out note importing
* Factor out media importing
* Maybe upgrade scheduler of apkg
* Fix parent deck gathering
* Unconditionally import static media
* Fix deck importing edge cases
Test those edge cases, and add some global test helpers.
* Test note importing
* Let import_apkg() take a progress func
* Expand roundtrip apkg test
* Use fat pointer to avoid propogating generics
* Fix progress_fn type
* Expose apkg export/import on backend
* Return note log when importing apkg
* Fix archived collection name on apkg import
* Add CollectionOpWithBackendProgress
* Fix wrong Interrupted Exception being checked
* Add ClosedCollectionOp
* Add note ids to log and strip HTML
* Update progress when checking incoming media too
* Conditionally enable new importing in GUI
* Fix all_checksums() for media import
Entries of deleted files are nulled, not removed.
* Make apkg exporting on backend abortable
* Return number of notes imported from apkg
* Fix exception printing for QueryOp as well
* Add QueryOpWithBackendProgress
Also support backend exporting progress.
* Expose new apkg and colpkg exporting
* Open transaction in insert_data()
Was slowing down exporting by several orders of magnitude.
* Handle zstd-compressed apkg
* Add legacy arg to ExportAnkiPackage
Currently not exposed on the frontend
* Remove unused import in proto file
* Add symlink for typechecking of import_export_pb2
* Avoid kwargs in pb message creation, so typechecking is not lost
Protobuf's behaviour is rather subtle and I had to dig through the docs
to figure it out: set a field on a submessage to automatically assign
the submessage to the parent, or call SetInParent() to persist a default
version of the field you specified.
* Avoid re-exporting protobuf msgs we only use internally
* Stop after one test failure
mypy often fails much faster than pylint
* Avoid an extra allocation when extracting media checksums
* Update progress after prepare_media() finishes
Otherwise the bulk of the import ends up being shown as "Checked: 0"
in the progress window.
* Show progress of note imports
Note import is the slowest part, so showing progress here makes the UI
feel more responsive.
* Reset filtered decks at import time
Before this change, filtered decks exported with scheduling remained
filtered on import, and maybe_remove_from_filtered_deck() moved cards
into them as their home deck, leading to errors during review.
We may still want to provide a way to preserve filtered decks on import,
but to do that we'll need to ensure we don't rewrite the home decks of
cards, and we'll need to ensure the home decks are included as part of
the import (or give an error if they're not).
https://github.com/ankitects/anki/pull/1743/files#r839346423
* Fix a corner-case where due dates were shifted by a day
This issue existed in the old Python code as well. We need to include
the user's UTC offset in the exported file, or days_elapsed falls back
on the v1 cutoff calculation, which may be a day earlier or later than
the v2 calculation.
* Log conflicting note in remapped nt case
* take_fields() → into_fields()
* Alias `[u8; 20]` with `Sha1Hash`
* Truncate logged fields
* Rework apkg note import tests
- Use macros for more helpful errors.
- Split monolith into unit tests.
- Fix some unknown error with the previous test along the way.
(Was failing after 969484de4388d225c9f17d94534b3ba0094c3568.)
* Fix sorting of imported decks
Also adjust the test, so it fails without the patch. It was only passing
before, because the parent deck happened to come before the
inconsistently capitalised child alphabetically. But we want all parent
decks to be imported before their child decks, so their children can
adopt their capitalisation.
* target[_id]s → existing_card[_id]s
* export_collection_extracting_media() → ...
export_into_collection_file()
* target_already_exists→card_ordinal_already_exists
* Add search_cards_of_notes_into_table.sql
* Imrove type of apkg export selector/limit
* Remove redundant call to mod_schema()
* Parent tooltips to mw
* Fix a crash when truncating note text
String::truncate() is a bit of a footgun, and I've hit this before
too :-)
* Remove ExportLimit in favour of separate classes
* Remove OpWithBackendProgress and ClosedCollectionOp
Backend progress logic is now in ProgressManager. QueryOp can be used
for running on closed collection.
Also fix aborting of colpkg exports, which slipped through in #1817.
* Tidy up import log
* Avoid QDialog.exec()
* Default to excluding scheuling for deck list deck
* Use IncrementalProgress in whole import_export code
* Compare checksums when importing colpkgs
* Avoid registering changes if hashes are not needed
* ImportProgress::Collection → ImportProgress::File
* Make downgrading apkgs depend on meta version
* Generalise IncrementableProgress
And use it in entire import_export code instead.
* Fix type complexity lint
* Take count_map for IncrementableProgress::get_inner
* Replace import/export env with Shift click
* Accept all args from update() for backend progress
* Pass fields of ProgressUpdate explicitly
* Move update_interval into IncrementableProgress
* Outsource incrementing into Incrementor
* Mutate ProgressUpdate in progress_update callback
* Switch import/export legacy toggle to profile setting
Shift would have been nice, but the existing shortcuts complicate things.
If the user triggers an import with ctrl+shift+i, shift is unlikely to
have been released by the time our code runs, meaning the user accidentally
triggers the new code. We could potentially wait a while before bringing
up the dialog, but then we're forced to guess at how long it will take the
user to release the key.
One alternative would be to use alt instead of shift, but then we need to
trigger our shortcut when that key is pressed as well, and it could
potentially cause a conflict with an add-on that already uses that
combination.
* Show extension in export dialog
* Continue to provide separate options for schema 11+18 colpkg export
* Default to colpkg export when using File>Export
* Improve appearance of combo boxes when switching between apkg/colpkg
+ Deal with long deck names
* Convert newlines to spaces when showing fields from import
Ensures each imported note appears on a separate line
* Don't separate total note count from the other summary lines
This may come down to personal preference, but I feel the other counts
are equally as important, and separating them feels like it makes it
a bit easier to ignore them.
* Fix 'deck not normal' error when importing a filtered deck for the 2nd time
* Fix [Identical] being shown on first import
* Revert "Continue to provide separate options for schema 11+18 colpkg export"
This reverts commit 8f0b2c175f4794d642823b60414d142a12768441.
Will use a different approach
* Move legacy support into a separate exporter option; add to apkg export
* Adjust 'too new' message to also apply to .apkg import case
* Show a better message when attempting to import new apkg into old code
Previously the user could end seeing a message like:
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb5 in position 1: invalid start byte
Unfortunately we can't retroactively fix this for older clients.
* Hide legacy support option in older exporting screen
* Reflect change from paths to fnames in type & name
* Make imported decks normal at once
Then skip special casing in update_deck(). Also skip updating
description if new one is empty.
Co-authored-by: Damien Elmes <gpg@ankiweb.net>
2022-05-02 13:12:46 +02:00
|
|
|
|
2022-07-18 05:01:36 +02:00
|
|
|
def last_loaded_profile_name(self) -> str | None:
|
|
|
|
return self.meta.get("last_loaded_profile_name")
|
|
|
|
|
|
|
|
def set_last_loaded_profile_name(self, name: str) -> None:
|
|
|
|
self.meta["last_loaded_profile_name"] = name
|
|
|
|
|
2020-02-04 00:07:15 +01:00
|
|
|
# Profile-specific
|
2020-02-02 23:32:07 +01:00
|
|
|
######################################################################
|
|
|
|
|
2021-10-03 10:59:42 +02:00
|
|
|
def set_sync_key(self, val: str | None) -> None:
|
2020-02-04 03:26:10 +01:00
|
|
|
self.profile["syncKey"] = val
|
|
|
|
|
2021-10-03 10:59:42 +02:00
|
|
|
def set_sync_username(self, val: str | None) -> None:
|
2020-05-30 04:28:22 +02:00
|
|
|
self.profile["syncUser"] = val
|
|
|
|
|
2021-10-03 10:59:42 +02:00
|
|
|
def set_host_number(self, val: int | None) -> None:
|
2020-05-30 04:28:22 +02:00
|
|
|
self.profile["hostNum"] = val or 0
|
|
|
|
|
2020-02-04 03:26:10 +01:00
|
|
|
def media_syncing_enabled(self) -> bool:
|
2023-04-26 11:11:48 +02:00
|
|
|
return self.profile.get("syncMedia", True)
|
2020-02-04 03:26:10 +01:00
|
|
|
|
2020-05-31 02:53:54 +02:00
|
|
|
def auto_syncing_enabled(self) -> bool:
|
2023-04-26 11:11:48 +02:00
|
|
|
return self.profile.get("autoSync", True)
|
2020-05-31 02:53:54 +02:00
|
|
|
|
2021-10-03 10:59:42 +02:00
|
|
|
def sync_auth(self) -> SyncAuth | None:
|
Rework syncing code, and replace local sync server (#2329)
This PR replaces the existing Python-driven sync server with a new one in Rust.
The new server supports both collection and media syncing, and is compatible
with both the new protocol mentioned below, and older clients. A setting has
been added to the preferences screen to point Anki to a local server, and a
similar setting is likely to come to AnkiMobile soon.
Documentation is available here: <https://docs.ankiweb.net/sync-server.html>
In addition to the new server and refactoring, this PR also makes changes to the
sync protocol. The existing sync protocol places payloads and metadata inside a
multipart POST body, which causes a few headaches:
- Legacy clients build the request in a non-deterministic order, meaning the
entire request needs to be scanned to extract the metadata.
- Reqwest's multipart API directly writes the multipart body, without exposing
the resulting stream to us, making it harder to track the progress of the
transfer. We've been relying on a patched version of reqwest for timeouts,
which is a pain to keep up to date.
To address these issues, the metadata is now sent in a HTTP header, with the
data payload sent directly in the body. Instead of the slower gzip, we now
use zstd. The old timeout handling code has been replaced with a new implementation
that wraps the request and response body streams to track progress, allowing us
to drop the git dependencies for reqwest, hyper-timeout and tokio-io-timeout.
The main other change to the protocol is that one-way syncs no longer need to
downgrade the collection to schema 11 prior to sending.
2023-01-18 03:43:46 +01:00
|
|
|
if not (hkey := self.profile.get("syncKey")):
|
2020-05-30 04:28:22 +02:00
|
|
|
return None
|
2023-02-08 05:32:37 +01:00
|
|
|
return SyncAuth(
|
|
|
|
hkey=hkey,
|
|
|
|
endpoint=self.sync_endpoint(),
|
|
|
|
io_timeout_secs=self.network_timeout(),
|
|
|
|
)
|
2020-02-04 03:46:57 +01:00
|
|
|
|
2020-05-31 03:46:40 +02:00
|
|
|
def clear_sync_auth(self) -> None:
|
Rework syncing code, and replace local sync server (#2329)
This PR replaces the existing Python-driven sync server with a new one in Rust.
The new server supports both collection and media syncing, and is compatible
with both the new protocol mentioned below, and older clients. A setting has
been added to the preferences screen to point Anki to a local server, and a
similar setting is likely to come to AnkiMobile soon.
Documentation is available here: <https://docs.ankiweb.net/sync-server.html>
In addition to the new server and refactoring, this PR also makes changes to the
sync protocol. The existing sync protocol places payloads and metadata inside a
multipart POST body, which causes a few headaches:
- Legacy clients build the request in a non-deterministic order, meaning the
entire request needs to be scanned to extract the metadata.
- Reqwest's multipart API directly writes the multipart body, without exposing
the resulting stream to us, making it harder to track the progress of the
transfer. We've been relying on a patched version of reqwest for timeouts,
which is a pain to keep up to date.
To address these issues, the metadata is now sent in a HTTP header, with the
data payload sent directly in the body. Instead of the slower gzip, we now
use zstd. The old timeout handling code has been replaced with a new implementation
that wraps the request and response body streams to track progress, allowing us
to drop the git dependencies for reqwest, hyper-timeout and tokio-io-timeout.
The main other change to the protocol is that one-way syncs no longer need to
downgrade the collection to schema 11 prior to sending.
2023-01-18 03:43:46 +01:00
|
|
|
self.set_sync_key(None)
|
|
|
|
self.set_sync_username(None)
|
|
|
|
self.set_host_number(None)
|
|
|
|
self.set_current_sync_url(None)
|
|
|
|
|
|
|
|
def sync_endpoint(self) -> str | None:
|
|
|
|
return self._current_sync_url() or self.custom_sync_url() or None
|
|
|
|
|
|
|
|
def _current_sync_url(self) -> str | None:
|
|
|
|
"""The last endpoint the server redirected us to."""
|
|
|
|
return self.profile.get("currentSyncUrl")
|
|
|
|
|
|
|
|
def set_current_sync_url(self, url: str | None) -> None:
|
|
|
|
self.profile["currentSyncUrl"] = url
|
|
|
|
|
|
|
|
def custom_sync_url(self) -> str | None:
|
|
|
|
"""A custom server provided by the user."""
|
|
|
|
return self.profile.get("customSyncUrl")
|
|
|
|
|
|
|
|
def set_custom_sync_url(self, url: str | None) -> None:
|
2023-01-19 09:54:46 +01:00
|
|
|
if url != self.custom_sync_url():
|
|
|
|
self.set_current_sync_url(None)
|
|
|
|
self.profile["customSyncUrl"] = url
|
2020-05-31 03:46:40 +02:00
|
|
|
|
2020-07-01 03:35:24 +02:00
|
|
|
def auto_sync_media_minutes(self) -> int:
|
|
|
|
return self.profile.get("autoSyncMediaMinutes", 15)
|
|
|
|
|
2020-07-30 16:56:48 +02:00
|
|
|
def set_auto_sync_media_minutes(self, val: int) -> None:
|
2020-07-01 03:35:24 +02:00
|
|
|
self.profile["autoSyncMediaMinutes"] = val
|
|
|
|
|
2021-09-21 07:03:37 +02:00
|
|
|
def show_browser_table_tooltips(self) -> bool:
|
|
|
|
return self.profile.get("browserTableTooltips", True)
|
|
|
|
|
|
|
|
def set_show_browser_table_tooltips(self, val: bool) -> None:
|
|
|
|
self.profile["browserTableTooltips"] = val
|
2023-02-08 05:32:37 +01:00
|
|
|
|
|
|
|
def set_network_timeout(self, timeout_secs: int) -> None:
|
|
|
|
self.profile["networkTimeout"] = timeout_secs
|
|
|
|
|
|
|
|
def network_timeout(self) -> int:
|
|
|
|
return self.profile.get("networkTimeout") or 30
|