webui: use date in more human readable exported filename (#19939)
* webui: use date in exported filename Move conversation naming and export to utils update index.html.gz * webui: move literals to message export constants file * webui: move export naming and download back to the conversation store * chore: update webui build output * webui: add comments to some constants * chore: update webui build output
This commit is contained in:
Binary file not shown.
+5
-12
@@ -3,6 +3,7 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { DialogConversationSelection, DialogConfirmation } from '$lib/components/app';
|
import { DialogConversationSelection, DialogConfirmation } from '$lib/components/app';
|
||||||
import { createMessageCountMap } from '$lib/utils';
|
import { createMessageCountMap } from '$lib/utils';
|
||||||
|
import { ISO_DATE_TIME_SEPARATOR } from '$lib/constants';
|
||||||
import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
|
import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
@@ -55,18 +56,10 @@
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const blob = new Blob([JSON.stringify(allData, null, 2)], {
|
conversationsStore.downloadConversationFile(
|
||||||
type: 'application/json'
|
allData,
|
||||||
});
|
`${new Date().toISOString().split(ISO_DATE_TIME_SEPARATOR)[0]}_conversations.json`
|
||||||
const url = URL.createObjectURL(blob);
|
);
|
||||||
const a = document.createElement('a');
|
|
||||||
|
|
||||||
a.href = url;
|
|
||||||
a.download = `conversations_${new Date().toISOString().split('T')[0]}.json`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
exportedConversations = selectedConversations;
|
exportedConversations = selectedConversations;
|
||||||
showExportSummary = true;
|
showExportSummary = true;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export * from './max-bundle-size';
|
|||||||
export * from './mcp';
|
export * from './mcp';
|
||||||
export * from './mcp-form';
|
export * from './mcp-form';
|
||||||
export * from './mcp-resource';
|
export * from './mcp-resource';
|
||||||
|
export * from './message-export';
|
||||||
export * from './model-id';
|
export * from './model-id';
|
||||||
export * from './precision';
|
export * from './precision';
|
||||||
export * from './processing-info';
|
export * from './processing-info';
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Conversation filename constants
|
||||||
|
|
||||||
|
// Length of the trimmed conversation ID in the filename
|
||||||
|
export const EXPORT_CONV_ID_TRIM_LENGTH = 8;
|
||||||
|
// Maximum length of the sanitized conversation name snippet
|
||||||
|
export const EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH = 20;
|
||||||
|
// Characters to keep in the ISO timestamp. 19 keeps 2026-01-01T00:00:00
|
||||||
|
export const ISO_TIMESTAMP_SLICE_LENGTH = 19;
|
||||||
|
|
||||||
|
// Replacements for making the conversation title filename-friendly
|
||||||
|
export const NON_ALPHANUMERIC_REGEX = /[^a-z0-9]/gi;
|
||||||
|
export const EXPORT_CONV_NONALNUM_REPLACEMENT = '_';
|
||||||
|
export const MULTIPLE_UNDERSCORE_REGEX = /_+/g;
|
||||||
|
|
||||||
|
// Replacements to the ISO date for use in the export filename
|
||||||
|
export const ISO_DATE_TIME_SEPARATOR = 'T';
|
||||||
|
export const ISO_DATE_TIME_SEPARATOR_REPLACEMENT = '_';
|
||||||
|
|
||||||
|
export const ISO_TIME_SEPARATOR = ':';
|
||||||
|
export const ISO_TIME_SEPARATOR_REPLACEMENT = '-';
|
||||||
@@ -26,6 +26,18 @@ import { config } from '$lib/stores/settings.svelte';
|
|||||||
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
|
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
|
||||||
import type { McpServerOverride } from '$lib/types/database';
|
import type { McpServerOverride } from '$lib/types/database';
|
||||||
import { MessageRole } from '$lib/enums';
|
import { MessageRole } from '$lib/enums';
|
||||||
|
import {
|
||||||
|
ISO_DATE_TIME_SEPARATOR,
|
||||||
|
ISO_DATE_TIME_SEPARATOR_REPLACEMENT,
|
||||||
|
ISO_TIMESTAMP_SLICE_LENGTH,
|
||||||
|
EXPORT_CONV_ID_TRIM_LENGTH,
|
||||||
|
EXPORT_CONV_NONALNUM_REPLACEMENT,
|
||||||
|
EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH,
|
||||||
|
ISO_TIME_SEPARATOR,
|
||||||
|
ISO_TIME_SEPARATOR_REPLACEMENT,
|
||||||
|
NON_ALPHANUMERIC_REGEX,
|
||||||
|
MULTIPLE_UNDERSCORE_REGEX
|
||||||
|
} from '$lib/constants';
|
||||||
|
|
||||||
class ConversationsStore {
|
class ConversationsStore {
|
||||||
/**
|
/**
|
||||||
@@ -619,6 +631,66 @@ class ConversationsStore {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a sanitized filename for a conversation export
|
||||||
|
* @param conversation - The conversation metadata
|
||||||
|
* @param msgs - Optional array of messages belonging to the conversation
|
||||||
|
* @returns The generated filename string
|
||||||
|
*/
|
||||||
|
generateConversationFilename(
|
||||||
|
conversation: { id?: string; name?: string },
|
||||||
|
msgs?: DatabaseMessage[]
|
||||||
|
): string {
|
||||||
|
const conversationName = (conversation.name ?? '').trim().toLowerCase();
|
||||||
|
|
||||||
|
const sanitizedName = conversationName
|
||||||
|
.replace(NON_ALPHANUMERIC_REGEX, EXPORT_CONV_NONALNUM_REPLACEMENT)
|
||||||
|
.replace(MULTIPLE_UNDERSCORE_REGEX, '_')
|
||||||
|
.substring(0, EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH);
|
||||||
|
|
||||||
|
// If we have messages, use the timestamp of the newest message
|
||||||
|
const referenceDate = msgs?.length
|
||||||
|
? new Date(Math.max(...msgs.map((m) => m.timestamp)))
|
||||||
|
: new Date();
|
||||||
|
|
||||||
|
const iso = referenceDate.toISOString().slice(0, ISO_TIMESTAMP_SLICE_LENGTH);
|
||||||
|
const formattedDate = iso
|
||||||
|
.replace(ISO_DATE_TIME_SEPARATOR, ISO_DATE_TIME_SEPARATOR_REPLACEMENT)
|
||||||
|
.replaceAll(ISO_TIME_SEPARATOR, ISO_TIME_SEPARATOR_REPLACEMENT);
|
||||||
|
const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV_ID_TRIM_LENGTH) ?? '';
|
||||||
|
return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers a browser download of the provided exported conversation data
|
||||||
|
* @param data - The exported conversation payload (either a single conversation or array of them)
|
||||||
|
* @param filename - Filename; if omitted, a deterministic name is generated
|
||||||
|
*/
|
||||||
|
downloadConversationFile(data: ExportedConversations, filename?: string): void {
|
||||||
|
// Choose the first conversation or message
|
||||||
|
const conversation =
|
||||||
|
'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined;
|
||||||
|
const msgs =
|
||||||
|
'messages' in data ? data.messages : Array.isArray(data) ? data[0]?.messages : undefined;
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
console.error('Invalid data: missing conversation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadFilename = filename ?? this.generateConversationFilename(conversation, msgs);
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = downloadFilename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads a conversation as JSON file.
|
* Downloads a conversation as JSON file.
|
||||||
* @param convId - The conversation ID to download
|
* @param convId - The conversation ID to download
|
||||||
@@ -636,40 +708,7 @@ class ConversationsStore {
|
|||||||
messages = await DatabaseService.getConversationMessages(convId);
|
messages = await DatabaseService.getConversationMessages(convId);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.triggerDownload({ conv: conversation, messages });
|
this.downloadConversationFile({ conv: conversation, messages });
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exports all conversations with their messages as a JSON file
|
|
||||||
* @returns The list of exported conversations
|
|
||||||
*/
|
|
||||||
async exportAllConversations(): Promise<DatabaseConversation[]> {
|
|
||||||
const allConversations = await DatabaseService.getAllConversations();
|
|
||||||
|
|
||||||
if (allConversations.length === 0) {
|
|
||||||
throw new Error('No conversations to export');
|
|
||||||
}
|
|
||||||
|
|
||||||
const allData = await Promise.all(
|
|
||||||
allConversations.map(async (conv) => {
|
|
||||||
const messages = await DatabaseService.getConversationMessages(conv.id);
|
|
||||||
return { conv, messages };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const blob = new Blob([JSON.stringify(allData, null, 2)], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `all_conversations_${new Date().toISOString().split('T')[0]}.json`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
toast.success(`All conversations (${allConversations.length}) prepared for download`);
|
|
||||||
|
|
||||||
return allConversations;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -743,37 +782,6 @@ class ConversationsStore {
|
|||||||
await this.loadConversations();
|
await this.loadConversations();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Triggers file download in browser
|
|
||||||
*/
|
|
||||||
private triggerDownload(data: ExportedConversations, filename?: string): void {
|
|
||||||
const conversation =
|
|
||||||
'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined;
|
|
||||||
|
|
||||||
if (!conversation) {
|
|
||||||
console.error('Invalid data: missing conversation');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversationName = conversation.name?.trim() || '';
|
|
||||||
const truncatedSuffix = conversationName
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]/gi, '_')
|
|
||||||
.replace(/_+/g, '_')
|
|
||||||
.substring(0, 20);
|
|
||||||
const downloadFilename = filename || `conversation_${conversation.id}_${truncatedSuffix}.json`;
|
|
||||||
|
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = downloadFilename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const conversationsStore = new ConversationsStore();
|
export const conversationsStore = new ConversationsStore();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Utility functions for conversation data manipulation
|
* Utility functions for conversation data manipulation
|
||||||
*/
|
*/
|
||||||
|
import type { DatabaseMessage } from '$lib/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a map of conversation IDs to their message counts from exported conversation data
|
* Creates a map of conversation IDs to their message counts from exported conversation data
|
||||||
|
|||||||
Reference in New Issue
Block a user