From 4800364f096b675f3dea9875906e21a2d17d870e Mon Sep 17 00:00:00 2001 From: avi Date: Fri, 18 Sep 2026 17:54:34 -0500 Subject: [PATCH] Export report as .odt from the detail screen renderOdt builds the minimal valid ODF package (STORED mimetype first, content.xml, manifest) mirroring the markdown report's sections so the two exports can't drift in content. The Export .odt button lives once for both detail paths (live transcript or library-opened saved report) and writes .report.odt next to the audio. Tests: 5 new (package shape, section parity, XML escaping + well-formedness, neutral fallback, filename). Verified in LibreOffice: headings, bullet lists, and escaped text render natively. --- .../kotlin/com/shonar/desktop/DesktopState.kt | 16 +++ .../main/kotlin/com/shonar/desktop/Reports.kt | 100 ++++++++++++++++++ .../main/kotlin/com/shonar/desktop/Screens.kt | 15 +++ .../kotlin/com/shonar/desktop/ReportsTest.kt | 91 ++++++++++++++++ 4 files changed, 222 insertions(+) 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, Set> { + val entries = mutableMapOf() + val stored = mutableSetOf() + java.util.zip.ZipInputStream(bytes.inputStream()).use { zip -> + var e = zip.nextEntry + while (e != null) { + entries[e.name] = zip.readBytes() + if (e.method == java.util.zip.ZipEntry.STORED) stored += e.name + e = zip.nextEntry + } + } + return entries to stored + } + + private fun sampleTranscript() = TranscriptData( + version = 1, provider = "faster_whisper", model = "base", + text = "hello world", + segments = listOf( + TranscriptSegment(0.0, 1.5, "hello"), + TranscriptSegment(65.0, 67.0, "world & "), + ), + ) + + @Test fun odtPackageIsMinimalValidOdf() { + val bytes = renderOdt("Notes", sampleTranscript(), null) + // mimetype must be the FIRST entry and STORED (ODF requirement). + java.util.zip.ZipInputStream(bytes.inputStream()).use { zip -> + val first = zip.nextEntry + assertEquals("mimetype", first.name) + assertEquals(java.util.zip.ZipEntry.STORED, first.method) + assertEquals("application/vnd.oasis.opendocument.text", + zip.readBytes().toString(Charsets.UTF_8)) + } + val (entries, stored) = odtZip(bytes) + assertTrue("content.xml", entries.containsKey("content.xml")) + assertTrue("manifest", entries.containsKey("META-INF/manifest.xml")) + assertEquals(setOf("mimetype"), stored) + } + + @Test fun odtContentMirrorsMarkdownSections() { + val s = SummaryData(version = 1, tone = "sarcastic", + content = mapOf( + "short" to "Standup happened.", + "decisions" to listOf("Ship Friday", "Cut scope"), + "detailed" to "Full prose here.", + )) + val xml = renderOdt("Evening notes", sampleTranscript(), s) + .let { odtZip(it).first.getValue("content.xml").toString(Charsets.UTF_8) } + assertTrue("Evening notes" in xml) + assertTrue(">Summary<" in xml) + assertTrue("Voice: sarcastic" in xml) + assertTrue("Standup happened." in xml) + assertTrue(">Decisions<" in xml) + assertTrue("" in xml && "Ship Friday" in xml) + assertTrue(">Details<" in xml && "Full prose here." in xml) + assertTrue(">Transcript<" in xml) + assertTrue("[00:00:00] hello" in xml) + assertTrue("[00:01:05] world" in xml) + assertTrue("faster_whisper, v1" in xml) + } + + @Test fun odtEscapesXmlSpecials() { + val xml = renderOdt("A & B ", sampleTranscript(), null) + .let { odtZip(it).first.getValue("content.xml").toString(Charsets.UTF_8) } + assertTrue("A & B <tag>" in xml) + assertTrue("world & <friends>" in xml) + // Must be well-formed XML overall. + javax.xml.parsers.DocumentBuilderFactory.newInstance() + .newDocumentBuilder().parse(xml.byteInputStream()) + } + + @Test fun odtTonelessSummarySaysNeutral() { + val s = SummaryData(version = 1, content = mapOf("short" to "s")) + val xml = renderOdt("t", null, s) + .let { odtZip(it).first.getValue("content.xml").toString(Charsets.UTF_8) } + assertTrue("Voice: neutral" in xml) + assertTrue("No transcript available." in xml) + } + + @Test fun odtFileName() { + val dir = Files.createTempDirectory("odt").toFile() + try { + val audio = File(dir, "chat.m4a") + assertEquals("chat.report.odt", DesktopState.odtFile(audio).name) + } finally { + dir.deleteRecursively() + } + } + @Test fun reportLinesRoundTripThroughRender() { // What renderReport writes, parseReportTranscriptLines must read // back with identical timestamps — the search-jump-to-playback