<?php
namespace App\Controller\Plugin;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Sert dynamiquement le config.json de chaque plugin Euro-Office.
*
* Avantage vs config.json statique : on injecte la section/workroom UUID
* dans l'URL de l'iframe du plugin (`variations[].url`) en query string,
* pour que le plugin lise son contexte sans postMessage cross-origin.
*
* REGISTRY : source unique de vérité pour la liste des plugins Labomega.
* - slug : nom du dossier sous /public/plugins/
* - featureKey : clé FeatureAccess (FeatureAccessService) → permet le
* filtrage par utilisateur/rôle dans EditorConfigBuilder.
* - guid : identifiant interne OnlyOffice (asc.{slug})
* - size : taille [largeur, hauteur] de l'iframe en mode visuel
*/
class PluginConfigController extends AbstractController
{
public const REGISTRY = [
// Plugin "Tags sémantiques" retiré (mai 2026) — les couleurs des
// marques ne s'affichaient pas dans OnlyOffice (limitation de
// l'éditeur sur les surlignages custom hors-DOCX) → UX confuse.
// Le code backend (SemanticTagPluginController, table
// semantic_annotation) reste en place : si la feature revient un
// jour avec un mode d'affichage natif, on remet juste le bloc ici.
'labomega-zotero' => [
'name' => 'Zotero Labomega',
'guid' => 'asc.{labomega-zotero}',
'description' => 'Recherche & insertion de références Zotero',
'featureKey' => 'plugin.zotero',
'size' => [400, 700],
],
'labomega-evaluation' => [
'name' => 'Rapport d\'évaluation',
'guid' => 'asc.{labomega-evaluation}',
'description' => 'Génère un rapport d\'évaluation IA structuré',
'featureKey' => 'plugin.evaluation',
'size' => [450, 700],
],
'labomega-draft' => [
'name' => 'Brouillon IA',
'guid' => 'asc.{labomega-draft}',
'description' => 'Aide à la rédaction IA en streaming',
'featureKey' => 'plugin.draft',
'size' => [400, 600],
],
'labomega-plagiarism' => [
'name' => 'Plagiat',
'guid' => 'asc.{labomega-plagiarism}',
'description' => 'Détection de similarités entre documents',
'featureKey' => 'plugin.plagiarism',
'size' => [400, 600],
],
'labomega-languagetool' => [
'name' => 'LanguageTool',
'guid' => 'asc.{labomega-languagetool}',
'description' => 'Vérification grammaticale & orthographique',
'featureKey' => 'plugin.languagetool',
'size' => [400, 600],
],
'labomega-synthesis' => [
'name' => 'Synthèse multi-doc',
'guid' => 'asc.{labomega-synthesis}',
'description' => 'Synthèse IA croisée de plusieurs ressources',
'featureKey' => 'plugin.synthesis',
'size' => [450, 700],
],
'labomega-translator' => [
'name' => 'Traducteur',
'guid' => 'asc.{labomega-translator}',
'description' => 'Traduction LibreTranslate intégrée',
'featureKey' => 'plugin.translator',
'size' => [400, 500],
],
'labomega-semantic-scholar' => [
'name' => 'Semantic Scholar',
'guid' => 'asc.{labomega-semantic-scholar}',
'description' => 'Recherche bibliographique Semantic Scholar',
'featureKey' => 'plugin.semantic-scholar',
'size' => [450, 700],
],
'labomega-arxiv' => [
'name' => 'arXiv',
'guid' => 'asc.{labomega-arxiv}',
'description' => 'Recherche de preprints arXiv (physique, math, CS, biologie)',
'featureKey' => 'plugin.arxiv',
'size' => [450, 700],
],
'labomega-openalex' => [
'name' => 'OpenAlex',
'guid' => 'asc.{labomega-openalex}',
'description' => 'Catalogue ouvert OpenAlex (~250M papers, alternative SS)',
'featureKey' => 'plugin.openalex',
'size' => [450, 700],
],
'labomega-crossref' => [
'name' => 'CrossRef',
'guid' => 'asc.{labomega-crossref}',
'description' => 'Recherche DOI/métadonnées CrossRef',
'featureKey' => 'plugin.crossref',
'size' => [450, 700],
],
'labomega-pubmed' => [
'name' => 'PubMed',
'guid' => 'asc.{labomega-pubmed}',
'description' => 'Littérature biomédicale PubMed (NCBI, ~36M articles)',
'featureKey' => 'plugin.pubmed',
'size' => [450, 700],
],
'labomega-hal' => [
'name' => 'HAL',
'guid' => 'asc.{labomega-hal}',
'description' => 'Archive ouverte HAL/CCSD — publications françaises (~1.5M, multidisciplinaire)',
'featureKey' => 'plugin.hal',
'size' => [450, 700],
],
// Plugin "labomega-autoeval" retiré (mai 2026) — remplacé par
// "labomega-reports" qui implémente toute la logique côté Symfony
// (Qwen via LlmClient, plus de microservice Python autoeval externe).
'labomega-reports' => [
'name' => 'Rapports',
'guid' => 'asc.{labomega-reports}',
'description' => 'Génération assistée d\'un rapport à partir d\'une trame stockée en base.',
'featureKey' => 'plugin.reports',
'size' => [550, 800],
],
'labomega-chat-rag' => [
'name' => 'Chat RAG workroom',
'guid' => 'asc.{labomega-chat-rag}',
'description' => 'Q&A en streaming sur les ressources et sections du workroom courant, avec citations sources',
'featureKey' => 'plugin.chat-rag',
'size' => [500, 750],
],
'labomega-agenda' => [
'name' => 'Agenda',
'guid' => 'asc.{labomega-agenda}',
'description' => 'Événements partagés du workroom — réunions, jalons, deadlines, visios Jitsi (export iCal)',
'featureKey' => 'plugin.agenda',
'size' => [560, 720],
],
'labomega-gantt' => [
'name' => 'Gantt',
'guid' => 'asc.{labomega-gantt}',
'description' => 'Diagramme de Gantt — tâches, jalons et dépendances du workroom',
'featureKey' => 'plugin.gantt',
'size' => [720, 720],
],
// Séparateur visuel entre les plugins Labomega et les plugins natifs
// OnlyOffice (Mendeley, Speech, etc.). OnlyOffice n'a pas de séparateur
// natif dans son toolbar — c'est un faux plugin avec icône "ligne
// verticale orange". Toujours en DERNIÈRE position pour qu'il soit
// entre les Labomega et les natifs (qui sont rendus après).
// featureKey null → toujours visible (pas de gating FeatureAccess).
'labomega-separator' => [
'name' => '│',
'guid' => 'asc.{labomega-zz-separator}',
'description' => 'Séparateur visuel — plugins Labomega ↔ plugins natifs OnlyOffice',
'featureKey' => null,
'size' => [400, 320],
],
];
public function __construct(private readonly string $publicUrl) {}
#[Route('/plugins/{slug}/config.json', name: 'plugin_config', methods: ['GET'])]
public function config(string $slug, Request $request): JsonResponse
{
if (!isset(self::REGISTRY[$slug])) {
throw $this->createNotFoundException();
}
$meta = self::REGISTRY[$slug];
// Forward section + workroom + access_token + apiBase à l'iframe plugin.
// - access_token : généré server-side par EditorConfigBuilder pour
// contourner SameSite=Lax dans les iframes cross-site
// - apiBase : URL Symfony publique HTTPS (sinon les plugins fall back
// sur `http://localhost:8082` → CORS / mixed content en preprod/prod)
$context = [
'section' => $request->query->get('section'),
'workroom' => $request->query->get('workroom'),
'access_token' => $request->query->get('access_token'),
'apiBase' => $request->query->get('apiBase'),
];
$qs = http_build_query(array_filter($context));
// baseUrl public (browser-accessible) pour que l'iframe charge depuis le bon host
$baseUrl = rtrim($this->publicUrl, '/').'/plugins/'.$slug.'/';
// IMPORTANT : OnlyOffice 9.x concatène `baseUrl + url` automatiquement.
// L'`url` doit donc être RELATIVE à `baseUrl`, pas absolue, sinon on
// obtient des doublons type `baseUrl/baseUrl/index.html` → 404.
// Idem pour `icons` (chemins relatifs à baseUrl).
$iframeUrl = 'index.html'.($qs ? '?'.$qs : '');
// Icons themed light/dark (format OnlyOffice 9.x `icons2`).
// - `icons` (legacy fallback) pointe vers la variante light.
// - `icons2` fournit les 5 résolutions (100%/125%/150%/175%/200%)
// pour les 2 thèmes (light=icon foncé, dark=icon clair) → reste
// lisible quel que soit le thème de l'éditeur.
$iconsLegacy = ['icons/light/icon.png', 'icons/light/icon@2x.png'];
$icons2 = [
[
'style' => 'light',
'100%' => ['normal' => 'icons/light/icon.png'],
'125%' => ['normal' => 'icons/light/icon@1.25x.png'],
'150%' => ['normal' => 'icons/light/icon@1.5x.png'],
'175%' => ['normal' => 'icons/light/icon@1.75x.png'],
'200%' => ['normal' => 'icons/light/icon@2x.png'],
],
[
'style' => 'dark',
'100%' => ['normal' => 'icons/dark/icon.png'],
'125%' => ['normal' => 'icons/dark/icon@1.25x.png'],
'150%' => ['normal' => 'icons/dark/icon@1.5x.png'],
'175%' => ['normal' => 'icons/dark/icon@1.75x.png'],
'200%' => ['normal' => 'icons/dark/icon@2x.png'],
],
];
$response = new JsonResponse([
'name' => $meta['name'],
'guid' => $meta['guid'],
'baseUrl' => $baseUrl,
'variations' => [[
'description' => $meta['description'],
'url' => $iframeUrl,
'icons' => $iconsLegacy,
'icons2' => $icons2,
'isViewer' => true,
'EditorsSupport' => ['word'],
'isVisual' => true,
'isModal' => false,
'isInsideMode' => false,
'initDataType' => 'text',
'initData' => '',
'buttons' => [],
'size' => $meta['size'],
'events' => ['onClick'],
]],
]);
$response->setEncodingOptions(JSON_UNESCAPED_UNICODE);
// CORS pour permettre au DocServer de fetcher
$response->headers->set('Access-Control-Allow-Origin', '*');
return $response;
}
}