serve.py supposait Python installé et une commande tapée dans un terminal depuis le dossier du dépôt. L'objectif ici : poser l'exécutable et son planning dans un dossier synchronisé, et les retrouver sur n'importe quelle machine sans rien installer. Le binaire embarque l'interface, expose les mêmes routes sur le même format, et cherche son projets.json à côté de lui. Compilé avec CGO_ENABLED=0, il ne dépend d'aucune bibliothèque système, pas même de la glibc ; build.sh produit les cinq plateformes depuis une seule machine. Le front est inchangé : les modules ES sont embarqués et servis tels quels, et --dev les sert depuis le disque pour développer sans recompiler. Deux ajustements dictés par le double-clic, où aucun drapeau ne peut être passé : le port se cherche à partir de 8000 s'il est occupé, et le fichier de données est résolu depuis l'exécutable et non depuis le répertoire courant. La décision 3 est rectifiée au passage : elle affirmait à tort que la File System Access API ne fonctionne pas depuis file://. Vérification faite, c'est CORS sur les modules ES qui ferme cette voie, pas la sécurité de l'API. serve.py est supprimé, le binaire l'ayant remplacé en usage réel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
143 lines
4.4 KiB
JavaScript
143 lines
4.4 KiB
JavaScript
/**
|
|
* Dialogue avec le serveur local : chargement du planning au démarrage, et
|
|
* sauvegarde automatique debouncée à chaque modification.
|
|
*
|
|
* Le module ne connaît rien au contenu du planning : il transporte un objet
|
|
* JSON. Les règles métier sont dans model.js.
|
|
*/
|
|
|
|
/** Délai d'inactivité avant d'écrire sur le disque. */
|
|
const DELAI_DEBOUNCE = 1000;
|
|
|
|
/** Le rendu « enregistré » disparaît après ce délai, pour ne pas rester affiché. */
|
|
const DUREE_CONFIRMATION = 2000;
|
|
|
|
const URL_DONNEES = '/api/data';
|
|
|
|
/**
|
|
* @param {(etat: 'inactif'|'en-cours'|'enregistre'|'erreur', detail?: string) => void} surEtat
|
|
* Appelé à chaque changement d'état, pour l'indicateur de l'interface.
|
|
*/
|
|
export function creerStockage(surEtat = () => {}) {
|
|
let minuteurDebounce = null;
|
|
let minuteurConfirmation = null;
|
|
|
|
// Dernier instantané en attente d'écriture. On ne garde que le plus récent :
|
|
// si trois modifications s'enchaînent, seule la dernière part sur le réseau.
|
|
let enAttente = null;
|
|
|
|
// Une écriture est en vol. Toute modification survenue entre-temps sera
|
|
// envoyée à la fin de celle-ci, pour éviter deux PUT concurrents.
|
|
let ecritureEnCours = false;
|
|
|
|
function annoncer(etat, detail) {
|
|
clearTimeout(minuteurConfirmation);
|
|
surEtat(etat, detail);
|
|
if (etat === 'enregistre') {
|
|
minuteurConfirmation = setTimeout(() => surEtat('inactif'), DUREE_CONFIRMATION);
|
|
}
|
|
}
|
|
|
|
async function ecrire() {
|
|
if (ecritureEnCours || enAttente === null) return;
|
|
|
|
const charge = enAttente;
|
|
enAttente = null;
|
|
ecritureEnCours = true;
|
|
annoncer('en-cours');
|
|
|
|
try {
|
|
const reponse = await fetch(URL_DONNEES, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(charge),
|
|
});
|
|
|
|
if (!reponse.ok) {
|
|
const corps = await reponse.json().catch(() => ({}));
|
|
throw new Error(corps.error || `Le serveur a répondu ${reponse.status}.`);
|
|
}
|
|
|
|
annoncer('enregistre');
|
|
} catch (err) {
|
|
// On remet la charge en attente : la prochaine modification, ou la
|
|
// prochaine tentative, la renverra. Rien n'est perdu tant que l'onglet
|
|
// reste ouvert.
|
|
if (enAttente === null) enAttente = charge;
|
|
annoncer('erreur', decrireErreur(err));
|
|
} finally {
|
|
ecritureEnCours = false;
|
|
// Une modification est arrivée pendant l'écriture : on enchaîne.
|
|
if (enAttente !== null) planifier();
|
|
}
|
|
}
|
|
|
|
function planifier() {
|
|
clearTimeout(minuteurDebounce);
|
|
minuteurDebounce = setTimeout(ecrire, DELAI_DEBOUNCE);
|
|
}
|
|
|
|
return {
|
|
/**
|
|
* Charge le planning. Lève une erreur explicite si le serveur est
|
|
* injoignable ou si le fichier est corrompu — l'application ne doit
|
|
* jamais démarrer sur des données douteuses.
|
|
*/
|
|
async charger() {
|
|
let reponse;
|
|
try {
|
|
reponse = await fetch(URL_DONNEES, { headers: { Accept: 'application/json' } });
|
|
} catch {
|
|
throw new Error(
|
|
"Serveur injoignable. L'application doit être ouverte en lançant l'exécutable " +
|
|
'« frise », pas en ouvrant index.html directement.'
|
|
);
|
|
}
|
|
|
|
if (!reponse.ok) {
|
|
const corps = await reponse.json().catch(() => ({}));
|
|
throw new Error(corps.error || `Le serveur a répondu ${reponse.status}.`);
|
|
}
|
|
|
|
return reponse.json();
|
|
},
|
|
|
|
/** Enregistre après un délai d'inactivité. Appelable à chaque frappe. */
|
|
sauvegarder(donnees) {
|
|
enAttente = donnees;
|
|
planifier();
|
|
},
|
|
|
|
/** Écrit immédiatement, sans attendre le debounce. */
|
|
async vider() {
|
|
clearTimeout(minuteurDebounce);
|
|
await ecrire();
|
|
},
|
|
|
|
/** Vrai s'il reste des modifications non écrites sur le disque. */
|
|
aDesModificationsEnAttente() {
|
|
return enAttente !== null || ecritureEnCours;
|
|
},
|
|
};
|
|
}
|
|
|
|
function decrireErreur(err) {
|
|
if (err instanceof TypeError) return 'Serveur injoignable. Est-il toujours lancé ?';
|
|
return err.message;
|
|
}
|
|
|
|
/**
|
|
* Écrit les modifications en attente quand l'onglet passe en arrière-plan.
|
|
*
|
|
* C'est plus fiable que « beforeunload » : sur mobile et lors d'une fermeture
|
|
* d'onglet, « visibilitychange » est le dernier événement dont la livraison
|
|
* soit garantie.
|
|
*/
|
|
export function viderAvantFermeture(stockage) {
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'hidden' && stockage.aDesModificationsEnAttente()) {
|
|
stockage.vider();
|
|
}
|
|
});
|
|
}
|