Skip to content

Commit 2f952b4

Browse files
committed
Add Transcript page
In a future commit, we'll add a UI for choosing the output format.
1 parent d737ff2 commit 2f952b4

File tree

10 files changed

+257
-9
lines changed

10 files changed

+257
-9
lines changed

desktop-app/src-tauri/capabilities/default.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@
88
"permissions": [
99
"core:default",
1010
"opener:default",
11+
{
12+
"identifier": "opener:allow-open-path",
13+
"allow": [
14+
{
15+
"path": "$DOWNLOAD/**"
16+
}
17+
]
18+
},
1119
{
1220
"identifier": "shell:allow-execute",
1321
"allow": [

desktop-app/src-tauri/src/lib.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ use {
1313
},
1414
};
1515

16+
use std::path::{Path, PathBuf};
17+
1618
#[cfg(target_os = "macos")]
1719
const CN_ENTITY_TYPE_CONTACTS: i64 = 0;
1820
#[cfg(target_os = "macos")]
@@ -260,13 +262,59 @@ unsafe fn ns_error_to_string(error: *mut Object) -> String {
260262
}
261263
}
262264

265+
#[tauri::command]
266+
fn resolve_download_output_path(base_name: String) -> Result<String, String> {
267+
if base_name.trim().is_empty() {
268+
return Err("Output filename cannot be empty.".into());
269+
}
270+
271+
let base_path = Path::new(&base_name);
272+
if base_path.is_absolute() || base_path.components().count() > 1 {
273+
return Err("Output filename must not include directory segments.".into());
274+
}
275+
276+
let home_dir = std::env::var("HOME")
277+
.map(PathBuf::from)
278+
.map_err(|_| "Could not resolve HOME directory.".to_string())?;
279+
let downloads_dir = home_dir.join("Downloads");
280+
281+
let stem = base_path
282+
.file_stem()
283+
.and_then(|value| value.to_str())
284+
.filter(|value| !value.trim().is_empty())
285+
.ok_or_else(|| "Output filename is invalid.".to_string())?;
286+
287+
let extension = base_path
288+
.extension()
289+
.and_then(|value| value.to_str())
290+
.filter(|value| !value.trim().is_empty())
291+
.unwrap_or("csv");
292+
293+
let mut index: usize = 0;
294+
loop {
295+
let candidate_name = if index == 0 {
296+
format!("{stem}.{extension}")
297+
} else {
298+
format!("{stem}-{index}.{extension}")
299+
};
300+
let candidate_path = downloads_dir.join(candidate_name);
301+
if !candidate_path.exists() {
302+
return Ok(candidate_path.to_string_lossy().into_owned());
303+
}
304+
index += 1;
305+
}
306+
}
307+
263308
#[cfg_attr(mobile, tauri::mobile_entry_point)]
264309
pub fn run() {
265310
tauri::Builder::default()
266311
.plugin(tauri_plugin_store::Builder::new().build())
267312
.plugin(tauri_plugin_opener::init())
268313
.plugin(tauri_plugin_shell::init())
269-
.invoke_handler(tauri::generate_handler![get_contact_names])
314+
.invoke_handler(tauri::generate_handler![
315+
get_contact_names,
316+
resolve_download_output_path
317+
])
270318
.run(tauri::generate_context!())
271319
.expect("error while running tauri application");
272320
}

desktop-app/src/components/Header.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
<HeaderNavItem href="/attachment-totals">Attachment Totals</HeaderNavItem>
2727
<HeaderNavItem href="/most-frequent-emojis">Emojis</HeaderNavItem>
2828
<HeaderNavItem href="/totals-by-day">Totals by Day</HeaderNavItem>
29+
<HeaderNavItem href="/transcript">Transcript</HeaderNavItem>
2930
</HeaderNav>
3031
<HeaderUtilities>
3132
<div class="app-header__contact">
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<script lang="ts">
2+
import {
3+
InlineNotification as CarbonInlineNotification,
4+
NotificationActionButton
5+
} from 'carbon-components-svelte';
6+
7+
type CarbonInlineNotificationProps = InstanceType<
8+
typeof CarbonInlineNotification
9+
>['$$prop_def'];
10+
type Props = Omit<CarbonInlineNotificationProps, 'lowContrast'> & {
11+
actionLabel?: string;
12+
onAction?: (event: MouseEvent) => void;
13+
};
14+
15+
let { actionLabel, onAction, ...props }: Props = $props();
16+
</script>
17+
18+
<CarbonInlineNotification
19+
{...props}
20+
lowContrast
21+
on:close
22+
on:click
23+
on:mouseover
24+
on:mouseenter
25+
on:mouseleave
26+
>
27+
{#if actionLabel}
28+
<NotificationActionButton slot="actions" onclick={onAction}>
29+
{actionLabel}
30+
</NotificationActionButton>
31+
{/if}
32+
</CarbonInlineNotification>

desktop-app/src/components/ResultGrid.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
Button,
66
DatePicker,
77
DatePickerInput,
8-
InlineNotification,
98
Loading,
109
TooltipIcon
1110
} from 'carbon-components-svelte';
@@ -14,6 +13,7 @@
1413
import '../styles/result-grid.css';
1514
import type { GridColumn } from '../types';
1615
import DateCell from './DateCell.svelte';
16+
import InlineNotification from './InlineNotification.svelte';
1717
import NumberCell from './NumberCell.svelte';
1818
1919
interface Props {

desktop-app/src/lib/cli.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import { getSelectedContact } from './contacts.svelte';
44
import { runIcaSidecar, type SidecarResult } from './sidecar';
55

66
const FORMAT_FLAGS = new Set(['--format', '-f']);
7+
const OUTPUT_FLAGS = new Set(['--output', '-o']);
78
const CONTACT_FLAGS = new Set(['--contact', '-c']);
8-
const COMBINED_SHORT_FLAGS = new Set(['-c', '-f']);
9-
const COMBINED_LONG_FLAGS = new Set(['--contact', '--format']);
9+
const COMBINED_SHORT_FLAGS = new Set(['-c', '-f', '-o']);
10+
const COMBINED_LONG_FLAGS = new Set(['--contact', '--format', '--output']);
1011

1112
export class MissingContactError extends Error {
1213
constructor(message = 'No contact selected. Choose a contact before running the analyzer.') {
@@ -30,6 +31,14 @@ export interface IcaCsvResult {
3031
args: string[];
3132
}
3233

34+
export interface IcaCommandResult {
35+
code: SidecarResult['code'];
36+
signal: SidecarResult['signal'];
37+
stderr: string;
38+
stdout: string;
39+
args: string[];
40+
}
41+
3342
function toCamelCase(header: string, headerIndex: number): string {
3443
const cleaned = header.replace(/[^0-9A-Za-z]+/g, ' ').trim();
3544
if (!cleaned) {
@@ -120,6 +129,11 @@ function ensureCsvFormat(args: string[]): string[] {
120129
return [...withoutFormat, '--format', 'csv'];
121130
}
122131

132+
function ensureOutputPath(args: string[], outputPath: string): string[] {
133+
const withoutOutput = removeOption(args, OUTPUT_FLAGS);
134+
return [...withoutOutput, '--output', outputPath];
135+
}
136+
123137
function parseCsvOutput(
124138
rawCsv: string,
125139
finalArgs: string[]
@@ -245,6 +259,33 @@ export async function invokeIcaCsv(args: string | string[]): Promise<IcaCsvResul
245259
};
246260
}
247261

262+
export async function invokeIcaCsvToFile(
263+
args: string | string[],
264+
outputPath: string
265+
): Promise<IcaCommandResult> {
266+
const parsedArgs = parseArgInput(args);
267+
const expandedArgs = expandKnownCombinedArgs(parsedArgs);
268+
const argsWithContact = await addContactArgument(expandedArgs);
269+
const csvArgs = ensureCsvFormat(argsWithContact);
270+
const finalArgs = ensureOutputPath(csvArgs, outputPath);
271+
272+
const result = await runIcaSidecar(finalArgs);
273+
const stderr = result.stderr.trim();
274+
const stdout = result.stdout.trim();
275+
276+
if (result.code !== 0) {
277+
throw new Error(stderr || stdout || `ICA exited with code ${result.code ?? 'unknown'}.`);
278+
}
279+
280+
return {
281+
code: result.code,
282+
signal: result.signal,
283+
stderr,
284+
stdout,
285+
args: finalArgs
286+
};
287+
}
288+
248289
export async function runMessageTotals(): Promise<IcaCsvResult> {
249290
return invokeIcaCsv(['message_totals']);
250291
}

desktop-app/src/routes/+layout.svelte

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
<script lang="ts">
22
import { Content, Theme } from 'carbon-components-svelte';
33
import 'carbon-components-svelte/css/all.css';
4-
import type { ThemeProps } from 'carbon-components-svelte/src/Theme/Theme.svelte';
54
import Header from '../components/Header.svelte';
65
import '../styles/colors.css';
76
import '../styles/layout.css';
87
import '../styles/utility-classes.css';
98
const { children } = $props();
10-
let theme: ThemeProps['theme'] = 'g100';
119
</script>
1210

13-
<Theme {theme}>
11+
<Theme theme="g100">
1412
<Header />
1513
<Content>
1614
<section>

desktop-app/src/routes/call-cli/+page.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
2-
import { Button, CodeSnippet, InlineNotification, TextInput } from 'carbon-components-svelte';
2+
import { Button, CodeSnippet, TextInput } from 'carbon-components-svelte';
3+
import InlineNotification from '../../components/InlineNotification.svelte';
34
import { invokeIcaCsv, MissingContactError, type IcaCsvHeader } from '../../lib/cli';
45
56
let icaArgs = $state('message_totals');

desktop-app/src/routes/set-contact/+page.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
<script lang="ts">
22
import { goto } from '$app/navigation';
3-
import { Button, InlineNotification } from 'carbon-components-svelte';
3+
import { Button } from 'carbon-components-svelte';
44
import { onMount } from 'svelte';
55
import ContactPicker from '../../components/ContactPicker.svelte';
6+
import InlineNotification from '../../components/InlineNotification.svelte';
67
import {
78
ensureSelectedContactLoaded,
89
selectedContact,
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<script lang="ts">
2+
import { invoke } from '@tauri-apps/api/core';
3+
import { revealItemInDir } from '@tauri-apps/plugin-opener';
4+
import { Button } from 'carbon-components-svelte';
5+
import InlineNotification from '../../components/InlineNotification.svelte';
6+
import { invokeIcaCsvToFile, MissingContactError } from '../../lib/cli';
7+
8+
const baseFilename = 'transcript.csv';
9+
10+
let isExporting = $state(false);
11+
let errorMessage = $state('');
12+
let successMessage = $state('');
13+
let exportedFilePath = $state('');
14+
15+
async function exportTranscript(event: Event) {
16+
event.preventDefault();
17+
isExporting = true;
18+
errorMessage = '';
19+
successMessage = '';
20+
21+
try {
22+
const outputPath = await invoke<string>('resolve_download_output_path', {
23+
baseName: baseFilename
24+
});
25+
await invokeIcaCsvToFile(['transcript'], outputPath);
26+
exportedFilePath = outputPath;
27+
successMessage = `Transcript exported to ${outputPath}`;
28+
} catch (error) {
29+
if (error instanceof MissingContactError) {
30+
errorMessage = error.message;
31+
} else {
32+
errorMessage = error instanceof Error ? error.message : String(error);
33+
}
34+
} finally {
35+
isExporting = false;
36+
}
37+
}
38+
39+
async function openDownloads(event: Event) {
40+
event.preventDefault();
41+
if (!exportedFilePath) {
42+
return;
43+
}
44+
try {
45+
await revealItemInDir(exportedFilePath);
46+
} catch (error) {
47+
errorMessage = error instanceof Error ? error.message : String(error);
48+
}
49+
}
50+
</script>
51+
52+
<section class="transcript-export">
53+
<div class="transcript-export__content">
54+
<header>
55+
<h2>Transcript</h2>
56+
<p class="transcript-export__description">
57+
Export your full conversation transcript to CSV in Downloads. The filename will be
58+
incremented when needed (for example, transcript-1.csv).
59+
</p>
60+
</header>
61+
62+
<form class="transcript-export__form" onsubmit={exportTranscript}>
63+
<Button type="submit" kind="primary" disabled={isExporting}>
64+
{isExporting ? 'Exporting…' : 'Export Transcript CSV'}
65+
</Button>
66+
</form>
67+
68+
{#if successMessage}
69+
<div class="transcript-export__notification">
70+
<InlineNotification
71+
kind="success"
72+
title="Export complete"
73+
subtitle={successMessage}
74+
actionLabel="Reveal in Finder"
75+
onAction={openDownloads}
76+
/>
77+
</div>
78+
{/if}
79+
80+
{#if errorMessage}
81+
<div class="transcript-export__notification">
82+
<InlineNotification kind="error" title="Export failed" subtitle={errorMessage} />
83+
</div>
84+
{/if}
85+
</div>
86+
</section>
87+
88+
<style>
89+
.transcript-export {
90+
align-items: center;
91+
}
92+
93+
.transcript-export__content {
94+
width: 100%;
95+
max-width: 56rem;
96+
margin: 0 auto;
97+
display: flex;
98+
flex-direction: column;
99+
align-items: center;
100+
}
101+
102+
.transcript-export__description {
103+
max-width: 44rem;
104+
margin: 0 auto;
105+
}
106+
107+
.transcript-export__form {
108+
margin-top: 1.5rem;
109+
}
110+
111+
.transcript-export__notification {
112+
width: 100%;
113+
max-width: 56rem;
114+
margin: 0 auto;
115+
display: flex;
116+
justify-content: center;
117+
}
118+
</style>

0 commit comments

Comments
 (0)