lucidiot's cybrecluster

Convertir de iCalendar à JSON avec jq, partie 1

Lucidiot Informatique 2023-03-19
Le contexte me conduisant à créer une machine de Rube Goldberg pour rien.


Dans un précédent article, j'ai mentionné que le fait d'apprendre à sortir dehors plus régulièrement, et de mettre en place des choses pour faciliter cela, a déclenché un nouveau projet. Ce projet a un but simple : m'encourager à aller à la bibliothèque.

Quand j'étais encore à l'école primaire, j'allais avec Maman à une bibliothèque dans une rue piétonne en Normandie qui était quasiment toujours très calme, et j'empruntais jusqu'à 10 livres à la fois. Chaque carte d'emprunteur permettait d'emprunter cinq livres, et Maman me laissait parfois utiliser sa carte en plus de la mienne. Depuis, je ne suis quasiment pas allé dans une bibliothèque, en excluant les CDI dans les collèges et lycées. Je suis allé quelques fois dans une bibliothèque universitaire, mais pas pour rester longtemps, et jamais seul.

Du coup, aller dans une bibliothèque maintenant, tout seul, pour chercher des livres au hasard, ou m'installer à une table et lire, ou écrire dans des carnets, ou utiliser mon ordinateur, m'angoisse un peu.

Ce n'est pas comme si c'était difficile de trouver une bibliothèque à Grenoble ; il y en a douze, sans compter les bibliothèques spécialisées appartenant à des écoles d'art ou des musées et dont le catalogue est aussi référencé sur le site des bibliothèques de Grenoble, ou le réseau de médiathèques dans les communes autour. Les chiffres varient selon les pages et les sources mais on peut considérer qu'il y a un million de documents dans ces bibliothèques. Et tout est gratuit en plus.

Une façon de réduire fortement mon angoisse à propos de n'importe quoi, c'est de m'informer dessus ; ça peut me fournir les arguments nécessaires pour contrer la voix dans ma tête qui s'obstine à imaginer des catastrophes. Alors j'ai lu à peu près chaque page qu'il y a sur le site des bibliothèques, et je recherche aussi tous les livres qui m'intéressent sur le catalogue en ligne.

Puisque chaque livre n'est pas forcément disponible dans chaque bibliothèque, je voulais un moyen de savoir dans quelle bibliothèque il serait le plus intéressant d'aller en fonction de tous les livres qui m'intéressent. Je voulais aussi savoir quand je peux aller dans une bibliothèque, puisque les bibliothèques ont des horaires assez différents, qui varient en fonction du jour de la semaine et des vacances scolaires. Les bibliothèques sont aussi fermées durant les jours fériés.

Afin d'éviter les 25 mégaoctets de JavaScript que charge le catalogue en ligne, tout en ne perdant pas un temps fou à essayer d'en comprendre l'API très générique (et générique en informatique veut souvent dire incompréhensible), j'ai choisi de créer une base de données SQLite. Je joue occasionnellement avec des recfiles, mais ici il est plus sensé de faire du SQL puisque je vais faire des requêtes assez complexes pour obtenir des statistiques et savoir quand aller récupérer mes livres. L'utilitaire recsel est très limité comparé à du SQL.

Avant de s'attaquer concrètement à cette base de données, je vais dans cet article et les suivants détailler un script que j'ai développé pour convertir des calendriers exportés au format iCalendar vers du JSON, ce que j'utiliserai ensuite pour pouvoir obtenir une liste des périodes de vacances scolaires et des jours fériés qu'on pourra utiliser pour calculer les prochains horaires d'ouverture des bibliothèques.

Pour commencer, on va aller chercher des sources de données pour récupérer les jours fériés et vacances scolaires, on va essayer de comprendre comment ces fichiers sont structurés, et déterminer une structure adaptée en JSON. Ma façon de procéder est si inutilement complexe qu'on ne verra l'implémentation concrète que dans l'article suivant !

Sources de données

Pour obtenir les dates des jours fériés et des vacances scolaires, j'ai pu directement puiser depuis les sources officielles via les initiatives d'Open Data du gouvernement. Le calendrier scolaire, pour les dates des vacances, est disponible sous les formats CSV, JSON, Excel et iCalendar depuis le portail de données ouvertes de l'éducation nationale, et les jours fériés sont disponibles via Etalab en CSV, JSON et iCalendar.

