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