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:
parent
d1a85bb6d0
commit
4800364f09
4 changed files with 222 additions and 0 deletions
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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("<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 {
|
||||
val total = sec.toLong().coerceAtLeast(0)
|
||||
return "%02d:%02d:%02d".format(total / 3600, (total % 3600) / 60, total % 60)
|
||||
|
|
|
|||
|
|
@ -582,6 +582,7 @@ fun DetailScreen(state: DesktopState) {
|
|||
mutableStateOf<String?>(null)
|
||||
}
|
||||
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.
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 & 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue