feat: add session import/export functionality

- Add HTML export with dark/light theme support and styled message types
- Add PDF export via print-optimized HTML opened in browser
- Include metadata toggle option for all export formats
- Add export buttons to SessionHistoryPanel dropdown

Closes #34
This commit is contained in:
2026-01-25 15:28:06 -08:00
parent 02987f1a17
commit a30b3a282c
2 changed files with 415 additions and 0 deletions
@@ -102,6 +102,16 @@
await sessionsStore.exportSessionAsMarkdown(sessionId);
}
async function handleExportHtml(sessionId: string): Promise<void> {
showExportMenu = null;
await sessionsStore.exportSessionAsHtml(sessionId);
}
async function handleExportPdf(sessionId: string): Promise<void> {
showExportMenu = null;
await sessionsStore.exportSessionAsPdf(sessionId);
}
async function handleImport(): Promise<void> {
isImporting = true;
try {
@@ -361,6 +371,18 @@
>
Export as Markdown
</button>
<button
onclick={() => handleExportHtml(session.id)}
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
Export as HTML
</button>
<button
onclick={() => handleExportPdf(session.id)}
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
Export as PDF
</button>
</div>
{/if}
</div>
+393
View File
@@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
import { save, open } from "@tauri-apps/plugin-dialog";
import type { Conversation } from "./conversations";
import { writeTextFile, readTextFile } from "@tauri-apps/plugin-fs";
import { openPath } from "@tauri-apps/plugin-opener";
// Debounce delay for auto-save (in milliseconds)
const AUTO_SAVE_DEBOUNCE_MS = 2000;
@@ -36,6 +37,335 @@ export interface SessionListItem {
preview: string;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function generateHtmlExport(session: SavedSession, includeMetadata: boolean): string {
const styles = `
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-code: #1e1e2e;
--accent-primary: #e94560;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--border-color: #2a2a4a;
--terminal-user: #22d3ee;
--terminal-tool: #c084fc;
--terminal-error: #f87171;
--hljs-keyword: #f472b6;
--hljs-string: #a3e635;
--hljs-number: #fbbf24;
--hljs-comment: #6b7280;
--hljs-function: #c084fc;
--hljs-type: #22d3ee;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
padding: 2rem;
max-width: 900px;
margin: 0 auto;
}
h1 { color: var(--accent-primary); margin-bottom: 1rem; }
.metadata {
background: var(--bg-secondary);
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
border: 1px solid var(--border-color);
}
.metadata p { color: var(--text-secondary); margin: 0.25rem 0; }
.metadata strong { color: var(--text-primary); }
hr { border: none; border-top: 1px solid var(--border-color); margin: 1.5rem 0; }
.message {
background: var(--bg-secondary);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
border-left: 4px solid var(--border-color);
}
.message.user { border-left-color: var(--terminal-user); }
.message.assistant { border-left-color: var(--accent-primary); }
.message.tool_use { border-left-color: var(--terminal-tool); }
.message.tool_result { border-left-color: var(--hljs-string); }
.message.error { border-left-color: var(--terminal-error); }
.message.system { border-left-color: var(--text-secondary); }
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.message-type {
font-weight: bold;
text-transform: capitalize;
}
.message-type.user { color: var(--terminal-user); }
.message-type.assistant { color: var(--accent-primary); }
.message-type.tool_use { color: var(--terminal-tool); }
.message-type.tool_result { color: var(--hljs-string); }
.message-type.error { color: var(--terminal-error); }
.message-type.system { color: var(--text-secondary); }
.timestamp { color: var(--text-secondary); font-size: 0.85rem; }
.tool-name { color: var(--terminal-tool); font-size: 0.9rem; }
.message-content {
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9rem;
}
pre {
background: var(--bg-code);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 0.5rem 0;
}
code {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.85rem;
}
.footer {
text-align: center;
color: var(--text-secondary);
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
@media (prefers-color-scheme: light) {
:root {
--bg-primary: #f8f9fa;
--bg-secondary: #ffffff;
--bg-code: #f5f5f5;
--text-primary: #1a1a2e;
--text-secondary: #5a5a7a;
--border-color: #d0d0e0;
--terminal-user: #0891b2;
--terminal-tool: #7c3aed;
--terminal-error: #dc2626;
--hljs-keyword: #d946ef;
--hljs-string: #16a34a;
}
}
`;
let messagesHtml = "";
for (const message of session.messages) {
const timestamp = new Date(message.timestamp).toLocaleString();
const typeLabel =
message.type === "tool_use"
? "Tool Use"
: message.type === "tool_result"
? "Tool Result"
: message.type.charAt(0).toUpperCase() + message.type.slice(1);
messagesHtml += `
<div class="message ${message.type}">
<div class="message-header">
<span class="message-type ${message.type}">${typeLabel}</span>
<span class="timestamp">${timestamp}</span>
</div>
${message.tool_name ? `<div class="tool-name">🔧 ${escapeHtml(message.tool_name)}</div>` : ""}
<div class="message-content">${escapeHtml(message.content)}</div>
</div>
`;
}
const metadataHtml = includeMetadata
? `
<div class="metadata">
<p><strong>Created:</strong> ${new Date(session.created_at).toLocaleString()}</p>
<p><strong>Last Activity:</strong> ${new Date(session.last_activity_at).toLocaleString()}</p>
<p><strong>Working Directory:</strong> ${session.working_directory || "Not set"}</p>
<p><strong>Messages:</strong> ${session.message_count}</p>
</div>
`
: "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(session.name)} - Hikari Desktop Export</title>
<style>${styles}</style>
</head>
<body>
<h1>💬 ${escapeHtml(session.name)}</h1>
${metadataHtml}
<hr>
${messagesHtml}
<div class="footer">
<p>✨ Exported from Hikari Desktop</p>
<p>Export date: ${new Date().toLocaleString()}</p>
</div>
</body>
</html>`;
}
function generatePrintableHtml(session: SavedSession, includeMetadata: boolean): string {
const printStyles = `
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 11pt;
line-height: 1.5;
padding: 0.5in;
max-width: 8.5in;
margin: 0 auto;
color: #000;
background: #fff;
}
h1 {
font-size: 18pt;
margin-bottom: 0.25in;
border-bottom: 2px solid #333;
padding-bottom: 0.1in;
}
.metadata {
background: #f5f5f5;
padding: 0.15in;
margin-bottom: 0.25in;
border: 1px solid #ddd;
font-size: 9pt;
}
.metadata p { margin: 2pt 0; }
hr { border: none; border-top: 1px solid #ccc; margin: 0.2in 0; }
.message {
margin-bottom: 0.15in;
padding: 0.1in;
border-left: 3px solid #ccc;
page-break-inside: avoid;
}
.message.user { border-left-color: #0088cc; }
.message.assistant { border-left-color: #cc0044; }
.message.tool_use { border-left-color: #8844cc; }
.message.tool_result { border-left-color: #44cc44; }
.message.error { border-left-color: #cc4444; }
.message.system { border-left-color: #888; }
.message-header {
display: flex;
justify-content: space-between;
font-size: 9pt;
color: #666;
margin-bottom: 0.05in;
border-bottom: 1px dotted #ddd;
padding-bottom: 0.03in;
}
.message-type { font-weight: bold; text-transform: uppercase; }
.message-type.user { color: #0066aa; }
.message-type.assistant { color: #aa0033; }
.message-type.tool_use { color: #6633aa; }
.message-type.tool_result { color: #33aa33; }
.message-type.error { color: #aa3333; }
.tool-name { color: #666; font-size: 9pt; font-style: italic; }
.message-content {
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 9pt;
background: #fafafa;
padding: 0.05in;
}
.footer {
text-align: center;
font-size: 8pt;
color: #999;
margin-top: 0.25in;
padding-top: 0.1in;
border-top: 1px solid #ddd;
}
.print-instructions {
background: #ffffcc;
border: 1px solid #cccc00;
padding: 0.15in;
margin-bottom: 0.25in;
font-size: 10pt;
}
.print-instructions h2 { font-size: 12pt; margin-bottom: 0.1in; }
.print-instructions ol { margin-left: 0.2in; }
.print-instructions li { margin: 0.05in 0; }
@media print {
.print-instructions { display: none; }
body { padding: 0; }
}
@page { margin: 0.5in; }
`;
let messagesHtml = "";
for (const message of session.messages) {
const timestamp = new Date(message.timestamp).toLocaleString();
const typeLabel =
message.type === "tool_use"
? "Tool"
: message.type === "tool_result"
? "Result"
: message.type.charAt(0).toUpperCase() + message.type.slice(1);
messagesHtml += `
<div class="message ${message.type}">
<div class="message-header">
<span class="message-type ${message.type}">${typeLabel}</span>
<span class="timestamp">${timestamp}</span>
</div>
${message.tool_name ? `<div class="tool-name">${escapeHtml(message.tool_name)}</div>` : ""}
<div class="message-content">${escapeHtml(message.content)}</div>
</div>
`;
}
const metadataHtml = includeMetadata
? `
<div class="metadata">
<p><strong>Created:</strong> ${new Date(session.created_at).toLocaleString()}</p>
<p><strong>Last Activity:</strong> ${new Date(session.last_activity_at).toLocaleString()}</p>
<p><strong>Working Directory:</strong> ${session.working_directory || "Not set"}</p>
<p><strong>Messages:</strong> ${session.message_count}</p>
</div>
`
: "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(session.name)} - Print to PDF</title>
<style>${printStyles}</style>
</head>
<body>
<div class="print-instructions">
<h2>Print to PDF Instructions</h2>
<ol>
<li>Press <strong>Ctrl+P</strong> (or <strong>Cmd+P</strong> on Mac) to open the print dialog</li>
<li>Select <strong>"Save as PDF"</strong> or <strong>"Microsoft Print to PDF"</strong> as the printer</li>
<li>Click <strong>Print</strong> to save your PDF</li>
</ol>
<p><em>This instruction box will not appear in your PDF.</em></p>
</div>
<h1>${escapeHtml(session.name)}</h1>
${metadataHtml}
<hr>
${messagesHtml}
<div class="footer">
<p>Exported from Hikari Desktop | ${new Date().toLocaleString()}</p>
</div>
</body>
</html>`;
}
function createSessionsStore() {
const sessions = writable<SessionListItem[]>([]);
const isLoading = writable(false);
@@ -260,6 +590,67 @@ function createSessionsStore() {
}
}
async function exportSessionAsHtml(
sessionId: string,
includeMetadata: boolean = true
): Promise<boolean> {
try {
const session = await loadSession(sessionId);
if (!session) {
console.error("Session not found for export");
return false;
}
const filePath = await save({
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}.html`,
filters: [{ name: "HTML", extensions: ["html"] }],
});
if (!filePath) {
return false;
}
const html = generateHtmlExport(session, includeMetadata);
await writeTextFile(filePath, html);
return true;
} catch (error) {
console.error("Failed to export session as HTML:", error);
return false;
}
}
async function exportSessionAsPdf(
sessionId: string,
includeMetadata: boolean = true
): Promise<boolean> {
try {
const session = await loadSession(sessionId);
if (!session) {
console.error("Session not found for export");
return false;
}
const filePath = await save({
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}-print.html`,
filters: [{ name: "HTML (for PDF printing)", extensions: ["html"] }],
});
if (!filePath) {
return false;
}
const html = generatePrintableHtml(session, includeMetadata);
await writeTextFile(filePath, html);
// Open the file in the default browser for print-to-PDF
await openPath(filePath);
return true;
} catch (error) {
console.error("Failed to export session for PDF:", error);
return false;
}
}
async function importSession(): Promise<boolean> {
try {
const filePath = await open({
@@ -308,6 +699,8 @@ function createSessionsStore() {
cancelAutoSave,
exportSessionAsJson,
exportSessionAsMarkdown,
exportSessionAsHtml,
exportSessionAsPdf,
importSession,
};
}