scoresPath = $path; } public function getAllScores() { if (!is_dir($this->scoresPath)) { return ['error' => 'Scores directory not found: ' . $this->scoresPath]; } $scores = []; $directories = scandir($this->scoresPath); foreach ($directories as $dir) { if ($dir === '.' || $dir === '..') continue; // Ignorer les fichiers cachés et non-numériques if (strpos($dir, '.') === 0) continue; if (!is_numeric($dir)) continue; $scorePath = $this->scoresPath . $dir; if (!is_dir($scorePath)) continue; $score = $this->getScoreInfo($dir); if ($score) { $scores[] = $score; } } // Trier par ID usort($scores, function($a, $b) { return intval($a['id']) - intval($b['id']); }); return $scores; } public function getScoreInfo($id) { $scoreDir = $this->scoresPath . $id; $iniFile = $scoreDir . '/score.ini'; if (!file_exists($iniFile)) { return null; } $ini = @parse_ini_file($iniFile, true); // If parse fails, try to extract info manually if ($ini === false) { $content = file_get_contents($iniFile); $name = ''; $compositor = ''; if (preg_match('/name\s*=\s*(.+)/i', $content, $matches)) { $name = trim($matches[1]); } if (preg_match('/compositor\s*=\s*(.+)/i', $content, $matches)) { $compositor = trim($matches[1]); } if ($name || $compositor) { return [ 'id' => $id, 'name' => $name, 'compositor' => $compositor, 'ressource' => null ]; } return null; } $info = $ini['info'] ?? []; return [ 'id' => $id, 'name' => $info['name'] ?? '', 'compositor' => $info['compositor'] ?? '', 'ressource' => $info['ressource'] ?? null ]; } public function getScoreDetail($id) { $scoreDir = $this->scoresPath . $id; if (!is_dir($scoreDir)) { return null; } $basicInfo = $this->getScoreInfo($id); if (!$basicInfo) { return null; } $instruments = []; // New structure: NUM/PIECE/INSTRUMENT/VERSION/ // First get all piece directories $pieceDirs = scandir($scoreDir); foreach ($pieceDirs as $pieceDir) { if ($pieceDir === '.' || $pieceDir === '..' || $pieceDir === 'score.ini') continue; if (strpos($pieceDir, '.') === 0) continue; if (!is_dir($scoreDir . '/' . $pieceDir)) continue; // Then get instrument directories $instrumentDirs = scandir($scoreDir . '/' . $pieceDir); foreach ($instrumentDirs as $instrumentId) { if ($instrumentId === '.' || $instrumentId === '..') continue; if (strpos($instrumentId, '.') === 0) continue; if (!is_dir($scoreDir . '/' . $pieceDir . '/' . $instrumentId)) continue; $instrument = $this->getInstrumentInfo($id, $instrumentId, $pieceDir); if ($instrument) { $instruments[] = $instrument; } } } // Trier instruments par nom usort($instruments, function($a, $b) { return strcmp($a['title'], $b['title']); }); return array_merge($basicInfo, ['instruments' => $instruments]); } private function getInstrumentInfo($scoreId, $instrumentId, $pieceId = null) { // New structure: NUM/PIECE/INSTRUMENT/VERSION/ if ($pieceId !== null) { $instrumentPath = $this->scoresPath . $scoreId . '/' . $pieceId . '/' . $instrumentId; } else { $instrumentPath = $this->scoresPath . $scoreId . '/' . $instrumentId; } if (!is_dir($instrumentPath)) { return null; } $title = $this->getInstrumentName($instrumentId); $parts = []; $entries = scandir($instrumentPath); foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') continue; if (strpos($entry, '.') === 0) continue; $partPath = $instrumentPath . '/' . $entry; if (!is_dir($partPath)) continue; $part = $this->getPartInfo($scoreId, $instrumentId, $entry, $pieceId); if ($part) { $parts[] = $part; } } // Trier les parties par numéro usort($parts, function($a, $b) { return intval($a['id']) - intval($b['id']); }); if (empty($parts)) { return null; } return [ 'id' => $instrumentId, 'title' => $title, 'piece' => $pieceId, 'parts' => $parts ]; } private function getPartInfo($scoreId, $instrumentId, $partId, $pieceId = null) { // New structure: NUM/PIECE/INSTRUMENT/VERSION/ if ($pieceId !== null) { $partPath = $this->scoresPath . $scoreId . '/' . $pieceId . '/' . $instrumentId . '/' . $partId; } else { $partPath = $this->scoresPath . $scoreId . '/' . $instrumentId . '/' . $partId; } if (!is_dir($partPath)) { return null; } $files = []; $entries = scandir($partPath); foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') continue; if (strpos($entry, '.') === 0) continue; $filePath = $partPath . '/' . $entry; if (!is_file($filePath)) continue; // Vérifier que c'est un PDF if (strtolower(pathinfo($entry, PATHINFO_EXTENSION)) !== 'pdf') continue; $relativePath = $pieceId !== null ? "$scoreId/$pieceId/$instrumentId/$partId/$entry" : "$scoreId/$instrumentId/$partId/$entry"; $parsed = $this->parseFilename(pathinfo($entry, PATHINFO_FILENAME)); $files[] = [ 'name' => pathinfo($entry, PATHINFO_FILENAME), 'filename' => $entry, 'path' => $relativePath, 'part' => $parsed['part'], 'key' => $parsed['key'], 'clef' => $parsed['clef'], 'variant' => $parsed['variant'] ]; } if (empty($files)) { return null; } // Trier les fichiers par nom usort($files, function($a, $b) { return strcmp($a['name'], $b['name']); }); return [ 'id' => $partId, 'files' => $files ]; } private function getInstrumentName($code) { $names = [ 'cla' => 'Clarinette', 'flu' => 'Flûte', 'trb' => 'Trombone', 'pic' => 'Piccolo', 'per' => 'Percussions', 'htb' => 'Hautbois', 'trp' => 'Trompette', 'dir' => 'Direction', 'sax' => 'Sax Alto', 'sat' => 'Sax Ténor', 'sab' => 'Sax Baryton', 'cor' => 'Cor', 'eup' => 'Euphonium', 'bas' => 'Basson', 'cba' => 'Contrebasse', 'crn' => 'Cornet', 'coa' => 'Cor Anglais', 'clb' => 'Clarinette Basse', 'har' => 'Harpe', 'pia' => 'Piano', 'tub' => 'Tuba', 'sup' => 'Parties supplémentaires', 'par' => 'Parties' ]; return $names[$code] ?? $code; } private function parseFilename(string $filename): array { $result = [ 'part' => null, 'key' => null, 'clef' => null, 'variant' => null ]; // Normalize: replace - by _ and lowercase $name = strtolower(str_replace('-', '_', $filename)); // Extract clef (clesol, clefa) if (preg_match('/(clesol|clefa)$/', $name, $m)) { $result['clef'] = $m[1]; $name = preg_replace('/_(clesol|clefa)$/', '', $name); } // Extract variant (solo, etc.) $variants = ['solo', 'default']; foreach ($variants as $variant) { if (strpos($name, $variant) !== false) { $result['variant'] = $variant; $name = str_replace('_' . $variant, '', $name); break; } } // Extract key (tonalités: sib, mib, fa, do, etc.) $keys = ['sib', 'mib', 'reb', 'dob', 'solb', 'lab', 'mibemol', 'sibemol']; foreach ($keys as $key) { if (preg_match('/_' . $key . '/', $name)) { $result['key'] = $key; $name = preg_replace('/_' . $key . '/', '', $name); break; } } // If no compound keys found, try single keys if ($result['key'] === null) { $singleKeys = ['fa', 'do', 'ut', 'sol', 're', 'mi', 'si']; foreach ($singleKeys as $key) { if (preg_match('/_' . $key . '(?:_|$)/', $name)) { $result['key'] = $key; $name = preg_replace('/_' . $key . '/', '', $name); break; } } } // Extract part number (1, 2, 1_et_2, etc.) - should be last // Handle formats: 1, 2, 1_et_2 if (preg_match('/_(\d+(?:_\w+)*)$/', $name, $m)) { $result['part'] = $m[1]; $name = preg_replace('/_\d+(?:_\w+)*$/', '', $name); } return $result; } // API Methods public function listScores(): array { return $this->getAllScores(); } public function getScore(string $id): ?array { return $this->getScoreDetail($id); } public function getPieces(string $scoreId): array { $scoreDir = $this->scoresPath . $scoreId; $iniFile = $scoreDir . '/score.ini'; if (!file_exists($iniFile)) { return []; } $ini = @parse_ini_file($iniFile, true); if ($ini === false) { return []; } $pieces = []; // Check if [pieces] section exists if (isset($ini['pieces'])) { $piecesSection = $ini['pieces']; $count = intval($piecesSection['count'] ?? 1); for ($i = 1; $i <= $count; $i++) { $pieces[] = [ 'id' => $i, 'name' => $piecesSection[$i] ?? "Pièce $i" ]; } } return $pieces; } public function getInstruments(string $scoreId, ?string $pieceId = null): array { $scoreDir = $this->scoresPath . $scoreId; if (!is_dir($scoreDir)) { return []; } $instruments = []; // New structure: NUM/PIECE/INSTRUMENT/VERSION/ $pieceDirs = scandir($scoreDir); foreach ($pieceDirs as $pieceDir) { if ($pieceDir === '.' || $pieceDir === '..' || $pieceDir === 'score.ini') continue; if (strpos($pieceDir, '.') === 0) continue; if (!is_dir($scoreDir . '/' . $pieceDir)) continue; // Filter by piece if specified if ($pieceId !== null && $pieceDir !== $pieceId) continue; $instrumentDirs = scandir($scoreDir . '/' . $pieceDir); foreach ($instrumentDirs as $instrumentId) { if ($instrumentId === '.' || $instrumentId === '..') continue; if (strpos($instrumentId, '.') === 0) continue; if (!is_dir($scoreDir . '/' . $pieceDir . '/' . $instrumentId)) continue; $instrument = $this->getInstrumentInfo($scoreId, $instrumentId, $pieceDir); if ($instrument) { $instruments[] = $instrument; } } } // Sort by title usort($instruments, function($a, $b) { return strcmp($a['title'], $b['title']); }); return $instruments; } public function createScore(string $id, string $name, string $compositor, array $pieces = []): array { $scoreDir = $this->scoresPath . $id; if (is_dir($scoreDir)) { return ['success' => false, 'error' => 'Score already exists']; } if (!mkdir($scoreDir, 0755, true)) { return ['success' => false, 'error' => 'Failed to create directory']; } // Security: Sanitize inputs to prevent INI injection $name = $this->sanitizeIniValue($name); $compositor = $this->sanitizeIniValue($compositor); $pieceCount = count($pieces) > 0 ? count($pieces) : 1; $iniContent = "[info]\nname = $name\ncompositor = $compositor\n\n[pieces]\ncount = $pieceCount\n"; // Add piece names if provided foreach ($pieces as $piece) { $num = $piece['number']; $pieceName = $this->sanitizeIniValue($piece['name']); $iniContent .= "{$num} = $pieceName\n"; } if (file_put_contents($scoreDir . '/score.ini', $iniContent) === false) { return ['success' => false, 'error' => 'Failed to create score.ini']; } return [ 'success' => true, 'score' => [ 'id' => $id, 'name' => $name, 'compositor' => $compositor ] ]; } private function sanitizeIniValue(string $value): string { // Remove newlines and carriage returns to prevent INI injection return str_replace(["\n", "\r", "\x00"], '', $value); } public function updateScore(string $scoreId, ?string $name, ?string $compositor): array { $scoreDir = $this->scoresPath . $scoreId; $iniFile = $scoreDir . '/score.ini'; if (!file_exists($iniFile)) { return ['success' => false, 'error' => 'Score not found']; } $ini = @parse_ini_file($iniFile, true); if ($ini === false) { return ['success' => false, 'error' => 'Failed to parse score.ini']; } // Update values if ($name !== null) { $ini['info']['name'] = $name; } if ($compositor !== null) { $ini['info']['compositor'] = $compositor; } // Rebuild ini content $content = "[info]\n"; $content .= "name = " . ($ini['info']['name'] ?? '') . "\n"; $content .= "compositor = " . ($ini['info']['compositor'] ?? '') . "\n\n"; $content .= "[pieces]\n"; if (isset($ini['pieces'])) { foreach ($ini['pieces'] as $key => $value) { $content .= "$key = $value\n"; } } if (file_put_contents($iniFile, $content) === false) { return ['success' => false, 'error' => 'Failed to write score.ini']; } return ['success' => true]; } public function updatePieces(string $scoreId, array $pieces): array { $scoreDir = $this->scoresPath . $scoreId; $iniFile = $scoreDir . '/score.ini'; if (!file_exists($iniFile)) { return ['success' => false, 'error' => 'Score not found']; } $ini = @parse_ini_file($iniFile, true); if ($ini === false) { return ['success' => false, 'error' => 'Failed to parse score.ini']; } // Get existing info $name = $ini['info']['name'] ?? ''; $compositor = $ini['info']['compositor'] ?? ''; $ressource = $ini['info']['ressource'] ?? ''; // Rebuild ini content with new pieces $content = "[info]\n"; $content .= "name = $name\n"; $content .= "compositor = $compositor\n"; if ($ressource) { $content .= "ressource = $ressource\n"; } $content .= "\n[pieces]\n"; $content .= "count = " . count($pieces) . "\n"; foreach ($pieces as $piece) { $num = $piece['number'] ?? $piece['id']; $pieceName = $piece['name'] ?? ''; $content .= "$num = $pieceName\n"; } if (file_put_contents($iniFile, $content) === false) { return ['success' => false, 'error' => 'Failed to write score.ini']; } return ['success' => true]; } public function deleteScore(string $scoreId): array { $scoreDir = $this->scoresPath . $scoreId; if (!is_dir($scoreDir)) { return ['success' => false, 'error' => 'Score not found']; } // Recursive delete $this->deleteDirectory($scoreDir); return ['success' => true]; } private function deleteDirectory(string $dir): void { if (!is_dir($dir)) return; $items = scandir($dir); foreach ($items as $item) { if ($item === '.' || $item === '..') continue; $path = $dir . '/' . $item; if (is_dir($path)) { $this->deleteDirectory($path); } else { unlink($path); } } rmdir($dir); } public function uploadPdf(string $scoreId, array $file, string $piece, string $instrument, string $version, string $key = '', string $clef = '', string $variant = '', string $part = '1', bool $watermark = false, string $watermarkPosition = 'left'): array { $scoreDir = $this->scoresPath . $scoreId; if (!is_dir($scoreDir)) { return ['success' => false, 'error' => 'Score not found']; } // Security: Validate file upload if (!isset($file['tmp_name']) || !isset($file['name'])) { return ['success' => false, 'error' => 'No file uploaded']; } // Security: Check file upload errors if ($file['error'] !== UPLOAD_ERR_OK) { return ['success' => false, 'error' => 'Upload error: ' . $file['error']]; } // Security: Validate MIME type $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); if ($mimeType !== 'application/pdf') { return ['success' => false, 'error' => 'Invalid file type. Only PDF files are allowed']; } // Security: Validate extension $originalExtension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); if ($originalExtension !== 'pdf') { return ['success' => false, 'error' => 'Invalid file extension. Only .pdf files are allowed']; } // Security: Check magic bytes (PDF starts with %PDF) $handle = fopen($file['tmp_name'], 'rb'); if ($handle) { $header = fread($handle, 4); fclose($handle); if ($header !== '%PDF') { return ['success' => false, 'error' => 'Invalid PDF file header']; } } // Security: Check file size (max 20MB) $maxSize = 20 * 1024 * 1024; // 20MB if ($file['size'] > $maxSize) { return ['success' => false, 'error' => 'File too large. Maximum size is 20MB']; } // Create directory structure: scoreId/piece/instrument/version $targetDir = $scoreDir . '/' . $piece . '/' . $instrument . '/' . $version; if (!is_dir($targetDir)) { if (!mkdir($targetDir, 0755, true)) { return ['success' => false, 'error' => 'Failed to create directory']; } } // Map instrument code to name $instrumentNames = [ 'dir' => 'direction', 'pic' => 'piccolo', 'flu' => 'flute', 'cla' => 'clarinette', 'clb' => 'clarinette_basse', 'sax' => 'saxophone_alto', 'sat' => 'saxophone_tenor', 'sab' => 'saxophone_baryton', 'coa' => 'cor_anglais', 'htb' => 'hautbois', 'bas' => 'basson', 'cor' => 'cor', 'trp' => 'trompette', 'crn' => 'cornet', 'trb' => 'trombone', 'eup' => 'euphonium', 'tub' => 'tuba', 'cba' => 'contrebasse', 'per' => 'percussion', 'pia' => 'piano', 'har' => 'harpe' ]; $instName = $instrumentNames[$instrument] ?? $instrument; // Build filename: instrument_variant_key_clef_part.pdf $filenameParts = [$instName]; if (!empty($variant)) { $filenameParts[] = $variant; } if (!empty($key)) { $filenameParts[] = $key; } if (!empty($clef)) { $filenameParts[] = $clef; } $filenameParts[] = $part; $filename = implode('_', $filenameParts) . '.pdf'; $targetPath = $targetDir . '/' . $filename; // Use copy for CLI testing, move_uploaded_file for real uploads if (is_uploaded_file($file['tmp_name'])) { $result = move_uploaded_file($file['tmp_name'], $targetPath); } else { $result = copy($file['tmp_name'], $targetPath); } if ($result && $watermark) { $this->addWatermark($targetPath, $scoreId, $watermarkPosition); } if ($result) { return ['success' => true, 'path' => "$scoreId/$piece/$instrument/$version/$filename"]; } return ['success' => false, 'error' => 'Failed to move uploaded file']; } public function getScoreFiles(string $scoreId): array { $scoreDir = $this->scoresPath . $scoreId; if (!is_dir($scoreDir)) { return ['success' => false, 'error' => 'Score not found']; } $tree = $this->buildFileTree($scoreDir, $scoreId); return ['success' => true, 'files' => $tree]; } private function buildFileTree(string $dir, string $scoreId, string $relativePath = ''): array { $items = []; $directories = scandir($dir); foreach ($directories as $item) { if ($item === '.' || $item === '..') continue; if ($item === 'score.ini') continue; $fullPath = $dir . '/' . $item; $itemRelativePath = $relativePath ? $relativePath . '/' . $item : $item; if (is_dir($fullPath)) { $children = $this->buildFileTree($fullPath, $scoreId, $itemRelativePath); $items[] = [ 'name' => $item, 'path' => $itemRelativePath, 'type' => 'folder', 'children' => $children ]; } else { $items[] = [ 'name' => $item, 'path' => $itemRelativePath, 'type' => 'file' ]; } } return $items; } public function deleteScoreFile(string $scoreId, string $filePath): array { $scoreDir = $this->scoresPath . $scoreId; $fullPath = $scoreDir . '/' . $filePath; if (!file_exists($fullPath)) { return ['success' => false, 'error' => 'File not found']; } if (is_dir($fullPath)) { return ['success' => false, 'error' => 'Cannot delete directory']; } if (unlink($fullPath)) { $this->cleanupEmptyDirectories($scoreDir, dirname($filePath)); return ['success' => true]; } return ['success' => false, 'error' => 'Failed to delete file']; } private function cleanupEmptyDirectories(string $scoreDir, string $dirPath): void { while ($dirPath && $dirPath !== '.') { $fullPath = $scoreDir . '/' . $dirPath; if (!is_dir($fullPath)) break; $files = scandir($fullPath); $files = array_diff($files, ['.', '..']); if (empty($files)) { rmdir($fullPath); $dirPath = dirname($dirPath); } else { break; } } } private function addWatermark(string $pdfPath, string $scoreId, string $position = 'left'): bool { try { require_once __DIR__ . '/../vendor/autoload.php'; $pdf = new \setasign\Fpdi\Fpdi(); $pdf->setSourceFile($pdfPath); $templateId = $pdf->importPage(1); $size = $pdf->getTemplateSize($templateId); $pdf->AddPage($size['width'] > $size['height'] ? 'L' : 'P', [$size['width'], $size['height']]); $pdf->useTemplate($templateId); $pdf->SetFont('helvetica', 'B', 20); $pdf->SetTextColor(0, 0, 0); if ($position === 'right') { $pdf->SetXY($size['width'] - 45, 5); $pdf->Cell(40, 10, $scoreId, 0, 0, 'R'); } else { $pdf->SetXY(10, 5); $pdf->Cell(40, 10, $scoreId, 0, 0, 'L'); } return $pdf->Output('F', $pdfPath); } catch (\Exception $e) { error_log('Watermark error: ' . $e->getMessage()); return false; } } }