exec(" CREATE TABLE IF NOT EXISTS phonebook_contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, extension_id INTEGER DEFAULT NULL, display_name VARCHAR(128) NOT NULL, internal_number VARCHAR(32) NOT NULL DEFAULT '', mobile_number VARCHAR(32) NOT NULL DEFAULT '', other_number VARCHAR(32) NOT NULL DEFAULT '', email VARCHAR(128) NOT NULL DEFAULT '', photo_url TEXT NOT NULL DEFAULT '', group_name VARCHAR(64) NOT NULL DEFAULT 'Général', is_internal INTEGER NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_phonebook_ext ON phonebook_contacts(extension_id); CREATE INDEX IF NOT EXISTS idx_phonebook_group ON phonebook_contacts(group_name); "); // 1. Récupère la cartographie des groupes d'extensions $egRows = $db->query("SELECT id, name, members FROM extension_groups")->fetchAll(); $extToGroup = []; foreach ($egRows as $eg) { $members = json_decode((string)$eg['members'], true) ?: []; foreach ($members as $extNum) { if (!isset($extToGroup[(string)$extNum])) { $extToGroup[(string)$extNum] = trim((string)$eg['name']); } } } // 2. Récupère tous les postes $exts = $db->query("SELECT id, extension, name, voicemail_email FROM extensions")->fetchAll(); $activeExtIds = []; $findStmt = $db->prepare("SELECT id, display_name, internal_number, email, group_name FROM phonebook_contacts WHERE extension_id = :ext_id OR (extension_id IS NULL AND is_internal = 1 AND internal_number = :num)"); $insertStmt = $db->prepare(" INSERT INTO phonebook_contacts (extension_id, display_name, internal_number, email, group_name, is_internal) VALUES (:ext_id, :name, :num, :email, :grp, 1) "); $updateStmt = $db->prepare(" UPDATE phonebook_contacts SET extension_id = :ext_id, display_name = :name, internal_number = :num, email = :email, group_name = :grp, updated_at = CURRENT_TIMESTAMP WHERE id = :id "); foreach ($exts as $ext) { $extId = (int)$ext['id']; $extNum = trim((string)$ext['extension']); $extName = trim((string)$ext['name']); $email = trim((string)($ext['voicemail_email'] ?? '')); $group = $extToGroup[$extNum] ?? 'Général'; $activeExtIds[] = $extId; $findStmt->execute([':ext_id' => $extId, ':num' => $extNum]); $existing = $findStmt->fetch(PDO::FETCH_ASSOC); if ($existing) { $updateStmt->execute([ ':id' => (int)$existing['id'], ':ext_id' => $extId, ':name' => $extName, ':num' => $extNum, ':email' => $email, ':grp' => $group, ]); } else { $insertStmt->execute([ ':ext_id' => $extId, ':name' => $extName, ':num' => $extNum, ':email' => $email, ':grp' => $group, ]); } } // 3. Supprime les contacts internes orphelins (si extension supprimée) if (!empty($activeExtIds)) { $inClause = implode(',', $activeExtIds); $db->exec("DELETE FROM phonebook_contacts WHERE is_internal = 1 AND extension_id IS NOT NULL AND extension_id NOT IN ({$inClause})"); } else { $db->exec("DELETE FROM phonebook_contacts WHERE is_internal = 1"); } } /** * Récupère la liste des contacts filtrée ou non par groupe. */ public static function getContacts(PDO $db, ?string $group = null): array { self::syncExtensions($db); $params = []; $sql = "SELECT * FROM phonebook_contacts"; if ($group !== null && trim($group) !== '' && strtolower(trim($group)) !== 'all' && strtolower(trim($group)) !== 'tous') { $cleanGroup = trim($group); $sql .= " WHERE LOWER(group_name) = LOWER(:grp) OR LOWER(group_name) LIKE LOWER(:grp_like) OR LOWER(group_name) IN ('tous', 'all')"; $params[':grp'] = $cleanGroup; $params[':grp_like'] = "%{$cleanGroup}%"; } $sql .= " ORDER BY display_name COLLATE NOCASE ASC"; $stmt = $db->prepare($sql); $stmt->execute($params); $contacts = $stmt->fetchAll(PDO::FETCH_ASSOC); // Vérification de la présence réelle du fichier sur le disque foreach ($contacts as &$c) { if (!empty($c['photo_url'])) { $filename = basename((string)$c['photo_url']); if (!file_exists(self::AVATARS_DIR . '/' . $filename)) { $c['photo_url'] = ''; } } } unset($c); return $contacts; } /** * Récupère la liste de tous les groupes d'annuaire disponibles. */ public static function getGroups(PDO $db): array { $groups = []; $egRows = $db->query("SELECT name FROM extension_groups ORDER BY name ASC")->fetchAll(PDO::FETCH_COLUMN); foreach ($egRows as $g) { $g = trim((string)$g); if ($g !== '') $groups[$g] = true; } $pbRows = $db->query("SELECT DISTINCT group_name FROM phonebook_contacts WHERE group_name != ''")->fetchAll(PDO::FETCH_COLUMN); foreach ($pbRows as $g) { $g = trim((string)$g); if ($g !== '') $groups[$g] = true; } // Ajoute Issy-Les-Moulineaux si pas encore présent if (!isset($groups['Issy-Les-Moulineaux'])) { $groups['Issy-Les-Moulineaux'] = true; } $result = array_keys($groups); sort($result, SORT_NATURAL | SORT_FLAG_CASE); return $result; } /** * Enregistre un contact (création ou mise à jour). */ public static function saveContact(PDO $db, array $data): int { $id = isset($data['id']) && $data['id'] !== '' ? (int)$data['id'] : null; $name = trim((string)($data['display_name'] ?? '')); $internal = trim((string)($data['internal_number'] ?? '')); $mobile = trim((string)($data['mobile_number'] ?? '')); $other = trim((string)($data['other_number'] ?? '')); $email = trim((string)($data['email'] ?? '')); $photo = trim((string)($data['photo_url'] ?? '')); $group = trim((string)($data['group_name'] ?? 'Général')) ?: 'Général'; $isInternal = !empty($data['is_internal']) ? 1 : 0; $extensionId = !empty($data['extension_id']) ? (int)$data['extension_id'] : null; if ($name === '') { throw new InvalidArgumentException("Le nom du contact est obligatoire."); } if ($internal === '' && $mobile === '' && $other === '') { throw new InvalidArgumentException("Veuillez renseigner au moins un numéro de téléphone (Poste, Mobile ou Autre)."); } if ($id) { $stmt = $db->prepare(" UPDATE phonebook_contacts SET display_name = :name, internal_number = :internal, mobile_number = :mobile, other_number = :other, email = :email, photo_url = :photo, group_name = :grp, is_internal = :is_int, extension_id = :ext_id, updated_at = CURRENT_TIMESTAMP WHERE id = :id "); $stmt->execute([ ':id' => $id, ':name' => $name, ':internal' => $internal, ':mobile' => $mobile, ':other' => $other, ':email' => $email, ':photo' => $photo, ':grp' => $group, ':is_int' => $isInternal, ':ext_id' => $extensionId, ]); return $id; } else { $stmt = $db->prepare(" INSERT INTO phonebook_contacts (display_name, internal_number, mobile_number, other_number, email, photo_url, group_name, is_internal, extension_id) VALUES (:name, :internal, :mobile, :other, :email, :photo, :grp, :is_int, :ext_id) "); $stmt->execute([ ':name' => $name, ':internal' => $internal, ':mobile' => $mobile, ':other' => $other, ':email' => $email, ':photo' => $photo, ':grp' => $group, ':is_int' => $isInternal, ':ext_id' => $extensionId, ]); return (int)$db->lastInsertId(); } } /** * Supprime un contact d'annuaire. */ public static function deleteContact(PDO $db, int $id): bool { $stmt = $db->prepare("DELETE FROM phonebook_contacts WHERE id = :id"); return $stmt->execute([':id' => $id]); } /** * Détermine l'URL de base pour l'accès aux photos et avatars depuis les postes IP. */ public static function getBaseUrl(?PDO $db = null): string { if (!empty($_SERVER['HTTP_HOST'])) { $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; return "{$scheme}://" . $_SERVER['HTTP_HOST']; } if ($db) { try { $st = $db->query("SELECT domain, external_ip FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); if (!empty($st['external_ip'])) { return "http://" . $st['external_ip'] . ":8080"; } if (!empty($st['domain']) && $st['domain'] !== 'pbx.local') { return "http://" . $st['domain'] . ":8080"; } } catch (\Throwable $e) {} } $serverIp = !empty($_SERVER['SERVER_ADDR']) ? $_SERVER['SERVER_ADDR'] : '127.0.0.1'; return "http://{$serverIp}:8080"; } /** * Génère le flux XML officiel Yealink IP Phone Remote Address Book. */ public static function renderYealinkXml(PDO $db, ?string $group = null): string { $contacts = self::getContacts($db, $group); $baseUrl = self::getBaseUrl($db); $groupTitle = ($group !== null && trim($group) !== '' && strtolower(trim($group)) !== 'all') ? trim($group) : 'NexusPBX'; $xml = new \SimpleXMLElement(''); $xml->addChild('Title', "Annuaire " . htmlspecialchars($groupTitle, ENT_XML1, 'UTF-8')); foreach ($contacts as $c) { $name = trim((string)$c['display_name']); $phone1 = trim((string)$c['internal_number']); $phone2 = trim((string)$c['mobile_number']); $phone3 = trim((string)$c['other_number']); $email = trim((string)($c['email'] ?? '')); $photo = trim((string)($c['photo_url'] ?? '')); if ($phone1 === '' && $phone2 !== '') { $phone1 = $phone2; $phone2 = ''; } if ($phone1 === '' && $phone2 === '' && $phone3 === '') { continue; } $entry = $xml->addChild('DirectoryEntry'); $entry->addChild('Name', htmlspecialchars($name, ENT_XML1, 'UTF-8')); $entry->addChild('Telephone', htmlspecialchars($phone1, ENT_XML1, 'UTF-8')); if ($phone2 !== '') { $entry->addChild('Mobile', htmlspecialchars($phone2, ENT_XML1, 'UTF-8')); } if ($phone3 !== '') { $entry->addChild('OtherTelephone', htmlspecialchars($phone3, ENT_XML1, 'UTF-8')); } if ($email !== '') { $entry->addChild('Email', htmlspecialchars($email, ENT_XML1, 'UTF-8')); } if ($photo !== '') { $photoFile = basename($photo); if (file_exists(self::AVATARS_DIR . '/' . $photoFile)) { $fullPhotoUrl = (strpos($photo, '://') === false) ? rtrim($baseUrl, '/') . '/' . ltrim($photo, '/') : $photo; $entry->addChild('Photo', htmlspecialchars($fullPhotoUrl, ENT_XML1, 'UTF-8')); $entry->addChild('Picture', htmlspecialchars($fullPhotoUrl, ENT_XML1, 'UTF-8')); } } } $dom = dom_import_simplexml($xml)->ownerDocument; $dom->formatOutput = true; return $dom->saveXML(); } }