From 8583e134e8ec5b2a3e2cc84d8fe0ec12406117b3 Mon Sep 17 00:00:00 2001
From: Cerdic <cedric@yterium.com>
Date: Wed, 30 Mar 2011 22:02:36 +0000
Subject: [PATCH] =?UTF-8?q?Int=C3=A9gration=20d'une=20gestion=20de=20files?=
 =?UTF-8?q?=20de=20taches=20(initialement=20le=20plugin=20job=5Fqueue)=20e?=
 =?UTF-8?q?n=20remplacement=20du=20syst=C3=A8me=20de=20cron=20periodique?=
 =?UTF-8?q?=20La=20declaration=20de=20taches=20periodiques=20reste=20ident?=
 =?UTF-8?q?ique=20a=20auparavant=20(pipeline=20taches=5Fgenerales=5Fcron)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

La balise #SPIP_CRON disparait, remplacee par un declenchement en fin de hit uniquement quand une tache
est en attente. Pour le declenchement, on utilise preferentiellement un socket, et a defaut une image background comme avant.

Lors d'un appel, le sequenceur execute autant de taches en attente que possible, dans la limite d'un temps donné. Des appels concurents peuvent se jouer, mais chaque tache est executee une unique fois par une seule instance. L'unicité de l'execution est garantie par un sql_delete.

SI jamais le nombre de taches en attente depasse un seuil (10000 par defaut) la file est purgee par un appel en fin de hit, synchrone.
Une page d'aministration est accesible aux administrateurs depuis le menu maintenance.

L'API est
/**
 * Ajout d'une tache dans la file d'attente
 *
 * @param $function
 *   The function name to call.
 * @param $description
 *   A human-readable description of the queued job.
 * @param $arguments
 *   Optional array of arguments to pass to the function.
 * @param $file
 *   Optional file path which needs to be included for $fucntion.
 * @param $no_duplicate
 *   If TRUE, do not add the job to the queue if one with the same function and
 *   arguments already exists.
 * @param $time
 *		time for starting the job. If 0, job will start as soon as possible
 * @param $priority
 *		-10 (low priority) to +10 (high priority), 0 is the default
 * @return int
 *	id of job
 */
function job_queue_add($function, $description, $arguments = array(), $file = '', $no_duplicate = FALSE, $time=0, $priority=0)

/**
 * Supprimer une tache de la file d'attente
 * @param int $id_job
 *  id of jonb to delete
 * @return bool
 */
function job_queue_remove($id_job)

/**
 * Associer une tache a un/des objets de SPIP
 * @param int $id_job
 *	id of job to link
 * @param array $objets
 *  can be a simple array('objet'=>'article','id_objet'=>23)
 *  or an array of simple array to link multiples objet in one time
 */
function job_queue_link($id_job,$objets)
---
 .gitattributes                                |  10 +
 ecrire/action/annuler_job.php                 |  30 +
 ecrire/action/forcer_job.php                  |  33 ++
 ecrire/action/purger_queue.php                |  30 +
 ecrire/action/super_cron.php                  |  50 ++
 ecrire/base/auxiliaires.php                   |  15 +-
 ecrire/base/serial.php                        |  20 +
 ecrire/core.xml                               |   9 +-
 ecrire/inc/autoriser.php                      |  10 +-
 ecrire/inc/genie.php                          | 109 ++--
 ecrire/inc/pipelines.php                      |  29 +-
 ecrire/inc/pipelines_ecrire.php               |  20 +
 ecrire/inc/presentation_mini.php              |   7 +-
 ecrire/inc/queue.php                          | 520 ++++++++++++++++++
 ecrire/inc/utils.php                          | 158 ++++--
 ecrire/inc_version.php                        |   2 +-
 ecrire/lang/ecrire_fr.php                     |   8 +
 ecrire/maj/svn10000.php                       |   4 +
 ecrire/public.php                             |  11 +-
 ecrire/public/balises.php                     |  14 -
 prive/modeles/object_jobs_list.html           |  17 +
 prive/squelettes/contenu/job_queue.html       |  41 ++
 prive/themes/spip/images/queue-process-16.png | Bin 0 -> 803 bytes
 prive/themes/spip/images/queue-process-24.png | Bin 0 -> 1385 bytes
 prive/themes/spip/images/queue-process-32.png | Bin 0 -> 1602 bytes
 25 files changed, 1036 insertions(+), 111 deletions(-)
 create mode 100644 ecrire/action/annuler_job.php
 create mode 100644 ecrire/action/forcer_job.php
 create mode 100644 ecrire/action/purger_queue.php
 create mode 100644 ecrire/action/super_cron.php
 create mode 100644 ecrire/inc/queue.php
 create mode 100644 prive/modeles/object_jobs_list.html
 create mode 100644 prive/squelettes/contenu/job_queue.html
 create mode 100644 prive/themes/spip/images/queue-process-16.png
 create mode 100644 prive/themes/spip/images/queue-process-24.png
 create mode 100644 prive/themes/spip/images/queue-process-32.png

diff --git a/.gitattributes b/.gitattributes
index 1913eb1d4a..38bd4b0f0c 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -4,6 +4,7 @@ config/ecran_securite.php -text
 config/remove.txt -text
 ecrire/action/activer_plugins.php -text
 ecrire/action/ajouter_lien.php -text
+ecrire/action/annuler_job.php -text
 ecrire/action/auth.php -text
 ecrire/action/charger_plugin.php -text
 ecrire/action/confirmer_email.php -text
@@ -16,6 +17,7 @@ ecrire/action/editer_liens.php -text
 ecrire/action/editer_objet.php -text
 ecrire/action/editer_rubrique.php -text
 ecrire/action/etre_webmestre.php -text
+ecrire/action/forcer_job.php -text
 ecrire/action/iconifier.php -text
 ecrire/action/index.php -text
 ecrire/action/instituer_article.php -text
@@ -25,10 +27,12 @@ ecrire/action/logout.php -text
 ecrire/action/menu_rubriques.php -text
 ecrire/action/preferer.php -text
 ecrire/action/purger.php -text
+ecrire/action/purger_queue.php -text
 ecrire/action/redirect.php -text
 ecrire/action/referencer_traduction.php -text
 ecrire/action/reorganiser.php -text
 ecrire/action/session.php -text
+ecrire/action/super_cron.php -text
 ecrire/action/supprimer_lien.php -text
 ecrire/action/supprimer_rubrique.php -text
 ecrire/action/tester.php -text
@@ -139,6 +143,7 @@ ecrire/inc/prepare_recherche.php -text
 ecrire/inc/presentation_mini.php -text
 ecrire/inc/presenter_enfants.php -text
 ecrire/inc/puce_statut.php -text
+ecrire/inc/queue.php -text
 ecrire/inc/recherche_to_array.php -text
 ecrire/inc/rechercher.php -text
 ecrire/inc/securiser_action.php -text
@@ -701,6 +706,7 @@ prive/modeles/formulaire.html -text
 prive/modeles/image.html -text
 prive/modeles/img.html -text
 prive/modeles/mail_inscription.html -text
+prive/modeles/object_jobs_list.html -text
 prive/modeles/pagination.html -text
 prive/modeles/pagination_page.html -text
 prive/modeles/pagination_page_precedent_suivant.html -text
@@ -766,6 +772,7 @@ prive/squelettes/contenu/configurer_langue.html -text
 prive/squelettes/contenu/configurer_multilang.html -text
 prive/squelettes/contenu/configurer_preferences.html -text
 prive/squelettes/contenu/infos_perso.html -text
+prive/squelettes/contenu/job_queue.html -text
 prive/squelettes/contenu/navigation.html -text
 prive/squelettes/contenu/plan.html -text
 prive/squelettes/contenu/recherche.html -text
@@ -975,6 +982,9 @@ prive/themes/spip/images/puce-proposer-8.png -text
 prive/themes/spip/images/puce-publier-8.png -text
 prive/themes/spip/images/puce-refuser-8.png -text
 prive/themes/spip/images/puce-supprimer-8.png -text
+prive/themes/spip/images/queue-process-16.png -text
+prive/themes/spip/images/queue-process-24.png -text
+prive/themes/spip/images/queue-process-32.png -text
 prive/themes/spip/images/racine-16.png -text
 prive/themes/spip/images/racine-24.png -text
 prive/themes/spip/images/reaction-48.png -text
