diff --git a/front_end/messages/en.json b/front_end/messages/en.json index d23c47cee..697e8827a 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -595,6 +595,7 @@ "feelFreeToJustSayHello": "Feel free to just say hello, too - we love hearing from the forecasting community!", "contentBoosted": "Content boosted! value {score} activity. Total boost score for the week: {score_total}", "contentBuried": "Content buried! value {score} activity. Total boost score for the week: {score_total}", + "downloadCSVError": "Error downloading CSV", "cpRevealTime": "CP Reveal Time", "openTime": "Open Time", "Question": "Question", diff --git a/front_end/package-lock.json b/front_end/package-lock.json index edc1082fd..b8c78ac74 100644 --- a/front_end/package-lock.json +++ b/front_end/package-lock.json @@ -37,6 +37,7 @@ "d3": "^7.9.0", "date-fns": "^3.6.0", "date-fns-tz": "^3.1.3", + "file-saver": "^2.0.5", "html-react-parser": "^5.1.18", "lexical-beautiful-mentions": "^0.1.44", "lodash": "^4.17.21", @@ -66,6 +67,7 @@ "zod": "^3.23.8" }, "devDependencies": { + "@types/file-saver": "^2.0.7", "eslint": "^8.57.0", "eslint-config-next": "14.2.3", "eslint-config-prettier": "^9.1.0", @@ -4903,6 +4905,12 @@ "@types/estree": "*" } }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true + }, "node_modules/@types/geojson": { "version": "7946.0.14", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", @@ -8318,6 +8326,12 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", diff --git a/front_end/package.json b/front_end/package.json index 2dfcfdef3..33ba0c5cf 100644 --- a/front_end/package.json +++ b/front_end/package.json @@ -42,6 +42,7 @@ "d3": "^7.9.0", "date-fns": "^3.6.0", "date-fns-tz": "^3.1.3", + "file-saver": "^2.0.5", "html-react-parser": "^5.1.18", "lexical-beautiful-mentions": "^0.1.44", "lodash": "^4.17.21", diff --git a/front_end/src/app/(main)/questions/[id]/components/forecast_maker/forecast_maker_group/forecast_maker_group_menu.tsx b/front_end/src/app/(main)/questions/[id]/components/forecast_maker/forecast_maker_group/forecast_maker_group_menu.tsx index 1bbb43749..d3069fb0a 100644 --- a/front_end/src/app/(main)/questions/[id]/components/forecast_maker/forecast_maker_group/forecast_maker_group_menu.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/forecast_maker/forecast_maker_group/forecast_maker_group_menu.tsx @@ -1,5 +1,4 @@ "use client"; -import Link from "next/link"; import { useTranslations } from "next-intl"; import React, { FC, ReactNode, useState } from "react"; @@ -131,15 +130,6 @@ const ForecastMakerGroupControls: FC = ({ ).then(); }, }, - { - id: "downloadCSV", - name: t("downloadCSV"), - onClick: () => { - window.open( - `/api/posts/${post!.id}/download-csv/?sub-question=${question.id}` - ); - }, - }, ]} textAlign="left" > diff --git a/front_end/src/app/(main)/questions/actions.ts b/front_end/src/app/(main)/questions/actions.ts index 5202eeca0..aed9b82b1 100644 --- a/front_end/src/app/(main)/questions/actions.ts +++ b/front_end/src/app/(main)/questions/actions.ts @@ -338,3 +338,11 @@ export async function changePostSubscriptions( } return response; } + +export async function getPostCSVData(postId: number) { + const blob = await PostsApi.getPostCSVData(postId); + const arrayBuffer = await blob.arrayBuffer(); + const base64String = Buffer.from(arrayBuffer).toString("base64"); + + return `data:application/octet-stream;base64,${base64String}`; +} diff --git a/front_end/src/components/post_actions/post_dropdown_menu.tsx b/front_end/src/components/post_actions/post_dropdown_menu.tsx index b91a7218a..fadab7e9d 100644 --- a/front_end/src/components/post_actions/post_dropdown_menu.tsx +++ b/front_end/src/components/post_actions/post_dropdown_menu.tsx @@ -1,15 +1,20 @@ "use client"; import { faEllipsis } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { saveAs } from "file-saver"; import { useTranslations } from "next-intl"; import React, { FC, useCallback, useMemo } from "react"; import toast from "react-hot-toast"; -import { changePostActivityBoost } from "@/app/(main)/questions/actions"; +import { + changePostActivityBoost, + getPostCSVData, +} from "@/app/(main)/questions/actions"; import Button from "@/components/ui/button"; import DropdownMenu from "@/components/ui/dropdown_menu"; import { useAuth } from "@/contexts/auth_context"; import { Post } from "@/types/post"; +import { base64ToBlob } from "@/utils/files"; type Props = { post: Post; @@ -45,6 +50,17 @@ export const PostDropdownMenu: FC = ({ post }) => { return `/questions/create/question?mode=create&post_id=${post.id}`; }; + const handleDownloadCSV = async () => { + try { + const base64 = await getPostCSVData(post.id); + const blob = base64ToBlob(base64); + const filename = `${post.url_title.replaceAll(" ", "_")}.csv`; + saveAs(blob, filename); + } catch (error) { + toast.error(t("downloadCSVError") + error); + } + }; + const items = useMemo( () => [ ...(user?.is_superuser @@ -73,9 +89,7 @@ export const PostDropdownMenu: FC = ({ post }) => { { id: "downloadCSV", name: t("downloadCSV"), - onClick: () => { - window.open(`/api/posts/${post!.id}/download-csv/`); - }, + onClick: handleDownloadCSV, }, ], [changePostActivity, t, user?.is_superuser] diff --git a/front_end/src/declarations/file-saver.d.ts b/front_end/src/declarations/file-saver.d.ts new file mode 100644 index 000000000..144f70be9 --- /dev/null +++ b/front_end/src/declarations/file-saver.d.ts @@ -0,0 +1,43 @@ +declare module "file-saver" { + export = FileSaver; + + export as namespace saveAs; + + /** + * FileSaver.js implements the saveAs() FileSaver interface in browsers that do not natively support it. + * @param data - The actual file data blob or URL. + * @param filename - The optional name of the file to be downloaded. If omitted, the name used in the file data will be used. If none is provided "download" will be used. + * @param options - Optional FileSaver.js config + */ + declare function FileSaver( + data: Blob | string, + filename?: string, + options?: FileSaver.FileSaverOptions + ): void; + + /** + * FileSaver.js implements the saveAs() FileSaver interface in browsers that do not natively support it. + * @param data - The actual file data blob or URL. + * @param filename - The optional name of the file to be downloaded. If omitted, the name used in the file data will be used. If none is provided "download" will be used. + * @param disableAutoBOM - Optional & defaults to `true`. Set to `false` if you want FileSaver.js to automatically provide Unicode text encoding hints + * @deprecated use `{ autoBom: false }` as the third argument + */ + // tslint:disable-next-line:unified-signatures + declare function FileSaver( + data: Blob | string, + filename?: string, + disableAutoBOM?: boolean + ): void; + + declare namespace FileSaver { + interface FileSaverOptions { + /** + * Automatically provide Unicode text encoding hints + * @default false + */ + autoBom: boolean; + } + + const saveAs: typeof FileSaver; + } +} diff --git a/front_end/src/services/posts.ts b/front_end/src/services/posts.ts index 7dab1f8c8..6907e8ec3 100644 --- a/front_end/src/services/posts.ts +++ b/front_end/src/services/posts.ts @@ -211,6 +211,10 @@ class PostsApi { static async getRandomPostId(): Promise<{ id: number; post_slug: string }> { return await get<{ id: number; post_slug: string }>("/posts/random/"); } + + static async getPostCSVData(postId: number): Promise { + return await get(`/posts/${postId}/download-csv/`); + } } export default PostsApi; diff --git a/front_end/src/utils/fetch.ts b/front_end/src/utils/fetch.ts index 5640036a0..8d0902416 100644 --- a/front_end/src/utils/fetch.ts +++ b/front_end/src/utils/fetch.ts @@ -71,6 +71,14 @@ const handleResponse = async (response: Response): Promise => { throw error; } + // Check the content type to determine how to process the response + const contentType = response.headers.get("content-type"); + + if (contentType && contentType.includes("text/csv")) { + // If the response is a CSV, return it as a Blob + return response.blob() as unknown as T; + } + // Some endpoints might still have successful null response // So need to handle such cases const text = await response.text(); diff --git a/front_end/src/utils/files.ts b/front_end/src/utils/files.ts new file mode 100644 index 000000000..2646f73fe --- /dev/null +++ b/front_end/src/utils/files.ts @@ -0,0 +1,10 @@ +export function base64ToBlob(base64: string): Blob { + const byteString = atob(base64.split(",")[1]); + const mimeString = base64.split(",")[0].split(":")[1].split(";")[0]; + const ab = new ArrayBuffer(byteString.length); + const ia = new Uint8Array(ab); + for (let i = 0; i < byteString.length; i++) { + ia[i] = byteString.charCodeAt(i); + } + return new Blob([ab], { type: mimeString }); +} diff --git a/front_end/tsconfig.json b/front_end/tsconfig.json index 987c37e1e..685cdb07a 100644 --- a/front_end/tsconfig.json +++ b/front_end/tsconfig.json @@ -26,7 +26,8 @@ "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", - "types/**/*.d.ts" + "types/**/*.d.ts", + "declarations/**/*.d.ts" ], "exclude": ["node_modules"] } diff --git a/utils/csv_utils.py b/utils/csv_utils.py index 17c662688..66f8d65d9 100644 --- a/utils/csv_utils.py +++ b/utils/csv_utils.py @@ -200,6 +200,7 @@ def _get_row_headers(question: Question) -> list[str]: "forecaster", "prediction_start_time", "prediction_end_time", + "number_of_forecasters", ] match question.type: case "binary": @@ -253,6 +254,7 @@ def build_csv( method, forecast.start_time, forecast.end_time, + getattr(forecast, "forecaster_count", None), ] match question.type: case "binary":