lucidiot's cybrecluster

Convertir de iCalendar à JSON avec jq, partie 2

Lucidiot Informatique 2023-04-02
Une expression régulière comme tokenizer, et jq comme parser.


Dans l'épisode précédent, j'ai décrit pourquoi je fais la folie de convertir le format iCalendar à du JSON, à quelle syntaxe j'ai affaire, et quel JSON je compte produire. Dans cet article, on va voir comment je m'y suis pris pour atteindre ces objectifs avec jq. Comme dans la plupart de mes projets, il va y avoir un beau bricolage d'expression régulière, mais elle reste encore de taille modérée par rapport aux inepties qui ont vu le jour sur ce blog.

Découper le fichier en lignes de contenu

jq est un outil normalement conçu pour recevoir en entrée du JSON, et fournir en sortie du JSON. Il est cependant possible d'ordonner à jq de traiter l'entrée comme une série de chaînes de caractères, une par ligne, ou comme une seule énorme chaîne de caractères, et aussi de sortir tout sans encoder en JSON, comme du texte brut. Comme on va avoir besoin de modifier nous-mêmes les sauts de ligne pour pouvoir traiter le cas du pliage des lignes longues, on va demander à jq d'absorber tout le fichier ICS comme une seule chaîne de caractères. Pour cela, on utilise --raw-input (traiter l'entrée comme du texte brut) et --slurp (tout rassembler en une seule chaîne de caractères) : jq --raw-input --slurp -f ics-json.jq calendar.ics.

Dans le fichier ics-json.jq, qui va contenir le script qu'on va utiliser pour transformer l'iCalendar en JSON, on va pouvoir découper ce très long texte en une série de lignes de contenu. Le découpage peut se faire avec split("\r\n"). split prend une chaîne de caractères et renvoie un tableau de chaînes de caractères en découpant par un séparateur spécifié. On indique ici \r\n pour un retour chariot suivi d'un saut de ligne, puisque c'est le format standard préconisé par iCalendar et couramment utilisé à l'époque.

Les systèmes Windows utilisent encore ce format pour indiquer un saut de ligne, mais les systèmes Unix (tout le monde sauf Windows en fait) utilisent \n seul. Par conséquent, pour gérer des fichiers un peu moins standard qui n'utilisent que \n, on devrait utiliser split("\n"). Il nous faudra plus tard tenir compte de la présence possible d'un retour chariot inutile à la fin des lignes de contenu.

Si on effectue ce découpage, on va aussi remarquer que dans la plupart des fichiers, la dernière ligne se termine par un saut de ligne, et il y a donc une ligne vide ensuite. C'est aussi une norme courante dans les systèmes Unix, et c'est aussi quelque chose de défini dans la RFC, même si ça n'est pas très explicite. Cela a pour conséquence de nous donner une ligne vide inutile dans le tableau, donc on va enlever n'importe quel nombre de lignes vides, peu importe si elles utilisent le retour chariot ou le saut de ligne, avant de découper : rtrimstr("\n") | rtrimstr("\r") | split("\n"). rtrimstr retire toute occurrence d'une chaîne de caractères qui se trouverait à la fin de notre chaîne. On enlève donc tous les \n en fin de ligne, puis tous les \r en fin de ligne. Utiliser rtrimstr("\r\n") ne supprimerait pas une ligne vide qui n'utilise pas de retour chariot.

Enfin, si une ligne de contenu a été découpée en plusieurs parce qu'elle dépassait la limite de caractères, alors on doit réassembler cette ligne. Pour cela, on va suivre le processus décrit dans la RFC : avant d'essayer de découper en lignes de contenu, si une ligne commence par un espace, alors on détruit cet espace et on détruit le retour chariot et le saut de ligne qui précédait cet espace. On ajoutera aussi qu'en plus de l'espace, on va devoir gérer la tabulation, qui est définie dans le format vCard comme étant aussi permise pour ce découpage de lignes.