diff --git a/ecrire/action/annuler_job.php b/ecrire/action/annuler_job.php
new file mode 100644
index 0000000000..87e701f7a0
--- /dev/null
+++ b/ecrire/action/annuler_job.php
@@ -0,0 +1,30 @@
+<?php
+
+/***************************************************************************\
+ *  SPIP, Systeme de publication pour l'internet                           *
+ *                                                                         *
+ *  Copyright (c) 2001-2011                                                *
+ *  Arnaud Martin, Antoine Pitrou, Philippe Riviere, Emmanuel Saint-James  *
+ *                                                                         *
+ *  Ce programme est un logiciel libre distribue sous licence GNU/GPL.     *
+ *  Pour plus de details voir le fichier COPYING.txt ou l'aide en ligne.   *
+\***************************************************************************/
+
+if (!defined('_ECRIRE_INC_VERSION')) return;
+
+/**
+ * Annuler un travail
+ * @return void
+ */
+function action_annuler_job_dist(){
+	$securiser_action = charger_fonction('securiser_action','inc');
+	$id_job = $securiser_action();
+
+	if ($id_job = intval($id_job)
+		AND autoriser('annuler','job',$id_job)
+	){
+		job_queue_remove($id_job);
+	}
+}
+
+?>
\ No newline at end of file
diff --git a/ecrire/action/forcer_job.php b/ecrire/action/forcer_job.php
new file mode 100644
index 0000000000..82c1def477
--- /dev/null
+++ b/ecrire/action/forcer_job.php
@@ -0,0 +1,33 @@
+<?php
+
+/***************************************************************************\
+ *  SPIP, Systeme de publication pour l'internet                           *
+ *                                                                         *
+ *  Copyright (c) 2001-2011                                                *
+ *  Arnaud Martin, Antoine Pitrou, Philippe Riviere, Emmanuel Saint-James  *
+ *                                                                         *
+ *  Ce programme est un logiciel libre distribue sous licence GNU/GPL.     *
+ *  Pour plus de details voir le fichier COPYING.txt ou l'aide en ligne.   *
+\***************************************************************************/
+
+if (!defined('_ECRIRE_INC_VERSION')) return;
+
+/**
+ * Executer un travaille immediatement
+ * @return void
+ */
+function action_forcer_job_dist(){
+	$securiser_action = charger_fonction('securiser_action','inc');
+	$id_job = $securiser_action();
+
+	if ($id_job = intval($id_job)
+		AND autoriser('forcer','job',$id_job)
+	){
+		include_spip('inc/queue');
+		include_spip('inc/genie');
+		queue_schedule(array($id_job));
+	}
+
+}
+
+?>
\ No newline at end of file
diff --git a/ecrire/action/purger_queue.php b/ecrire/action/purger_queue.php
new file mode 100644
index 0000000000..a9c7caff47
--- /dev/null
+++ b/ecrire/action/purger_queue.php
@@ -0,0 +1,30 @@
+<?php
+
+/***************************************************************************\
+ *  SPIP, Systeme de publication pour l'internet                           *
+ *                                                                         *
+ *  Copyright (c) 2001-2011                                                *
+ *  Arnaud Martin, Antoine Pitrou, Philippe Riviere, Emmanuel Saint-James  *
+ *                                                                         *
+ *  Ce programme est un logiciel libre distribue sous licence GNU/GPL.     *
+ *  Pour plus de details voir le fichier COPYING.txt ou l'aide en ligne.   *
+\***************************************************************************/
+
+if (!defined('_ECRIRE_INC_VERSION')) return;
+
+/**
+ * Purger la liste des travaux en attente
+ * @return void
+ */
+function action_purger_queue_dist(){
+	$securiser_action = charger_fonction('securiser_action','inc');
+	$securiser_action();
+
+	if (autoriser('purger','queue')){
+		include_spip('inc/queue');
+		queue_purger();
+	}
+
+}
+
+?>
\ No newline at end of file
diff --git a/ecrire/action/super_cron.php b/ecrire/action/super_cron.php
new file mode 100644
index 0000000000..de008fed27
--- /dev/null
+++ b/ecrire/action/super_cron.php
@@ -0,0 +1,50 @@
+<?php
+
+/***************************************************************************\
+ *  SPIP, Systeme de publication pour l'internet                           *
+ *                                                                         *
+ *  Copyright (c) 2001-2011                                                *
+ *  Arnaud Martin, Antoine Pitrou, Philippe Riviere, Emmanuel Saint-James  *
+ *                                                                         *
+ *  Ce programme est un logiciel libre distribue sous licence GNU/GPL.     *
+ *  Pour plus de details voir le fichier COPYING.txt ou l'aide en ligne.   *
+\***************************************************************************/
+
+if (!defined('_ECRIRE_INC_VERSION')) return;
+
+/**
+ * Url pour lancer le cron de manière asynchrone si le serveur
+ * le permet
+ *
+ * On se base sur le même code que celui du pipeline affichage final
+ *
+ * Cette fonction est utile pour être appelée depuis un cron UNIX par exemple
+ * car elle retourne tout de suite
+ *
+ * Exemple de tache cron Unix pour un appel toutes les minutes :
+ * "* * * * * curl  http://www.mondomaine.tld/spip.php?action=super_cron"
+ */
+function action_super_cron_dist(){
+	// Si fsockopen est possible, on lance le cron via un socket
+	// en asynchrone
+	if(function_exists('fsockopen')){
+		$url = generer_url_action('cron');
+		$parts=parse_url($url);
+		$fp = fsockopen($parts['host'],
+	        isset($parts['port'])?$parts['port']:80,
+	        $errno, $errstr, 30);
+		if ($fp) {
+	    	$out = "GET ".$parts['path']."?".$parts['query']." HTTP/1.1\r\n";
+    		$out.= "Host: ".$parts['host']."\r\n";
+    		$out.= "Connection: Close\r\n\r\n";
+			fwrite($fp, $out);
+			fclose($fp);
+			return;
+		}
+	}
+	// ici lancer le cron par un CURL asynchrone si CURL est présent
+	// TBD
+
+	return;
+}
+?>
\ No newline at end of file
diff --git a/ecrire/base/auxiliaires.php b/ecrire/base/auxiliaires.php
index 5d15bb7aa7..e26d4318fe 100644
--- a/ecrire/base/auxiliaires.php
+++ b/ecrire/base/auxiliaires.php
@@ -46,6 +46,16 @@ $spip_meta = array(
 $spip_meta_key = array(
 		"PRIMARY KEY"	=> "nom");
 
+$spip_jobs_liens = array(
+	"id_job"	=> "bigint(21) DEFAULT '0' NOT NULL",
+	"id_objet"	=> "bigint(21) DEFAULT '0' NOT NULL",
+	"objet"	=> "VARCHAR (25) DEFAULT '' NOT NULL",
+);
+
+$spip_documents_liens_key = array(
+		"PRIMARY KEY"		=> "id_job,id_objet,objet",
+		"KEY id_job"	=> "id_job");
+
 $tables_auxiliaires['spip_auteurs_liens'] = array(
 	'field' => &$spip_auteurs_liens,
 	'key' => &$spip_auteurs_liens_key);
@@ -56,7 +66,10 @@ $tables_auxiliaires['spip_meta'] = array(
 $tables_auxiliaires['spip_resultats'] = array(
 	'field' => &$spip_resultats,
 	'key' => &$spip_resultats_key);
-	
+$tables_auxiliaires['spip_jobs_liens'] = array(
+	'field' => &$spip_jobs_liens,
+	'key' => &$spip_documents_liens_key);
+
 	$tables_auxiliaires = pipeline('declarer_tables_auxiliaires',$tables_auxiliaires);
 }
 
diff --git a/ecrire/base/serial.php b/ecrire/base/serial.php
index 12eb0f803a..ff16745103 100644
--- a/ecrire/base/serial.php
+++ b/ecrire/base/serial.php
@@ -115,6 +115,24 @@ $spip_rubriques_key = array(
 );
 
 
+$spip_jobs = array(
+	"id_job" 	=> "bigint(21) NOT NULL",
+	"descriptif"	=> "text DEFAULT '' NOT NULL",
+	"fonction" 	=> "varchar(255) NOT NULL", //nom de la fonction
+	"args"=> "longblob DEFAULT '' NOT NULL", // arguments
+	"md5args"=> "char(32) NOT NULL default ''", // signature des arguments
+	"inclure" => "varchar(255) NOT NULL", // fichier a inclure ou path/ pour charger_fonction
+	"priorite" 	=> "smallint(6) NOT NULL default 0",
+	"date" => "datetime DEFAULT '0000-00-00 00:00:00' NOT NULL", // date au plus tot
+	"status" => "tinyint NOT NULL default 1",
+	);
+
+$spip_jobs_key = array(
+	"PRIMARY KEY" 	=> "id_job",
+	"KEY date" => "date",
+	"KEY status" => "status",
+);
+
 /// Attention: mes_fonctions peut avoir deja defini cette variable
 /// il faut donc rajouter, mais pas reinitialiser
 
@@ -124,6 +142,8 @@ $tables_principales['spip_auteurs']  =
 	array('field' => &$spip_auteurs, 'key' => &$spip_auteurs_key,'join' => &$spip_auteurs_join);
 $tables_principales['spip_rubriques'] =
 	array('field' => &$spip_rubriques, 'key' => &$spip_rubriques_key);
+$tables_principales['spip_jobs'] =
+	array('field' => &$spip_jobs, 'key' => &$spip_jobs_key);
 
 	$tables_principales = pipeline('declarer_tables_principales',$tables_principales);
 }
diff --git a/ecrire/core.xml b/ecrire/core.xml
index 2656751bf4..28d70f5e74 100644
--- a/ecrire/core.xml
+++ b/ecrire/core.xml
@@ -78,7 +78,11 @@
 		<titre>titre_admin_tech</titre>
 		<url>admin_tech</url>
 	</bouton>
-	
+	<bouton id="job_queue" parent='bando_administration'>
+		<icone>images/queue-process-16.png</icone>
+		<titre>queue_titre</titre>
+	</bouton>
+
 	
 	
 	<bouton id="bando_configuration">
@@ -185,6 +189,7 @@
 	</pipeline>
 	<pipeline><nom>affichage_final</nom><action>f_tidy</action></pipeline>
 	<pipeline><nom>affichage_final</nom><action>f_admin</action></pipeline>
+	<pipeline><nom>affichage_final</nom><action>f_queue</action></pipeline>
 	<pipeline><nom>affichage_final_prive</nom>
 		<action>affichage_final_prive_title_auto</action>
 		<inclure>inc/pipelines_ecrire.php</inclure>
@@ -200,7 +205,7 @@
 	<pipeline><nom>affiche_auteurs_interventions</nom><action></action></pipeline>
 	<pipeline><nom>affiche_droite</nom><action></action></pipeline>
 	<pipeline><nom>affiche_gauche</nom><action></action></pipeline>
-	<pipeline><nom>affiche_milieu</nom><action></action></pipeline>
+	<pipeline><nom>affiche_milieu</nom><action>f_queue_affiche_milieu</action></pipeline>
 	<pipeline><nom>affiche_enfants</nom><action></action></pipeline>
 	<pipeline><nom>affiche_hierarchie</nom><action></action></pipeline>
 	<pipeline><nom>affiche_formulaire_login</nom>
