Intégration du blog dans mon Obsidian - logbook

Skelets numériques

Première publication le 21/05/2023
Temps de lecture estimé: 17 m
Tags liés à cet article: technologie

Intégration du blog dans mon Obsidian - logbook

20-05-2023

Première étape, mettre à jour mon éditeur de texte pour gérer le développement OCaml. On regardera [[Neovim]] pour se faire, mais en gros j’ai juste eu besoin de faire un :PackerSync, installer ripgrep.

opam update Et un switch opam avec ocaml 5. opam install . –deps-only opam install ocaml-lsp-server et ocamlformat

Puis finalement, dune init proj obsidian_to_hugo (je suis pas original)

c’est long de setup un environnement de développement, et ça demande plein de trucs

Je procède par niveau de complexité grandissante:

  • d’abord trouver où commencent et où finissent les liens Obsidian (base a un truc pour ça)
  • capturer ce qu’il y a entre chaque lien
  • créer un lien hugo à partir de ce qui est capturé
  • remplacer à chaque intervalle par mon payload

C’est pas des plus efficaces, mais voilà où j’en suis pour l’instant

open Stdio
open Base
open Cmdliner

let print = Stdio.printf

(* Assumption: an opening can only be followed by a
   closing; there are as many openings as there are closing *)

let idx_of_openings str = String.substr_index_all ~pattern:"[[" ~may_overlap:false str
let idx_of_closings str = String.substr_index_all
~pattern:"]]" ~may_overlap:false str
let capture start stop str = String.sub ~pos:(start+2)
~len:(stop-start-2) str
let hugo_of_payload p = Printf.sprintf "[%s]({{ref \"%s\" }})" p p
let obsidian_of_payload p = Printf.sprintf "[[%s]]" p

let file_replace path = 
    let str = In_channel.read_all path
    in let plds = 
    List.map2_exn
    (idx_of_openings str)
    (idx_of_closings str)
    ~f:(fun start stop -> let p = capture start stop str in
    p)
    in
    printf "%s"(
    List.fold plds ~init:str
    ~f:(fun acc pld -> String.substr_replace_first
        ~pattern:(obsidian_of_payload pld)
        ~with_:(hugo_of_payload pld) acc))

(* Command-line parsing *)

let path =
  let doc = "Path for file." in
  Arg.(required & opt (some string) None &  info [ "p"; "path"
] ~doc ~docv:"PATH")

let file_replace_t = Term.(const file_replace $ path)

let cmd =
  let doc = "Replace wikilinks by Hugo links in given  file" in
  let man = [
    `S Manpage.s_bugs;
    `P "Email bug reports to <rhapsodos@example.org>." ]
  in
  let info = Cmd.info "obsidian_to_hugo" ~version:"0.1" ~doc ~man in
  Cmd.v info file_replace_t

let main () = 
    Caml.exit (Cmd.eval cmd)

let () = main()

27-05-2023

J’ai:

  • modifié la fonction file_replace pour qu’elle écrive directement dans un fichier plutôt que d’imprimer. Comme pour l’instant je n’ai aucune garantie sur l’existence du fichier source, les droits d’accès du dossier cible, je l’ai appelée file_replace_exn (qui renvoie une exception système si l’évaluation échoue)
  • ajouté un utilitaire basique pour lister tous les fichiers dans un vault obsidian, et appliquer la fonction file_replace_exn dessus
  • retravaillé l’interface de mon programme: désormais, l’utilisateurice devra spécifier la racine du vault Obsidian, et la racine du blog Hugo considéré (pour l’instant, racine du blog Hugo = la racine du dossier content dans lequel Hugo cherche le markdown)