Pour cette dernière étape, on va utiliser une expression régulière : gsub("\r?\n[ \t]"; ""). sub effectue une substitution d'un morceau de texte correspondant à un motif, décrit par une expression régulière, par un autre morceau de texte. gsub fait la même chose, mais en remplaçant toutes les occurrences, pas seulement la première. Le premier argument de cette fonction indique que nous chercherons éventuellement un retour chariot, puis un saut de ligne, puis soit un espace, soit une tabulation. Si nous trouvons tout ça, alors on le remplace par… rien du tout. Cela va ainsi fusionner les lignes.

Notre script ressemble donc maintenant à ça :

rtrimstr("\n") | rtrimstr("\r") | gsub("\r?\n[ \t]"; "") | split("\n")

Lire une ligne de contenu

Puisque j'ai choisi d'ignorer complètement les paramètres, on pourrait penser que lire une seule ligne de contenu après tout ce pré-traitement ne consiste qu'à découper le texte avant la première occurrence de deux points ou d'un point-virgule pour obtenir le nom, puis prendre la partie après le point-virgule si elle existe pour avoir les paramètres et m'arrêter aux deux-points suivants, et enfin traiter tout le reste comme la valeur. Mais ce n'est pas aussi simple, puisque dans les paramètres, il est possible d'avoir le caractère deux points quand il est entouré d'apostrophes doubles : NAME;PARAM="a:b":value.

Afin de résoudre de problème, et aussi histoire d'avoir de la validation supplémentaire sur les caractères autorisés dans les noms, paramètres et valeurs, et potentiellement détecter des problèmes supplémentaires dans la façon dont je lis ces fichiers, j'ai décidé de construire une bonne vieille expression régulière.

Nom

En premier lieu, les caractères autorisés dans le nom sont définis de façon assez particulière, pour contraindre les extensions non-officielles à utiliser le préfixe X- devant les noms. Mais on ne va pas se préoccuper de cette particularité, histoire de pouvoir plus facilement gérer toutes les versions de iCalendar, vCard, et de tout autre format qui utiliserait cette syntaxe :

^(?'name'[a-zA-Z0-9-]+)

On récupérera ici le nom en tant que name, en autorisant tous les caractères alphanumériques ainsi que le tiret cadratin.

Valeur

On peut continuer avec la valeur, qui est définie de façon très complexe dans chaque spécification car elle doit être validée différemment en fonction de chaque nom. Les RFC définissent des types de valeurs pour chaque paramètre, car on n'est pas censé par exemple mettre « demain » comme date d'un événement mais une date suivant un format particulier. On ne va pas s'en préoccuper et à la place utiliser une définition très générique : après les deux points, n'importe quel caractère qui n'est pas un caractère de contrôle (qui est interdit partout) compte comme la valeur.

^(?'name'[a-zA-Z0-9-]+):(?'value'[^[:cntrl:]]*)$

Dans un ensemble de caractères délimité par des crochets, [:cntrl:] est interprété par la plupart des moteurs d'expressions régulières comme « tous les caractères de contrôle ». C'est plus facile d'écrire ça que d'essayer d'écrire chaque caractère spécial, en utilisant des échappements particuliers comme \u0000 ou \x00 qui dépendent souvent de l'implémentation du moteur d'expressions régulières. Notez qu'on pourrait aussi utiliser [[:alnum:]-] pour le nom, mais je préfère pour ça la syntaxe plus classique et plus explicite.

On va aussi gérer le cas particulier qu'on a créé tout à l'heure : il pourrait y avoir un retour chariot en fin de ligne, qu'on peut ignorer.

^(?'name'[a-zA-Z0-9-]+):(?'value'[^[:cntrl:]]*)\r?$

Paramètres

On peut maintenant passer aux paramètres. Vu qu'on veut essayer de limiter la casse, on va essayer de faire au plus simple. D'abord, en ignorant les apostrophes, on peut définir les paramètres comme étant optionnellement un point-virgule, suivi par n'importe quoi sauf les deux-points et les caractères de contrôle :