diff --git a/ecrire/inc/autoriser.php b/ecrire/inc/autoriser.php
index 790e34dde0..a1dcf5d093 100644
--- a/ecrire/inc/autoriser.php
+++ b/ecrire/inc/autoriser.php
@@ -581,4 +581,12 @@ function autoriser_synchro_bouton_dist($faire, $type, $id, $qui, $opts){
 	return $qui['statut']=='0minirezo';
 }
 
-?>
+/**
+ * Autoriser la purge de la queue : il faut etre webmestre
+ * @return mixed
+ */
+function autoriser_queue_purger_dist(){
+	return autoriser('webmestre');
+}
+
+?>
\ No newline at end of file
diff --git a/ecrire/inc/genie.php b/ecrire/inc/genie.php
index bb589eb2f9..90c0dd55f2 100644
--- a/ecrire/inc/genie.php
+++ b/ecrire/inc/genie.php
@@ -52,43 +52,21 @@ if (!defined('_ECRIRE_INC_VERSION')) return;
 
 // http://doc.spip.org/@inc_genie_dist
 function inc_genie_dist($taches = array()) {
-
-	if (!$taches)
-		$taches = taches_generales();
-
-	// Quelle est la tache la plus urgente ?
-	$tache = '';
-	$tmin = $t = time();
-	foreach ($taches as $nom => $periode) {
-		$celock = _DIR_TMP . $nom . '.lock';
-		$date_lock = @filemtime($celock);
-		if ($date_lock + $periode < $tmin) {
-			$tmin = $date_lock + $periode;
-			$tache = $nom;
-			$lock = $celock;
-			$last = $date_lock;
-		}
-	// debug : si la date du fichier est superieure a l'heure actuelle,
-	// c'est que les serveurs Http et de fichiers sont desynchro.
-	// Ca peut mettre en peril les taches cron : signaler dans le log
-	// (On laisse toutefois flotter sur une heure, pas la peine de s'exciter
-	// pour si peu)
-		else if ($date_lock > $t + 3600)
-			spip_log("Erreur de date du fichier $lock : $date_lock > $t !");
-	}
-	if ($tache) {
-		spip_timer('tache');
-		spip_log('cron: debut '.$tache, 'genie');
-		touch($lock);
-		$cron = charger_fonction($tache, 'genie');
-		$retour = $cron($last);
-		// si la tache a eu un effet : log
-		if ($retour) {
-			spip_log("cron: $tache (" . spip_timer('tache') . ") $retour", 'genie');
-			if ($retour < 0)
-				@touch($lock, 0 - $retour);
-		}
-	}
+	include_spip('inc/queue');
+
+	if (_request('exec')=='job_queue')
+		return;
+
+	$force_jobs = array();
+	// l'ancienne facon de lancer une tache cron immediatement
+	// etait de la passer en parametre a ing_genie_dist
+	// on reroute en ajoutant simplement le job a la queue, ASAP
+	foreach($taches as $function=>$period)
+		$force_jobs[] = queue_add_job($function, _L("Tache CRON $function (ASAP)"), array(time()-abs($period)), "genie/");
+	
+	// et on passe la main a la gestion de la queue !
+	// en forcant eventuellement les jobs ajoute a l'instant
+	queue_schedule(count($force_jobs)?$force_jobs:null);
 }
 
 //