Il est extrêmement facile d'importer un fichier CSV avec SQLite, donc si je veux récupérer immédiatement ces informations pour ma base de données, je pourrais utiliser ce format. Je pourrais aussi utiliser le JSON, soit en passant par jq pour l'extraire en CSV ou générer des requêtes SQL, soit en utilisant l'extension officielle JSON de SQLite pour lire les données. Pour le format Excel du calendrier scolaire, je peux juste le reconvertir en CSV, donc il n'a pas beaucoup d'intérêt.

Mais j'ai quand même choisi le format iCalendar ici, bien que ce soit le format le plus complexe de toutes les options disponibles et le seul qui n'aie pas de façon assez simple et directe d'être importé dans SQLite. J'adore me compliquer la vie. Mais surtout, ça faisait un bon moment que je voulais essayer d'écrire un script capable de lire ce format ; ce n'est pas la première fois que j'essaie de jouer avec du iCalendar. Il n'est pas impossible que je me retrouve à réutiliser le script que je vais écrire ici dans de futurs projets.

Le format iCalendar

Le format iCalendar, standardisé par la RFC 5545, est le format de calendrier standard utilisé par la quasi-totalité des logiciels de calendriers. Il permet non seulement d'ajouter des événements, mais aussi des tâches et des entrées de journal, et contient aussi des fonctionnalités pour publier son état disponible ou occupé sans publier ses événements privés, déclarer des fuseaux horaires personnalisés, définir des rappels sur des événements, et plus encore. D'autres RFC étendent le format pour y ajouter par exemple la gestion de ressources et de participants.

La capacité de iCalendar à gérer des événements ainsi que des tâches est la raison pour laquelle la plupart des logiciels et services de calendrier offrent aussi un service de gestion de tâches. Les entrées de journal sont toutefois rarement prises en charge. Vous avez probablement déjà sans le savoir utilisé iCalendar, notamment si vous utilisez n'importe quel type de calendrier ou de liste de tâches synchronisé avec votre téléphone, ou si vous avez déjà envoyé des invitations par e-mail à des événements. Les invitations utilisent des petits fichiers iCal pour partager les événements. Le protocole CalDAV est souvent utilisé pour la synchronisation, et il ne fait pas grand chose de plus qu'ajouter iCalendar par dessus WebDAV.

Le format iCalendar se structure en des composants. Chaque fichier iCalendar a toujours un composant racine quelconque. Chaque composant peut contenir des propriétés et d'autres sous-composants. Les propriétés sont une valeur associée à une clé, mais il peut parfois y avoir des paramètres supplémentaires à cette clé : par exemple, la propriété ORGANIZER indique qui organise un événement, et est censée être une URL. C'est généralement un lien mailto: pour pouvoir indiquer une adresse e-mail. Si on veut associer aussi un nom d'affichage à cette adresse, on peut ajouter un paramètre CN, pour Common Name : ORGANIZER;CN=lucidiot:mailto:lucidiot@example.com. Mais voyons ça un peu plus en détail.

Lignes de contenu

À la base, le format iCalendar se constitue juste d'une longue liste de lignes de contenu. Les lignes de contenu ont une clé et une valeur, et optionnellement des paramètres :

SUMMARY:Ceci est un bel exemple.
DTSTART;TZID=Europe/Paris:20230301T083000

On a ici deux lignes de contenu. Une a pour clé SUMMARY, n'a pas de paramètres, et pour valeur Ceci est un bel exemple..

La seconde a pour clé DTSTART et pour valeur 20230301T083000; elle déclarerait une date de début d'un événement le 1 mars 2023 à 8h30. Elle a aussi un paramètre appelé TZID, l'identifiant de fuseau horaire, défini à Europe/Paris, ce qui nous permet de signifier que c'est 8h30 à l'heure de Paris et pas autre chose.

On notera que les noms de clés et les noms de paramètres sont insensibles à la casse, et qu'il est également possible pour un paramètre d'avoir plusieurs valeurs en même temps : EXEMPLE;PARAM=un,deux,trois:VALEUR.

Échappement de caractères

Vous remarquerez peut-être que le point-virgule peut donc avoir une signification particulière, ainsi que les deux points, la virgule, ou les sauts de ligne. Comment peut-on représenter dans ce cas ces caractères ?