^(?'name'[a-zA-Z0-9-]+);?(?'params'[^:[:cntrl:]]+)?:(?'value'[^[:cntrl:]]*)\r?$

Le problème de ça, c'est qu'il n'est plus garanti que le nom ne sera pas un peu découpé pour rentrer dans les paramètres. Par exemple, une ligne invalide comme LOL@@:chose sera considérée comme valide, avec comme nom LOL, comme paramètres @@ et comme valeur chose. Le point-virgule n'est pas là, mais c'est pas grave, on capture quand même tout ce qui n'est pas deux points. On va donc imbriquer le point-virgule et les paramètres dans un groupe non-capturant, pour que les paramètres ne fonctionnent que s'il y a un point-virgule, sans pour autant capturer ledit point-virgule :

^(?'name'[a-zA-Z0-9-]+)(?:;(?'params'[^:[:cntrl:]]+)):(?'value'[^[:cntrl:]]*)\r?$

Valeurs de paramètres entre apostrophes

On doit maintenant gérer le cas où on peut avoir des apostrophes doubles. Dans ces apostrophes doubles, on peut trouver à la fois d'autres points-virgule et d'autres deux-points. Pour pouvoir gérer ça, il nous faudrait soit gérer séparement chaque paramètre, pour en lire le nom puis la valeur, n'autoriser les apostrophes doubles que dans chaque valeur, etc., ce qui devient rapidement inutilement complexe. On peut à la place tricher un petit peu et utiliser le problème qu'on a eu tout à l'heure avec LOL@@:chose à notre profit.

Une autre possibilité qu'on aurait pu avoir en ne gérant pas correctement l'aspect obligatoire du point-virgule avant les paramètres, c'est encore de l'incohérence entre différents moteurs d'expressions régulières. Avec juste LOL:chose, on pourrait avoir comme nom et paramètres LOL et rien du tout, LO et L, ou L et OL. On impose bien qu'il y aie au moins un caractère dans le nom, mais ça s'arrête là. Rien ne garantit que l'expression donnera un résultat cohérent.

Ça va devenir ici un avantage car on peut traiter le problème des apostrophes plus facilement. On peut considérer que des apostrophes doubles peuvent exister n'importe où dans les paramètres, et ce n'est que quand elles sont là qu'on autorise n'importe quel caractère qui ne soit pas un caractère de contrôle. Et ces apostrophes doubles peuvent être entourées par d'autres caractères, sauf les deux-points :

^(?'name'[a-zA-Z0-9-]+)(?'params'[^:[:cntrl:]]+(?:"[^[:cntrl:]]*"[^:[:cntrl:]]*)*):(?'value'[^[:cntrl:]]*)\r?$

Ça commence à vraiment devenir illisible. D'abord, on retrouve toujours le [^:[:cntrl:]]+ au début, qui imposera d'avoir au moins un caractère normal avant une éventuelle apostrophe double. Cela assure qu'on aie toujours la restriction d'avoir au moins un caractère dans les paramètres, et ça aide un petit peu pour la validité avec la RFC puisque les apostrophes doubles ne sont utilisables que dans les valeurs des paramètres et pas les noms.

Ensuite, on a un groupe non-capturant qui peut être répété de 0 à une infinité de fois, défini avec (?:…)*. Ce groupe impose d'avoir une apostrophe double, suivi d'optionnellement n'importe quoi, suivi d'une apostrophe double, et optionnellement d'autres caractères sauf les deux-points. On autorise ainsi d'avoir une répétition de texte entouré d'apostrophes doubles, ainsi que du texte sans apostrophes doubles avant et après. Notez bien la différence entre les ensembles de caractères ; celui entre les apostrophes n'interdit pas les deux-points.

Voilà qui finit ainsi la construction de cette expression régulière, bien trop courte en comparaison avec les précédentes folies publiées sur ce blog. On va pouvoir utiliser cette expression avec jq en appliquant la fonction capture sur chaque ligne :