@@ -102,6 +80,10 @@ function inc_genie_dist($taches = array()) {
 // http://doc.spip.org/@taches_generales
 function taches_generales($taches_generales = array()) {
 
+	// verifier que toutes les taches cron sont planifiees
+	// c'est une tache cron !
+	$taches_generales['queue_watch'] = 3600*24;
+
 	// MAJ des rubriques publiques (cas de la publication post-datee)
 	// est fait au coup par coup a present
 	//	$taches_generales['rubriques'] = 3600;
@@ -109,8 +91,9 @@ function taches_generales($taches_generales = array()) {
 	// Optimisation de la base
 	$taches_generales['optimiser'] = 3600*48;
 
-	// cache (chaque 20 minutes => 1/16eme du repertoire cache)
-	$taches_generales['invalideur'] = 1200;
+	// cache (chaque 10 minutes => 1/16eme du repertoire cache,
+	// soit toutes les 2h40 sur le meme rep)
+	$taches_generales['invalideur'] = 600;
 
 	// nouveautes
 	if ($GLOBALS['meta']['adresse_neuf'] AND $GLOBALS['meta']['jours_neuf']
@@ -141,4 +124,52 @@ function genie_invalideur_dist($t) {
 		return (0 - $t);
 	return 1;
 }
+
+/**
+ * Une tache periodique pour surveiller les taches crons et les relancer si besoin
+ * quand ce cron s'execute, il n'est plus dans la queue, donc il se replanifie
+ * lui meme, avec last=time()
+ * avec une dose d'aleatoire pour ne pas planifier toutes les taches au meme moment
+ *
+ * @return int
+ */
+function genie_queue_watch_dist(){
+	$taches = taches_generales();
+	foreach($taches as $tache=>$periode){
+		queue_genie_replan_job($tache,$periode,time()-round(rand(1,$periode)));
+	}
+	return 1;
+}
+
+/**
+ * Replanifier une tache periodique
+ *
+ * @param string $function
+ *   nom de la fonction a appeler
+ * @param int $period
+ *   periodicite en secondes
+ * @param int $last
+ *   date du dernier appel (timestamp)
+ * @param int $time
+ *   date de replanification
+ * @param int $priority
+ *   priorite
+ * @return void
+ */
+function queue_genie_replan_job($function,$period,$last=null,$time=0, $priority=0){
+		if (!$time){
+			if (!is_null($last))
+				$time = $last+$period;
+			else
+				$time=time();
+		}
+		if (is_null($last))
+			$last = $time-$period;
+		spip_log("replan_job $function $period $last $time $priority",'queue');
+		include_spip('inc/queue');
+		// on replanifie un job cron
+		// uniquement si il n'y en a pas deja un avec le meme nom
+		// independament de l'argument
+		queue_add_job($function, _L("Tache CRON $function (toutes les $period s)"), array($last), "genie/", 'function_only', $time, $priority);
+}
 ?>
diff --git a/ecrire/inc/pipelines.php b/ecrire/inc/pipelines.php
index f5c6801bb7..36a428adba 100644
--- a/ecrire/inc/pipelines.php
+++ b/ecrire/inc/pipelines.php
@@ -111,4 +111,31 @@ function f_recuperer_fond($flux) {
 	include_spip('inc/pipelines_ecrire');
 	return f_afficher_blocs_ecrire($flux);
 }
-?>
+
+// gerer le lancement du cron
+// si des taches sont en attentes
+function f_queue(&$texte){
+
+	// eviter une inclusion si rien a faire
+	if (queue_sleep_time_to_next_job() OR defined('_DEBUG_BLOCK_QUEUE')){
+		return $texte;
+	}
+
+	include_spip('inc/queue');
+	$code = queue_affichage_cron();
+
+	// si rien a afficher
+	// ou si on est pas dans une page html, on ne sait rien faire de mieux
+	if (!$code OR !$GLOBALS['html'])
+		return $texte;
+
+	// inserer avant le </body> fermant si on peut, a la fin de la page sinon
+	if (($p=strpos($texte,'</body>'))!==FALSE)
+		$texte = substr($texte,0,$p).$code.substr($texte,$p);
+	else
+		$texte .= $code;
+
+	return $texte;
+}
+
+?>
\ No newline at end of file
diff --git a/ecrire/inc/pipelines_ecrire.php b/ecrire/inc/pipelines_ecrire.php
index 9782ad17f7..586151a06f 100644
--- a/ecrire/inc/pipelines_ecrire.php
+++ b/ecrire/inc/pipelines_ecrire.php
@@ -246,6 +246,26 @@ function f_afficher_blocs_ecrire($flux) {
 	return $flux;
 }
 
+/**
+ * Afficher les taches en attente liees a un objet
+ * @param string $flux
+ * @return string
+ */
+function f_queue_affiche_milieu($flux){
+	$args = $flux['args'];
+	$res = "";
+	foreach($args as $key=>$arg){
+		if (preg_match(",^id_,",$key)){
+			$objet = preg_replace(',^id_,', '', $key);
+			$res .= recuperer_fond('modeles/object_jobs_list',array('id_objet'=>$arg,'objet'=>$objet),array('ajax'=>true));
+		}
+	}
+	if ($res)
+		$flux['data'] = $res . $flux['data'];
+
+	return $flux;
+}
+
 /**
  * Trouver l'objet qui correspond
  * a l'exec de l'espace prive passe en argument
diff --git a/ecrire/inc/presentation_mini.php b/ecrire/inc/presentation_mini.php
index eb3e33d5bd..bb6031be8e 100644
--- a/ecrire/inc/presentation_mini.php
+++ b/ecrire/inc/presentation_mini.php
@@ -80,14 +80,16 @@ function liste_objets_bloques($exec,$contexte=array(),$auteur=null){
 // Elle comporte une image invisible declenchant une tache de fond
 // http://doc.spip.org/@fin_page
 function fin_page(){
+	include_spip('inc/pipelines');
 	// avec &var_profile=1 on a le tableau de mesures SQL
 	$debug = ((_request('exec') !== 'valider_xml')  AND ((_request('var_mode') == 'debug') OR $GLOBALS['tableau_des_temps'] AND isset($_COOKIE['spip_admin'])));
-	return '<div id="pied">'
+	$t = '<div id="pied">'
 	. recuperer_fond('prive/squelettes/inclure/pied')
 	. "</div>"
 	. "</div></div>" // cf. div#page et div.largeur ouvertes dans conmmencer_page()
 	. ($debug?erreur_squelette():'')
 	. "</body></html>\n";
+	return f_queue($t);
 }
 
 function html_tests_js(){
@@ -99,9 +101,6 @@ function html_tests_js(){
 		        . "' width='1' height='1' alt='' /></div></noscript>\n");
 	}
 	return $GLOBALS['rejoue_session']
-	. '<div style="background-image: url(\''
-	. generer_url_action('cron')
-	. '\');"></div>'
 	. (defined('_TESTER_NOSCRIPT') ? _TESTER_NOSCRIPT : '');
 }
 
diff --git a/ecrire/inc/queue.php b/ecrire/inc/queue.php
new file mode 100644
index 0000000000..2ba5f6ded8
--- /dev/null
+++ b/ecrire/inc/queue.php
@@ -0,0 +1,520 @@
+<?php
+
+/***************************************************************************\
+ *  SPIP, Systeme de publication pour l'internet                           *
+ *                                                                         *
+ *  Copyright (c) 2001-2009                                                *
+ *  Arnaud Martin, Antoine Pitrou, Philippe Riviere, Emmanuel Saint-James  *
+ *                                                                         *
+ *  Ce programme est un logiciel libre distribue sous licence GNU/GPL.     *
+ *  Pour plus de details voir le fichier COPYING.txt ou l'aide en ligne.   *
+\***************************************************************************/
+
+if (!defined("_ECRIRE_INC_VERSION")) return;
+
+define('_JQ_SCHEDULED',1);
+define('_JQ_PENDING',0);
+#define('_JQ_MAX_JOBS_EXECUTE',200); // pour personaliser le nombre de jobs traitables a chaque hit
+#define('_JQ_MAX_JOBS_TIME_TO_EXECUTE',15); // pour personaliser le temps d'excution dispo a chaque hit
+#define('_JQ_NB_JOBS_OVERFLOW',10000); // nombre de jobs a partir duquel on force le traitement en fin de hit pour purger
+
+/**
+ * Ajouter une tache a la file
+ * Les taches sont ensuites executees par date programmee croissant/priorite decroissante
+ *
+ * @param $function
+ *   The function name to call.
+ * @param $description
+ *   A human-readable description of the queued job.
+ * @param $arguments
+ *   Optional array of arguments to pass to the function.
+ * @param $file
+ *   Optional file path which needs to be included for $fucntion.
+ * @param $no_duplicate
+ *   If TRUE, do not add the job to the queue if one with the same function and
+ *   arguments already exists.
+ *	 If 'function_only' test of existence is only on function name (for cron job)
+ * @param $time
+ *		time for starting the job. If 0, job will start as soon as possible
+ * @param $priority
+ *		-10 (low priority) to +10 (high priority), 0 is the default
+ * @return int
+ *	id of job
+ */
+function queue_add_job($function, $description, $arguments = array(), $file = '', $no_duplicate = false, $time=0, $priority=0){
+	include_spip('base/abstract_sql');
+
+	// cas pourri de ecrire/action/editer_site avec l'option reload=oui
+	if (defined('_GENIE_SYNDIC_NOW'))
+		$arguments['id_syndic'] = _GENIE_SYNDIC_NOW;
+
+	// serialiser les arguments
+	$arguments = serialize($arguments);
+	$md5args = md5($arguments);
+
+	// si option ne pas dupliquer, regarder si la fonction existe deja
+	// avec les memes args et file
+	if (
+			$no_duplicate
+		AND
+			sql_countsel('spip_jobs',
+				'status='.intval(_JQ_SCHEDULED).' AND fonction='.sql_quote($function)
+				.(($no_duplicate==='function_only')?'':
+				 ' AND md5args='.sql_quote($md5args).' AND inclure='.sql_quote($file)))
+		)
+		return false;
+
+	// si pas de date programee, des que possible
+	if (!$time)
+		$time = time();
+	$date = date('Y-m-d H:i:s',$time);
+
+	$id_job = sql_insertq('spip_jobs',array(
+			'fonction'=>$function,
+			'descriptif'=>$description,
+			'args'=>$arguments,
+			'md5args'=>$md5args,
+			'inclure'=>$file,
+			'priorite'=>max(-10,min(10,intval($priority))),
+			'date'=>$date,
+			'status'=>_JQ_SCHEDULED,
+		));
+
+	// une option de debug pour verifier que les arguments en base sont bons
+	// ie cas d'un char non acceptables sur certains type de champs
+	// qui coupe la valeur
+	if (defined('_JQ_INSERT_CHECK_ARGS') AND $id_job) {
+		$args = sql_getfetsel('args', 'spip_jobs', 'id_job='.intval($id_job));
+		if ($args!==$arguments) {
+			spip_log('arguments job errones / longueur '.strlen($args)." vs ".strlen($arguments).' / valeur : '.var_export($arguments,true),'queue');
+		}
+	}
+
+	if ($id_job){
+		queue_update_next_job_time($time);
+	}
+
+	return $id_job;
+
+}
+
+/**
+ * Purger la file de tache
+ * et reprgrammer les taches periodiques
+ * 
+ * @return void
+ */
+function queue_purger(){
+	include_spip('base/abstract_sql');
+	sql_delete('spip_jobs');
+  sql_delete("spip_jobs_liens","id_job NOT IN (".sql_get_select("id_job","spip_jobs").")");
+  include_spip('inc/genie');
+  genie_queue_watch_dist();
+}
+
+/**
+ * Retirer une tache de la file d'attente
+ * @param int $id_job
+ *  id de la tache a retirer
+ * @return bool
+ */
+function queue_remove_job($id_job){
+	include_spip('base/abstract_sql');
+
+	if ($row = sql_fetsel('fonction,inclure,date','spip_jobs','id_job='.intval($id_job))
+	 AND $res = sql_delete('spip_jobs','id_job='.intval($id_job))){
+		queue_unlink_job($id_job);
+		// est-ce une tache cron qu'il faut relancer ?
+		if ($periode = queue_is_cron_job($row['fonction'],$row['inclure'])){
+			// relancer avec les nouveaux arguments de temps
+			include_spip('inc/genie');
+			// relancer avec la periode prevue
+			queue_genie_replan_job($row['fonction'],$periode,strtotime($row['date']));
+		}
+		queue_update_next_job_time();
+	}
+	return $res;
+}
+
+/**
+ * Associer une tache avec un objet
+ *
+ * @param int $id_job
+ *	id of job to link
+ * @param array $objets
+ *  can be a simple array('objet'=>'article','id_objet'=>23)
+ *  or an array of simple array to link multiples objet in one time
+ */
+function queue_link_job($id_job,$objets){
+	include_spip('base/abstract_sql');
+
+	if (is_array($objets) AND count($objets)){
+		if (is_array(reset($objets))){
+			foreach($objets as $k=>$o){
+				$objets[$k]['id_job'] = $id_job;
+			}
+			sql_insertq_multi('spip_jobs_liens',$objets);
+		}
+		else
+			sql_insertq('spip_jobs_liens',array_merge(array('id_job'=>$id_job),$objets));
+	}
+}
+
+/**
+ * Dissocier une tache d'un objet
+ *
+ * @param int $id_job
+ *	id of job to unlink ibject with
+ * @return int/bool
+ *	result of sql_delete
+ */
+function queue_unlink_job($id_job){
+	return sql_delete("spip_jobs_liens","id_job=".intval($id_job));
+}
+
+/**
+ * Lancer une tache decrite par sa ligne SQL
+ * @param array $row
+ *	describe the job, with field of table spip_jobs
+ * @return mixed
+ *	return the result of job
+ */
+function queue_start_job($row){
+
+	// deserialiser les arguments
+	$args = unserialize($row['args']);
+	if ($args===false){
+		spip_log('arguments job errones '.var_export($row,true),'queue');
+		$args = array();
+	}
+
+	$fonction = $row['fonction'];
+	if (strlen($inclure = trim($row['inclure']))){
+		if (substr($inclure,-1)=='/'){ // c'est un chemin pour charger_fonction
+			$f = charger_fonction($fonction,rtrim($inclure,'/'),false);
+			if ($f)
+				$fonction = $f;
+		}
+		else
+			include_spip($inclure);
+	}
+
+	if (!function_exists($fonction)){
+		spip_log("fonction $fonction ($inclure) inexistante ".var_export($row,true),'queue');
+		return false;
+	}
+
+	spip_log("queue [".$row['id_job']."]: $fonction() start", 'queue');
+	switch (count($args)) {
+		case 0:	$res = $fonction(); break;
+		case 1:	$res = $fonction($args[0]); break;
+		case 2:	$res = $fonction($args[0],$args[1]); break;
+		case 3:	$res = $fonction($args[0],$args[1], $args[2]); break;
+		case 4:	$res = $fonction($args[0],$args[1], $args[2], $args[3]); break;
+		case 5:	$res = $fonction($args[0],$args[1], $args[2], $args[3], $args[4]); break;
+		case 6:	$res = $fonction($args[0],$args[1], $args[2], $args[3], $args[4], $args[5]); break;
+		case 7:	$res = $fonction($args[0],$args[1], $args[2], $args[3], $args[4], $args[5], $args[6]); break;
+		case 8:	$res = $fonction($args[0],$args[1], $args[2], $args[3], $args[4], $args[5], $args[6], $args[7]); break;
+		case 9:	$res = $fonction($args[0],$args[1], $args[2], $args[3], $args[4], $args[5], $args[6], $args[7], $args[8]); break;
+		case 10:$res = $fonction($args[0],$args[1], $args[2], $args[3], $args[4], $args[5], $args[6], $args[7], $args[8], $args[9]); break;
+		default:
+			# plus lent mais completement generique
+			$res = call_user_func_array($fonction, $args);
+	}
+	spip_log("queue [".$row['id_job']."]: $fonction() end", 'queue');
+	return $res;
+
+}
+
+/**
+ * Scheduler :
+ * Prend une par une les taches en attente
+ * et les lance, dans la limite d'un temps disponible total
+ * et d'un nombre maxi de taches
+ *
+ * La date de la prochaine tache a executer est mise a jour
+ * apres chaque chaque tache finie
+ * afin de relancer le scheduler uniquement quand c'est necessaire
+ *
+ * @param array $force_jobs
+ *   list of id_job to execute when provided
+ */
+function queue_schedule($force_jobs = null){
+	$time = time();
+	if (defined('_DEBUG_BLOCK_QUEUE')) {
+		spip_log("_DEBUG_BLOCK_QUEUE : schedule stop",'jq'._LOG_DEBUG);
+		return;
+	}
+
+	// rien a faire si le prochain job est encore dans le futur
+	if (queue_sleep_time_to_next_job() AND (!$force_jobs OR !count($force_jobs))){
+		spip_log("queue_sleep_time_to_next_job",'jq'._LOG_DEBUG);
+		return;
+	}
+
+	include_spip('base/abstract_sql');
+
+	if (!defined('_JQ_MAX_JOBS_TIME_TO_EXECUTE')){
+		$max_time = ini_get('max_execution_time')/2;
+		// valeur conservatrice si on a pas reussi a lire le max_execution_time
+		if (!$max_time) $max_time=5;
+		define('_JQ_MAX_JOBS_TIME_TO_EXECUTE',min($max_time,15)); // une valeur maxi en temps.
+	}
+	$end_time = $time + _JQ_MAX_JOBS_TIME_TO_EXECUTE;
+
+	spip_log("JQ schedule $time / $end_time",'jq'._LOG_DEBUG);
+
+	if (!defined('_JQ_MAX_JOBS_EXECUTE'))
+		define('_JQ_MAX_JOBS_EXECUTE',200);
+	$nbj=0;
+	// attraper les jobs
+	// dont la date est passee (echus en attente),
+	// par odre :
+	//	- de priorite
+	//	- de date
+	// lorsqu'un job cron n'a pas fini, sa priorite est descendue
+	// pour qu'il ne bloque pas les autres jobs en attente
+	if (is_array($force_jobs) AND count($force_jobs))
+		$cond = "status=".intval(_JQ_SCHEDULED)." AND ".sql_in("id_job", $force_jobs);
+	else {
+		$now = date('Y-m-d H:i:s',$time);
+		$cond = "status=".intval(_JQ_SCHEDULED)." AND date<".sql_quote($now);
+	}
+
+	register_shutdown_function('queue_error_handler'); // recuperer les erreurs auant que possible
+	$res = sql_allfetsel('*','spip_jobs',$cond,'','priorite DESC,date','0,'.(_JQ_MAX_JOBS_EXECUTE+1));
+	do {
+		if ($row = array_shift($res)){
+			$nbj++;
+			// il faut un verrou, a base de sql_delete
+			if (sql_delete('spip_jobs',"id_job=".intval($row['id_job'])." AND status=".intval(_JQ_SCHEDULED))){
+				#spip_log("JQ schedule job ".$nbj." OK",'jq');
+				// on reinsert dans la base aussitot avec un status=_JQ_PENDING
+				$row['status'] = _JQ_PENDING;
+				$row['date'] = $time;
+				sql_insertq('spip_jobs', $row);
+
+				// on a la main sur le job :
+				// l'executer
+				$result = queue_start_job($row);
+
+				$time = time();
+				queue_close_job($row, $time, $result);
+			}
+		}
+		spip_log("JQ schedule job end time ".$time,'jq'._LOG_DEBUG);
+	} while ($nbj<_JQ_MAX_JOBS_EXECUTE AND $row AND $time<$end_time);
+	spip_log("JQ schedule end time ".time(),'jq'._LOG_DEBUG);
+
+	if ($row = array_shift($res)){
+		queue_update_next_job_time(0); // on sait qu'il y a encore des jobs a lancer ASAP
+		spip_log("JQ encore !",'jq'._LOG_DEBUG);
+	}
+	else
+		queue_update_next_job_time();
+
+}
+
+/**
+ * Terminer un job au status _JQ_PENDING :
+ *  - le reprogrammer si c'est un cron
+ *  - supprimer ses liens
+ *  - le detruire en dernier
+ *
+ * @param array $row
+ * @param int $time
+ * @param int $result
+ */
+function queue_close_job(&$row,$time,$result=0){
+	// est-ce une tache cron qu'il faut relancer ?
+	if ($periode = queue_is_cron_job($row['fonction'],$row['inclure'])){
+		// relancer avec les nouveaux arguments de temps
+		include_spip('inc/genie');
+		if ($result<0)
+			// relancer tout de suite, mais en baissant la priorite
+			queue_genie_replan_job($row['fonction'],$periode,0-$result/*last*/,0/*ASAP*/,$row['priorite']-1);
+		else
+			// relancer avec la periode prevue
+			queue_genie_replan_job($row['fonction'],$periode,$time);
+	}
+	// purger ses liens eventuels avec des objets
+	sql_delete("spip_jobs_liens","id_job=".intval($row['id_job']));
+	// supprimer le job fini
+	sql_delete('spip_jobs','id_job='.intval($row['id_job']));
+}
+
+/**
+ * Recuperer des erreurs auant que possible
+ * en terminant la gestion de la queue
+ */
+function queue_error_handler(){
+	// se remettre dans le bon dossier, car Apache le change parfois (toujours?)
+	chdir(_ROOT_CWD);
+
+	queue_update_next_job_time();
+}
+
+
+/**
+ * Tester si une tache etait une tache periodique a reprogrammer
+ *
+ * @param <type> $function
+ * @param <type> $inclure
+ * @return <type>
+ */
+function queue_is_cron_job($function,$inclure){
+	static $taches = null;
+	if (strncmp($inclure,'genie/',6)==0){
+		if (is_null($taches)){
+			include_spip('inc/genie');
+			$taches = taches_generales();
+		}
+		if (isset($taches[$function]))
+			return $taches[$function];
+	}
+	return false;
+}
+
+/**
+ * Mettre a jour la date du prochain job a lancer
+ * Si une date est fournie (au format time unix)
+ * on fait simplement un min entre la date deja connue et celle fournie
+ * (cas de l'ajout simple
+ * ou cas $next_time=0 car l'on sait qu'il faut revenir ASAP)
+ *
+ * @param int $next_time
+ *	temps de la tache ajoutee ou 0 pour ASAP
+ */
+function queue_update_next_job_time($next_time=null){
+	static $nb_jobs_scheduled = null;
+	static $deja_la = false;
+	// prendre le min des $next_time que l'on voit passer ici, en cas de reentrance
+	static $next = null;
+	if (!is_null($next_time)){
+		if (is_null($next) OR $next>$next_time)
+			$next = $next_time;
+	}
+	// queue_close_job peut etre reentrant ici
+	if ($deja_la) return;
+	$deja_la = true;
+
+	include_spip('base/abstract_sql');
+	$time = time();
+
+	// traiter les jobs morts au combat (_JQ_PENDING depuis plus de 180s)
+	// pour cause de timeout ou autre erreur fatale
+	$res = sql_allfetsel("*","spip_jobs","status=".intval(_JQ_PENDING)." AND date<".sql_quote(date('Y-m-d H:i:s',$time-180)));
+	if (is_array($res)) {
+		foreach ($res as $row)
+			queue_close_job($row,$time);
+	}
+
+	// chercher la date du prochain job si pas connu
+	if (is_null($next) OR is_null(queue_sleep_time_to_next_job())){
+		$date = sql_getfetsel('date','spip_jobs',"status=".intval(_JQ_SCHEDULED),'','date','0,1');
+		$next = strtotime($date);
+	}
+	else {
+		if ($next){
+			if (is_null($nb_jobs_scheduled))
+				$nb_jobs_scheduled = sql_countsel('spip_jobs',"status=".intval(_JQ_SCHEDULED)." AND date<".sql_quote(date('Y-m-d H:i:s',$time)));
+			elseif ($next<=$time)
+				$nb_jobs_scheduled++;
+			// si trop de jobs en attente, on force la purge en fin de hit
+			// pour assurer le coup
+			if ($nb_jobs_scheduled>defined('_JQ_NB_JOBS_OVERFLOW')?_JQ_NB_JOBS_OVERFLOW:10000)
+				define('_DIRECT_CRON_FORCE',true);
+		}
+	}
+
+	queue_set_next_job_time($next);
+	$deja_la = false;
+}
+
+
+/**
+ * Mettre a jour la date de prochain job
+ * @param int $next
+ */
+function queue_set_next_job_time($next) {
+
+	// utiliser le temps courant reel plutot que temps de la requete ici
+	$time = time();
+
+	// toujours relire la valeur pour comparer, pour tenir compte des maj concourrantes
+	// et ne mettre a jour que si il y a un interet a le faire
+	// permet ausis d'initialiser le nom de fichier a coup sur
+	$curr_next = $_SERVER['REQUEST_TIME'] + queue_sleep_time_to_next_job(true);
+	if (
+			($curr_next<$time AND $next>$time) // le prochain job est dans le futur mais pas la date planifiee actuelle
+			OR $curr_next>$next // le prochain job est plus tot que la date planifiee actuelle
+		) {
+		if (include_spip('inc/memoization') AND defined('_MEMOIZE_MEMORY') AND _MEMOIZE_MEMORY) {
+			cache_set(_JQ_NEXT_JOB_TIME_FILENAME,intval($next));
+		}
+		else {
+			ecrire_fichier(_JQ_NEXT_JOB_TIME_FILENAME,intval($next));
+		}
+		queue_sleep_time_to_next_job($next);
+	}
+
+	return queue_sleep_time_to_next_job();
+}
+
+/**
+ * html a ajouter a la page pour declencher le cron
+ * ou rien si on a reussi a le lancer en asynchrone
+ * @return string
+ */
+function queue_affichage_cron(){
+	$texte = "";
+
+	// rien a faire si le prochain job est encore dans le futur
+	if (queue_sleep_time_to_next_job() OR defined('_DEBUG_BLOCK_QUEUE'))
+		return $texte;
+
+	// il y a des taches en attentes
+
+	// Si fsockopen est possible, on lance le cron via un socket
+	// en asynchrone
+	if(function_exists('fsockopen')){
+		$url = generer_url_action('cron','',false,true);
+		$parts=parse_url($url);
+
+		$fp = @fsockopen($parts['host'],
+	        isset($parts['port'])?$parts['port']:80,
+	        $errno, $errstr, 30);
+
+		if ($fp) {
+			$query = $parts['path'].($parts['query']?"?".$parts['query']:"");
+			$out = "GET ".$query." HTTP/1.1\r\n";
+			$out.= "Host: ".$parts['host']."\r\n";
+			$out.= "Connection: Close\r\n\r\n";
+			fwrite($fp, $out);
+			fclose($fp);
+			return $texte;
+		}
+	}
+
+	// ici lancer le cron par un CURL asynchrone si CURL est présent
+	// TBD
+
+	// si deja force, on retourne sans rien
+	if (defined('_DIRECT_CRON_FORCE'))
+		return $texte;
+	// si c'est un bot
+	// inutile de faire un appel par image background,
+	// on force un appel direct en fin de hit
+	if ((defined('_IS_BOT') AND _IS_BOT)){
+		define('_DIRECT_CRON_FORCE',true);
+		return $texte;
+	}
+
+	// en derniere solution, on insere une image background dans la page
+	$texte = '<!-- SPIP-CRON --><div style="background-image: url(\'' .
+		generer_url_action('cron') .
+		'\');"></div>';
+
+	return $texte;
+}
+?>
\ No newline at end of file
diff --git a/ecrire/inc/utils.php b/ecrire/inc/utils.php
index 2e49c22ee6..e81937f381 100644
--- a/ecrire/inc/utils.php
+++ b/ecrire/inc/utils.php
@@ -554,49 +554,129 @@ function action_cron() {
 	include_spip('inc/headers');
 	http_status(204); // No Content
 	header("Connection: close");
-	cron (2);
-}
-
-// cron() : execution des taches de fond
-// Le premier argument indique l'intervalle demande entre deux taches
-// par defaut, 60 secondes (quand il est appele par public.php)
-// il vaut 2 quand il est appele par ?action=cron, voire 0 en urgence
-// On peut lui passer en 2e arg le tableau de taches attendu par inc_genie()
-// Retourne Vrai si un tache a pu etre effectuee
-
-// http://doc.spip.org/@cron
-function cron ($gourmand=false, $taches= array()) {
-
-	// Si on est gourmand, ou si le fichier gourmand n'existe pas
-	// ou est trop vieux (> 60 sec), on va voir si un cron est necessaire.
-	// Au passage si on est gourmand on le dit aux autres
-	if (spip_touch(_DIR_TMP.'cron.lock-gourmand', 60, $gourmand)
-	OR ($gourmand!==false)) {
-
-	// Le fichier cron.lock indique la date de la derniere tache
-	// Il permet d'imposer qu'il n'y ait qu'une tache a la fois
-	// et 2 secondes minimum entre chaque:
-	// ca soulage le serveur et ca evite
-	// les conflits sur la base entre taches.
-
-	if (spip_touch(_DIR_TMP.'cron.lock',
-			(is_int($gourmand) ? $gourmand : 2))) {
-			// Si base inaccessible, laisser tomber.
-			if (!spip_connect()) return false;
-
-			$genie = charger_fonction('genie', 'inc', true);
-			if ($genie) {
-				$genie($taches);
-				// redater a la fin du cron
-				// car il peut prendre plus de 2 secondes.
-				spip_touch(_DIR_TMP.'cron.lock', 0);
-				return true;
-			}
-		}# else spip_log("busy");
+	define('_DIRECT_CRON_FORCE',true);
+	cron();
+}
+
+/**
+ * cron() : execution des taches de fond
+ * On peut lui passer en 1er (ou 2e arg pour compat)
+ * le tableau de taches attendu par inc_genie()
+ * Retourne Vrai si un tache a pu etre effectuee
+ * pas de verrou ici : les verrous sont geres sur chaque tache
+ * a chaque execution
+ *
+ * http://doc.spip.org/@cron
+ *
+ * @param array $taches
+ *   taches forcees
+ * @param array $taches_old
+ *   taches forcees, pour compat avec ancienne syntaxe
+ * @return bool
+ */
+function cron ($taches=array(), $taches_old= array()) {
+	// si pas en mode cron force
+	// ou si base inaccessible, laisser tomber.
+	if (!defined('_DIRECT_CRON_FORCE') OR !spip_connect()) return false;
+	spip_log("cron !",'jq'._LOG_DEBUG);
+	if (!is_array($taches)) $taches = $taches_old; // compat anciens appels
+	if ($genie = charger_fonction('genie', 'inc', true)) {
+		$genie($taches);
+		return true;
 	}
 	return false;
 }
 
+/**
+ * Ajout d'une tache dans la file d'attente
+ *
+ * @param $function
+ *   The function name to call.
+ * @param $description
+ *   A human-readable description of the queued job.
+ * @param $arguments
+ *   Optional array of arguments to pass to the function.
+ * @param $file
+ *   Optional file path which needs to be included for $fucntion.
+ * @param $no_duplicate
+ *   If TRUE, do not add the job to the queue if one with the same function and
+ *   arguments already exists.
+ * @param $time
+ *		time for starting the job. If 0, job will start as soon as possible
+ * @param $priority
+ *		-10 (low priority) to +10 (high priority), 0 is the default
+ * @return int
+ *	id of job
+ */
+function job_queue_add($function, $description, $arguments = array(), $file = '', $no_duplicate = FALSE, $time=0, $priority=0) {
+	include_spip('inc/queue');
+	return queue_add_job($function, $description, $arguments, $file, $no_duplicate, $time, $priority);
+}
+
+/**
+ * Supprimer une tache de la file d'attente
+ * @param int $id_job
+ *  id of jonb to delete
+ * @return bool
+ */
+function job_queue_remove($id_job){
+	include_spip('inc/queue');
+	return queue_remove_job($id_job);
+}
+
+/**
+ * Associer une tache a un/des objets de SPIP
+ * @param int $id_job
+ *	id of job to link
+ * @param array $objets
+ *  can be a simple array('objet'=>'article','id_objet'=>23)
+ *  or an array of simple array to link multiples objet in one time
+ */
+function job_queue_link($id_job,$objets){
+	include_spip('inc/queue');
+	return queue_link_job($id_job,$objets);
+}
+
+
+/**
+ * Renvoyer le temps de repos restant jusqu'au prochain job
+ * 0 si un job est a traiter
+ * null si la queue n'est pas encore initialise
+ * $force est utilisee par queue_set_next_job_time() pour maj la valeur
+ *  - si true, force la relecture depuis le fichier
+ *  - si int, affecte la static directement avec la valeur
+ *
+ * @staticvar int $queue_next_job_time
+ * @param int/bool $force_next
+ * @return int
+ */
+function queue_sleep_time_to_next_job($force=null) {
+	static $queue_next_job_time = -1;
+	if ($force===true)
+		$queue_next_job_time = -1;
+	elseif ($force)
+		$queue_next_job_time = $force;
+
+	if ($queue_next_job_time==-1) {
+		define('_JQ_NEXT_JOB_TIME_FILENAME',_DIR_TMP . "job_queue_next.txt");
+		// utiliser un cache memoire si dispo
+		if (include_spip('inc/memoization') AND defined('_MEMOIZE_MEMORY') AND _MEMOIZE_MEMORY) {
+			$queue_next_job_time = cache_get(_JQ_NEXT_JOB_TIME_FILENAME);
+		}
+		else {
+			$queue_next_job_time = null;
+			if (lire_fichier(_JQ_NEXT_JOB_TIME_FILENAME, $contenu))
+				$queue_next_job_time = intval($contenu);
+		}
+	}
+
+	if (is_null($queue_next_job_time))
+		return null;
+	if (!$_SERVER['REQUEST_TIME'])
+		$_SERVER['REQUEST_TIME'] = time();
+	return max(0,$queue_next_job_time-$_SERVER['REQUEST_TIME']);
+}
+
 
 // transformation XML des "&" en "&amp;"
 // http://doc.spip.org/@quote_amp
diff --git a/ecrire/inc_version.php b/ecrire/inc_version.php
index 421b1ce230..9ba7744411 100644
--- a/ecrire/inc_version.php
+++ b/ecrire/inc_version.php
@@ -321,7 +321,7 @@ $spip_version_branche = "2.3.0-dev";
 // (= numero SVN de leur derniere modif cassant la compatibilite et/ou necessitant un recalcul des squelettes)
 $spip_version_code = 17563;
 // version de la base SQL (= numero SVN de sa derniere modif)
-$spip_version_base = 17563;
+$spip_version_base = 17577;
 
 // version de l'interface a la base
 $spip_sql_version = 1;
diff --git a/ecrire/lang/ecrire_fr.php b/ecrire/lang/ecrire_fr.php
index 230c7e2ee3..2955a9a58d 100644
--- a/ecrire/lang/ecrire_fr.php
+++ b/ecrire/lang/ecrire_fr.php
@@ -986,6 +986,14 @@ dans une couleur qui indique leur état :',
 'plugins_vue_liste' => 'Liste',
 'protocole_ldap' => 'Version du protocole :',
 
+// Q
+'queue_executer_maintenant' => 'Exécuter maintenant',
+'queue_nb_jobs_in_queue' => '@nb@ travaux en attente',
+'queue_next_job_in_nb_sec' => 'Prochain travail dans @nb@ s',
+'queue_one_job_in_queue' => '1 travail en attente',
+'queue_purger_queue' => 'Purger la liste des travaux',
+'queue_titre' => 'Liste de travaux',
+
 // R
 'repertoire_plugins' => 'Répertoire :',
 
diff --git a/ecrire/maj/svn10000.php b/ecrire/maj/svn10000.php
index 2702b9f7c5..d269871e61 100644
--- a/ecrire/maj/svn10000.php
+++ b/ecrire/maj/svn10000.php
@@ -342,4 +342,8 @@ $GLOBALS['maj'][17563] = array(
 	array('sql_update','spip_articles',array('virtuel'=>'SUBSTRING(chapo,2)','chapo'=>"''"),"chapo LIKE '=_%'"),
 );
 
+$GLOBALS['maj'][17577] = array(
+	array('maj_tables',array('spip_jobs','spip_jobs_liens')),
+);
+
 ?>
\ No newline at end of file
diff --git a/ecrire/public.php b/ecrire/public.php
index f95123f5ff..fa3f6f10ef 100644
--- a/ecrire/public.php
+++ b/ecrire/public.php
@@ -207,15 +207,8 @@ if (isset($GLOBALS['_INC_PUBLIC'])) {
 		}
 
 		// Effectuer une tache de fond ?
-		// si #SPIP_CRON est present, on ne le tente que pour les navigateurs
-		// en mode texte (par exemple), et seulement sur les pages web
-		if (defined('_DIRECT_CRON_FORCE')
-			OR (
-			!defined('_DIRECT_CRON_INHIBE')
-			AND $html
-			AND !strstr($page['texte'], '<!-- SPIP-CRON -->')
-			AND !preg_match(',msie|mozilla|opera|konqueror,i', $_SERVER['HTTP_USER_AGENT']))
-			)
+		// si _DIRECT_CRON_FORCE est present, on force l'appel
+		if (defined('_DIRECT_CRON_FORCE'))
 			cron();
 
 		// sauver le cache chemin si necessaire
diff --git a/ecrire/public/balises.php b/ecrire/public/balises.php
index 2c3e93e58f..0e9cc0987c 100644
--- a/ecrire/public/balises.php
+++ b/ecrire/public/balises.php
@@ -330,20 +330,6 @@ function balise_FIN_SURLIGNE_dist($p) {
 }
 
 
-// #SPIP_CRON
-// a documenter
-// insere un <div> avec un lien background-image vers les taches de fond.
-// Si cette balise est presente sur la page de sommaire, le site ne devrait
-// quasiment jamais se trouver ralenti par des taches de fond un peu lentes
-// http://doc.spip.org/@balise_SPIP_CRON_dist
-function balise_SPIP_CRON_dist ($p) {
-	$p->code = '"<!-- SPIP-CRON --><div style=\"background-image: url(\'' .
-		generer_url_action('cron') .
-		'\');\"></div>"';
-	$p->interdire_scripts = false;
-	return $p;
-}
-
 // #INTRODUCTION
 // #INTRODUCTION{longueur}
 // http://www.spip.net/@introduction
diff --git a/prive/modeles/object_jobs_list.html b/prive/modeles/object_jobs_list.html
new file mode 100644
index 0000000000..ad7025788d
--- /dev/null
+++ b/prive/modeles/object_jobs_list.html
@@ -0,0 +1,17 @@
+<B_jobs>
+	<div class="jobs_liste jobs_liste_#ENV{objet}">
+	#ANCRE_PAGINATION
+	<ul class="liste_items jobs">
+		<BOUCLE_jobs(JOBS){par date}{pagination 5}{objet}{id_objet}>
+			<li class="item">
+				<div class="date">[(#DATE|date_relative)]</div>
+				[<strong class="description">(#DESCRIPTIF|PtoBR)</strong>]
+				[(#AUTORISER{'annuler','job',#ID_JOB}|oui)
+				<div class="actions">[(#BOUTON_ACTION{<:annuler:>,#URL_ACTION_AUTEUR{annuler_job,#ID_JOB,#SELF},ajax})]</div>
+				]
+			</li>
+		</BOUCLE_jobs>
+	</ul>
+	[<p class="pagination">(#PAGINATION)</p>]
+	</div>
+</B_jobs>
\ No newline at end of file
diff --git a/prive/squelettes/contenu/job_queue.html b/prive/squelettes/contenu/job_queue.html
new file mode 100644
index 0000000000..e4a0c7a8f3
--- /dev/null
+++ b/prive/squelettes/contenu/job_queue.html
@@ -0,0 +1,41 @@
+#CACHE{0}
+[(#AUTORISER{administrer,queue}|sinon_interdire_acces)]
+<?php
+// bloquer la queue sur ce hit
+// pour avoir coherence entre l'affichage de la liste de jobs
+// et les jobs en base en fin de hit
+define('_DEBUG_BLOCK_QUEUE',true);
+include_spip('inc/genie');
+genie_queue_watch_dist();
+?>
+<h1><:queue_titre:></h1>
+<B_jobs>
+	<h3>[(#GRAND_TOTAL|singulier_ou_pluriel{queue_one_job_in_queue,queue_nb_jobs_in_queue})]</h3>
+	<p>#SET{nb,#REM|queue_sleep_time_to_next_job}<:queue_next_job_in_nb_sec{nb=#GET{nb}}:></p>
+	[<p class="pagination">(#PAGINATION{prive})</p>]
+	<ul class="liste-items">
+		<BOUCLE_jobs(jobs){par date}{pagination 20}>
+			<li class="item[ (#STATUS|?{'scheduled','pending'})]">
+				<div class="date">[(#DATE|date_relative)][ (#STATUS|non)(en cours)][(#PRIORITE|oui)&#91;#PRIORITE&#93;]</div>
+				[<strong class="description">(#DESCRIPTIF|PtoBR)</strong>]
+				[<span class="small">| #FONCTION((#ARGS|unserialize|implode{','}))</span>]
+				[(#AUTORISER{'annuler','job',#ID_JOB}|oui)
+				<div class="actions">
+					[(#BOUTON_ACTION{<:annuler:>,#URL_ACTION_AUTEUR{annuler_job,#ID_JOB,#SELF},ajax})]
+					[(#BOUTON_ACTION{<:queue_executer_maintenant:>,#URL_ACTION_AUTEUR{forcer_job,#ID_JOB,#SELF},ajax})]
+				</div>
+				]
+			</li>
+		</BOUCLE_jobs>
+	</ul>
+	[<p class="pagination">(#PAGINATION{prive})</p>]
+	[(#AUTORISER{'purger','queue'}|oui)
+	<div class="actions">
+		[(#BOUTON_ACTION{<:queue_purger_queue:>,#URL_ACTION_AUTEUR{purger_queue,'',#SELF},ajax})]
+	</div>
+	]
+</B_jobs>
+<script type="text/javascript">/*<![CDATA[*/
+function queue_reload(){jQuery('h1').ajaxReload();}
+if (window.jQuery) setTimeout(queue_reload,60000);
+/*]]>*/</script>
\ No newline at end of file
diff --git a/prive/themes/spip/images/queue-process-16.png b/prive/themes/spip/images/queue-process-16.png
new file mode 100644
index 0000000000000000000000000000000000000000..54805d4d9875ac5bd10194a74d685721aae72a33
GIT binary patch
literal 803
zcmV+;1Kj+HP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV0000PbVXQnQ*UN;
zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!#Ysd#RCwByQ{78bVHp3N_ne*0{MbZ{
z{MyW^Y$2u1@{4GRR-#3Ak#-S8)J>P&RAhlJQi6gYO2Z%s%A5Xygv{oJwauWK<ww?q
zMu}JxZC~f3=bUY21Vs-#+xxt{&-?y<zvp*2TkkNmP$?x@!CFjStjNpC%@TQhjY#m%
ze*@2P`Wfw)F%AZvk}((sRw^JFs;VL)DW3lJt+}0*1^8PG&n&`hGNASD_#jJdDs(|O
z7C(_{mZ;WJUYBa}Z12c>L<wziPP$u2!KWJT(vA6Ay&9AC3)crD8a%;I6BwS(E#c^c
zRfx0}QUcHm8JWgJ8IfgZ{aO>JCGn+hr16Q%1UdS0;ljaF`ueAU(THPvzF9vIlN4&=
zsYjrHdYKwM=g&f*_2Dz!h%4&!z_q@B!tqH2bcL+QyIqB5Qa(=nR65LXv*#kr*%s_<
z+=u%51`Pzpp8GIR@}*-fEOcm`SYiSsqAWYI(?qP$=rn<1iUJEYA82dCyTCY#Z8lh~
zHe`IBLgU{3gxvrpi1;-|UaWBynDKgXg~)4U88($UG3p(IeRB!CUayYS>D+>mXI{+A
z&J5b^rC|C52!dsUsVHF<j?BX2p647)u`pa!RR~T_!m+0X?MFKBCKyCjWhI)Mnop8<
zR3R=aD!C7u*|yynC4n?F1{_M7OC~BRO(n0`j+{)|qgv6Yr0OX|N%2s3%O7t0IB&)5
z+z0smK9rZ2<MEyA`m`JlCjt`_c;WM1{j@Ouicha^Z-v2FP-rn>>3a;z;W(C~^g?<z
zo;(_Y+wI2N$snq$cc7@qimB;o)Ya7?6bdyA^xr<lWi=gd5_feD6BXTky8nnzj1$qr
zykMdc_ah~^<Rq;~*oQ+yr)z3!Z%L9wS~2}IGg4OIAJN^_<(Fvc9#4<vwlZz+@|C^-
h{v%w;{4IV6FaR35CW1<ctrh?P002ovPDHLkV1ffOdl&!!

literal 0
HcmV?d00001

diff --git a/prive/themes/spip/images/queue-process-24.png b/prive/themes/spip/images/queue-process-24.png
new file mode 100644
index 0000000000000000000000000000000000000000..e55cd17cdd1f6282ce1fedc0907054f3f186b8f2
GIT binary patch
literal 1385
zcmV-v1(y1WP)<h;3K|Lk000e1NJLTq000;O000;W1^@s6;CDUv0000PbVXQnQ*UN;
zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU%3rR#lRCwC7Rc&ljRTzHmN85GX?Y--u
z+t}7^9UFA41}K7p5jDz(fJk%!eh}0E#>4<oPzfp@PDG7H-5<jU{<B+#A)+x7KFp9A
zP__YsWlZYUG2Cd`)^`0|d++hw4l=sM0WrSGX>QMb-t)ZYea?9<gQ7VY9|T~yqQZB+
zsn-QOxr+M#KEooV;cUp*AFkcA7@W3`M~-zoie>2!|J+lBMn?(HGl~0Moz{_W)S~TN
zATKQD%wvvKf6rj{lJP)IhJDRv2QN7nh<MOA@uvsYNdn$&@(tEj&VPv4I98`3<Oq}@
z|A+hMj4VE!P-wgEy|9*J5sbyKqbUOCDl_ae^qEdh`!pZ_c6%x^bPEL=_S-BvKp_Ni
zMH!iACh81B34oRW(01tKX8Wf-JzTCO1MvYDkId{;bY_mB;_=04%I)eC%Dn|9grYSZ
zx)UtIzc@cKyjFEMRQ<YI63au(9|KpW*rpW<s?1SJpzh*6v`t3WK$H?0*wDdG?o7)(
zcugug5?N@=PB*IbF-4{@W<m)_vi~CpZ(x|#j40UAHF<y=BQjG`BI=SEu>_%ba!&$1
z&m&HEdd2PW(IegoCJq=&fPjk<#Nv0MX)j~GNrLV+7wtcpNL->0S^&$y7gccC8}@=z
zdf>aMY6(brl%T(LG{buxX{&abB$^bA%NUjkxG@9_Miyl`F3G^~S{SuF+GBv{=;03d
zgM&(J2&_&IQIgaa@uS(3i_^`^*V;sqKsc_@9Z->(@nvguI>coKqCtngem|Ubb%<GK
zLEmxoYSq4NXgJ$+mj-f5dTxQ1WT$Ic+zKZk%j)*WS`-JF&b1srhIk@@e!m}&l-n`A
zycC}aOu@yDuCrq9UuyMB!r`!w*PBg<v{Oc7AzeT&DakdNG_<Dys!g0~!pNxvf@s9=
zCys*&1~7fvR1AeOVK8Okde05i*40-Ek^qGkHhZ>XH^*^XS-4tY4Aabjn;tQZ$&#f5
zM+pX^GCihb%uo;+j-$Xn6E1H*($X_fWU=7Lksr|5&;Ya9Of}>2!QLuQb8~ZDYwIO&
z8mb~^-G-Qd5YT0ozp-IerA^erK--j?p~Ymw=$K;4fD-iKRJ{{rRx8zk;Pjba@yxS}
zu%pov9XRC-SxU>&G9^hn@6iRz8qb_Q#v|W0HO~3BV`yj?Qn6!8Z8Hv28@V<Zg-U+s
znyN=qFG%p6KaQ$d1}u7h1!`&zp&&mG)9rS&cb_~XwKcewuU-G@n$?w>F8B4koo~N2
zmtm!%jqm?<>A<=|0Reh6JKeqL9ZEpZaY(Tt%B9E*(n$n28;>B$=rPwZ3!PW5pjB5O
ze|Pc^_m<+A_{0nAvKmgEkb~i{zxs>MUj)+4wxxCL0rCLhy3tEEZQn_{T%E-2a*^G;
z_bD}BeM!9DTja*yePr|2ZGr5ZoK5tuP)lG1pzPuIp8UFg?_*gS_(BQ%tH03~e3l-8
z^B1v_=|EBWOkC^g#=-^jla6|_;WSF8mVu|Oed(2#t1mP+ZDu%L^&&=;4cQy14^5&-
zc>;4_Kv;UySpS>rS8i~1bq_bSTnM&azC2I8-4zuT=Nt})Xe};gYibTGq6uzFz$Xv5
zER_NY)m-EWzAox}ddt%2d(YJwbiz+tv2@w;6}wc={prV}dwTo&ex~z6h9I;heG30Q
r^y+8o_Vb`>_$l5+2*Lju{|GPuj4etZ7>eb%00000NkvXXu0mjfnhBE)

literal 0
HcmV?d00001

diff --git a/prive/themes/spip/images/queue-process-32.png b/prive/themes/spip/images/queue-process-32.png
new file mode 100644
index 0000000000000000000000000000000000000000..da5b0d5eb929bae9c23a856913eeea89e9ea0677
GIT binary patch
literal 1602
zcmV-I2EF--P)<h;3K|Lk000e1NJLTq001BW001Be1^@s6b9#F80000PbVXQnQ*UN;
zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU%<Vi$9RCwCVRtaoWRTMpcnVH{p=#;L|
zLJ<peOG=9vL$qZH4hV?|)<g}9B!o6V6oOF^#0617O_Uf2VUsABro{rHh*+0Gi?kA;
zl%=H`#jdn;hR#f9{(SHI{gYIfw$s`~UvsDb{rBF#=iYnneV0=d1^?yXeApwHHWX}e
zKv*xk6(~bg!t)$-BnFXpiVo|d+wUzrO0V4mofVC5967S4;Ic9t!{Tr%3l5x9LN=XH
zj8A2U4G7$ny(44(z7l1)#)OYfD;+kca;&0J34J}kfEg(E13>2r3}3takyv%&a=YPH
z2Jd<%qQIl9;V!}h^=R(0Bi7^x=%v}T<kuen$stHH=S_-)i-_u!mBF)zZKHim$?2+A
z)OFbK(l-|f5XV62>9wIh0Qy<+VG?`jb}{Sk1K^`Kcm`AePTgoh?Q65lwSZGwYG;n^
z3jk+avNhe~awoapqn{gO1}NHr&Tbd`SFI^lKTFcp7XU%wgwPq|N2(K99$NabGyXb3
zeJ-u<@L1jak2LEG0E}T%P5$~G0{)9)#S;l`ciJH;uEuU}dEQt4D~5pdv}hx`DE_Wq
z^dD>pbVRu7_AYX%Z1Glj`UD^=oG~OoPXv@9_mh7E0TRdq=Wlg@>ufG$(exF7;MPeA
zqk|>Xll$Z$grN5c20&2UD(ig)5Tw_kCk5r9Uo)C}s@utk=IRn(0dOHp-cOx8+Nh<V
z(yyzz`(~{0aP-3o0)nL;5}&zcfs(hq)K>r^HzMLOlU5{|`+XrE&-36(sj8fwi%*_F
zbwxS5{qRC?B)$Y3-T?640Fcq9$)k*_^|BA5LKJi;{_%T!cW|FtwyVl7Vb!V}tjx(q
zGFsHIZrlE8^M~6$IojKB!3zLCX~vSR3sPc6P7Dt~tF>Dt6yu>cQA-_J{>RDV@aNr#
zj*7&I+(Q@_Hx2<oLAYzN!fNe8YFgSW=~L4EjmDt(q~w$-r++$in5CuH5V)xlr;-Qm
z*qm^Z7Jh)shFQ_p*oe@F#v&y-8A*wWko^2{zO+;gpV>SccDqdt(}jzd=nVfTGnB+4
zdQCw=7ZplX&`ufPW-jIih&}yHd**o*?EIL>Cn6#u9M>wYqQ1Ts2Av-5ZEYAIACIQy
z7L=5f;GNAIXL|U{Kac@WzdVMycC=AKyWQE7(F(i^`vxQ2@qFZc_5sF@jl-zuC|tXG
z9aUA8Sh;*Lc3NU_>FNzsR#vKOV`5{mVdLh~H#fcg74!B30H{_<T(7HCqRYgf<GqSR
zqSuS4y<CQM>(*lK+&L6;3+n3Y@Z`cQ1cyc8I!q|Y{dz@BO$|CNotQJnj5VuQ5r5os
zJq?LI>fIk4T~1kLuY+9fB-eB6zJ=NXaRU?^QIGYqjHae0*c=Y3{UQ{%j1kmoRCN-Y
z<n9(fN$M0uftnB<8X9iFpZFomYJrm&0Drqv?(TTYtRL!)cj%A>yLRtJOzaqBW@aEE
zAp!eou1hl`!B*3nnVGpaK7IlWk^zN<zo4<P2@Z$tS5F}lVQniqP+FfkFEJ7}*8TX}
z{HYWWFl*7Xz^DoM?eHGpiN`4g<0vx)RFoItx!~?esp-=Z6BCPWrxSH`bvRd2yt?>o
z(a-b+JIf$Jt}DM%Y?`v{;jlm$$#r%D!7{)XanN2H)wiPD14v0pLfp9V6harm!y^zM
zKM|9Xk`ZbOrSQrK3Jiuop`gr}T734*R?=-{9<VtZuGicvJ^5<3)4hf6e!)G6o{xD!
z=5?6frAPEVE6V;XBR3Z!k-}g$o7FA(`T3~2c@r5K8MsFzwb^VJwU$in30Zx<(~-A5
z7kS%%*9yd-$1w_J<kF>^^!|YfJHOcR;)<0y1zA~H>Kao{@8*-`2OSQ_l|8$6t?@J`
zT(3L9peYxJ$VJvchiK(pUc7YK=aS?%jV(|WEZV<!PmZ?64F1d06>}KAA!-J2+H(}i
zNNufEQ|i)!YxCTU!wJ9}7)hJkdn^54jlTsL0D)5&ae|tUSpWb407*qoM6N<$g0!&x
AL;wH)

literal 0
HcmV?d00001

-- 
GitLab