Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion src/components/PlaylistTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import JSZip from "jszip"
import PlaylistTable from "./PlaylistTable"

import "../icons"
import { handlerCalled, handlers, nullAlbumHandlers, nullTrackHandlers, localTrackHandlers, duplicateTrackHandlers, missingPlaylistsHandlers } from "../mocks/handlers"
import { handlerCalled, handlers, nullAlbumHandlers, nullTrackHandlers, localTrackHandlers, duplicateTrackHandlers, missingPlaylistsHandlers, multipleFilterPlaylistsHandlers } from "../mocks/handlers"

const server = setupServer(...handlers)

Expand Down Expand Up @@ -487,6 +487,40 @@ describe("searching playlists", () => {
expect(screen.queryByText("Ghostpoet – Peanut Butter Blues and Melancholy Jam")).not.toBeInTheDocument()
})
})

test("multiple filters can be combined", async () => {
server.use(...multipleFilterPlaylistsHandlers)

render(<PlaylistTable accessToken="TEST_ACCESS_TOKEN" onSetSubtitle={onSetSubtitle} />)

expect(await screen.findByRole('searchbox')).toBeInTheDocument()

userEvent.type(screen.getByRole('searchbox'), 'public:true owner:otheruser{enter}')

await waitFor(() => {
expect(screen.queryAllByRole('row')).toHaveLength(2)
expect(screen.queryByText("Public Playlist")).toBeInTheDocument()
expect(screen.queryByText("Ghostpoet – Peanut Butter Blues and Melancholy Jam")).not.toBeInTheDocument()
expect(screen.queryByText("Liked")).not.toBeInTheDocument()
})
})

test("filters can be combined with a name search", async () => {
server.use(...multipleFilterPlaylistsHandlers)

render(<PlaylistTable accessToken="TEST_ACCESS_TOKEN" onSetSubtitle={onSetSubtitle} />)

expect(await screen.findByRole('searchbox')).toBeInTheDocument()

userEvent.type(screen.getByRole('searchbox'), 'public:false Ghost{enter}')

await waitFor(() => {
expect(screen.queryAllByRole('row')).toHaveLength(2)
expect(screen.queryByText("Ghostpoet – Peanut Butter Blues and Melancholy Jam")).toBeInTheDocument()
expect(screen.queryByText("Public Playlist")).not.toBeInTheDocument()
expect(screen.queryByText("Liked")).not.toBeInTheDocument()
})
})
})

describe("missing playlists", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/PlaylistTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class PlaylistTable extends React.Component<PlaylistTableProps> {
})

let key = "subtitle_search"
if (query.startsWith("public:") || query.startsWith("collaborative:") || query.startsWith("owner:")) {
if (/\b(public|collaborative|owner):/.test(query)) {
key += "_advanced"
}

Expand Down
4 changes: 4 additions & 0 deletions src/components/TopMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ class TopMenu extends React.Component<TopMenuProps> {
<td><code>owner:[owner]</code></td>
<td><Trans i18nKey="help.search_syntax.owner_owner" components={{ code: <code /> }} /></td>
</tr>
<tr>
<td><code>public:true collaborative:false</code></td>
<td><Trans i18nKey="help.search_syntax.multiple_filters" components={{ code: <code /> }} /></td>
</tr>
</tbody>
</Table>

Expand Down
49 changes: 38 additions & 11 deletions src/components/data/PlaylistsData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,47 @@ class PlaylistsData {
// Remove any uninitialized playlists when exporting
let results = this.data.filter(p => p && Object.keys(p).length > 0)

if (query.startsWith("public:")) {
return results.filter(p => p.public === query.endsWith(":true"))
} else if (query.startsWith("collaborative:")) {
return results.filter(p => p.collaborative === query.endsWith(":true"))
} else if (query.startsWith("owner:")) {
let owner = query.match(/owner:(.*)/)?.at(-1)?.toLowerCase()
if (owner === "me") owner = this.userId

return results.filter(p => p.owner).filter(p => p.owner.id === owner)
} else {
const { filters, textQuery } = this.parseQuery(query)

filters.forEach(filter => {
if (filter.key === "public") {
results = results.filter(p => p.public === filter.value)
} else if (filter.key === "collaborative") {
results = results.filter(p => p.collaborative === filter.value)
} else if (filter.key === "owner") {
results = results.filter(p => p.owner && p.owner.id === filter.value)
}
})

if (textQuery) {
// Case-insensitive search in playlist name
// TODO: Add lazy evaluation for performance?
return results.filter(p => p.name.toLowerCase().includes(query.toLowerCase()))
results = results.filter(p => p.name.toLowerCase().includes(textQuery.toLowerCase()))
}

return results
}

private parseQuery(query: string) {
const tokens = query.trim() ? query.trim().split(/\s+/) : []
const filters: { key: string, value: any }[] = []
const textTokens: string[] = []

tokens.forEach(token => {
if (/^public:(true|false)$/.test(token)) {
filters.push({ key: "public", value: token.endsWith(":true") })
} else if (/^collaborative:(true|false)$/.test(token)) {
filters.push({ key: "collaborative", value: token.endsWith(":true") })
} else if (/^owner:(.+)$/.test(token)) {
let owner = token.match(/^owner:(.+)$/)![1].toLowerCase()
if (owner === "me") owner = this.userId
filters.push({ key: "owner", value: owner })
} else {
textTokens.push(token)
}
})

return { filters, textQuery: textTokens.join(" ") }
}

async loadAll() {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"collaborative_false": "Don't show collaborative playlists",
"owner_me": "Only show playlists I own",
"owner_owner": "Only show playlists owned by <code>[owner]</code>",
"multiple_filters": "Combine multiple filters and/or a name search",
"more_detail": "For more detail please refer to the <a href='https://github.com/watsonbox/exportify' target='_blank' rel='noreferrer'>full project documentation</a>."
}
},
Expand Down
84 changes: 84 additions & 0 deletions src/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,90 @@ export const localTrackHandlers = [
})
]