On voit que notre spécification définie [[Intégration du blog dans mon Obsidian#Spécification]] est largement remplie. Mais qu’elle ne répond pas du tout au besoin (on ne fait qu’écrire un fichier dans un autre).

Doit-on préserver la hiérarchie Obsidian vers Hugo? Ça semble le plus simple et ça correspond à mon cas d’usage: envoyer tout le contenu d’une partie de mon Vault vers mon blog. Donc le Vault est considéré comme source de confiance principale.

01-08-2023

J’avais envie d’apprendre à utiliser les parser combinators (exemple ici); et comme j’avais un peu de temps, j’ai décidé de changer un peu la base de mon parsing. J’ai utilisé la bibliothèque Angstrom. Le principe est plutôt séduisant: on écrit et on combine des parsers pour produire des valeurs complexes. La donnée que je traite est assez peu structurée en apparence (du texte brut avec toute la richesse de ce que peut produire mon cerveau équipé d’un clavier francophone), mais il y a quelques éléments intéressants à transformer pour les rendre compatibles avec Hugo: les wikilinks bien sûr, mais aussi certaines métadonnées (en yaml dans mon obsidian, en TOML dans mon Hugo) et tags. Tout cela peut évidemment se faire en traitant le texte brut (comme j’ai fait jusque là). Cependant, j’apprécie écrire des parsers; mais les outils vénérables type yacc/bison m’ont toujours un peu effrayé. L’approche des parser combinator me semble être un peu plus simple d’accès, conceptuellement.

Je m’interroge aussi à l’interface avec l’utilisateurice. Est-ce qu’il vaut mieux un logiciel qui considère le “fichier” comme base, et se fier à tous les utilitaires Linux qui existent déjà pour combiner les fichiers et les dossiers? Ou utiliser le vault Obsidian comme base? Je n’ai pas encore décidé, les deux approches ont leur mérite; la seconde me semble potentiellement demander moins de barrière à l’entrée (il n’y a qu’à préciser deux dossiers et d’éventuels filtres), à défaut de la flexibilité.

Voilà le code pour parser, mis à jour (je compte à moyen terme me configurer une forge logicielle personnelle histoire de ne pas partager des screenshots de code). Je me suis largement inspiré de ce post de blog, et j’ai reçu un peu d’aide de quelqu’un qui utilisait déjà cette bibliothèque. L’objectif ici, c’est d’avoir un parser qui nous renvoie une chaîne de caractère avec les liens wikilinks transformés en liens Hugo.

open Angstrom
open Base

(* Assumption: an opening can only be followed by a closing; there are as many
   openings as there are closing *)

let is_letter = Char.is_alpha
let underscore = char '_'
let enclosed l p r = string l *> p <* string r

let link_parser =
  sep_by1 underscore (take_while1 is_letter) >>| String.concat ~sep:"_"

let not_bracket = take_while1 (function '[' -> false | _ -> true)
let hugo_of_payload p = Printf.sprintf "[%s]({{ ref "%s" }})" p p
let wikilink = enclosed "[[" link_parser "]]" >>| fun l -> hugo_of_payload l

let collect_all =
  many1 (not_bracket <|> wikilink <|> string "[") >>| String.concat ~sep:""

let convert_wikilinks s =
  match Angstrom.parse_string ~consume:All collect_all s with
  | Ok s -> s
  | Error msg -> failwith msg

Qu’est-ce qui se passe ici?

let is_letter = Char.is_alpha
let underscore = char '_'
let enclosed l p r = string l *> p <* string r

Ici, on définit trois parsers: les deux premiers sont simples: un parser de lettres, un autre d’underscores. Le troisième définit un parser qui parse si le résultat d’un autre parser est inclu entre deux chaînes de caractères l et p. Les opérateurs *>* et <* sont fournis par Angstrom: p1 *> p2 signifie “prend le résultat du parsing de p1, jette le, et calcule p2”. p2 <* p1 signifie la même chose.

let link_parser =
  sep_by1 underscore (take_while1 is_letter) >>| String.concat ~sep:"_"

Ce parser est une composition de plusieurs autres:

  • take_while1 est un parser qui renverra un résultat tant que le parser donné en entrée (is_letter) trouve au moins une fois quelque chose. Donc ici, “tant que tu trouves une lettre, continue et renvoie la lettre”
  • sep_by1 p1 p2 est un parser qui détecte si le résultat de p2 est séparé par le résultat de p1. Donc ici, “trouves au moins une lettre séparée par au moins un underscore”.
  • p >>| f est une function d’Angstrom qui dit “si p réussit, applique f sur le résultat”

Le link_parser pourrait être largement amélioré (pour l’instant il ne prend pas en compte la présence d’espaces, mais c’est réglable facilement).

let not_bracket = take_while1 (function '[' -> false | _ -> true)
let hugo_of_payload p = Printf.sprintf "[%s]({{ref \"%s\" }})" p p
let wikilink = enclosed "[[" link_parser "]]" >>| fun l -> hugo_of_payload l

let collect_all =
  many1 (not_bracket <|> wikilink <|> string "[") >>| String.concat ~sep:""

Ici, collect_all est une composition de trois parsers: not_bracket (qui renvoie tout ce qui n’est pas un crochet ouvrant), wikilink (qui renverra un lien au format Hugo à partir d’un wikilink) et un parser qui détecte des (simples) crochets. La fonction <|> indique le “choix” de parser. Ici, “prend le résultat de not_bracket, si tu trouves un crochet ouvrant, prend le résultat de wikilink, et si ce n’est pas un wikilink, alors c’est un crochet ouvrant simple; tu peux le collecter.”. many1 indique qu’on applique ce parser composé autant que possible, en stockant le résultat dans une liste.

L’objectif de la prochaine itération est d’avoir un programme suffisamment utilisable de sorte à ce que je puisse écrire cette note uniquement dans mon vault obsidian (pour l’instant, elle est toujours dans le dossier de mon blog).

17-08-2023

En voulant convertir une note obsidian vers mon blog, je me suis rendu compte que le parser de méta-données était trop simple. J’ai une méta-donnée qui contenait un signe “-”, ce qui flinguait le parser de méta-données (qui ne prend que des caractères autres que “-” et “+”).

L’en-tête en question ressemble à ça:

---
notions: [genre]
status: fini
genre: [non-fiction]
---

Il faut donc un parser un peu plus malin que ça. Pour ça, on peut se demander ce que c’est qu’une méta-donnée. Dans mon cas, c’est un ensemble clef-valeur balisées entre trois -. Chaque clef est séparée de sa valeur par un :. Ça a la forme suivante:

---
clef1 : valeur1
clef2: valeur2
---

*reste du document*

On peut spécifier un peu plus ce que sont les clefs. Il y en a relativement peu, donc on peut les énumérer directement dans le code (si j’ouvre ce logiciel un jour, il faudra un moyen plus pratique pour que l’utilisateurice puisse configurer ses propres métadonnées). Je mets celles de l’exemple, plus une autre qui indique une métadonnée inconnue:

type key = NOTIONS | STATUS | GENRE | UNKNOWN

Niveau valeurs, c’est beaucoup plus libre. Ça peut être des mots ou une liste de mots, ou rien du tout (on peut avoir des méta-donnée pas encore remplies).

Note
on peut mettre des valeurs de type non homogènes dans les méta-données obsidian, ainsi que des nombres: je fais au plus rapide en ayant un type simple mais qui ne capture pas tout ce qui est possible pour l’instant.
type data = String of string | List of data list | Empty

Le type représentant nos méta-données est alors :

type metadata = Metadata of key*data

Avec cette structure, on va pouvoir construire nos parsers.

Premièrement, le parser de clefs est le plus simple. On matche sur le résultat d’un parser qui prend n’importe quoi tant qu’il n’y a pas d’espace, et on matche dessus.

let not_space = take_while1 (function ' ' -> false | _ -> true)

let key =
  not_space >>| function
  | "notions" -> NOTIONS
  | "status" -> STATUS
  | "genre" -> GENRE
  | _ -> UNKNOWN

Simple; maintenant on passe aux valeurs. On se concentre sur les chaînes de caractères ou les listes de chaînes, pour l’exemple. Les éléments de listes sont supposés être séparés par des virgules ,. Pas de difficulté pour les valeurs ‘simples’:

let not_bracket = take_while1 (function '[' -> false | _ -> true)
let single = not_bracket >>| fun s -> String s

Pour les valeurs de liste, il faut:

  • identifier qu’on arrive dans une liste (avec le caractère [)
  • prendre toute les chaînes de caractère qu’on peut et les construire tant qu’on ne trouve pas de ,; alternativement on peut tout prendre qui ne soit pas séparé par un ,
  • s’arrêter dès qu’on croise le caractère ]

On peut réutiliser le parser enclosed_by, ici très utile pour délimiter notre liste

  let value_list =
    enclosed "[" (sep_by (string ",") not_bracket2) "]" >>| fun l ->
    List (List.map ~f:(fun s -> String s) l)

Pour combiner les deux parsers, on peut appliquer le combinateur <|> déjà vu:

let value =
  let single = not_bracket >>| fun s -> String s in
  let value_list =
    enclosed "[" (sep_by (string ",") not_bracket2) "]" >>| fun l ->
    List (List.map ~f:(fun s -> String s) l)
  in
  single <|> value_list

Et finalement, on écrit notre parser de métadonnées plus structuré: une clef doit être suivie de deux points (avec un nombre arbitraire d’espace avant et après), suivie d’une valeur. On écrit une petite fonction intermédiaire qui nous débarasse des espaces et des deux points en trop:

let metadata_parser =
  let semicols =
    let may_spaces = take_while (function ' ' -> true | _ -> false) in
    sep_by1 (string ":") may_spaces
  in
  key_parser >>= fun key ->
  semicols *> peek_char >>= function
  | Some '[' -> value_parser >>| fun v -> Metadata (key, v)
  | _ -> return (Metadata (UNKNOWN, Empty))

Qu’est-ce qu’on fait avec ces valeurs? Là, rien de bien sorcier, on va les reconvertir en chaîne de caractère “toute bête” pour qu’on puisse écrire dans Hugo. On écrit quelques fonctions d’aide en plus:

let key_to_string = function
  | NOTIONS -> "notions"
  | STATUS -> "status"
  | GENRE -> "genre"
  | UNKNOWN -> "unknown_metadata"

let rec data_to_string = function
  | String s -> s
  | List l ->
    List.fold ~init:"" ~f:(fun acc s -> acc ^ "," ^ data_to_string s) l
  | Empty -> ""

let metadata_to_string (Metadata (k, v)) =
  key_to_string k ^ ":" ^ data_to_string v
Remarque
On a un peu l’impression de n’avoir rien fait avec nos types, et en un sens c’est vrai. Ils sont là pour apporter un peu plus de structure dans le code, mais les valeurs sont retransformées “telles quelles” à Hugo. Dans le futur, on opèrera probablement des transformations sur les valeurs typées avant de les réécrire pour Hugo.

Et finalement:

let metadata =
  enclosed "---\n" metadata_parser "---\n" >>| fun s ->
  "+++\n" ^ metadata_to_string s ^ "+++\n"

On essaie sur notre header et… ça ne marche pas :’) On va essayer de debugger. Où est-ce que ça à pu foirer?

  • on a pas modifié le type de retour de la fonction metadata_parser donc ça ne peut se passer que là dedans
  • c’est donc soit semicols, nos parsers de clefs et de valeurs, ou leur combinaison Pour éliminer les parsers “simples”, on va tester dans utop directement les clefs et les valeurs du test
Angstrom.parse_string ~consume:All metadata_parser "notions  
: [genre]";;  
- : (metadata, string) result =  
Stdlib.Ok (Metadata (UNKNOWN, List [String "genre"]))

Premier souci, la clef “notions” n’est pas parsée correctement. Le souci n’est pas à trouver dans le key_parser vu sa simplicité. Par contre, value_parser fonctionne bien sur une liste à un élément. Par contre…

Angstrom.parse_string ~consume:All metadata_parser "notions  
: [genre, toto]";;  
- : (metadata, string) result =  
Stdlib.Ok (Metadata (UNKNOWN, List [String "genre,toto"]))

Avec plusieurs éléments, on obtient une liste à un seul élément qui contient la chaîne “genre,toto”, alors qu’on souhaiterait une liste à deux éléments: un “chaîne”, un “toto”. Ceci se résout en modifiant notre fonction value_list, plus précisément not_bracket2 (qui peut tout à fait prendre des virgules vu sa définition)

let not_bracket2 = take_while1 (function ']' | ',' -> false | _ -> true)

(on commence à démultiplier les parsers et les cas particuliers, il faudra simplifier à un moment)

Pour corriger le bug sur les clefs, on a été trop permissif sur ce que devait contenir une clef (avec seulement un not_space). Une clef, c’est des caractères alphabétiques et c’est tout. Notre nouvelle fonction est alors

let alpha = take_while1 Char.is_alpha

let key_parser =
  alpha >>| function
  | "notions" -> NOTIONS
  | "status" -> STATUS
  | "genre" -> GENRE
  | _ -> UNKNOWN

Tout semble baigner sur les fonctions de clef et de valeur, ainsi que sur la suppression des deux points. Mais il se passe quoi pour une valeur qui n’est pas une liste?

utop # Angstrom.parse_string ~consume:All metadata_parser "status:  
fini";;  
- : (metadata, string) result = Stdlib.Error ": end_of_input"

Pas attendu du tout. Clef, valeur et deux points sont bien gérés. Mais est-ce qu’on a une condition d’arrêt? Réponse: j’ai oublié de corrigé le match; mais à dire vrai, le peek_char n’est pas utile dans la mesure où on sait qu’une séquence clef -> deux points sera nécessairement suivie d’une valeur. Donc pas besoin de match. À dire vrai, on pourrait se débarasser du peek_char avec un opérateur spécial, mais je commence à en avoir assez de travailler là dessus.

Ça a l’air d’échouer au passage à la ligne. On va donc supposer qu’on passe immédiatement à la ligne quand on a fini de parser une metadonnée. No change.

utop # Angstrom.parse_string ~consume:All metadata_parser "notions  
: [genre]\n ";;  
- : (metadata, string) result = Stdlib.Error ": end_of_input"

Il y a un mystère à élucider ici sur pourquoi il échoue.

Ah, et le link parser devrait retirer les points: Hugo n’arrive pas à trouver les références dont les noms comportent des points, semble-t-il

18-08-2023

Quelques simplifications:

  • le type data encode des comportements impossibles. Soit il y a un élément, soit plusieurs, soit zéro, mais le type récursif de liste sous-entend qu’on peut avoir des listes de listes, ou des listes d’éléments vides. Ce n’est pas le cas. On retire la récursion. On se laisse guider par le type checker pour modifier les fonctions qui restent, et ça simplifie pas mal de choses! On va aussi rajouter un parser d’espace, dont on va sauter le résultat pour éviter de démultiplier les parsers
let alpha = take_while1 Char.is_alpha
let space = char ' '

type data =
  | String of string
  | Many of string list
  | Empty

let data_to_string = function
  | String s -> s
  | Many l -> String.concat ~sep:"," l
  | Empty -> ""

let value =
    let single = alpha >>| fun s -> Single s in
    let elems = sep_by (string ",") alpha >>| fun l -> Many l in
    let list = enclosed "[" elems "]" in
    single <|> list <|> return Empty
    
let sep =
    let may_spaces = skip_many space in
    sep_by1 (string ":") may_spaces
  • une entrée valide est de type key sep value; il y a moyen d’exploiter cette structure? En demandant de l’aide, on m’a pointé vers la fonction lift3 de la bibliothèque. Sa signature de type est ('a -> 'b -> 'c -> 'd) -> 'a t -> 'b t -> 'c t -> 'd t. La description: The liftn family of functions promote functions to the parser monad. Sybillin. Ce que ça veut dire, c’est qu’on donne une fonction à trois arguments, trois parsers donc le résultat sera un argument de la fonction donnée. La fonction renvoie une valeur de type d, donc la fonction lift3 renvoie un parser de type 'd t. On a construit un parser de metadata à partir du constructeur de ce type, et des parsers qui renvoient des valeurs de ces types. C’est puissant!
let entry =
  lift3 (fun k _ v -> Metadata (k, v)) key sep value

Je mettais parfois des \n dans mon code, parfois non. On va utiliser le parser end_of_line, plus robuste (notamment aux retours chariots sous Windows) et pour avoir un style de code plus cohérent. ça donne ça (oui il y a quand même des \n mais j’écris ça à 23h36, je me souviens plus comment mettre des retours à la ligne plateforme-indépendants)

let metadata =
  let delim = string "---" <* end_of_line in
  let entries = many (entry <* end_of_line) in
  delim *> entries <* delim >>| fun s ->
  "+++\n" ^ (String.concat @@ List.map ~f:metadata_to_string s) ^ "+++\n"

Avec tout ça, on retire notre parser not_bracket2 bancal et on a quelque chose qui est, à mon avis, un poil plus lisible.

Les tests maintenant! Toujours pas. Ça échoue dans le parsing de non-fiction, donc il faut revoir notre parser de value une dernière fois. Plus précisément, alpha est trop restrictif: il faudrait aussi accepter les -. On se rend compte qu’on ne matchait jamais sur le parser de liste parce que la définition de word prenait des crochets ouvrants. Comment faire un parser qui n’accepte que certains caractères? On va s’arrêter là pour ce soir.

19-08-2023

Ce qu’il fallait changer pour que les tests passent:

  • Faire un parser qui n’accepte que certains caractères pour word:
let word =
  let is_valid_word_char = function
    | c when Char.is_alpha c -> true
    | '-' | '"' -> true
    | _ -> false
  in
  take_while1 is_valid_word_char
  • Écrire un parser de séparateurs, parce qu’on en a deux et que c’était hackish: un séparateur entre les clefs et les valeurs et un séparateur dans les listes. Un séparateur, c’est un caractère séparé par zéro ou plus espaces de part et d’autre. On modifie le type de Metadata pour prendre en compte le séparateur
let space = char ' '
let spaces = many space >>| String.of_char_list

let sep_parser c =
  lift3 (fun l c r -> l ^ c ^ r) spaces (char c >>| String.of_char) spaces
  • Notre parser d’entrée devient alors:
let metadata_to_string (Metadata (k, s, v)) =
  key_to_string k ^ s ^ data_to_string v

[...]

let entry =
  let key =
    alpha >>| function
    | "notions" -> NOTIONS
    | "status" -> STATUS
    | "genre" -> GENRE
    | _ -> UNKNOWN
  in
  let value =
    let single = word >>| fun s -> Single s in
    let elems = sep_by (sep_parser ',') word >>| fun l -> Many l in
    let list = enclosed "[" elems "]" in
    single <|> list <|> return Empty
  in
  lift3 (fun k s v -> Metadata (k, s, v)) key (sep_parser ':') value

Finalement, appeler notre programme sur un fichier avec l’en-tête suivante:

---
notions: [genre]
status: fini
genre: [non-fiction]
---

nous renvoie un fichier avec l’en-tête suivante

+++
notions: [genre]
status: fini
genre: [non-fiction]
+++

C’est un succès. On ne gère cependant pas les accents, mais entre les métadonnées et les liens, on s’approche de l’utilisabilité.

Prochaine étape: on va rendre le logiciel un peu plus robuste avec du report d’erreur.

Retour en haut de page Tous mes posts sont en licence CC-BY-NC-SA 4.0