Author: Kevin Schoon [kevinschoon@gmail.com]
Hash: 56f35cb21b40723967aa75c1ef6cc31f4036845a
Timestamp: Wed, 19 May 2021 01:42:01 +0000 (3 years ago)

+1015 -709 +/-14 browse
The Great Refactor
The Great Refactor

Notes are now loaded into a tree for traversal internally. Add new Alcotest
framework. Note data type has been simplified. Various refactoring.
1diff --git a/bin/dune b/bin/dune
2index ce01fde..43a0b41 100644
3--- a/bin/dune
4+++ b/bin/dune
5 @@ -1,6 +1,8 @@
6 (executable
7 (public_name note)
8 (name note)
9- (libraries note_lib)
10+ (preprocess
11+ (pps ppx_jane))
12+ (libraries note_lib base core dune-build-info)
13 (modes exe)
14 (ocamlopt_flags))
15 diff --git a/bin/note.ml b/bin/note.ml
16index 8170d1d..9c3dc9b 100644
17--- a/bin/note.ml
18+++ b/bin/note.ml
19 @@ -1,3 +1,268 @@
20- let () =
21- Note_lib.Cmd.run ;
22+ open Core
23+ open Note_lib
24
25+ let cfg = Config.load
26+
27+ let note_of_title title =
28+ sprintf {|
29+ ---
30+ title: "%s"
31+ ---
32+
33+ # %s
34+ |} title title |> Note.of_string
35+
36+ let rec convert_tree tree =
37+ let (Note.Tree (note, others)) = tree in
38+ let title = note.frontmatter.title in
39+ let title = "[" ^ title ^ "]" in
40+ Display.Tree.Tree (title, List.map ~f:convert_tree others)
41+
42+ let get_notes =
43+ let notes = cfg.state_dir |> Note.load |> Note.flatten ~accm:[] in
44+ notes
45+
46+ let get_title (note : Note.note) = note.frontmatter.title
47+
48+ let get_tags (note : Note.note) = note.frontmatter.tags
49+
50+ let to_keys ~kind notes =
51+ match kind with
52+ | `Title -> List.map ~f:get_title notes
53+ | `Tags -> List.concat (List.map ~f:get_tags notes)
54+
55+ let search_arg kind =
56+ Command.Arg_type.create
57+ ~complete:(fun _ ~part ->
58+ let notes = get_notes in
59+ List.filter_map
60+ ~f:(fun key ->
61+ if String.is_substring ~substring:part key then Some key else None)
62+ (to_keys ~kind notes))
63+ (fun filter -> filter)
64+
65+ let key_arg =
66+ Command.Arg_type.create
67+ ~complete:(fun _ ~part ->
68+ let string_keys = List.map ~f:Config.Key.to_string Config.Key.all in
69+ List.filter
70+ ~f:(fun key -> String.is_substring ~substring:part key)
71+ string_keys)
72+ Config.Key.of_string
73+
74+ let flag_to_op state =
75+ match state with true -> Note.Operator.And | false -> Note.Operator.Or
76+
77+ let column_list_arg =
78+ Command.Arg_type.create (fun value ->
79+ List.map ~f:Config.Column.of_string (String.split ~on:',' value))
80+
81+ let encoding_arg =
82+ Command.Arg_type.create
83+ ~complete:(fun _ ~part ->
84+ let string_keys =
85+ List.map ~f:Config.Encoding.to_string Config.Encoding.all
86+ in
87+ List.filter
88+ ~f:(fun key -> String.is_substring ~substring:part key)
89+ string_keys)
90+ Config.Encoding.of_string
91+
92+ let list_style_arg =
93+ Command.Arg_type.create
94+ ~complete:(fun _ ~part ->
95+ let string_keys =
96+ List.map ~f:Config.ListStyle.to_string Config.ListStyle.all
97+ in
98+ List.filter
99+ ~f:(fun key -> String.is_substring ~substring:part key)
100+ string_keys)
101+ Config.ListStyle.of_string
102+
103+ let term_args =
104+ let open Command.Let_syntax in
105+ [%map_open
106+ let title =
107+ flag "title"
108+ (listed (search_arg `Title))
109+ ~doc:"regular expression matching the note title"
110+ and tags =
111+ flag "tag"
112+ (listed (search_arg `Tags))
113+ ~doc:"sequence of regular expressions matching note tags"
114+ in
115+ let term : Note.Term.t = { title; description = []; tags } in
116+ term]
117+
118+ (*
119+ * commands
120+ *)
121+
122+ let cat_note =
123+ let open Command.Let_syntax in
124+ Command.basic ~summary:"write notes to stdout"
125+ ~readme:(fun () ->
126+ {|
127+ Write one or more notes to stdout. By default the cat command will write every
128+ note to stdout as plain text however the encoding can be adjusted to yaml or
129+ json for consumption by other tools.
130+ |})
131+ [%map_open
132+ let term = term_args
133+ and encoding =
134+ flag "encoding"
135+ (optional_with_default cfg.encoding encoding_arg)
136+ ~doc:"format [json | yaml | raw] (default: raw)"
137+ in
138+ fun () ->
139+ let notes =
140+ cfg.state_dir |> Note.load |> Note.find_many ~term ~notes:[]
141+ in
142+ List.iter
143+ ~f:(fun note ->
144+ print_endline (Note.Encoding.to_string ~style:encoding note))
145+ notes]
146+
147+ let config_show =
148+ Command.basic ~summary:"show the current configuration"
149+ (Command.Param.return (fun () -> print_string (Config.to_string cfg)))
150+
151+ let config_get =
152+ let open Command.Let_syntax in
153+ Command.basic ~summary:"get a config value"
154+ [%map_open
155+ let key = anon ("key" %: key_arg) in
156+ fun () -> print_endline (Config.get cfg key)]
157+
158+ let config_set =
159+ let open Command.Let_syntax in
160+ Command.basic ~summary:"set a config value"
161+ [%map_open
162+ let key = anon ("key" %: key_arg) and value = anon ("value" %: string) in
163+ fun () ->
164+ let cfg = Config.set cfg key value in
165+ Config.save cfg]
166+
167+ let create_note =
168+ let open Command.Let_syntax in
169+ Command.basic ~summary:"create a new note"
170+ ~readme:(fun () ->
171+ {|
172+ Create a new note and save it to disk in your configured state_dir. The
173+ on_modification callback will be invoked if the file is committed to disk.
174+ |})
175+ [%map_open
176+ let open_stdin =
177+ flag "stdin" (optional bool)
178+ ~doc:"read content from stdin and copy it into the note body"
179+ and title = anon ("title" %: string) in
180+ fun () ->
181+ let slug = Slug.next cfg.state_dir in
182+ match open_stdin with
183+ | Some _ ->
184+ (* reading from stdin so write directly to note *)
185+ let note =
186+ In_channel.stdin |> In_channel.input_all |> Note.of_string
187+ in
188+ slug.path
189+ |> Io.create ~callback:cfg.on_modification
190+ ~content:(Note.to_string note)
191+ | None ->
192+ let content = title |> note_of_title |> Note.to_string in
193+ Io.create_on_change ~callback:cfg.on_modification
194+ ~editor:cfg.editor ~content slug.path]
195+
196+ let delete_note =
197+ let open Command.Let_syntax in
198+ Command.basic ~summary:"delete an existing note"
199+ ~readme:(fun () ->
200+ {|
201+ Delete the first note that matches the filter criteria.
202+ |})
203+ [%map_open
204+ let term = term_args in
205+ fun () ->
206+ let note = cfg.state_dir |> Note.load |> Note.find_one ~term in
207+ match note with
208+ | Some note ->
209+ (Option.value_exn note.slug).path
210+ |> Io.delete ~callback:cfg.on_modification
211+ ~title:note.frontmatter.title
212+ | None -> failwith "not found"]
213+
214+ let edit_note =
215+ let open Command.Let_syntax in
216+ Command.basic ~summary:"edit an existing note"
217+ ~readme:(fun () ->
218+ {|
219+ Select a note that matches the filter criteria and open it in your text editor.
220+ |})
221+ [%map_open
222+ let term = term_args in
223+ fun () ->
224+ let note = cfg.state_dir |> Note.load |> Note.find_one ~term in
225+ match note with
226+ | Some note ->
227+ (Option.value_exn note.slug).path
228+ |> Io.edit ~callback:cfg.on_modification ~editor:cfg.editor
229+ | None -> failwith "not found"]
230+
231+ let list_notes =
232+ let open Command.Let_syntax in
233+ Command.basic ~summary:"list existing notes"
234+ ~readme:(fun () ->
235+ {|
236+ List one or more notes that match the filter criteria, if no filter criteria
237+ is provided then all notes will be listed.
238+ |})
239+ [%map_open
240+ let term = term_args
241+ and style =
242+ flag "style"
243+ (optional_with_default cfg.list_style list_style_arg)
244+ ~doc:"list style [fixed | wide | simple]"
245+ and columns =
246+ flag "columns"
247+ (optional_with_default cfg.column_list column_list_arg)
248+ ~doc:"columns to include in output"
249+ in
250+ fun () ->
251+ let notes =
252+ Note.find_many
253+ ~term
254+ ~notes:[] (Note.load cfg.state_dir)
255+ in
256+ let styles = cfg.styles in
257+ let cells = Note.Util.to_cells ~columns ~styles notes in
258+ Display.Cell.to_stdout ~style cells]
259+
260+ let sync =
261+ Command.basic ~summary:"sync notes to a remote server"
262+ (Command.Param.return (fun () -> Sync.sync cfg.on_sync))
263+
264+ let tree =
265+ Command.basic ~summary:"tree debug command"
266+ (Command.Param.return (fun () ->
267+ cfg.state_dir |> Note.load |> convert_tree |> Display.Tree.to_string |> print_endline))
268+
269+ let version =
270+ match Build_info.V1.version () with
271+ | None -> "n/a"
272+ | Some v -> Build_info.V1.Version.to_string v
273+
274+ let run =
275+ Command.run ~version ~build_info:""
276+ (Command.group ~summary:"Note is a simple CLI based note taking application"
277+ [
278+ ("cat", cat_note);
279+ ("create", create_note);
280+ ( "config",
281+ Command.group ~summary:"config management"
282+ [ ("show", config_show); ("get", config_get); ("set", config_set) ]
283+ );
284+ ("delete", delete_note);
285+ ("edit", edit_note);
286+ ("ls", list_notes);
287+ ("sync", sync);
288+ ("tree", tree);
289+ ])
290 diff --git a/lib/cmd.ml b/lib/cmd.ml
291deleted file mode 100644
292index 2b5aae3..0000000
293--- a/lib/cmd.ml
294+++ /dev/null
295 @@ -1,279 +0,0 @@
296- open Core
297-
298- let cfg = Config.load
299-
300- let get_notes =
301- List.map
302- ~f:(fun slug ->
303- let content = In_channel.read_all (Slug.get_path slug) in
304- Note.of_string ~content slug)
305- (Slug.load cfg.state_dir)
306-
307- let to_keys ~kind notes =
308- match kind with
309- | `Title -> List.map ~f:Note.get_title notes
310- | `Tags -> List.concat (List.map ~f:Note.get_tags notes)
311-
312- let search_arg kind =
313- Command.Arg_type.create
314- ~complete:(fun _ ~part ->
315- let notes = get_notes in
316- List.filter_map
317- ~f:(fun key ->
318- if String.is_substring ~substring:part key then Some key else None)
319- (to_keys ~kind notes))
320- (fun filter -> Re.Str.regexp filter)
321-
322- let key_arg =
323- Command.Arg_type.create
324- ~complete:(fun _ ~part ->
325- let string_keys = List.map ~f:Config.Key.to_string Config.Key.all in
326- List.filter
327- ~f:(fun key -> String.is_substring ~substring:part key)
328- string_keys)
329- Config.Key.of_string
330-
331- let flag_to_op state = match state with true -> Note.And | false -> Note.Or
332-
333- let column_list_arg =
334- Command.Arg_type.create (fun value ->
335- List.map ~f:Config.Column.of_string (String.split ~on:',' value))
336-
337- let encoding_arg =
338- Command.Arg_type.create
339- ~complete:(fun _ ~part ->
340- let string_keys =
341- List.map ~f:Config.Encoding.to_string Config.Encoding.all
342- in
343- List.filter
344- ~f:(fun key -> String.is_substring ~substring:part key)
345- string_keys)
346- Config.Encoding.of_string
347-
348- let list_style_arg =
349- Command.Arg_type.create
350- ~complete:(fun _ ~part ->
351- let string_keys =
352- List.map ~f:Config.ListStyle.to_string Config.ListStyle.all
353- in
354- List.filter
355- ~f:(fun key -> String.is_substring ~substring:part key)
356- string_keys)
357- Config.ListStyle.of_string
358-
359- let filter_args =
360- let open Command.Let_syntax in
361- [%map_open
362- let titles =
363- flag "title"
364- (listed (search_arg `Title))
365- ~doc:"regular expression matching the note title"
366- and tags =
367- flag "tag"
368- (listed (search_arg `Tags))
369- ~doc:"sequence of regular expressions matching note tags"
370- and operator = flag "and" no_arg ~doc:"logical AND instead of default OR" in
371- (titles, tags, operator)]
372-
373- (*
374- * commands
375- *)
376-
377- let cat_note =
378- let open Command.Let_syntax in
379- Command.basic ~summary:"write notes to stdout"
380- ~readme:(fun () ->
381- {|
382- Write one or more notes to stdout. By default the cat command will write every
383- note to stdout as plain text however the encoding can be adjusted to yaml or
384- json for consumption by other tools.
385- |})
386- [%map_open
387- let titles, tags, operator = filter_args
388- and title = anon (sequence ("title" %: search_arg `Title))
389- and encoding =
390- flag "encoding"
391- (optional_with_default cfg.encoding encoding_arg)
392- ~doc:"format [json | yaml | raw] (default: raw)"
393- in
394- fun () ->
395- let notes =
396- Note.find_many
397- ~term:
398- {
399- titles = List.append title titles;
400- tags;
401- operator = flag_to_op operator;
402- }
403- get_notes
404- in
405- List.iter
406- ~f:(fun note ->
407- print_endline (Note.Encoding.to_string ~style:encoding note))
408- notes]
409-
410- let config_show =
411- Command.basic ~summary:"show the current configuration"
412- (Command.Param.return (fun () -> print_string (Config.to_string cfg)))
413-
414- let config_get =
415- let open Command.Let_syntax in
416- Command.basic ~summary:"get a config value"
417- [%map_open
418- let key = anon ("key" %: key_arg) in
419- fun () -> print_endline (Config.get cfg key)]
420-
421- let config_set =
422- let open Command.Let_syntax in
423- Command.basic ~summary:"set a config value"
424- [%map_open
425- let key = anon ("key" %: key_arg) and value = anon ("value" %: string) in
426- fun () ->
427- let cfg = Config.set cfg key value in
428- Config.save cfg]
429-
430- let create_note =
431- let open Command.Let_syntax in
432- Command.basic ~summary:"create a new note"
433- ~readme:(fun () ->
434- {|
435- Create a new note and save it to disk in your configured state_dir. The
436- on_modification callback will be invoked if the file is committed to disk.
437- |})
438- [%map_open
439- let open_stdin =
440- flag "stdin" (optional bool)
441- ~doc:"read content from stdin and copy it into the note body"
442- and title = anon ("title" %: string)
443- and tags = anon (sequence ("tag" %: string)) in
444- fun () ->
445- let slug = Slug.next cfg.state_dir in
446- match open_stdin with
447- | Some _ ->
448- (* reading from stdin so write directly to note *)
449- let content = In_channel.input_all In_channel.stdin in
450- let note = Note.build ~tags ~content ~title slug in
451- Io.create ~callback:cfg.on_modification
452- ~content:(Note.to_string note) (Slug.get_path slug)
453- | None ->
454- let note = Note.build ~tags ~content:"" ~title slug in
455- let init_content = Note.to_string note in
456- Io.create_on_change ~callback:cfg.on_modification ~editor:cfg.editor
457- init_content (Slug.get_path slug)]
458-
459- let delete_note =
460- let open Command.Let_syntax in
461- Command.basic ~summary:"delete an existing note"
462- ~readme:(fun () ->
463- {|
464- Delete the first note that matches the filter criteria.
465- |})
466- [%map_open
467- let titles, tags, operator = filter_args
468- and title = anon (sequence ("title" %: search_arg `Title)) in
469- fun () ->
470- let notes = get_notes in
471- let note =
472- Note.find_one
473- ~term:
474- {
475- titles = List.append title titles;
476- tags;
477- operator = flag_to_op operator;
478- }
479- notes
480- in
481- match note with
482- | Some note ->
483- Io.delete ~callback:cfg.on_modification ~title:(Note.get_title note)
484- (Note.get_path note)
485- | None -> failwith "not found"]
486-
487- let edit_note =
488- let open Command.Let_syntax in
489- Command.basic ~summary:"edit an existing note"
490- ~readme:(fun () ->
491- {|
492- Select a note that matches the filter criteria and open it in your text editor.
493- |})
494- [%map_open
495- let titles, tags, operator = filter_args
496- and title = anon (sequence ("title" %: search_arg `Title)) in
497- fun () ->
498- let note =
499- Note.find_one
500- ~term:
501- {
502- titles = List.append title titles;
503- tags;
504- operator = flag_to_op operator;
505- }
506- get_notes
507- in
508- match note with
509- | Some note ->
510- Io.edit ~callback:cfg.on_modification ~editor:cfg.editor
511- (Note.get_path note)
512- | None -> failwith "not found"]
513-
514- let list_notes =
515- let open Command.Let_syntax in
516- Command.basic ~summary:"list existing notes"
517- ~readme:(fun () ->
518- {|
519- List one or more notes that match the filter criteria, if no filter criteria
520- is provided then all notes will be listed.
521- |})
522- [%map_open
523- let titles, tags, operator = filter_args
524- and style =
525- flag "style"
526- (optional_with_default cfg.list_style list_style_arg)
527- ~doc:"list style [fixed | wide | simple]"
528- and columns =
529- flag "columns"
530- (optional_with_default cfg.column_list column_list_arg)
531- ~doc:"columns to include in output"
532- in
533- fun () ->
534- let notes =
535- Note.find_many
536- ~term:{ titles; tags; operator = flag_to_op operator }
537- get_notes
538- in
539- let styles = cfg.styles in
540- let cells = Note.to_cells ~columns ~styles notes in
541- Display.to_stdout ~style cells]
542-
543- let sync =
544- Command.basic ~summary:"sync notes to a remote server"
545- (Command.Param.return (fun () -> Sync.sync cfg.on_sync))
546-
547- let tree =
548- Command.basic ~summary:"tree debug command"
549- (Command.Param.return (fun () ->
550- let notes = get_notes in
551- print_endline (sprintf "%d" (List.length notes));
552- Note.dump_tree notes))
553-
554- let version =
555- match Build_info.V1.version () with
556- | None -> "n/a"
557- | Some v -> Build_info.V1.Version.to_string v
558-
559- let run =
560- Command.run ~version ~build_info:""
561- (Command.group ~summary:"Note is a simple CLI based note taking application"
562- [
563- ("cat", cat_note);
564- ("create", create_note);
565- ( "config",
566- Command.group ~summary:"config management"
567- [ ("show", config_show); ("get", config_get); ("set", config_set) ]
568- );
569- ("delete", delete_note);
570- ("edit", edit_note);
571- ("ls", list_notes);
572- ("sync", sync);
573- ("tree", tree);
574- ])
575 diff --git a/lib/display.ml b/lib/display.ml
576index 4036af7..99d4821 100644
577--- a/lib/display.ml
578+++ b/lib/display.ml
579 @@ -5,66 +5,106 @@ type cell = string * int * int
580
581 type row = cell list
582
583- let fixed_spacing cells =
584- (* find the maximum cell length per column *)
585- let maximum_values =
586- List.fold ~init:[]
587- ~f:(fun accm row ->
588- List.mapi
589- ~f:(fun i col ->
590- let col_length = snd3 col in
591- let current_max =
592- match List.nth accm i with Some len -> len | None -> 0
593- in
594- if col_length > current_max then col_length + 2 else current_max)
595- row)
596- cells
597- in
598- maximum_values
599+ module Tree = struct
600+ type t = Tree of (string * t list)
601
602- let fix_right cells =
603- let widths = fixed_spacing cells in
604- let term_width, _ = size () in
605- let _, right = List.split_n widths 1 in
606- let col_one = List.nth_exn widths 0 in
607- [ col_one + (term_width - List.fold ~init:5 ~f:( + ) widths) ] @ right
608+ let fill ~last ~state =
609+ let get_padding = function true -> "│ " | false -> " " in
610+ let get_edge = function true -> "└──" | false -> "├──" in
611+ match List.length state with
612+ | 0 -> []
613+ | 1 -> [ last |> get_edge ]
614+ | len ->
615+ let state = List.slice state 0 (len - 1) in
616+ let padding = List.map ~f:get_padding state in
617+ List.append padding [ last |> get_edge ]
618
619- let apply cells widths =
620- (* let maximums = fixed_spacing cells in *)
621- let cells =
622- List.map
623- ~f:(fun row ->
624- List.mapi
625- ~f:(fun i entry ->
626- let max = List.nth_exn widths i in
627- let text, length, padding = entry in
628- let padding = padding + (max - length) in
629- let padding = if padding > 0 then padding else 0 in
630- (text, length, padding))
631- row)
632- cells
633- in
634- List.fold ~init:[]
635- ~f:(fun accm row ->
636- accm
637- @ [
638- List.fold ~init:""
639- ~f:(fun accm cell ->
640- let text, _, padding = cell in
641- String.concat [ accm; text; String.make padding ' ' ])
642- row;
643- ])
644- cells
645+ let rec to_lines ?(state = []) ?(last = false) next =
646+ let (Tree next) = next in
647+ let title, children = next in
648+ match List.length children with
649+ | 0 ->
650+ (* leaf *)
651+ List.append (fill ~last ~state) [ title; "\n" ]
652+ | n_children ->
653+ (* node *)
654+ List.foldi
655+ ~init:
656+ [ List.append (fill ~last ~state) [ title; "\n" ] |> String.concat ]
657+ ~f:(fun i accm node ->
658+ let is_last = Int.equal i (n_children - 1) in
659+ let state = List.append state [ phys_equal is_last false ] in
660+ let lines = to_lines ~state ~last:is_last node in
661+ List.append accm lines)
662+ children
663+
664+ let to_string t =
665+ let result = t |> to_lines |> String.concat in
666+ "\n" ^ result
667+ end
668+
669+ module Cell = struct
670+ let fixed_spacing cells =
671+ (* find the maximum cell length per column *)
672+ let maximum_values =
673+ List.fold ~init:[]
674+ ~f:(fun accm row ->
675+ List.mapi
676+ ~f:(fun i col ->
677+ let col_length = snd3 col in
678+ let current_max =
679+ match List.nth accm i with Some len -> len | None -> 0
680+ in
681+ if col_length > current_max then col_length + 2 else current_max)
682+ row)
683+ cells
684+ in
685+ maximum_values
686+
687+ let fix_right cells =
688+ let widths = fixed_spacing cells in
689+ let term_width, _ = size () in
690+ let _, right = List.split_n widths 1 in
691+ let col_one = List.nth_exn widths 0 in
692+ [ col_one + (term_width - List.fold ~init:5 ~f:( + ) widths) ] @ right
693
694- let to_stdout ~style cells =
695- match style with
696- | `Simple ->
697- List.iter
698- ~f:(fun cell ->
699- print_endline
700- (let value = List.nth_exn cell 0 in
701- let text = fst3 value in
702- text))
703+ let apply cells widths =
704+ (* let maximums = fixed_spacing cells in *)
705+ let cells =
706+ List.map
707+ ~f:(fun row ->
708+ List.mapi
709+ ~f:(fun i entry ->
710+ let max = List.nth_exn widths i in
711+ let text, length, padding = entry in
712+ let padding = padding + (max - length) in
713+ let padding = if padding > 0 then padding else 0 in
714+ (text, length, padding))
715+ row)
716 cells
717- | `Fixed -> List.iter ~f:print_endline (apply cells (fixed_spacing cells))
718- | `Wide -> List.iter ~f:print_endline (apply cells (fix_right cells))
719+ in
720+ List.fold ~init:[]
721+ ~f:(fun accm row ->
722+ accm
723+ @ [
724+ List.fold ~init:""
725+ ~f:(fun accm cell ->
726+ let text, _, padding = cell in
727+ String.concat [ accm; text; String.make padding ' ' ])
728+ row;
729+ ])
730+ cells
731+
732+ let to_stdout ~style cells =
733+ match style with
734+ | `Simple ->
735+ List.iter
736+ ~f:(fun cell ->
737+ print_endline
738+ (let value = List.nth_exn cell 0 in
739+ let text = fst3 value in
740+ text))
741+ cells
742+ | `Fixed -> List.iter ~f:print_endline (apply cells (fixed_spacing cells))
743+ | `Wide -> List.iter ~f:print_endline (apply cells (fix_right cells))
744+ end
745 diff --git a/lib/io.ml b/lib/io.ml
746index 13d07e6..f950e67 100644
747--- a/lib/io.ml
748+++ b/lib/io.ml
749 @@ -4,7 +4,7 @@ let create ~callback ~content dest =
750 Out_channel.write_all ~data:content dest;
751 match callback with Some cmd -> Sys.command_exn cmd | None -> ()
752
753- let create_on_change ~callback ~editor content dest =
754+ let create_on_change ~callback ~editor ~content dest =
755 let tmp_file = Filename.temp_file "note" ".md" in
756 Out_channel.write_all ~data:content tmp_file;
757 let command = sprintf "%s %s" editor tmp_file in
758 @@ -37,3 +37,7 @@ let delete ~callback ~title path =
759 else
760 print_endline "No changes made"
761 | None -> ()
762+
763+
764+ let read path =
765+ In_channel.read_all path
766 diff --git a/lib/note.ml b/lib/note.ml
767index f90f9cc..912e77c 100644
768--- a/lib/note.ml
769+++ b/lib/note.ml
770 @@ -1,71 +1,114 @@
771 open Core
772
773- type operator = And | Or
774+ module Operator = struct
775+ type t = And | Or
776
777- and term = {
778- titles : Re.Str.regexp list;
779- tags : Re.Str.regexp list;
780- operator : operator;
781- }
782+ let of_string = function
783+ | "Or" -> Or
784+ | "And" -> And
785+ | _ -> failwith "invalid operator"
786
787- and note = {
788- frontmatter : Ezjsonm.t;
789- content : string;
790- markdown : Omd.doc;
791- slug : Slug.t;
792- parent : term option;
793- }
794+ let to_string = function Or -> "Or" | And -> "And"
795+ end
796
797- let operator_of_string = function
798- | "Or" -> Or
799- | "And" -> And
800- | _ -> failwith "invalid operator"
801+ module Term = struct
802+ (* TODO: almost identical to frontmatter structure *)
803+ type t = {
804+ title : string list;
805+ description : string list;
806+ tags : string list;
807+ }
808
809- let term_of_json json =
810- let titles =
811- match Ezjsonm.find_opt json [ "titles" ] with
812- | Some titles -> List.map ~f:Re.Str.regexp (Ezjsonm.get_strings titles)
813- | None -> []
814- and tags =
815- match Ezjsonm.find_opt json [ "tags" ] with
816- | Some tags -> List.map ~f:Re.Str.regexp (Ezjsonm.get_strings tags)
817- | None -> []
818- and operator =
819- match Ezjsonm.find_opt json [ "operator" ] with
820- | Some operator -> operator_of_string (Ezjsonm.get_string operator)
821- | None -> Or
822- in
823- { titles; tags; operator }
824+ let empty = { title = []; description = []; tags = [] }
825+
826+ let of_json json =
827+ let title =
828+ match Ezjsonm.find_opt json [ "title" ] with
829+ | Some title -> Ezjsonm.get_strings title
830+ | None -> []
831+ in
832+ let description =
833+ match Ezjsonm.find_opt json [ "description" ] with
834+ | Some description -> Ezjsonm.get_strings description
835+ | None -> []
836+ in
837+ let tags =
838+ match Ezjsonm.find_opt json [ "tags" ] with
839+ | Some tags -> Ezjsonm.get_strings tags
840+ | None -> []
841+ in
842+ { title; description; tags }
843
844- let build ?(description = "") ?(tags = []) ?(content = "") ~title slug =
845- let frontmatter =
846+ let to_json term =
847 Ezjsonm.dict
848 [
849- ("title", Ezjsonm.string title);
850- ("description", Ezjsonm.string description);
851- ("tags", Ezjsonm.strings tags);
852+ ("title", Ezjsonm.strings term.title);
853+ ("description", Ezjsonm.strings term.description);
854+ ("tags", Ezjsonm.strings term.tags);
855 ]
856- in
857- let markdown = Omd.of_string content in
858- { frontmatter; content; markdown; slug; parent = None }
859+ end
860+
861+ module Frontmatter = struct
862+ type t = {
863+ title : string;
864+ description : string;
865+ tags : string list;
866+ parent : Term.t option;
867+ }
868+
869+ let empty = { title = ""; description = ""; tags = []; parent = None }
870+
871+ let of_json json =
872+ let title =
873+ match Ezjsonm.find_opt json [ "title" ] with
874+ | Some title -> Ezjsonm.get_string title
875+ | None -> ""
876+ in
877+ let description =
878+ match Ezjsonm.find_opt json [ "description" ] with
879+ | Some description -> Ezjsonm.get_string description
880+ | None -> ""
881+ in
882+ let tags =
883+ match Ezjsonm.find_opt json [ "tags" ] with
884+ | Some tags -> Ezjsonm.get_strings tags
885+ | None -> []
886+ in
887+ let parent =
888+ match Ezjsonm.find_opt json [ "parent" ] with
889+ | Some parent -> Some (Term.of_json parent)
890+ | None -> None
891+ in
892+ { title; description; tags; parent }
893+
894+ let to_json frontmatter =
895+ Ezjsonm.dict
896+ [
897+ ("title", Ezjsonm.string frontmatter.title);
898+ ("description", Ezjsonm.string frontmatter.description);
899+ ("tags", Ezjsonm.strings frontmatter.tags);
900+ ]
901+ end
902
903- let get_title t =
904- (* if title is specified use that, otherwise fall back to slug *)
905- match Ezjsonm.find_opt (Ezjsonm.value t.frontmatter) [ "title" ] with
906- | Some title -> Ezjsonm.get_string title
907- | None -> Slug.to_string t.slug
908+ type note = {
909+ frontmatter : Frontmatter.t;
910+ content : string;
911+ slug : Slug.t option;
912+ }
913
914- let get_description t =
915- match Ezjsonm.find_opt (Ezjsonm.value t.frontmatter) [ "description" ] with
916- | Some description -> Ezjsonm.get_string description
917- | None -> ""
918+ and tree = Tree of (note * tree list)
919
920- let get_tags t =
921- match Ezjsonm.find_opt (Ezjsonm.value t.frontmatter) [ "tags" ] with
922- | Some tags -> Ezjsonm.get_strings tags
923- | None -> []
924+ let root_template =
925+ {|
926+ ---
927+ title: root
928+ description: all of my notes decend from here
929+ tags: []
930+ ---
931
932- let get_path t = Slug.get_path t.slug
933+ # This is a Note!
934+
935+ |}
936
937 let rec extract_structured_data (accm : Ezjsonm.value list) (doc : Omd.doc) :
938 Ezjsonm.value list =
939 @@ -84,56 +127,149 @@ let rec extract_structured_data (accm : Ezjsonm.value list) (doc : Omd.doc) :
940 | _ -> extract_structured_data accm tl)
941 | _ -> extract_structured_data accm tl)
942
943- let get_data t =
944- let data = extract_structured_data [] t.markdown in
945- Ezjsonm.list (fun value -> value) data
946-
947- let to_json t =
948+ let to_json note =
949+ let data =
950+ note.content |> Omd.of_string |> extract_structured_data []
951+ |> Ezjsonm.list (fun value -> value)
952+ in
953 Ezjsonm.dict
954 [
955- ("frontmatter", Ezjsonm.value t.frontmatter);
956- ("content", Ezjsonm.string t.content);
957- ("data", get_data t);
958+ ("frontmatter", Frontmatter.to_json note.frontmatter);
959+ ("content", Ezjsonm.string note.content);
960+ ("data", data);
961 ]
962
963- let to_string t =
964- let yaml = Yaml.to_string_exn (Ezjsonm.value t.frontmatter) in
965- "\n---\n" ^ yaml ^ "\n---\n" ^ t.content
966+ let to_string note =
967+ let yaml = Yaml.to_string_exn (Frontmatter.to_json note.frontmatter) in
968+ "\n---\n" ^ yaml ^ "\n---\n" ^ note.content
969
970- let of_string ~content slug =
971+ let of_string ?(slug = None) content =
972 let indexes =
973 String.substr_index_all ~may_overlap:true ~pattern:"---" content
974 in
975 if List.length indexes >= 2 then
976+ (* parsing the top half of the note *)
977 let meta_str =
978 String.slice content (List.nth_exn indexes 0 + 3) (List.nth_exn indexes 1)
979 in
980- let frontmatter : Ezjsonm.t =
981- match Yaml.of_string_exn meta_str with
982- | `O v -> `O v
983- | `A v -> `A v
984- | _ ->
985- failwith
986- "frontmatter is a partial fragment, should be either a dictionary \
987- or list"
988- in
989- let parent = Ezjsonm.find_opt (Ezjsonm.value frontmatter) [ "parent" ] in
990- let parent =
991- match parent with Some json -> Some (term_of_json json) | None -> None
992- in
993- let markdown : Omd.doc =
994- Omd.of_string
995- (String.slice content
996- (List.nth_exn indexes 1 + 3)
997- (String.length content))
998- in
999- { frontmatter; content; markdown; slug; parent }
1000+ let frontmatter = meta_str |> Yaml.of_string_exn |> Frontmatter.of_json in
1001+ { frontmatter; content; slug }
1002+ else { frontmatter = Frontmatter.empty; content; slug }
1003+
1004+ let rec flatten ~accm tree =
1005+ let (Tree (note, others)) = tree in
1006+ List.fold ~init:(note :: accm) ~f:(fun accm note -> flatten ~accm note) others
1007+
1008+ let match_term ?(operator = Operator.Or) ~(term : Term.t) note =
1009+ let open Re.Str in
1010+ let titles =
1011+ List.map
1012+ ~f:(fun exp ->
1013+ let note_title = note.frontmatter.title in
1014+ string_match exp note_title 0)
1015+ (List.map ~f:regexp term.title)
1016+ in
1017+ let tags =
1018+ List.map
1019+ ~f:(fun expr ->
1020+ Option.is_some
1021+ (List.find
1022+ ~f:(fun tag -> string_match expr tag 0)
1023+ note.frontmatter.tags))
1024+ (List.map ~f:regexp term.tags)
1025+ in
1026+ let results = List.concat [ titles; tags ] in
1027+ (* if there are no conditions consider it matched *)
1028+ if List.length results = 0 then true
1029 else
1030- let frontmatter = Ezjsonm.dict [] in
1031- let markdown = Omd.of_string content in
1032- { frontmatter; content; markdown; slug; parent = None }
1033+ match operator with
1034+ | And ->
1035+ List.length (List.filter ~f:(fun v -> v) results) = List.length results
1036+ | Or -> List.length (List.filter ~f:(fun v -> v) results) > 0
1037+
1038+ let rec find_many ?(operator = Operator.Or) ~(term : Term.t) ~notes tree =
1039+ let (Tree (note, others)) = tree in
1040+ let notes =
1041+ if match_term ~operator ~term note then note :: notes else notes
1042+ in
1043+ List.fold ~init:notes
1044+ ~f:(fun accm note -> find_many ~operator ~term ~notes:accm note)
1045+ others
1046+
1047+ let find_one ?(operator = Operator.Or) ~(term : Term.t) tree =
1048+ tree |> find_many ~operator ~term ~notes:[] |> List.hd
1049+
1050+ let find_one_exn ?(operator = Operator.Or) ~(term : Term.t) tree =
1051+ tree |> find_many ~operator ~term ~notes:[] |> List.hd_exn
1052+
1053+ let rec length tree =
1054+ let (Tree (_, others)) = tree in
1055+ List.fold ~init:(List.length others)
1056+ ~f:(fun accm tree -> accm + length tree)
1057+ others
1058+
1059+ let rec insert ?(operator = Operator.Or) ?(term = None) ~tree other =
1060+ let (Tree (note, others)) = tree in
1061+ match term with
1062+ | Some term ->
1063+ if match_term ~operator ~term note then
1064+ (Tree (note, other :: others), true)
1065+ else
1066+ let others =
1067+ List.map
1068+ ~f:(fun tree -> insert ~operator ~term:(Some term) ~tree other)
1069+ others
1070+ in
1071+ let result =
1072+ List.fold ~init:([], false)
1073+ ~f:(fun accm result ->
1074+ let others, updated = accm in
1075+ if updated then (fst result :: others, true)
1076+ else (fst result :: others, snd result))
1077+ others
1078+ in
1079+ let others, updated = result in
1080+ (Tree (note, others), updated)
1081+ | None -> (Tree (note, other :: others), true)
1082+
1083+ let buf_insert ~root notes =
1084+ let tree =
1085+ List.fold ~init:(root, [])
1086+ ~f:(fun accm note ->
1087+ let tree, buf = accm in
1088+ let term = note.frontmatter.parent in
1089+ let tree, inserted = insert ~term ~tree (Tree (note, [])) in
1090+ let buf = if inserted then buf else note :: buf in
1091+ (tree, buf))
1092+ notes
1093+ in
1094+ tree
1095+
1096+ let rec resolve ~root notes =
1097+ let tree, buf = buf_insert ~root notes in
1098+ match buf |> List.length with 0 -> tree | _ -> resolve ~root:tree buf
1099+
1100+ let load path =
1101+ let notes =
1102+ path |> Slug.load
1103+ |> List.map ~f:(fun slug ->
1104+ slug.path |> In_channel.read_all |> of_string ~slug:(Some slug))
1105+ in
1106+ (* check if a "root" note is defined *)
1107+ match
1108+ List.find
1109+ ~f:(fun note ->
1110+ note
1111+ |> match_term
1112+ ~term:{ title = [ "__root" ]; description = []; tags = [] })
1113+ notes
1114+ with
1115+ | Some root -> notes |> resolve ~root:(Tree (root, []))
1116+ | None -> notes |> resolve ~root:(Tree (of_string root_template, []))
1117
1118 module Util = struct
1119+ open ANSITerminal
1120+
1121 let split_words str =
1122 List.filter_map
1123 ~f:(fun x ->
1124 @@ -155,146 +291,76 @@ module Util = struct
1125 let accm = accm @ split_words inline.il_desc in
1126 to_words accm tl
1127 | _ -> to_words accm tl)
1128+
1129+ let paint_tag (styles : Config.StylePair.t list) text : string =
1130+ match
1131+ List.find ~f:(fun entry -> String.equal entry.pattern text) styles
1132+ with
1133+ | Some entry -> sprintf entry.styles "%s" text
1134+ | None -> sprintf [ Foreground Default ] "%s" text
1135+
1136+ let to_cells ~columns ~styles (notes : note list) =
1137+ let header =
1138+ List.map
1139+ ~f:(fun column ->
1140+ let text_value = Config.Column.to_string column in
1141+ let text_length = String.length text_value in
1142+ let text_value = sprintf [ Bold; Underlined ] "%s" text_value in
1143+ (text_value, text_length, 1))
1144+ columns
1145+ in
1146+ let note_cells =
1147+ let default_padding = 1 in
1148+ List.fold ~init:[]
1149+ ~f:(fun accm note ->
1150+ accm
1151+ @ [
1152+ List.map
1153+ ~f:(fun column ->
1154+ match column with
1155+ | `Title ->
1156+ let text_value = note.frontmatter.title in
1157+ (text_value, String.length text_value, default_padding)
1158+ | `Description ->
1159+ let text_value = note.frontmatter.description in
1160+ (text_value, String.length text_value, default_padding)
1161+ | `Slug ->
1162+ let text_value =
1163+ match note.slug with
1164+ | Some slug -> slug |> Slug.shortname
1165+ | None -> "??"
1166+ in
1167+ (text_value, String.length text_value, default_padding)
1168+ | `Tags ->
1169+ let text_value =
1170+ String.concat ~sep:"|" note.frontmatter.tags
1171+ in
1172+ let text_length = String.length text_value in
1173+ let tags = note.frontmatter.tags in
1174+ let tags =
1175+ List.map ~f:(fun tag -> paint_tag styles tag) tags
1176+ in
1177+ let text_value = String.concat ~sep:"|" tags in
1178+ (text_value, text_length, default_padding)
1179+ | `WordCount ->
1180+ let text_value =
1181+ Core.sprintf "%d"
1182+ (List.length
1183+ (to_words [] (note.content |> Omd.of_string)))
1184+ in
1185+ (text_value, String.length text_value, default_padding))
1186+ columns;
1187+ ])
1188+ notes
1189+ in
1190+ [ header ] @ note_cells
1191 end
1192
1193 module Encoding = struct
1194 let to_string ~style t =
1195 match style with
1196- | `Raw -> In_channel.read_all (get_path t)
1197+ | `Raw -> t.content
1198 | `Json -> Ezjsonm.to_string (to_json t)
1199 | `Yaml -> Yaml.to_string_exn (to_json t)
1200- | `Html -> Omd.to_html t.markdown
1201+ | `Html -> t.content |> Omd.of_string |> Omd.to_html
1202 end
1203-
1204- let find_many ~term notes =
1205- let open Re.Str in
1206- let n_titles, n_tags = (List.length term.titles, List.length term.tags) in
1207- if n_titles + n_tags = 0 then notes
1208- else
1209- List.filter
1210- ~f:(fun note ->
1211- let has_title =
1212- let result =
1213- List.count
1214- ~f:(fun expr -> string_match expr (get_title note) 0)
1215- term.titles
1216- in
1217- match term.operator with Or -> result > 0 | And -> result = n_titles
1218- and has_tags =
1219- let result =
1220- List.count
1221- ~f:(fun expr ->
1222- Option.is_some
1223- (List.find
1224- ~f:(fun tag -> string_match expr tag 0)
1225- (get_tags note)))
1226- term.tags
1227- in
1228- match term.operator with Or -> result > 0 | And -> result = n_tags
1229- in
1230- match term.operator with
1231- | Or -> has_title || has_tags
1232- | And -> has_title && has_tags)
1233- notes
1234-
1235- let find_one ~term notes =
1236- let results = find_many ~term notes in
1237- match List.length results with 0 -> None | _ -> Some (List.hd_exn results)
1238-
1239- let find_one_exn ~term notes =
1240- let result = find_one ~term notes in
1241- match result with
1242- | Some result -> result
1243- | None -> failwith "not found"
1244-
1245- (* TODO terrible performance but who cares? *)
1246- let resolve tree notes =
1247- List.fold ~init:tree
1248- ~f:(fun accm note ->
1249- let slug_id = Slug.to_string note.slug in
1250- match note.parent with
1251- | Some term -> (
1252- match find_one ~term notes with
1253- | Some parent -> (
1254- let parent_slug_id = Slug.to_string parent.slug in
1255- let children =
1256- List.Assoc.find ~equal:String.equal accm parent_slug_id
1257- in
1258- match children with
1259- | Some children ->
1260- List.Assoc.add ~equal:String.equal accm parent_slug_id
1261- (List.append children [ slug_id ])
1262- | None ->
1263- List.Assoc.add ~equal:String.equal accm parent_slug_id
1264- [ slug_id ])
1265- | None -> failwith "cannot resolve parent")
1266- | None ->
1267- if List.Assoc.mem ~equal:String.equal accm slug_id then accm
1268- else List.Assoc.add ~equal:String.equal accm slug_id [])
1269- notes
1270-
1271- type node = Node of (note option * note list)
1272-
1273- let dump_tree notes =
1274- let tree = resolve [] notes in
1275- let tree = resolve tree notes in
1276- List.iter
1277- ~f:(fun (key, values) ->
1278- print_endline (key ^ sprintf " -> %d" (List.length values)))
1279- tree
1280-
1281- open ANSITerminal
1282-
1283- let paint_tag (styles : Config.StylePair.t list) text : string =
1284- match List.find ~f:(fun entry -> String.equal entry.pattern text) styles with
1285- | Some entry -> sprintf entry.styles "%s" text
1286- | None -> sprintf [ Foreground Default ] "%s" text
1287-
1288- let to_cells ~columns ~styles notes =
1289- let header =
1290- List.map
1291- ~f:(fun column ->
1292- let text_value = Config.Column.to_string column in
1293- let text_length = String.length text_value in
1294- let text_value = sprintf [ Bold; Underlined ] "%s" text_value in
1295- (text_value, text_length, 1))
1296- columns
1297- in
1298- let note_cells =
1299- let default_padding = 1 in
1300- List.fold ~init:[]
1301- ~f:(fun accm note ->
1302- accm
1303- @ [
1304- List.map
1305- ~f:(fun column ->
1306- match column with
1307- | `Title ->
1308- let text_value = get_title note in
1309- (text_value, String.length text_value, default_padding)
1310- | `Description ->
1311- let text_value = get_description note in
1312- (text_value, String.length text_value, default_padding)
1313- | `Tags ->
1314- let text_value = String.concat ~sep:"|" (get_tags note) in
1315- let text_length = String.length text_value in
1316- let tags = get_tags note in
1317- let tags =
1318- List.map ~f:(fun tag -> paint_tag styles tag) tags
1319- in
1320- let text_value = String.concat ~sep:"|" tags in
1321- (text_value, text_length, default_padding)
1322- | `WordCount ->
1323- let text_value =
1324- Core.sprintf "%d"
1325- (List.length (Util.to_words [] note.markdown))
1326- in
1327- (text_value, String.length text_value, default_padding)
1328- | `Slug ->
1329- let text_value = Slug.to_string note.slug in
1330- (text_value, String.length text_value, default_padding))
1331- columns;
1332- ])
1333- notes
1334- in
1335- [ header ] @ note_cells
1336 diff --git a/lib/slug.ml b/lib/slug.ml
1337index b01a9b0..9a2bd33 100644
1338--- a/lib/slug.ml
1339+++ b/lib/slug.ml
1340 @@ -2,33 +2,26 @@ open Core
1341
1342 type t = { path : string; date : Date.t; index : int }
1343
1344- let compare s1 s2 =
1345- String.compare s1.path s2.path
1346-
1347- let sexp_of_t t : Sexp.t =
1348- List [ Atom t.path ]
1349+ let shortname t =
1350+ let date_str = Date.format t.date "%Y%m%d" in
1351+ sprintf "note-%s-%d" date_str t.index
1352
1353- let get_path t = t.path
1354+ let compare s1 s2 = String.compare s1.path s2.path
1355
1356- let to_string t =
1357- let date_str = Date.format t.date "%Y%m%d" in
1358- sprintf "%s-%d" date_str t.index
1359+ let is_note path =
1360+ Filename.basename path |> String.is_substring ~substring:"note-"
1361
1362 let of_path path =
1363- (* note-20010103-0.md *)
1364- if is_some (String.substr_index ~pattern:"note-" path) then
1365- let slug = Filename.chop_extension (Filename.basename path) in
1366- let split = String.split ~on:'-' slug in
1367- (* TODO: add proper error handling *)
1368- let date = Date.parse ~fmt:"%Y%m%d" (List.nth_exn split 1) in
1369- let index = int_of_string (List.nth_exn split 2) in
1370- Some { path; date; index }
1371- else None
1372+ let slug = Filename.chop_extension (Filename.basename path) in
1373+ let split = String.split ~on:'-' slug in
1374+ let date = Date.parse ~fmt:"%Y%m%d" (List.nth_exn split 1) in
1375+ let index = int_of_string (List.nth_exn split 2) in
1376+ { path; date; index }
1377
1378 let load state_dir =
1379- List.filter_map
1380- ~f:(fun path -> of_path (Filename.concat state_dir path))
1381- (Sys.ls_dir state_dir)
1382+ state_dir |> Sys.ls_dir |> List.filter ~f:is_note
1383+ |> List.map ~f:(Filename.concat state_dir)
1384+ |> List.map ~f:of_path
1385
1386 let next state_dir =
1387 let slugs = load state_dir in
1388 diff --git a/test/display_test.ml b/test/display_test.ml
1389new file mode 100644
1390index 0000000..667a66b
1391--- /dev/null
1392+++ b/test/display_test.ml
1393 @@ -0,0 +1,20 @@
1394+ open Note_lib
1395+
1396+ let test_tree () =
1397+ let open Display.Tree in
1398+ let expected = {|
1399+ A
1400+ ├──B
1401+ │ └──C
1402+ └──D
1403+ |} in
1404+ let result =
1405+ Tree ("A", [ Tree ("B", [ Tree ("C", []) ]); Tree ("D", []) ])
1406+ |> to_lines |> String.concat ""
1407+ in
1408+ let result = "\n" ^ result in
1409+ Alcotest.(check string) "tree" expected result
1410+
1411+ let () =
1412+ Alcotest.run "Display"
1413+ [ ("tree", [ Alcotest.test_case "display a tree" `Quick test_tree ]) ]
1414 diff --git a/test/dune b/test/dune
1415index 5ed362e..ee6d694 100644
1416--- a/test/dune
1417+++ b/test/dune
1418 @@ -1,4 +1,4 @@
1419 (tests
1420- (names e2e search)
1421- (libraries note_lib)
1422+ (names display_test note_test slug_test)
1423+ (libraries note_lib alcotest)
1424 )
1425 diff --git a/test/note-0.md b/test/note-0.md
1426deleted file mode 100644
1427index 1aae215..0000000
1428--- a/test/note-0.md
1429+++ /dev/null
1430 @@ -1,16 +0,0 @@
1431- # This is a Note
1432-
1433- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi lobortis euismod eros sit amet venenatis. Nulla vitae blandit lorem. Fusce sit amet ipsum massa. Morbi laoreet neque diam, in auctor lectus tempor ac. Cras ante lectus, sagittis et volutpat at, eleifend sit amet arcu. Aliquam vestibulum libero et lacus faucibus, at porttitor sapien imperdiet. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur eget purus varius, facilisis mi quis, placerat dui. Nullam quis gravida ante, sit amet commodo mi. Duis id semper nulla, a porttitor sapien. Vestibulum elementum efficitur dictum. Ut rhoncus lorem diam, ac congue enim luctus sed.
1434-
1435- ## Hello
1436-
1437- ```bash
1438- fuu bar baz qux
1439- ```
1440-
1441- ### World
1442-
1443- * Item One
1444- * Item Two
1445- * Item Three
1446- * Item Four
1447 diff --git a/test/note-1.md b/test/note-1.md
1448deleted file mode 100644
1449index 2814b45..0000000
1450--- a/test/note-1.md
1451+++ /dev/null
1452 @@ -1,35 +0,0 @@
1453- ---
1454- title: "This Note Has Frontmatter"
1455- tags: [fuu, bar, baz, qux]
1456- and_some_random: [{arbitrary: {data: [3.145, 1.00000000]}}]
1457- ---
1458- # This is a Note
1459-
1460- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi lobortis euismod eros sit amet venenatis. Nulla vitae blandit lorem. Fusce sit amet ipsum massa. Morbi laoreet neque diam, in auctor lectus tempor ac. Cras ante lectus, sagittis et volutpat at, eleifend sit amet arcu. Aliquam vestibulum libero et lacus faucibus, at porttitor sapien imperdiet. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur eget purus varius, facilisis mi quis, placerat dui. Nullam quis gravida ante, sit amet commodo mi. Duis id semper nulla, a porttitor sapien. Vestibulum elementum efficitur dictum. Ut rhoncus lorem diam, ac congue enim luctus sed.
1461-
1462- ## Hello
1463-
1464- ```bash
1465- fuu bar baz qux
1466- ```
1467-
1468- ### World
1469-
1470- * Item One
1471- * Item Two
1472- * Item Three
1473- * Item Four
1474-
1475-
1476- ```json
1477- {
1478- "fuu": ["bar", "baz", "qux"]
1479- }
1480- ```
1481-
1482- ```yaml
1483- fuu:
1484- - bar
1485- - baz
1486- - qux
1487- ```
1488 diff --git a/test/note_test.ml b/test/note_test.ml
1489new file mode 100644
1490index 0000000..fc612ce
1491--- /dev/null
1492+++ b/test/note_test.ml
1493 @@ -0,0 +1,269 @@
1494+ open Core
1495+ open Note_lib
1496+
1497+ let dump_notes (notes : Note.note list) =
1498+ List.iter ~f:(fun note -> print_endline note.frontmatter.title) notes
1499+
1500+ let rec convert_tree tree =
1501+ let (Note.Tree (note, others)) = tree in
1502+ let title = note.frontmatter.title in
1503+ let title = "[" ^ title ^ "]" in
1504+ Display.Tree.Tree (title, List.map ~f:convert_tree others)
1505+
1506+ let make_a_note () =
1507+ let note =
1508+ Note.of_string
1509+ {|
1510+ ---
1511+ title: this is a note
1512+ description: although it doesn't contain anything of value
1513+ tags: ["it", "is", "still", "a", "note"]
1514+ ---
1515+
1516+ # What is the Purpose of Life?
1517+
1518+ ```json
1519+ {
1520+ "answer": "it isn't clear"
1521+ }
1522+ ```
1523+ |}
1524+ in
1525+
1526+ let title = note.frontmatter.title in
1527+ Alcotest.(check string) "title" "this is a note" title;
1528+ let description = note.frontmatter.description in
1529+ Alcotest.(check string)
1530+ "description" "although it doesn't contain anything of value" description;
1531+ let tags = note.frontmatter.tags in
1532+ Alcotest.(check (list string))
1533+ "tags"
1534+ [ "it"; "is"; "still"; "a"; "note" ]
1535+ tags;
1536+ let data =
1537+ Ezjsonm.find (note |> Note.to_json) [ "data" ]
1538+ |> Ezjsonm.get_list (fun x -> x)
1539+ |> List.hd_exn
1540+ in
1541+ let answer = Ezjsonm.find data [ "answer" ] |> Ezjsonm.get_string in
1542+ Alcotest.(check string) "answer" "it isn't clear" answer
1543+
1544+ let note_of_title title =
1545+ let template =
1546+ sprintf {|
1547+ ---
1548+ title: %s
1549+ ---
1550+
1551+ # Note %s
1552+
1553+ Hello World |} title title
1554+ in
1555+ Note.of_string template
1556+
1557+ (*
1558+ [root]
1559+ ├──[note-0]
1560+ ├──[note-1]
1561+ │  └──[note-2]
1562+ ├──[note-3]
1563+ ├──[note-4]
1564+ │  ├──[note-5]
1565+ │  │  └──[note-6]
1566+ │  └──[note-7]
1567+ └──[note-8]
1568+ *)
1569+
1570+ let tree =
1571+ Note.Tree
1572+ ( note_of_title "root",
1573+ [
1574+ Note.Tree (note_of_title "note-0", []);
1575+ Note.Tree
1576+ (note_of_title "note-1", [ Note.Tree (note_of_title "note-2", []) ]);
1577+ Note.Tree (note_of_title "note-3", []);
1578+ Note.Tree
1579+ ( note_of_title "note-4",
1580+ [
1581+ Note.Tree
1582+ ( note_of_title "note-5",
1583+ [ Note.Tree (note_of_title "note-6", []) ] );
1584+ Note.Tree (note_of_title "note-7", []);
1585+ ] );
1586+ Note.Tree (note_of_title "note-8", []);
1587+ ] )
1588+
1589+ let find_many () =
1590+ let results =
1591+ Note.find_many
1592+ ~term:{ title = [ "note-3"; "note-4" ]; description = []; tags = [] }
1593+ ~notes:[] tree
1594+ in
1595+ dump_notes results;
1596+ let n_results = results |> List.length in
1597+ Alcotest.(check int) "two results" 2 n_results;
1598+ let n3 = List.nth_exn results 1 in
1599+ Alcotest.(check string) "first result" "note-3" n3.frontmatter.title;
1600+ let n4 = List.nth_exn results 0 in
1601+ Alcotest.(check string) "first result" "note-4" n4.frontmatter.title
1602+
1603+ let insert_at () =
1604+ let n9 = note_of_title "note-9" in
1605+ let tree, inserted =
1606+ Note.insert
1607+ ~term:(Some { title = [ "note-3" ]; description = []; tags = [] })
1608+ ~tree
1609+ (Note.Tree (n9, []))
1610+ in
1611+ Alcotest.(check bool) "inserted" true inserted ;
1612+ let result =
1613+ Note.find_one
1614+ ~term:{ title = [ "note-9" ]; description = []; tags = [] }
1615+ tree
1616+ in
1617+ Alcotest.(check bool) "inserted" true (Option.is_some result)
1618+
1619+ let test_structure () =
1620+ let open Display.Tree in
1621+ let expected =
1622+ {|
1623+ [root]
1624+ ├──[note-0]
1625+ ├──[note-1]
1626+ │ └──[note-2]
1627+ ├──[note-3]
1628+ ├──[note-4]
1629+ │ ├──[note-5]
1630+ │ │ └──[note-6]
1631+ │ └──[note-7]
1632+ └──[note-8]
1633+ |}
1634+ in
1635+ Alcotest.(check int) "length" 9 (Note.length tree);
1636+ let note_tree = tree |> convert_tree |> to_string in
1637+ Alcotest.(check string) "structure" expected note_tree
1638+
1639+ (*
1640+ we are attempting to go from a flat list of things that may or may
1641+ not reference other items from within the list and construct a hiarchrial
1642+ tree.
1643+
1644+ Example:
1645+
1646+ [
1647+ note-1
1648+ note-2 [ref note-4]
1649+ note-3
1650+ note-4 [ref note-1]
1651+ note-5
1652+ ]
1653+
1654+ .
1655+ ├──[note-1]
1656+ │ │──[note-4]
1657+ │ └──[note-2]
1658+ │note-3
1659+ └note-5
1660+
1661+
1662+ def resolve(tree, notes):
1663+ buf = []
1664+ for note in notes:
1665+ if note.ref == None:
1666+ tree.add(note)
1667+ else:
1668+ inserted = tree.insert(note.ref)
1669+ if not inserted:
1670+ buf.append(note)
1671+ if buf.length > 0:
1672+ return resolve(tree, buf)
1673+ return tree
1674+
1675+ *)
1676+ let n0 = Note.of_string {|
1677+ ---
1678+ title: note-0
1679+ ---
1680+ # Note 0
1681+ |}
1682+
1683+ let n1 =
1684+ Note.of_string
1685+ {|
1686+ ---
1687+ title: note-1
1688+ parent: {"title": ["note-0"]}
1689+ ---
1690+ # Note 1
1691+ |}
1692+
1693+ let n2 = Note.of_string {|
1694+ ---
1695+ title: note-2
1696+ ---
1697+ # Note 2
1698+ |}
1699+
1700+ let n3 =
1701+ Note.of_string
1702+ {|
1703+ ---
1704+ title: note-3
1705+ parent: {"title": ["note-1"]}
1706+ ---
1707+ # Note 3
1708+ |}
1709+
1710+ let n4 =
1711+ Note.of_string {|
1712+ ---
1713+ title: note-4
1714+ parent: {"title": ["note-1"]}
1715+ ---
1716+ # Note 4
1717+ |}
1718+
1719+ let test_buf_insert () =
1720+ let root = Note.Tree (Note.of_string Note.root_template, []) in
1721+ let tree, buf = Note.buf_insert ~root [ n3; n2; n1; n0 ] in
1722+ Alcotest.(check int) "n" 2 (List.length buf);
1723+ let _, buf = Note.buf_insert ~root:tree buf in
1724+ Alcotest.(check int) "n" 0 (List.length buf)
1725+
1726+ let test_resolve () =
1727+ let root = Note.Tree (Note.of_string Note.root_template, []) in
1728+ let expected =
1729+ {|
1730+ [root]
1731+ ├──[note-2]
1732+ └──[note-0]
1733+ └──[note-1]
1734+ ├──[note-4]
1735+ └──[note-3]
1736+ |}
1737+ in
1738+ let tree_as_string =
1739+ [ n3; n2; n1; n0 ; n4] |> Note.resolve ~root |> convert_tree
1740+ |> Display.Tree.to_string
1741+ in
1742+ Alcotest.(check string) "resolve" expected tree_as_string
1743+
1744+ let () =
1745+ Alcotest.run "Note"
1746+ [
1747+ ( "create",
1748+ [ Alcotest.test_case "create a note from a string" `Quick make_a_note ]
1749+ );
1750+ ( "find-many",
1751+ [
1752+ Alcotest.test_case "find notes with multiple criteria" `Quick
1753+ find_many;
1754+ ] );
1755+ ( "insert",
1756+ [ Alcotest.test_case "insert a note into a tree" `Quick insert_at ] );
1757+ ( "structure",
1758+ [ Alcotest.test_case "note tree structure" `Quick test_structure ] );
1759+ ( "buf_insert",
1760+ [ Alcotest.test_case "buf insert flat list" `Quick test_buf_insert ] );
1761+ ("resolve", [ Alcotest.test_case "resolve flat list" `Quick test_resolve ]);
1762+ ]
1763 diff --git a/test/search.ml b/test/search.ml
1764deleted file mode 100644
1765index c98dfb4..0000000
1766--- a/test/search.ml
1767+++ /dev/null
1768 @@ -1,69 +0,0 @@
1769- open Note_lib
1770-
1771- let make_notes =
1772- let state_dir = Core.Filename.temp_dir "note-test" "" in
1773- let note_1 =
1774- Note.build ~tags:[ "fuu"; "bar" ] ~content:"" ~title:"A Very Important Note"
1775- (Slug.next state_dir)
1776- in
1777- let note_2 =
1778- Note.build ~tags:[ "fuu"; "bar"; "baz" ] ~content:""
1779- ~title:"Another Very Important Note" (Slug.next state_dir)
1780- in
1781- let note_3 =
1782- Note.build ~tags:[ "fuu"; "bar"; "baz" ] ~content:"" ~title:"fuu"
1783- (Slug.next state_dir)
1784- in
1785- [ note_1; note_2; note_3 ]
1786-
1787- let test_filter_by_keys =
1788- let notes = make_notes in
1789- let result =
1790- Note.find_many
1791- ~term:
1792- {
1793- titles = [ Re.Str.regexp "A Very Important Note" ];
1794- tags = [];
1795- operator = Note.Or;
1796- }
1797- notes
1798- in
1799- assert (List.length result = 1);
1800- let result =
1801- Note.find_many
1802- ~term:
1803- {
1804- titles = [ Re.Str.regexp "A Very Important Note" ];
1805- tags = [];
1806- operator = Note.Or;
1807- }
1808- notes
1809- in
1810- assert (List.length result = 1);
1811- let result =
1812- Note.find_many
1813- ~term:
1814- {
1815- titles = [];
1816- tags =
1817- [ Re.Str.regexp "fuu"; Re.Str.regexp "bar"; Re.Str.regexp "baz" ];
1818- operator = Note.And;
1819- }
1820- notes
1821- in
1822- assert (List.length result = 2)
1823-
1824- let test_filter_by_title_find_one =
1825- let notes = make_notes in
1826- let result =
1827- Note.find_one
1828- ~term:{ titles = [Re.Str.regexp "^A.*"]; tags = []; operator = Note.Or }
1829- notes
1830- in
1831- assert (Option.is_some result);
1832- let note = Option.get result in
1833- assert (Note.get_title note = "A Very Important Note")
1834-
1835- let () =
1836- test_filter_by_keys;
1837- test_filter_by_title_find_one
1838 diff --git a/test/slug_test.ml b/test/slug_test.ml
1839new file mode 100644
1840index 0000000..c02fa90
1841--- /dev/null
1842+++ b/test/slug_test.ml
1843 @@ -0,0 +1,46 @@
1844+ open Core
1845+ open Note_lib
1846+
1847+ let test_slug_shortname () =
1848+ let s1 = Slug.of_path "fuu/note-19700101-0.md" in
1849+ Alcotest.(check string) "short name" "note-19700101-0" (Slug.shortname s1)
1850+
1851+ let test_slug_comparable () =
1852+ let s1, s2 =
1853+ ( Slug.of_path "fuu/note-19700101-0.md",
1854+ Slug.of_path "fuu/note-19700101-0.md" )
1855+ in
1856+ Alcotest.(check bool) "identical paths" true (Slug.compare s1 s2 = 0)
1857+
1858+ let test_slug_path () =
1859+ let date_string = Time.format (Time.now ()) "%Y%m%d" ~zone:Time.Zone.utc in
1860+ let state_dir = Filename.temp_dir "note-test" "" in
1861+ let slug = Slug.next state_dir in
1862+ let expected =
1863+ Filename.concat state_dir (sprintf "note-%s-0.md" date_string)
1864+ in
1865+ Alcotest.(check string) "path" expected slug.path
1866+
1867+ let test_slug_increment () =
1868+ let state_dir = Filename.temp_dir "note-test" "" in
1869+ let date_string = Time.format (Time.now ()) "%Y%m%d" ~zone:Time.Zone.utc in
1870+ for i = 0 to 5 do
1871+ let filename =
1872+ Filename.concat state_dir (sprintf "note-%s-%d.md" date_string i)
1873+ in
1874+ Out_channel.write_all filename ~data:""
1875+ done;
1876+ let slug = Slug.next state_dir in
1877+ Alcotest.(check int) "index" 6 slug.index
1878+
1879+ let () =
1880+ Alcotest.run "Slug"
1881+ [
1882+ ( "slug-comparable",
1883+ [ Alcotest.test_case "compare" `Quick test_slug_comparable ] );
1884+ ( "slug-shortname",
1885+ [ Alcotest.test_case "shortname" `Quick test_slug_shortname ] );
1886+ ("path", [ Alcotest.test_case "path" `Quick test_slug_path ]);
1887+ ( "slug-increment",
1888+ [ Alcotest.test_case "increment" `Quick test_slug_increment ] );
1889+ ]