From 71125e332cd759fea8bbda6ab89d0969879862e7 Mon Sep 17 00:00:00 2001
From: Henri-Damien LAURENT <hdlaurent@afi-sa.fr>
Date: Wed, 14 Jun 2023 08:19:27 +0000
Subject: [PATCH] Dev#163369 connecteur cvs

---
 library/Class/Album.php                       |   9 +
 library/Class/CodifTypeDoc.php                |   7 +
 library/Class/DigitalResource.php             |  21 +-
 .../Class/DigitalResource/AlbumViewHelper.php |   2 +-
 library/Class/DigitalResource/Config.php      |  23 +-
 .../Harvester/{OAI.php => XML.php}            |  41 +-
 .../Importer/{OAI.php => XML.php}             |  44 +-
 library/Class/DigitalResource/Service.php     |   2 +
 library/Class/DigitalResource/ServiceOAI.php  |   4 +-
 .../DigitalResource/WebServiceAbstract.php    | 103 ++
 .../{OAI => XML}/AbstractInitJob.php          |  35 +-
 library/Class/TypeDoc.php                     |  22 +
 .../BibNumerique/RessourceNumerique.php       |  71 +-
 .../BibNumerique/SoundCloud/Track.php         |   2 +-
 library/Class/WebService/XMLParser.php        |   2 +-
 library/ZendAfi/View/Helper/RenderAlbum.php   |   4 +-
 .../View/Helper/Telephone/RenderAlbum.php     |   4 +-
 library/digital_resources/Arkhenum/Config.php |   2 +-
 .../Assimil/tests/AssimilTest.php             |   2 +-
 library/digital_resources/Bacon/Config.php    |   2 +-
 .../digital_resources/Cvs/Service/Album.php   |  27 +
 .../digital_resources/Cvs2/AccesDirect.php    |  37 +
 library/digital_resources/Cvs2/Batch.php      |  23 +
 library/digital_resources/Cvs2/Config.php     | 241 +++++
 library/digital_resources/Cvs2/Harvester.php  |  98 ++
 library/digital_resources/Cvs2/Importer.php   |  34 +
 library/digital_resources/Cvs2/ModuleMenu.php |  23 +
 library/digital_resources/Cvs2/Service.php    | 284 +++++
 .../digital_resources/Cvs2/Service/Album.php  |  27 +
 .../Cvs2/Service/Catalogue.php                | 103 ++
 .../Cvs2/Service/DirectAccess.php             |  51 +
 .../Cvs2/Service/Parser/Abstract.php          |  60 ++
 .../Cvs2/Service/Parser/AccesDirect.php       |  53 +
 .../Cvs2/Service/Parser/Catalogue.php         | 193 ++++
 .../Cvs2/Service/Parser/Catalogues.php        |  67 ++
 .../Cvs2/Validate/DocumentTypes.php           |  68 ++
 .../Cvs2/View/Helper/Album.php                |  24 +
 .../Cvs2/View/Helper/Dashboard.php            |  86 ++
 .../Cvs2/controllers/IndexController.php      |  25 +
 .../digital_resources/Cvs2/images/icon.png    | Bin 0 -> 730 bytes
 library/digital_resources/Cvs2/js/Cvs.js      |  32 +
 .../digital_resources/Cvs2/tests/Cvs2Test.php | 981 ++++++++++++++++++
 .../Cvs2/tests/catalogue_tout_page_1.xml      | 386 +++++++
 .../Cvs2/tests/catalogue_tout_page_3.xml      | 102 ++
 .../Cvs2/tests/catalogue_tout_page_51.xml     |  24 +
 .../Cvs2/tests/cvs_accesdirect.xml            |  12 +
 .../Cvs2/tests/cvs_liste_catalogue.xml        |  22 +
 .../digital_resources/Cyberlibris/Config.php  |   4 +-
 .../Cyberlibris/Harvester.php                 |   2 +-
 .../Cyberlibris/Importer.php                  |  19 +-
 .../Cyberlibris/tests/CyberlibrisTest.php     |  98 +-
 library/digital_resources/DiMusic/Config.php  |   9 +-
 .../digital_resources/DiMusic/Harvester.php   |   2 +-
 .../digital_resources/DiMusic/Importer.php    |   2 +-
 .../DiMusic/tests/DiMusicTest.php             | 197 ++--
 .../tests/LaSourisQuiRaconteTest.php          |   5 +-
 .../Musicme/tests/MusicmeTest.php             |   4 +-
 .../Numel/View/Helper/Album.php               |   2 +-
 library/digital_resources/Numilog/Config.php  |   6 +-
 library/digital_resources/Omeka/Config.php    |   4 +-
 .../Skilleos/tests/SkilleosTest.php           |   2 +-
 library/storm                                 |   2 +-
 scripts/cvs_first_harvest.php                 |  28 +
 scripts/cvs_first_import.php                  |  22 +
 .../controllers/ModulesControllerTest.php     |  17 +-
 65 files changed, 3616 insertions(+), 294 deletions(-)
 rename library/Class/DigitalResource/Harvester/{OAI.php => XML.php} (64%)
 rename library/Class/DigitalResource/Importer/{OAI.php => XML.php} (66%)
 create mode 100644 library/Class/DigitalResource/WebServiceAbstract.php
 rename library/Class/DigitalResource/{OAI => XML}/AbstractInitJob.php (75%)
 create mode 100644 library/digital_resources/Cvs/Service/Album.php
 create mode 100644 library/digital_resources/Cvs2/AccesDirect.php
 create mode 100644 library/digital_resources/Cvs2/Batch.php
 create mode 100644 library/digital_resources/Cvs2/Config.php
 create mode 100644 library/digital_resources/Cvs2/Harvester.php
 create mode 100644 library/digital_resources/Cvs2/Importer.php
 create mode 100644 library/digital_resources/Cvs2/ModuleMenu.php
 create mode 100644 library/digital_resources/Cvs2/Service.php
 create mode 100644 library/digital_resources/Cvs2/Service/Album.php
 create mode 100644 library/digital_resources/Cvs2/Service/Catalogue.php
 create mode 100644 library/digital_resources/Cvs2/Service/DirectAccess.php
 create mode 100644 library/digital_resources/Cvs2/Service/Parser/Abstract.php
 create mode 100644 library/digital_resources/Cvs2/Service/Parser/AccesDirect.php
 create mode 100644 library/digital_resources/Cvs2/Service/Parser/Catalogue.php
 create mode 100644 library/digital_resources/Cvs2/Service/Parser/Catalogues.php
 create mode 100644 library/digital_resources/Cvs2/Validate/DocumentTypes.php
 create mode 100644 library/digital_resources/Cvs2/View/Helper/Album.php
 create mode 100644 library/digital_resources/Cvs2/View/Helper/Dashboard.php
 create mode 100644 library/digital_resources/Cvs2/controllers/IndexController.php
 create mode 100644 library/digital_resources/Cvs2/images/icon.png
 create mode 100644 library/digital_resources/Cvs2/js/Cvs.js
 create mode 100644 library/digital_resources/Cvs2/tests/Cvs2Test.php
 create mode 100644 library/digital_resources/Cvs2/tests/catalogue_tout_page_1.xml
 create mode 100644 library/digital_resources/Cvs2/tests/catalogue_tout_page_3.xml
 create mode 100644 library/digital_resources/Cvs2/tests/catalogue_tout_page_51.xml
 create mode 100644 library/digital_resources/Cvs2/tests/cvs_accesdirect.xml
 create mode 100644 library/digital_resources/Cvs2/tests/cvs_liste_catalogue.xml
 create mode 100644 scripts/cvs_first_harvest.php
 create mode 100644 scripts/cvs_first_import.php

diff --git a/library/Class/Album.php b/library/Class/Album.php
index 68ed5169c91..914e9501e7c 100644
--- a/library/Class/Album.php
+++ b/library/Class/Album.php
@@ -240,6 +240,15 @@ class Class_Album extends Storm_Model_Abstract {
   }
 
 
+  public function getMatieresLibelle() {
+    return array_map(fn($matiere) => ($matiere = Class_CodifMatiere::find($matiere))
+                     ? $matiere->getLibelle()
+                     : '' ,
+                     explode(';',$this->getMatiere()));
+
+  }
+
+
   /**
    * @return int
    */
diff --git a/library/Class/CodifTypeDoc.php b/library/Class/CodifTypeDoc.php
index 8b6fa72ab77..bfc3f789527 100644
--- a/library/Class/CodifTypeDoc.php
+++ b/library/Class/CodifTypeDoc.php
@@ -152,4 +152,11 @@ class Class_CodifTypeDoc extends Storm_Model_Abstract {
   public function isPeriodique() {
     return static::PERIODIQUE == $this->getFamilleId();
   }
+
+
+  public function getLabel() : string {
+    return ($doc_type = Class_TypeDoc::find($this->getId()))
+      ? $doc_type->getLabel()
+      : '';
+  }
 }
diff --git a/library/Class/DigitalResource.php b/library/Class/DigitalResource.php
index e6ed9b1d94d..2783ae95764 100644
--- a/library/Class/DigitalResource.php
+++ b/library/Class/DigitalResource.php
@@ -228,13 +228,11 @@ class Class_DigitalResource extends Class_Entity {
   }
 
 