export const multipleFilterPlaylistsHandlers = [
rest.get('https://api.spotify.com/v1/users/watsonbox/playlists', (req, res, ctx) => {
handlerCalled(req.url.toString())

if (req.headers.get("Authorization") !== "Bearer TEST_ACCESS_TOKEN") {
return res(ctx.status(401), ctx.json({ message: 'Not authorized' }))
}

return res(ctx.json(
{
"href": "https://api.spotify.com/v1/users/watsonbox/playlists?offset=0&limit=20",
"items": [{
"collaborative": false,
"description": "",
"external_urls": {
"spotify": "https://open.spotify.com/playlist/4XOGDpHMrVoH33uJEwHWU5"
},
"href": "https://api.spotify.com/v1/playlists/4XOGDpHMrVoH33uJEwHWU5",
"id": "4XOGDpHMrVoH33uJEwHWU5",
"images": [{
"height": 640,
"url": "https://i.scdn.co/image/ab67616d0000b273306e7640be17c5b3468e6e80",
"width": 640
}],
"name": "Ghostpoet – Peanut Butter Blues and Melancholy Jam",
"owner": {
"display_name": "watsonbox",
"external_urls": {
"spotify": "https://open.spotify.com/user/watsonbox"
},
"href": "https://api.spotify.com/v1/users/watsonbox",
"id": "watsonbox",
"type": "user",
"uri": "spotify:user:watsonbox"
},
"primary_color": null,
"public": false,
"snapshot_id": "MixjMzFkNjFhYzJkMDkzNmE3OGQ1N2YyYmQ0NTkxYTk5NjBhZmZkYzZi",
"tracks": {
"href": "https://api.spotify.com/v1/playlists/4XOGDpHMrVoH33uJEwHWU5/tracks",
"total": 10
},
"type": "playlist",
"uri": "spotify:playlist:4XOGDpHMrVoH33uJEwHWU5"
}, {
"collaborative": false,
"description": "",
"external_urls": {
"spotify": "https://open.spotify.com/playlist/PUBLICPLAYLIST"
},
"href": "https://api.spotify.com/v1/playlists/PUBLICPLAYLIST",
"id": "PUBLICPLAYLIST",
"images": [],
"name": "Public Playlist",
"owner": {
"display_name": "otheruser",
"external_urls": {
"spotify": "https://open.spotify.com/user/otheruser"
},
"href": "https://api.spotify.com/v1/users/otheruser",
"id": "otheruser",
"type": "user",
"uri": "spotify:user:otheruser"
},
"primary_color": null,
"public": true,
"snapshot_id": "public",
"tracks": {
"href": "https://api.spotify.com/v1/playlists/PUBLICPLAYLIST/tracks",
"total": 5
},
"type": "playlist",
"uri": "spotify:playlist:PUBLICPLAYLIST"
}],
"limit": 20,
"next": null,
"offset": 0,
"previous": null,
"total": 2
}
))
}),
]

export const duplicateTrackHandlers = [
rest.get('https://api.spotify.com/v1/playlists/4XOGDpHMrVoH33uJEwHWU5/tracks', (req, res, ctx) => {
handlerCalled(req.url.toString())
Expand Down