def backup_db( db_path: Union[str, pathlib.PurePath], backup_folder: Union[str, pathlib.PurePath] = None, ) -> pathlib.Path: """ Back up database file. Args: db_path: Path to database backup_folder: Path to backup folder. If None is given, the backup is created in the Anki backup directory. Returns: Path to newly created backup file as :class:`pathlib.Path`. """ db_path = pathlib.Path(db_path) if backup_folder: backup_folder = pathlib.Path(backup_folder) if not backup_folder.is_dir(): log.debug("Creating backup directory %s.", backup_folder) backup_folder.mkdir(parents=True) else: backup_folder = get_anki_backup_folder(db_path, nexist="raise") if not db_path.is_file(): raise FileNotFoundError("Database does not seem to exist.") backup_path = backup_folder / db_backup_file_name() shutil.copy2(str(db_path), str(backup_path)) return backup_path
def _find_db( search_path, maxdepth=6, filename="collection.anki2", break_on_first=False, user: Optional[str] = None, ) -> DefaultDict[str, List[Path]]: """ Like find_database but only for one search path at a time. Also doesn't raise any error, even if the search path doesn't exist. Args: search_path: maxdepth: Maximum depth relative to search_path filename: break_on_first: Break on first search result user: Only search for this user Returns: collection.defaultdict({user: [list of results]}) """ search_path = Path(search_path) if not search_path.exists(): log.debug("_find_db: Search path %r does not exist.", str(search_path)) return collections.defaultdict(list) if search_path.is_file(): if search_path.name == filename: return collections.defaultdict( list, {search_path.parent.name: [search_path]} ) else: log.warning( "_find_db: Search path %r is a file, but filename does not " "match that of %r.", str(search_path), filename, ) return collections.defaultdict(list) found = collections.defaultdict(list) # type: DefaultDict[str, List[Path]] for root, dirs, files in os.walk(str(search_path)): if filename in files: _user = os.path.basename(root) if user and not _user == user: continue found[_user].append(Path(root) / filename) if break_on_first: log.debug("_find_db: Breaking after first hit.") break depth = len(Path(root).relative_to(search_path).parts) if maxdepth and depth >= maxdepth: # log.debug( # "_find_db: Abort search at %r. " # "Max depth exceeded.", # str(root) # ) del dirs[:] return found
def _sync_metadata(df_ret: pd.DataFrame, df_old: pd.DataFrame) -> None: """ If the df_old has a `_metadata` field, containing a list of attribute names that contain metadata, then this is copied from `df_old` to the new dataframe `df_ret. Args: df_ret: df_old: Returns: None """ if hasattr(df_old, "_metadata"): for key in df_old._metadata: value = getattr(df_old, key) log.debug("Setting metadata attribute %s to %s", key, value) setattr(df_ret, key, value)
def _prepare_write_data( self, modify=False, add=False, delete=False ) -> Dict[str, Any]: prepared = {} for key, value in self.__items.items(): if value is None: log.debug("Write: Skipping {}, because it's None.".format(key)) continue if key in ["notes", "cards", "revs"]: if not delete: ndeleted = len(value.was_deleted()) if ndeleted: raise ValueError( "You specified delete=False, but {} rows of item " "{} would be deleted.".format(ndeleted, key) ) if not modify: nmodified = sum(value.was_modified(na=False)) if nmodified: raise ValueError( "You specified modify=False, but {} rows of item " "{} would be modified.".format(nmodified, key) ) if not add: nadded = sum(value.was_added()) if nadded: raise ValueError( "You specified add=False, but {} rows of item " "{} would be modified.".format(nadded, key) ) mode = "replace" if modify and not add and not delete: mode = "update" if add and not modify and not delete: mode = "append" value._check_table_integrity() raw_table = value.raw() prepared[key] = {"raw": raw_table, "mode": mode} return prepared
def db_path_input(path: Union[str, PurePath] = None, user: str = None) -> Path: """Helper function to interpret user input of path to database. 1. If no path is given, we search through some default locations 2. If path points to a file: Take that file 3. If path points to a directory: Search in that directory Args: path: Path to database or search path or None user: User name of anki collection or None Returns: Path to anki database as :class:`Path` object Raises: If path does not exist: :class:`FileNotFoundError` In various other cases: :class:`ValueError` """ if path is None: result = find_db(user=user) else: path = Path(path) if not path.exists(): raise FileNotFoundError( "db_path_input: File '{}' does not exist.".format(str(path)) ) if path.is_file(): log.debug( "db_path_input: Database explicitly set to %r.", str(path) ) result = path else: result = find_db( search_paths=(path,), user=user, break_on_first=False ) log.info("Database found at %r.", str(result)) if result: return result else: raise ValueError("Database could not be found.")
def _prepare_write_data(self, modify=False, add=False, delete=False) -> Dict[str, Any]: prepared = {} for key, value in self.__items.items(): if value is None: log.debug("Write: Skipping %s, because it's None.", key) continue if key in ["notes", "cards", "revs"]: ndeleted = len(value.was_deleted()) nmodified = sum(value.was_modified(na=False)) nadded = sum(value.was_added()) if not delete and ndeleted: raise ValueError( "You specified delete=False, but {} rows of item " "{} would be deleted.".format(ndeleted, key)) if not modify and nmodified: raise ValueError( "You specified modify=False, but {} rows of item " "{} would be modified.".format(nmodified, key)) if not add and nadded: raise ValueError( "You specified add=False, but {} rows of item " "{} would be modified.".format(nadded, key)) if not ndeleted and not nmodified and not nadded: log.debug( "Skipping table %s for writing, because nothing " "seemed to have changed", key, ) continue mode = "replace" if modify and not add and not delete: mode = "update" if add and not modify and not delete: mode = "append" log.debug("Will update table %s with mode %s", key, mode) value.check_table_integrity() raw_table = value.raw() prepared[key] = {"raw": raw_table, "mode": mode} return prepared
def _find_db( search_path, maxdepth=6, filename="collection.anki2", break_on_first=False, user=None, ): """ Like find_database but only for one search path at a time. Also doesn't raise any error, even if the search path doesn't exist. Returns: collection.defaultdict({user: [list of results]}) """ search_path = pathlib.Path(search_path) if not search_path.exists(): log.debug("_find_db: Search path '{}' does not " "exist.".format(str(search_path))) return collections.defaultdict(list) if search_path.is_file(): if search_path.name == filename: return collections.defaultdict( list, {search_path.parent.name: [search_path]}) else: log.warning( "_find_db: Search path '{}' is a file, but filename does not " "match that of '{}'.".format(str(search_path), filename)) return collections.defaultdict(list) found = collections.defaultdict(list) for root, dirs, files in os.walk(str(search_path)): if filename in files: _user = os.path.basename(root) if user and not _user == user: continue found[_user].append(pathlib.Path(root) / filename) if break_on_first: log.debug("_find_db: Breaking after first hit.") break if maxdepth and root.count(os.sep) >= maxdepth: log.debug("_find_db: Abort search at '{}'. " "Max depth exceeded.".format(str(root))) del dirs[:] return found
def find_db( search_paths=None, maxdepth=8, filename="collection.anki2", user=None, break_on_first=True, ) -> pathlib.Path: """ Find path to anki2 database. Args: search_paths: Search path as string or pathlib object or list/iterable thereof. If None, some search paths are set by default. maxdepth: Maximal search depth. filename: Filename of the collection (default: ``collections.anki2``) user: Username to which the collection belongs. If None, search for databases of any user. break_on_first: Stop searching once a database is found. This is obviously faster, but you will not get any errors if there are multiple databases matching your criteria. Raises: If none ore more than one result is found: :class:`ValueError` Returns: pathlib.Path to the anki2 database """ if not search_paths: log.info("Searching for database. This might take some time. " "You can speed this up by specifying a search path or " "directly entering the path to your database.") search_paths = [ "~/.local/share/Anki2/", "~/Documents/Anki2", pathlib.Path(os.getenv("APPDATA", "~") + "/Anki2/"), "~/.local/share/Anki2", pathlib.Path.home(), ] search_paths = [ pathlib.Path(sp).expanduser().resolve() for sp in search_paths ] if break_on_first: log.warning( "The search will stop at the first hit, so please verify that " "the result is correct (for example in case there might be more " "than one Anki installation)") if isinstance(search_paths, (str, pathlib.PurePath)): search_paths = [search_paths] found = {} for search_path in search_paths: found = { **found, **_find_db( search_path, maxdepth=maxdepth, filename=filename, user=user, break_on_first=break_on_first, ), } if break_on_first: if user is not None: if user in found: break else: if found: break if user: if user not in found: raise ValueError( f"Could not find database belonging to user {user}") found = found[user] else: if len(found) >= 2: raise ValueError( "Found databases for more than one user: {}. Please specify " "the user.".format(", ".join(found))) elif not found: raise ValueError( "No database found. You might increase the search depth or " "specify search paths to find more.") else: found = found.popitem()[1] if len(found) >= 2: raise ValueError( "Found more than one database belonging to user {} at {}".format( user, ", ".join(map(str, found)))) found = found[0] log.debug("Database found at %r.", found) return found
def write( self, modify=False, add=False, delete=False, backup_folder: Union[PurePath, str] = None, ): """ Creates a backup of the database and then writes back the new data. .. danger:: The switches ``modify``, ``add`` and ``delete`` will run additional cross-checks, but do not rely on them to 100%! .. warning:: It is recommended to run :meth:`summarize_changes` before to check whether the changes match your expectation. .. note:: Please make sure to thoroughly check your collection in Anki after every write process! Args: modify: Allow modification of existing items (notes, cards, etc.) add: Allow adding of new items (notes, cards, etc.) delete: Allow deletion of items (notes, cards, etc.) backup_folder: Path to backup folder. If None is given, the backup is created in the Anki backup directory (if found). Returns: None """ if not modify and not add and not delete: log.warning( "Please set modify=True, add=True or delete=True, you're" " literally not allowing me any modification at all." ) return None try: prepared = self._prepare_write_data( modify=modify, add=add, delete=delete ) info = self._get_and_update_info() except Exception as e: log.critical( "Something went wrong preparing the data for writing. " "However, no data has been written out, so your" "database is save!" ) raise e else: log.debug("Successfully prepared data for writing.") backup_path = ankipandas.paths.backup_db( self.path, backup_folder=backup_folder ) log.info("Backup created at {}.".format(backup_path.resolve())) # Actually setting values here, after all conversion tasks have been # carried out. That way if any of them fails, we don't have a # partially written collection. log.debug("Now actually writing to database.") try: for table, values in prepared.items(): raw.set_table( self.db, values["raw"], table=table, mode=values["mode"] ) # Actually only needed if we actually modify the info. # This will trigger a complete re-upload, so we want to avoid this # if possible. # raw.set_info(self.db, info) except Exception as e: log.critical( "Error while writing data to database at {path}" "This means that your database might have become corrupted. " "It's STRONGLY adviced that you manually restore the database " "by replacing it with the backup from {backup_path} and restart" " from scratch. " "Please also open a bug report at " "https://github.com/klieret/AnkiPandas/issues/, as errors " "during the actual writing process should never occurr!".format( path=self.path.resolve(), backup_path=backup_path.resolve() ) ) raise e
def __del__(self): log.debug("Closing db {db} which was loaded from {path}.".format( db=self.db, path=self.path)) raw.close_db(self.db) log.debug("Closing successful")
def write( self, modify=False, add=False, delete=False, backup_folder: Union[PurePath, str] = None, ): """ Creates a backup of the database and then writes back the new data. .. danger:: The switches ``modify``, ``add`` and ``delete`` will run additional cross-checks, but do not rely on them to 100%! .. warning:: It is recommended to run :meth:`summarize_changes` before to check whether the changes match your expectation. .. note:: Please make sure to thoroughly check your collection in Anki after every write process! Args: modify: Allow modification of existing items (notes, cards, etc.) add: Allow adding of new items (notes, cards, etc.) delete: Allow deletion of items (notes, cards, etc.) backup_folder: Path to backup folder. If None is given, the backup is created in the Anki backup directory (if found). Returns: None """ if not modify and not add and not delete: log.warning( "Please set modify=True, add=True or delete=True, you're" " literally not allowing me any modification at all.") return None try: prepared = self._prepare_write_data(modify=modify, add=add, delete=delete) log.debug("Now getting & updating info.") info = self._get_and_update_info() except Exception as e: log.critical( "Something went wrong preparing the data for writing. " "However, no data has been written out, so your " "database is save!") raise e else: log.debug("Successfully prepared data for writing.") if prepared == {}: log.warning( "Nothing seems to have been changed. Will not do anything!") return None backup_path = ankipandas.paths.backup_db(self.path, backup_folder=backup_folder) log.info(f"Backup created at {backup_path.resolve()}.") log.warning( "Currently AnkiPandas might not be able to tell Anki to" " sync its database. " "You might have to manually tell Anki to sync everything " "to AnkiDroid.\n" "Furthermore, if you run into issues with tag searches not working" "anymore, please first do Notes > Clear unused notes and then " "Tools > Check Database (from the main menu). This should get them" " to work (sorry about this issue).") # Actually setting values here, after all conversion tasks have been # carried out. That way if any of them fails, we don't have a # partially written collection. log.debug("Now actually writing to database.") try: for table, values in prepared.items(): log.debug(f"Now setting table {table}.") raw.set_table(self.db, values["raw"], table=table, mode=values["mode"]) log.debug(f"Setting table {table} successful.") # log.debug("Now setting info") # raw.set_info(self.db, info) # log.debug("Setting info successful.") except Exception as e: log.critical( "Error while writing data to database at {path}" "This means that your database might have become corrupted. " "It's STRONGLY advised that you manually restore the database " "by replacing it with the backup from {backup_path} and restart" " from scratch. " "Please also open a bug report at " "https://github.com/klieret/AnkiPandas/issues/, as errors " "during the actual writing process should never occur!".format( path=self.path.resolve(), backup_path=backup_path.resolve())) raise e
def db(self) -> sqlite3.Connection: """Opened Anki database. Make sure to call `db.close()` after you're done. Better still, use `contextlib.closing`. """ log.debug(f"Opening Db from {self._path}") return raw.load_db(self._path)
def __del__(self): log.debug("Closing db %s which was loaded from %s.", self.db, self.path) raw.close_db(self.db) log.debug("Closing successful")