D'abord, on n'a pas à se poser cette question pour la clé ou le nom d'un paramètre, car ces caractères sont tous interdits.

Dans la valeur d'une ligne de contenu, on peut déclarer un point-virgule avec \;, une virgule avec \,, un saut de ligne avec \n ou \N, et une controblique avec \\. Dans le cas d'une valeur, il n'y a pas à se préoccuper des deux-points : les premiers deux points qui n'ont pas été échappés dans un paramètre seront forcément ceux qui délimitent la valeur du reste de la ligne, et les autres deux points seront ignorés.

Dans la valeur d'un paramètre particulier, on peut choisir d'entourer la valeur d'apostrophes doubles, ce qui permet d'utiliser tous les caractères sauf l'apostrophe double : PARAM=":,;=\". Notez que le \" n'est pas interprété comme l'échappement d'une double-apostrophe, donc il n'est jamais possible d'en mettre.

Valeurs sur plusieurs lignes

À l'époque de la définition de tous les formats texte lié à l'envoi d'e-mails, on voulait toujours limiter à quelque chose comme 76, 78 ou 80 caractères chaque ligne, pour que le format texte soit lisible par les humains sur les terminaux de l'époque. Par conséquent, iCalendar définit une limite de longueur de toutes les lignes à 75 octets. Comment faire donc si on veut mettre un texte plus long que ça, par exemple une description détaillée d'un événement ?

La RFC définit un concept de pliage des lignes. Si la ligne qui suit la ligne actuelle commence par un espace, alors cet espace est supprimé et la ligne est fusionnée avec la précédente. On peut donc écrire ceci :

DESCRIPTION:Ceci est un texte excessivement et inutilement long justt pour
 montrer un exemple de pliage de lignes.\NLes sauts de ligne sont échappés
 avec la controblique, donc les lignes pliées n'influencent pas le vrai te
 xte. S'il y a plusieurs espaces, seul le premier espace est retiré. Voici
  une ligne pliée commençant par deux espaces, mais seul un sera vraiment 
 retiré.

Composants

Toutes ces lignes de contenu se regroupent donc en des composants. Un composant commence par une ligne BEGIN qui ne prend pas de paramètres, et dont la valeur est le nom du type. Le composant se termine lorsqu'on atteint une ligne END, qui ne prend pas non plus de paramètres et dont la valeur doit être le même nom de type.