rtrimstr("\n")
  | rtrimstr("\r")
  | gsub("\r?\n[ \t]"; "")
  | split("\n")[]
  | capture("^(?'name'[a-zA-Z0-9-]+)(?'params'[^:[:cntrl:]]+(?:\"[^[:cntrl:]]*\"[^:[:cntrl:]]*)*):(?'value'[^[:cntrl:]]*)\r?$")

Notez le [] ajouté après le split, qui va permettre de transformer le tableau en une série de chaînes de caractères sur lesquelles on peut agir séparément, ce qui nous permet d'exécuter l'expression régulière une fois par ligne.

On obtiendra ainsi pour chaque ligne un objet JSON de cette forme :

{
  "name": "SUMMARY",
  "params": "language=en",
  "value": "Breakfast"
}

Interprétation des lignes

Maintenant que nous avons traité séparément chaque ligne de contenu, il va nous falloir les agréger en des objets. jq est conçu pour travailler plutôt sur des flux, en traitant chaque élément dans ce flux un par un et sans vraiment conserver d'état entre chaque exécution. Il existe cependant une syntaxe permettant de faire des agrégations, des accumulations de données vers un seul état unique. Ça peut par exemple être utilisé pour faire l'équivalent d'un GROUP BY en SQL. Dans notre cas, on va pouvoir utiliser cet état pour sauvegarder les objets finaux, mais aussi la position du générateur :

{
  "root": {"_type": null},
  "current_path": ["root"]
}

Ceci sera l'état initial de notre générateur. root est la racine du résultat que nous construisons, et current_path sera le chemin d'accès, à partir de cet objet, de l'endroit dans lequel nous sommes dans le résultat. Cette notion de chemin sera utile lorsqu'il faudra traiter des composants imbriqués : dans une ligne BEGIN:, on pourra modifier le chemin pour rentrer dans l'objet, et dans une ligne END:, on pourra revenir en arrière. Quand on atteint le END: du composant racine, on saura aussi qu'on a terminé toute la lecture. Pour toutes les autres lignes, on pourra juste aller placer une ligne de contenu au niveau du current_path.

Vous noterez que je spécifie un _type à null. J'ai expliqué précédemment la structure JSON que je voulais utiliser, et _type sera le type d'un composant. Ce type sera défini correctement quand on démarrera la lecture.

Pour exécuter une agrégation avec jq, on va utiliser reduce, qui effectue une réduction, un synonyme pour agrégation dans notre contexte. On réduit tout le flux de lignes de contenu en un seul objet.

reduce split("\n")[] as $item (
  {
    "root": {"_type": null},
    "current_path": ["root"]
  };
  . as $state
  | $item
  | capture("^(?'name'[a-zA-Z0-9-]+)(?'params'[^:[:cntrl:]]+(?:\"[^[:cntrl:]]*\"[^:[:cntrl:]]*)*):(?'value'[^[:cntrl:]]*)\r?$") as {$name, $params, $value}
  | $name
  | ascii_downcase as $name
  | $state
)

On a ici un script d'agrégation qui ne fait pas grand chose de très intéressant, car le résultat de la capture qu'on a fait ci-dessus va écraser l'état à chaque itération. On déplace la capture dans l'agrégation, ce qui n'est pas forcément nécessaire mais assure qu'on aie tout le travail qui est fait ligne par ligne directement au sein de cette agrégation et pas ailleurs dans le script.

Dans le contexte du script exécuté pour chaque ligne de contenu, . sera l'état qu'on a défini précédemment, et $item est une variable contenant la ligne à traiter actuellement. Ce que le script renvoie devient le nouvel état pour la prochaine itération. Je commence par créer la variable $state qui contient l'état, ce qui va me permettre d'utiliser ensuite comme j'en ai envie le contexte courant (.). Je peux ainsi exécuter la capture avec l'expression régulière précédente. J'assigne le résultat de cette capture en utilisant une opération de déstructuration : {$name, $params, $value} signifie que j'assigne .name à $name, .params à $params et .value à $value, ce qui correspond aux noms des trois groupes de capture de mon expression.

