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 <name>.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.
This commit is contained in:
avi 2026-09-18 17:54:34 -05:00
commit 4800364f09
4 changed files with 222 additions and 0 deletions

View file

@ -1744,6 +1744,19 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
reportFile(d.file).writeText(renderReport(d.file.nameWithoutExtension, d.transcript, d.summary)) 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 { companion object {
/** Live pipeline activity for a recording: human label plus the /** Live pipeline activity for a recording: human label plus the
* 0..1 fraction when the backend reports one. Null = idle. * 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 = fun reportFile(audio: File): File =
File(audio.parentFile, "${audio.nameWithoutExtension}.transcript.md") File(audio.parentFile, "${audio.nameWithoutExtension}.transcript.md")
fun odtFile(audio: File): File =
File(audio.parentFile, "${audio.nameWithoutExtension}.report.odt")
} }
} }

View file

@ -47,6 +47,106 @@ fun renderReport(title: String, transcript: TranscriptData?, summary: SummaryDat
return out.joinToString("\n") 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
val body = StringBuilder()
fun h(level: Int, text: String) {
body.append("<text:h text:outline-level=\"").append(level).append("\">")
.append(esc(text)).append("</text:h>")
}
fun p(text: String, style: String? = null) {
body.append("<text:p")
if (style != null) body.append(" text:style-name=\"").append(style).append('"')
body.append('>').append(esc(text)).append("</text:p>")
}
fun bullets(items: List<String>) {
body.append("<text:list>")
items.forEach { body.append("<text:list-item>").append("<text:p")
.append(">").append(esc(it)).append("</text:p></text:list-item>") }
body.append("</text:list>")
}
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 = """<?xml version="1.0" encoding="UTF-8"?>""" + "\n" +
"<office:document-content " +
"xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" " +
"xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\" " +
"office:version=\"1.2\">" +
"<office:automatic-styles>" +
"<style:style xmlns:style=\"urn:oasis:names:tc:opendocument:xmlns:style:1.0\" " +
"xmlns:fo=\"urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0\" " +
"style:name=\"Voice\" style:family=\"paragraph\">" +
"<style:text-properties fo:font-style=\"italic\"/>" +
"</style:style>" +
"</office:automatic-styles>" +
"<office:body><office:text>$body</office:text></office:body>" +
"</office:document-content>"
val manifest = """<?xml version="1.0" encoding="UTF-8"?>""" + "\n" +
"<manifest:manifest " +
"xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\" " +
"manifest:version=\"1.2\">" +
"<manifest:file-entry manifest:full-path=\"/\" " +
"manifest:media-type=\"application/vnd.oasis.opendocument.text\"/>" +
"<manifest:file-entry manifest:full-path=\"content.xml\" " +
"manifest:media-type=\"text/xml\"/>" +
"</manifest:manifest>"
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 { fun fmtTs(sec: Double): String {
val total = sec.toLong().coerceAtLeast(0) val total = sec.toLong().coerceAtLeast(0)
return "%02d:%02d:%02d".format(total / 3600, (total % 3600) / 60, total % 60) return "%02d:%02d:%02d".format(total / 3600, (total % 3600) / 60, total % 60)

View file

@ -582,6 +582,7 @@ fun DetailScreen(state: DesktopState) {
mutableStateOf<String?>(null) mutableStateOf<String?>(null)
} }
var savingTranscript by remember(detail.file.name) { mutableStateOf(false) } var savingTranscript by remember(detail.file.name) { mutableStateOf(false) }
var odtNote by remember(detail.file.name) { mutableStateOf<String?>(null) }
// The save (or its failure) clears busy — release the button lock. // The save (or its failure) clears busy — release the button lock.
LaunchedEffect(detail.busy) { if (detail.busy == null) savingTranscript = false } LaunchedEffect(detail.busy) { if (detail.busy == null) savingTranscript = false }
val playingFile by state.playingFile.collectAsState() val playingFile by state.playingFile.collectAsState()
@ -1134,6 +1135,20 @@ fun DetailScreen(state: DesktopState) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant) 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 // (Voice chips + Re-transcribe + summarize progress live above the
// transcript, right under the summary.) // transcript, right under the summary.)
} // close the scrollable column (header stays pinned above it) } // close the scrollable column (header stays pinned above it)

View file

@ -57,6 +57,97 @@ class ReportsTest {
} }
} }
// ---- renderOdt ----------------------------------------------------
private fun odtZip(bytes: ByteArray): Pair<Map<String, ByteArray>, Set<String>> {
val entries = mutableMapOf<String, ByteArray>()
val stored = mutableSetOf<String>()
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 & <friends>"),
),
)
@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("<text:h text:outline-level=\"1\">Evening notes</text:h>" in xml)
assertTrue(">Summary<" in xml)
assertTrue("Voice: sarcastic" in xml)
assertTrue("Standup happened." in xml)
assertTrue(">Decisions<" in xml)
assertTrue("<text:list-item>" 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 <tag>", sampleTranscript(), null)
.let { odtZip(it).first.getValue("content.xml").toString(Charsets.UTF_8) }
assertTrue("A &amp; B &lt;tag&gt;" in xml)
assertTrue("world &amp; &lt;friends&gt;" 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() { @Test fun reportLinesRoundTripThroughRender() {
// What renderReport writes, parseReportTranscriptLines must read // What renderReport writes, parseReportTranscriptLines must read
// back with identical timestamps — the search-jump-to-playback // back with identical timestamps — the search-jump-to-playback