@@ -26,6 +26,18 @@ import { config } from '$lib/stores/settings.svelte';
2626import { filterByLeafNodeId , findLeafNode } from '$lib/utils' ;
2727import type { McpServerOverride } from '$lib/types/database' ;
2828import { MessageRole } from '$lib/enums' ;
29+ import {
30+ ISO_DATE_TIME_SEPARATOR ,
31+ ISO_DATE_TIME_SEPARATOR_REPLACEMENT ,
32+ ISO_TIMESTAMP_SLICE_LENGTH ,
33+ EXPORT_CONV_ID_TRIM_LENGTH ,
34+ EXPORT_CONV_NONALNUM_REPLACEMENT ,
35+ EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH ,
36+ ISO_TIME_SEPARATOR ,
37+ ISO_TIME_SEPARATOR_REPLACEMENT ,
38+ NON_ALPHANUMERIC_REGEX ,
39+ MULTIPLE_UNDERSCORE_REGEX
40+ } from '$lib/constants' ;
2941
3042class ConversationsStore {
3143 /**
@@ -620,56 +632,83 @@ class ConversationsStore {
620632 */
621633
622634 /**
623- * Downloads a conversation as JSON file.
624- * @param convId - The conversation ID to download
635+ * Generates a sanitized filename for a conversation export
636+ * @param conversation - The conversation metadata
637+ * @param msgs - Optional array of messages belonging to the conversation
638+ * @returns The generated filename string
625639 */
626- async downloadConversation ( convId : string ) : Promise < void > {
627- let conversation : DatabaseConversation | null ;
628- let messages : DatabaseMessage [ ] ;
640+ generateConversationFilename (
641+ conversation : { id ?: string ; name ?: string } ,
642+ msgs ?: DatabaseMessage [ ]
643+ ) : string {
644+ const conversationName = ( conversation . name ?? '' ) . trim ( ) . toLowerCase ( ) ;
629645
630- if ( this . activeConversation ?. id === convId ) {
631- conversation = this . activeConversation ;
632- messages = this . activeMessages ;
633- } else {
634- conversation = await DatabaseService . getConversation ( convId ) ;
635- if ( ! conversation ) return ;
636- messages = await DatabaseService . getConversationMessages ( convId ) ;
637- }
646+ const sanitizedName = conversationName
647+ . replace ( NON_ALPHANUMERIC_REGEX , EXPORT_CONV_NONALNUM_REPLACEMENT )
648+ . replace ( MULTIPLE_UNDERSCORE_REGEX , '_' )
649+ . substring ( 0 , EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH ) ;
650+
651+ // If we have messages, use the timestamp of the newest message
652+ const referenceDate = msgs ?. length
653+ ? new Date ( Math . max ( ...msgs . map ( ( m ) => m . timestamp ) ) )
654+ : new Date ( ) ;
638655
639- this . triggerDownload ( { conv : conversation , messages } ) ;
656+ const iso = referenceDate . toISOString ( ) . slice ( 0 , ISO_TIMESTAMP_SLICE_LENGTH ) ;
657+ const formattedDate = iso
658+ . replace ( ISO_DATE_TIME_SEPARATOR , ISO_DATE_TIME_SEPARATOR_REPLACEMENT )
659+ . replaceAll ( ISO_TIME_SEPARATOR , ISO_TIME_SEPARATOR_REPLACEMENT ) ;
660+ const trimmedConvId = conversation . id ?. slice ( 0 , EXPORT_CONV_ID_TRIM_LENGTH ) ?? '' ;
661+ return `${ formattedDate } _conv_${ trimmedConvId } _${ sanitizedName } .json` ;
640662 }
641663
642664 /**
643- * Exports all conversations with their messages as a JSON file
644- * @returns The list of exported conversations
665+ * Triggers a browser download of the provided exported conversation data
666+ * @param data - The exported conversation payload (either a single conversation or array of them)
667+ * @param filename - Filename; if omitted, a deterministic name is generated
645668 */
646- async exportAllConversations ( ) : Promise < DatabaseConversation [ ] > {
647- const allConversations = await DatabaseService . getAllConversations ( ) ;
669+ downloadConversationFile ( data : ExportedConversations , filename ?: string ) : void {
670+ // Choose the first conversation or message
671+ const conversation =
672+ 'conv' in data ? data . conv : Array . isArray ( data ) ? data [ 0 ] ?. conv : undefined ;
673+ const msgs =
674+ 'messages' in data ? data . messages : Array . isArray ( data ) ? data [ 0 ] ?. messages : undefined ;
648675
649- if ( allConversations . length === 0 ) {
650- throw new Error ( 'No conversations to export' ) ;
676+ if ( ! conversation ) {
677+ console . error ( 'Invalid data: missing conversation' ) ;
678+ return ;
651679 }
652680
653- const allData = await Promise . all (
654- allConversations . map ( async ( conv ) => {
655- const messages = await DatabaseService . getConversationMessages ( conv . id ) ;
656- return { conv, messages } ;
657- } )
658- ) ;
681+ const downloadFilename = filename ?? this . generateConversationFilename ( conversation , msgs ) ;
659682
660- const blob = new Blob ( [ JSON . stringify ( allData , null , 2 ) ] , { type : 'application/json' } ) ;
683+ const blob = new Blob ( [ JSON . stringify ( data , null , 2 ) ] , { type : 'application/json' } ) ;
661684 const url = URL . createObjectURL ( blob ) ;
662685 const a = document . createElement ( 'a' ) ;
663686 a . href = url ;
664- a . download = `all_conversations_ ${ new Date ( ) . toISOString ( ) . split ( 'T' ) [ 0 ] } .json` ;
687+ a . download = downloadFilename ;
665688 document . body . appendChild ( a ) ;
666689 a . click ( ) ;
667690 document . body . removeChild ( a ) ;
668691 URL . revokeObjectURL ( url ) ;
692+ }
693+
694+ /**
695+ * Downloads a conversation as JSON file.
696+ * @param convId - The conversation ID to download
697+ */
698+ async downloadConversation ( convId : string ) : Promise < void > {
699+ let conversation : DatabaseConversation | null ;
700+ let messages : DatabaseMessage [ ] ;
669701
670- toast . success ( `All conversations (${ allConversations . length } ) prepared for download` ) ;
702+ if ( this . activeConversation ?. id === convId ) {
703+ conversation = this . activeConversation ;
704+ messages = this . activeMessages ;
705+ } else {
706+ conversation = await DatabaseService . getConversation ( convId ) ;
707+ if ( ! conversation ) return ;
708+ messages = await DatabaseService . getConversationMessages ( convId ) ;
709+ }
671710
672- return allConversations ;
711+ this . downloadConversationFile ( { conv : conversation , messages } ) ;
673712 }
674713
675714 /**
@@ -743,37 +782,6 @@ class ConversationsStore {
743782 await this . loadConversations ( ) ;
744783 return result ;
745784 }
746-
747- /**
748- * Triggers file download in browser
749- */
750- private triggerDownload ( data : ExportedConversations , filename ?: string ) : void {
751- const conversation =
752- 'conv' in data ? data . conv : Array . isArray ( data ) ? data [ 0 ] ?. conv : undefined ;
753-
754- if ( ! conversation ) {
755- console . error ( 'Invalid data: missing conversation' ) ;
756- return ;
757- }
758-
759- const conversationName = conversation . name ?. trim ( ) || '' ;
760- const truncatedSuffix = conversationName
761- . toLowerCase ( )
762- . replace ( / [ ^ a - z 0 - 9 ] / gi, '_' )
763- . replace ( / _ + / g, '_' )
764- . substring ( 0 , 20 ) ;
765- const downloadFilename = filename || `conversation_${ conversation . id } _${ truncatedSuffix } .json` ;
766-
767- const blob = new Blob ( [ JSON . stringify ( data , null , 2 ) ] , { type : 'application/json' } ) ;
768- const url = URL . createObjectURL ( blob ) ;
769- const a = document . createElement ( 'a' ) ;
770- a . href = url ;
771- a . download = downloadFilename ;
772- document . body . appendChild ( a ) ;
773- a . click ( ) ;
774- document . body . removeChild ( a ) ;
775- URL . revokeObjectURL ( url ) ;
776- }
777785}
778786
779787export const conversationsStore = new ConversationsStore ( ) ;
0 commit comments