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) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 20:46:42 -07:00
parent b8fb85c5f0
commit e106d1ab76
3 changed files with 100 additions and 1 deletions

View File

@@ -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
}
}

View File

@@ -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._-]"), "_") name.replace(Regex("[^a-zA-Z0-9._-]"), "_")
} }

View File

@@ -31,16 +31,24 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel 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.db.EngPadDatabase
import net.metacircular.engpad.data.model.Notebook import net.metacircular.engpad.data.model.Notebook
import net.metacircular.engpad.data.model.PageSize import net.metacircular.engpad.data.model.PageSize
import net.metacircular.engpad.data.repository.NotebookRepository 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.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -62,6 +70,9 @@ fun NotebookListScreen(
val repository = remember { val repository = remember {
NotebookRepository(database.notebookDao(), database.pageDao()) NotebookRepository(database.notebookDao(), database.pageDao())
} }
val pageRepository = remember {
PageRepository(database.pageDao(), database.strokeDao())
}
val viewModel: NotebookListViewModel = viewModel( val viewModel: NotebookListViewModel = viewModel(
factory = NotebookListViewModel.Factory(repository), factory = NotebookListViewModel.Factory(repository),
) )
@@ -69,6 +80,8 @@ fun NotebookListScreen(
var showCreateDialog by remember { mutableStateOf(false) } var showCreateDialog by remember { mutableStateOf(false) }
var notebookToDelete by remember { mutableStateOf<Notebook?>(null) } var notebookToDelete by remember { mutableStateOf<Notebook?>(null) }
var notebookToRename by remember { mutableStateOf<Notebook?>(null) } var notebookToRename by remember { mutableStateOf<Notebook?>(null) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
var filterText by remember { mutableStateOf("") } var filterText by remember { mutableStateOf("") }
var sortField by remember { mutableStateOf(SortField.LAST_EDITED) } var sortField by remember { mutableStateOf(SortField.LAST_EDITED) }
@@ -171,6 +184,16 @@ fun NotebookListScreen(
onClick = { onNotebookClick(notebook.id) }, onClick = { onNotebookClick(notebook.id) },
onRename = { notebookToRename = notebook }, onRename = { notebookToRename = notebook },
onDelete = { notebookToDelete = 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, onClick: () -> Unit,
onRename: () -> Unit, onRename: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onExportBackup: () -> Unit,
) { ) {
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) } val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) }
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
@@ -262,6 +286,10 @@ private fun NotebookItem(
text = { Text("Rename") }, text = { Text("Rename") },
onClick = { showMenu = false; onRename() }, onClick = { showMenu = false; onRename() },
) )
DropdownMenuItem(
text = { Text("Export backup") },
onClick = { showMenu = false; onExportBackup() },
)
DropdownMenuItem( DropdownMenuItem(
text = { Text("Delete") }, text = { Text("Delete") },
onClick = { showMenu = false; onDelete() }, onClick = { showMenu = false; onDelete() },