diff --git a/src/auth.ts b/src/auth.ts index 039b35fa..8df8e84a 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -7,7 +7,7 @@ import axios from "axios" const SPOTIFY_CLIENT_ID = "9950ac751e34487dbbe027c4fd7f8e99" const SPOTIFY_AUTH_URL = "https://accounts.spotify.com/authorize" const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token" -const SPOTIFY_SCOPES = "playlist-read-private playlist-read-collaborative user-library-read" +const SPOTIFY_SCOPES = "playlist-read-private playlist-read-collaborative user-library-read playlist-modify-public playlist-modify-private" // Access token management export function loadAccessToken(): string | null { diff --git a/src/components/ImportPlaylistModal.test.tsx b/src/components/ImportPlaylistModal.test.tsx new file mode 100644 index 00000000..fb286c67 --- /dev/null +++ b/src/components/ImportPlaylistModal.test.tsx @@ -0,0 +1,90 @@ +import React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import ImportPlaylistModal, { PlaylistImportItem } from "./ImportPlaylistModal" +import "../i18n/config" +import "../icons" + +describe("ImportPlaylistModal", () => { + const singleItem: PlaylistImportItem[] = [ + { id: "1", fileName: "Road_Trip.csv", playlistName: "Road Trip", trackUris: ["spotify:track:1", "spotify:track:2"] } + ] + + const multiItems: PlaylistImportItem[] = [ + { id: "1", fileName: "Road_Trip.csv", playlistName: "Road Trip", trackUris: ["spotify:track:1", "spotify:track:2"] }, + { id: "2", fileName: "Summer_Vibes.csv", playlistName: "Summer Vibes", trackUris: ["spotify:track:3"] }, + { id: "3", fileName: "Empty.csv", playlistName: "Empty", trackUris: [] } + ] + + it("renders single-file modal layout when 1 item is passed", () => { + render() + + const nameInput = screen.getByLabelText(/Playlist Name/i) as HTMLInputElement + expect(nameInput.value).toBe("Road Trip") + expect(screen.getByText(/2 tracks found ready to import/i)).toBeInTheDocument() + }) + + it("renders multi-file itemized list when multiple items are passed", () => { + render() + + expect(screen.getByDisplayValue("Road Trip")).toBeInTheDocument() + expect(screen.getByDisplayValue("Summer Vibes")).toBeInTheDocument() + expect(screen.getByDisplayValue("Empty")).toBeInTheDocument() + expect(screen.getByText(/2 playlists ready to import \(3 tracks total\)/i)).toBeInTheDocument() + }) + + it("allows editing playlist names and removing items in multi-file mode", () => { + const onConfirmMock = jest.fn() + render() + + const roadTripInput = screen.getByDisplayValue("Road Trip") + fireEvent.change(roadTripInput, { target: { value: "Updated Road Trip" } }) + + const removeButtons = screen.getAllByRole("button", { name: /Remove/i }) + fireEvent.click(removeButtons[0]) // remove first item + + const submitBtn = screen.getByRole("button", { name: /Import to Spotify/i }) + fireEvent.click(submitBtn) + + expect(onConfirmMock).toHaveBeenCalledWith( + [ + expect.objectContaining({ playlistName: "Summer Vibes", trackUris: ["spotify:track:3"] }) + ], + false + ) + }) + + it("toggles public status when checkbox is clicked", () => { + const onConfirmMock = jest.fn() + render() + + const publicCheckbox = screen.getByLabelText(/Make playlist public/i) + fireEvent.click(publicCheckbox) + + const submitBtn = screen.getByRole("button", { name: /Import to Spotify/i }) + fireEvent.click(submitBtn) + + expect(onConfirmMock).toHaveBeenCalledWith( + [ + expect.objectContaining({ playlistName: "Road Trip", trackUris: ["spotify:track:1", "spotify:track:2"] }) + ], + true + ) + }) + + it("handles cancel button click", () => { + const onCloseMock = jest.fn() + render() + fireEvent.click(screen.getByRole("button", { name: /Cancel/i })) + expect(onCloseMock).toHaveBeenCalled() + }) + + it("disables submit button when no valid playlists with tracks and names exist", () => { + const emptyItems: PlaylistImportItem[] = [ + { id: "1", fileName: "Empty.csv", playlistName: "Empty", trackUris: [] } + ] + render() + + const submitBtn = screen.getByRole("button", { name: /Import to Spotify/i }) + expect(submitBtn).toBeDisabled() + }) +}) diff --git a/src/components/ImportPlaylistModal.tsx b/src/components/ImportPlaylistModal.tsx new file mode 100644 index 00000000..d7a90c1e --- /dev/null +++ b/src/components/ImportPlaylistModal.tsx @@ -0,0 +1,188 @@ +import React, { useState, useEffect } from "react" +import { useTranslation } from "react-i18next" +import { Modal, Button, Form, Alert, Badge, Table } from "react-bootstrap" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" + +export interface PlaylistImportItem { + id: string + fileName: string + playlistName: string + trackUris: string[] +} + +export interface ImportPlaylistModalProps { + show: boolean + items: PlaylistImportItem[] + onClose: () => void + onConfirm: (validItems: PlaylistImportItem[], isPublic: boolean) => void +} + +const EMPTY_ITEMS: PlaylistImportItem[] = [] + +export const ImportPlaylistModal: React.FC = ({ + show, + items = EMPTY_ITEMS, + onClose, + onConfirm +}) => { + const { t } = useTranslation() + const [localItems, setLocalItems] = useState(items) + const [isPublic, setIsPublic] = useState(false) + + useEffect(() => { + if (show) { + setLocalItems(items || EMPTY_ITEMS) + setIsPublic(false) + } + }, [items, show]) + + const handleNameChange = (id: string, newName: string) => { + setLocalItems((prev) => + prev.map((item) => (item.id === id ? { ...item, playlistName: newName } : item)) + ) + } + + const handleRemoveItem = (id: string) => { + setLocalItems((prev) => prev.filter((item) => item.id !== id)) + } + + const validItems = (localItems || []).filter( + (item) => item.playlistName.trim().length > 0 && item.trackUris.length > 0 + ) + const totalTracks = validItems.reduce((acc, item) => acc + item.trackUris.length, 0) + const canSubmit = validItems.length > 0 + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!canSubmit) return + onConfirm( + validItems.map((item) => ({ ...item, playlistName: item.playlistName.trim() })), + isPublic + ) + } + + const isSingle = (items?.length === 1) && localItems.length === 1 + + return ( + + + {t("import_modal_title")} + +
+ + {isSingle ? ( + <> + + {t("import_modal_playlist_name")} + handleNameChange(localItems[0].id, e.target.value)} + placeholder={t("import_modal_playlist_name")} + autoFocus + /> + + + {localItems[0]?.trackUris.length > 0 ? ( + + {t("import_modal_tracks_found", { count: localItems[0].trackUris.length })} + + ) : ( + + {t("import_modal_no_tracks")} + + )} + + ) : ( + <> +
+ + + + + + + + + + {localItems.map((item) => { + const hasTracks = item.trackUris.length > 0 + return ( + + + + + + ) + })} + +
{t("import_modal_playlist_name")}{t("playlist.tracks")}
+ handleNameChange(item.id, e.target.value)} + placeholder={t("import_modal_playlist_name")} + aria-label={t("import_modal_playlist_name")} + /> + + {hasTracks ? ( + + {item.trackUris.length} {t("playlist.tracks").toLowerCase()} + + ) : ( + + {t("import_modal_no_tracks")} + + )} + + +
+
+ + {canSubmit ? ( + + {t("import_modal_batch_summary", { + playlistCount: validItems.length, + trackCount: totalTracks + })} + + ) : ( + + {t("import_modal_no_tracks")} + + )} + + )} + + + setIsPublic(e.target.checked)} + /> + +
+ + + + +
+
+ ) +} + +export default ImportPlaylistModal diff --git a/src/components/PlaylistImporter.test.tsx b/src/components/PlaylistImporter.test.tsx new file mode 100644 index 00000000..daa48200 --- /dev/null +++ b/src/components/PlaylistImporter.test.tsx @@ -0,0 +1,124 @@ +import React from "react" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import PlaylistImporter from "./PlaylistImporter" +import PlaylistImportService from "./data/PlaylistImportService" +import "../i18n/config" +import "../icons" + +jest.mock("./data/PlaylistImportService") + +describe("PlaylistImporter", () => { + const mockProps = { + accessToken: "test_token", + onImportStarted: jest.fn(), + onImportProgress: jest.fn(), + onImportDone: jest.fn(), + onImportError: jest.fn() + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("renders the import button", () => { + render() + expect(screen.getByRole("button", { name: /Import Playlist/i })).toBeInTheDocument() + }) + + it("parses single file on change and opens modal", async () => { + render() + + const file = new File( + ['"Track URI"\n"spotify:track:abc12345"\n"spotify:track:xyz67890"'], + "My_Favorites.csv", + { type: "text/csv" } + ) + + const fileInput = screen.getByTestId("playlist-import-input") + fireEvent.change(fileInput, { target: { files: [file] } }) + + await waitFor(() => { + expect(screen.getByText(/2 tracks found ready to import/i)).toBeInTheDocument() + expect(screen.getByDisplayValue("My Favorites")).toBeInTheDocument() + }) + }) + + it("parses multiple files on change and opens modal with all items", async () => { + render() + + const file1 = new File(['"Track URI"\n"spotify:track:1"'], "Play_1.csv", { type: "text/csv" }) + const file2 = new File(['"Track URI"\n"spotify:track:2"\n"spotify:track:3"'], "Play_2.csv", { type: "text/csv" }) + + const fileInput = screen.getByTestId("playlist-import-input") + fireEvent.change(fileInput, { target: { files: [file1, file2] } }) + + await waitFor(() => { + expect(screen.getByDisplayValue("Play 1")).toBeInTheDocument() + expect(screen.getByDisplayValue("Play 2")).toBeInTheDocument() + expect(screen.getByText(/2 playlists ready to import \(3 tracks total\)/i)).toBeInTheDocument() + }) + }) + + it("triggers PlaylistImportService.importMultiplePlaylists on confirm", async () => { + ;(PlaylistImportService.importMultiplePlaylists as jest.Mock).mockResolvedValue({ + successfulPlaylistsCount: 2, + totalTracksCount: 3, + failedPlaylists: [] + }) + + render() + + const file1 = new File(['"Track URI"\n"spotify:track:1"'], "Play_1.csv", { type: "text/csv" }) + const file2 = new File(['"Track URI"\n"spotify:track:2"\n"spotify:track:3"'], "Play_2.csv", { type: "text/csv" }) + + const fileInput = screen.getByTestId("playlist-import-input") + fireEvent.change(fileInput, { target: { files: [file1, file2] } }) + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Import to Spotify/i })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole("button", { name: /Import to Spotify/i })) + + await waitFor(() => { + expect(PlaylistImportService.importMultiplePlaylists).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: "test_token", + items: [ + { name: "Play 1", trackUris: ["spotify:track:1"] }, + { name: "Play 2", trackUris: ["spotify:track:2", "spotify:track:3"] } + ], + isPublic: false + }) + ) + expect(mockProps.onImportDone).toHaveBeenCalledWith(2, 3, undefined) + }) + }) + + it("handles import errors gracefully", async () => { + const error = new Error("Failed to create playlist") + ;(PlaylistImportService.importMultiplePlaylists as jest.Mock).mockRejectedValue(error) + + render() + + const file = new File(['"Track URI"\n"spotify:track:abc12345"'], "Error_Test.csv", { type: "text/csv" }) + + const fileInput = screen.getByTestId("playlist-import-input") + fireEvent.change(fileInput, { target: { files: [file] } }) + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Import to Spotify/i })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole("button", { name: /Import to Spotify/i })) + + await waitFor(() => { + expect(mockProps.onImportError).toHaveBeenCalledWith(error) + }) + }) + + it("disables button when disabled prop is true", () => { + render() + expect(screen.getByRole("button", { name: /Import Playlist/i })).toBeDisabled() + }) +}) diff --git a/src/components/PlaylistImporter.tsx b/src/components/PlaylistImporter.tsx new file mode 100644 index 00000000..245b03d3 --- /dev/null +++ b/src/components/PlaylistImporter.tsx @@ -0,0 +1,149 @@ +import React, { useRef, useState } from "react" +import { withTranslation, WithTranslation } from "react-i18next" +import { Button } from "react-bootstrap" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import ImportPlaylistModal, { PlaylistImportItem } from "./ImportPlaylistModal" +import { parseTracksCsv, filenameToPlaylistName } from "../utils/csvParser" +import PlaylistImportService from "./data/PlaylistImportService" + +export interface PlaylistImporterProps extends WithTranslation { + accessToken: string + disabled?: boolean + onImportStarted: (playlistName: string, totalTracks: number, totalPlaylists: number) => void + onImportProgress: ( + playlistIndex: number, + totalPlaylists: number, + playlistName: string, + importedCount: number, + totalTracks: number + ) => void + onImportDone: (importedPlaylistsCount: number, totalTracksCount: number, singlePlaylistName?: string) => void + onImportError: (error: any) => void +} + +export const PlaylistImporter: React.FC = ({ + t, + accessToken, + disabled = false, + onImportStarted, + onImportProgress, + onImportDone, + onImportError +}) => { + const fileInputRef = useRef(null) + const [modalShow, setModalShow] = useState(false) + const [parsedItems, setParsedItems] = useState([]) + const [isImporting, setIsImporting] = useState(false) + + const handleButtonClick = () => { + if (fileInputRef.current) { + fileInputRef.current.value = "" + fileInputRef.current.click() + } + } + + const readFileAsText = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = (e) => resolve((e.target?.result as string) || "") + reader.onerror = reject + reader.readAsText(file) + }) + } + + const handleFileChange = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []) + if (files.length === 0) return + + try { + const parsedResults: PlaylistImportItem[] = await Promise.all( + files.map(async (file, idx) => { + const content = await readFileAsText(file) + const { trackUris } = parseTracksCsv(content) + return { + id: `${file.name}-${idx}-${Date.now()}`, + fileName: file.name, + playlistName: filenameToPlaylistName(file.name), + trackUris + } + }) + ) + + setParsedItems(parsedResults) + setModalShow(true) + } catch (error) { + onImportError(error) + } + } + + const handleModalClose = () => { + setModalShow(false) + } + + const handleConfirmImport = async (validItems: PlaylistImportItem[], isPublic: boolean) => { + setModalShow(false) + setIsImporting(true) + + const totalTracks = validItems.reduce((acc, item) => acc + item.trackUris.length, 0) + const firstName = validItems[0]?.playlistName || "" + onImportStarted(firstName, totalTracks, validItems.length) + + try { + const result = await PlaylistImportService.importMultiplePlaylists({ + accessToken, + items: validItems.map((item) => ({ + name: item.playlistName, + trackUris: item.trackUris + })), + isPublic, + onProgress: (pIdx, pTotal, pName, importedCount, trackTotal) => { + onImportProgress(pIdx, pTotal, pName, importedCount, trackTotal) + } + }) + + if (validItems.length === 1) { + onImportDone(result.successfulPlaylistsCount, result.totalTracksCount, firstName) + } else { + onImportDone(result.successfulPlaylistsCount, result.totalTracksCount, undefined) + } + } catch (error) { + onImportError(error) + } finally { + setIsImporting(false) + } + } + + return ( + <> + + {/* @ts-ignore */} + + + + + ) +} + +export default withTranslation()(PlaylistImporter) diff --git a/src/components/PlaylistTable.test.tsx b/src/components/PlaylistTable.test.tsx index 5d0ff62d..364468e8 100644 --- a/src/components/PlaylistTable.test.tsx +++ b/src/components/PlaylistTable.test.tsx @@ -1,12 +1,13 @@ import React from "react" import "i18n/config" -import { render, screen, waitFor, act, waitForElementToBeRemoved } from "@testing-library/react" +import { render, screen, waitFor, fireEvent } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { setupServer } from "msw/node" import FileSaver from "file-saver" import JSZip from "jszip" import PlaylistTable from "./PlaylistTable" +import PlaylistImportService from "./data/PlaylistImportService" import "../icons" import { handlerCalled, handlers, nullAlbumHandlers, nullTrackHandlers, localTrackHandlers, duplicateTrackHandlers, missingPlaylistsHandlers } from "../mocks/handlers" @@ -621,3 +622,120 @@ test("exporting of search results", async () => { expect(saveAsMock).toHaveBeenCalledWith("zip_content", "spotify_playlists.zip") }) + +describe("importing playlists", () => { + let alertMock: jest.SpyInstance + + beforeEach(() => { + alertMock = jest.spyOn(window, "alert").mockImplementation(() => {}) + }) + + afterEach(() => { + alertMock.mockRestore() + }) + + it("renders import playlist button and handles import flow", async () => { + render() + expect(await screen.findByRole("button", { name: /Import Playlist/i })).toBeInTheDocument() + }) + + it("handles successful multi-playlist import flow with progress and cache reset", async () => { + const importMultipleSpy = jest.spyOn(PlaylistImportService, "importMultiplePlaylists").mockImplementation(async (params) => { + params.onProgress?.(0, 2, "List 1", 1, 1) + params.onProgress?.(1, 2, "List 2", 2, 2) + return { + successfulPlaylistsCount: 2, + totalTracksCount: 3, + failedPlaylists: [] + } + }) + + render() + + expect(await screen.findByRole("button", { name: /Import Playlist/i })).toBeInTheDocument() + + const file1 = new File(['"Track URI"\n"spotify:track:1"'], "List_1.csv", { type: "text/csv" }) + const file2 = new File(['"Track URI"\n"spotify:track:2"\n"spotify:track:3"'], "List_2.csv", { type: "text/csv" }) + + const fileInput = screen.getByTestId("playlist-import-input") + fireEvent.change(fileInput, { target: { files: [file1, file2] } }) + + expect(await screen.findByRole("button", { name: /Import to Spotify/i })).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: /Import to Spotify/i })) + + await waitFor(() => { + expect(importMultipleSpy).toHaveBeenCalled() + }) + + await waitFor(() => { + expect(screen.getByText(/Successfully imported 2 playlists \(3 tracks total\)!/i)).toBeInTheDocument() + }) + }) + + it("handles successful import flow with progress and cache reset", async () => { + const importSpy = jest.spyOn(PlaylistImportService, "importMultiplePlaylists").mockImplementation(async (params) => { + const totalTracks = params.items.reduce((acc, item) => acc + item.trackUris.length, 0) + params.onProgress?.(0, 1, params.items[0].name, totalTracks, totalTracks) + return { + successfulPlaylistsCount: 1, + totalTracksCount: totalTracks, + failedPlaylists: [] + } + }) + + render() + + expect(await screen.findByRole("button", { name: /Import Playlist/i })).toBeInTheDocument() + + const file = new File( + ['"Track URI"\n"spotify:track:abc12345"\n"spotify:track:xyz67890"'], + "My_New_Playlist.csv", + { type: "text/csv" } + ) + + const fileInput = screen.getByTestId("playlist-import-input") + fireEvent.change(fileInput, { target: { files: [file] } }) + + expect(await screen.findByRole("button", { name: /Import to Spotify/i })).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: /Import to Spotify/i })) + + await waitFor(() => { + expect(importSpy).toHaveBeenCalled() + }) + + await waitFor(() => { + expect(screen.getByText(/Successfully imported 2 tracks into "My New Playlist"!/i)).toBeInTheDocument() + }) + }) + + it("handles 403 scope error with alert", async () => { + jest.spyOn(PlaylistImportService, "importMultiplePlaylists").mockRejectedValue({ + response: { status: 403 } + }) + + render() + + expect(await screen.findByRole("button", { name: /Import Playlist/i })).toBeInTheDocument() + + const file = new File( + ['"Track URI"\n"spotify:track:abc12345"'], + "Scope_Error.csv", + { type: "text/csv" } + ) + + const fileInput = screen.getByTestId("playlist-import-input") + fireEvent.change(fileInput, { target: { files: [file] } }) + + expect(await screen.findByRole("button", { name: /Import to Spotify/i })).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: /Import to Spotify/i })) + + await waitFor(() => { + expect(alertMock).toHaveBeenCalledWith( + "Spotify permissions needed to create playlists. Please re-login." + ) + }) + }) +}) diff --git a/src/components/PlaylistTable.tsx b/src/components/PlaylistTable.tsx index 0fe3fd5e..1fa18b7f 100644 --- a/src/components/PlaylistTable.tsx +++ b/src/components/PlaylistTable.tsx @@ -9,6 +9,7 @@ import PlaylistSearch, { PlaylistSearchRef } from "./PlaylistSearch" import PlaylistRow from "./PlaylistRow" import Paginator from "./Paginator" import PlaylistsExporter from "./PlaylistsExporter" +import PlaylistImporter from "./PlaylistImporter" import { apiCall, apiCallErrorHandler } from "helpers" interface PlaylistTableProps extends WithTranslation { @@ -38,7 +39,8 @@ class PlaylistTable extends React.Component { progressBar: { show: false, label: "", - value: 0 + value: 0, + max: 0 }, config: { includeArtistsData: false, @@ -154,6 +156,98 @@ class PlaylistTable extends React.Component { }) } + handleImportStarted = (playlistName: string, totalTracks: number, totalPlaylists: number) => { + Bugsnag.leaveBreadcrumb(`Started importing ${totalPlaylists} playlist(s)`) + const label = + totalPlaylists > 1 + ? this.props.i18n.t("importing_batch_started", { + current: 1, + total: totalPlaylists, + playlistName + }) + : this.props.i18n.t("importing_started", { playlistName }) + + this.setState({ + progressBar: { + show: true, + label, + value: 0, + max: totalTracks + } + }) + } + + handleImportProgress = ( + playlistIndex: number, + totalPlaylists: number, + playlistName: string, + importedCount: number, + totalTracks: number + ) => { + const label = + totalPlaylists > 1 + ? this.props.i18n.t("importing_batch_progress", { + current: playlistIndex + 1, + total: totalPlaylists, + playlistName, + count: importedCount, + trackTotal: totalTracks + }) + : this.props.i18n.t("importing_progress", { + count: importedCount, + total: totalTracks, + playlistName + }) + + this.setState({ + progressBar: { + show: true, + label, + value: importedCount, + max: totalTracks + } + }) + } + + handleImportDone = async ( + importedPlaylistsCount: number, + totalTracksCount: number, + singlePlaylistName?: string + ) => { + Bugsnag.leaveBreadcrumb(`Finished importing ${importedPlaylistsCount} playlists`) + const label = singlePlaylistName + ? this.props.i18n.t("importing_done", { + count: totalTracksCount, + playlistName: singlePlaylistName + }) + : this.props.i18n.t("importing_batch_done", { + playlistCount: importedPlaylistsCount, + trackCount: totalTracksCount + }) + + this.playlistsData?.reset() + this.setState({ currentPage: 1 }, async () => { + await this.loadCurrentPlaylistPage() + this.setState({ + progressBar: { + show: true, + label, + value: totalTracksCount, + max: totalTracksCount + } + }) + }) + } + + handleImportError = (error: any) => { + Bugsnag.notify(error) + if (error?.response?.status === 403) { + alert(this.props.i18n.t("import_error_scope")) + } else { + apiCallErrorHandler(error) + } + } + handleConfigChanged = (config: any) => { Bugsnag.leaveBreadcrumb(`Config updated to ${JSON.stringify(config)}`) @@ -193,7 +287,7 @@ class PlaylistTable extends React.Component { } render() { - const progressBar = + const progressBar = if (this.state.initialized) { return ( @@ -215,14 +309,23 @@ class PlaylistTable extends React.Component { {this.props.i18n.t("playlist.public")} {this.props.i18n.t("playlist.collaborative")} - +
+ + +
diff --git a/src/components/__snapshots__/PlaylistTable.test.tsx.snap b/src/components/__snapshots__/PlaylistTable.test.tsx.snap index d7178d50..1be801cf 100644 --- a/src/components/__snapshots__/PlaylistTable.test.tsx.snap +++ b/src/components/__snapshots__/PlaylistTable.test.tsx.snap @@ -145,27 +145,59 @@ exports[`playlist loading 1`] = ` - + + Import Playlist + + + diff --git a/src/components/data/PlaylistImportService.test.ts b/src/components/data/PlaylistImportService.test.ts new file mode 100644 index 00000000..6170fa5f --- /dev/null +++ b/src/components/data/PlaylistImportService.test.ts @@ -0,0 +1,115 @@ +import { rest } from "msw" +import { setupServer } from "msw/node" +import { PlaylistImportService } from "./PlaylistImportService" + +jest.mock("@bugsnag/js") + +const server = setupServer( + rest.post("https://api.spotify.com/v1/me/playlists", (req, res, ctx) => { + return res( + ctx.json({ + id: "new_playlist_123", + name: "Test Imported Playlist", + uri: "spotify:playlist:new_playlist_123" + }) + ) + }), + rest.post("https://api.spotify.com/v1/playlists/:playlistId/items", (req, res, ctx) => { + return res( + ctx.json({ + snapshot_id: "snapshot_abc" + }) + ) + }) +) + +beforeAll(() => server.listen()) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +describe("PlaylistImportService", () => { + it("creates playlist and adds tracks in batches of 100", async () => { + const trackUris = Array.from({ length: 250 }, (_, i) => `spotify:track:${i}`) + const progressCalls: Array<{ playlistName: string; count: number; total: number }> = [] + + const result = await PlaylistImportService.importPlaylist({ + accessToken: "mock_token", + name: "Test Imported Playlist", + isPublic: false, + trackUris, + onProgress: (name, count, total) => { + progressCalls.push({ playlistName: name, count, total }) + } + }) + + expect(result.id).toBe("new_playlist_123") + expect(result.name).toBe("Test Imported Playlist") + expect(result.importedTracksCount).toBe(250) + expect(result.totalTracksCount).toBe(250) + expect(progressCalls.length).toBe(3) + expect(progressCalls[progressCalls.length - 1]).toEqual({ playlistName: "Test Imported Playlist", count: 250, total: 250 }) + }) + + it("handles empty track list by creating empty playlist", async () => { + const result = await PlaylistImportService.importPlaylist({ + accessToken: "mock_token", + name: "Empty Playlist", + isPublic: true, + trackUris: [] + }) + + expect(result.id).toBe("new_playlist_123") + expect(result.importedTracksCount).toBe(0) + }) + + it("imports multiple playlists sequentially and reports progress", async () => { + const items = [ + { name: "Playlist 1", trackUris: ["spotify:track:1", "spotify:track:2"] }, + { name: "Playlist 2", trackUris: ["spotify:track:3"] } + ] + const progressLogs: any[] = [] + + const result = await PlaylistImportService.importMultiplePlaylists({ + accessToken: "mock_token", + items, + isPublic: false, + onProgress: (pIdx, pTotal, pName, count, total) => { + progressLogs.push({ pIdx, pTotal, pName, count, total }) + } + }) + + expect(result.successfulPlaylistsCount).toBe(2) + expect(result.totalTracksCount).toBe(3) + expect(progressLogs.length).toBe(2) + expect(progressLogs[0]).toEqual({ + pIdx: 0, + pTotal: 2, + pName: "Playlist 1", + count: 2, + total: 2 + }) + expect(progressLogs[1]).toEqual({ + pIdx: 1, + pTotal: 2, + pName: "Playlist 2", + count: 1, + total: 1 + }) + }) + + it("propagates 403 scope error during multiple playlists import", async () => { + server.use( + rest.post("https://api.spotify.com/v1/me/playlists", (req, res, ctx) => { + return res(ctx.status(403), ctx.json({ error: { message: "Insufficient client scope" } })) + }) + ) + + await expect( + PlaylistImportService.importMultiplePlaylists({ + accessToken: "mock_token", + items: [{ name: "P1", trackUris: ["spotify:track:1"] }], + isPublic: false + }) + ).rejects.toMatchObject({ response: { status: 403 } }) + }) +}) diff --git a/src/components/data/PlaylistImportService.ts b/src/components/data/PlaylistImportService.ts new file mode 100644 index 00000000..0a8c784b --- /dev/null +++ b/src/components/data/PlaylistImportService.ts @@ -0,0 +1,121 @@ +import { apiPost } from "helpers" + +export interface ImportPlaylistParams { + accessToken: string + name: string + isPublic: boolean + trackUris: string[] + onProgress?: (playlistName: string, importedCount: number, totalTracks: number) => void +} + +export interface ImportPlaylistResult { + id: string + name: string + importedTracksCount: number + totalTracksCount: number +} + +export interface ImportMultiplePlaylistsParams { + accessToken: string + items: Array<{ name: string; trackUris: string[] }> + isPublic: boolean + onProgress?: ( + playlistIndex: number, + totalPlaylists: number, + playlistName: string, + importedTracks: number, + totalTracks: number + ) => void +} + +export interface ImportMultiplePlaylistsResult { + successfulPlaylistsCount: number + totalTracksCount: number + failedPlaylists: Array<{ name: string; error: any }> +} + +export class PlaylistImportService { + private static readonly BATCH_SIZE = 100 + + static async importPlaylist(params: ImportPlaylistParams): Promise { + const { accessToken, name, isPublic, trackUris, onProgress } = params + + // 1. Create playlist on Spotify (default private and non-collaborative) + const createPlaylistUrl = "https://api.spotify.com/v1/me/playlists" + const createResponse = await apiPost(createPlaylistUrl, accessToken, { + name: name, + public: isPublic, + collaborative: false, + description: "Imported via Exportify" + }) + + const playlist = createResponse.data + const playlistId = playlist.id + const totalTracks = trackUris.length + let importedTracksCount = 0 + + // 2. Add tracks in batches of 100 + const addItemsUrl = `https://api.spotify.com/v1/playlists/${playlistId}/items` + + for (let i = 0; i < totalTracks; i += this.BATCH_SIZE) { + const batch = trackUris.slice(i, i + this.BATCH_SIZE) + await apiPost(addItemsUrl, accessToken, { + uris: batch + }) + importedTracksCount += batch.length + if (onProgress) { + onProgress(name, importedTracksCount, totalTracks) + } + } + + return { + id: playlistId, + name: name, + importedTracksCount, + totalTracksCount: totalTracks + } + } + + static async importMultiplePlaylists(params: ImportMultiplePlaylistsParams): Promise { + const { accessToken, items, isPublic, onProgress } = params + let successfulPlaylistsCount = 0 + let totalTracksCount = 0 + const failedPlaylists: Array<{ name: string; error: any }> = [] + + for (let i = 0; i < items.length; i++) { + const item = items[i] + try { + const result = await this.importPlaylist({ + accessToken, + name: item.name, + isPublic, + trackUris: item.trackUris, + onProgress: (playlistName, importedCount, totalTracks) => { + if (onProgress) { + onProgress(i, items.length, playlistName, importedCount, totalTracks) + } + } + }) + successfulPlaylistsCount++ + totalTracksCount += result.importedTracksCount + } catch (error: any) { + if (error?.response?.status === 403) { + throw error + } + failedPlaylists.push({ name: item.name, error }) + } + } + + if (successfulPlaylistsCount === 0 && failedPlaylists.length > 0) { + throw failedPlaylists[0].error + } + + return { + successfulPlaylistsCount, + totalTracksCount, + failedPlaylists + } + } +} + +export default PlaylistImportService diff --git a/src/components/data/PlaylistsData.ts b/src/components/data/PlaylistsData.ts index 533f3764..0877034f 100644 --- a/src/components/data/PlaylistsData.ts +++ b/src/components/data/PlaylistsData.ts @@ -22,6 +22,12 @@ class PlaylistsData { this.likedTracksPlaylist = null } + reset(): void { + this.data = [] + this.dataInitialized = false + this.likedTracksPlaylist = null + } + async total() { if (!this.dataInitialized) { await this.loadSlice() diff --git a/src/helpers.ts b/src/helpers.ts index babe1f6a..29527e0b 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -40,6 +40,15 @@ export const apiCall = limiter.wrap(function(url: string, accessToken: string) { return axios.get(url, { headers: { 'Authorization': 'Bearer ' + accessToken } }) }) +export const apiPost = limiter.wrap(function(url: string, accessToken: string, data: any) { + return axios.post(url, data, { + headers: { + 'Authorization': 'Bearer ' + accessToken, + 'Content-Type': 'application/json' + } + }) +}) + export function apiCallErrorHandler(error: any) { if (error.isAxiosError) { if (error.request.status === 401) { diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index f39266db..126e0ba5 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -9,6 +9,23 @@ "exporting_done": "تم التصدير!", "exporting_playlist": "جاري تصدير {{playlistName}}...", "export_search_results": "تصدير النتائج", + "import_playlist": "استيراد قائمة التشغيل", + "import_modal_title": "استيراد قائمة التشغيل", + "import_modal_playlist_name": "اسم قائمة التشغيل", + "import_modal_public": "جعل قائمة التشغيل عامة", + "import_modal_tracks_found": "تم العثور على {{count}} مسار جاهز للاستيراد", + "import_modal_no_tracks": "لم يتم العثور على مسارات صالحة في ملف CSV هذا", + "import_modal_batch_summary": "{{playlistCount}} قائمة تشغيل جاهزة للاستيراد (إجمالي {{trackCount}} مسار)", + "import_modal_remove": "إزالة", + "import_modal_cancel": "إلغاء", + "import_modal_confirm": "استيراد إلى Spotify", + "importing_started": "إنشاء قائمة التشغيل \"{{playlistName}}\"...", + "importing_progress": "استيراد المسارات إلى \"{{playlistName}}\" ({{count}}/{{total}})...", + "importing_done": "تم استيراد {{count}} مسار بنجاح إلى \"{{playlistName}}\"!", + "importing_batch_started": "إنشاء قائمة التشغيل {{current}} من {{total}}: \"{{playlistName}}\"...", + "importing_batch_progress": "استيراد قائمة التشغيل {{current}} من {{total}}: \"{{playlistName}}\" ({{count}}/{{trackTotal}})...", + "importing_batch_done": "تم استيراد {{playlistCount}} قائمة تشغيل بنجاح (إجمالي {{trackCount}} مسار)!", + "import_error_scope": "مطلوب أذونات Spotify لإنشاء قوائم التشغيل. يرجى تسجيل الدخول مرة أخرى.", "top_menu": { "help": "المساعدة", "toggle_dark_mode": "تبديل الوضع الداكن", diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index ac81e794..811e3fb3 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -9,6 +9,23 @@ "exporting_done": "Fertig!", "exporting_playlist": "Exportiere {{playlistName}}...", "export_search_results": "Ergebnisse exportieren", + "import_playlist": "Playlist importieren", + "import_modal_title": "Playlist importieren", + "import_modal_playlist_name": "Playlist-Name", + "import_modal_public": "Playlist öffentlich machen", + "import_modal_tracks_found": "{{count}} Titel zum Importieren gefunden", + "import_modal_no_tracks": "Keine gültigen Spotify-Titel in dieser CSV gefunden", + "import_modal_batch_summary": "{{playlistCount}} Playlists bereit zum Importieren (insgesamt {{trackCount}} Titel)", + "import_modal_remove": "Entfernen", + "import_modal_cancel": "Abbrechen", + "import_modal_confirm": "In Spotify importieren", + "importing_started": "Playlist \"{{playlistName}}\" wird erstellt...", + "importing_progress": "Titel werden in \"{{playlistName}}\" importiert ({{count}}/{{total}})...", + "importing_done": "{{count}} Titel erfolgreich in \"{{playlistName}}\" importiert!", + "importing_batch_started": "Playlist {{current}} von {{total}} wird erstellt: \"{{playlistName}}\"...", + "importing_batch_progress": "Playlist {{current}} von {{total}} wird importiert: \"{{playlistName}}\" ({{count}}/{{trackTotal}})...", + "importing_batch_done": "{{playlistCount}} Playlists erfolgreich importiert (insgesamt {{trackCount}} Titel)!", + "import_error_scope": "Spotify-Berechtigungen zum Erstellen von Playlists erforderlich. Bitte erneut anmelden.", "top_menu": { "help": "Hilfe", "toggle_dark_mode": "Dunkelmodus umschalten", diff --git a/src/i18n/locales/el/translation.json b/src/i18n/locales/el/translation.json index 4d171ae9..fdfd033f 100644 --- a/src/i18n/locales/el/translation.json +++ b/src/i18n/locales/el/translation.json @@ -9,6 +9,23 @@ "exporting_done": "Ολοκληρώθηκε!", "exporting_playlist": "Γίνεται εξαγωγή {{playlistName}}...", "export_search_results": "Εξαγωγή αποτελεσμάτων", + "import_playlist": "Εισαγωγή λίστας αναπαραγωγής", + "import_modal_title": "Εισαγωγή λίστας αναπαραγωγής", + "import_modal_playlist_name": "Όνομα λίστας αναπαραγωγής", + "import_modal_public": "Δημόσια λίστα αναπαραγωγής", + "import_modal_tracks_found": "Βρέθηκαν {{count}} κομμάτια έτοιμα για εισαγωγή", + "import_modal_no_tracks": "Δεν βρέθηκαν έγκυρα κομμάτια Spotify σε αυτό το αρχείο CSV", + "import_modal_batch_summary": "{{playlistCount}} λίστες έτοιμες για εισαγωγή ({{trackCount}} κομμάτια συνολικά)", + "import_modal_remove": "Αφαίρεση", + "import_modal_cancel": "Ακύρωση", + "import_modal_confirm": "Εισαγωγή στο Spotify", + "importing_started": "Δημιουργία λίστας \"{{playlistName}}\"...", + "importing_progress": "Εισαγωγή κομματιών στη λίστα \"{{playlistName}}\" ({{count}}/{{total}})...", + "importing_done": "Επιτυχής εισαγωγή {{count}} κομματιών στη λίστα \"{{playlistName}}\"!", + "importing_batch_started": "Δημιουργία λίστας {{current}} από {{total}}: \"{{playlistName}}\"...", + "importing_batch_progress": "Εισαγωγή λίστας {{current}} από {{total}}: \"{{playlistName}}\" ({{count}}/{{trackTotal}})...", + "importing_batch_done": "Επιτυχής εισαγωγή {{playlistCount}} λιστών ({{trackCount}} κομμάτια συνολικά)!", + "import_error_scope": "Απαιτούνται δικαιώματα Spotify για δημιουργία λίστας. Παρακαλώ συνδεθείτε ξανά.", "top_menu": { "help": "Βοήθεια", "toggle_dark_mode": "Εναλλαγή σκοτεινής λειτουργίας", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 0214958e..44368ecd 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -9,6 +9,23 @@ "exporting_done": "Done!", "exporting_playlist": "Exporting {{playlistName}}...", "export_search_results": "Export Results", + "import_playlist": "Import Playlist", + "import_modal_title": "Import Playlist", + "import_modal_playlist_name": "Playlist Name", + "import_modal_public": "Make playlist public", + "import_modal_tracks_found": "{{count}} tracks found ready to import", + "import_modal_no_tracks": "No valid Spotify tracks found in this CSV", + "import_modal_batch_summary": "{{playlistCount}} playlists ready to import ({{trackCount}} tracks total)", + "import_modal_remove": "Remove", + "import_modal_cancel": "Cancel", + "import_modal_confirm": "Import to Spotify", + "importing_started": "Creating playlist \"{{playlistName}}\"...", + "importing_progress": "Importing tracks to \"{{playlistName}}\" ({{count}}/{{total}})...", + "importing_done": "Successfully imported {{count}} tracks into \"{{playlistName}}\"!", + "importing_batch_started": "Creating playlist {{current}} of {{total}}: \"{{playlistName}}\"...", + "importing_batch_progress": "Importing playlist {{current}} of {{total}}: \"{{playlistName}}\" ({{count}}/{{trackTotal}})...", + "importing_batch_done": "Successfully imported {{playlistCount}} playlists ({{trackCount}} tracks total)!", + "import_error_scope": "Spotify permissions needed to create playlists. Please re-login.", "top_menu": { "help": "Help", "toggle_dark_mode": "Toggle dark mode", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 604563b1..bbd71cc0 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -9,6 +9,23 @@ "exporting_done": "¡Hecho!", "exporting_playlist": "Exportando {{playlistName}}...", "export_search_results": "Exportar resultados", + "import_playlist": "Importar lista", + "import_modal_title": "Importar lista de reproducción", + "import_modal_playlist_name": "Nombre de la lista", + "import_modal_public": "Hacer lista pública", + "import_modal_tracks_found": "{{count}} canciones encontradas listas para importar", + "import_modal_no_tracks": "No se encontraron canciones válidas de Spotify en este CSV", + "import_modal_batch_summary": "{{playlistCount}} listas listas para importar ({{trackCount}} canciones en total)", + "import_modal_remove": "Eliminar", + "import_modal_cancel": "Cancelar", + "import_modal_confirm": "Importar a Spotify", + "importing_started": "Creando lista \"{{playlistName}}\"...", + "importing_progress": "Importando canciones a \"{{playlistName}}\" ({{count}}/{{total}})...", + "importing_done": "¡Se importaron con éxito {{count}} canciones en \"{{playlistName}}\"!", + "importing_batch_started": "Creando lista {{current}} de {{total}}: \"{{playlistName}}\"...", + "importing_batch_progress": "Importando lista {{current}} de {{total}}: \"{{playlistName}}\" ({{count}}/{{trackTotal}})...", + "importing_batch_done": "¡Se importaron con éxito {{playlistCount}} listas ({{trackCount}} canciones en total)!", + "import_error_scope": "Se necesitan permisos de Spotify para crear listas. Inicie sesión nuevamente.", "top_menu": { "help": "Ayuda", "toggle_dark_mode": "Activar/desactivar modo oscuro", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index fac45f1e..b0e4e453 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -9,6 +9,23 @@ "exporting_done": "Terminé!", "exporting_playlist": "Exportation de {{playlistName}}...", "export_search_results": "Exporter les résultats", + "import_playlist": "Importer une playlist", + "import_modal_title": "Importer une playlist", + "import_modal_playlist_name": "Nom de la playlist", + "import_modal_public": "Rendre la playlist publique", + "import_modal_tracks_found": "{{count}} pistes trouvées prêtes à importer", + "import_modal_no_tracks": "Aucune piste Spotify valide trouvée dans ce CSV", + "import_modal_batch_summary": "{{playlistCount}} playlists prêtes à être importées ({{trackCount}} pistes au total)", + "import_modal_remove": "Supprimer", + "import_modal_cancel": "Annuler", + "import_modal_confirm": "Importer dans Spotify", + "importing_started": "Création de la playlist \"{{playlistName}}\"...", + "importing_progress": "Importation des pistes dans \"{{playlistName}}\" ({{count}}/{{total}})...", + "importing_done": "{{count}} pistes importées avec succès dans \"{{playlistName}}\" !", + "importing_batch_started": "Création de la playlist {{current}} sur {{total}} : \"{{playlistName}}\"...", + "importing_batch_progress": "Importation de la playlist {{current}} sur {{total}} : \"{{playlistName}}\" ({{count}}/{{trackTotal}})...", + "importing_batch_done": "{{playlistCount}} playlists importées avec succès ({{trackCount}} pistes au total) !", + "import_error_scope": "Autorisations Spotify requises pour créer des playlists. Veuillez vous reconnecter.", "top_menu": { "help": "Aide", "toggle_dark_mode": "Activer/désactiver le mode sombre", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 841aed56..5bedd8f6 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -9,6 +9,23 @@ "exporting_done": "Fatto!", "exporting_playlist": "Esportando {{playlistName}}...", "export_search_results": "Esporta i risultati", + "import_playlist": "Importa playlist", + "import_modal_title": "Importa playlist", + "import_modal_playlist_name": "Nome della playlist", + "import_modal_public": "Rendi pubblica la playlist", + "import_modal_tracks_found": "{{count}} brani trovati pronti per l'importazione", + "import_modal_no_tracks": "Nessun brano Spotify valido trovato in questo CSV", + "import_modal_batch_summary": "{{playlistCount}} playlist pronte per l'importazione ({{trackCount}} brani in totale)", + "import_modal_remove": "Rimuovi", + "import_modal_cancel": "Annulla", + "import_modal_confirm": "Importa in Spotify", + "importing_started": "Creazione della playlist \"{{playlistName}}\" in corso...", + "importing_progress": "Importazione brani in \"{{playlistName}}\" ({{count}}/{{total}})...", + "importing_done": "Importati con successo {{count}} brani in \"{{playlistName}}\"!", + "importing_batch_started": "Creazione della playlist {{current}} di {{total}}: \"{{playlistName}}\"...", + "importing_batch_progress": "Importazione playlist {{current}} di {{total}}: \"{{playlistName}}\" ({{count}}/{{trackTotal}})...", + "importing_batch_done": "Importate con successo {{playlistCount}} playlist ({{trackCount}} brani in totale)!", + "import_error_scope": "Autorizzazioni Spotify necessarie per creare playlist. Effettua nuovamente il login.", "top_menu": { "help": "Aiuto", "toggle_dark_mode": "Attiva/disattiva modalità scura", diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index ec5b29a3..53ee4689 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -9,6 +9,23 @@ "exporting_done": "完了!", "exporting_playlist": "プレイリスト {{playlistName}} をエクスポート中...", "export_search_results": "検索結果をエクスポート", + "import_playlist": "プレイリストをインポート", + "import_modal_title": "プレイリストのインポート", + "import_modal_playlist_name": "プレイリスト名", + "import_modal_public": "プレイリストを公開する", + "import_modal_tracks_found": "{{count}}曲が見つかりました(インポート可能)", + "import_modal_no_tracks": "有効なSpotifyトラックが見つかりませんでした", + "import_modal_batch_summary": "{{playlistCount}}件のプレイリスト(合計{{trackCount}}曲)をインポート可能", + "import_modal_remove": "削除", + "import_modal_cancel": "キャンセル", + "import_modal_confirm": "Spotifyにインポート", + "importing_started": "プレイリスト「{{playlistName}}」を作成中...", + "importing_progress": "「{{playlistName}}」に曲をインポート中 ({{count}}/{{total}})...", + "importing_done": "「{{playlistName}}」に{{count}}曲を正常にインポートしました!", + "importing_batch_started": "プレイリスト {{current}} / {{total}} を作成中: 「{{playlistName}}」...", + "importing_batch_progress": "プレイリスト {{current}} / {{total}} をインポート中: 「{{playlistName}}」 ({{count}}/{{trackTotal}})...", + "importing_batch_done": "{{playlistCount}}件のプレイリスト(合計{{trackCount}}曲)を正常にインポートしました!", + "import_error_scope": "プレイリストを作成するためのSpotify権限が必要です。再ログインしてください。", "top_menu": { "help": "ヘルプ", "toggle_dark_mode": "ダークモード切り替え", diff --git a/src/i18n/locales/nl/translation.json b/src/i18n/locales/nl/translation.json index decdcf79..ef8df933 100644 --- a/src/i18n/locales/nl/translation.json +++ b/src/i18n/locales/nl/translation.json @@ -9,6 +9,23 @@ "exporting_done": "Klaar!", "exporting_playlist": "Exporteer {{playlistName}}...", "export_search_results": "Resultaten exporteren", + "import_playlist": "Afspeellijst importeren", + "import_modal_title": "Afspeellijst importeren", + "import_modal_playlist_name": "Naam van afspeellijst", + "import_modal_public": "Afspeellijst openbaar maken", + "import_modal_tracks_found": "{{count}} nummers gevonden klaar om te importeren", + "import_modal_no_tracks": "Geen geldige Spotify-nummers gevonden in dit CSV-bestand", + "import_modal_batch_summary": "{{playlistCount}} afspeellijsten klaar om te importeren (in totaal {{trackCount}} nummers)", + "import_modal_remove": "Verwijderen", + "import_modal_cancel": "Annuleren", + "import_modal_confirm": "Importeren naar Spotify", + "importing_started": "Afspeellijst \"{{playlistName}}\" aanmaken...", + "importing_progress": "Nummers importeren naar \"{{playlistName}}\" ({{count}}/{{total}})...", + "importing_done": "Succesvol {{count}} nummers geïmporteerd in \"{{playlistName}}\"!", + "importing_batch_started": "Afspeellijst {{current}} van {{total}} aanmaken: \"{{playlistName}}\"...", + "importing_batch_progress": "Afspeellijst {{current}} van {{total}} importeren: \"{{playlistName}}\" ({{count}}/{{trackTotal}})...", + "importing_batch_done": "Succesvol {{playlistCount}} afspeellijsten geïmporteerd (in totaal {{trackCount}} nummers)!", + "import_error_scope": "Spotify-machtigingen vereist om afspeellijsten te maken. Log opnieuw in.", "top_menu": { "help": "Help", "toggle_dark_mode": "Donkere modus wisselen", diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 4df4eda0..c6e72ec7 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -9,6 +9,23 @@ "exporting_done": "Concluído!", "exporting_playlist": "Exportando {{playlistName}}...", "export_search_results": "Exportar resultados", + "import_playlist": "Importar playlist", + "import_modal_title": "Importar playlist", + "import_modal_playlist_name": "Nome da playlist", + "import_modal_public": "Tornar playlist pública", + "import_modal_tracks_found": "{{count}} faixas encontradas prontas para importar", + "import_modal_no_tracks": "Nenhuma faixa válida do Spotify encontrada neste CSV", + "import_modal_batch_summary": "{{playlistCount}} playlists prontas para importar ({{trackCount}} faixas no total)", + "import_modal_remove": "Remover", + "import_modal_cancel": "Cancelar", + "import_modal_confirm": "Importar para o Spotify", + "importing_started": "Criando playlist \"{{playlistName}}\"...", + "importing_progress": "Importando faixas para \"{{playlistName}}\" ({{count}}/{{total}})...", + "importing_done": "{{count}} faixas importadas com sucesso em \"{{playlistName}}\"!", + "importing_batch_started": "Criando playlist {{current}} de {{total}}: \"{{playlistName}}\"...", + "importing_batch_progress": "Importando playlist {{current}} de {{total}}: \"{{playlistName}}\" ({{count}}/{{trackTotal}})...", + "importing_batch_done": "{{playlistCount}} playlists importadas com sucesso ({{trackCount}} faixas no total)!", + "import_error_scope": "Permissões do Spotify necessárias para criar playlists. Faça login novamente.", "top_menu": { "help": "Ajuda", "toggle_dark_mode": "Ativar/desativar modo escuro", diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index 3c398871..66f2ddb1 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -9,6 +9,23 @@ "exporting_done": "Klart!", "exporting_playlist": "Exporterar {{playlistName}}...", "export_search_results": "Exportera resultat", + "import_playlist": "Importera spellista", + "import_modal_title": "Importera spellista", + "import_modal_playlist_name": "Spellistans namn", + "import_modal_public": "Gör spellistan offentlig", + "import_modal_tracks_found": "{{count}} låtar hittades redo att importeras", + "import_modal_no_tracks": "Inga giltiga Spotify-låtar hittades i denna CSV", + "import_modal_batch_summary": "{{playlistCount}} spellistor redo att importeras (totalt {{trackCount}} låtar)", + "import_modal_remove": "Ta bort", + "import_modal_cancel": "Avbryt", + "import_modal_confirm": "Importera till Spotify", + "importing_started": "Skapar spellista \"{{playlistName}}\"...", + "importing_progress": "Importerar låtar till \"{{playlistName}}\" ({{count}}/{{total}})...", + "importing_done": "{{count}} låtar har importerats till \"{{playlistName}}\"!", + "importing_batch_started": "Skapar spellista {{current}} av {{total}}: \"{{playlistName}}\"...", + "importing_batch_progress": "Importerar spellista {{current}} av {{total}}: \"{{playlistName}}\" ({{count}}/{{trackTotal}})...", + "importing_batch_done": "{{playlistCount}} spellistor har importerats framgångsrikt (totalt {{trackCount}} låtar)!", + "import_error_scope": "Spotify-behörigheter krävs för att skapa spellistor. Logga in igen.", "top_menu": { "help": "Hjälp", "toggle_dark_mode": "Växla mörkt läge", diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 298d3aad..63dd41eb 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -9,6 +9,23 @@ "exporting_done": "Tamamlandı!", "exporting_playlist": "{{playlistName}} çalma listesi dışa aktarılıyor...", "export_search_results": "Sonuçları Dışa Aktar", + "import_playlist": "Çalma Listesini İçe Aktar", + "import_modal_title": "Çalma Listesini İçe Aktar", + "import_modal_playlist_name": "Çalma Listesi Adı", + "import_modal_public": "Çalma listesini herkese açık yap", + "import_modal_tracks_found": "İçe aktarılmaya hazır {{count}} şarkı bulundu", + "import_modal_no_tracks": "Bu CSV dosyasında geçerli Spotify şarkısı bulunamadı", + "import_modal_batch_summary": "İçe aktarılmaya hazır {{playlistCount}} çalma listesi (toplam {{trackCount}} şarkı)", + "import_modal_remove": "Kaldır", + "import_modal_cancel": "İptal", + "import_modal_confirm": "Spotify'a İçe Aktar", + "importing_started": "\"{{playlistName}}\" çalma listesi oluşturuluyor...", + "importing_progress": "\"{{playlistName}}\" çalma listesine şarkılar aktarılıyor ({{count}}/{{total}})...", + "importing_done": "\"{{playlistName}}\" çalma listesine {{count}} şarkı başarıyla aktarıldı!", + "importing_batch_started": "Çalma listesi {{current}} / {{total}} oluşturuluyor: \"{{playlistName}}\"...", + "importing_batch_progress": "Çalma listesi {{current}} / {{total}} aktarılıyor: \"{{playlistName}}\" ({{count}}/{{trackTotal}})...", + "importing_batch_done": "{{playlistCount}} çalma listesi başarıyla aktarıldı (toplam {{trackCount}} şarkı)!", + "import_error_scope": "Çalma listesi oluşturmak için Spotify izinleri gerekiyor. Lütfen tekrar giriş yapın.", "top_menu": { "help": "Yardım", "toggle_dark_mode": "Karanlık mod aç/kapa", diff --git a/src/icons.ts b/src/icons.ts index d748de17..746ed063 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -1,7 +1,7 @@ import { library } from '@fortawesome/fontawesome-svg-core' import { fab } from '@fortawesome/free-brands-svg-icons' import { faCheckCircle, faTimesCircle, faFileArchive, faHeart } from '@fortawesome/free-regular-svg-icons' -import { faBolt, faMusic, faDownload, faCog, faSearch, faTimes, faSignOutAlt, faSync, faLightbulb, faCircleInfo, faGlobe, faCheck } from '@fortawesome/free-solid-svg-icons' +import { faBolt, faMusic, faDownload, faCog, faSearch, faTimes, faSignOutAlt, faSync, faLightbulb, faCircleInfo, faGlobe, faCheck, faFileImport } from '@fortawesome/free-solid-svg-icons' library.add( fab, @@ -20,5 +20,6 @@ library.add( faLightbulb, faCircleInfo, faGlobe, - faCheck + faCheck, + faFileImport ) diff --git a/src/utils/csvParser.test.ts b/src/utils/csvParser.test.ts new file mode 100644 index 00000000..7641fbe3 --- /dev/null +++ b/src/utils/csvParser.test.ts @@ -0,0 +1,57 @@ +import { parseTracksCsv, filenameToPlaylistName } from "./csvParser" + +describe("csvParser", () => { + describe("filenameToPlaylistName", () => { + it("strips .csv extension and replaces underscores/hyphens with spaces", () => { + expect(filenameToPlaylistName("My_Favorite_Songs.csv")).toBe("My Favorite Songs") + expect(filenameToPlaylistName("road-trip-2024.CSV")).toBe("road trip 2024") + expect(filenameToPlaylistName("classic%20rock.csv")).toBe("classic rock") + }) + + it("handles files without extension", () => { + expect(filenameToPlaylistName("cool_playlist")).toBe("cool playlist") + }) + }) + + describe("parseTracksCsv", () => { + it("parses valid Exportify CSV with Track URI column", () => { + const csv = `"Track URI","Track Name","Artist Name(s)"\n"spotify:track:4iV5W9uYEdYUVa79Axb7Rh","Song 1","Artist A"\n"spotify:track:1301WleyT98MSxVHPZCA6M","Song 2","Artist B"` + const result = parseTracksCsv(csv) + expect(result.trackUris).toEqual([ + "spotify:track:4iV5W9uYEdYUVa79Axb7Rh", + "spotify:track:1301WleyT98MSxVHPZCA6M" + ]) + expect(result.errors).toEqual([]) + }) + + it("handles CRLF line breaks and quoted values containing commas", () => { + const csv = `"Track URI","Track Name","Artist Name(s)"\r\n"spotify:track:123","Song, with comma","Artist 1, Artist 2"\r\n"spotify:track:456","Another ""Song""","Artist 3"` + const result = parseTracksCsv(csv) + expect(result.trackUris).toEqual([ + "spotify:track:123", + "spotify:track:456" + ]) + expect(result.errors).toEqual([]) + }) + + it("falls back to regex matching if Track URI column is not explicitly named", () => { + const csv = `"URI","Title"\n"spotify:track:789","Test Track"\n"spotify:track:999","Another Track"` + const result = parseTracksCsv(csv) + expect(result.trackUris).toEqual([ + "spotify:track:789", + "spotify:track:999" + ]) + }) + + it("ignores empty lines and rows without valid track URIs", () => { + const csv = `"Track URI","Track Name"\n\n"not-a-track","Invalid"\n"spotify:track:111","Valid Track"\n\n` + const result = parseTracksCsv(csv) + expect(result.trackUris).toEqual(["spotify:track:111"]) + }) + + it("returns empty trackUris if CSV is empty or has no track URIs", () => { + const result = parseTracksCsv("") + expect(result.trackUris).toEqual([]) + }) + }) +}) diff --git a/src/utils/csvParser.ts b/src/utils/csvParser.ts new file mode 100644 index 00000000..09be33de --- /dev/null +++ b/src/utils/csvParser.ts @@ -0,0 +1,114 @@ +export interface ParsedCsvResult { + trackUris: string[] + errors: string[] +} + +const SPOTIFY_TRACK_URI_REGEX = /^spotify:track:[a-zA-Z0-9]+$/ + +/** + * Splits a CSV string into rows and columns adhering to RFC 4180. + */ +function parseCsvRows(csvContent: string): string[][] { + const rows: string[][] = [] + let currentRow: string[] = [] + let currentField = "" + let insideQuotes = false + + for (let i = 0; i < csvContent.length; i++) { + const char = csvContent[i] + const nextChar = csvContent[i + 1] + + if (insideQuotes) { + if (char === '"' && nextChar === '"') { + currentField += '"' + i++ // Skip escaped quote + } else if (char === '"') { + insideQuotes = false + } else { + currentField += char + } + } else { + if (char === '"') { + insideQuotes = true + } else if (char === ',') { + currentRow.push(currentField) + currentField = "" + } else if (char === '\r') { + if (nextChar === '\n') { + i++ + } + currentRow.push(currentField) + rows.push(currentRow) + currentRow = [] + currentField = "" + } else if (char === '\n') { + currentRow.push(currentField) + rows.push(currentRow) + currentRow = [] + currentField = "" + } else { + currentField += char + } + } + } + + if (currentField.length > 0 || currentRow.length > 0) { + currentRow.push(currentField) + rows.push(currentRow) + } + + return rows +} + +/** + * Converts a filename into a default playlist title. + */ +export function filenameToPlaylistName(filename: string): string { + return filename + .replace(/\.csv$/i, "") + .replace(/%20/g, " ") + .replace(/[_-]+/g, " ") + .trim() +} + +/** + * Parses track URIs from CSV file content. + */ +export function parseTracksCsv(csvContent: string): ParsedCsvResult { + const rows = parseCsvRows(csvContent).filter(row => row.some(cell => cell.trim().length > 0)) + if (rows.length === 0) { + return { trackUris: [], errors: [] } + } + + const header = rows[0].map(col => col.trim().toLowerCase()) + const trackUriColIndex = header.findIndex(col => + col === "track uri" || + col === "track_uri" || + col === "uri" || + col.includes("track uri") + ) + + const trackUris: string[] = [] + + if (trackUriColIndex !== -1) { + for (let r = 1; r < rows.length; r++) { + const cell = rows[r][trackUriColIndex]?.trim() || "" + if (SPOTIFY_TRACK_URI_REGEX.test(cell)) { + trackUris.push(cell) + } + } + } else { + // Fallback: search all cells for track URI patterns + for (let r = 0; r < rows.length; r++) { + for (const cell of rows[r]) { + const trimmed = cell.trim() + if (SPOTIFY_TRACK_URI_REGEX.test(trimmed)) { + trackUris.push(trimmed) + break + } + } + } + } + + return { trackUris, errors: [] } +}