Skip to content

Commit d2b2cb2

Browse files
committed
feat(web): add result export (CSV/BibTeX) (task-5)
- Created exportUtils with CSV and BibTeX export functions - Created ExportButton component with format selection menu - Integrated export button into search results header - CSV export includes: ID, name, type, citations, works, hint, URL - BibTeX export supports works and authors with @misc entries - Automatic filename generation with query and timestamp
1 parent f2f7abb commit d2b2cb2

File tree

3 files changed

+211
-0
lines changed

3 files changed

+211
-0
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Export Button Component
3+
*
4+
* Provides export functionality for search results to CSV and BibTeX formats.
5+
*/
6+
7+
import { ActionIcon, Group, Menu, Stack, Text, Tooltip } from '@mantine/core';
8+
import { IconDownload, IconFileExport, IconFileTypography, IconTable } from '@tabler/icons-react';
9+
10+
import { ICON_SIZE } from '@/config/style-constants';
11+
import { exportToBibTeX, exportToCSV, getExportFilename } from '@/utils/exportUtils';
12+
13+
import type { AutocompleteResult } from '@bibgraph/types';
14+
15+
interface ExportButtonProps {
16+
results: AutocompleteResult[];
17+
query: string;
18+
disabled?: boolean;
19+
}
20+
21+
export const ExportButton: React.FC<ExportButtonProps> = ({ results, query, disabled = false }) => {
22+
const handleExportCSV = () => {
23+
const filename = getExportFilename(query, 'csv');
24+
exportToCSV(results, filename);
25+
};
26+
27+
const handleExportBibTeX = () => {
28+
const filename = getExportFilename(query, 'bib');
29+
exportToBibTeX(results, filename);
30+
};
31+
32+
return (
33+
<Menu position="bottom-end" shadow="md" withinPortal>
34+
<Menu.Target>
35+
<Tooltip label="Export results" withinPortal>
36+
<ActionIcon variant="subtle" size="input-lg" disabled={disabled || results.length === 0}>
37+
<IconFileExport size={ICON_SIZE.MD} />
38+
</ActionIcon>
39+
</Tooltip>
40+
</Menu.Target>
41+
42+
<Menu.Dropdown>
43+
<Stack gap="xs">
44+
<Text size="sm" fw={500} px="xs">
45+
Export {results.length} result{results.length !== 1 ? 's' : ''}
46+
</Text>
47+
48+
<Menu.Item
49+
leftSection={<IconTable size={ICON_SIZE.SM} />}
50+
onClick={handleExportCSV}
51+
disabled={results.length === 0}
52+
>
53+
Export as CSV
54+
</Menu.Item>
55+
56+
<Menu.Item
57+
leftSection={<IconFileTypography size={ICON_SIZE.SM} />}
58+
onClick={handleExportBibTeX}
59+
disabled={results.length === 0}
60+
>
61+
Export as BibTeX
62+
</Menu.Item>
63+
</Stack>
64+
</Menu.Dropdown>
65+
</Menu>
66+
);
67+
};

apps/web/src/routes/search.lazy.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { useUserInteractions } from "@/hooks/use-user-interactions";
4040
import { useGraphList } from "@/hooks/useGraphList";
4141

4242
import { AdvancedQueryBuilder, type QueryStructure } from "../components/search/AdvancedQueryBuilder";
43+
import { ExportButton } from "../components/export/ExportButton";
4344
import { SearchInterface } from "../components/search/SearchInterface";
4445
import { SearchResultsSkeleton } from "../components/search/SearchResultsSkeleton";
4546
import { pageDescription, pageTitle } from "../styles/layout.css";
@@ -543,6 +544,10 @@ const SearchPage = () => {
543544
]}
544545
size="xs"
545546
/>
547+
<ExportButton
548+
results={sortedResults}
549+
query={searchFilters.query}
550+
/>
546551
</Group>
547552
</Group>
548553

