From e106d1ab76e31e0b235eb0da923737aa49941b19 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 24 Mar 2026 20:46:42 -0700 Subject: [PATCH] Add notebook zip backup export - BackupExporter: exports notebook as .engpad.zip containing notebook.json (metadata) and pages/NNN.json (strokes as JSON float arrays). Uses Dispatchers.IO for file I/O. - Added "Export backup" to notebook overflow menu in library - PdfExporter.sanitize changed from private to internal for reuse - Shares via FileProvider + ACTION_SEND with application/zip MIME type Co-Authored-By: Claude Opus 4.6 (1M context) --- .../engpad/ui/export/BackupExporter.kt | 71 +++++++++++++++++++ .../engpad/ui/export/PdfExporter.kt | 2 +- .../engpad/ui/notebooks/NotebookListScreen.kt | 28 ++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/net/metacircular/engpad/ui/export/BackupExporter.kt diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/export/BackupExporter.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/export/BackupExporter.kt new file mode 100644 index 0000000..c9a10b8 --- /dev/null +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/export/BackupExporter.kt @@ -0,0 +1,71 @@ +package net.metacircular.engpad.ui.export + +import android.content.Context +import net.metacircular.engpad.data.db.toFloatArray +import net.metacircular.engpad.data.repository.NotebookRepository +import net.metacircular.engpad.data.repository.PageRepository +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +object BackupExporter { + + suspend fun exportNotebook( + context: Context, + notebookId: Long, + notebookRepository: NotebookRepository, + pageRepository: PageRepository, + ): File { + val notebook = notebookRepository.getById(notebookId) + ?: throw IllegalArgumentException("Notebook $notebookId not found") + + val pages = pageRepository.getPagesList(notebookId) + .sortedBy { it.pageNumber } + + val exportDir = File(context.cacheDir, "exports") + exportDir.mkdirs() + val sanitizedTitle = PdfExporter.sanitize(notebook.title) + val file = File(exportDir, "$sanitizedTitle.engpad.zip") + + ZipOutputStream(file.outputStream()).use { zip -> + val notebookJson = JSONObject().apply { + put("title", notebook.title) + put("page_size", notebook.pageSize) + put("page_count", pages.size) + } + zip.putNextEntry(ZipEntry("notebook.json")) + zip.write(notebookJson.toString(2).toByteArray()) + zip.closeEntry() + + for (page in pages) { + val strokes = pageRepository.getStrokes(page.id) + val strokesJson = JSONArray() + for (stroke in strokes) { + val points = stroke.pointData.toFloatArray() + val pointsArray = JSONArray() + for (p in points) { + pointsArray.put(p.toDouble()) + } + strokesJson.put(JSONObject().apply { + put("pen_size", stroke.penSize.toDouble()) + put("color", stroke.color) + put("style", stroke.style) + put("points", pointsArray) + }) + } + val pageJson = JSONObject().apply { + put("page_number", page.pageNumber) + put("strokes", strokesJson) + } + val entryName = "pages/%03d.json".format(page.pageNumber) + zip.putNextEntry(ZipEntry(entryName)) + zip.write(pageJson.toString(2).toByteArray()) + zip.closeEntry() + } + } + + return file + } +} diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/export/PdfExporter.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/export/PdfExporter.kt index b5f1274..5435068 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/export/PdfExporter.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/export/PdfExporter.kt @@ -124,6 +124,6 @@ object PdfExporter { } } - private fun sanitize(name: String): String = + internal fun sanitize(name: String): String = name.replace(Regex("[^a-zA-Z0-9._-]"), "_") } diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListScreen.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListScreen.kt index 07891c8..8910032 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListScreen.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListScreen.kt @@ -31,16 +31,24 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.metacircular.engpad.data.db.EngPadDatabase import net.metacircular.engpad.data.model.Notebook import net.metacircular.engpad.data.model.PageSize import net.metacircular.engpad.data.repository.NotebookRepository +import net.metacircular.engpad.data.repository.PageRepository +import net.metacircular.engpad.ui.export.BackupExporter +import net.metacircular.engpad.ui.export.PdfExporter import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -62,6 +70,9 @@ fun NotebookListScreen( val repository = remember { NotebookRepository(database.notebookDao(), database.pageDao()) } + val pageRepository = remember { + PageRepository(database.pageDao(), database.strokeDao()) + } val viewModel: NotebookListViewModel = viewModel( factory = NotebookListViewModel.Factory(repository), ) @@ -69,6 +80,8 @@ fun NotebookListScreen( var showCreateDialog by remember { mutableStateOf(false) } var notebookToDelete by remember { mutableStateOf(null) } var notebookToRename by remember { mutableStateOf(null) } + val context = LocalContext.current + val scope = rememberCoroutineScope() var filterText by remember { mutableStateOf("") } var sortField by remember { mutableStateOf(SortField.LAST_EDITED) } @@ -171,6 +184,16 @@ fun NotebookListScreen( onClick = { onNotebookClick(notebook.id) }, onRename = { notebookToRename = notebook }, onDelete = { notebookToDelete = notebook }, + onExportBackup = { + scope.launch { + val file = withContext(Dispatchers.IO) { + BackupExporter.exportNotebook( + context, notebook.id, repository, pageRepository, + ) + } + PdfExporter.shareFile(context, file, "application/zip") + } + }, ) } } @@ -217,6 +240,7 @@ private fun NotebookItem( onClick: () -> Unit, onRename: () -> Unit, onDelete: () -> Unit, + onExportBackup: () -> Unit, ) { val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) } var showMenu by remember { mutableStateOf(false) } @@ -262,6 +286,10 @@ private fun NotebookItem( text = { Text("Rename") }, onClick = { showMenu = false; onRename() }, ) + DropdownMenuItem( + text = { Text("Export backup") }, + onClick = { showMenu = false; onExportBackup() }, + ) DropdownMenuItem( text = { Text("Delete") }, onClick = { showMenu = false; onDelete() },