Ensuite, je convertis le nom du champ, $name en minuscules, car les noms sont insensibles à la casse. Cela garantit que je peux ensuite faire tout traitement que je souhaite sans me préoccuper de la casse. Enfin, je reprends $state comme contexte, ce qui me permet de refaire des traitements en ayant l'état dans . comme au départ, et en ayant maintenant $name, $params et $value à ma disposition pour intégrer la ligne.

On va maintenant pouvoir essayer de gérer les multiples cas qui peuvent survenir en lisant chaque ligne.

Toute première ligne

La toute première ligne est un cas particulier : elle doit obligatoirement être une ligne BEGIN:, sinon le fichier iCalendar ou vCard est forcément invalide. Il y a toujours un composant racine et on ne peut pas avoir des propriétés orphelines. Ce cas particulier est représenté par le fait que _type soit encore défini à null; dans le cas d'un composant imbriqué, on pourrait assigner immédiatement le type quand on atteint un BEGIN: et ne pas avoir de valeur nulle.

Pour pouvoir récupérer le _type actuel, on va utiliser la fonction getpath. J'ai choisi de structurer .current_path d'une façon particulière qui est compatible avec cette fonction : un tableau de chaînes de caractères représentant des clés dans un objet, ou de nombres représentant des indices dans un tableau. Par exemple, ["root", "_components", 4] indiquera le cinquième composant enfant. Pour accéder au _type on va donc devoir assembler le current_path avec "_type".

if getpath([.current_path[], "_type"]) == null then
  if $name == "begin" then
    setpath([.current_path[], "_type"]; $value)
  else
    error("Expected BEGIN, got \($name)")
  end
else . end

Dans jq, la virgule est un opérateur, et c'est pour ça que le point-virgule est utilisé à la place pour spécifier plusieurs arguments à une fonction. La virgule permet de spécifier plusieurs éléments qui seront produits dans un seul flux. Si un de ces éléments est un itérateur, chacun des éléments qu'il contient sera concaténé. En d'autres termes, .current_path[], "_type" renvoie un flux contenant tous les éléments qui étaient dans .current_path, suivi de la chaîne de caractères "_type". En entourant ce flux par des crochets, je déclare un tableau qui regroupe tous ces éléments ensemble, et donc je fais une concaténation.

Je passe donc ce nouveau chemin à getpath, qui va être capable de récupérer le type. Si le chemin n'existe pas pour une raison ou pour une autre, ce n'est pas bien grave; getpath se débrouillera quand même. Si cette valeur est nulle, alors il me reste deux cas à gérer : soit on a un BEGIN:, auquel cas il n'y a plus à définir le type qu'on vient de récupérer, soit on n'en a pas, et il faut causer une erreur.

Pour définir un attribut à un chemin donné, j'utilise setpath, l'équivalent en écriture de getpath. On notera qu'il existe aussi delpath pour supprimer quelque chose, mais on ne va pas l'utiliser dans ce script. On passe un deuxième argument cette fois, $value, ce qui va stocker la valeur de notre ligne comme type.

Pour causer une erreur, on utilise error. On pourrait aussi utiliser halt_error, qui cause une erreur qui ne peut pas être gérée par un try dans un autre script et qui fait donc forcément échouer le script, mais j'ai choisi de n'utiliser que error pour rendre l'erreur récupérable. Surtout, puisque nous sommes dans une agrégation, une erreur simple pourra être ignorée ; elle s'affichera comme un avertissement durant l'exécution, mais la lecture continuera pour les autres lignes de contenu. Cela peut-être utile pour ignorer des lignes d'un fichier légèrement mal fichu et lire tout ce qu'il est possible de lire.