-  public function renderAlbumOn($album, $view) {
-    if (!$this->isPluginDocType($type = $album->getTypeDocId()))
+  public function renderAlbumOn(Class_Album $album, Zend_View $view) : string {
+    if ( ! $config = $this->getConfigForDocType($album->getTypeDocId()))
       return '';
 
-    return ($helper = $this->viewHelperFor($type, 'Album', $view))
-      ? $helper->album($album)
-      : '';
+    return $config->renderAlbum($album, $view);
   }
 
 
@@ -265,16 +263,7 @@ class Class_DigitalResource extends Class_Entity {
 
   public function getDocTypes() {
     return $this->getPlugins()
-                ->injectInto([],
-                             function($types, $config)
-                             {
-                               if(!$label = $config->getDocTypeLabel())
-                                 return $types;
-
-                               $types[$config->getName()] = $label;
-                               return $types;
-                             }
-                );
+                ->injectInto([], fn($types, $config) => $config->addDocTypeIn($types));
   }
 
 
@@ -301,7 +290,7 @@ class Class_DigitalResource extends Class_Entity {
   }
 
 
-  public function getConfigForDocType($type) {
+  public function getConfigForDocType(string $type) : ?Class_DigitalResource_Config {
     if(!$type)
       return null;
 
diff --git a/library/Class/DigitalResource/AlbumViewHelper.php b/library/Class/DigitalResource/AlbumViewHelper.php
index 2a1a9ffd5a5..673a4d8ec2a 100644
--- a/library/Class/DigitalResource/AlbumViewHelper.php
+++ b/library/Class/DigitalResource/AlbumViewHelper.php
@@ -38,7 +38,7 @@ class Class_DigitalResource_AlbumViewHelper extends ZendAfi_View_Helper_BaseHelp
 
   protected function _getViewLink(Class_Album $album) : string{
     if (Class_Users::getIdentity() && ! $this->hasRightAccesRessourcesNumeriques(Class_Users::getIdentity()))
-      return '<p>' . $this->_getTextInvalidSubscription() . '</p>';
+      return $this->view->tag('p', $this->_getTextInvalidSubscription());
 
     return $this->view->tagAnchor($this->_getAlbumSsoUrl($album),
                                   $this->_getTextAccessButton($album),
diff --git a/library/Class/DigitalResource/Config.php b/library/Class/DigitalResource/Config.php
index 75a2b848893..f2206d60955 100644
--- a/library/Class/DigitalResource/Config.php
+++ b/library/Class/DigitalResource/Config.php
@@ -61,7 +61,7 @@ class Class_DigitalResource_Config extends Class_Entity {
   }
 
 
-  public function isDocTypeHandled($type) {
+  public function isDocTypeHandled(string $type) : bool {
     return $type === $this->getDocType();
   }
 
@@ -433,7 +433,7 @@ class Class_DigitalResource_Config extends Class_Entity {
   }
 
 
-  public function newOAIClient() {
+  public function newClient() {
     $parser_class = $this->withNameSpace('Service_Parser');
     return (new Class_WebService_OAI)
       ->setParser(new $parser_class)
@@ -474,4 +474,23 @@ class Class_DigitalResource_Config extends Class_Entity {
   public function getHarvestStartDate() {
     return $this->_harvest_start_date;
   }
+
+
+  public function addDocTypeIn(array $types) : array {
+    if(!$label = $this->getDocTypeLabel())
+      return $types;
+
+    $types[$this->getName()] = $label;
+
+    return $types;
+  }
+
+
+  public function renderAlbum(Class_Album $album, Zend_View $view) : string {
+    return ($helper = Class_DigitalResource::getInstance()->viewHelperFor($album->getTypeDocId(),
+                                                                          'Album',
+                                                                          $view))
+      ? $helper->album($album)
+      : '';
+  }
 }
diff --git a/library/Class/DigitalResource/Harvester/OAI.php b/library/Class/DigitalResource/Harvester/XML.php
similarity index 64%
rename from library/Class/DigitalResource/Harvester/OAI.php
rename to library/Class/DigitalResource/Harvester/XML.php
index 37022f93ad3..8d43112eb58 100644
--- a/library/Class/DigitalResource/Harvester/OAI.php
+++ b/library/Class/DigitalResource/Harvester/XML.php
@@ -20,7 +20,7 @@
  */
 
 
-class Class_DigitalResource_Harvester_OAI extends Class_DigitalResource_OAI_AbstractInitJob {
+class Class_DigitalResource_Harvester_XML extends Class_DigitalResource_XML_AbstractInitJob {
 
   public function __construct() {
     parent::__construct();
@@ -47,9 +47,16 @@ class Class_DigitalResource_Harvester_OAI extends Class_DigitalResource_OAI_Abst
 
 
   protected function _cleanXML() {
-    $this->_log($this->_('Suppression des fichiers XML existants dans %s', $this->_oai_xml_directory_path));
+    $this->_log($this->_('Suppression des fichiers XML existants dans %s', $this->_xml_directory_path));
 
-    Class_FileManager::deleteFiles($this->_oai_xml_directory_path);
+    if ($this->getFileSystem()->fileExists($this->_xml_done_directory_path)) {
+      $this->getFileSystem()->deleteFilesAt($this->_xml_done_directory_path);
+      $this->getFileSystem()->rmdir($this->_xml_done_directory_path);
+    }
+    if ($this->getFileSystem()->fileExists($this->_xml_directory_path)){
+      $this->getFileSystem()->deleteFilesAt($this->_xml_directory_path);
+      $this->getFileSystem()->rmdir($this->_xml_directory_path);
+    }
 
     return $this;
   }
@@ -57,14 +64,16 @@ class Class_DigitalResource_Harvester_OAI extends Class_DigitalResource_OAI_Abst
 
   protected function _work() {
     $this->_log($this->_('Début du moissonnage.'));
-    $this->_oai_ws->setLogger($this->getLogger());
+    $this->_ws->setLogger($this->getLogger());
 
-    Class_FileManager::create($this->_oai_xml_directory_name,
-                              Class_FileManager::find(USERFILES));
+    if (!$this->getFileSystem()->fileExists($this->_xml_directory_path))
+      $this->getFileSystem()->mkdir($this->_xml_directory_path);
+
+    $page = 1;
+    do {
+      $this->_writeXML($page++);
+    } while( $this->_ws->hasRecordsToHarvest() );
 
-    $page = 0;
-    while($this->_oai_ws->hasRecordsToHarvest())
-      $this->_writeXML($page ++);
 
     Class_WebService_HarvestLog::newInstance(['end_date' => $this->getCurrentDate(),
                                               'type_doc' => $this->_doc_type])
@@ -77,17 +86,17 @@ class Class_DigitalResource_Harvester_OAI extends Class_DigitalResource_OAI_Abst
 
   protected function _writeXML($page) {
     if ( ! $xml = $this->_getXML($page))
-      return;
+      return '';
 
-    $this->_oai_ws->parseResumptionToken($xml);
-    Class_FileManager::createFile($this->_oai_xml_directory_path . '/' . $page . '.xml', $xml);
+    $this->_ws->parseResumptionToken($xml);
+    $this->getFileSystem()->filePutContents($this->_xml_directory_path . '/' . $page . '.xml', $xml);
   }
 
 
   protected function _getXML($page) {
     return $page == 1
-      ? $this->_oai_ws->getXML()
-      : $this->_oai_ws->getNextXML();
+      ? $this->_ws->getXML()
+      : $this->_ws->getNextXML();
   }
 
 
@@ -97,7 +106,7 @@ class Class_DigitalResource_Harvester_OAI extends Class_DigitalResource_OAI_Abst
 
 
   public function isRunning() {
-    return ( ! $this->isDone())
-      && Class_FileManager::hasFilesIn($this->_oai_xml_directory_path);
+    return (( ! $this->isDone())
+            && (count($this->getFileSystem()->fileNamesAt($this->_xml_directory_path)) > 0));
   }
 }
diff --git a/library/Class/DigitalResource/Importer/OAI.php b/library/Class/DigitalResource/Importer/XML.php
similarity index 66%
rename from library/Class/DigitalResource/Importer/OAI.php
rename to library/Class/DigitalResource/Importer/XML.php
index 6c592a6e804..72420964fb2 100644
--- a/library/Class/DigitalResource/Importer/OAI.php
+++ b/library/Class/DigitalResource/Importer/XML.php
@@ -20,7 +20,7 @@
  */
 
 
-class Class_DigitalResource_Importer_OAI extends Class_DigitalResource_OAI_AbstractInitJob {
+class Class_DigitalResource_Importer_XML extends Class_DigitalResource_XML_AbstractInitJob {
 
   public function __construct() {
     parent::__construct();
@@ -29,11 +29,12 @@ class Class_DigitalResource_Importer_OAI extends Class_DigitalResource_OAI_Abstr
 
 
   protected function _clean() {
-    if (Class_FileManager::files($this->_oai_xml_done_directory_path))
+    if ($this->_config->getBatchRunning()
+        || $this->getFileSystem()->fileExists($this->_xml_done_directory_path))
       return $this;
 
     $this->_log($this->_('%s : suppression des albums existants',
-                                     $this->_name));
+                         $this->_name));
 
     $count_all_harvested_albums = Class_Album::countBy(['type_doc_id' => $this->_doc_type]);
 
@@ -43,8 +44,8 @@ class Class_DigitalResource_Importer_OAI extends Class_DigitalResource_OAI_Abstr
                                               'limit' => 100])) {
       $deleted_albums += $this->_deleteAlbums($albums);
       $this->_log(sprintf('%s/%s',
-                                      $deleted_albums,
-                                      $count_all_harvested_albums));
+                          $deleted_albums,
+                          $count_all_harvested_albums));
 
       $this->_cleanMemory();
     }
@@ -57,7 +58,7 @@ class Class_DigitalResource_Importer_OAI extends Class_DigitalResource_OAI_Abstr
     $deleted_albums = 0;
     foreach($albums as $album) {
       $album->delete();
-      $deleted_albums ++;
+      $deleted_albums++;
     }
 
     return $deleted_albums;
@@ -68,18 +69,20 @@ class Class_DigitalResource_Importer_OAI extends Class_DigitalResource_OAI_Abstr
     $this->_log($this->_('%s : création des albums.',
                                      $this->_name));
 
-    Class_FileManager::create(static::DONE_DIRECTORY,
-                              Class_FileManager::find($this->_oai_xml_directory_path));
+    if (!$this->getFileSystem()->fileExists($this->_xml_done_directory_path))
+        $this->getFileSystem()->mkdir($this->_xml_done_directory_path);
+
+    $files = $this->getFileSystem()->fileNamesAt($this->_xml_directory_path);
 
-    $files = Class_FileManager::files($this->_oai_xml_directory_path);
     $total = count($files);
     $count = 1;
 
     foreach ( $files as $xml_file ) {
       $this->_log($this->_('Traitement du fichier : %s. %s/%s',
-                                       $xml_file->getPath(),
-                                       $count,
-                                       $total));
+                           $xml_file,
+                           $count,
+                           $total));
+
       $this->_workWithFile($xml_file);
       $count ++;
     }
@@ -89,12 +92,13 @@ class Class_DigitalResource_Importer_OAI extends Class_DigitalResource_OAI_Abstr
 
 
   protected function _workWithFile($xml_file) {
-    $xml = $xml_file->getContent();
+    $xml = $this->getFileSystem()
+                ->fileGetContents($this->_xml_directory_path.'/'.$xml_file);
     if ($this->_service->importFrom($xml))
-      Class_FileManager::moveItemInto($xml_file,
-                                      $this->_oai_xml_done_directory_path
-                                      . '/'
-                                      . $xml_file->getName());
+      $this->getFileSystem()->rename($this->_xml_directory_path.'/'.$xml_file,
+                                     $this->_xml_done_directory_path
+                                     . '/'
+                                     . $xml_file);
 
     return $this;
   }
@@ -108,14 +112,14 @@ class Class_DigitalResource_Importer_OAI extends Class_DigitalResource_OAI_Abstr
 
   public function isRunning() {
     return ( ! $this->isDone())
-      && Class_FileManager::hasFilesIn($this->_oai_xml_directory_path);
+      && (count($this->getFileSystem()->fileNamesAt($this->_xml_directory_path)) > 1);
   }
 
 
   public function getUserfilesFolderToDelete() {
-    return (Class_FileManager::find($this->_oai_xml_done_directory_path)
+    return ($this->getFileSystem()->fileExists($this->_xml_done_directory_path)
             && $this->isDone())
-      ? $this->_oai_xml_directory_path
+      ? $this->_xml_directory_path
       : '';
   }
 }
diff --git a/library/Class/DigitalResource/Service.php b/library/Class/DigitalResource/Service.php
index 9b1c028668d..fa7cffa8902 100644
--- a/library/Class/DigitalResource/Service.php
+++ b/library/Class/DigitalResource/Service.php
@@ -66,4 +66,6 @@ class Class_DigitalResource_Service extends Class_WebService_BibNumerique_Abstra
 
     return $this;
   }
+
+  public function reset(){}
 }
diff --git a/library/Class/DigitalResource/ServiceOAI.php b/library/Class/DigitalResource/ServiceOAI.php
index f27e3433e68..5bb3e07226e 100644
--- a/library/Class/DigitalResource/ServiceOAI.php
+++ b/library/Class/DigitalResource/ServiceOAI.php
@@ -32,7 +32,7 @@ class Class_DigitalResource_ServiceOAI extends Class_WebService_BibNumerique_Abs
 
   public function __construct($config) {
     $this->_config = $config;
-    $this->_oaiws = $config->newOAIClient();
+    $this->_oaiws = $config->newClient();
   }
 
 
@@ -54,4 +54,4 @@ class Class_DigitalResource_ServiceOAI extends Class_WebService_BibNumerique_Abs
   public function getName() {
     return $this->_config->getName();
   }
-}
\ No newline at end of file
+}
diff --git a/library/Class/DigitalResource/WebServiceAbstract.php b/library/Class/DigitalResource/WebServiceAbstract.php
new file mode 100644
index 00000000000..7dfd9e62956
--- /dev/null
+++ b/library/Class/DigitalResource/WebServiceAbstract.php
@@ -0,0 +1,103 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+abstract class Class_DigitalResource_WebServiceAbstract {
+  use
+    Trait_TimeSource,
+    Trait_Translator,
+    Trait_MemoryCleaner,
+    Trait_Logger,
+    Trait_StormFileSystem;
+
+  const DONE_DIRECTORY = 'done';
+
+
+  protected
+    $_job,
+    $_name,
+    $_config,
+    $_service,
+    $_xml_directory_name,
+    $_xml_directory_path,
+    $_xml_done_directory_path;
+
+
+
+  public function __construct() {
+    $this->_config = Class_DigitalResource_Config::getInstanceFor(static::class);
+    $this->_name = $this->_config->getName();
+    $this->_service = $this->_config->getServiceInstance();
+
+    $this->_xml_directory_name = sprintf('%s_xml_responses',
+                                         strtolower($this->_name));
+
+    $this->_xml_directory_path = USERFILES . '/' . $this->_xml_directory_name ;
+
+    $this->_xml_done_directory_path =
+      $this->_xml_directory_path
+      . '/'
+      . static::DONE_DIRECTORY;
+  }
+
+
+  protected function _log($message) {
+    return $this->getLogger()->log("\n" . $message);
+  }
+
+
+  public function run() {
+    if ( ! $this->_config->isEnabled() ) {
+      $this->_log($this->_('%s n\'est pas activé. Le script est arrété.',
+                           $this->_name));
+      return $this;
+    }
+
+    return $this
+      ->_intro()
+      ->_clean()
+      ->_work()
+      ->_stop();
+  }
+
+  protected function _intro() {
+    $this->_log($this->_('%s : Début %s à %s.',
+                         $this->_name,
+                         $this->_job,
+                         $this->getCurrentDateTime()));
+    return $this;
+  }
+
+
+  protected function _stop() {
+    $this->_log($this->_('%s : Fin %s à %s.',
+                         $this->_name,
+                         $this->_job,
+                         $this->getCurrentDateTime()));
+    return $this;
+  }
+
+
+  abstract protected function _clean();
+  abstract protected function _work();
+  abstract public function isDone();
+  abstract public function isRunning();
+}
diff --git a/library/Class/DigitalResource/OAI/AbstractInitJob.php b/library/Class/DigitalResource/XML/AbstractInitJob.php
similarity index 75%
rename from library/Class/DigitalResource/OAI/AbstractInitJob.php
rename to library/Class/DigitalResource/XML/AbstractInitJob.php
index 18d098ecc76..e3b04ef3ed6 100644
--- a/library/Class/DigitalResource/OAI/AbstractInitJob.php
+++ b/library/Class/DigitalResource/XML/AbstractInitJob.php
@@ -20,12 +20,13 @@
  */
 
 
-abstract class Class_DigitalResource_OAI_AbstractInitJob {
+abstract class Class_DigitalResource_XML_AbstractInitJob {
   use
     Trait_TimeSource,
     Trait_Translator,
     Trait_MemoryCleaner,
-    Trait_Logger;
+    Trait_Logger,
+    Trait_StormFileSystem;
 
   const DONE_DIRECTORY = 'done';
 
@@ -35,8 +36,9 @@ abstract class Class_DigitalResource_OAI_AbstractInitJob {
     $_config,
     $_service,
     $_doc_type,
-    $_oai_xml_directory_name,
-    $_oai_xml_directory_path;
+    $_xml_directory_name,
+    $_xml_directory_path,
+    $_xml_done_directory_path;
 
 
   public function __construct() {
@@ -44,21 +46,17 @@ abstract class Class_DigitalResource_OAI_AbstractInitJob {
     $this->_name = $this->_config->getName();
     $this->_doc_type = $this->_config->getDocType();
     $this->_service = $this->_config->getServiceInstance();
-    $this->_oai_ws = $this->_config->newOAIClient();
+    $this->_ws = $this->_config->newClient();
 
-    $this->_oai_xml_directory_name = sprintf('%s_oai_xml_responses',
-                                             strtolower($this->_name));
+    $this->_xml_directory_name = sprintf('%s_xml_responses',
+                                         strtolower($this->_name));
 
-    $this->_oai_xml_directory_path = USERFILES . '/' . $this->_oai_xml_directory_name;
+    $this->_xml_directory_path = USERFILESPATH . '/' . $this->_xml_directory_name;
 
-    $this->_oai_xml_done_directory_path =
-      $this->_oai_xml_directory_path
+    $this->_xml_done_directory_path =
+      $this->_xml_directory_path
       . '/'
       . static::DONE_DIRECTORY;
-
-    Class_FileManager::disableCache();
-    Class_FileManager::beOpenBar();
-    Class_FileManager_FileSystem::beUnlimited();
   }
 
 
@@ -82,7 +80,7 @@ abstract class Class_DigitalResource_OAI_AbstractInitJob {
   }
 
   protected function _intro() {
-    $this->_log($this->_('%s : Début premier %s à %s.',
+    $this->_log($this->_('%s : Début %s à %s.',
                                      $this->_name,
                                      $this->_job,
                                      $this->getCurrentDateTime()));
@@ -91,10 +89,9 @@ abstract class Class_DigitalResource_OAI_AbstractInitJob {
 
 
   protected function _stop() {
-    Class_FileManager::reset();
-    $this->_oai_ws->reset();
+    $this->_ws->reset();
 
-    $this->_log($this->_('%s : Fin du premier %s à %s.',
+    $this->_log($this->_('%s : Fin %s à %s.',
                                      $this->_name,
                                      $this->_job,
                                      $this->getCurrentDateTime()));
@@ -106,4 +103,4 @@ abstract class Class_DigitalResource_OAI_AbstractInitJob {
   abstract protected function _work();
   abstract public function isDone();
   abstract public function isRunning();
-}
\ No newline at end of file
+}
diff --git a/library/Class/TypeDoc.php b/library/Class/TypeDoc.php
index 306bf6e33e9..e0d536a598b 100644
--- a/library/Class/TypeDoc.php
+++ b/library/Class/TypeDoc.php
@@ -101,6 +101,27 @@ class TypeDocLoader extends Class_CosmoVar_ModelLoader  {
   }
 
 
+  public function findOrCreate(string $id, string $label) : Class_TypeDoc {
+    if ($doc_type = Class_TypeDoc::find($id))
+      return $doc_type;
+
+    $doc_type = Class_TypeDoc::newInstanceWithId($id, ['label' => $label]);
+    $doc_type->save();
+    return $doc_type;
+  }
+
+
+  public function createOrUpdate(string $code, string $label) : Class_TypeDoc {
+    if ($typedoc = Class_TypeDoc::find($code))
+      return $typedoc->setLabel($label);
+
+    $typedoc = Class_TypeDoc::newInstanceWithId($code,
+                                                ['label' => $label]);
+    $typedoc->save();
+    return $typedoc;
+  }
+
+
   public function saveCodifTypeDoc($model)  {
     $model->getCodifTypeDoc()->save();
     return $this;
@@ -164,6 +185,7 @@ class TypeDocLoader extends Class_CosmoVar_ModelLoader  {
 
 
   public function isDigital($doc_type) {
+    $types = Class_TypeDoc::getDigitalDocTypes();
     return array_key_exists($doc_type, Class_TypeDoc::getDigitalDocTypes())
       || ((int) $doc_type >= Class_TypeDoc::DIGITAL_RESOURCE_THRESHOLD);
   }
diff --git a/library/Class/WebService/BibNumerique/RessourceNumerique.php b/library/Class/WebService/BibNumerique/RessourceNumerique.php
index c5648987b51..02dd596efe9 100644
--- a/library/Class/WebService/BibNumerique/RessourceNumerique.php
+++ b/library/Class/WebService/BibNumerique/RessourceNumerique.php
@@ -33,12 +33,16 @@ class Class_WebService_BibNumerique_RessourceNumerique {
     $_title,
     $_subtitle,
     $_description,
+    $_type_doc,
+    $_type_doc_id,
     $_year,
     $_external_uri,
     $_id_language,
+    $_genre,
     $_date_maj,
     $_annee,
     $_duration,
+    $_provenance,
     $_tags = [],
     $_rights = [],
     $_posters = [],
@@ -83,12 +87,45 @@ class Class_WebService_BibNumerique_RessourceNumerique {
   }
 
 
+  public function setTypeDocId($type_doc_id) {
+    $this->_type_doc_id = $type_doc_id;
+    return $this;
+  }
+
+
+  public function getTypeDocId() {
+    return $this->_type_doc_id;
+  }
+
+
+  public function setProvenance($provenance) {
+    $this->_provenance = $provenance;
+    return $this;
+  }
+
+
+  public function getProvenance() {
+    return $this->_provenance;
+  }
+
+
   public function setBaseUrl($url) {
     $this->_base_url = $url;
     return $this;
   }
 
 
+  public function setGenre( string $genre) :self {
+    $this->_genre = $genre;
+    return $this;
+  }
+
+
+  public function getGenre() :string {
+    return $this->_genre ?? '';
+  }
+
+
   public function setExternalUri($uri) {
     $this->_external_uri = $uri;
     return $this;
@@ -122,8 +159,8 @@ class Class_WebService_BibNumerique_RessourceNumerique {
   }
 
 
-  public function setAnnee($uri) {
-    $this->_annee = $uri;
+  public function setAnnee(string $data) :self{
+    $this->_annee = $data;
     return $this;
   }
 
@@ -151,7 +188,7 @@ class Class_WebService_BibNumerique_RessourceNumerique {
 
 
   public function getTitle() {
-    return trim($this->_title);
+    return trim((string)$this->_title);
   }
 
 
@@ -162,7 +199,7 @@ class Class_WebService_BibNumerique_RessourceNumerique {
 
 
   public function getSubtitle() {
-    return trim($this->_subtitle);
+    return trim((string)$this->_subtitle);
   }
 
 
@@ -186,7 +223,7 @@ class Class_WebService_BibNumerique_RessourceNumerique {
 
 
   public function getEditeur() {
-    return trim($this->_editeur);
+    return trim((string)$this->_editeur);
   }
 
 
@@ -257,7 +294,7 @@ class Class_WebService_BibNumerique_RessourceNumerique {
   }
 
 
-  public function getPhotos() {
+  public function getPhotos() :array {
     return $this->_photos;
   }
 
@@ -294,12 +331,13 @@ class Class_WebService_BibNumerique_RessourceNumerique {
   }
 
 
-  public function addTag($tag) {
+  public function addTag(string $tag) :self {
     $this->_tags[] = $tag;
+    return $this;
   }
 
 
-  public function setTags($tags) {
+  public function setTags(array $tags) :self {
     $this->_tags = $tags;
     return $this;
   }
@@ -310,19 +348,19 @@ class Class_WebService_BibNumerique_RessourceNumerique {
   }
 
 
-  public function addMatiere($matiere_libelle) {
+  public function addMatiere(string $matiere_libelle) :self {
     $this->_matieres[] = $matiere_libelle;
     return $this;
   }
 
 
-  protected function _importCollections($album) {
+  protected function _importCollections($album) :self {
     array_map([$album, 'addCollection'], $this->_collections);
     return $this;
   }
 
 
-  public function importMatieres($album) {
+  public function importMatieres($album) :self {
 
     foreach (array_filter($this->_matieres) as $label)
       if ($matiere = $this->_importMatiere($label))
@@ -400,6 +438,15 @@ class Class_WebService_BibNumerique_RessourceNumerique {
       ->setStatus(Class_Album::STATUS_VALIDATED)
       ->setDroits(implode(', ', $this->getRights()));
 
+    if ($genre = $this->getGenre())
+      $album->setGenre($genre);
+
+    if ($typedocid = $this->getTypeDocId())
+      $album->setTypeDocId($typedocid);
+
+    if ($provenance = $this->getProvenance())
+      $album->setProvenance($provenance);
+
     if ($editor = $this->getEditeur())
       $album->addEditor($editor);
 
@@ -548,7 +595,7 @@ class Class_WebService_BibNumerique_RessourceNumerique {
 
 
   public function addCollection($collection) {
-    if (! $data = trim($collection))
+    if (! $data = trim((string)$collection))
       return $this;
 
     $this->_collections []= $data;
diff --git a/library/Class/WebService/BibNumerique/SoundCloud/Track.php b/library/Class/WebService/BibNumerique/SoundCloud/Track.php
index 7317c15b455..605500098d4 100644
--- a/library/Class/WebService/BibNumerique/SoundCloud/Track.php
+++ b/library/Class/WebService/BibNumerique/SoundCloud/Track.php
@@ -44,7 +44,7 @@ class Class_WebService_BibNumerique_SoundCloud_Track extends Class_WebService_Bi
                        ]);
   }
 
-  public function setTags($tags) {
+  public function setTags(array $tags) : Class_WebService_BibNumerique_RessourceNumerique {
     $this->_tags = $tags;
     return $this;
   }
diff --git a/library/Class/WebService/XMLParser.php b/library/Class/WebService/XMLParser.php
index e95eb66e8e5..294c532a0bd 100644
--- a/library/Class/WebService/XMLParser.php
+++ b/library/Class/WebService/XMLParser.php
@@ -127,7 +127,7 @@ class Class_WebService_XMLParser {
    */
   public function endElement($parser, $tag) {
     $this->_callFuncOrClosure('end'.$this->tagWithoutNamespace($tag),
-                              trim($this->_current_data));
+                              trim((string)$this->_current_data));
     array_pop($this->_parents) ;
   }
 
diff --git a/library/ZendAfi/View/Helper/RenderAlbum.php b/library/ZendAfi/View/Helper/RenderAlbum.php
index 47e2cf37a35..d3d458f30e8 100644
--- a/library/ZendAfi/View/Helper/RenderAlbum.php
+++ b/library/ZendAfi/View/Helper/RenderAlbum.php
@@ -20,7 +20,7 @@
  */
 
 class ZendAfi_View_Helper_RenderAlbum extends ZendAfi_View_Helper_BaseHelper {
-  public function renderAlbum($album) {
+  public function renderAlbum($album) : string {
     if (!$album)
       return '';
 
@@ -38,4 +38,4 @@ class ZendAfi_View_Helper_RenderAlbum extends ZendAfi_View_Helper_BaseHelper {
   public function renderAlbumHelper($album) {
     return $album->renderOn($this->view);
   }
-}
\ No newline at end of file
+}
diff --git a/library/ZendAfi/View/Helper/Telephone/RenderAlbum.php b/library/ZendAfi/View/Helper/Telephone/RenderAlbum.php
index c82e861e15c..3a4aa2eedc9 100644
--- a/library/ZendAfi/View/Helper/Telephone/RenderAlbum.php
+++ b/library/ZendAfi/View/Helper/Telephone/RenderAlbum.php
@@ -20,9 +20,9 @@
  */
 
 class ZendAfi_View_Helper_Telephone_RenderAlbum extends ZendAfi_View_Helper_RenderAlbum {
-  public function renderAlbum($album) {
+  public function renderAlbum($album) : string {
     return $album
       ? sprintf('<div id="resnum">%s</div>', $this->renderAlbumHelper($album))
       : $this->view->_('Aucune ressource numérique trouvée.');
   }
-}
\ No newline at end of file
+}
diff --git a/library/digital_resources/Arkhenum/Config.php b/library/digital_resources/Arkhenum/Config.php
index e1c44d7cf64..e03257560ac 100644
--- a/library/digital_resources/Arkhenum/Config.php
+++ b/library/digital_resources/Arkhenum/Config.php
@@ -53,7 +53,7 @@ class Arkhenum_Config extends Class_DigitalResource_Config {
   }
 
 
-  public function isDocTypeHandled($type) {
+  public function isDocTypeHandled(string $type) : bool {
     return 0 === strpos($type, $this->getDocTypeId());
   }
 
diff --git a/library/digital_resources/Assimil/tests/AssimilTest.php b/library/digital_resources/Assimil/tests/AssimilTest.php
index 6f316b5fac2..daa14b1c337 100644
--- a/library/digital_resources/Assimil/tests/AssimilTest.php
+++ b/library/digital_resources/Assimil/tests/AssimilTest.php
@@ -495,7 +495,7 @@ class AssimilAjaxRecordWithNoLoggedUserDispatchTest extends AssimilAjaxRecordTes
   /** @test */
   public function linkToModuleAssimilShouldBePresent() {
     $this->assertXPathContentContains('//a[contains(@href, "modules/assimil/album_id/15")]',
-                                      utf8_encode('Accéder à '));
+                                      Class_CharSet::fromISOtoUTF8('Accéder à '));
   }
 }
 
diff --git a/library/digital_resources/Bacon/Config.php b/library/digital_resources/Bacon/Config.php
index cab20cef9b4..a40e6c045de 100644
--- a/library/digital_resources/Bacon/Config.php
+++ b/library/digital_resources/Bacon/Config.php
@@ -186,7 +186,7 @@ class Bacon_Config extends Class_DigitalResource_Config {
   }
 
 
-  public function isDocTypeHandled($type) {
+  public function isDocTypeHandled(string $type) : bool {
     $doctype_id = $this->getDoctypeId();
 
     return parent::isDocTypeHandled($type)
diff --git a/library/digital_resources/Cvs/Service/Album.php b/library/digital_resources/Cvs/Service/Album.php
new file mode 100644
index 00000000000..6dcb3c42fe5
--- /dev/null
+++ b/library/digital_resources/Cvs/Service/Album.php
@@ -0,0 +1,27 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cvs_Service_Album extends Class_WebService_BibNumerique_RessourceNumerique {
+  public function getRessourceCategorieLibelle() {
+    return 'Cvs';
+  }
+}
diff --git a/library/digital_resources/Cvs2/AccesDirect.php b/library/digital_resources/Cvs2/AccesDirect.php
new file mode 100644
index 00000000000..cc3fdf824cb
--- /dev/null
+++ b/library/digital_resources/Cvs2/AccesDirect.php
@@ -0,0 +1,37 @@
+<?php
+/**
+ * Copyright (c) 2012, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+class Cvs2_AccesDirect {
+  protected Class_Users $_user;
+  protected Class_Album $_album;
+
+  public function __construct(Class_Album $album) {
+    $this->_user = Class_Users::getIdentity();
+    $this->_album = $album;
+  }
+
+
+  public function url() : string {
+    return (new Cvs2_Service_DirectAccess(Cvs2_Config::getInstance()))
+      ->getDirectAccessFor($this->_user,
+                           $this->_album);
+  }
+}
diff --git a/library/digital_resources/Cvs2/Batch.php b/library/digital_resources/Cvs2/Batch.php
new file mode 100644
index 00000000000..33397a3d35a
--- /dev/null
+++ b/library/digital_resources/Cvs2/Batch.php
@@ -0,0 +1,23 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cvs2_Batch extends Class_DigitalResource_Batch {}
diff --git a/library/digital_resources/Cvs2/Config.php b/library/digital_resources/Cvs2/Config.php
new file mode 100644
index 00000000000..160aadc1e08
--- /dev/null
+++ b/library/digital_resources/Cvs2/Config.php
@@ -0,0 +1,241 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cvs2_Config extends Class_DigitalResource_Config {
+
+  const CATALOGUE= 'id';
+  const ACCES_DIRECT_ENABLED = 'acces_direct_enabled';
+
+
+  protected function _getConfig() {
+    $options = ['fields' => [['name' => static::CATALOGUE,
+                              'label' => $this->_('Identifiant catalogue')],
+                             ['name' => static::ACCES_DIRECT_ENABLED,
+                              'label' => $this->_('Acces direct'),
+                              'type' => 'checkbox',
+                              'value' => '1'
+                             ]],
+                'trim' => true];
+
+    $catalogues = Class_AdminVar_Meta::newMultiInput($this->_('Catalogues'),
+                                                     ['options' => $options,
+                                                      'validate' => Cvs2_Validate_DocumentTypes::class
+                                                     ])
+      ->bePrivate();
+
+    return [
+            'Introduction' => $this->_('<strong>Nouveau connecteur pour CVS</strong> “ Spécialiste de la fourniture de produits audiovisuels aux collectivités depuis 1987. ”'),
+
+            'DocTypeLabel' => $this->_('Vidéos à la demande'),
+
+            'HelpLink' => 'http://wiki.bokeh-library-portal.org/index.php/CVS',
+            'Url' => 'http://www.cvs-mediatheques.com/',
+            'Icon' => 'http://www.cvs-mediatheques.com/res/cvs/css/default/habillage/logo.png',
+            'MailUrl' => 'http://www.cvs-mediatheques.com/?lnk=cgv',
+            'Mail' => 'gerard@ruffin.fr',
+            'ContactName' => 'Gérard Ruffin',
+
+            'MenuLabel' => $this->_('Lien vers CVS'),
+            'PermissionLabel' => $this->_('Bibliothèque numérique: accéder aux ressources moissonnées depuis CVS'),
+            'NotAllowedMessage' => $this->_('Votre abonnement ne permet pas l\'accès à cette ressource.'),
+            'Service' => $this->withNameSpace('Service'),
+            'Harvesting' => true,
+            'Harvester' => $this->withNameSpace('Harvester'),
+            'Batch' => $this->withNameSpace('Batch'),
+            'Importer' => $this->withNameSpace('Importer'),
+            'OtherBatches' => [
+            ],
+            'BatchRunning' => false,
+            'SsoAction' => true,
+            'ModuleMenu' => 'Cvs2_ModuleMenu',
+
+            'AdminVars' =>
+            [
+             'BMKEY' => Class_AdminVar_Meta::newDefault($this->_('Paramétrage CVS2'))->bePrivate(),
+             'BMID' => Class_AdminVar_Meta::newDefault($this->_('Paramétrage CVS2'))->bePrivate(),
+             'BMLABEL' => Class_AdminVar_Meta::newDefault($this->_('Libellé de regroupement des abonnés'))->bePrivate(),
+             'SOURCENAME' => Class_AdminVar_Meta::newDefault($this->_('Paramétrage CVS2'))->bePrivate(),
+             'SOURCEID' => Class_AdminVar_Meta::newDefault($this->_('Paramétrage CVS2'))->bePrivate(),
+             'SOURCEKEY' => Class_AdminVar_Meta::newDefault($this->_('Paramétrage CVS2'))->bePrivate(),
+             'SOURCEPASSWORD' => Class_AdminVar_Meta::newDefault($this->_('Paramétrage CVS2'))->bePrivate(),
+             'LOGINTEST' => Class_AdminVar_Meta::newDefault($this->_('Paramétrage CVS2 (adhid)'))->bePrivate(),
+             'API_URL' => Class_AdminVar_Meta::newDefault($this->_('Url de l\'API'), ['value' => 'http://stream.cvs-mediatheques.com/api/partners.php'])->bePrivate(),
+             'CATALOGUES' => $catalogues,
+             'CATALOG_URL' => Class_AdminVar_Meta::newDefault($this->_('URL de consultation catalogue'))->bePrivate()
+            ],
+    ];
+  }
+
+
+  public function newClient(){
+    $parser_class = Cvs2_Service_Parser_Catalogue::class;
+    return (new Cvs2_Service_Catalogue)
+      ->setParser(new $parser_class);
+  }
+
+
+  public function isEnabled() {
+    return (('' != $this->getAdminVar('BMKEY'))
+            && ('' != $this->getAdminVar('BMID'))
+            && ('' != $this->getAdminVar('SOURCEPASSWORD'))
+            && ('' != $this->getAdminVar('SOURCEKEY'))
+            && ('' != $this->getAdminVar('SOURCEID'))
+            && ('' != $this->getAdminVar('SOURCENAME'))
+            && ('' != $this->getAdminVar('LOGINTEST')));
+  }
+
+
+  public function getSsoUrl(Class_Users $user) : string {
+    return $this->getAdminVar('CATALOG_URL');
+  }
+
+
+  public function hasRightAccess($user) : bool {
+    if ($this->getAdminVar('ANONYMOUS_ACCESS'))
+      return true;
+
+    return $user
+      ? parent::hasRightAccess($user)
+      : (bool) $this->getAdminVar('LOGINTEST');
+  }
+
+
+  public function getAlbumSsoUrl(?Class_Users $user, ?Class_Album $album) : string {
+    if (!$user)
+      return $this->_getConnectionLink();
+
+    if ($this->_isAccesDirectFor($album))
+      return (new Cvs2_AccesDirect($album))->url();
+
+    return $this->getAdminVar('CATALOG_URL') . ($album ? '/album/' . $album->getIdOrigine() : '');
+  }
+
+
+  public function getMuteHarvestDashboard() {
+    return true;
+  }
+
+
+  public function renderCustomDiagOn($view) {
+    return (new Cvs2_View_Helper_Dashboard)
+      ->setView($view)
+      ->dashboard();
+  }
+
+
+  public function getSearchUrlForRecord($record) : array {
+    return ($album = $record->getAlbum())
+      ? ['module' => 'opac',
+         'controller' => 'modules',
+         'action' => 'new-cvs',
+         'album_id' => $album->getId()]
+    : [];
+
+  }
+
+
+  public function addFormElementsIn($form) {
+    if ($this->isEnabled())
+      (new Cvs2_Form_SearchResult($form))->addElements();
+
+    return $this;
+
+  }
+
+
+  public function cataloguesIds(string $adminvar_value ='') :array {
+    if (!$adminvar_value)
+      $adminvar_value = $this->getAdminVar('CATALOGUES');
+
+    return ($catalogues = json_decode($adminvar_value, true))
+      ? array_filter($catalogues[static::CATALOGUE],
+                     fn($code) => '' !== $code,
+                     ARRAY_FILTER_USE_KEY)
+      : [];
+  }
+
+
+  protected function _isAccesDirectFor(Class_Album $album) : bool {
+    $catalog = strtolower($this->catalogFromDocType($album->getTypeDocId()));
+    if (!$catalogs = json_decode($this->getAdminVar('CATALOGUES'), true))
+      return false;
+
+    $catalogs_acces_direct_mappings =  array_combine($catalogs[static::CATALOGUE],
+                                                     $catalogs[static::ACCES_DIRECT_ENABLED]);
+    return isset($catalogs_acces_direct_mappings[$catalog])
+      ? (bool)$catalogs_acces_direct_mappings[$catalog]
+    : false;
+  }
+
+
+  public function catalogFromDocType(string $doc_type_id) : string {
+    $doc_type_as_array = explode('_', $doc_type_id, 2);
+    return array_pop($doc_type_as_array);
+  }
+
+
+  public function guessDocTypeId(string $catalogue) : string {
+    $id = $this->withNameSpace($catalogue);
+    return Class_TypeDoc::findOrCreate($id, $catalogue)->getId();
+  }
+
+
+  public function countAlbums(){
+    return Class_Album::countBy(['provenance' => $this->_name]);
+  }
+
+
+  public function getRecordTypeDocForAlbum($album) {
+    return $album->getProvenance()
+      ? $this->withNameSpace($album->getProvenance())
+      : $this->getDoctypeId();
+  }
+
+
+  public function isDocTypeHandled(string $type) : bool {
+    return 0 === strpos($type, $this->_name);
+  }
+
+
+  public function knownDoctypes() : array {
+    return Class_CodifTypeDoc::query()
+      ->start('type_doc_id', $this->_name)
+      ->fetchAll();
+  }
+
+
+  public function addDocTypeIn(array $types) : array {
+    $my_doc_types = array_map(fn($codif) => $codif->getId(),
+                              $this->knownDoctypes());
+
+    return array_merge($types, array_combine($my_doc_types, $my_doc_types));
+  }
+
+
+  public function renderAlbum(Class_Album $album, Zend_View $view) : string {
+    return ($helper = Class_DigitalResource::getInstance()->viewHelperFor($this->_name,
+                                                                          'Album',
+                                                                          $view))
+      ? $helper->album($album)
+      : '';
+  }
+}
diff --git a/library/digital_resources/Cvs2/Harvester.php b/library/digital_resources/Cvs2/Harvester.php
new file mode 100644
index 00000000000..3d2b6bfbe1e
--- /dev/null
+++ b/library/digital_resources/Cvs2/Harvester.php
@@ -0,0 +1,98 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cvs2_Harvester extends Class_DigitalResource_Harvester_XML {
+
+  protected
+    $_catalogue,
+    $_date_from;
+
+
+  public function __construct(string $catalogue, string $date_from = '') {
+    parent::__construct();
+
+    $this->_catalogue = $catalogue;
+    $this->_date_from = $date_from;
+    $this->_job = $this->_('moissonnage CVS');
+
+    $this->_ws = new Cvs2_Service_Catalogue($catalogue, $date_from);
+    $this->_doc_type = $this->_ws->getDocType();
+  }
+
+
+  public function isDone() {
+    foreach($this->_config->cataloguesIds() as $name)
+      if (!$this->_catalogueIsDone($name))
+        return false;
+
+    return true;
+  }
+
+
+  protected function _catalogueIsDone($name) {
+    return Class_WebService_HarvestLog::findFirstBy(['type_doc' => $this->_config->withNameSpace($name)]);
+  }
+
+
+  protected function _intro() {
+    $this->_log($this->_('%s : Début %s pour %s à %s.',
+                         $this->_name,
+                         $this->_job,
+                         $this->_catalogue,
+                         $this->getCurrentDateTime()));
+    $this->_log($this->_('%s : Depuis %s.',
+                         $this->_name,
+                         $this->_date_from));
+    return $this;
+  }
+
+
+  protected function _stop() {
+    $this->_log($this->_('%s : Fin du %s pour %s à %s.',
+                         $this->_name,
+                         $this->_job,
+                         $this->_catalogue,
+                         $this->getCurrentDateTime()));
+    return $this;
+  }
+
+
+  protected function _clean() {
+    return ($this->_catalogue == 'tout')
+      ? $this
+      ->_cleanHarvestLogs()
+      ->_cleanXML()
+      : $this->_cleanHarvestLogs();
+  }
+
+
+  protected function _writeXML($page) {
+    if ( ! $xml = $this->_getXML($page))
+      return;
+    $this->getFileSystem()->filePutContents($this->_xml_directory_path . '/' . $this->_doc_type. '_' . $page . '.xml', $xml);
+  }
+
+
+  protected function _getXML($page) {
+    return $this->_ws->getXML($page, 100);
+  }
+}
diff --git a/library/digital_resources/Cvs2/Importer.php b/library/digital_resources/Cvs2/Importer.php
new file mode 100644
index 00000000000..3303553d390
--- /dev/null
+++ b/library/digital_resources/Cvs2/Importer.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cvs2_Importer extends Class_DigitalResource_Importer_XML {
+
+  public function __construct() {
+    parent::__construct();
+    $this->_job = $this->_('import CVS');
+  }
+
+
+  protected function _clean() : self {
+    return $this;
+  }
+}
diff --git a/library/digital_resources/Cvs2/ModuleMenu.php b/library/digital_resources/Cvs2/ModuleMenu.php
new file mode 100644
index 00000000000..4e24dd4e379
--- /dev/null
+++ b/library/digital_resources/Cvs2/ModuleMenu.php
@@ -0,0 +1,23 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cvs2_ModuleMenu extends Class_DigitalResource_ModuleMenu {}
diff --git a/library/digital_resources/Cvs2/Service.php b/library/digital_resources/Cvs2/Service.php
new file mode 100644
index 00000000000..9218eb89ee9
--- /dev/null
+++ b/library/digital_resources/Cvs2/Service.php
@@ -0,0 +1,284 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cvs2_Service extends Class_DigitalResource_Service {
+  use Trait_Logger;
+
+  const
+    CATEGORY_LABEL = 'Cvs2';
+
+  const SOURCEXPIRATIONTIME = 30;
+
+  protected
+    $_config,
+    $_user,
+    $_harvesting = false;
+  protected bool $_should_throw_exception=false;
+
+
+  public function getConfig() {
+    return $this->_config;
+  }
+
+
+  public function shouldThrowException() {
+    $this->_should_throw_exception = true;
+    return $this;
+  }
+
+
+
+  public function createDomDocument() {
+    $xml = new DOMDocument('1.0', 'utf-8');
+    $xml->formatOutput = true;
+    $xml->preserveWhiteSpace = false;
+    return $xml;
+  }
+
+
+  public function getEncodedXML($xml) {
+    return strtr(base64_encode($xml), '+/', '-_');
+  }
+
+
+  protected function _callListeCatalogue() :string {
+    $xml = $this->getListeCatalogueXML();
+    return $this->httpPost($xml);
+  }
+
+
+  public function getListeCatalogueXML() {
+    $this->setUser(Class_Users::newInstance(['login' => $this->_var('LOGINTEST')]));
+    return $this->_getXML('liste_catalogue', [], null);
+  }
+
+
+  public function getCatalogueNames() {
+    $response = $this->_callListeCatalogue();
+    $parser = new Cvs2_Service_Parser_Catalogues;
+    $parser->parseXML($response);
+
+    return array_map(fn($elt) => $elt['name'],
+                     $parser->getCatalogues());
+  }
+
+
+  public function httpPost($xml) {
+    if(!$xml)
+      return '';
+
+    if(!$encoded_xml = $this->getEncodedXML($xml))
+      return '';
+    if ($this->_should_throw_exception)
+      return Class_WebService_Abstract::getHttpClient()
+        ->postData($this->_var('API_URL'),
+                   ['xml' => $encoded_xml]);
+
+    try {
+      return Class_WebService_Abstract::getHttpClient()
+        ->postData($this->_var('API_URL'),
+                   ['xml' => $encoded_xml]);
+    } catch(Exception $e) {
+      return '';
+    }
+  }
+
+  public function getUser() {
+    $this->_user ??= Class_Users::getIdentity();
+
+    return ($this->_user && !$this->_harvesting)
+      ? $this->_user
+      : Class_Users::newInstance(['login' => $this->_var('LOGINTEST')]);
+  }
+
+
+  public function setUser($user) {
+    $this->_user = $user;
+    return $this;
+  }
+
+
+  public function harvest() {
+    foreach($this->_config->cataloguesIds() as $name){
+      (new Cvs2_Harvester($name, $this->_getLastRunOrDefault()))
+        ->setLogger($this->getLogger())
+        ->run();
+
+      (new Cvs2_Importer())
+        ->setLogger($this->getLogger())
+        ->run();
+    }
+  }
+
+
+  protected function _getLastRunOrDefault(){
+    $lastdate = '1970-01-01';
+
+    if (($batch = Class_Batch::findFirstBy(['type' => 'Cvs2_Batch']))
+        && $batch->getLastRun()
+        && $batch->getLastRun() != '0000-00-00 00:00:00')
+      $lastdate = $batch->getLastRun();
+
+    return $lastdate;
+  }
+
+
+  protected function _getKey($time, $login) {
+    return md5($login
+               . $this->_var('BMID')
+               . $this->_var('BMKEY')
+               . $this->_var('SOURCENAME')
+               . $this->_var('SOURCEID')
+               . $this->_var('SOURCEKEY')
+               . $this->_var('SOURCEPASSWORD')
+               . $time
+               . static::SOURCEXPIRATIONTIME);
+  }
+
+
+  protected function _var($name) {
+    return $this->_config->getAdminVar($name);
+  }
+
+
+  protected function _getLogin() {
+    $user = $this->getUser();
+    return $user->getIdabon() ? $user->getIdabon() : $user->getLogin();
+  }
+
+
+  protected function _getXML($action, $params, ?Closure $closure) {
+    $time = $this->getCurrentTime();
+    $login = $this->_getLogin();
+
+    $xml = $this->createDomDocument();
+
+    $albums = $xml->appendChild($xml->createElement('albums'));
+    $header = $albums->appendChild($xml->createElement('header'));
+    $albums->appendChild($xml->createElement('body'));
+
+    foreach(['bmid' => $this->_var('BMID'),
+             'sourceid' => $this->_var('SOURCEID'),
+             'key' => $this->_getKey($time, $login),
+             'time' => $time,
+             'adhid' => $login,
+             'action' => $action] as $key => $value)
+      $header->appendChild($xml->createElement($key, $value));
+
+    if ($closure)
+      $closure($xml, $params);
+
+    return $xml->saveXML();
+  }
+
+
+  protected function _appendAccesDirect($xml, $params) {
+    $body = $xml->getElementsByTagName('body')->item(0);
+
+    $this->_cdataIn($xml, $body, 'cataloguename', urldecode($params['cataloguename']));
+    $this->_cdataIn($xml, $body, 'querystring', urldecode($params['querystring']));
+    $this->_appendUser($xml);
+  }
+
+
+  protected function _cdataIn($xml, $parent, $name, $value) {
+    $child = $xml->createElement($name);
+    $child->appendChild($xml->createCDATASection($value));
+    $parent->appendChild($child);
+  }
+
+
+  protected function _appendSearchDocument($xml, $params) {
+    $body = $xml->getElementsByTagName('body')->item(0);
+
+    foreach(['q',
+             'espace',
+             'classement',
+             'cataloguename',
+             'page',
+             'nombre_par_page',
+             'from',
+             'until'
+             ] as $param)
+      if (isset($params[$param]))
+        $this->_cdataIn($xml, $body, $param, $params[$param]);
+  }
+
+
+  protected function _appendSearchDocumentAndUser($xml, $params) {
+    $this->_appendSearchDocument($xml,$params);
+    $this->_appendUser($xml);
+  }
+
+  protected function _appendUser($xml) {
+    $user = $this->getUser();
+
+    $user_data = array_filter(['login' => $user->getLogin(),
+                               'nom' => $user->getNom(),
+                               'prenom' => $user->getPrenom(),
+                               'pseudo' => $user->getPseudo(),
+                               'password' => $user->getPassword(),
+                               'email' => $user->getMail(),
+                               'dnaiss' => (($naissance = $user->getNaissance())
+                                            ? Class_Date::frToIso($naissance)
+                                            : ''),
+                               'datout' => Class_Date::frToIso($user->getValidSubscriptionEndDate()),
+                               'bibliotheque' => (($label = $this->_var('BMLABEL'))
+                                                  ? $label : $user->getLibelleBib())]);
+
+    $body = $xml->getElementsByTagName('body')->item(0);
+
+    foreach($user_data as $key => $value) {
+      $value = preg_replace('#&(?![a-z]{1,6};)#i', '&amp;', $value);
+      $body->appendChild($xml->createElement($key, $value));
+    }
+  }
+
+
+  public function importFrom(string $xml) : array {
+    $parser = (new Cvs2_Service_Parser_Catalogue());
+    $parser->parseXML($xml);
+
+    if(!($ressources = $parser->getRessources()))
+      return [];
+
+    $count_ressources=['created' => 0,
+                       'imported' =>0];
+
+    $harvestedIds = [];
+    foreach ($ressources as $ressource) {
+      $count_ressources['created']++;
+      $harvestedIds[] = $ressource->getId();
+      if ($ressource->isAlreadyHarvested())
+        continue;
+
+      $album = $ressource->import();
+      $count_ressources['imported']++;
+    }
+
+    $this->getLogger()->log($this->_('albums créés :%d albums importés:%d \n',
+                                     $count_ressources['created'],
+                                     $count_ressources['imported']));
+
+    return $harvestedIds;
+  }
+}
diff --git a/library/digital_resources/Cvs2/Service/Album.php b/library/digital_resources/Cvs2/Service/Album.php
new file mode 100644
index 00000000000..c58bd6286ff
--- /dev/null
+++ b/library/digital_resources/Cvs2/Service/Album.php
@@ -0,0 +1,27 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cvs2_Service_Album extends Class_WebService_BibNumerique_RessourceNumerique {
+  public function getRessourceCategorieLibelle() : string {
+    return Cvs2_Service::CATEGORY_LABEL;
+  }
+}
diff --git a/library/digital_resources/Cvs2/Service/Catalogue.php b/library/digital_resources/Cvs2/Service/Catalogue.php
new file mode 100644
index 00000000000..d39a165a035
--- /dev/null
+++ b/library/digital_resources/Cvs2/Service/Catalogue.php
@@ -0,0 +1,103 @@
+<?php
+/**
+ * Copyright (c) 2012-2023, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cvs2_Service_Catalogue extends Cvs2_Service {
+
+  protected string $_catalogue;
+  protected string $_from;
+  protected string $_until = '';
+  protected Cvs2_Service_Parser_Catalogue $_parser;
+  protected string $_response;
+
+
+  public function __construct(string $cataloguename = 'tout', string $from = ''){
+    parent::__construct(Cvs2_Config::getInstance());
+    $this->_harvesting = true;
+    $this->_catalogue = $cataloguename;
+    $this->_from = $from
+      ? $from
+      : $this->getCurrentDate();
+    $this->_page = 1;
+    $this->_number_per_page = 100;
+  }
+
+
+  public function setParser(Cvs2_Service_Parser_Catalogue $parser) : self {
+    $this->_parser = $parser;
+    return $this;
+  }
+
+
+  public function setCatalogue(string $catalogue):self {
+    $this->_catalogue = $catalogue;
+    return $this;
+  }
+
+
+  public function setResponse(string $xml ):self {
+    $this->_response = $xml;
+    return $this;
+  }
+
+
+  protected function _callCatalogue(int $page = 1, int $count_records_per_page = 100 ) :string {
+    $xml = $this->getCatalogueOnePageXML($page);
+    $xml_response = $this->httpPost($xml);
+    $this->setResponse($xml_response);
+    return $xml_response;
+  }
+
+
+  public function getCatalogueOnePageXML(int $page = 1, int $count_records_per_page = 100) :string {
+    $params = ['cataloguename' => $this->_catalogue,
+               'page' => $page,
+               'nombre_par_page' => $count_records_per_page];
+    if (isset($this->_from))
+      $params['from'] = $this->_from;
+    if (isset($this->_until))
+      $params['until'] = $this->_until;
+
+    $closure = fn($xml, $params) => $this->_appendSearchDocument($xml, $params);
+
+    return $this->_getXML('catalogue', $params, $closure);
+  }
+
+
+  public function hasRecordsToHarvest() :bool {
+    return (! empty($this->_response))
+          && preg_match('#<success>1</success>#', $this->_response)
+          && !preg_match('#<messageId>API:#', $this->_response)
+          && !preg_match('#<albumspagesize>0</albumspagesize>#', $this->_response)
+          && !preg_match('#<totalalbums>0</totalalbums>#', $this->_response);
+  }
+
+
+  public function getXML(int $page =1, int $number_per_page = 100) :string {
+    return $this
+      ->_callCatalogue($page, $number_per_page);
+  }
+
+
+  public function getDocType() :string {
+    return parent::getConfig()->getDocTypeId(). '_' .$this->_catalogue;
+  }
+}
diff --git a/library/digital_resources/Cvs2/Service/DirectAccess.php b/library/digital_resources/Cvs2/Service/DirectAccess.php
new file mode 100644
index 00000000000..eceb3f1361a
--- /dev/null
+++ b/library/digital_resources/Cvs2/Service/DirectAccess.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cvs2_Service_DirectAccess extends Cvs2_Service {
+  public function getDirectAccessFor(Class_Users $user, ?Class_Album $album) : string {
+    $response = $this->_callDirectAccess($user, $album);
+
+    $parser = new Cvs2_Service_Parser_AccesDirect;
+    $parser->parseXML($response);
+
+    return ($parser->isSuccess()
+            && ($redirect = $parser->getRedirect())) ? $redirect : '';
+  }
+
+
+  protected function _callDirectAccess(Class_Users $user, ?Class_Album $album) : string {
+    $xml = $this->getAccesDirectXML($user, $album);
+    return $this->httpPost($xml);
+  }
+
+
+  public function getAccesDirectXML(Class_Users $user, ?Class_Album $album ) : string {
+    $this->_user = $user;
+
+    $closure = fn($xml, $params) => $this->_appendAccesDirect($xml, $params);
+
+    $params = ['querystring' => $album->getIdOrigine() ?? '',
+               'cataloguename' => strtolower($this->_config->catalogFromDocType($album->getTypeDocId()))];
+
+    return $this->_getXML('acces_direct', $params, $closure);
+  }
+}
diff --git a/library/digital_resources/Cvs2/Service/Parser/Abstract.php b/library/digital_resources/Cvs2/Service/Parser/Abstract.php
new file mode 100644
index 00000000000..1e4ee2d7028
--- /dev/null
+++ b/library/digital_resources/Cvs2/Service/Parser/Abstract.php
@@ -0,0 +1,60 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cvs2_Service_Parser_Abstract {
+
+  protected Class_WebService_XMLParser $_parser;
+  protected bool $_success = false;
+  protected Class_DigitalResource_Config $_config;
+
+
+  public function __construct() {
+    $this->_config = Cvs2_Config::getInstance();
+  }
+
+
+  public function parseXML(string $xml) : self {
+    $this->_parser = new Class_WebService_XMLParser();
+    $this->_parser->setElementHandler($this);
+    $this->_parser->parse($xml);
+
+    return $this;
+  }
+
+
+  public function endSuccess(bool $data) : self {
+    $this->_success = $data;
+
+    return $this;
+  }
+
+
+  public function isSuccess() : bool{
+    return $this->_success;
+  }
+
+
+  protected function _inParents($parent) : bool {
+    return $this->_parser->inParents($parent);
+  }
+
+}
diff --git a/library/digital_resources/Cvs2/Service/Parser/AccesDirect.php b/library/digital_resources/Cvs2/Service/Parser/AccesDirect.php
new file mode 100644
index 00000000000..e3368c241cb
--- /dev/null
+++ b/library/digital_resources/Cvs2/Service/Parser/AccesDirect.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cvs2_Service_Parser_AccesDirect extends Cvs2_Service_Parser_Abstract {
+
+  protected string $url,
+    $_redirect;
+
+
+  public function endUrl(string $data) : self {
+    if($this->_inParents('data'))
+      $this->url = $data;
+
+    return $this;
+  }
+
+
+  public function getUrl() : string {
+    return $this->url;
+  }
+
+
+  public function endRedirect(string $data) : self {
+    if($this->_inParents('data'))
+      $this->_redirect = $data;
+
+    return $this;
+  }
+
+
+  public function getRedirect() : string {
+    return $this->_redirect;
+  }
+}
diff --git a/library/digital_resources/Cvs2/Service/Parser/Catalogue.php b/library/digital_resources/Cvs2/Service/Parser/Catalogue.php
new file mode 100644
index 00000000000..5ac6301abfa
--- /dev/null
+++ b/library/digital_resources/Cvs2/Service/Parser/Catalogue.php
@@ -0,0 +1,193 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cvs2_Service_Parser_Catalogue extends Cvs2_Service_Parser_Abstract  {
+
+  const PARENT = 'album';
+
+  protected array $_ressources = [];
+  protected ?Cvs2_Service_Album $_current_ressource;
+  protected int $_total_ressources = 0;
+
+
+  public function startAlbum(array $attributes) : self {
+    $this->_current_ressource = new Cvs2_Service_Album;
+    return $this;
+  }
+
+  public function endAlbum(string $data) :self {
+    $this->_ressources[] = $this->_current_ressource;
+    $this->_current_ressource = null;
+    return $this;
+  }
+
+
+  public function endDocid(string $data) :self {
+    $this->_current_ressource->setId((string)$data);
+    return $this;
+  }
+
+
+  public function endCatalogue(string $data) :self {
+    if ($this->_inParents('album')){
+      $this->_current_ressource->setProvenance($this->_config->getName());
+      $this->_current_ressource->setTypeDocId($this->_config->guessDocTypeId($data));
+    }
+    return $this;
+  }
+
+
+  public function endLabelname(string $data) :self {
+    if ($this->_inParents('album'))
+      $this->_current_ressource->setEditeur($data);
+
+    return $this;
+  }
+
+
+  public function endStitle(string $data) :self {
+    $this->_current_ressource->setSubTitle($data);
+    return $this;
+  }
+
+
+  public function endNbPages(string $data) :self {
+    $this->_current_ressource->setDescription(sprintf("%s\nNb Pages : %s"
+                                                     ,$this->_current_ressource->getDescription()
+                                                     ,$data));
+    return $this;
+  }
+
+
+  public function endAlbumreleasedate(string $data) :self {
+    $this->_current_ressource->setYear($data);
+    return $this;
+  }
+
+
+  public function endUri(string $data) :self {
+    if($this->_inParents('album')) {
+      $this->_current_ressource->setExternalUri((string)$data);
+    }
+    return $this;
+  }
+
+
+  public function endAlbumname(string $data) :self {
+    $this->_current_ressource->setTitle($data);
+    return $this;
+  }
+
+
+  public function endSaison(string $data) :self {
+    $this->_current_ressource->setDescription(sprintf("%s\nsaison : %s"
+                                                     ,$this->_current_ressource->getDescription()
+                                                     ,$data));
+    return $this;
+  }
+
+
+  public function endEpisode(string $data) :self {
+    $this->_current_ressource->setDescription(sprintf("%s\nepisode : %s",
+                                                     $this->_current_ressource->getDescription(),
+                                                     $data));
+    return $this;
+  }
+
+
+  public function endSrc(string $data) :self {
+    if($this->_inParents('album133image'))
+      $this->_current_ressource->addPoster($data);
+    return $this;
+  }
+
+
+  public function endYear(string $data) :self {
+    $this->_current_ressource->setYear($data);
+    return $this;
+  }
+
+
+  public function endMaxtimeint(string $data) :self {
+    $data = (int) $data;
+    $hour = $data / 3600;
+    $min  = ($data / 60) % 60;
+    $this->_current_ressource->setDuration(sprintf('%02d h %02d min',
+                                                  $hour,
+                                                  $min));
+    return $this;
+  }
+
+
+  public function endResume(string $data) :self {
+    $resume = $data;
+
+    if (strlen($resume) > 400) {
+      $resume = substr($data,0,400);
+      $resume = substr($resume, 0, strrpos($resume, ' ')) . '...';
+    }
+
+    $this->_current_ressource->setDescription($resume);
+    return $this;
+  }
+
+
+  public function endGenrename(string $data) :self {
+    $this->_current_ressource->addMatiere($data);
+    return $this;
+  }
+
+
+  public function endThemename(string $data) :self {
+    $this->_current_ressource->addMatiere($data);
+    return $this;
+  }
+
+
+  public function endArtistname(string $data) :self {
+    if($this->_inParents('artist'))
+      $this->_current_ressource->addAuthor($data);
+    return $this;
+  }
+
+
+  public function endRefpublicname(string $data):self {
+    $this->_current_ressource->setRights($data);
+    return $this;
+  }
+
+
+  public function endTotalalbums(int $data) :self {
+    $this->_total_ressources = $data;
+    return $this;
+  }
+
+
+  public function getRessources() :array {
+    return $this->_ressources;
+  }
+
+
+  public function getTotalRessources() :int {
+    return $this->_total_ressources;
+  }
+}
diff --git a/library/digital_resources/Cvs2/Service/Parser/Catalogues.php b/library/digital_resources/Cvs2/Service/Parser/Catalogues.php
new file mode 100644
index 00000000000..04be77f29e3
--- /dev/null
+++ b/library/digital_resources/Cvs2/Service/Parser/Catalogues.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cvs2_Service_Parser_Catalogues extends Cvs2_Service_Parser_Abstract {
+
+  protected array $_catalogues = [];
+  protected $_current_catalog;
+  protected $_total_catalog = 0;
+
+  public function getCatalogues() : array {
+    return $this->_catalogues;
+  }
+
+  public function startCatalogs(array $attributes) : self {
+    $this->_current_catalog = [];
+    return $this;
+  }
+
+
+  public function endCatalog(string $data) : self {
+    $this->_catalogues[] = $this->_current_catalog;
+    return $this;
+  }
+
+
+  public function endName(string $data) : self {
+    $this->_current_catalog['name']= $data;
+    return $this;
+  }
+
+
+  public function endStartDate(string $data) : self {
+    $this->_current_catalog['start_date']= $data;
+    return $this;
+  }
+
+
+  public function endFinishDate(string $data) : self {
+    $this->_current_catalog['end_date']= $data;
+    return $this;
+  }
+
+
+  public function endTotal(string $data) : self {
+    $this->_total_catalog = $data;
+    return $this;
+  }
+}
diff --git a/library/digital_resources/Cvs2/Validate/DocumentTypes.php b/library/digital_resources/Cvs2/Validate/DocumentTypes.php
new file mode 100644
index 00000000000..8f09499d526
--- /dev/null
+++ b/library/digital_resources/Cvs2/Validate/DocumentTypes.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * Copyright (c) 2012-2023, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cvs2_Validate_DocumentTypes {
+  use Trait_Translator;
+
+
+  protected array $_messages =[];
+  protected array $_harvestSets;
+
+
+  public function isValid(string $value) : bool {
+    if ( ! $value = json_decode($value, true))
+      return true;
+
+    $ids = $value[Cvs2_Config::CATALOGUE] ?? [];
+
+    if ( ! $result = count(array_unique($ids)) == count($ids))
+      $this->_messages [] = $this->_('Attention, les catalogues ne doivent pas être dupliqués');
+
+    return $this->_containsOnlyHarvestSets($ids) && $result;
+  }
+
+  protected function _containsOnlyHarvestSets(array $ids) :bool {
+    foreach ($ids as $cataloguename)
+      if (!$this->_isValidHarvestSet($cataloguename)){
+        $this->_messages [] = $this->_('Le catalogue %s ne fait pas parti des catalogues moissonables :%s.',$cataloguename, implode(', ',$this->_harvestSets));
+        return false;
+      }
+    return true;
+  }
+
+
+  protected function _isValidHarvestSet(string $id) :bool{
+    return in_array($id, $this->_getHarvestSets());
+  }
+
+
+  protected function _getHarvestSets() :array{
+    if (isset($this->_harvestSets))
+        return $this->_harvestSets;
+
+    return $this->_harvestSets ??= (new Cvs2_Service(Cvs2_Config::getInstance()))->getCatalogueNames();
+  }
+
+  public function getMessages() : array {
+    return $this->_messages;
+  }
+}
diff --git a/library/digital_resources/Cvs2/View/Helper/Album.php b/library/digital_resources/Cvs2/View/Helper/Album.php
new file mode 100644
index 00000000000..68f7700058c
--- /dev/null
+++ b/library/digital_resources/Cvs2/View/Helper/Album.php
@@ -0,0 +1,24 @@
+<?php
+/**
+ * Copyright (c) 2012-2018, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cvs2_View_Helper_Album extends Class_DigitalResource_AlbumViewHelper {
+}
diff --git a/library/digital_resources/Cvs2/View/Helper/Dashboard.php b/library/digital_resources/Cvs2/View/Helper/Dashboard.php
new file mode 100644
index 00000000000..3286ef377b7
--- /dev/null
+++ b/library/digital_resources/Cvs2/View/Helper/Dashboard.php
@@ -0,0 +1,86 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+class Cvs2_View_Helper_Dashboard extends ZendAfi_View_Helper_DigitalResource_Dashboard_Harvest {
+  protected $_config;
+
+  public function dashboard() {
+    return $this->_render(Cvs2_Config::getInstance());
+  }
+
+
+  protected function _getAlbumsHtml($config) : string {
+    $this->_config = $config;
+
+    if(!$count = $this->_config->countAlbums())
+      return $this->_tagWarning($this->_('Aucun album présent pour cette ressource'));
+
+    $html = implode([$this->_tag('h4',
+                                 $this->_('Nombre d\'albums présents dans Bokeh : %d', $count))]);
+
+    $html .= $this->_countAlbumsByDocTypes();
+    $html .=  '<br />';
+
+    $html .= $this->_tagAnchor($this->view->absoluteUrl(['module' => 'admin',
+                                                         'controller' => 'album',
+                                                         'action' => 'index',
+                                                         'title_search' => $this->_config->getDocType()], null, true),
+                               $this->_('Voir les albums'),
+                               ['target' => '_blank']);
+
+    $html.= $this->_thumbnailing($this->_config);
+
+    return $html;
+  }
+
+
+  protected function _countAlbumsByDocTypes() : string {
+    $html = [];
+    foreach($this->_config->knownDoctypes() as $known_doc_type)
+      $html [] = $this->_tag('li', sprintf('%s : %d',
+                                           $known_doc_type->getLabel(),
+                                           Class_Album::countBy(['type_doc_id' => $known_doc_type->getId()])));
+
+    return $this->_tag('ul', implode($html));
+  }
+
+
+  protected function _getRecordsHtml($config) {
+    if (!$count = count(Class_Notice::query()
+                        ->select(['id_notice'])
+                        ->start('type_doc', $config->getDocType())
+                        ->fetchAll()))
+      return $this->_tagWarning($this->_('Aucune notice présente pour cette ressource'));
+
+    $records_html= [$this->_tag('h4',
+                                $this->_('Nombre de notices présentes dans Bokeh : %d', $count))];
+
+    foreach ($config->knownDoctypes() as $doc_type)
+      $records_html[] = $this->_tagAnchor($this->view->absoluteUrl(['module' => 'opac',
+                                                                    'controller' => 'recherche',
+                                                                    'action' => 'simple',
+                                                                    'facette' => 'T' . $doc_type->getId()], null, true),
+                                          $this->_('Voir les documents ' . $doc_type->getLabel()),
+                                          ['target' => '_blank']) . '&nbsp;&nbsp;&nbsp;';
+
+    return implode($records_html);
+  }
+}
diff --git a/library/digital_resources/Cvs2/controllers/IndexController.php b/library/digital_resources/Cvs2/controllers/IndexController.php
new file mode 100644
index 00000000000..24ee65a4a61
--- /dev/null
+++ b/library/digital_resources/Cvs2/controllers/IndexController.php
@@ -0,0 +1,25 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cvs2_Plugin_IndexController extends Class_DigitalResource_Controller {
+
+}
diff --git a/library/digital_resources/Cvs2/images/icon.png b/library/digital_resources/Cvs2/images/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..3602ad329392d9dcdc5fb1642db0626889a7b979
GIT binary patch
literal 730
zcmV<00ww*4P)<h;3K|Lk000e1NJLTq000&M000aK1^@s6X8BFV0007^Nkl<ZI8S3_
zKm#hFlkAn1RHiXAGP6E^a+mSu&8ti>C5(*hK}?K)jQ;-nTLTvR_n*PVH-drT@1Or*
zJ|hE|e*5<AIYwl2Lpx5t5tdXGhKb#|c8=l9@k4MWe;NK;Gczz8WME)q0SQLNr7>{w
z3ZgiTk&%I6_vZEDaDyES)^3s4F!e@O_4v*ehN~CPz=eMN_*TTh!PN&6`1|({LrPW&
z!+(fd;o#f1ug@mMOG?8GW{l{+@$Wy9Dn>>|hL;a-F+8|?1Fq@+pP#=NS=iaY2LJiP
zkd&6qzyR?*6fiQZTDijF+KTj(V1xByX4cEA8#TbBKYe_E?BK3lZn6?Q4Sf89CFt=0
z<6Bu-pHxy-Hiz;5|7ZAB<uAw%32p`k2FKDJe;JwBz>x$Bqq8Rt34S=x@Il45wL?fy
zv>cnkzyJPyaeis0<dj{X{{Q*?2do6-%lxz^E`}%l-x<X`Ym^n0Ozyxm|Niy+(z)58
z`XIh`aCe8Oh&Wb*?>v5K@nl=+$(V*MyKF2iTw(fWELv%HVQKbhMg`xFYdk_ynlOn6
zk8bOH+24E(#5atb*d`?|S%L22fBzW1ZJ!jz&0w6yzp$(M!~g$~6bbTe<%GHN&o);)
zVwCZ2f567cCyxkzQ0PD+gr9?nL0Lu`BP5K~#TeK)*lx&3%V_-jhlo5zMus2Z_Trpi
zON1P%EjYLYj$;c)Q9e!vJq<M!DUdOtE(Q$$|Nlb~XJTYz@XPAvVYt-u6&`s4j`e$)
z7#N+<4VM<-XRt6ZKoOP``paOXuZ<%1<LB?!`BnY$PmcG0NBA7d;<BtYWM=+b`=5cq
zg5lqPE|9Q{xFCa-u`!Avp5_V+|Dfsp&BxF8wj8>YynS-~d2~ks0Q$}4O4|v!ssI20
M07*qoM6N<$g3MWL$^ZZW

literal 0
HcmV?d00001

diff --git a/library/digital_resources/Cvs2/js/Cvs.js b/library/digital_resources/Cvs2/js/Cvs.js
new file mode 100644
index 00000000000..8335f88bd1f
--- /dev/null
+++ b/library/digital_resources/Cvs2/js/Cvs.js
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+$(document).ready(function(){
+  $('.cvs2_container:hidden').slideDown('slow');
+  
+  $('#cvs2_result .pager a').click(function(e){
+    e.preventDefault();
+    var url = $(this).attr('href');
+    $('#cvs_result').load(url);
+    return false;
+  });
+  
+  $('a[href*=\"modules/cvs2\"]').attr('target', '_blank');
+});
diff --git a/library/digital_resources/Cvs2/tests/Cvs2Test.php b/library/digital_resources/Cvs2/tests/Cvs2Test.php
new file mode 100644
index 00000000000..f79a02b362f
--- /dev/null
+++ b/library/digital_resources/Cvs2/tests/Cvs2Test.php
@@ -0,0 +1,981 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+
+abstract class Cvs2ActivatedTestCase extends AbstractControllerTestCase {
+  protected
+    $_user,
+    $_xml_content;
+
+  public function setUp() {
+    parent::setUp();
+
+    Class_Users::setTimeSource(new TimeSourceForTest('2020-03-10'));
+
+    Class_AdminVar::set('Cvs2_BMKEY', '22222');
+    Class_AdminVar::set('Cvs2_BMID', '22223');
+    Class_AdminVar::set('Cvs2_SOURCENAME', '22224');
+    Class_AdminVar::set('Cvs2_SOURCEID', '22225');
+    Class_AdminVar::set('Cvs2_SOURCEKEY', '22226');
+    Class_AdminVar::set('Cvs2_SOURCEPASSWORD', '22227');
+    Class_AdminVar::set('Cvs2_API_URL', 'http://cvs.api.org');
+    Class_AdminVar::set('Cvs2_LOGINTEST', 'cvs_login_test');
+
+    Cvs2_Service::setTimeSource((new TimeSourceForTest)->setTime(1369640315));
+
+    $group = $this->fixture(Class_UserGroup::class,
+                            ['id' => 1,
+                             'libelle' => 'Digital resources']);
+
+
+    $this->fixture(Class_Permission::class,
+                   ['id' => 1,
+                    'code' => 'Cvs2'])
+         ->permitTo($group,  new Class_Entity());
+
+    $this->_user = $this->fixture(Class_Users::class,
+                                  ['id' => 1,
+                                   'login' => 'Tom',
+                                   'password' => 'pwd'])
+                        ->setUserGroups([$group]);
+
+    $this->fixture(Class_Album::class,
+                   ['id' => 123,
+                    'id_origine' => 135,
+                    'titre' => 'apprendre à apprendre',
+                    'provenance' => 'Cvs2',
+                    'type_doc_id' => 'Cvs2_skilleos'
+                   ]);
+
+    $this->_setXmlContent();
+    $this->_setWebClient();
+  }
+
+
+  protected function _setWebClient(){
+    $web_client = $this->mock()
+                       ->whenCalled('postData')
+                       ->answers($this->_xml_content);
+
+    Class_WebService_Abstract::setHttpClient($web_client);
+  }
+
+
+  protected function _setXmlContent() {
+    return $this->_xml_content = file_get_contents(__DIR__.'/cvs_liste_catalogue.xml');
+  }
+
+
+  public function tearDown() {
+    Class_WebService_Abstract::setHttpClient(null);
+    Class_AdminVar::set('Cvs2_BMKEY', '');
+    Class_AdminVar::set('Cvs2_BMID', '');
+    Class_AdminVar::set('Cvs2_SOURCENAME', '');
+    Class_AdminVar::set('Cvs2_SOURCEID', '');
+    Class_AdminVar::set('Cvs2_SOURCEKEY', '');
+    Class_AdminVar::set('Cvs2_SOURCEPASSWORD', '');
+    Class_AdminVar::set('Cvs2_API_URL', '');
+    Class_AdminVar::set('Cvs2_LOGINTEST', '');
+    Class_Users::setTimeSource(null);
+    Cvs2_Service::setTimeSource(null);
+    $this->_user = null;
+    $this->_xml_content = null;
+    parent::tearDown();
+  }
+}
+
+
+
+
+abstract class Cvs2ActivatedAdminLoggedTestCase extends Cvs2ActivatedTestCase {
+
+  public function setUp() {
+    parent::setUp();
+    $admin = $this->fixture(Class_Users::class,
+                            ['id' => 78,
+                             'login' => 'pro',
+                             'password' => 'ultimate',
+                             'role_level' => ZendAfi_Acl_AdminControllerRoles::SUPER_ADMIN
+                            ]);
+    ZendAfi_Auth::getInstance()->logUser($admin);
+  }
+}
+
+
+
+
+class Cvs2DashboardTest extends Cvs2ActivatedTestCase {
+
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->fixture(Class_WebService_HarvestLog::class,
+                   ['id' => 1,
+                   'type_doc' => 'Cvs2_tout']);
+
+    $this->fixture(Class_Batch::class,
+                   ['id' => 1,
+                    'type' => 'Cvs2_Batch',
+                    'name' => 'Moissonage Cvs2'
+                   ]);
+
+    Class_AdminVar::set('Cvs2_CATALOGUES',
+                        json_encode(["id" => ['tout'],
+                                     'acces_direct_enabled' =>['1']]));
+
+    Class_TypeDoc::findOrCreate('Cvs2_izneo', 'Ressource Numérique Catalogue(Izneo)');
+
+    $this->fixture(Class_Album::class,
+                   ['id' => 2,
+                    'notice_id' => 1234,
+                    'titre' => 'Le monde de Sophie',
+                    'type_doc_id' => 'Cvs2_izneo'
+                   ]);
+
+    $this->fixture(Class_Notice::class,
+                   ['id' => 1234,
+                    'id_notice' => 1234,
+                    'titre' => 'Le monde de Sophie',
+                    'type_doc' => 'Cvs2_izneo'
+                   ]);
+
+    $this->dispatch('/Cvs2_Plugin/index');
+    Class_TypeDoc::reset();
+  }
+
+
+  public function tearDown(){
+    Class_AdminVar::set('Cvs2_CATALOGUES','');
+    parent::tearDown();
+  }
+
+
+  /** @test */
+  public function pageTitleShouldBeCVS() {
+    $this->assertXpathContentContains('//h1', 'CVS');
+  }
+
+
+  /** @test */
+  public function cvs2BmLabelVarShouldBePresent() {
+    $this->assertXpathContentContains('//table', 'Cvs2_BMLABEL');
+  }
+
+
+  /** @test */
+  public function cvsShouldBeActivated() {
+    $this->assertXPathContentContains('//button', 'Activé');
+  }
+
+
+  /** @test */
+  public function pageShouldContainsAlbumCount() {
+    $this->assertXPathContentContains('//h4', 'Nombre d\'albums présents dans Bokeh : 1');
+  }
+
+
+  /** @test */
+  public function pageShouldNotContainsNoRecordWarning() {
+    $this->assertNotXPathContentContains('//h4', 'Aucune notice présente pour cette ressource');
+  }
+
+
+  /** @test */
+  public function pageShouldContainRecordCount() {
+    $this->assertXPathContentContains('//h4', 'Nombre de notices présentes dans Bokeh : 1');
+  }
+
+
+  /** @test */
+  public function pageShouldContainRecordLink() {
+    $this->assertXPathContentContains('//a',
+                                      'Voir les documents Ressource Numérique Catalogue(Izneo)');
+  }
+
+
+  /** @test */
+  public function pageShouldContainsTableWithDocTypes() {
+    $this->assertXPathContentContains('//ul//li','Ressource Numérique Catalogue(Izneo) : 1');
+  }
+
+
+  /** @test */
+  public function pageShouldNotContainFirstHarvestWarning() {
+    $this->assertNotXPathContentContains('//p','Cette ressource nécessite un premier moissonnage');
+  }
+}
+
+
+
+
+class Cvs2NotHarvestedDashboardTest extends Cvs2ActivatedTestCase {
+  public function setUp() {
+    parent::setUp();
+
+    Class_AdminVar::set('Cvs2_CATALOGUES',
+                        json_encode(["id" => ['tout'],
+                                     'acces_direct_enabled' =>['1']])
+    );
+
+    $this->dispatch('/Cvs2_Plugin/index');
+  }
+
+
+  /** @test */
+  public function pageShouldContainFirstHarvestWarning() {
+    $this->assertXPathContentContains('//p','Cette ressource nécessite un premier moissonnage');
+  }
+
+}
+
+
+
+
+class Cvs2ModuleMenuTest extends Cvs2ActivatedTestCase {
+  protected $_menu;
+
+  /** @test */
+  public function cvsMenuShouldBeAvailable() {
+    Class_DigitalResource::resetInstance();
+    $menu = new Class_Systeme_ModulesMenu;
+    $this->assertEquals('Cvs2', $menu->getFonction('Cvs2')->getType());
+  }
+}
+
+
+
+
+class Cvs2SsoTest extends Cvs2ActivatedTestCase {
+  public function setUp() {
+    parent::setUp();
+    ZendAfi_Auth::getInstance()->logUser($this->_user);
+    Class_AdminVar::set('Cvs2_CATALOG_URL','http://eureka.mediatheque.fr');
+  }
+
+
+  /** @test */
+  public function shouldRedirectToCvs2() {
+    $this->dispatch('/opac/modules/cvs2');
+    $this->assertXPathContentContains('//script',
+                                      'document.location.href="http://eureka.mediatheque.fr');
+  }
+
+
+  /** @test */
+  public function shouldRedirectToAlbum() {
+    $this->dispatch('/opac/modules/cvs2/album_id/123');
+    $this->assertXPathContentContains('//script',
+                                      'http://eureka.mediatheque.fr/album/135');
+  }
+}
+
+
+
+abstract class Cvs2SsoTestWithAccesDirectTestCase extends Cvs2ActivatedTestCase {
+  public function setUp() {
+    parent::setUp();
+    ZendAfi_Auth::getInstance()->logUser($this->_user);
+    Class_AdminVar::set('Cvs2_CATALOGUES','{"id":["skilleos"],
+                                           "acces_direct_enabled":["1"]}');
+    Class_AdminVar::set('Cvs2_BMLABEL','MaBib');
+  }
+
+  protected function _setWebClient(){
+    $xml = "<?xml version=\"1.0\" encoding=\"utf-8\"?>
+<albums>
+  <header>
+    <bmid>22223</bmid>
+    <sourceid>22225</sourceid>
+    <key>73844495168ecc1aa59ba96c8cea6e82</key>
+    <time>1369640315</time>
+    <adhid>cvs_login_test</adhid>
+    <action>liste_catalogue</action>
+  </header>
+  <body/>
+</albums>
+";
+
+    $acces_direct_xml = "<?xml version=\"1.0\" encoding=\"utf-8\"?>
+<albums>
+  <header>
+    <bmid>22223</bmid>
+    <sourceid>22225</sourceid>
+    <key>06e91bd2ea3c7d0b12980ab5ef634f38</key>
+    <time>1369640315</time>
+    <adhid>Tom</adhid>
+    <action>acces_direct</action>
+  </header>
+  <body>
+    <cataloguename><![CDATA[skilleos]]></cataloguename>
+    <querystring><![CDATA[135]]></querystring>
+    <login>Tom</login>
+    <password>pwd</password>
+    <datout>2020-04-09</datout>
+    <bibliotheque>MaBib</bibliotheque>
+  </body>
+</albums>
+";
+
+    $web_client = $this->mock()
+                       ->whenCalled('postData')
+                       ->with(Class_AdminVar::get('Cvs2_API_URL'),
+                              ['xml' => strtr(base64_encode($xml),'+/','-_')])
+                       ->answers('<?xml version="1.0" encoding="UTF-8"?>
+<response>
+  <success>1</success>
+  <key>2f9c2123e68ba665f28117005e648de9</key>
+  <time>1678719291</time>
+  <action>liste_catalogue</action>
+  <data>
+    <total>1</total>
+    <catalogs>
+      <catalog>
+        <name>skilleos</name>
+        <start_date>2022-07-29</start_date>
+        <finish_date>2024-01-15</finish_date>
+      </catalog>
+    </catalogs>
+  </data>
+</response>
+')
+                       ->whenCalled('postData')
+                       ->with(Class_AdminVar::get('Cvs2_API_URL'),
+                              ['xml' => strtr(base64_encode($acces_direct_xml),'+/','-_')])
+                       ->answers(file_get_contents(__DIR__.'/cvs_accesdirect.xml'))
+                       ->beStrict();
+
+    Class_WebService_Abstract::setHttpClient($web_client);
+  }
+
+
+  protected function setXmlContent(){
+    return $this->_xml_content = file_get_contents(__DIR__.'/cvs_accesdirect.xml');
+  }
+}
+
+
+
+
+class Cvs2SsoTestWithAccesDirect extends Cvs2SsoTestWithAccesDirectTestCase {
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/opac/modules/cvs2/album_id/123');
+  }
+
+
+
+  /** @test */
+  public function shouldRedirectToAccesDirectLink() {
+    $this->assertXPathContentContains('//script',
+                                      'https://moncompte.skilleos.com/sign/cvs?publisher=skilleos&tokenid=7fc4a27dab81b165385828d94c69ca5b&docid=138&categoryid=12&clientid=12345&bibid=9876&bibname=MEL-autoformation&dend=1680781904&prenom=oai&nom=LOGINTEST&email=noreply-1017992%40mediatheques.fr');
+  }
+}
+
+
+
+
+class Cvs2SsoTestWithoutUsergroup extends Cvs2SsoTestWithAccesDirectTestCase {
+
+  protected $_notice;
+
+
+  public function setUp() {
+    parent::setUp();
+    $this->_user->setUserGroups([]);
+    $this->_buildTemplateProfil(['id'=>1]);
+
+    Class_TypeDoc::findOrCreate('Cvs2_Skilleos', 'Ressource Numérique Catalogue (Skilleos)');
+
+    $album =
+      $this->fixture(Class_Album::class,
+                     ['id' => 789798,
+                      'type_doc_id' => 'Cvs2_Skilleos',
+                      'titre' => 'Apprendre le chinois',
+                      'provenance' => 'Cvs2']);
+
+    $album
+      ->beValidated()
+      ->index();
+
+    $id = $album->getNoticeId();
+
+    $this->dispatch('/noticeajax/media/id/' . $id);
+  }
+
+
+  /** @test */
+  public function shouldRedirectToAccesDirectLink() {
+    $this->assertXPathContentContains('//div[@id="resnum"]/p',
+                                      utf8_encode('Vous devez être connecté sous un compte avec abonnement valide pour pouvoir accéder à la ressource'),
+                                      $this->_response->getBody());
+  }
+}
+
+
+
+
+class Cvs2HarvesterTest extends Cvs2ActivatedTestCase {
+
+  protected string $_log_content = '';
+  protected $_cvs;
+
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->_cvs  = (new Cvs2_Harvester('tout'))
+      ->setLogger($this->mock()
+                  ->whenCalled('log')
+                  ->willDo(function($message) { $this->_log_content .= $message . "\n"; }));
+
+
+    ZendAfi_Auth::getInstance()->clearIdentity();
+
+    $xml2 = $this->_cvs->run();
+  }
+
+
+  public function tearDown(){
+    $this->_cvs = null;
+    Class_WebService_Abstract::setHttpClient(null);
+    (new Storm_FileSystem_Disk)->deleteFilesAt('userfiles/cvs2_xml_responses');
+    parent::tearDown();
+  }
+
+
+  protected function _setWebClient(){
+    $web_client = $this->mock();
+    $web_client
+      ->whenCalled('postData')
+      ->willDo(function($element) use ($web_client){
+        $cvs1_xml = file_get_contents(__DIR__ . '/catalogue_tout_page_1.xml');
+        $cvs3_xml = file_get_contents(__DIR__ . '/catalogue_tout_page_3.xml');
+        $cvs51_xml = file_get_contents(__DIR__ . '/catalogue_tout_page_51.xml');
+        switch ($web_client->methodCallCount('postData')){
+          case 1: return $cvs1_xml;
+          case 2: return $cvs3_xml;
+          case 3: return $cvs51_xml;
+          default: return '';
+        }
+      });
+
+    Class_WebService_Abstract::setHttpClient($web_client);
+  }
+
+
+  protected function _setXmlContent(){
+    $this->_xml_content = file_get_contents(__DIR__ . '/catalogue_tout_page_1.xml');
+  }
+
+
+  /** @test */
+  public function logShouldContainsDebutMoissonage() {
+    $this->assertContains('Cvs2 : Début moissonnage CVS pour tout à ', $this->_log_content);
+  }
+
+
+  /** @test */
+  public function logShouldContainsFinDuPremierMoissonage() {
+    $this->assertContains('Fin du moissonnage', $this->_log_content);
+  }
+
+
+   /** @test */
+   public function file1DotXmlContentShouldBeEqualToExportDotxml() {
+     $this->assertEquals((new Storm_FileSystem_Disk)->fileGetContents(__DIR__.'/catalogue_tout_page_1.xml'),
+                         (new Storm_FileSystem_Disk)->fileGetContents('userfiles/cvs2_xml_responses/Cvs2_tout_1.xml'));
+   }
+
+
+   /** @test */
+   public function harvestLogShouldBeSaved() {
+     $this->assertNotNull(Class_WebService_HarvestLog::findFirstBy(['type_doc' => 'Cvs2_tout']));
+   }
+}
+
+
+
+
+class Cvs2ImporterTest extends Cvs2ActivatedTestCase {
+
+  protected string $_log_content = '';
+  protected $_cvs;
+  protected $_file_system;
+
+
+  public function setUp() {
+    parent::setUp();
+    $this->_file_system = new Storm_FileSystem_Volatile;
+    $this->_file_system->mkdir(USERFILESPATH);
+    $this->_file_system->mkdir(USERFILESPATH.'/cvs2_xml_responses');
+    $this->_file_system->filePutContents(USERFILESPATH.'/cvs2_xml_responses/Cvs2_tout_1.xml',file_get_contents(__DIR__ . '/catalogue_tout_page_1.xml'));
+
+    Cvs2_Importer::setFileSystem($this->_file_system);
+    $this->_cvs  = (new Cvs2_Importer)
+      ->setLogger($this->mock()
+                  ->whenCalled('log')
+                  ->willDo(function($message) { $this->_log_content .= $message . "\n"; }));
+
+
+    ZendAfi_Auth::getInstance()->clearIdentity();
+
+    $this->fixture(Class_Album::class,
+                   ['id' => 789789,
+                    'titre' => 'Do not delete me',
+                    'type_doc_id' => 'Cvs2_Izneo',
+                    'provenance' => 'Cvs2']);
+
+    $xml2 = $this->_cvs->run();
+  }
+
+
+  public function tearDown(){
+    $this->_file_system = null;
+    Cvs2_Importer::setFileSystem($this->_file_system);
+    $this->_cvs = null;
+    parent::tearDown();
+  }
+
+
+  /** @test */
+  public function albumDoNotDeleteMeShouldNotBeDeleted() {
+    $this->assertNotNull(Class_Album::find(789789));
+  }
+
+
+  /** @test */
+  public function logShouldContainsDebutPremierImport() {
+    $this->assertContains('Cvs2 : Début import CVS à ', $this->_log_content);
+  }
+
+
+  /** @test */
+  public function logShouldContainsFinDuPremierMoissonage() {
+    $this->assertContains('Fin import', $this->_log_content);
+  }
+
+
+  /** @test */
+  public function logShouldContainsTraitemenDuFichierAnd3Files() {
+    $this->assertContains('Traitement du fichier : Cvs2_tout_', $this->_log_content);
+    $this->assertContains('1/1', $this->_log_content);
+  }
+
+
+  /** @test */
+  public function albumsCountShouldBeSix() {
+    $this->assertEquals(6, Class_Album::countBy(['provenance' => 'Cvs2']));
+  }
+
+
+  /** @test */
+  public function edikaShouldBeCreated() {
+    $album = Class_Album::findFirstBy(['titre' => 'Edika']);
+    $this->assertNotNull($album);
+    return $album;
+  }
+
+
+  /** @test
+   * @depends edikaShouldBeCreated
+   */
+  public function albumOneDescriptionShouldBe($album) {
+    $this->assertEquals('Laissez-vous transporter dans une autre dimension : celle des
+          profondeurs insondables de l\'imagination débordante d\'édika, un des
+          auteurs les plus emblèmatiques de Fluide Glacial. Albums après
+          albums, il est devenu l\'icône de l\'humour surréaliste en BD, des
+          histoires sans chute et des Blougous à sens giratoire inversé pour le
+          plus grand...',$album->getDescription());
+  }
+
+
+  /** @test
+   * @depends edikaShouldBeCreated
+   */
+  public function albumOneIdOrigineShouldBe977481($album) {
+    $this->assertEquals('977481',$album->getIdOrigine());
+  }
+
+  /** @test
+   * @depends edikaShouldBeCreated
+   */
+  public function albumOneIdDroitsShouldBeInterdit($album) {
+    $this->assertEquals('Interdit -16 ans',$album->getDroits());
+  }
+
+
+  /** @test
+   * @depends edikaShouldBeCreated
+   */
+  public function albumOneMatieresShouldBeHumourAndBandesDessinees($album) {
+    $this->assertEquals(['Humour', 'Bandes dessinées'],
+                        $album->getMatieresLibelle());
+  }
+
+
+  /** @test
+   * @depends edikaShouldBeCreated
+   */
+  public function albumOneTypeDocShouldBeCvs2Izneo($album) {
+    $this->assertEquals('Cvs2_Izneo', $album->getTypeDocId());
+  }
+
+
+  /** @test
+   * @depends edikaShouldBeCreated
+   */
+  public function albumOneProvenanceShouldBeCvs2($album) {
+    $this->assertEquals('Cvs2', $album->getProvenance());
+  }
+
+
+  /** @test
+   * @depends edikaShouldBeCreated
+   */
+  public function albumOneEditeurShouldBeFluideGlacial($album) {
+    $this->assertEquals(['Fluide Glacial'],$album->getEditors());
+  }
+
+
+  /** @test
+   * @depends edikaShouldBeCreated
+   */
+  public function albumOneExternalUriShouldBeAlbum($album) {
+    $this->assertEquals('/album/977481',$album->getExternalUri());
+  }
+
+
+  /** @test */
+  public function afterEffectsCS6ShouldBeCreated() {
+    $album = Class_Album::findFirstBy(['titre' => 'After Effects CS6 : les Fondamentaux']);
+    $this->assertNotNull($album);
+    return $album;
+  }
+
+
+  /** @test
+   * @depends afterEffectsCS6ShouldBeCreated
+   */
+  public function albumThreeSousTitreShouldBeApprendre($album) {
+    $this->assertEquals('Apprendre les bases de After Effects CS6 en ligne',$album->getSousTitre());
+  }
+
+
+  /** @test
+   * @depends afterEffectsCS6ShouldBeCreated
+   */
+  public function albumThreeIdOrigineShouldBe977481($album) {
+    $this->assertEquals('851829',$album->getIdOrigine());
+  }
+
+
+  /** @test
+   * @depends afterEffectsCS6ShouldBeCreated
+   */
+  public function albumThreeAnneeShouldBe2023($album) {
+    $this->assertEquals('2023', $album->getAnnee());
+  }
+
+
+  /** @test
+   * @depends afterEffectsCS6ShouldBeCreated
+   */
+  public function albumThreeDurationShouldBeExpectedDate($album) {
+    $this->assertEquals('21 h 24 min', $album->getDuration());
+  }
+
+
+  /** @test
+   * @depends afterEffectsCS6ShouldBeCreated
+   */
+  public function albumThreeMatiereShouldBeExpectedDate($album) {
+    $this->assertEquals(['Bureautique', 'Auto-formation'],
+                        $album->getMatieresLibelle());
+  }
+
+
+  /** @test
+   * @depends afterEffectsCS6ShouldBeCreated
+   */
+  public function indexAfterEffecsShouldCreateRecord($album) {
+    $album->index();
+    $record = $album->getNotice();
+    $this->assertNotNull($record);
+    return $record;
+  }
+
+
+  /** @test
+   * @depends indexAfterEffecsShouldCreateRecord
+   */
+  public function recordDocTypeLabelShouldBeSkilleos($record) {
+    $this->assertEquals('Skilleos', $record->getTypeDocLabel());
+  }
+
+
+  /** @test
+   * @depends indexAfterEffecsShouldCreateRecord
+   */
+  public function recordDocTypeIdShouldBeCvs2Skilleos($record) {
+    $this->assertEquals('Cvs2_Skilleos', $record->getTypeDoc());
+  }
+}
+
+
+
+
+abstract class Cvs2ActivatedBorrowerLoggedTestCase extends Cvs2ActivatedTestCase {
+  public function setUp() {
+    parent::setUp();
+
+    Class_AdminVar::newInstanceWithId('Cvs2_LOGINTEST',
+                                      ['valeur' => '']);
+
+    $this->_user->beAbonneSIGB();
+    ZendAfi_Auth::getInstance()->logUser($this->_user);
+  }
+}
+
+
+
+
+
+class Cvs2RechercheControllerSimpleActionWithCvs2ActivatedAndPreferencesHiddenTest
+  extends Cvs2ActivatedBorrowerLoggedTestCase {
+
+  public function setUp() {
+    parent::setUp();
+
+    Class_Profil::getCurrentProfil()
+      ->setCfgModules(['recherche' =>
+                       ['resultatsimple' => [ 'cvs_display_position' => '0']]]);
+
+    $this->dispatch('/recherche/simple/expressionRecherche/pomme/tri/alpha_auteur');
+  }
+
+
+  /** @test */
+  public function simpleContentShouldNotContainsCVSDiv() {
+    $this->assertNotXPath('//div[@class="conteneur_simple"]//div[contains(@class,"cvs_container")]');
+  }
+}
+
+
+
+
+
+class Cvs2DocumentTypesVariableEditTest extends Cvs2ActivatedTestCase {
+  protected function _setXmlContent(){
+    return $this->_xml_content = file_get_contents(__DIR__.'/cvs_liste_catalogue.xml');
+  }
+
+  public function setUp() {
+    parent::setUp();
+    Class_AdminVar::set('Cvs2_CATALOGUES',
+                        json_encode(['id' => ['cinema'],
+                                     'acces_direct_enabled' => ['']]));
+
+    $this->dispatch('/admin/index/adminvaredit/cle/Cvs2_CATALOGUES');
+  }
+
+
+  /** @test */
+  public function scriptShouldContains2Fields() {
+    $this->assertXPathContentContains('//script',
+                                      '"fields":[{"name":"id","label":"Identifiant catalogue"},{"name":"acces_direct_enabled","label":"Acces direct","type":"checkbox","value":"1"}],"values":{"id":["cinema"],"acces_direct_enabled":[""]}');
+  }
+
+
+  /** @test */
+  public function DocTypeHandledForCvs2CinemaShouldBeTrue() {
+    $this->assertTrue(Cvs2_Config::getInstance()->isDocTypeHandled('Cvs2_cinema'));
+  }
+}
+
+
+
+
+class Cvs2DocumentTypesVariablePostWithValidationErrorTest extends Admin_AbstractControllerTestCase {
+
+  public function setUp() {
+    parent::setUp();
+    Class_AdminVar::set('Cvs2_CATALOGUES', '');
+
+    $this->postDispatch('/admin/index/adminvaredit/cle/Cvs2_CATALOGUES',
+                        ['id' => ['izneo', 'izneo']]);
+  }
+
+
+  /** @test */
+  public function cvsCatalogueShouldNotBeUpdated() {
+    Class_AdminVar::clearCache();
+    $this->assertEquals('', Class_AdminVar::get('Cvs2_CATALOGUES'));
+  }
+
+
+  /** @test */
+  public function pageShouldContainsErrorLesCataloguesNeDoiventPas() {
+    $this->assertXPathContentContains('//ul[@class="errors"]', 'Attention, les catalogues ne doivent pas être dupliqués');
+  }
+
+  /** @test */
+  public function pageShouldContainsError() {
+    $this->assertXPathContentContains('//ul[@class="errors"]',
+                                      'Le catalogue izneo ne fait pas parti des catalogues moissonables');
+  }
+}
+
+
+
+
+class Cvs2DocumentTypesVariablePostWithValidationSuccessTest extends Cvs2ActivatedTestCase {
+
+  public function setUp() {
+    parent::setUp();
+    Class_AdminVar::set('Cvs2_CATALOGUES', '');
+
+    $this->postDispatch('/admin/index/adminvaredit/cle/Cvs2_CATALOGUES',
+                        ['id' => ['tout']]);
+  }
+
+  protected function _setXmlContent(){
+    return $this->_xml_content = file_get_contents(__DIR__.'/cvs_liste_catalogue.xml');
+  }
+
+
+  /** @test */
+  public function cvsCatalogueShouldContains() {
+    Class_AdminVar::clearCache();
+    $this->assertEquals('{"id":["tout"],"acces_direct_enabled":[]}', Class_AdminVar::get('Cvs2_CATALOGUES'));
+  }
+
+
+  /** @test */
+  public function resultShouldRedirect() {
+    $this->assertRedirect();
+  }
+}
+
+
+
+
+abstract class Cvs2BatchTestCase extends Cvs2ActivatedTestCase {
+  protected string $_log_content ='';
+
+  public function setUp() {
+    parent::setUp();
+    Class_AdminVar::set('Cvs2_CATALOGUES','{"id":["skilleos"],
+                                           "acces_direct_enabled":["1"]}');
+
+    $this->_batch();
+
+    (new Cvs2_Batch(Cvs2_Config::getInstance()))
+      ->setLogger($this->mock()
+                  ->whenCalled('log')
+                  ->willDo(function($message) { $this->_log_content .= $message . "\n"; }))
+      ->run();
+  }
+
+
+  public function tearDown(){
+    Class_AdminVar::set('Cvs2_CATALOGUES','');
+    (new Cvs2_Batch(Cvs2_Config::getInstance()))
+      ->setLogger(null);
+    parent::tearDown();
+  }
+
+
+  protected function _batch(){
+  }
+
+
+  protected function _setWebClient(){
+    $xml = "<?xml version=\"1.0\" encoding=\"utf-8\"?>
+<albums>
+  <header>
+    <bmid>22223</bmid>
+    <sourceid>22225</sourceid>
+    <key>73844495168ecc1aa59ba96c8cea6e82</key>
+    <time>1369640315</time>
+    <adhid>cvs_login_test</adhid>
+    <action>liste_catalogue</action>
+  </header>
+  <body/>
+</albums>
+";
+
+
+    $web_client = $this->mock()
+                       ->whenCalled('postData')
+                       ->with(Class_AdminVar::get('Cvs2_API_URL'),
+                              ['xml' => strtr(base64_encode($xml),'+/','-_')])
+                       ->answers('<?xml version="1.0" encoding="UTF-8"?>
+<response>
+  <success>1</success>
+  <key>2f9c2123e68ba665f28117005e648de9</key>
+  <time>1678719291</time>
+  <action>liste_catalogue</action>
+  <data>
+    <total>1</total>
+    <catalogs>
+      <catalog>
+        <name>skilleos</name>
+        <start_date>2022-07-29</start_date>
+        <finish_date>2024-01-15</finish_date>
+      </catalog>
+    </catalogs>
+  </data>
+</response>
+')
+                       ->beStrict();
+
+    Class_WebService_Abstract::setHttpClient($web_client);
+  }
+}
+
+
+
+
+class Cvs2FirstBatchTest extends Cvs2BatchTestCase {
+   /** @test */
+   public function LastRunDateShouldBeInitDate() {
+     $this->assertContains('Depuis 1970-01-01', $this->_log_content);
+   }
+}
+
+
+
+
+class Cvs2BatchWithPreviousBatchTest extends Cvs2BatchTestCase {
+  protected function _batch(){
+    $this->fixture(Class_Batch::class,
+                   ['id' => 14,
+                    'type' => 'Cvs2_Batch',
+                    'last_run' => '2023-01-01']);
+  }
+
+   /** @test */
+   public function LastRunDateShouldBeInitDate() {
+     $this->assertContains('Depuis 2023-01-01', $this->_log_content);
+   }
+}
diff --git a/library/digital_resources/Cvs2/tests/catalogue_tout_page_1.xml b/library/digital_resources/Cvs2/tests/catalogue_tout_page_1.xml
new file mode 100644
index 00000000000..5552657c165
--- /dev/null
+++ b/library/digital_resources/Cvs2/tests/catalogue_tout_page_1.xml
@@ -0,0 +1,386 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<response>
+  <success>1</success>
+  <key>ffcb3bf15a7dd556193fc1fc84a40f49</key>
+  <time>1678719644</time>
+  <action>catalogue</action>
+  <cataloguename>tout</cataloguename>
+  <page>1</page>
+  <nombre_par_page>100</nombre_par_page>
+  <from>1970-01-01</from>
+  <until>2023-03-13</until>
+  <cache>true</cache>
+  <data>
+    <success>1</success>
+    <albumsmin>0</albumsmin>
+    <albumsmax>100</albumsmax>
+    <albumspagesize>100</albumspagesize>
+    <totalalbums>29449</totalalbums>
+    <albums>
+      <album>
+        <pilier>4</pilier>
+        <catalogue>Izneo</catalogue>
+        <jeunesse>0</jeunesse>
+        <docid>977481</docid>
+        <albumname>Edika</albumname>
+        <stitle>Debiloff Proffondikoum - Tome 1</stitle>
+        <saison>1</saison>
+        <episode>0</episode>
+        <note>0</note>
+        <nombre_votants>0</nombre_votants>
+        <nombre_avis>0</nombre_avis>
+        <nombre_vus>15</nombre_vus>
+        <nombre_vus_mois>0</nombre_vus_mois>
+        <nombre_vus_7j>2</nombre_vus_7j>
+        <url>album&amp;docid=977481</url>
+        <uri>/album/977481</uri>
+        <year>0</year>
+        <datsortie>0000-00-00</datsortie>
+        <albumreleasedate>1981-09-01</albumreleasedate>
+        <albumreleasetime>00:00:00</albumreleasetime>
+        <datfin>9999-12-31</datfin>
+        <datouv>1981-09-01</datouv>
+        <datfer>9999-12-31</datfer>
+        <closed>0</closed>
+        <nopublish>0</nopublish>
+        <maxtimeint>0</maxtimeint>
+        <nb_pages>48</nb_pages>
+        <nbunit_docs>1</nbunit_docs>
+        <resume>
+          Laissez-vous transporter dans une autre dimension : celle des
+          profondeurs insondables de l'imagination débordante d'édika, un des
+          auteurs les plus emblèmatiques de Fluide Glacial. Albums après
+          albums, il est devenu l'icône de l'humour surréaliste en BD, des
+          histoires sans chute et des Blougous à sens giratoire inversé pour le
+          plus grand plaisir des lecteurs.
+        </resume>
+        <genres>
+          <genre>
+            <genrename>Humour</genrename>
+            <themename>Bandes dessinées</themename>
+          </genre>
+        </genres>
+        <subtitles>
+          <subtitle/>
+        </subtitles>
+        <haspicture>1</haspicture>
+        <images>
+          <album50image>
+            <src>https://media.mediatheques.fr/res/medias/album/50/481/977481.jpg</src>
+          </album50image>
+          <albumimage>
+            <src>https://media.mediatheques.fr/res/medias/album/100/481/977481.jpg</src>
+          </albumimage>
+          <album133image>
+            <src>https://media.mediatheques.fr/res/medias/album/200p/481/977481.jpg</src>
+          </album133image>
+          <albumbigimage>
+            <src>https://media.mediatheques.fr/res/medias/album/400p/481/977481.jpg</src>
+          </albumbigimage>
+          <albumorigimage>
+            <src>https://media.mediatheques.fr/res/medias/album/400p/481/977481.jpg</src>
+          </albumorigimage>
+        </images>
+        <labelname>Fluide Glacial</labelname>
+        <artistname>Edika</artistname>
+        <artistrole>Auteur</artistrole>
+        <artists>
+          <artist>
+            <haspicture>0</haspicture>
+            <artist_images/>
+            <artistname>Edika</artistname>
+            <artistrole>Auteur</artistrole>
+          </artist>
+        </artists>
+        <refpublicid>2</refpublicid>
+        <refpublicname>Interdit -16 ans</refpublicname>
+        <langueid>0</langueid>
+        <langues>
+          <langue>Français</langue>
+        </langues>
+      </album>
+      <album>
+        <pilier>2</pilier>
+        <catalogue>cinema</catalogue>
+        <jeunesse>0</jeunesse>
+        <docid>69</docid>
+        <albumname>La Boîte à tartines</albumname>
+        <stitle></stitle>
+        <saison>0</saison>
+        <episode>0</episode>
+        <note>8</note>
+        <nombre_votants>2</nombre_votants>
+        <nombre_avis>2</nombre_avis>
+        <nombre_vus>178</nombre_vus>
+        <nombre_vus_mois>2</nombre_vus_mois>
+        <nombre_vus_7j>2</nombre_vus_7j>
+        <url>album&amp;docid=69</url>
+        <uri>/album/69</uri>
+        <year>2007</year>
+        <datsortie>0000-00-00</datsortie>
+        <albumreleasedate>0000-00-00</albumreleasedate>
+        <albumreleasetime>00:00:00</albumreleasetime>
+        <datfin>9999-12-31</datfin>
+        <datouv>2009-07-21</datouv>
+        <datfer>9999-12-31</datfer>
+        <closed>0</closed>
+        <nopublish>0</nopublish>
+        <maxtimeint>3220</maxtimeint>
+        <nb_pages>0</nb_pages>
+        <nbunit_docs>1</nbunit_docs>
+        <resume>&lt;p style="text-align: justify;"&gt;« On dit qu’ouvrir une boîte, c’est toujours prendre un risque ». Je me suis mise au travail en suivant le mouvement d’une pensée, qui s’attache au petit rien pour envisager le grand tout, dans la perspective de raconter une « histoire » qui part de mon appartement et va à la rencontre d’un pays.&lt;/p&gt;&#13;
+&lt;p style="text-align: justify;"&gt;Je me suis saisie de cet objet du quotidien, commun à l’ensemble de la population, comme un miroir tendu à ceux que je rencontrais. Que signifie un usage à priori insignifiant ? Pourquoi nos sociétés industrielles induisent-elles un sens de l’ordre et de l’organisation ? Comment devient-on pragmatique ? Questions et idées se sont échappées de la boîte, cette phrase de Karl Marx les résumant presque toutes : « L’objet le plus naturel contient, si dissipé soit elle, si faible, une trace politique, la présence plus ou moins mémorable de l’acte humain qui l’a produit, aménagé, utilisé… ».&lt;/p&gt;&#13;
+&lt;p style="text-align: justify;"&gt;La boîte à tartines contient elle aussi une trace politique…&lt;/p&gt;</resume>
+        <genres>
+          <genre>
+            <genrename>Documentaire</genrename>
+            <themename>Documentaire</themename>
+          </genre>
+        </genres>
+        <subtitles>
+          <subtitle>
+            <id>8</id>
+            <title>Français partiel</title>
+          </subtitle>
+        </subtitles>
+        <haspicture>1</haspicture>
+        <images>
+          <album50image>
+            <src>https://media.mediatheques.fr/res/medias/album/50/069/69.jpg</src>
+          </album50image>
+          <albumimage>
+            <src>https://media.mediatheques.fr/res/medias/album/100/069/69.jpg</src>
+          </albumimage>
+          <album133image>
+            <src>https://media.mediatheques.fr/res/medias/album/200p/069/69.jpg</src>
+          </album133image>
+          <albumbigimage>
+            <src>https://media.mediatheques.fr/res/medias/album/400p/069/69.jpg</src>
+          </albumbigimage>
+          <albumorigimage>
+            <src>https://media.mediatheques.fr/res/medias/album/400p/069/69.jpg</src>
+          </albumorigimage>
+        </images>
+        <labelname>Les Productions de L'Oeil Sauvage</labelname>
+        <artistname>Floriane Devigne</artistname>
+        <artistrole>Réalisateur</artistrole>
+        <artists>
+          <artist>
+            <haspicture>0</haspicture>
+            <artist_images/>
+            <artistname>Floriane Devigne</artistname>
+            <artistrole>Réalisateur</artistrole>
+          </artist>
+        </artists>
+        <refpublicid>0</refpublicid>
+        <refpublicname>Tout public</refpublicname>
+        <langueid>0</langueid>
+        <langues>
+          <langue>Français</langue>
+        </langues>
+      </album>
+      <album>
+        <pilier>3</pilier>
+        <catalogue>Skilleos</catalogue>
+        <jeunesse>0</jeunesse>
+        <docid>851829</docid>
+        <albumname>After Effects CS6 : les Fondamentaux</albumname>
+        <stitle>Apprendre les bases de After Effects CS6 en ligne</stitle>
+        <saison>0</saison>
+        <episode>0</episode>
+        <note>0</note>
+        <nombre_votants>0</nombre_votants>
+        <nombre_avis>0</nombre_avis>
+        <nombre_vus>231</nombre_vus>
+        <nombre_vus_mois>2</nombre_vus_mois>
+        <nombre_vus_7j>1</nombre_vus_7j>
+        <url>album&amp;docid=851829</url>
+        <uri>/album/851829</uri>
+        <year>2023</year>
+        <datsortie>0000-00-00</datsortie>
+        <albumreleasedate>2023-04-21</albumreleasedate>
+        <albumreleasetime>00:00:00</albumreleasetime>
+        <datfin>9999-12-31</datfin>
+        <datouv>0000-00-00</datouv>
+        <datfer>9999-12-31</datfer>
+        <closed>0</closed>
+        <nopublish>0</nopublish>
+        <maxtimeint>77064</maxtimeint>
+        <nb_pages>0</nb_pages>
+        <nbunit_docs>0</nbunit_docs>
+        <resume>&lt;p&gt;Vous voulez réaliser des montages vidéos et y ajouter des effets visuels ? Vous vous demandez comment fonctionne le logiciel Adobe After Effets CS6 ?&lt;/p&gt;&lt;p&gt;Apprenez, dans ce cours en ligne, à utiliser les bases de ce logiciel phare du traitement de vidéos et de la réalisation d'animations et lancez-vous dans le montage vidéo.&lt;/p&gt;&lt;p&gt;Anciennement connu en tant que logiciel de montage vidéo, Adobe After Effects est ensuite devenu un outil de composition et d'effets visuels considéré comme un pionnier dans l'animation graphique sur les ordinateurs personnels. Adobe After Effects est un logiciel complet dont l'utilisation nécessite un certain nombre de compétences. Vous en apprendrez les bases dans ce cours en ligne en suivant les enseignements du professeur Gilles Pfeiffer. Ainsi, après avoir appris à naviguer dans les différents menus et à prendre en main les fonctionnalités de bases comme les différents menus effets, calques, animation et autres, vous apprendrez à réaliser un projet de A à Z : de l'importation à l'exportation finale en passant par la manipulation d'un calque. Ensuite, vous apprendrez à créer des animations et à ajuster leurs paramètres ainsi qu'à exploiter les outils de gestion temporelle pour enrichir sa composition. Vous apprendrez également à utiliser les différents calques et masques ainsi qu'à appliquer des effets d'audios, de lumière ou encore de bruit à votre composition, puis, avant de terminer ce cours en ligne en apprenant à suivre un élément durant une animation, vous verrez comment maitriser les fonctionnalités 3D sur After Effects CS6. Ainsi, après avoir suivi ce cours, vous aurez acquis l'ensemble des connaissances de bases nécessaires pour commencer à réaliser votre premier montage vidéo et animation sur le logiciel Adobe After Effects CS6. Vous ne vous ferez donc pas de film en vous imaginant réaliser un montage vidéo avec des effets !&lt;/p&gt;</resume>
+        <genres>
+          <genre>
+            <genrename>Bureautique</genrename>
+            <themename>Auto-formation</themename>
+          </genre>
+        </genres>
+        <subtitles>
+          <subtitle/>
+        </subtitles>
+        <haspicture>1</haspicture>
+        <images>
+          <album50image>
+            <src>https://media.mediatheques.fr/res/medias/album/50/829/851829.jpg</src>
+          </album50image>
+          <albumimage>
+            <src>https://media.mediatheques.fr/res/medias/album/100/829/851829.jpg</src>
+          </albumimage>
+          <album133image>
+            <src>https://media.mediatheques.fr/res/medias/album/200c/829/851829.jpg</src>
+          </album133image>
+          <albumbigimage>
+            <src>https://media.mediatheques.fr/res/medias/album/400c/829/851829.jpg</src>
+          </albumbigimage>
+          <albumorigimage>
+            <src>https://media.mediatheques.fr/res/medias/album/400c/829/851829.jpg</src>
+          </albumorigimage>
+        </images>
+        <labelname>Skilleos</labelname>
+        <artistname>Gilles Pfeiffer</artistname>
+        <artistrole>Conférencier(e)/Intervenant(e)</artistrole>
+        <artists>
+          <artist>
+            <haspicture>0</haspicture>
+            <artist_images/>
+            <artistname>Gilles Pfeiffer</artistname>
+            <artistrole>Conférencier(e)/Intervenant(e)</artistrole>
+          </artist>
+        </artists>
+        <refpublicid>0</refpublicid>
+        <refpublicname>Tout public</refpublicname>
+        <langueid>0</langueid>
+        <langues></langues>
+      </album>
+      <album>
+        <pilier>3</pilier>
+        <catalogue>Assimil</catalogue>
+        <jeunesse>1</jeunesse>
+        <docid>1030279</docid>
+        <albumname>L'allemand - Deutsch</albumname>
+        <stitle></stitle>
+        <saison>0</saison>
+        <episode>0</episode>
+        <note>9</note>
+        <nombre_votants>21</nombre_votants>
+        <nombre_avis>14</nombre_avis>
+        <nombre_vus>5948</nombre_vus>
+        <nombre_vus_mois>191</nombre_vus_mois>
+        <nombre_vus_7j>53</nombre_vus_7j>
+        <url>album&amp;docid=1030279</url>
+        <uri>/album/1030279</uri>
+        <year>0</year>
+        <datsortie>0000-00-00</datsortie>
+        <albumreleasedate>0000-00-00</albumreleasedate>
+        <albumreleasetime>00:00:00</albumreleasetime>
+        <datfin>9999-12-31</datfin>
+        <datouv>0000-00-00</datouv>
+        <datfer>9999-12-31</datfer>
+        <closed>0</closed>
+        <nopublish>0</nopublish>
+        <maxtimeint>0</maxtimeint>
+        <nb_pages>0</nb_pages>
+        <nbunit_docs>0</nbunit_docs>
+        <resume>&lt;p class="dureeinfo"&gt;Apprentissage de la langue : Allemand - Débutants et Faux-débutants&lt;/p&gt;Vous avez choisi L’Allemand, dans la collection “Sans peine” d’Assimil pour votre apprentissage, et nous vous en félicitons ! En nous suivant attentivement – et régulièrement –, vous allez apprendre en quelques mois le vocabulaire de la langue courante, ainsi que les règles fondamentales de la grammaire. Très rapidement, cette belle langue vous semblera familière, grâce à la centaine de dialogues tirés de la vie quotidienne que nous vous présentons ici. Assimil vous propose sa méthode sur support 100% numérique. A la structure et la progression qui ont fait le succès des méthodes de langue de nos éditions, de nombreuses fonctions interactives ont été ajoutées pour une expérience inédite de l'auto-apprentissage. Privilégiant une approche par le dialogue, vous allez maitriser progressivement la compréhension orale et écrite. La progression grammaticale est également soigneusement étudiée tout au long des leçons et des exercices, et des révisions régulières pour consolider vos acquis. Avec la e-méthode, enregistrez-vous et comparez votre accent à la bonne prononciation, accédez facilement aux notes et remarques pendant l'apprentissage !</resume>
+        <genres>
+          <genre>
+            <genrename>Langues débutants et faux débutants / Assimil</genrename>
+            <themename>Auto-formation</themename>
+          </genre>
+        </genres>
+        <subtitles>
+          <subtitle/>
+        </subtitles>
+        <haspicture>1</haspicture>
+        <images>
+          <album50image>
+            <src>https://media.mediatheques.fr/res/medias/album/50/279/1030279.jpg</src>
+          </album50image>
+          <albumimage>
+            <src>https://media.mediatheques.fr/res/medias/album/100/279/1030279.jpg</src>
+          </albumimage>
+          <album133image>
+            <src>https://media.mediatheques.fr/res/medias/album/200c/279/1030279.jpg</src>
+          </album133image>
+          <albumbigimage>
+            <src>https://media.mediatheques.fr/res/medias/album/400c/279/1030279.jpg</src>
+          </albumbigimage>
+          <albumorigimage>
+            <src>https://media.mediatheques.fr/res/medias/album/400c/279/1030279.jpg</src>
+          </albumorigimage>
+        </images>
+        <labelname>Assimil S.A.S.</labelname>
+        <artistname></artistname>
+        <artistrole></artistrole>
+        <artists>
+          <artist/>
+        </artists>
+        <refpublicid>0</refpublicid>
+        <refpublicname>Tout public</refpublicname>
+        <langueid>0</langueid>
+        <langues>
+          <langue>Français</langue>
+        </langues>
+      </album>
+    </albums>
+    <facettes>
+      <pilier>
+        <count>13947</count>
+        <id>2</id>
+        <name>Cinéma</name>
+      </pilier>
+      <theme>
+        <count>3408</count>
+        <id>20</id>
+        <name>Documentaire</name>
+        <pilierid>2</pilierid>
+      </theme>
+      <genre>
+        <count>3195</count>
+        <id>59</id>
+        <name>Documentaire</name>
+        <pilierid>2</pilierid>
+        <themeid>20</themeid>
+        <haspicture>0</haspicture>
+        <summary></summary>
+      </genre>
+      <reference>
+        <count>2464</count>
+        <id>2</id>
+        <name>Festivals Internationaux et Prix divers</name>
+        <categoryid>1</categoryid>
+        <categoryname>Récompenses</categoryname>
+      </reference>
+      <soustitre>
+        <count>263</count>
+        <id>8</id>
+        <name>Français partiel</name>
+      </soustitre>
+      <annee>
+        <count>251</count>
+        <id>2007</id>
+        <name>2007</name>
+      </annee>
+      <pays>
+        <count>481</count>
+        <id>56</id>
+        <name>Belgique</name>
+      </pays>
+      <public>
+        <count>24419</count>
+        <id>0</id>
+        <name>Tout public</name>
+      </public>
+      <artiste>
+        <count>2</count>
+        <id>139</id>
+        <name>Floriane Devigne</name>
+      </artiste>
+      <statut>
+        <count>29449</count>
+        <id>0</id>
+        <name>Publiés</name>
+      </statut>
+    </facettes>
+  </data>
+</response>
diff --git a/library/digital_resources/Cvs2/tests/catalogue_tout_page_3.xml b/library/digital_resources/Cvs2/tests/catalogue_tout_page_3.xml
new file mode 100644
index 00000000000..72ef1b4a398
--- /dev/null
+++ b/library/digital_resources/Cvs2/tests/catalogue_tout_page_3.xml
@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<response>
+  <success>1</success>
+  <key>1823effedc4e89aa62620c4a60fc4082</key>
+  <time>1678720120</time>
+  <action>catalogue</action>
+  <cataloguename>tout</cataloguename>
+  <page>2</page>
+  <nombre_par_page>100</nombre_par_page>
+  <from>1970-01-01</from>
+  <until>2023-04-01</until>
+  <cache>true</cache>
+  <data>
+    <success>1</success>
+    <albumsmin>100</albumsmin>
+    <albumsmax>200</albumsmax>
+    <albumspagesize>100</albumspagesize>
+    <totalalbums>29449</totalalbums>
+    <albums>
+      <album>
+        <pilier>4</pilier>
+        <catalogue>Izneo</catalogue>
+        <jeunesse>1</jeunesse>
+        <docid>2474839</docid>
+        <albumname>Robin Dubois</albumname>
+        <stitle>Robin Dubois - Tome 11 - Ca va pas la tête ? - Tome 11</stitle>
+        <saison>11</saison>
+        <episode>0</episode>
+        <note>0</note>
+        <nombre_votants>0</nombre_votants>
+        <nombre_avis>0</nombre_avis>
+        <nombre_vus>13</nombre_vus>
+        <nombre_vus_mois>0</nombre_vus_mois>
+        <nombre_vus_7j>1</nombre_vus_7j>
+        <url>album&amp;docid=2474839</url>
+        <uri>/album/2474839</uri>
+        <year>0</year>
+        <datsortie>0000-00-00</datsortie>
+        <albumreleasedate>2000-11-02</albumreleasedate>
+        <albumreleasetime>00:00:00</albumreleasetime>
+        <datfin>9999-12-31</datfin>
+        <datouv>2000-11-02</datouv>
+        <datfer>9999-12-31</datfer>
+        <closed>0</closed>
+        <nopublish>0</nopublish>
+        <maxtimeint>0</maxtimeint>
+        <nb_pages>48</nb_pages>
+        <nbunit_docs>1</nbunit_docs>
+        <resume>Pas de résumé disponible pour cet album, désolé.</resume>
+        <genres>
+          <genre>
+            <genrename>Humour</genrename>
+            <themename>Bandes dessinées</themename>
+          </genre>
+          <genre>
+            <genrename>Jeunesse</genrename>
+            <themename>Bandes dessinées</themename>
+          </genre>
+        </genres>
+        <subtitles>
+          <subtitle/>
+        </subtitles>
+        <haspicture>1</haspicture>
+        <images>
+          <album50image>
+            <src>https://media.mediatheques.fr/res/medias/album/50/839/2474839.jpg</src>
+          </album50image>
+          <albumimage>
+            <src>https://media.mediatheques.fr/res/medias/album/100/839/2474839.jpg</src>
+          </albumimage>
+          <album133image>
+            <src>https://media.mediatheques.fr/res/medias/album/200p/839/2474839.jpg</src>
+          </album133image>
+          <albumbigimage>
+            <src>https://media.mediatheques.fr/res/medias/album/400p/839/2474839.jpg</src>
+          </albumbigimage>
+          <albumorigimage>
+            <src>https://media.mediatheques.fr/res/medias/album/400p/839/2474839.jpg</src>
+          </albumorigimage>
+        </images>
+        <labelname>Le Lombard</labelname>
+        <artistname>De Groot</artistname>
+        <artistrole>Auteur</artistrole>
+        <artists>
+          <artist>
+            <haspicture>0</haspicture>
+            <artist_images/>
+            <artistname>De Groot</artistname>
+            <artistrole>Auteur</artistrole>
+          </artist>
+        </artists>
+        <refpublicid>0</refpublicid>
+        <refpublicname>Tout public</refpublicname>
+        <langueid>0</langueid>
+        <langues>
+          <langue>Français</langue>
+        </langues>
+      </album>
+    </albums>
+    <facettes/>
+  </data>
+</response>
diff --git a/library/digital_resources/Cvs2/tests/catalogue_tout_page_51.xml b/library/digital_resources/Cvs2/tests/catalogue_tout_page_51.xml
new file mode 100644
index 00000000000..20ca7f4fafa
--- /dev/null
+++ b/library/digital_resources/Cvs2/tests/catalogue_tout_page_51.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<response>
+  <success>1</success>
+  <key>fb20fe217b91381146380c0389e8b72f</key>
+  <time>1678720316</time>
+  <action>catalogue</action>
+  <cataloguename>tout</cataloguename>
+  <page>50</page>
+  <nombre_par_page>100</nombre_par_page>
+  <from>1970-01-01</from>
+  <until>2023-04-01</until>
+  <cache>true</cache>
+  <data>
+    <success>1</success>
+    <albumsmin>4900</albumsmin>
+    <albumsmax>5000</albumsmax>
+    <albumspagesize>0</albumspagesize>
+    <totalalbums>29449</totalalbums>
+    <albums>
+    </albums>
+    <facettes>
+    </facettes>
+  </data>
+</response>
diff --git a/library/digital_resources/Cvs2/tests/cvs_accesdirect.xml b/library/digital_resources/Cvs2/tests/cvs_accesdirect.xml
new file mode 100644
index 00000000000..222f1bcfbb0
--- /dev/null
+++ b/library/digital_resources/Cvs2/tests/cvs_accesdirect.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<response>
+  <success>1</success>
+  <key>69b5f99b4e3b1cdc9d80e0038844f55e</key>
+  <time>1680781728</time>
+  <action>acces_direct</action>
+  <cataloguename>skilleos</cataloguename>
+  <querystring>851829</querystring>
+  <data>
+    <redirect>https://moncompte.skilleos.com/sign/cvs?publisher=skilleos&amp;tokenid=7fc4a27dab81b165385828d94c69ca5b&amp;docid=138&amp;categoryid=12&amp;clientid=12345&amp;bibid=9876&amp;bibname=MEL-autoformation&amp;dend=1680781904&amp;prenom=oai&amp;nom=LOGINTEST&amp;email=noreply-1017992%40mediatheques.fr</redirect>
+  </data>
+</response>
diff --git a/library/digital_resources/Cvs2/tests/cvs_liste_catalogue.xml b/library/digital_resources/Cvs2/tests/cvs_liste_catalogue.xml
new file mode 100644
index 00000000000..835112d8536
--- /dev/null
+++ b/library/digital_resources/Cvs2/tests/cvs_liste_catalogue.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<response>
+  <success>1</success>
+  <key>2f9c2123e68ba665f28117005e648de9</key>
+  <time>1678719291</time>
+  <action>liste_catalogue</action>
+  <data>
+    <total>1</total>
+    <catalogs>
+      <catalog>
+        <name>tout</name>
+        <start_date>2022-07-29</start_date>
+        <finish_date>2024-01-15</finish_date>
+      </catalog>
+      <catalog>
+        <name>cinema</name>
+        <start_date>2022-07-29</start_date>
+        <finish_date>2024-01-15</finish_date>
+      </catalog>
+    </catalogs>
+  </data>
+</response>
diff --git a/library/digital_resources/Cyberlibris/Config.php b/library/digital_resources/Cyberlibris/Config.php
index c4385f30490..ab80eef12fa 100644
--- a/library/digital_resources/Cyberlibris/Config.php
+++ b/library/digital_resources/Cyberlibris/Config.php
@@ -103,8 +103,8 @@ class Cyberlibris_Config extends Class_DigitalResource_Config {
       : static::CYBERLIBRIS_HARVEST_URL;
   }
 
-  public function newOAIClient(){
-    return parent::newOAIClient()
+  public function newClient(){
+    return parent::newClient()
       ->setOAIHandler($this->getHarvestUrl());
   }
 
diff --git a/library/digital_resources/Cyberlibris/Harvester.php b/library/digital_resources/Cyberlibris/Harvester.php
index 0b2baa15d60..1d2d937baee 100644
--- a/library/digital_resources/Cyberlibris/Harvester.php
+++ b/library/digital_resources/Cyberlibris/Harvester.php
@@ -20,4 +20,4 @@
  */
 
 
-class Cyberlibris_Harvester extends Class_DigitalResource_Harvester_OAI {}
\ No newline at end of file
+class Cyberlibris_Harvester extends Class_DigitalResource_Harvester_XML {}
diff --git a/library/digital_resources/Cyberlibris/Importer.php b/library/digital_resources/Cyberlibris/Importer.php
index 955f95fb8f7..5140aea6aa6 100644
--- a/library/digital_resources/Cyberlibris/Importer.php
+++ b/library/digital_resources/Cyberlibris/Importer.php
@@ -20,19 +20,16 @@
  */
 
 
-class Cyberlibris_Importer extends Class_DigitalResource_Importer_OAI {
-
-  use Trait_StormFileSystem;
-
+class Cyberlibris_Importer extends Class_DigitalResource_Importer_XML {
 
   protected function _work() {
     $this->_log($this->_('%s : création des albums.',
                                      $this->_name));
 
-    if (!$this->getFileSystem()->fileExists($this->_oai_xml_directory_path . '/' . static::DONE_DIRECTORY))
-      $this->getFileSystem()->mkdir($this->_oai_xml_directory_path . '/' . static::DONE_DIRECTORY);
+    if (!$this->getFileSystem()->fileExists($this->_xml_directory_path . '/' . static::DONE_DIRECTORY))
+      $this->getFileSystem()->mkdir($this->_xml_directory_path . '/' . static::DONE_DIRECTORY);
 
-    $files = $this->getFileSystem()->fileNamesAt($this->_oai_xml_directory_path);
+    $files = $this->getFileSystem()->fileNamesAt($this->_xml_directory_path);
     $total = count($files);
     $count = 1;
 
@@ -50,12 +47,12 @@ class Cyberlibris_Importer extends Class_DigitalResource_Importer_OAI {
 
 
   protected function _importOAIFrom(string $xml_filename) : self {
-    $xml = $this->getFileSystem()->fileGetContents($this->_oai_xml_directory_path . '/' . $xml_filename);
+    $xml = $this->getFileSystem()->fileGetContents($this->_xml_directory_path . '/' . $xml_filename);
 
     if ($this->_service->importFrom($xml))
-      $this->getFileSystem()->rename($this->_oai_xml_directory_path . '/' . $xml_filename,
-                                     $this->_oai_xml_done_directory_path . '/' . $xml_filename);
+      $this->getFileSystem()->rename($this->_xml_directory_path . '/' . $xml_filename,
+                                     $this->_xml_done_directory_path . '/' . $xml_filename);
 
     return $this;
   }
-}
\ No newline at end of file
+}
diff --git a/library/digital_resources/Cyberlibris/tests/CyberlibrisTest.php b/library/digital_resources/Cyberlibris/tests/CyberlibrisTest.php
index 4b8299eab70..7b11db80093 100644
--- a/library/digital_resources/Cyberlibris/tests/CyberlibrisTest.php
+++ b/library/digital_resources/Cyberlibris/tests/CyberlibrisTest.php
@@ -123,11 +123,11 @@ class CyberlibrisDashboardActivatedTest extends CyberlibrisActivatedTestCase {
                       'type_doc_id' => 'Cyberlibris']);
 
 
-    $group = $this->fixture('Class_UserGroup',
+    $group = $this->fixture(Class_UserGroup::class,
                             ['id' => 1,
                              'libelle' => 'Digital resources']);
 
-    $user = $this->fixture('Class_Users',
+    $user = $this->fixture(Class_Users::class,
                            ['id' => 1,
                             'login' => 'Tom',
                             'password' => 'pwd'])
@@ -168,7 +168,7 @@ class CyberlibrisDashboardActivatedTest extends CyberlibrisActivatedTestCase {
 class CyberlibrisHarvesterTest extends CyberlibrisActivatedTestCase {
 
   protected string $_log_content = '';
-
+  protected $_file_system;
 
   public function setUp() {
     parent::setUp();
@@ -189,12 +189,42 @@ class CyberlibrisHarvesterTest extends CyberlibrisActivatedTestCase {
            ->beStrict();
 
     Class_WebService_Abstract::setHttpClient($http_client);
+    $this->_file_system = $this->mock()
+                               ->whenCalled('fileExists')
+                               ->with('./userfiles/cyberlibris_xml_responses/done')
+                               ->answers(false)
+
+                               ->whenCalled('mkdir')
+                               ->with('./userfiles/cyberlibris_xml_responses/done')
+                               ->answers(true)
+
+                               ->whenCalled('fileExists')
+                               ->with('./userfiles/cyberlibris_xml_responses')
+                               ->answers(false)
+
+                               ->whenCalled('mkdir')
+                               ->with('./userfiles/cyberlibris_xml_responses')
+                               ->answers(true)
+
+                               ->whenCalled('filePutContents')
+                               ->with('./userfiles/cyberlibris_xml_responses/1.xml', $cyberlibris_xml)
+                               ->answers(123)
+
+                               ->beStrict();
+
+    Cyberlibris_Harvester::setFileSystem($this->_file_system);
 
     (new Cyberlibris_Harvester)
       ->setLogger($this->mock()->whenCalled('log')->willDo(fn($message) => $this->_log_content .= $message))
       ->run();
   }
 
+  public function tearDown(){
+    Cyberlibris_Harvester::setFileSystem(null);
+
+    parent::tearDown();
+  }
+
 
   /** @test */
   public function logShouldContainsDebutMoissonage() {
@@ -210,20 +240,7 @@ class CyberlibrisHarvesterTest extends CyberlibrisActivatedTestCase {
 
   /** @test */
   public function logShouldContainsFinDuPremierMoissonage() {
-    $this->assertContains('Fin du premier moissonnage', $this->_log_content);
-  }
-
-
-  /** @test */
-  public function importerShouldMoveFileCyberlibrisOneDotXmlToUserfilesCyberlibrisLandingDirectory() {
-    $this->assertTrue((new Storm_FileSystem_Disk)->fileExists('userfiles/cyberlibris_oai_xml_responses/1.xml'));
-  }
-
-
-  /** @test */
-  public function file1DotXmlContentShouldBeEqualToExportDotxml() {
-    $this->assertEquals((new Storm_FileSystem_Disk)->fileGetContents(__DIR__.'/export.xml'),
-                        (new Storm_FileSystem_Disk)->fileGetContents('userfiles/cyberlibris_oai_xml_responses/1.xml'));
+    $this->assertContains('Fin moissonnage', $this->_log_content);
   }
 
 
@@ -240,7 +257,7 @@ class CyberlibrisImporterTest extends CyberlibrisActivatedTestCase {
 
 
   protected string $_log_content = '';
-
+  protected $_file_system;
 
   public function setUp() {
     parent::setUp();
@@ -249,14 +266,44 @@ class CyberlibrisImporterTest extends CyberlibrisActivatedTestCase {
                    ['id' => 89,
                     'type_doc' => 'Cyberlibris']);
 
-    (new Storm_FileSystem_Disk)->filePutContents('userfiles/cyberlibris_oai_xml_responses/1.xml',
-                                                 (new Storm_FileSystem_Disk)->fileGetContents(__DIR__.'/export.xml'));
+    $this->_file_system = $this->mock()
+                               ->whenCalled('fileExists')
+                               ->with('./userfiles/cyberlibris_xml_responses')
+                               ->answers(true)
+
+                               ->whenCalled('fileExists')
+                               ->with('./userfiles/cyberlibris_xml_responses/done')
+                               ->answers(false)
+
+                               ->whenCalled('mkdir')
+                               ->with('./userfiles/cyberlibris_xml_responses/done')
+                               ->answers(true)
+
+                               ->whenCalled('fileNamesAt')
+                               ->with('./userfiles/cyberlibris_xml_responses')
+                               ->answers(['1.xml'])
+
+                               ->whenCalled('fileGetContents')
+                               ->with('./userfiles/cyberlibris_xml_responses/1.xml')
+                               ->answers(file_get_contents(__DIR__.'/export.xml'))
+                               ->whenCalled('rename')
+                               ->with('./userfiles/cyberlibris_xml_responses/1.xml','./userfiles/cyberlibris_xml_responses/done/1.xml')
+                               ->answers(true)
+                               ->beStrict();
+
+    Cyberlibris_Importer::setFileSystem($this->_file_system);
 
     (new Cyberlibris_Importer)
       ->setLogger($this->mock()->whenCalled('log')->willDo(fn($message) => $this->_log_content .= $message))
       ->run();
   }
 
+  public function tearDown(){
+    Cyberlibris_Importer::setFileSystem(null);
+    $this->_file_system = null;
+    parent::tearDown();
+  }
+
 
   public function recordProperties() {
     return [['titre_principal', 'Je crée mon entreprise'],
@@ -311,17 +358,6 @@ de la création d'entreprise avec, pour chacune d'elles, les pièges et écueils
   public function firstImportShouldBeDone() {
     $this->assertTrue(Cyberlibris_Config::getInstance()->isFirstImportDone());
   }
-
-  /** @test */
-  public function fileOneDotXmlShouldHaveBeenMovedInDoneDirectory() {
-    $this->assertTrue((new Storm_FileSystem_Disk)->fileExists('userfiles/cyberlibris_oai_xml_responses/done/1.xml'));
-  }
-
-
-  /** @test */
-  public function fileOneDotXmlShouldNoLongerBeInHarvestDirectory() {
-    $this->assertFalse((new Storm_FileSystem_Disk)->fileExists('userfiles/cyberlibris_oai_xml_responses/1.xml'));
-  }
 }
 
 
diff --git a/library/digital_resources/DiMusic/Config.php b/library/digital_resources/DiMusic/Config.php
index eb66cac7732..955b01e9981 100644
--- a/library/digital_resources/DiMusic/Config.php
+++ b/library/digital_resources/DiMusic/Config.php
@@ -31,6 +31,7 @@ class DiMusic_Config extends Class_DigitalResource_Config {
             'NotAllowedMessage' => $this->_('Votre compte n\'est pas autorisé à accéder à cette ressource.'),
 
             'SsoAction' => true,
+            'BatchRunning' => false,
 
             'MenuLabel' => $this->_('Lien vers DiMusic'),
             'ModuleMenu' => $this->withNameSpace('ModuleMenu'),
@@ -110,15 +111,17 @@ class DiMusic_Config extends Class_DigitalResource_Config {
   }
 
 
-  public function newOAIClient() {
-    return parent::newOAIClient()
+  public function newClient() {
+    return parent::newClient()
       ->setMetadataPrefix($this->getAdminVar('OAI_metadataPrefix'));
   }
 
 
   public function getHarvestUrl(int $page = 1) : string {
     return 1 === $page
-      ? $this->newOAIClient()->getRecordsUrl()
+      ? $this->newClient()->getRecordsUrl()
       : '';
   }
+
+
 }
diff --git a/library/digital_resources/DiMusic/Harvester.php b/library/digital_resources/DiMusic/Harvester.php
index a1e75f11411..f3fbd99b79c 100644
--- a/library/digital_resources/DiMusic/Harvester.php
+++ b/library/digital_resources/DiMusic/Harvester.php
@@ -20,4 +20,4 @@
  */
 
 
-class DiMusic_Harvester extends Class_DigitalResource_Harvester_OAI {}
\ No newline at end of file
+class DiMusic_Harvester extends Class_DigitalResource_Harvester_XML {}
diff --git a/library/digital_resources/DiMusic/Importer.php b/library/digital_resources/DiMusic/Importer.php
index f3d6fb666ec..d3fb9e7eb01 100644
--- a/library/digital_resources/DiMusic/Importer.php
+++ b/library/digital_resources/DiMusic/Importer.php
@@ -20,4 +20,4 @@
  */
 
 
-class DiMusic_Importer extends Class_DigitalResource_Importer_OAI{}
+class DiMusic_Importer extends Class_DigitalResource_Importer_XML{}
diff --git a/library/digital_resources/DiMusic/tests/DiMusicTest.php b/library/digital_resources/DiMusic/tests/DiMusicTest.php
index 1a2e84d87b8..e23ea2abcf1 100644
--- a/library/digital_resources/DiMusic/tests/DiMusicTest.php
+++ b/library/digital_resources/DiMusic/tests/DiMusicTest.php
@@ -433,12 +433,12 @@ class DiMusicIncTest extends DiMusicActivatedTestCase {
 
       ->beStrict();
 
-    $this->fixture('Class_WebService_HarvestLog',
+    $this->fixture(Class_WebService_HarvestLog::class,
                    ['id' => 1,
                     'end_date' => '2015-02-01',
                     'type_doc' => 'DiMusic']);
 
-    $this->fixture('Class_Album',
+    $this->fixture(Class_Album::class,
                    ['id' => 1,
                     'type_doc_id' => 'DiMusic',
                     'titre' => '1Dtouch res',
@@ -523,7 +523,7 @@ class DiMusicWithErrorResponseTest extends DiMusicActivatedTestCase {
       ->answers($error_response)
       ->beStrict();
 
-    $this->fixture('Class_WebService_HarvestLog',
+    $this->fixture(Class_WebService_HarvestLog::class,
                    ['id' => 1,
                     'end_date' => '2015-02-01',
                     'type_doc' => 'DiMusic']);
@@ -555,7 +555,7 @@ abstract class DiMusicRechercheControllerTestCase extends AbstractControllerTest
     Class_AdminVar::set('AUTHOR_PAGE', 0);
     $this->_prepareFixtures();
 
-    $bridges = $this->fixture('Class_Album',
+    $bridges = $this->fixture(Class_Album::class,
                    ['id' => 74721,
                     'titre' => 'Bridges',
                     'date_maj' => '2015-06-22 16:55:37',
@@ -699,42 +699,22 @@ abstract class DiMusicOAIFirstHarvestTestCase extends DiMusicActivatedTestCase {
     $this->_page1_xml = file_get_contents(__DIR__ . '/oai_page1.xml');
     $this->_page2_xml = file_get_contents(__DIR__ . '/oai_page2.xml');
 
-    $this->_page1 = $this
-      ->mock()
-      ->whenCalled('getModels')
-      ->answers(1);
-
-    $this->_page2 = $this
-      ->mock()
-      ->whenCalled('getModels')
-      ->answers(1);
-
-    $this->_userfiles = $this
-      ->mock()
-
-      ->whenCalled('isWritable')->answers(true)
-
-      ->whenCalled('isDir')->answers(true)
-
-      ->whenCalled('getId')->answers('userfiles')
-
-      ->beStrict();
-
-    $this->_file_system = $this
-      ->mock()
-
-      ->whenCalled('disableCache')
-      ->answers(true)
-
-      ->beStrict();
 
     $this->_web_client = $this
       ->mock()
       ->beStrict();
 
-    Class_FileManager::setFileSystem($this->_file_system);
+    $this->_file_system =  $this
+      ->mock();
+
+    DiMusic_Harvester::setFileSystem($this->_filesystem);
     Class_WebService_OAI::setWebClient($this->_web_client);
   }
+
+  public function tearDown(){
+    DiMusic_Harvester::setFileSystem(null);
+    parent::tearDown();
+  }
 }
 
 
@@ -746,22 +726,13 @@ class DiMusicHarvesterTest extends DiMusicOAIFirstHarvestTestCase {
     parent::setUp();
 
     $this->_file_system
-      ->whenCalled('filesAt')->with('userfiles/dimusic_oai_xml_responses')
-      ->answers([$this->_page1, $this->_page2])
-
-      ->whenCalled('directoryAt')->with('userfiles')
-      ->answers($this->_userfiles)
+      ->whenCalled('fileNamesAt')->with('./userfiles/dimusic_oai_xml_responses')
+      ->answers(['oai_page1.xml', 'oai_page2.xml'])
 
-      ->whenCalled('create')->with('userfiles/dimusic_oai_xml_responses')
+      ->whenCalled('mkdir')->with('userfiles/dimusic_oai_xml_responses')
       ->answers(true)
 
-      ->whenCalled('createImage')->with('userfiles/dimusic_oai_xml_responses/1.xml',
-                                        $this->_page1_xml)
-      ->answers(true)
-
-      ->whenCalled('createImage')->with('userfiles/dimusic_oai_xml_responses/2.xml',
-                                        $this->_page2_xml)
-      ->answers(true);
+      ->beStrict();
 
     $this->_web_client
       ->whenCalled('open_url')->with('https://music.divercities.eu/oai?verb=ListRecords&metadataPrefix=oai1dtouch_dc')
@@ -787,9 +758,9 @@ class DiMusicHarvesterTest extends DiMusicOAIFirstHarvestTestCase {
     $page2_start = substr($this->_page2_xml, 0, 200);
     $page2_end = substr($this->_page2_xml, -200);
 
-    $this->assertEquals(["\nDiMusic : Début premier moissonnage de l'OAI à 2021-11-18 14:21:00.",
+    $this->assertEquals(["\nDiMusic : Début moissonnage de l'OAI à 2021-11-18 14:21:00.",
                          "\nSuppression des précédents journaux de moissonnage de DiMusic.",
-                         "\nSuppression des fichiers XML existants dans userfiles/dimusic_oai_xml_responses",
+                         "\nSuppression des fichiers XML existants dans ./userfiles/dimusic_xml_responses",
                          "\nDébut du moissonnage.",
                          "Url : https://music.divercities.eu/oai?verb=ListRecords&metadataPrefix=oai1dtouch_dc",
                          "Réponse :",
@@ -802,7 +773,7 @@ class DiMusicHarvesterTest extends DiMusicOAIFirstHarvestTestCase {
                          "[…]",
                          $page2_end,
                          "\n2 fichiers sauvegardés.",
-                         "\nDiMusic : Fin du premier moissonnage de l'OAI à 2021-11-18 14:21:00."],
+                         "\nDiMusic : Fin moissonnage de l'OAI à 2021-11-18 14:21:00."],
                         $this->_log);
   }
 
@@ -828,77 +799,42 @@ class DiMusicImporterTest extends DiMusicOAIFirstHarvestTestCase {
                     'titre' => 'old_album',
                     'type_doc_id' => 'DiMusic']);
 
-    $this->_page1
-      ->whenCalled('getContent')
-      ->answers($this->_page1_xml)
-
-      ->whenCalled('getPath')
-      ->answers('userfiles/dimusic_oai_xml_responses/1.xml')
-
-      ->whenCalled('isWritable')
-      ->answers(true)
-
-      ->whenCalled('isDir')
+    $this->_file_system
+      ->whenCalled('fileExists')->with('./userfiles/dimusic_xml_responses/done')
       ->answers(false)
 
-      ->whenCalled('getId')
-      ->answers('userfiles/dimusic_oai_xml_responses/1.xml')
-
-      ->whenCalled('getName')
-      ->answers('1.xml');
-
-    $this->_page2
-      ->whenCalled('getContent')
-      ->answers($this->_page2_xml)
-
-      ->whenCalled('getPath')
-      ->answers('userfiles/dimusic_oai_xml_responses/2.xml')
-
-      ->whenCalled('isWritable')
+      ->whenCalled('fileExists')->with('userfiles/dimusic_oai_xml_responses')
       ->answers(true)
 
-      ->whenCalled('isDir')
-      ->answers(false)
-
-      ->whenCalled('getName')
-      ->answers('2.xml')
-
-      ->whenCalled('getId')
-      ->answers('userfiles/dimusic_oai_xml_responses/2.xml');
-
-    $dimusic_oai_xml_responses = $this
-      ->mock()
-      ->whenCalled('isWritable')
-      ->answers(true)
+      ->whenCalled('fileNamesAt')->with('userfiles/dimusic_xml_responses/done')
+      ->answers([])
 
-      ->whenCalled('isDir')
+      ->whenCalled('mkdir')->with('./userfiles/dimusic_xml_responses/done')
       ->answers(true)
 
-      ->whenCalled('getId')
-      ->answers('dimusic_oai_xml_responses');
+      ->whenCalled('fileNamesAt')->with('./userfiles/dimusic_xml_responses')
+      ->answers(['oai_page1.xml', 'oai_page2.xml'])
 
-    $this->_file_system
-      ->whenCalled('directoryAt')->with('userfiles/dimusic_oai_xml_responses')
-      ->answers($dimusic_oai_xml_responses)
+      ->whenCalled('fileGetContents')->with('./userfiles/dimusic_xml_responses/oai_page1.xml')
+      ->answers($this->_page1_xml)
 
-      ->whenCalled('filesAt')->with('userfiles/dimusic_oai_xml_responses/done')
-      ->answers([])
+      ->whenCalled('fileGetContents')->with('./userfiles/dimusic_xml_responses/oai_page2.xml')
+      ->answers($this->_page2_xml)
 
-      ->whenCalled('create')->with('dimusic_oai_xml_responses/done')
+      ->whenCalled('rename')->with('./userfiles/dimusic_xml_responses/oai_page1.xml',
+                                   './userfiles/dimusic_xml_responses/done/oai_page1.xml')
       ->answers(true)
 
-      ->whenCalled('filesAt')->with('userfiles/dimusic_oai_xml_responses')
-      ->answers([$this->_page1, $this->_page2])
-
-      ->whenCalled('rename')->with('userfiles/dimusic_oai_xml_responses/1.xml',
-                                   'userfiles/dimusic_oai_xml_responses/done/1.xml')
+      ->whenCalled('rename')->with('./userfiles/dimusic_xml_responses/oai_page2.xml',
+                                   './userfiles/dimusic_xml_responses/done/oai_page2.xml')
       ->answers(true)
+      ->beStrict();
 
-      ->whenCalled('rename')->with('userfiles/dimusic_oai_xml_responses/2.xml',
-                                   'userfiles/dimusic_oai_xml_responses/done/2.xml')
-      ->answers(true);
 
     $this->_logger = new Class_Cata_LogVolatile;
+
+    DiMusic_Importer::setFileSystem($this->_file_system);
+
     (new DiMusic_Importer)
       ->setLogger($this->_logger)
       ->run();
@@ -907,6 +843,11 @@ class DiMusicImporterTest extends DiMusicOAIFirstHarvestTestCase {
   }
 
 
+  public function tearDown(){
+    DiMusic_Importer::setFileSystem(null);
+    parent::tearDown();
+  }
+
   /** @test */
   public function diMusicImporterShouldCreateFourAlbums() {
     $this->assertEquals(4, Class_Album::countBy(['type_doc_id' => DiMusic_Config::getInstance()->getDocType()]));
@@ -915,13 +856,13 @@ class DiMusicImporterTest extends DiMusicOAIFirstHarvestTestCase {
 
   /** @test */
   public function diMusicImportLogShouldBeHasExpected() {
-    $this->assertEquals(["\nDiMusic : Début premier import des albums à 2021-11-18 14:21:00.",
+    $this->assertEquals(["\nDiMusic : Début import des albums à 2021-11-18 14:21:00.",
                          "\nDiMusic : suppression des albums existants",
                          "\n1/1",
                          "\nDiMusic : création des albums.",
-                         "\nTraitement du fichier : userfiles/dimusic_oai_xml_responses/1.xml. 1/2",
-                         "\nTraitement du fichier : userfiles/dimusic_oai_xml_responses/2.xml. 2/2",
-                         "\nDiMusic : Fin du premier import des albums à 2021-11-18 14:21:00."],
+                         "\nTraitement du fichier : oai_page1.xml. 1/2",
+                         "\nTraitement du fichier : oai_page2.xml. 2/2",
+                         "\nDiMusic : Fin import des albums à 2021-11-18 14:21:00."],
                         $this->_log);
   }
 }
@@ -977,21 +918,32 @@ class DiMusicDashboardActivatedAndFirstRunDoneTest extends DiMusicActivatedTestC
     $file_system = $this
       ->mock()
 
-      ->whenCalled('disableCache')
+      ->whenCalled('fileExists')
+      ->with('./userfiles/dimusic_xml_responses/done')
       ->answers(true)
 
-      ->whenCalled('directoryAt')->with('userfiles/dimusic_oai_xml_responses/done')
+      ->whenCalled('fileNamesAt')->with('./userfiles/dimusic_xml_responses/done')
+      ->answers(['oai_page1.xml','oai_page2.xml'])
+
+      ->whenCalled('fileExists')
+      ->with('./userfiles/dimusic_xml_responses')
       ->answers(true)
 
+      ->whenCalled('fileNamesAt')
+      ->with('./userfiles/dimusic_xml_responses')
+      ->answers([])
+
+
       ->beStrict();
 
-    Class_FileManager::setFileSystem($file_system);
+    DiMusic_Importer::setFileSystem($file_system);
 
     $this->dispatch('/DiMusic_Plugin');
   }
 
 
   public function tearDown() {
+    DiMusic_Importer::setFileSystem(null);
     Class_WebService_BibNumerique_Vignette::setHttpClient(null);
     parent::tearDown();
   }
@@ -1020,13 +972,13 @@ class DiMusicDashboardActivatedAndFirstRunDoneTest extends DiMusicActivatedTestC
   /** @test */
   public function removeDiMusicFilesMessageShouldBeDisplay() {
     $this->assertXPathContentContains('//p[@class="notice"]',
-                                      'Vous pouvez supprimer le dossier userfiles/dimusic_oai_xml_responses dans l\'explorateur de fichier :');
+                                      'Vous pouvez supprimer le dossier ./userfiles/dimusic_xml_responses dans l\'explorateur de fichier :');
   }
 
 
   /** @test */
   public function removeDiMusicLinkShouldBeDisplay() {
-    $this->assertXPathContentContains('//p[@class="notice"]//a[@href =  "/admin/file-manager/delete/item/userfiles%2Fdimusic_oai_xml_responses"]',
+    $this->assertXPathContentContains('//p[@class="notice"]//a[@href =  "/admin/file-manager/delete/item/.%2Fuserfiles%2Fdimusic_xml_responses"]',
                                       'Supprimer');
   }
 }
@@ -1052,7 +1004,7 @@ class DiMusicDashboardActivatedAndFirstRunRunningTest extends DiMusicActivatedTe
                                                           ->answers(new Class_Testing_HttpResponse(['Body' => true])));
 
     ZendAfi_Auth::getInstance()
-      ->logUser($this->fixture('Class_Users',
+      ->logUser($this->fixture(Class_Users::class,
                                ['id' => 2,
                                 'login' => 'admin',
                                 'password' => 'admin',
@@ -1061,29 +1013,28 @@ class DiMusicDashboardActivatedAndFirstRunRunningTest extends DiMusicActivatedTe
     $file_system = $this
       ->mock()
 
-      ->whenCalled('disableCache')
-      ->answers(true)
-
-      ->whenCalled('directoryAt')->with('userfiles/dimusic_oai_xml_responses/done')
+      ->whenCalled('fileExists')->with('./userfiles/dimusic_xml_responses/done')
       ->answers(true)
 
-      ->whenCalled('directoryAt')->with('userfiles/dimusic_oai_xml_responses')
+      ->whenCalled('fileExists')->with('./userfiles/dimusic_xml_responses')
       ->answers(true)
 
-      ->whenCalled('countFilesIn')->with('userfiles/dimusic_oai_xml_responses')
-      ->answers(3)
+      ->whenCalled('fileNamesAt')->with('./userfiles/dimusic_xml_responses')
+      ->answers(['a','b','c'])
 
       ->beStrict();
 
-    Class_FileManager::setFileSystem($file_system);
+    Dimusic_Importer::setFileSystem($file_system);
+    Dimusic_Harvester::setFileSystem($file_system);
 
     $this->dispatch('/DiMusic_Plugin');
   }
 
 
   public function tearDown() {
+    Trait_StormFileSystem::setFileSystem(null);
+
     Class_WebService_BibNumerique_Vignette::setHttpClient(null);
-    Class_FileManager::reset();
     parent::tearDown();
   }
 
diff --git a/library/digital_resources/LaSourisQuiRaconte/tests/LaSourisQuiRaconteTest.php b/library/digital_resources/LaSourisQuiRaconte/tests/LaSourisQuiRaconteTest.php
index 474532c4b63..56c150bb6b3 100644
--- a/library/digital_resources/LaSourisQuiRaconte/tests/LaSourisQuiRaconteTest.php
+++ b/library/digital_resources/LaSourisQuiRaconte/tests/LaSourisQuiRaconteTest.php
@@ -372,8 +372,7 @@ class LaSourisQuiRaconteViewRecordTest extends AbstractControllerTestCase {
     ZendAfi_Auth::getInstance()->clearIdentity();
     $this->dispatch('/noticeajax/resnumeriques/id/1', true);
     $this->assertXPathContentContains('//a[contains(@href, "modules/la-souris-qui-raconte")]',
-                                      utf8_encode('Accéder à l\'histoire "Le prénom du monde"'));
-
+                                      Class_CharSet::fromISOtoUTF8('Accéder à l\'histoire "Le prénom du monde"'));
   }
 
 
@@ -382,6 +381,6 @@ class LaSourisQuiRaconteViewRecordTest extends AbstractControllerTestCase {
     (new LaSourisQuiRaconteFixtures())->logValidUser();
     $this->dispatch('/noticeajax/resnumeriques/id/1', true);
     $this->assertXPathContentContains('//a[contains(@href, "modules/la-souris-qui-raconte/album_id/1")]',
-                                      utf8_encode('Accéder à l\'histoire "Le prénom du monde"'));
+                                      Class_CharSet::fromISOtoUTF8('Accéder à l\'histoire "Le prénom du monde"'));
   }
 }
diff --git a/library/digital_resources/Musicme/tests/MusicmeTest.php b/library/digital_resources/Musicme/tests/MusicmeTest.php
index 0323291917e..62e729e1f96 100644
--- a/library/digital_resources/Musicme/tests/MusicmeTest.php
+++ b/library/digital_resources/Musicme/tests/MusicmeTest.php
@@ -396,8 +396,8 @@ class MusicmeViewRecordTest extends AbstractControllerTestCase {
   public function viewNoticeMusicMeWithNotValidUserShouldDisplayCantAccess() {
     $this->logValidUserNotAllowed();
 
-    $this->dispatch('/noticeajax/resnumeriques?id_notice=1', true);
-    $this->assertXPathContentContains('//p', utf8_encode('Votre abonnement ne permet pas d\'accéder à la ressource. Merci de contacter la médiathèque.') );
+    $this->dispatch('/noticeajax/resnumeriques?id_notice=1');
+    $this->assertXPathContentContains('//p', Class_CharSet::fromISOtoUTF8('Votre abonnement ne permet pas d\'accéder à la ressource. Merci de contacter la médiathèque.') );
   }
 
 
diff --git a/library/digital_resources/Numel/View/Helper/Album.php b/library/digital_resources/Numel/View/Helper/Album.php
index f6a8dcc9050..67172e4a178 100644
--- a/library/digital_resources/Numel/View/Helper/Album.php
+++ b/library/digital_resources/Numel/View/Helper/Album.php
@@ -21,7 +21,7 @@
 
 
 class Numel_View_Helper_Album extends Class_DigitalResource_AlbumViewHelper {
- public function album(?Class_Album $album) : string{
+  public function album(?Class_Album $album) : string{
     if (!$album)
       return '';
 
diff --git a/library/digital_resources/Numilog/Config.php b/library/digital_resources/Numilog/Config.php
index fd1e2347069..6b733cb8c3c 100644
--- a/library/digital_resources/Numilog/Config.php
+++ b/library/digital_resources/Numilog/Config.php
@@ -80,13 +80,13 @@ class Numilog_Config extends Class_DigitalResource_Config {
 
   public function getHarvestUrl(int $page = 1) : string {
     return 1 === $page
-      ? $this->newOAIClient()->getRecordsUrl()
+      ? $this->newClient()->getRecordsUrl()
       : '';
   }
 
 
-  public function newOAIClient() {
-    return parent::newOAIClient()
+  public function newClient() {
+    return parent::newClient()
       ->setOAIHandler($this->getAdminVar('HARVEST_URL'))
       ->setDefaultSet('bib:' . $this->getAdminVar('KEY'));
   }
diff --git a/library/digital_resources/Omeka/Config.php b/library/digital_resources/Omeka/Config.php
index 00bb1fbd099..937b97eaf1b 100644
--- a/library/digital_resources/Omeka/Config.php
+++ b/library/digital_resources/Omeka/Config.php
@@ -52,8 +52,8 @@ class Omeka_Config extends Class_DigitalResource_Config {
   }
 
 
-  public function newOAIClient() {
-    return parent::newOAIClient()
+  public function newClient() {
+    return parent::newClient()
       ->setOAIHandler($this->getRootUrl() . '/oai-pmh-repository/request');
   }
 
diff --git a/library/digital_resources/Skilleos/tests/SkilleosTest.php b/library/digital_resources/Skilleos/tests/SkilleosTest.php
index 8aedd649c6e..60791531ba5 100644
--- a/library/digital_resources/Skilleos/tests/SkilleosTest.php
+++ b/library/digital_resources/Skilleos/tests/SkilleosTest.php
@@ -339,7 +339,7 @@ class SkilleosServiceHarvestTest extends SkilleosServiceTestCase {
     Class_Album::find(6)->index();
     $this->dispatch('/noticeajax/resnumeriques/id/1', true);
     $this->assertXPathContentContains('//a[contains(@href, "modules/skilleos")]',
-                                      utf8_encode('Accéder à "Dessiner'));
+                                      Class_CharSet::fromISOtoUTF8('Accéder à "Dessiner'));
   }
 
 
diff --git a/library/storm b/library/storm
index d2015db0519..4859dae8444 160000
--- a/library/storm
+++ b/library/storm
@@ -1 +1 @@
-Subproject commit d2015db051956daad6aa242baa24908ce761d77f
+Subproject commit 4859dae8444425a97da09b7989a566afbeba4e1a
diff --git a/scripts/cvs_first_harvest.php b/scripts/cvs_first_harvest.php
new file mode 100644
index 00000000000..fc9751a457f
--- /dev/null
+++ b/scripts/cvs_first_harvest.php
@@ -0,0 +1,28 @@
+<?php
+error_reporting(E_ALL^E_DEPRECATED);
+require __DIR__ . '/../console.php';
+
+class Write_Log_In_Temp {
+  protected $_path = './temp/cvs_harvest_log.txt';
+
+  public function __construct() {
+    file_put_contents($this->_path,
+                      '');
+  }
+
+
+  public function log($message) {
+    file_put_contents($this->_path,
+                      $message . "\n",
+                      FILE_APPEND);
+  }
+}
+
+echo "\n\nWelcome to the Cvs2 harvester  Pro tool\n\n";
+
+Class_DigitalResource::getInstance()->getHarvesters();
+
+foreach(Cvs2_Config::getInstance()->cataloguesIds() as $name)
+  (new Cvs2_Harvester($name, '1970-01-01'))
+    ->setLogger(new Write_Log_In_Temp)
+    ->run();
diff --git a/scripts/cvs_first_import.php b/scripts/cvs_first_import.php
new file mode 100644
index 00000000000..7a997726638
--- /dev/null
+++ b/scripts/cvs_first_import.php
@@ -0,0 +1,22 @@
+<?php
+require('console.php');
+
+class Write_Log_In_Temp {
+  protected $_path = './temp/cvs_import_log.txt';
+
+  public function __construct() {
+    file_put_contents($this->_path,
+                      '');
+  }
+
+
+  public function log($message) {
+    file_put_contents($this->_path,
+                      $message . "\n",
+                      FILE_APPEND);
+  }
+}
+
+(new Cvs2_Importer)
+  ->setLogger(new Write_Log_In_Temp)
+  ->run();
diff --git a/tests/application/modules/admin/controllers/ModulesControllerTest.php b/tests/application/modules/admin/controllers/ModulesControllerTest.php
index 040b553c73d..aece09da76c 100644
--- a/tests/application/modules/admin/controllers/ModulesControllerTest.php
+++ b/tests/application/modules/admin/controllers/ModulesControllerTest.php
@@ -766,10 +766,11 @@ class ModulesControllerRechercheSimpleWithPreferencesTest
 
   public function setUp() {
     parent::setUp();
-    $this->fixture('Class_CodifThesaurus',['id' => 5,
-                                           'id_thesaurus' => 'NEWF',
-                                           'libelle' => 'Custum Facet ',
-                                           'libelle_facette' => 'New Facet']);
+    $this->fixture(Class_CodifThesaurus::class,
+                   ['id' => 5,
+                    'id_thesaurus' => 'NEWF',
+                    'libelle' => 'Custum Facet ',
+                    'libelle_facette' => 'New Facet']);
 
     Class_Profil::getCurrentProfil()
       ->setCfgModules(
@@ -802,12 +803,6 @@ class ModulesControllerRechercheSimpleWithPreferencesTest
   }
 
 
-  /** @test **/
-  public function selectCvsDisplayPositionShouldNotBeDisplay() {
-    $this->assertNotXPath('//select[@name="cvs_display_position"]');
-  }
-
-
   /** @test */
   public function checkboxSuggestionAchatShouldBeChecked() {
     $this->assertXPath('//input[@type="checkbox"][@name="suggestion_achat"][@checked="checked"]');
@@ -1565,4 +1560,4 @@ class ModulesControllerNoticeApplyToActionWithTemplatePostTest extends ModulesCo
   public function currentProfilShouldUseThemeIntonation() {
     $this->assertEquals('INTONATION', Class_Profil::getCurrentProfil()->getTemplate());
   }
-}
\ No newline at end of file
+}
-- 
GitLab