Skip to content

Commit 59ac39c

Browse files
committed
feat(web): implement CSV and BibTeX export for catalogue lists
Users can now export their catalogue lists to CSV and BibTeX formats. CSV export includes all entities with ID, type, notes, position, and date. BibTeX export is works-only and creates basic BibTeX entries with OpenAlex IDs and user notes. Changes: - Add exportListAsCSV function to useCatalogue hook - Add exportListAsBibTeX function to useCatalogue hook - Add escapeCSVValue helper for proper CSV formatting - Add convertToBibTeX helper for BibTeX entry generation - Update exportListAsFile to handle csv and bibtex formats - Update ExportModal to enable CSV and BibTeX options - Remove "not implemented" alert from ExportModal - Add proper error messages for BibTeX with no works
1 parent 16237ad commit 59ac39c

File tree

2 files changed

+194
-39
lines changed

2 files changed

+194
-39
lines changed

apps/web/src/components/catalogue/ExportModal.tsx

Lines changed: 17 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -37,32 +37,22 @@ export const ExportModal = ({ listId, listTitle, onClose }: ExportModalProps) =>
3737
setIsExporting(true);
3838
setExportSuccess(false);
3939
try {
40-
// Call the appropriate export method based on format
41-
if (selectedFormat === "json" || selectedFormat === "compressed") {
42-
await exportListAsFile(listId, selectedFormat);
43-
44-
logger.debug("catalogue-ui", "List exported successfully", {
45-
listId,
46-
listTitle,
47-
format: selectedFormat,
48-
});
49-
50-
setExportSuccess(true);
51-
52-
notifications.show({
53-
title: "Export Successful",
54-
message: `List exported as ${selectedFormat.toUpperCase()} format`,
55-
color: "green",
56-
icon: <IconCheck size={ICON_SIZE.MD} />,
57-
});
58-
} else {
59-
// CSV and BibTeX not yet implemented
60-
notifications.show({
61-
title: "Not Implemented",
62-
message: `${selectedFormat.toUpperCase()} export is not yet implemented`,
63-
color: "yellow",
64-
});
65-
}
40+
await exportListAsFile(listId, selectedFormat);
41+
42+
logger.debug("catalogue-ui", "List exported successfully", {
43+
listId,
44+
listTitle,
45+
format: selectedFormat,
46+
});
47+
48+
setExportSuccess(true);
49+
50+
notifications.show({
51+
title: "Export Successful",
52+
message: `List exported as ${selectedFormat.toUpperCase()} format`,
53+
color: "green",
54+
icon: <IconCheck size={ICON_SIZE.MD} />,
55+
});
6656
} catch (error) {
6757
logger.error("catalogue-ui", "Failed to export list", {
6858
listId,
@@ -72,7 +62,7 @@ export const ExportModal = ({ listId, listTitle, onClose }: ExportModalProps) =>
7262

7363
notifications.show({
7464
title: "Export Failed",
75-
message: "Failed to export list. Please try again.",
65+
message: error instanceof Error ? error.message : "Failed to export list. Please try again.",
7666
color: "red",
7767
});
7868
} finally {
@@ -111,14 +101,12 @@ export const ExportModal = ({ listId, listTitle, onClose }: ExportModalProps) =>
111101
value="csv"
112102
label="CSV"
113103
description="Spreadsheet-compatible format"
114-
disabled
115104
aria-describedby="csv-description"
116105
/>
117106
<Radio
118107
value="bibtex"
119108
label="BibTeX"
120109
description="Bibliography format (works only)"
121-
disabled
122110
aria-describedby="bibtex-description"
123111
/>
124112
</Stack>
@@ -137,20 +125,13 @@ export const ExportModal = ({ listId, listTitle, onClose }: ExportModalProps) =>
137125
</Alert>
138126
)}
139127

140-
{(selectedFormat === "csv" || selectedFormat === "bibtex") && (
141-
<Alert icon={<IconAlertCircle size={ICON_SIZE.MD} />} color="yellow">
142-
This export format is not yet implemented. Please use JSON or Compressed Data format.
143-
</Alert>
144-
)}
145-
146128
<Group justify="flex-end" gap="xs">
147129
<Button variant="subtle" onClick={onClose} disabled={isExporting}>
148130
{exportSuccess ? "Done" : "Cancel"}
149131
</Button>
150132
<Button
151133
onClick={handleExport}
152134
loading={isExporting}
153-
disabled={selectedFormat === "csv" || selectedFormat === "bibtex"}
154135
leftSection={<IconDownload size={ICON_SIZE.MD} />}
155136
data-testid="export-list-button"
156137
>

apps/web/src/hooks/useCatalogue.ts

Lines changed: 177 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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-9a-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-9a-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

Comments
 (0)