Enfin, on notera la présence d'un else . end. jq impose de toujours avoir une clause else, donc pour l'instant je ne fais que renvoyer ., l'état qu'on avait au départ.

Ligne de contenu simple

Je peux remplacer cette dernière clause else par l'instruction plus simple qui peut définir une ligne de contenu qui n'est pas un BEGIN:, un END:, ou toute autre ligne avec un comportement particulier : le cas où il faut enregistrer le contenu d'une ligne normale.

Quand on atteint ce cas-là, il faudra faire un appel à setpath pour pouvoir ajouter des valeurs à un tableau de valeurs. Prenons un exemple :

{
  "current_path": ["root", "components", 0],
  "root": {
    "_type": "VCALENDAR",
    "version": "2.0",
    "components": {
      "_type": "VEVENT",
      "summary": {
        "": ["Petit-déjeuner"]
      },
      "attendee": {
        "": ["mailto:lucidiot@example.com"]
      }
    }
  }
}

Nous recevons la ligne SUMMARY;LANGUAGE=en:Breakfast. Elle sera décomposée en le nom summary, les paramètres LANGUAGE=en et la valeur Breakfast. Dans ce cas-là, il faudra simplement ajouter une nouvelle propriété pour ces paramètres : setpath([.current_path[], $name, $params]; [$value]). On obtient ainsi "summary": {"": ["Petit-déjeuner"], "LANGUAGE=en": ["Breakfast"]}.

Nous recevons maintenant ATTENDEE:mailto:gordon.ramsay@example.com. Si on procède de la même manière, on va écraser le participant existant. On aura aussi une erreur car $params vaut null, et pas une chaîne vide, ce qui n'est pas pris en charge par setpath ! Il nous faut donc gérer la possibilité de paramètres nuls et l'ajout à une éventuelle propriété existante. On peut pour cela utiliser //, l'équivalent de l'opérateur ?? en JavaScript : si la valeur est nulle, alors on va utiliser une valeur alternative. Par exemple, $params // "" permettra d'utiliser une chaîne de caractères vide au lieu d'une valeur nulle.

