diff --git a/ftl/core/exporting.ftl b/ftl/core/exporting.ftl
index c373b63ed..b396001f3 100644
--- a/ftl/core/exporting.ftl
+++ b/ftl/core/exporting.ftl
@@ -5,6 +5,7 @@ exporting-anki-deck-package = Anki Deck Package
exporting-cards-in-plain-text = Cards in Plain Text
exporting-collection = collection
exporting-collection-exported = Collection exported.
+exporting-colpkg-too-new = Please update to the latest Anki version, then import the .colpkg file again.
exporting-couldnt-save-file = Couldn't save file: { $val }
exporting-export = Export...
exporting-export-format = Export format:
diff --git a/proto/anki/collection.proto b/proto/anki/collection.proto
index a76097dff..7d55b9b60 100644
--- a/proto/anki/collection.proto
+++ b/proto/anki/collection.proto
@@ -20,6 +20,7 @@ service CollectionService {
rpc LatestProgress(generic.Empty) returns (Progress);
rpc SetWantsAbort(generic.Empty) returns (generic.Empty);
rpc AwaitBackupCompletion(generic.Empty) returns (generic.Empty);
+ rpc ExportCollection(ExportCollectionRequest) returns (generic.Empty);
}
message OpenCollectionRequest {
@@ -121,5 +122,12 @@ message Progress {
NormalSync normal_sync = 5;
DatabaseCheck database_check = 6;
string importing = 7;
+ uint32 exporting = 8;
}
}
+
+message ExportCollectionRequest {
+ string out_path = 1;
+ bool include_media = 2;
+ bool legacy = 3;
+}
diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py
index 84896cb5a..2e7468129 100644
--- a/pylib/anki/collection.py
+++ b/pylib/anki/collection.py
@@ -264,6 +264,14 @@ class Collection(DeprecatedNamesMixin):
self._clear_caches()
self.db = None
+ def export_collection(
+ self, out_path: str, include_media: bool, legacy: bool
+ ) -> None:
+ self.close_for_full_sync()
+ self._backend.export_collection(
+ out_path=out_path, include_media=include_media, legacy=legacy
+ )
+
def rollback(self) -> None:
self._clear_caches()
self.db.rollback()
diff --git a/pylib/anki/exporting.py b/pylib/anki/exporting.py
index db433c911..1c6398aaa 100644
--- a/pylib/anki/exporting.py
+++ b/pylib/anki/exporting.py
@@ -9,6 +9,8 @@ import json
import os
import re
import shutil
+import threading
+import time
import unicodedata
import zipfile
from io import BufferedWriter
@@ -419,6 +421,7 @@ class AnkiCollectionPackageExporter(AnkiPackageExporter):
ext = ".colpkg"
verbatim = True
includeSched = None
+ LEGACY = True
def __init__(self, col):
AnkiPackageExporter.__init__(self, col)
@@ -427,22 +430,32 @@ class AnkiCollectionPackageExporter(AnkiPackageExporter):
def key(col: Collection) -> str:
return col.tr.exporting_anki_collection_package()
- def doExport(self, z, path):
- "Export collection. Caller must re-open afterwards."
- # close our deck & write it into the zip file
- self.count = self.col.card_count()
- v2 = self.col.sched_ver() != 1
- mdir = self.col.media.dir()
- self.col.close(downgrade=True)
- if not v2:
- z.write(self.col.path, "collection.anki2")
- else:
- self._addDummyCollection(z)
- z.write(self.col.path, "collection.anki21")
- # copy all media
- if not self.includeMedia:
- return {}
- return self._exportMedia(z, os.listdir(mdir), mdir)
+ def exportInto(self, path: str) -> None:
+ """Export collection. Caller must re-open afterwards."""
+
+ def exporting_media() -> bool:
+ return any(
+ hook.__name__ == "exported_media"
+ for hook in hooks.media_files_did_export._hooks
+ )
+
+ def progress() -> None:
+ while exporting_media():
+ progress = self.col._backend.latest_progress()
+ if progress.HasField("exporting"):
+ hooks.media_files_did_export(progress.exporting)
+ time.sleep(0.1)
+
+ threading.Thread(target=progress).start()
+ self.col.export_collection(path, self.includeMedia, self.LEGACY)
+
+
+class AnkiCollectionPackage21bExporter(AnkiCollectionPackageExporter):
+ LEGACY = False
+
+ @staticmethod
+ def key(_col: Collection) -> str:
+ return "Anki 2.1.50+ Collection Package"
# Export modules
@@ -459,6 +472,7 @@ def exporters(col: Collection) -> list[tuple[str, Any]]:
exps = [
id(AnkiCollectionPackageExporter),
+ id(AnkiCollectionPackage21bExporter),
id(AnkiPackageExporter),
id(TextNoteExporter),
id(TextCardExporter),
diff --git a/rslib/src/backend/collection.rs b/rslib/src/backend/collection.rs
index 9e9672067..80247e704 100644
--- a/rslib/src/backend/collection.rs
+++ b/rslib/src/backend/collection.rs
@@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-use std::path::Path;
+use std::{path::Path, sync::MutexGuard};
use slog::error;
@@ -12,6 +12,7 @@ use crate::{
backend_proto::{self as pb, preferences::Backups},
collection::{
backup::{self, ImportProgress},
+ exporting::export_collection_file,
CollectionBuilder,
},
log::{self},
@@ -30,10 +31,7 @@ impl CollectionService for Backend {
}
fn open_collection(&self, input: pb::OpenCollectionRequest) -> Result {
- let mut col = self.col.lock().unwrap();
- if col.is_some() {
- return Err(AnkiError::CollectionAlreadyOpen);
- }
+ let mut guard = self.lock_closed_collection()?;
let mut builder = CollectionBuilder::new(input.collection_path);
builder
@@ -46,7 +44,7 @@ impl CollectionService for Backend {
builder.set_logger(self.log.clone());
}
- *col = Some(builder.build()?);
+ *guard = Some(builder.build()?);
Ok(().into())
}
@@ -54,12 +52,9 @@ impl CollectionService for Backend {
fn close_collection(&self, input: pb::CloseCollectionRequest) -> Result {
self.abort_media_sync_and_wait();
- let mut col = self.col.lock().unwrap();
- if col.is_none() {
- return Err(AnkiError::CollectionNotOpen);
- }
+ let mut guard = self.lock_open_collection()?;
- let mut col_inner = col.take().unwrap();
+ let mut col_inner = guard.take().unwrap();
let limits = col_inner.get_backups();
let col_path = std::mem::take(&mut col_inner.col_path);
@@ -82,30 +77,39 @@ impl CollectionService for Backend {
Ok(().into())
}
- fn restore_backup(&self, input: pb::RestoreBackupRequest) -> Result {
- let col = self.col.lock().unwrap();
- if col.is_some() {
- Err(AnkiError::CollectionAlreadyOpen)
- } else {
- let mut handler = self.new_progress_handler();
- let progress_fn = move |progress| {
- let throttle = matches!(progress, ImportProgress::Media(_));
- if handler.update(Progress::Import(progress), throttle) {
- Ok(())
- } else {
- Err(AnkiError::Interrupted)
- }
- };
+ fn export_collection(&self, input: pb::ExportCollectionRequest) -> Result {
+ self.abort_media_sync_and_wait();
- backup::restore_backup(
- progress_fn,
- &input.col_path,
- &input.backup_path,
- &input.media_folder,
- &self.tr,
- )
- .map(Into::into)
- }
+ let mut guard = self.lock_open_collection()?;
+
+ let col_inner = guard.take().unwrap();
+ let col_path = col_inner.col_path.clone();
+ let media_dir = input.include_media.then(|| col_inner.media_folder.clone());
+
+ col_inner.close(true)?;
+
+ export_collection_file(
+ input.out_path,
+ col_path,
+ media_dir,
+ input.legacy,
+ &self.tr,
+ self.export_progress_fn(),
+ )
+ .map(Into::into)
+ }
+
+ fn restore_backup(&self, input: pb::RestoreBackupRequest) -> Result {
+ let _guard = self.lock_closed_collection()?;
+
+ backup::restore_backup(
+ self.import_progress_fn(),
+ &input.col_path,
+ &input.backup_path,
+ &input.media_folder,
+ &self.tr,
+ )
+ .map(Into::into)
}
fn check_database(&self, _input: pb::Empty) -> Result {
@@ -150,6 +154,22 @@ impl CollectionService for Backend {
}
impl Backend {
+ fn lock_open_collection(&self) -> Result>> {
+ let guard = self.col.lock().unwrap();
+ guard
+ .is_some()
+ .then(|| guard)
+ .ok_or(AnkiError::CollectionNotOpen)
+ }
+
+ fn lock_closed_collection(&self) -> Result>> {
+ let guard = self.col.lock().unwrap();
+ guard
+ .is_none()
+ .then(|| guard)
+ .ok_or(AnkiError::CollectionAlreadyOpen)
+ }
+
fn await_backup_completion(&self) {
if let Some(task) = self.backup_task.lock().unwrap().take() {
task.join().unwrap();
@@ -170,8 +190,28 @@ impl Backend {
limits,
minimum_backup_interval,
self.log.clone(),
+ self.tr.clone(),
)?;
Ok(())
}
+
+ fn import_progress_fn(&self) -> impl FnMut(ImportProgress) -> Result<()> {
+ let mut handler = self.new_progress_handler();
+ move |progress| {
+ let throttle = matches!(progress, ImportProgress::Media(_));
+ if handler.update(Progress::Import(progress), throttle) {
+ Ok(())
+ } else {
+ Err(AnkiError::Interrupted)
+ }
+ }
+ }
+
+ fn export_progress_fn(&self) -> impl FnMut(usize) {
+ let mut handler = self.new_progress_handler();
+ move |media_files| {
+ handler.update(Progress::Export(media_files), true);
+ }
+ }
}
diff --git a/rslib/src/backend/progress.rs b/rslib/src/backend/progress.rs
index fd10f7d59..ea88c1c29 100644
--- a/rslib/src/backend/progress.rs
+++ b/rslib/src/backend/progress.rs
@@ -52,6 +52,7 @@ pub(super) enum Progress {
NormalSync(NormalSyncProgress),
DatabaseCheck(DatabaseCheckProgress),
Import(ImportProgress),
+ Export(usize),
}
pub(super) fn progress_to_proto(progress: Option