Skip to content

Commit

Permalink
Refactor PlaylistExporter in preparation for adding more data
Browse files Browse the repository at this point in the history
(Includes migration of PlaylistExporter and helpers to TypeScript)
  • Loading branch information
watsonbox committed Nov 20, 2020
1 parent 5d73669 commit e55491c
Show file tree
Hide file tree
Showing 11 changed files with 218 additions and 89 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/file-saver": "^2.0.1",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "^16.9.53",
Expand Down
74 changes: 0 additions & 74 deletions src/components/PlaylistExporter.jsx

This file was deleted.

81 changes: 81 additions & 0 deletions src/components/PlaylistExporter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { saveAs } from "file-saver"

import TracksData from "components/tracks_data/TracksData"
import TracksBaseData from "components/tracks_data/TracksBaseData"

class TracksCsvFile {
playlist: any
columnNames: string[]
lineData: Map<string, string[]>

constructor(playlist: any) {
this.playlist = playlist
this.columnNames = []
this.lineData = new Map()
}

async addData(tracksData: TracksData) {
this.columnNames.push(...tracksData.dataLabels())

const data: Map<string, string[]> = await tracksData.data()

data.forEach((value: string[], key: string) => {
if (this.lineData.has(key)) {
this.lineData.get(key)!.push(...value)
} else {
this.lineData.set(key, value)
}
})
}

content(): string {
let csvContent = ''

csvContent += this.columnNames.map(this.sanitize).join() + "\n"

this.lineData.forEach((lineData, trackId) => {
csvContent += lineData.map(this.sanitize).join(",") + "\n"
})

return csvContent
}

sanitize(string: string): string {
return '"' + String(string).replace(/"/g, '""') + '"'
}
}

// Handles exporting a single playlist as a CSV file
class PlaylistExporter {
accessToken: string
playlist: any

constructor(accessToken: string, playlist: any) {
this.accessToken = accessToken
this.playlist = playlist
}

async export() {
return this.csvData().then((data) => {
var blob = new Blob([ data ], { type: "text/csv;charset=utf-8" })
saveAs(blob, this.fileName(), true)
})
}

async csvData() {
const tracksCsvFile = new TracksCsvFile(this.playlist)
const tracksBaseData = new TracksBaseData(this.accessToken, this.playlist)

await tracksCsvFile.addData(tracksBaseData)

tracksBaseData.tracks()

return tracksCsvFile.content()
}

fileName(): string {
return this.playlist.name.replace(/[\x00-\x1F\x7F/\\<>:;"|=,.?*[\] ]+/g, "_").toLowerCase() + ".csv" // eslint-disable-line no-control-regex
}
}

export default PlaylistExporter
2 changes: 1 addition & 1 deletion src/components/PlaylistRow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import PlaylistExporter from "./PlaylistExporter"

class PlaylistRow extends React.Component {
exportPlaylist = () => {
PlaylistExporter.export(this.props.accessToken, this.props.playlist).catch(apiCallErrorHandler)
(new PlaylistExporter(this.props.accessToken, this.props.playlist)).export().catch(apiCallErrorHandler)
}

renderTickCross(condition) {
Expand Down
12 changes: 10 additions & 2 deletions src/components/PlaylistTable.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import JSZip from "jszip"
import PlaylistTable from "./PlaylistTable"

import "../icons"
import { handlers, nullTrackHandlers } from "../mocks/handlers"
import { handlerCalled, handlers, nullTrackHandlers } from "../mocks/handlers"

const server = setupServer(...handlers)

Expand Down Expand Up @@ -75,6 +75,14 @@ describe("single playlist exporting", () => {
fireEvent.click(linkElement)

await waitFor(() => {
expect(handlerCalled).toHaveBeenCalledTimes(4)
expect(handlerCalled.mock.calls).toEqual([ // Ensure API call order and no duplicates
[ 'https://api.spotify.com/v1/me' ],
[ 'https://api.spotify.com/v1/users/watsonbox/playlists' ],
[ 'https://api.spotify.com/v1/users/watsonbox/tracks' ],
[ 'https://api.spotify.com/v1/me/tracks?offset=0&limit=20' ]
])

expect(saveAsMock).toHaveBeenCalledTimes(1)
expect(saveAsMock).toHaveBeenCalledWith(
{
Expand Down Expand Up @@ -124,7 +132,7 @@ describe("single playlist exporting", () => {
})
})

test("exporting of all playlist", async () => {
test("exporting of all playlists", async () => {
const saveAsMock = jest.spyOn(FileSaver, "saveAs")
saveAsMock.mockImplementation(jest.fn())

Expand Down
18 changes: 10 additions & 8 deletions src/components/PlaylistsExporter.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,17 @@ class PlaylistsExporter extends React.Component {
},
})

let trackPromises = playlists.map((playlist, index) => {
return PlaylistExporter.csvData(accessToken, playlist).then((csvData) => {
playlistFileNames.push(PlaylistExporter.fileName(playlist))
playlistCsvExports.push(csvData)
this.props.onExportedPlaylistsCountChanged(index + 1)
})
})
let index = 0

for (const playlist of playlists) {
let exporter = new PlaylistExporter(accessToken, playlist)
let csvData = await exporter.csvData()

await Promise.all(trackPromises)
playlistFileNames.push(exporter.fileName(playlist))
playlistCsvExports.push(csvData)

this.props.onExportedPlaylistsCountChanged(index += 1)
}

var zip = new JSZip()

Expand Down
80 changes: 80 additions & 0 deletions src/components/tracks_data/TracksBaseData.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import TracksData from "./TracksData"
import { apiCall } from "helpers"

class TracksBaseData extends TracksData {
playlist: any

constructor(accessToken: string, playlist: any) {
super(accessToken)
this.playlist = playlist
}

dataLabels() {
return [
"Track URI",
"Track Name",
"Artist URI",
"Artist Name",
"Album URI",
"Album Name",
"Disc Number",
"Track Number",
"Track Duration (ms)",
"Added By",
"Added At"
]
}

async tracks() {
await this.getPlaylistItems()

return this.playlistItems.map(i => i.track)
}

async data() {
await this.getPlaylistItems()

return new Map(this.playlistItems.map(item => {
return [
item.track.id,
[
item.track.uri,
item.track.name,
item.track.artists.map((a: any) => { return a.uri }).join(', '),
item.track.artists.map((a: any) => { return String(a.name).replace(/,/g, "\\,") }).join(', '),
item.track.album.uri,
item.track.album.name,
item.track.disc_number,
item.track.track_number,
item.track.duration_ms,
item.added_by == null ? '' : item.added_by.uri,
item.added_at
]
]
}))
}

// Memoization supporting multiple calls
private playlistItems: any[] = []
private async getPlaylistItems() {
if (this.playlistItems.length > 0) {
return this.playlistItems
}

var requests = []
var limit = this.playlist.tracks.limit || 100

for (var offset = 0; offset < this.playlist.tracks.total; offset = offset + limit) {
requests.push(`${this.playlist.tracks.href.split('?')[0]}?offset=${offset}&limit=${limit}`)
}

const trackPromises = requests.map(request => { return apiCall(request, this.accessToken) })
const trackResponses = await Promise.all(trackPromises)

this.playlistItems = trackResponses.flatMap(response => {
return response.data.items.filter((i: any) => i.track) // Exclude null track attributes
})
}
}

export default TracksBaseData
12 changes: 12 additions & 0 deletions src/components/tracks_data/TracksData.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
abstract class TracksData {
accessToken: string

constructor(accessToken: string) {
this.accessToken = accessToken
}

abstract dataLabels(): string[]
abstract data(): Promise<Map<string, string[]>>
}

export default TracksData
6 changes: 3 additions & 3 deletions src/helpers.jsx → src/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function authorize() {
}

// http://stackoverflow.com/a/901144/4167042
export function getQueryParam(name) {
export function getQueryParam(name: string) {
name = name.replace(/[[]/, "\\[").replace(/[\]]/, "\\]");
var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"),
results = regex.exec(window.location.search);
Expand Down Expand Up @@ -51,11 +51,11 @@ limiter.on("failed", async (error, jobInfo) => {
}
})

export const apiCall = limiter.wrap(function(url, accessToken) {
export const apiCall = limiter.wrap(function(url: string, accessToken: string) {
return axios.get(url, { headers: { 'Authorization': 'Bearer ' + accessToken } })
})

export function apiCallErrorHandler(error) {
export function apiCallErrorHandler(error: any) {
if (error.isAxiosError) {
if (error.response.status === 401) {
// Return to home page after auth token expiry
Expand Down
Loading

0 comments on commit e55491c

Please sign in to comment.