else
  setpath(
    [.current_path[], $name, ($params // "")];
    (getpath([.current_path[], $name, ($params // "")]) // []) + [$value]
  )
end

On récupère avec getpath toute valeur existante s'il y en a. S'il y en a bien, on aura un tableau, sinon une valeur nulle. On n'a pas à se préoccuper de si n'importe quelle partie du chemin existe, car getpath renvoie toujours une valeur nulle s'il ne trouve rien pour n'importe quelle raison. On utilise donc // [] pour garantir qu'on aie toujours un tableau : si rien n'existe, on aura un tableau vide. Maintenant qu'on a la certitude de toujours avoir un tableau, on peut y ajouter notre nouvelle valeur.

Il est aussi à noter que setpath est intelligent : si le nom et les paramètres n'existaient pas du tout au départ, il créera quand même des objets. J'ai précisé plus haut que des chaînes de caractères sont des clés dans des objets et les nombres sont des indices dans des tableaux, donc setpath peut également deviner s'il doit créer un nouveau tableau ou un nouvel objet. Cela permet de créer des structures complexes plus simplement et d'avoir moins de cas particuliers à gérer.

Début de composant

On peut maintenant passer à la gestion de composants enfants. Lorsqu'on atteint une ligne BEGIN et qu'on n'est pas juste au début de notre fichier, il va nous falloir déplacer tout le contexte de lecture vers un nouveau sous-composant, et stocker le nouveau type de composant qu'on vient d'observer. On va pouvoir effectuer ce déplacement en modifiant .current_path, et on pourra définir le type avec un setpath.

...
elif $name == "begin" then
  .current_path += ["_components"]
  | .current_path += [(getpath(.current_path) // []) | length]
  | setpath(.current_path; {"_type": $value})
else
...

On commence par simplement entrer dans la clé _components. Cela ne change rien au véritable contenu de notre objet pour l'instant.

On ajoute ensuite une valeur un peu plus complexe, qui doit être l'indice dans le tableau de composants de ce nouveau composant. Lorsqu'on en est au premier composant enfant, cet indice sera de zéro, et quand on aura les composants suivants, ce doit être un indice après le dernier de la liste. Puisque les indices de tableaux commencent à zéro dans jq, la longueur du tableau correspond au prochain indice disponible, donc on va essayer de récupérer la longueur du tableau de composants.

On a défini .current_path au chemin menant à la liste de composants, donc on peut l'utiliser directement avec getpath pour récupérer le tableau. Dans l'éventualité où on en est au tout premier composant enfant, on aura une valeur nulle, donc on ajoute // [] pour toujours avoir un tableau, même vide. On envoie ensuite le résultat de cette opération à length, qui renvoie la taille d'un tableau, et on a notre nouvel indice. On l'ajoute directement à .current_path.

Notez qu'on utilise des parenthèses dans (getpath(.current_path) // []), car l'ordre de priorité des opérateurs dans jq fait que cette expression serait autrement interprétée comme getpath(.current_path) // ([] | length), ce qui renverrait soit le tableau de composants, soit zéro.

On utilise enfin un setpath plus classique, où on crée le nouveau composant avec le type qu'on a reçu dans notre ligne BEGIN:. Si on en est au premier enfant, setpath pourra créer lui-même le tableau _components, puisqu'une valeur numérique dans le chemin lui indique qu'on souhaite cette fois un tableau.

Le fait d'utiliser ce système de chemin, et pas de seulement enregistrer le type actuel du composant ou quelque chose du genre, fait qu'il nous est maintenant possible de gérer une infinité de BEGIN:, d'imbriquer autant de composants qu'on le souhaite.

On notera enfin que j'utilise un elif ici. Ce cas doit se trouver après le cas de la première ligne du fichier, qui devrait être géré en priorité sur tout le reste, et le cas par défaut de la ligne sans caractéristique particulière, qui doit être le tout dernier cas à tester. C'est la première clause renvoyant true qui sera utilisée, donc l'ordre de placement des conditions est important.

Fin de composant

Dans le cas où on atteint une ligne END:, il nous suffit de remonter à travers notre chemin :

...
elif $name == "end" then
  .current_path |= .[:-2]
else
...

L'opérateur |= permet d'appliquer une fonction ou un traitement à la valeur, puis de l'assigner en même temps. C'est un raccourci pour .current_path = .current_path[:-2]. [:-2] signifie qu'on prend une tranche du tableau : la syntaxe complète est [début:fin]. Si on spécifie le début, alors on prend tous les éléments à partir d'un indice donné. Si on spécifie la fin, on prend tous les éléments jusqu'à un indice donné. Si on utilise des nombres négatifs pour la fin, alors cela veut dire qu'on prend tous les éléments sauf un certain nombre des derniers. Dans le cas actuel, on retire donc les deux derniers éléments.

Lorsqu'on gère un BEGIN:, on rentre dans _components puis dans un des composants dans le tableau. Si on retire donc les deux derniers éléments, on remonte hors de _components et on se retrouve dans le composant parent.

On peut cependant ajouter une vérification particulière pour mieux gérer du contenu qui n'a pas été lu correctement : puisque les lignes de fin de composant incluent aussi le nom du type de composant, par exemple END:VEVENT, on peut comparer ce type au type du composant dans lequel on est pour vérifier qu'on met bien fin à ce composant-là. Quand ce n'est pas le cas, on peut afficher une erreur.

elif $name == "end" then
  if $value == getpath([.current_path[], "_type"]) then
    .current_path |= .[:-2]
  else
    error("Unexpected end of \($value) component while in a \(getpath([.current_path[], "_type"])) component")
  end
else

Lignes de contenu après la racine

Il reste enfin un dernier cas particulier à gérer. On a géré plus haut le cas où il y a des propriétés en dehors du composant racine, avant qu'on aie un BEGIN:VCALENDAR par exemple, mais il serait aussi possible d'avoir des lignes invalides après un END:VCALENDAR. On va donc devoir ajouter une condition supplémentaire qui détecte quand nous nous retrouvons à lire une ligne en étant en dehors du composant racine.

On peut pour cela exploiter un bug que j'ai volontairement laissé dans le traitement de la ligne de fin de composant, décrit ci-dessus. Quand on atteint la fin du composant racine, on aura "current_path": ["root"]. En retirant les deux derniers éléments, on se retrouvera avec un tableau vide. Le seul cas possible où .current_path ne commence par root est donc quand on lit une ligne invalide après avoir terminé le traitement complet du composant racine. On peut donc vérifier que notre chemin ne commence pas par root, et toujours causer une erreur.

if .current_path[0] != "root" then
  error("Unexpected content lines after end of root component")
elif ...

J'utilise cette fois if, car on peut mettre cette condition au tout début de notre liste de cas. Il est inutile d'essayer de comprendre quoi que ce soit de cette ligne de contenu, on sait immédiatement qu'elle est invalide, donc autant planter plus tôt. La généralisation de cette dernière phrase est quelque chose que j'encourage dans mes relectures de code au travail : gérer en premier les cas particuliers, les validations de paramètres, etc. pour pouvoir exclure tous ces problèmes supplémentaires, les retirer de son cerveau, et pouvoir se concentrer sur la logique importante du code.

Valeur de retour

Nous avons géré tous les cas possibles dans le traitement du fichier. On sort donc de reduce, et on se retrouve avec comme résultat l'état complet de notre générateur, avec à la fois .current_path et .root. On va cacher nos détails d'implémentation et retourner juste les données au format JSON qui nous intéressaient, en ajoutant une des instructions les plus compliquées de ce script :

| .root

Et avec ça, on termine enfin de lire un fichier iCalendar et d'en faire du JSON.

Exécution du script

Le script complet, accompagné de commentaires en anglais que j'avais écrit pendant que je le développais histoire de comprendre quelque chose à ce que je faisais, est disponible sur le dépôt Git de booksql. Il est utilisé dans un autre script qu'on verra dans un prochain article, afin d'importer les jours fériés et les vacances scolaires pour remplir la base de données.

J'ai débuté le script par la ligne #!/usr/bin/jq -sRf, ce qui permet de rendre le script exécutable directement. On retrouve le -sR mentionné précédemment pour la lecture de texte brut en une seule chaîne de caractères, et l'argument -f dit à jq de lire l'expression à utiliser depuis un fichier au lieu d'un argument de ligne de commande. Puisque le shebang, cette première ligne lue par l'interpréteur de commandes, sera utilisé en tant que /usr/bin/jq -sRf parse_ics, avec le nom du script ajouté à la fin, cela rend le script exécutable correctement. On peut ainsi utiliser cat file.ics | parse_ics, ou parse_ics file.vcf, pour lire un fichier iCalendar ou vCard et en récupérer un fichier JSON.

Conclusion

Dans la première partie, j'avais notamment précisé que toute cette conversion était complètement futile puisque les calendriers originaux fournis par l'État incluent des versions CSV ou JSON que j'aurais pu exploiter directement. Cependant, écrire ce script, puis faire toutes les recherches qui conduisent à toutes les explications dans ces articles, m'a permis d'apprendre pas mal de choses sur les problèmes qui touchent la gestion des calendriers et des tâches, et de comprendre certains concepts qu'on retrouve dans presque tous les gestionnaires d'informations personnels (PIM).

Par la suite, on commencera donc à voir la structure de la base de données que j'ai créé pour gérer les horaires des bibliothèques, et à écrire des requêtes pour tout importer. Cela nécessitera de faire encore d'autres scripts jq, de faire appel à un autre format de fichiers moins connu, et d'exploiter à notre profit une notion pas évidente de SQL qui rend toujours les développeurs confus.


Commentaires

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