From 74068a44179772d8102c908f31a4c7fa1f202106 Mon Sep 17 00:00:00 2001 From: dgloukhman Date: Thu, 20 Aug 2026 15:44:25 +0200 Subject: [PATCH 1/4] feat(data): add CSV parser, API helpers, and PlaylistImportService - Add playlist-modify-public and playlist-modify-private OAuth scopes - Add rate-limited apiPost wrapper in helpers - Register faFileImport icon - Implement RFC 4180 CSV parser for extracting Spotify track URIs/IDs - Add cache invalidation method to PlaylistsData - Implement PlaylistImportService with track batching and multi-playlist support --- src/auth.ts | 2 +- .../data/PlaylistImportService.test.ts | 115 +++++++++++++++++ src/components/data/PlaylistImportService.ts | 121 ++++++++++++++++++ src/components/data/PlaylistsData.ts | 6 + src/helpers.ts | 9 ++ src/icons.ts | 5 +- src/utils/csvParser.test.ts | 57 +++++++++ src/utils/csvParser.ts | 114 +++++++++++++++++ 8 files changed, 426 insertions(+), 3 deletions(-) create mode 100644 src/components/data/PlaylistImportService.test.ts create mode 100644 src/components/data/PlaylistImportService.ts create mode 100644 src/utils/csvParser.test.ts create mode 100644 src/utils/csvParser.ts 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/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/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: [] } +} From 20a336a38508e5b9e7ffdf74b84162c77390d392 Mon Sep 17 00:00:00 2001 From: dgloukhman Date: Thu, 20 Aug 2026 15:44:27 +0200 Subject: [PATCH 2/4] feat(i18n): add import playlist translations across all 12 locales Add translation strings for import playlist button, modal dialogs, status messages, and errors across Arabic, German, Greek, English, Spanish, French, Italian, Japanese, Dutch, Portuguese, Swedish, and Turkish. --- src/i18n/locales/ar/translation.json | 17 +++++++++++++++++ src/i18n/locales/de/translation.json | 17 +++++++++++++++++ src/i18n/locales/el/translation.json | 17 +++++++++++++++++ src/i18n/locales/en/translation.json | 17 +++++++++++++++++ src/i18n/locales/es/translation.json | 17 +++++++++++++++++ src/i18n/locales/fr/translation.json | 17 +++++++++++++++++ src/i18n/locales/it/translation.json | 17 +++++++++++++++++ src/i18n/locales/ja/translation.json | 17 +++++++++++++++++ src/i18n/locales/nl/translation.json | 17 +++++++++++++++++ src/i18n/locales/pt/translation.json | 17 +++++++++++++++++ src/i18n/locales/sv/translation.json | 17 +++++++++++++++++ src/i18n/locales/tr/translation.json | 17 +++++++++++++++++ 12 files changed, 204 insertions(+) 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", From b323d50d148c095e1f623e861451dd348e136f3f Mon Sep 17 00:00:00 2001 From: dgloukhman Date: Thu, 20 Aug 2026 15:44:29 +0200 Subject: [PATCH 3/4] feat(components): implement PlaylistImporter and ImportPlaylistModal - Add PlaylistImporter supporting single and multi-file CSV selection - Add ImportPlaylistModal supporting multi-file itemized preview, inline editing, and privacy configuration - Include unit tests for modal and file importer workflows --- src/components/ImportPlaylistModal.test.tsx | 90 ++++++++++ src/components/ImportPlaylistModal.tsx | 188 ++++++++++++++++++++ src/components/PlaylistImporter.test.tsx | 124 +++++++++++++ src/components/PlaylistImporter.tsx | 149 ++++++++++++++++ 4 files changed, 551 insertions(+) create mode 100644 src/components/ImportPlaylistModal.test.tsx create mode 100644 src/components/ImportPlaylistModal.tsx create mode 100644 src/components/PlaylistImporter.test.tsx create mode 100644 src/components/PlaylistImporter.tsx 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) From 6790c2720aebbdc17fc0938363f1fcb39a532018 Mon Sep 17 00:00:00 2001 From: dgloukhman Date: Thu, 20 Aug 2026 15:44:32 +0200 Subject: [PATCH 4/4] feat(ui): integrate PlaylistImporter and progress feedback into PlaylistTable - Embed PlaylistImporter into PlaylistTable action bar next to Export All - Display real-time progress and completion/error alerts for imports - Reset table pagination to page 1 upon successful import - Update PlaylistTable tests and snapshot --- src/components/PlaylistTable.test.tsx | 120 ++++++++++++++++- src/components/PlaylistTable.tsx | 123 ++++++++++++++++-- .../__snapshots__/PlaylistTable.test.tsx.snap | 70 +++++++--- 3 files changed, 283 insertions(+), 30 deletions(-) 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 + + +