Skelets numériques
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éefile_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 dep2
est séparé par le résultat dep1
. Donc ici, “trouves au moins une lettre séparée par au moins un underscore”.p >>| f
est une function d’Angstrom qui dit “sip
réussit, appliquef
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).
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
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 fonctionlift3
de la bibliothèque. Sa signature de type est('a -> 'b -> 'c -> 'd) -> 'a t -> 'b t -> 'c t -> 'd t
. La description: Theliftn
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 typed
, donc la fonctionlift3
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.
J’aimerais aussi
- vérifier si un fichier existe dans le folder obsidian et si non, en créer un nouveau
- transformer les intégrations d’image en figure hugo