From 3a13c24301afd3a005a6b89119c4425e1e6e5d72 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Thu, 24 Apr 2025 23:45:14 +0200 Subject: [PATCH] Folders working! --- package-lock.json | 19 ++ packages/backend/src/models/Playlist.ts | 22 ++- packages/backend/src/routes/playlists.ts | 8 +- packages/frontend/package.json | 2 + packages/frontend/src/App.tsx | 104 ++++++++-- .../src/components/PlaylistManager.tsx | 139 +++++++++---- packages/frontend/src/components/SongList.tsx | 164 +++++++--------- packages/frontend/src/hooks/useXmlParser.ts | 4 +- packages/frontend/src/services/api.ts | 75 +++---- packages/frontend/src/services/xmlService.ts | 183 +++++++++++------- packages/frontend/src/types/Song.ts | 12 ++ packages/frontend/src/types/interfaces.ts | 9 + 12 files changed, 467 insertions(+), 274 deletions(-) create mode 100644 packages/frontend/src/types/Song.ts diff --git a/package-lock.json b/package-lock.json index 1f1b2ee..2b41560 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2233,6 +2233,11 @@ "@types/send": "*" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==" + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -5751,6 +5756,18 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -6038,12 +6055,14 @@ "@chakra-ui/transition": "^2.1.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@types/uuid": "^10.0.0", "events": "^3.3.0", "framer-motion": "^12.5.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.5.2", "sax": "^1.4.1", + "uuid": "^11.1.0", "xml2js": "^0.6.2", "xmlbuilder": "^15.1.1" }, diff --git a/packages/backend/src/models/Playlist.ts b/packages/backend/src/models/Playlist.ts index f632c7f..5cbd820 100644 --- a/packages/backend/src/models/Playlist.ts +++ b/packages/backend/src/models/Playlist.ts @@ -1,8 +1,22 @@ import mongoose from 'mongoose'; -const playlistSchema = new mongoose.Schema({ - name: { type: String, required: true, unique: true }, - tracks: [{ type: String, ref: 'Song' }], +const playlistNodeSchema = new mongoose.Schema({ + name: { type: String, required: true }, + type: { type: String, enum: ['folder', 'playlist'], default: 'playlist' }, + tracks: [{ type: String, ref: 'Song', default: [] }], + children: [{ type: mongoose.Schema.Types.Mixed }], // This allows recursive structure +}, { + _id: true, + id: true, + timestamps: true, + toJSON: { + transform: function(doc, ret) { + ret.id = ret._id.toString(); + delete ret._id; + delete ret.__v; + return ret; + } + } }); -export const Playlist = mongoose.model('Playlist', playlistSchema); \ No newline at end of file +export const PlaylistNode = mongoose.model('PlaylistNode', playlistNodeSchema); \ No newline at end of file diff --git a/packages/backend/src/routes/playlists.ts b/packages/backend/src/routes/playlists.ts index bd4f9e4..a25b37c 100644 --- a/packages/backend/src/routes/playlists.ts +++ b/packages/backend/src/routes/playlists.ts @@ -1,12 +1,12 @@ import express from 'express'; -import { Playlist } from '../models/Playlist.js'; +import { PlaylistNode } from '../models/Playlist.js'; const router = express.Router(); // Get all playlists router.get('/', async (req, res) => { try { - const playlists = await Playlist.find(); + const playlists = await PlaylistNode.find(); res.json(playlists); } catch (error) { res.status(500).json({ message: 'Error fetching playlists', error }); @@ -18,9 +18,9 @@ router.post('/batch', async (req, res) => { try { const playlists = req.body; // Delete all existing playlists first - await Playlist.deleteMany({}); + await PlaylistNode.deleteMany({}); // Insert new playlists - const result = await Playlist.insertMany(playlists); + const result = await PlaylistNode.insertMany(playlists); res.status(201).json(result); } catch (error) { res.status(500).json({ message: 'Error creating playlists', error }); diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 53753d1..a3d78ae 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -19,12 +19,14 @@ "@chakra-ui/transition": "^2.1.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@types/uuid": "^10.0.0", "events": "^3.3.0", "framer-motion": "^12.5.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.5.2", "sax": "^1.4.1", + "uuid": "^11.1.0", "xml2js": "^0.6.2", "xmlbuilder": "^15.1.1" }, diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index c5ab944..25578fe 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -8,7 +8,8 @@ import { SongDetails } from "./components/SongDetails"; import { useXmlParser } from "./hooks/useXmlParser"; import { exportToXml } from "./services/xmlService"; import { api } from "./services/api"; -import { Song } from "./types/interfaces"; +import type { Song, PlaylistNode } from "./types/interfaces"; +import { v4 as uuidv4 } from "uuid"; import "./App.css"; const StyledFileInput = ({ isMobile = false }) => { @@ -92,6 +93,27 @@ const ResizeHandle = ({ onMouseDown }: { onMouseDown: (e: React.MouseEvent) => v ); +const findPlaylistByName = (playlists: PlaylistNode[], name: string): PlaylistNode | null => { + for (const playlist of playlists) { + if (playlist.name === name) return playlist; + if (playlist.type === 'folder' && playlist.children) { + const found = findPlaylistByName(playlist.children, name); + if (found) return found; + } + } + return null; +}; + +const getAllPlaylistTracks = (node: PlaylistNode): string[] => { + if (!node.type || node.type === 'playlist') { // Handle both old and new playlist formats + return node.tracks || []; + } + if (node.type === 'folder' && node.children) { + return node.children.flatMap(child => getAllPlaylistTracks(child)); + } + return []; +}; + export default function RekordboxReader() { const { songs, playlists, setPlaylists, loading } = useXmlParser(); const [selectedSong, setSelectedSong] = useState(null); @@ -120,7 +142,7 @@ export default function RekordboxReader() { // If we've loaded the data and the playlist doesn't exist if (initialLoadDone.current && currentPlaylist !== "All Songs" && - !playlists.some(p => p.name === currentPlaylist)) { + !findPlaylistByName(playlists, currentPlaylist)) { navigate("/", { replace: true }); } }, [currentPlaylist, playlists, navigate, loading]); @@ -137,7 +159,13 @@ export default function RekordboxReader() { }; const handleCreatePlaylist = async (name: string) => { - const newPlaylist = { name, tracks: [] }; + const newPlaylist: PlaylistNode = { + id: uuidv4(), + name, + type: 'playlist', + tracks: [], + children: undefined + }; const updatedPlaylists = [...playlists, newPlaylist]; const savedPlaylists = await api.savePlaylists(updatedPlaylists); setPlaylists(savedPlaylists); @@ -145,11 +173,29 @@ export default function RekordboxReader() { }; const handleAddSongsToPlaylist = async (songIds: string[], playlistName: string) => { - const updatedPlaylists = playlists.map((playlist) => - playlist.name === playlistName - ? { ...playlist, tracks: [...new Set([...playlist.tracks, ...songIds])] } - : playlist - ); + const updatedPlaylists = playlists.map(node => { + if (node.name === playlistName && node.type === 'playlist') { + return { + ...node, + tracks: [...new Set([...(node.tracks || []), ...songIds])] + }; + } + if (node.type === 'folder' && node.children) { + return { + ...node, + children: node.children.map(child => { + if (child.name === playlistName && child.type === 'playlist') { + return { + ...child, + tracks: [...new Set([...(child.tracks || []), ...songIds])] + }; + } + return child; + }) + }; + } + return node; + }); const savedPlaylists = await api.savePlaylists(updatedPlaylists); setPlaylists(savedPlaylists); }; @@ -157,11 +203,29 @@ export default function RekordboxReader() { const handleRemoveFromPlaylist = async (songIds: string[]) => { if (currentPlaylist === "All Songs") return; - const updatedPlaylists = playlists.map((playlist) => - playlist.name === currentPlaylist - ? { ...playlist, tracks: playlist.tracks.filter(id => !songIds.includes(id)) } - : playlist - ); + const updatedPlaylists = playlists.map(node => { + if (node.name === currentPlaylist && node.type === 'playlist') { + return { + ...node, + tracks: (node.tracks || []).filter(id => !songIds.includes(id)) + }; + } + if (node.type === 'folder' && node.children) { + return { + ...node, + children: node.children.map(child => { + if (child.name === currentPlaylist && child.type === 'playlist') { + return { + ...child, + tracks: (child.tracks || []).filter(id => !songIds.includes(id)) + }; + } + return child; + }) + }; + } + return node; + }); const savedPlaylists = await api.savePlaylists(updatedPlaylists); setPlaylists(savedPlaylists); }; @@ -189,9 +253,17 @@ export default function RekordboxReader() { const displayedSongs = currentPlaylist === "All Songs" ? songs - : songs.filter((song) => - playlists.find((p) => p.name === currentPlaylist)?.tracks.includes(song.id) - ); + : songs.filter((song: Song) => { + // Try to find the playlist either in nested structure or at root level + const playlist = findPlaylistByName(playlists, currentPlaylist) || + playlists.find(p => p.name === currentPlaylist); + + if (!playlist) return false; + + // Use getAllPlaylistTracks for both nested and root playlists + const playlistTracks = getAllPlaylistTracks(playlist); + return playlistTracks.includes(song.id); + }); const handleResizeStart = (e: React.MouseEvent) => { e.preventDefault(); diff --git a/packages/frontend/src/components/PlaylistManager.tsx b/packages/frontend/src/components/PlaylistManager.tsx index 6e17206..3578c50 100644 --- a/packages/frontend/src/components/PlaylistManager.tsx +++ b/packages/frontend/src/components/PlaylistManager.tsx @@ -18,13 +18,14 @@ import { MenuList, MenuItem, IconButton, + Collapse, } from "@chakra-ui/react"; -import { ChevronDownIcon, DeleteIcon } from "@chakra-ui/icons"; +import { ChevronDownIcon, DeleteIcon, ChevronRightIcon } from "@chakra-ui/icons"; import React, { useState } from "react"; -import { Playlist } from "../types/Playlist"; +import { PlaylistNode } from "../types/interfaces"; interface PlaylistManagerProps { - playlists: Playlist[]; + playlists: PlaylistNode[]; selectedItem: string | null; onPlaylistCreate: (name: string) => void; onPlaylistSelect: (name: string | null) => void; @@ -50,6 +51,97 @@ const getButtonStyles = (isSelected: boolean) => ({ }, }); +interface PlaylistItemProps { + node: PlaylistNode; + level: number; + selectedItem: string | null; + onPlaylistSelect: (name: string | null) => void; + onPlaylistDelete: (name: string) => void; +} + +const PlaylistItem: React.FC = ({ + node, + level, + selectedItem, + onPlaylistSelect, + onPlaylistDelete, +}) => { + const [isOpen, setIsOpen] = useState(true); + const indent = level * 16; // 16px indent per level + + if (node.type === 'folder') { + return ( + + + + + + + {node.children?.map((child, index) => ( + + ))} + + + + ); + } + + return ( + + + + } + variant="ghost" + size="sm" + color="gray.400" + _hover={{ color: "white", bg: "whiteAlpha.200" }} + /> + + } + onClick={() => onPlaylistDelete(node.name)} + > + Delete Playlist + + + + + ); +}; + export const PlaylistManager: React.FC = ({ playlists, selectedItem, @@ -77,38 +169,15 @@ export const PlaylistManager: React.FC = ({ > All Songs - {playlists.map((playlist) => ( - - - - } - variant="ghost" - size="sm" - color="gray.400" - _hover={{ color: "white", bg: "whiteAlpha.200" }} - /> - - } - onClick={() => onPlaylistDelete(playlist.name)} - > - Delete Playlist - - - - + {playlists.map((node, index) => ( + ))} diff --git a/packages/frontend/src/components/SongList.tsx b/packages/frontend/src/components/SongList.tsx index 970feae..a4a9828 100644 --- a/packages/frontend/src/components/SongList.tsx +++ b/packages/frontend/src/components/SongList.tsx @@ -1,3 +1,4 @@ +import React, { ChangeEvent } from 'react'; import { Box, Flex, @@ -5,25 +6,25 @@ import { Button, IconButton, HStack, - Menu as ChakraMenu, - MenuButton as ChakraMenuButton, - MenuList as ChakraMenuList, + Menu, + MenuButton, + MenuList, MenuItem, MenuDivider, - MenuGroup, - Checkbox as ChakraCheckbox -} from "@chakra-ui/react"; + Checkbox +} from '@chakra-ui/react'; import { Input, InputGroup, InputLeftElement } from "@chakra-ui/input"; import { Search2Icon, ChevronDownIcon } from "@chakra-ui/icons"; -import { Song } from "../types/interfaces"; -import { useState, useCallback, useMemo, forwardRef } from "react"; -import { ChangeEvent, MouseEvent } from "react"; +import type { Song, PlaylistNode } from "../types/interfaces"; +import { useState, useCallback, useMemo } from "react"; +import type { MouseEvent } from 'react'; +import { v4 as uuidv4 } from 'uuid'; interface SongListProps { songs: Song[]; onAddToPlaylist: (songIds: string[], playlistName: string) => void; onRemoveFromPlaylist?: (songIds: string[]) => void; - playlists: { name: string }[]; + playlists: PlaylistNode[]; onSongSelect: (song: Song) => void; selectedSongId: string | null; currentPlaylist: string | null; @@ -41,6 +42,22 @@ export const SongList: React.FC = ({ const [selectedSongs, setSelectedSongs] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(""); + // Helper function to get all playlists (excluding folders) from the playlist tree + const getAllPlaylists = useCallback((nodes: PlaylistNode[]): PlaylistNode[] => { + let result: PlaylistNode[] = []; + for (const node of nodes) { + if (node.type === 'playlist') { + result.push(node); + } else if (node.type === 'folder' && node.children) { + result = result.concat(getAllPlaylists(node.children)); + } + } + return result; + }, []); + + // Get flattened list of all playlists + const allPlaylists = useMemo(() => getAllPlaylists(playlists), [playlists, getAllPlaylists]); + const filteredSongs = useMemo(() => { if (!searchQuery) return songs; const query = searchQuery.toLowerCase(); @@ -76,7 +93,7 @@ export const SongList: React.FC = ({ } }; - const handleSongClick = (e: MouseEvent, song: Song) => { + const handleSongClick = (e: MouseEvent, song: Song) => { e.stopPropagation(); onSongSelect(song); }; @@ -102,7 +119,7 @@ export const SongList: React.FC = ({ {/* Bulk Actions Toolbar */} - 0 && selectedSongs.size < filteredSongs.length} onChange={toggleSelectAll} @@ -118,35 +135,30 @@ export const SongList: React.FC = ({ {selectedSongs.size === 0 ? "Select All" : `Selected ${selectedSongs.size} song${selectedSongs.size === 1 ? '' : 's'}`} - + {selectedSongs.size > 0 && ( - - + } size="sm" colorScheme="blue" > Actions - - - - {playlists - .filter(p => p.name !== currentPlaylist) - .map((playlist) => ( - { - handleBulkAddToPlaylist(playlist.name); - setSelectedSongs(new Set()); - }} - > - {playlist.name} - - ))} - + + + {allPlaylists.map((playlist) => ( + { + handleBulkAddToPlaylist(playlist.name); + }} + > + Add to {playlist.name} + + ))} {currentPlaylist !== "All Songs" && onRemoveFromPlaylist && ( <> @@ -161,76 +173,36 @@ export const SongList: React.FC = ({ )} - - + + )} {/* Song List */} {filteredSongs.map((song) => ( - handleSongClick(e, song)} - > - - - ) => { - e.stopPropagation(); - toggleSelection(song.id); - }} - colorScheme="blue" - onClick={(e: MouseEvent) => e.stopPropagation()} - sx={{ - '& > span:first-of-type': { - opacity: 1, - border: '2px solid', - borderColor: 'gray.500' - } - }} - /> - - {song.title} - {song.artist} - - - - {!selectedSongs.has(song.id) && ( - - e.stopPropagation()} - > - ••• - - e.stopPropagation()}> - {playlists.map((playlist) => ( - onAddToPlaylist([song.id], playlist.name)} - > - {playlist.name} - - ))} - - - )} - - + + ) => { + e.stopPropagation(); + toggleSelection(song.id); + }} + mr={4} + /> + + {song.title} + {song.artist} + + + ⋮} variant="ghost" /> + + ) => handleSongClick(e, song)}>Select + ) => handleSongClick(e, song)}>Add to Playlist + ) => handleSongClick(e, song)}>Remove from Playlist + + + ))} diff --git a/packages/frontend/src/hooks/useXmlParser.ts b/packages/frontend/src/hooks/useXmlParser.ts index 89a745c..5be052c 100644 --- a/packages/frontend/src/hooks/useXmlParser.ts +++ b/packages/frontend/src/hooks/useXmlParser.ts @@ -1,11 +1,11 @@ import { useState, useEffect } from "react"; -import { Song, Playlist } from "../types/interfaces"; +import type { Song, PlaylistNode } from "../types/interfaces"; import { parseXmlFile } from "../services/xmlService"; import { api } from "../services/api"; export const useXmlParser = () => { const [songs, setSongs] = useState([]); - const [playlists, setPlaylists] = useState([]); + const [playlists, setPlaylists] = useState([]); const [loading, setLoading] = useState(true); // Load data from the backend on mount diff --git a/packages/frontend/src/services/api.ts b/packages/frontend/src/services/api.ts index bf51768..80ff1af 100644 --- a/packages/frontend/src/services/api.ts +++ b/packages/frontend/src/services/api.ts @@ -1,70 +1,43 @@ -import { Song, Playlist } from '../types/interfaces'; +import type { Song, PlaylistNode } from '../types/interfaces'; -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api'; +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api'; -async function handleResponse(response: Response): Promise { - if (!response.ok) { - const error = await response.json().catch(() => ({ message: 'An error occurred' })); - throw new Error(error.message || 'An error occurred'); - } - return response.json(); -} - -export const api = { +class Api { async getSongs(): Promise { - console.log('Fetching songs from API...'); - const response = await fetch(`${API_URL}/songs`); - const data = await handleResponse(response); - console.log(`Received ${data.length} songs from API`); - return data; - }, + const response = await fetch(`${API_BASE_URL}/songs`); + if (!response.ok) throw new Error('Failed to fetch songs'); + return response.json(); + } async saveSongs(songs: Song[]): Promise { - console.log(`Saving ${songs.length} songs to API...`); - const response = await fetch(`${API_URL}/songs/batch`, { + const response = await fetch(`${API_BASE_URL}/songs/batch`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(songs), }); - const data = await handleResponse(response); - console.log(`Successfully saved ${data.length} songs`); - return data; - }, + if (!response.ok) throw new Error('Failed to save songs'); + return response.json(); + } - async deleteSongs(songIds: string[]): Promise { - console.log(`Deleting ${songIds.length} songs from API...`); - const response = await fetch(`${API_URL}/songs/batch`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ ids: songIds }), - }); - await handleResponse(response); - console.log(`Successfully deleted ${songIds.length} songs`); - }, + async getPlaylists(): Promise { + const response = await fetch(`${API_BASE_URL}/playlists`); + if (!response.ok) throw new Error('Failed to fetch playlists'); + return response.json(); + } - async getPlaylists(): Promise { - console.log('Fetching playlists from API...'); - const response = await fetch(`${API_URL}/playlists`); - const data = await handleResponse(response); - console.log(`Received ${data.length} playlists from API`); - return data; - }, - - async savePlaylists(playlists: Playlist[]): Promise { - console.log(`Saving ${playlists.length} playlists to API...`); - const response = await fetch(`${API_URL}/playlists/batch`, { + async savePlaylists(playlists: PlaylistNode[]): Promise { + const response = await fetch(`${API_BASE_URL}/playlists/batch`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(playlists), }); - const data = await handleResponse(response); - console.log(`Successfully saved ${data.length} playlists`); - return data; - }, -}; \ No newline at end of file + if (!response.ok) throw new Error('Failed to save playlists'); + return response.json(); + } +} + +export const api = new Api(); \ No newline at end of file diff --git a/packages/frontend/src/services/xmlService.ts b/packages/frontend/src/services/xmlService.ts index a22fdd1..0afd191 100644 --- a/packages/frontend/src/services/xmlService.ts +++ b/packages/frontend/src/services/xmlService.ts @@ -1,8 +1,29 @@ import { parseStringPromise } from "xml2js"; import { create } from "xmlbuilder"; -import { Song, Playlist } from "../types/interfaces"; +import { Song, PlaylistNode } from "../types/interfaces"; +import { v4 as uuidv4 } from 'uuid'; -export const parseXmlFile = async (xmlText: string): Promise<{ songs: Song[], playlists: Playlist[] }> => { +const parseNode = (node: any): PlaylistNode => { + const type = node.$.Type === "0" ? "folder" : "playlist"; + const result: PlaylistNode = { + id: uuidv4(), + name: node.$.Name, + type, + children: [], + tracks: [], + }; + + if (type === "folder" && node.NODE) { + result.children = node.NODE.map((childNode: any) => parseNode(childNode)); + } else if (type === "playlist") { + // Always initialize tracks array, even if TRACK is not present + result.tracks = node.TRACK ? node.TRACK.map((track: any) => track.$.Key) : []; + } + + return result; +}; + +export const parseXmlFile = async (xmlText: string): Promise<{ songs: Song[], playlists: PlaylistNode[] }> => { try { const result = await parseStringPromise(xmlText); @@ -40,10 +61,24 @@ export const parseXmlFile = async (xmlText: string): Promise<{ songs: Song[], pl } : undefined, })); - const playlists: Playlist[] = result.DJ_PLAYLISTS.PLAYLISTS[0].NODE[0].NODE.map((playlist: any) => ({ - name: playlist.$.Name, - tracks: playlist.TRACK ? playlist.TRACK.map((t: any) => t.$.Key) : [], - })); + // Parse the playlist structure + const playlistsRoot = result.DJ_PLAYLISTS.PLAYLISTS[0]; + let playlists: PlaylistNode[] = []; + + // Handle root level playlists (directly under PLAYLISTS) + if (playlistsRoot.NODE) { + for (const node of playlistsRoot.NODE) { + // If this is a root playlist (not the ROOT folder) + if (node.$.Name !== 'ROOT') { + playlists.push(parseNode(node)); + } else { + // This is the ROOT folder, parse its children + if (node.NODE) { + playlists.push(...node.NODE.map((childNode: any) => parseNode(childNode))); + } + } + } + } return { songs: tracks, playlists }; } catch (err) { @@ -52,69 +87,85 @@ export const parseXmlFile = async (xmlText: string): Promise<{ songs: Song[], pl } }; -export const exportToXml = (songs: Song[], playlists: Playlist[]): string => { - const doc = create("DJ_PLAYLISTS", { Version: "1.0.0" }); - const collection = doc.ele("COLLECTION", { - Entries: songs.length.toString(), - }); +const buildXmlNode = (node: PlaylistNode): any => { + const xmlNode: any = { + '@Type': node.type === 'folder' ? '0' : '1', + '@Name': node.name, + }; - songs.forEach((song) => { - const trackElement = collection.ele("TRACK", { - TrackID: song.id, - Name: song.title, - Artist: song.artist, - Composer: song.composer || "", - Album: song.album || "", - Grouping: song.grouping || "", - Genre: song.genre || "", - Kind: song.kind || "", - Size: song.size || "", - TotalTime: song.totalTime || "", - DiscNumber: song.discNumber || "", - TrackNumber: song.trackNumber || "", - Year: song.year || "", - AverageBpm: song.averageBpm || "", - DateAdded: song.dateAdded || "", - BitRate: song.bitRate || "", - SampleRate: song.sampleRate || "", - Comments: song.comments || "", - PlayCount: song.playCount || "", - Rating: song.rating || "", - Location: song.location || "", - Remixer: song.remixer || "", - Tonality: song.tonality || "", - Label: song.label || "", - Mix: song.mix || "", - }); + if (node.type === 'folder') { + xmlNode['@Count'] = (node.children || []).length; + if (node.children && node.children.length > 0) { + xmlNode.NODE = node.children.map(child => buildXmlNode(child)); + } + } else { + // For playlists, always include KeyType and Entries + xmlNode['@KeyType'] = '0'; + // Set Entries to the actual number of tracks + xmlNode['@Entries'] = (node.tracks || []).length; + // Include TRACK elements if there are tracks + if (node.tracks && node.tracks.length > 0) { + xmlNode.TRACK = node.tracks.map(trackId => ({ + '@Key': trackId + })); + } + } - if (song.tempo) { - trackElement.ele("TEMPO", { - Inizio: song.tempo.inizio || "", - Bpm: song.tempo.bpm || "", - Metro: song.tempo.metro || "", - Battito: song.tempo.battito || "", - }); + return xmlNode; +}; + +export const exportToXml = (songs: Song[], playlists: PlaylistNode[]): string => { + const xml = create({ + DJ_PLAYLISTS: { + '@Version': '1.0.0', + COLLECTION: { + '@Entries': songs.length, + TRACK: songs.map(song => ({ + '@TrackID': song.id, + '@Name': song.title, + '@Artist': song.artist, + '@Composer': song.composer, + '@Album': song.album, + '@Grouping': song.grouping, + '@Genre': song.genre, + '@Kind': song.kind, + '@Size': song.size, + '@TotalTime': song.totalTime, + '@DiscNumber': song.discNumber, + '@TrackNumber': song.trackNumber, + '@Year': song.year, + '@AverageBpm': song.averageBpm, + '@DateAdded': song.dateAdded, + '@BitRate': song.bitRate, + '@SampleRate': song.sampleRate, + '@Comments': song.comments, + '@PlayCount': song.playCount, + '@Rating': song.rating, + '@Location': song.location, + '@Remixer': song.remixer, + '@Tonality': song.tonality, + '@Label': song.label, + '@Mix': song.mix, + ...(song.tempo ? { + TEMPO: { + '@Inizio': song.tempo.inizio, + '@Bpm': song.tempo.bpm, + '@Metro': song.tempo.metro, + '@Battito': song.tempo.battito + } + } : {}) + })) + }, + PLAYLISTS: { + NODE: { + '@Type': '0', + '@Name': 'ROOT', + '@Count': playlists.length, + NODE: playlists.map(playlist => buildXmlNode(playlist)) + } + } } }); - const playlistsNode = doc.ele("PLAYLISTS"); - const playlistsFolder = playlistsNode.ele("NODE", { - Name: "ROOT", - Type: "0", - Count: playlists.length.toString(), - }); - - playlists.forEach((playlist) => { - const playlistNode = playlistsFolder.ele("NODE", { - Name: playlist.name, - KeyType: "0", - Type: "1", - Entries: playlist.tracks.length.toString(), - }); - playlist.tracks.forEach((trackId) => { - playlistNode.ele("TRACK", { Key: trackId }); - }); - }); - - return doc.end({ pretty: true }); + return xml.end({ pretty: true }); }; \ No newline at end of file diff --git a/packages/frontend/src/types/Song.ts b/packages/frontend/src/types/Song.ts new file mode 100644 index 0000000..2c49c51 --- /dev/null +++ b/packages/frontend/src/types/Song.ts @@ -0,0 +1,12 @@ +export interface Song { + _id: string; + title: string; + artist: string; + genre: string; + bpm: number; + key: string; + rating: number; + comments: string; + createdAt: string; + updatedAt: string; +} \ No newline at end of file diff --git a/packages/frontend/src/types/interfaces.ts b/packages/frontend/src/types/interfaces.ts index ed5226e..0bb789d 100644 --- a/packages/frontend/src/types/interfaces.ts +++ b/packages/frontend/src/types/interfaces.ts @@ -32,6 +32,15 @@ export interface Song { }; } +export interface PlaylistNode { + id: string; + name: string; + type: 'folder' | 'playlist'; + children?: PlaylistNode[]; + tracks?: string[]; +} + +// Keep the old Playlist interface for backward compatibility during transition export interface Playlist { name: string; tracks: string[];