diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt
index 2f73143..a3af382 100644
--- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt
+++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt
@@ -1744,6 +1744,19 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
reportFile(d.file).writeText(renderReport(d.file.nameWithoutExtension, d.transcript, d.summary))
}
+ /** Write the .odt export next to the audio (same base name as the
+ * .md report, .report.odt extension). Returns null on success or a
+ * human reason it failed. */
+ fun exportOdt(d: DetailUi): String? {
+ val out = odtFile(d.file)
+ return runCatching {
+ out.writeBytes(renderOdt(d.file.nameWithoutExtension, d.transcript, d.summary))
+ }.fold(
+ onSuccess = { null },
+ onFailure = { "Couldn't save ${out.name}: ${it.message}" },
+ )
+ }
+
companion object {
/** Live pipeline activity for a recording: human label plus the
* 0..1 fraction when the backend reports one. Null = idle.
@@ -1844,6 +1857,9 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
fun reportFile(audio: File): File =
File(audio.parentFile, "${audio.nameWithoutExtension}.transcript.md")
+
+ fun odtFile(audio: File): File =
+ File(audio.parentFile, "${audio.nameWithoutExtension}.report.odt")
}
}
diff --git a/app/src/main/kotlin/com/shonar/desktop/Reports.kt b/app/src/main/kotlin/com/shonar/desktop/Reports.kt
index 3d70a19..a8efcc4 100644
--- a/app/src/main/kotlin/com/shonar/desktop/Reports.kt
+++ b/app/src/main/kotlin/com/shonar/desktop/Reports.kt
@@ -47,6 +47,106 @@ fun renderReport(title: String, transcript: TranscriptData?, summary: SummaryDat
return out.joinToString("\n")
}
+/**
+ * OpenDocument (.odt) version of [renderReport] — same sections, same
+ * order, so the two exports can't drift in content. Built as the minimal
+ * valid ODF package (uncompressed `mimetype` first, then `content.xml`,
+ * `styles.xml`, manifest) that LibreOffice and Word open natively.
+ * Pure logic — unit-tested by unzipping and inspecting content.xml.
+ */
+fun renderOdt(title: String, transcript: TranscriptData?, summary: SummaryData?): ByteArray {
+ fun esc(s: String) = s.replace("&", "&").replace("<", "<").replace(">", ">")
+ val body = StringBuilder()
+ fun h(level: Int, text: String) {
+ body.append("")
+ .append(esc(text)).append("")
+ }
+ fun p(text: String, style: String? = null) {
+ body.append("').append(esc(text)).append("")
+ }
+ fun bullets(items: List) {
+ body.append("")
+ items.forEach { body.append("").append("").append(esc(it)).append("") }
+ body.append("")
+ }
+
+ h(1, title)
+ if (summary != null) {
+ if (summary.short.isNotBlank()) {
+ h(2, "Summary")
+ p("Voice: ${summary.tone ?: "neutral"}", style = "Voice")
+ p(summary.short)
+ }
+ for (key in SUMMARY_LIST_KEYS) {
+ val items = summary.list(key)
+ if (items.isNotEmpty()) {
+ h(2, key.replace('_', ' ').replaceFirstChar { it.uppercase() })
+ bullets(items)
+ }
+ }
+ if (summary.detailed.isNotBlank()) {
+ h(2, "Details")
+ p(summary.detailed)
+ }
+ }
+ h(2, "Transcript")
+ if (transcript != null) {
+ if (transcript.segments.isNotEmpty()) {
+ transcript.segments.forEach { p("[${fmtTs(it.startSec)}] ${it.text}") }
+ } else if (transcript.text.isNotBlank()) {
+ p(transcript.text)
+ }
+ p("Transcribed with ${transcript.provider.ifBlank { "?" }}, v${transcript.version}.",
+ style = "Voice")
+ } else {
+ p("No transcript available.")
+ }
+
+ val content = """""" + "\n" +
+ "" +
+ "" +
+ "" +
+ "" +
+ "" +
+ "" +
+ "$body" +
+ ""
+ val manifest = """""" + "\n" +
+ "" +
+ "" +
+ "" +
+ ""
+
+ val out = java.io.ByteArrayOutputStream()
+ java.util.zip.ZipOutputStream(out).use { zip ->
+ // ODF requires the mimetype entry first and STORED (uncompressed).
+ val mime = "application/vnd.oasis.opendocument.text".toByteArray()
+ zip.putNextEntry(java.util.zip.ZipEntry("mimetype").apply {
+ method = java.util.zip.ZipEntry.STORED
+ size = mime.size.toLong()
+ crc = java.util.zip.CRC32().apply { update(mime) }.value
+ })
+ zip.write(mime)
+ zip.putNextEntry(java.util.zip.ZipEntry("content.xml"))
+ zip.write(content.toByteArray())
+ zip.putNextEntry(java.util.zip.ZipEntry("META-INF/manifest.xml"))
+ zip.write(manifest.toByteArray())
+ }
+ return out.toByteArray()
+}
+
fun fmtTs(sec: Double): String {
val total = sec.toLong().coerceAtLeast(0)
return "%02d:%02d:%02d".format(total / 3600, (total % 3600) / 60, total % 60)
diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt
index 34f644c..d53fff1 100644
--- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt
+++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt
@@ -582,6 +582,7 @@ fun DetailScreen(state: DesktopState) {
mutableStateOf(null)
}
var savingTranscript by remember(detail.file.name) { mutableStateOf(false) }
+ var odtNote by remember(detail.file.name) { mutableStateOf(null) }
// The save (or its failure) clears busy — release the button lock.
LaunchedEffect(detail.busy) { if (detail.busy == null) savingTranscript = false }
val playingFile by state.playingFile.collectAsState()
@@ -1134,6 +1135,20 @@ fun DetailScreen(state: DesktopState) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
+ // Export lives once for both paths (live transcript or saved report).
+ if (detail.transcript != null || detail.reportText != null) {
+ Row(verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedButton(onClick = {
+ odtNote = state.exportOdt(detail)
+ ?: "Saved to ${detail.file.nameWithoutExtension}.report.odt"
+ }) { Text("Export .odt") }
+ odtNote?.let {
+ Text(it, style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ }
// (Voice chips + Re-transcribe + summarize progress live above the
// transcript, right under the summary.)
} // close the scrollable column (header stays pinned above it)
diff --git a/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt b/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt
index 82d7a15..d624331 100644
--- a/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt
+++ b/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt
@@ -57,6 +57,97 @@ class ReportsTest {
}
}
+ // ---- renderOdt ----------------------------------------------------
+
+ private fun odtZip(bytes: ByteArray): Pair