Par convention, on est aussi censé ordonner les lignes de contenu pour que celles qui définissent des propriétés (par exemple la date de début d'un événement) se retrouvent avant toute ligne de contenu qui décrirait un composant enfant. Par exemple, on mettrait la date de début d'un événement VEVENT avant des composants VALARM qui déclarent des rappels à l'utilisateur :

BEGIN:VEVENT
DTSTART:20230301T083000
SUMMARY:Petit déjeuner
BEGIN:VALARM
TRIGGER:-PT60M
ACTION:DISPLAY
DESCRIPTION:Alarme une heure avant
END:VALARM
END:VEVENT

Différences avec vCard

Le format iCalendar provient du format vCalendar, un format qui avait été défini en dehors des RFC. On en voit des restes avec tous les composants dont les noms commencent par un V. Un autre format similaire est vCard, qui lui a par contre été standardisé dans une RFC, la RFC 6350. Ce format décrit un contact ; le card veut dire une carte de visite. La similarité entre vCard et iCalendar fait que les logiciels gérant les calendriers et les tâches gèrent souvent aussi les contacts. L'équivalent de CalDAV pour vCard s'appelle CardDAV, et si vous avez utilisé un système de synchronisation de contacts, vous avez probablement utilisé vCard sans le savoir aussi.

vCard dispose aussi du concept de lignes de contenu, mais a quelques variations dessus. Les lignes peuvent optionnellement commencer par un nom de groupe, qui est séparé du nom de la clé par un point : GROUPE.NOM=valeur.

Ensuite, l'échappement de caractères est différent pour les valeurs de paramètres, où on peut cette fois utiliser \;, \,, et \\. On ne peut toujours pas utiliser d'apostrophe double.

Enfin, les valeurs découpées sur plusieurs lignes peuvent utiliser non seulement un espace mais aussi une tabulation en début de ligne pour indiquer le découpage.

Toutes ces différences font qu'écrire du code capable de lire les format iCalendar et vCard simultanément peut très facilement conduire à un désastre, ou qu'utiliser des caractères spéciaux peut vite poser problème.

Second caractère d'échappement

Il est évident que la solution à tous ces problèmes d'échappement de caractères, c'est de rajouter encore plus de problèmes. La RFC 6868 ajoute un nouveau caractère d'échappement valide dans les valeurs de paramètres (pas les valeurs tout court), à la fois dans iCalendar et dans vCard : l'accent circonflexe.

Si on utilise ^n, ça doit se traduire en un saut de ligne. ^' se traduit en une apostrophe double ; on n'utilise pas ^" puisque ce caractère est interdit dans la syntaxe originale des deux formats. Et ^^ devient un seul accent circonflexe.

Cela n'exclut pas d'utiliser le caractère d'échappement original, donc on peut maintenant écrire ce genre d'inepties :

NOM;PARAM=^\,\^^':valeur

Quelle sera la valeur du paramètre ? En iCalendar, ce serait deux valeurs, ^\ puis \^. En vCard, ce serait une seule valeur, ^,\^.

On notera qu'on est déjà à un niveau assez élevé de confusion ici qui pourrait causer des problèmes entre différentes implémentations, alors qu'on n'a même pas abordé la moindre signification particulière d'une clé. Par exemple, RRULE, qui définit des règles de récurrence, cause un très grand nombre de problèmes car son interprétation n'est pas facile, et elle fait usage de beaucoup de paramètres. C'est pour ça qu'il est quasiment impossible d'utiliser des tâches récurrentes dans la plupart des calendriers, et que les événements récurrents soient souvent mal synchronisés entre différents appareils. Mais avant même de se heurter à des problèmes tels que les règles de récurrence, on pourrait se heurter à des bêtes problèmes de ponctuation !

Errata

Quand des RFC définissent quelque chose, les lire n'est pas toujours suffisant pour pouvoir les interpréter et les implémenter correctement. Un système d'errata est en place, permettant à n'importe qui de signaler des erreurs, des incohérences, ou toute modification à faire dans le texte d'une RFC. Cela peut aller d'une simple faute de frappe à la nécessité de repenser une partie du standard pour sa prochaine version. Quand quelqu'un signale un erratum, les auteurs de la RFC peuvent la lire et soit l'approuver, la rejeter, ou la garder au chaud en attente d'une nouvelle version du document. Si des errata existent pour une RFC, la mention ERRATA EXIST est affichée quand on lit la RFC en ligne, en rouge, pour signaler qu'il faut faire attention et aussi aller lire ces petites notes.

Il existe plusieurs errata sur les diverses RFC liées à iCalendar et vCard qui montrent la confusion engendrée par tout le système d'échappement de caractères :

Il est clair que je ne suis pas le premier, et probablement pas le dernier non plus, à subir tous ces problèmes d'échappement. Je n'ai ici listé que les errata qui concernent directement l'échappement de caractères ; il y a d'autres erreurs dans ces standards qui renforcent encore la nécessité d'une mise à jour qui n'est toujours pas arrivée. En attendant, on se retrouve donc avec tout un tas d'implémentations qui interpréteront les choses différentes, et elles n'auront ni tort, ni raison. Les standards sont censés améliorer l'interopérabilité, pas en donner l'illusion et ajouter des bugs incroyablement complexes à résoudre par la suite…

Me sortir de la noyade

Vu que les caractères d'échappement sont un enfer et ne concernent que les paramètres, une façon simple d'éviter tous les problèmes est d'ignorer les paramètres. On va par conséquent faire de notre mieux pour les oublier, et traiter la liste des paramètres juste comme une longue chaîne de caractères dont on ne se préoccupe pas.

Je reviendrai peut-être dans un autre article sur le traitement de ces paramètres, mais certainement pas aujourd'hui !

Structure JSON à obtenir

La RFC 7265 spécifie un format appelé jCal, une version JSON de iCalendar. On notera aussi l'existence de xCal, la version XML, via la RFC 6321, ainsi que xCard et jCard, les équivalents XML et JSON de vCard, définis respectivement par la RFC 6351 et la RFC 7095. Des recherches n'ayant rien à voir m'avaient aussi conduit à découvrir il y a quelques semaines RDF Calendar, l'équivalent de iCalendar pour RDF, qui comme la plupart des choses avec RDF est trop complexe pour avoir été adopté par qui que ce soit de raisonnable.

Cela dit, quand j'ai écrit ce script, je n'avais aucune idée de l'existence de tous ces formats. J'ai écrit ce script en une heure ou deux sans trop me renseigner, et je n'ai vraiment recherché toutes ces RFC que lors de la rédaction de cet article. Par conséquent, on va ignorer complètement toutes ces RFC et inventer notre propre structure. Je reviendrai peut-être sur ce script une autre fois pour me conformer à une ou plusieurs de ces RFCs.

Je voulais essayer de représenter chaque composant comme étant un objet en JSON. Chaque clé dans l'objet correspondrait à une clé d'une ligne de contenu iCal. Puisqu'une même clé peut-être répétée plusieurs fois, il va nous falloir gérer la possibilité de plusieurs valeurs pour la même clé. Il faudra également gérer les paramètres ; je vais au minimum essayer de les traiter comme des sous-ensembles de valeurs. Il faut également pouvoir stocker dans cet objet une liste de ses composants enfants.

Voici un exemple de lignes de contenu iCalendar complètement bidon :

BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
BEGIN:VEVENT
DTSTART:20230301T083000
SUMMARY:Petit déjeuner
SUMMARY;LANGUAGE=en:Breakfast
ATTENDEE:mailto:lucidiot@example.com
ATTENDEE:mailto:gordon.ramsay@example.com
END:VEVENT
BEGIN:VJOURNAL
DTSTAMP:20230301T100000
SUMMARY:Compte-rendu du petit déjeuner
DESCRIPTION:C'était bon.
END:VJOURNAL
END:VCALENDAR

Et voici le résultat une fois traduit dans la structure JSON que j'ai imaginé :

{
  "_type": "VCALENDAR",
  "version": {"": ["2.0"]},
  "calscale": {"": ["GREGORIAN"]},
  "_components": [
    {
      "_type": "VEVENT",
      "dtstart": {"": ["20230301T083000"]},
      "summary": {
        "": ["Petit déjeuner"],
        "LANGUAGE=en": ["Breakfast"]
      },
      "attendee": {
        "": [
          "mailto:lucidiot@example.com",
          "mailto:gordon.ramsay@example.com"
        ]
      }
    },
    {
      "_type": "VJOURNAL",
      "dtstamp": {"": ["20230301T100000"]},
      "summary": {"": ["Compte-rendu du petit déjeuner"]},
      "description": {"": ["C'était bon."]}
    }
  ]
}

Chaque composant a un _type, qui représente le type spécifié par BEGIN: et END:. Puisque le caractère _ est interdit dans les clés, je peux me permettre de l'utiliser pour mettre ce que je veux sans risquer de rentrer en collision avec une clé qui serait vraiment définie dans le fichier. On notera aussi _components, qui est un tableau listant tous les composants enfants qui ont été définis dans ce composant.

Toutes les autres clés dans ces objets sont les clés dans le format iCal. Elles contiennent ensuite un objet qui associe à chaque ensemble de paramètres un tableau de valeurs. Chaque valeur dans le tableau correspond à la valeur qu'avait donné une ligne de contenu particulière.

Pour cette structure, je m'épargne tous les problèmes de caractères d'échappement en les oubliant complètement et en n'essayant que de découper par clé, ensemble de paramètres, et valeur. Je peux néanmoins grâce au découpage par ensemble de paramètres choisir quels paramètres je veux traiter spécifiquement, y compris choisir de tout traiter.

Conclusion

Je me retrouve souvent dans mes projets à écrire des articles entiers pour simplement donner du contexte, et ce projet ne fait pas exception. Je trouve que la longueur de ce genre d'articles peut faire apparaître un projet comme étant bien plus complexe qu'il ne l'est vraiment, donc un rappel : j'ai développé le script final en deux bonnes heures, et j'ai pu continuer sur des parties plus intéressantes de mon projet après ça. L'écriture des deux articles pour convertir iCalendar à jq me prend au moins 4 fois plus de temps que le script lui-même !

Dans le prochain article, on verra donc comment concrètement écrire le script, sur la base de toutes les informations qu'on a retrouvé. On n'en est cela dit pas encore à faire quelque chose d'intéressant avec ces données.


Commentaires

Il n'y a pour l'instant aucun commentaire. Soyez le premier !