@@ -150,7 +150,9 @@ export interface UseCatalogueReturn {
150150 // File Export
151151 exportList : ( listId : string ) => Promise < ExportFormat > ;
152152 exportListCompressed : ( listId : string ) => Promise < string > ;
153- exportListAsFile : ( listId : string , format : "json" | "compressed" ) => Promise < void > ;
153+ exportListAsFile : ( listId : string , format : "json" | "compressed" | "csv" | "bibtex" ) => Promise < void > ;
154+ exportListAsCSV : ( listId : string ) => Promise < void > ;
155+ exportListAsBibTeX : ( listId : string ) => Promise < void > ;
154156
155157 // Import Methods
156158 importList : ( data : ExportFormat ) => Promise < string > ;
@@ -843,9 +845,179 @@ export const useCatalogue = (options: UseCatalogueOptions = {}): UseCatalogueRet
843845 }
844846 } , [ exportList , storage ] ) ;
845847
848+ // Helper function to escape CSV values
849+ const escapeCSVValue = useCallback ( ( value : string ) : string => {
850+ // Wrap in quotes if it contains comma, quote, or newline
851+ if ( value . includes ( ',' ) || value . includes ( '"' ) || value . includes ( '\n' ) ) {
852+ return `"${ value . replaceAll ( / " / g, '""' ) } "` ;
853+ }
854+ return value ;
855+ } , [ ] ) ;
856+
857+ // Export list as CSV
858+ const exportListAsCSV = useCallback ( async ( listId : string ) : Promise < void > => {
859+ try {
860+ const list = await storage . getList ( listId ) ;
861+ if ( ! list ) {
862+ throw new Error ( "List not found" ) ;
863+ }
864+
865+ const listEntities = await storage . getListEntities ( listId ) ;
866+
867+ // Create CSV header
868+ const headers = [ "Entity ID" , "Entity Type" , "Notes" , "Position" , "Added At" ] ;
869+ let csv = headers . map ( escapeCSVValue ) . join ( "," ) + "\n" ;
870+
871+ // Add data rows
872+ for ( const entity of listEntities ) {
873+ const row = [
874+ entity . entityId ,
875+ entity . entityType ,
876+ entity . notes || "" ,
877+ entity . position ?. toString ( ) || "" ,
878+ entity . addedAt instanceof Date ? entity . addedAt . toISOString ( ) : entity . addedAt || "" ,
879+ ] ;
880+ csv += row . map ( escapeCSVValue ) . join ( "," ) + "\n" ;
881+ }
882+
883+ // Create filename
884+ const date = new Date ( ) . toISOString ( ) . split ( 'T' ) [ 0 ] ;
885+ const sanitizedTitle = list . title
886+ . replaceAll ( / [ ^ 0 - 9 a - z ] / gi, '-' )
887+ . replaceAll ( / - + / g, '-' )
888+ . replaceAll ( / ^ - / g, '' )
889+ . replaceAll ( / - $ / g, '' )
890+ . toLowerCase ( ) ;
891+ const filename = `catalogue-${ sanitizedTitle } -${ date } .csv` ;
892+
893+ // Create blob and trigger download
894+ const blob = new Blob ( [ csv ] , { type : "text/csv;charset=utf-8;" } ) ;
895+ const url = URL . createObjectURL ( blob ) ;
896+
897+ const link = document . createElement ( "a" ) ;
898+ link . href = url ;
899+ link . download = filename ;
900+ link . style . display = "none" ;
901+
902+ document . body . append ( link ) ;
903+ link . click ( ) ;
904+
905+ link . remove ( ) ;
906+ URL . revokeObjectURL ( url ) ;
907+
908+ logger . debug ( CATALOGUE_LOGGER_CONTEXT , "CSV export completed" , {
909+ listId,
910+ entityCount : listEntities . length ,
911+ filename,
912+ } ) ;
913+ } catch ( error ) {
914+ logger . error ( CATALOGUE_LOGGER_CONTEXT , "Failed to export list as CSV" , { listId, error } ) ;
915+ throw error ;
916+ }
917+ } , [ storage , escapeCSVValue ] ) ;
918+
919+ // Helper function to convert entity to BibTeX format
920+ const convertToBibTeX = useCallback ( ( entity : CatalogueEntity ) : string | null => {
921+ // Only works can be exported to BibTeX
922+ if ( entity . entityType !== "works" ) {
923+ return null ;
924+ }
925+
926+ const citationKey = entity . entityId . replace ( / ^ W / , '' ) ; // Remove W prefix for key
927+ const bibTeXType = "misc" ; // Default to misc since we have limited data
928+
929+ // Basic BibTeX entry with the data we have
930+ let bibtex = `@${ bibTeXType } {${ citationKey } ,\n` ;
931+ bibtex += ` openalex = {${ entity . entityId } },\n` ;
932+
933+ if ( entity . notes ) {
934+ bibtex += ` note = {${ entity . notes . replaceAll ( / " / g, "{" ) . replaceAll ( / } / g, "}" ) } },\n` ;
935+ }
936+
937+ bibtex += `}` ;
938+
939+ return bibtex ;
940+ } , [ ] ) ;
941+
942+ // Export list as BibTeX (works only)
943+ const exportListAsBibTeX = useCallback ( async ( listId : string ) : Promise < void > => {
944+ try {
945+ const list = await storage . getList ( listId ) ;
946+ if ( ! list ) {
947+ throw new Error ( "List not found" ) ;
948+ }
949+
950+ const listEntities = await storage . getListEntities ( listId ) ;
951+
952+ // Filter only works
953+ const works = listEntities . filter ( e => e . entityType === "works" ) ;
954+
955+ if ( works . length === 0 ) {
956+ throw new Error ( "No works found in this list. BibTeX export is only available for works." ) ;
957+ }
958+
959+ let bibtex = "" ;
960+ const skippedEntities : { entityId : string ; type : string } [ ] = [ ] ;
961+
962+ for ( const entity of listEntities ) {
963+ const entry = convertToBibTeX ( entity ) ;
964+ if ( entry ) {
965+ bibtex += entry + "\n\n" ;
966+ } else {
967+ skippedEntities . push ( { entityId : entity . entityId , type : entity . entityType } ) ;
968+ }
969+ }
970+
971+ // Create filename
972+ const date = new Date ( ) . toISOString ( ) . split ( 'T' ) [ 0 ] ;
973+ const sanitizedTitle = list . title
974+ . replaceAll ( / [ ^ 0 - 9 a - z ] / gi, '-' )
975+ . replaceAll ( / - + / g, '-' )
976+ . replaceAll ( / ^ - / g, '' )
977+ . replaceAll ( / - $ / g, '' )
978+ . toLowerCase ( ) ;
979+ const filename = `bibliography-${ sanitizedTitle } -${ date } .bib` ;
980+
981+ // Create blob and trigger download
982+ const blob = new Blob ( [ bibtex ] , { type : "text/plain;charset=utf-8;" } ) ;
983+ const url = URL . createObjectURL ( blob ) ;
984+
985+ const link = document . createElement ( "a" ) ;
986+ link . href = url ;
987+ link . download = filename ;
988+ link . style . display = "none" ;
989+
990+ document . body . append ( link ) ;
991+ link . click ( ) ;
992+
993+ link . remove ( ) ;
994+ URL . revokeObjectURL ( url ) ;
995+
996+ logger . debug ( CATALOGUE_LOGGER_CONTEXT , "BibTeX export completed" , {
997+ listId,
998+ worksExported : works . length ,
999+ skippedCount : skippedEntities . length ,
1000+ skippedTypes : skippedEntities . map ( s => s . type ) ,
1001+ filename,
1002+ } ) ;
1003+ } catch ( error ) {
1004+ logger . error ( CATALOGUE_LOGGER_CONTEXT , "Failed to export list as BibTeX" , { listId, error } ) ;
1005+ throw error ;
1006+ }
1007+ } , [ storage , convertToBibTeX ] ) ;
1008+
8461009 // Export list as downloadable file
847- const exportListAsFile = useCallback ( async ( listId : string , format : "json" | "compressed" ) : Promise < void > => {
1010+ const exportListAsFile = useCallback ( async ( listId : string , format : "json" | "compressed" | "csv" | "bibtex" ) : Promise < void > => {
8481011 try {
1012+ if ( format === "csv" ) {
1013+ await exportListAsCSV ( listId ) ;
1014+ return ;
1015+ }
1016+ if ( format === "bibtex" ) {
1017+ await exportListAsBibTeX ( listId ) ;
1018+ return ;
1019+ }
1020+
8491021 const list = await storage . getList ( listId ) ;
8501022 if ( ! list ) {
8511023 throw new Error ( "List not found" ) ;
@@ -906,7 +1078,7 @@ export const useCatalogue = (options: UseCatalogueOptions = {}): UseCatalogueRet
9061078 logger . error ( CATALOGUE_LOGGER_CONTEXT , "Failed to export list as file" , { listId, format, error } ) ;
9071079 throw error ;
9081080 }
909- } , [ storage , exportList , exportListCompressed ] ) ;
1081+ } , [ storage , exportList , exportListCompressed , exportListAsCSV , exportListAsBibTeX ] ) ;
9101082
9111083 // Import list from ExportFormat data
9121084 const importList = useCallback ( async ( data : ExportFormat ) : Promise < string > => {
@@ -1211,6 +1383,8 @@ export const useCatalogue = (options: UseCatalogueOptions = {}): UseCatalogueRet
12111383 exportList,
12121384 exportListCompressed,
12131385 exportListAsFile,
1386+ exportListAsCSV,
1387+ exportListAsBibTeX,
12141388
12151389 // Import Methods
12161390 importList,
0 commit comments