apps/web/src/utils/exportUtils.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* Export Utilities
3+
*
4+
* Provides functionality to export search results to CSV and BibTeX formats.
5+
*/
6+
7+
import type { AutocompleteResult } from '@bibgraph/types';
8+
9+
/**
10+
* Convert search results to CSV format
11+
*/
12+
export const exportToCSV = (results: AutocompleteResult[], filename?: string): void => {
13+
if (results.length === 0) return;
14+
15+
// CSV headers
16+
const headers = [
17+
'ID',
18+
'Display Name',
19+
'Entity Type',
20+
'Citation Count',
21+
'Works Count',
22+
'Hint',
23+
'URL',
24+
];
25+
26+
// Convert results to CSV rows
27+
const rows = results.map((result) => [
28+
result.id,
29+
`"${result.display_name.replace(/"/g, '""')}"`, // Escape quotes
30+
result.entity_type,
31+
result.cited_by_count ?? 0,
32+
result.works_count ?? 0,
33+
result.hint ?? '',
34+
result.id,
35+
]);
36+
37+
// Combine headers and rows
38+
const csvContent = [headers.join(','), ...rows.map((row) => row.join(','))].join('\n');
39+
40+
// Create blob and download
41+
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
42+
const link = document.createElement('a');
43+
const url = URL.createObjectURL(blob);
44+
45+
link.setAttribute('href', url);
46+
link.setAttribute('download', filename || `search-results-${Date.now()}.csv`);
47+
link.style.visibility = 'hidden';
48+
document.body.appendChild(link);
49+
link.click();
50+
document.body.removeChild(link);
51+
};
52+
53+
/**
54+
* Convert a work result to BibTeX format
55+
*/
56+
const workToBibTeX = (result: AutocompleteResult): string => {
57+
const id = result.id.replace('https://openalex.org/', '').toUpperCase();
58+
const bibKey = `work${id}`.replace(/[^a-zA-Z0-9]/g, '');
59+
60+
let bibtex = `@misc{${bibKey},\n`;
61+
bibtex += ` title = {${result.display_name}},\n`;
62+
63+
if (result.hint) {
64+
bibtex += ` howpublished = {${result.hint}},\n`;
65+
}
66+
67+
if (result.cited_by_count) {
68+
bibtex += ` citations = {${result.cited_by_count}},\n`;
69+
}
70+
71+
bibtex += ` url = {${result.id}},\n`;
72+
bibtex += `}\n`;
73+
74+
return bibtex;
75+
};
76+
77+
/**
78+
* Convert author result to BibTeX format
79+
*/
80+
const authorToBibTeX = (result: AutocompleteResult): string => {
81+
const id = result.id.replace('https://openalex.org/', '').toUpperCase();
82+
const bibKey = `${result.display_name.split(' ').pop() || 'author'}${id}`.replace(/[^a-zA-Z0-9]/g, '');
83+
84+
let bibtex = `@misc{${bibKey},\n`;
85+
bibtex += ` author = {${result.display_name}},\n`;
86+
87+
if (result.cited_by_count) {
88+
bibtex += ` citations = {${result.cited_by_count}},\n`;
89+
}
90+
91+
if (result.works_count) {
92+
bibtex += ` works = {${result.works_count}},\n`;
93+
}
94+
95+
bibtex += ` url = {${result.id},\n`;
96+
bibtex += `}\n`;
97+
98+
return bibtex;
99+
};
100+
101+
/**
102+
* Convert search results to BibTeX format
103+
*/
104+
export const exportToBibTeX = (results: AutocompleteResult[], filename?: string): void => {
105+
if (results.length === 0) return;
106+
107+
// Convert results to BibTeX entries
108+
const bibtexEntries = results.map((result) => {
109+
if (result.entity_type === 'work') {
110+
return workToBibTeX(result);
111+
}
112+
return authorToBibTeX(result);
113+
});
114+
115+
const bibtexContent = bibtexEntries.join('\n');
116+
117+
// Create blob and download
118+
const blob = new Blob([bibtexContent], { type: 'text/plain;charset=utf-8;' });
119+
const link = document.createElement('a');
120+
const url = URL.createObjectURL(blob);
121+
122+
link.setAttribute('href', url);
123+
link.setAttribute('download', filename || `search-results-${Date.now()}.bib`);
124+
link.style.visibility = 'hidden';
125+
document.body.appendChild(link);
126+
link.click();
127+
document.body.removeChild(link);
128+
};
129+
130+
/**
131+
* Get export filename based on current search query
132+
*/
133+
export const getExportFilename = (query: string, format: 'csv' | 'bib'): string => {
134+
const sanitizedQuery = query.trim().replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
135+
const timestamp = new Date().toISOString().slice(0, 10);
136+
return sanitizedQuery
137+
? `${sanitizedQuery}-results-${timestamp}.${format}`
138+
: `search-results-${timestamp}.${format}`;
139+
};

0 commit comments

Comments
 (0)