stitch

Note taking for messy minimalists

git clone git://mccd.space/stitch
commit 121a6a376209de3f5a9474bf03721e2032a73e01
Author: Marc Coquand <marc@mccd.space>
Date:   Mon, 13 May 2024 11:00:47 -0500

initial commit

Diffstat:
A.envrc | 3+++
A.gitignore | 2++
A.ocamlformat | 3+++
AREADME.md | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abin/.#main.ml | 2++
Abin/common.ml | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abin/dune | 12++++++++++++
Abin/main.ml | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adune-project | 24++++++++++++++++++++++++
Aflake.lock | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aflake.nix | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/dune | 7+++++++
Alib/stitch.ml | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/zipper.ml | 22++++++++++++++++++++++
Astitch.opam | 32++++++++++++++++++++++++++++++++
Atest/dune | 2++
Atest/test_apbox.ml | 0
17 files changed, 749 insertions(+), 0 deletions(-)
diff --git a/.envrc b/.envrc
@@ -0,0 +1,3 @@
+use flake .
+
+
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,2 @@
+_build/*
+.direnv
diff --git a/.ocamlformat b/.ocamlformat
@@ -0,0 +1,3 @@
+profile = janestreet
+parse-docstrings = true
+let-binding-spacing = sparse
diff --git a/README.md b/README.md
@@ -0,0 +1,76 @@
+Stitch - Note Composer
+
+Stitch is a minimal grep-based CLI tool for composing notes.
+It is built around the idea of writing notes separately and then 
+using tags to compose these notes together. 
+
+Stitch does not have any opinion about which file 
+format you use for file capturing, use org, markdown, whatever you
+want. You can customize the grep command.
+
+Stitch limits itself only to note composing. For capturing notes, you
+will have to set up your own system. 
+
+AIMS
+
+- Single binary
+- Minimal
+- Work with any file format
+
+CREDIT
+
+Stitch is based and largely a stripped down copy of the note composing
+system of Howm for Emacs.
+
+INSTALLATION
+
+Chmod +x the binary and put in PATH.
+
+Set the environment variables:
+
+STICH_DIRECTORY
+STICH_GREP_CMD (default "grep")
+STITCH_HEADLINE_PATTERN (default "^\* ")
+STITCH_TAG_PATTERN (default ":[a-z]:")
+
+SPEED UP
+
+Stitch uses grep by default. As your notes grow, this will 
+become rather slow. To speed it up, you can replace it grep with ugrep.
+
+DEVELOPMENT
+
+To set up the project, easiest way is to just install Nix, direnv and
+enable nix flakes. Then to compile:
+
+dune build
+
+And to run:
+
+dune exec -- stitch
+
+
+RECIPES
+
+Building a journaling system
+---
+
+You can build a basic capture command using $EDITOR and date command:
+
+alias capture="JRNL=\"$STITCH_DIRECTORY/$(date +'%Y-%m-%d %H:%M').org\" echo '* :journal:' > $JRNL_FILE && $EDITOR $JRNL_FILE 
+
+and then you can find your journal entries, automatically sorted by creation date with
+stitch and the journal tag:
+
+alias jrnl="stitch -t journal"
+
+LICENSE
+
+License is BSD-3-Clause
+
+KNOWN ISSUES
+
+- Resizing the screen when editor is open causes panic
+
+
+
diff --git a/bin/.#main.ml b/bin/.#main.ml
@@ -0,0 +1 @@
+mccd@void.21115:1715189760
+\ No newline at end of file
diff --git a/bin/common.ml b/bin/common.ml
@@ -0,0 +1,166 @@
+(* Copyright (c) 2016-2017 David Kaloper Meršinjak. All rights reserved.
+   See LICENSE.md. *)
+
+open Notty
+open Notty.Infix
+
+let pow n e = int_of_float (float n ** float e)
+
+module List = struct
+  include List
+
+  let rec replicate n a = if n < 1 then [] else a :: replicate (n - 1) a
+  let rec range a b = if a > b then [] else a :: range (a + 1) b
+
+  let rec intersperse a = function
+    | ([] | [ _ ]) as t -> t
+    | x :: xs -> x :: a :: intersperse a xs
+
+
+  let rec take n = function
+    | x :: xs when n > 0 -> x :: take (pred n) xs
+    | _ -> []
+
+
+  let rec splitat n = function
+    | x :: xs when n > 0 ->
+      let a, b = splitat (pred n) xs in
+      x :: a, b
+    | xs -> [], xs
+
+
+  let rec chunks n xs =
+    match splitat n xs with
+    | a, [] -> [ a ]
+    | a, b -> a :: chunks n b
+
+
+  let rec zip xs ys =
+    match xs, ys with
+    | [], _ | _, [] -> []
+    | x :: xs, y :: ys -> (x, y) :: zip xs ys
+end
+
+module String = struct
+  include String
+
+  let repeat n str =
+    let b = Buffer.create 16 in
+    for _ = 1 to n do
+      Buffer.add_string b str
+    done;
+    Buffer.contents b
+end
+
+let tile w h i = I.tabulate w h (fun _ _ -> i)
+
+(** A few images used in several places. *)
+module Images = struct
+  let i1 =
+    I.(string A.(fg lightblack) "omgbbq" <-> string A.(fg white ++ bg red) "@")
+    <|> I.(pad ~t:2 @@ string A.(fg green) "xo")
+
+
+  let i2 = I.(hpad 1 1 (hcrop 1 1 @@ tile 3 3 i1) <|> i1)
+  let i3 = tile 5 5 i2
+
+  let i4 =
+    let i = I.(i3 <|> crop ~t:1 i3 <|> i3) in
+    I.(crop ~l:1 i <-> crop ~r:1 i <-> crop ~b:2 i)
+
+
+  let i5 = tile 5 1 List.(range 0 15 |> map (fun i -> I.pad ~t:i ~l:(i * 2) i2) |> I.zcat)
+  let c_gray_ramp = I.tabulate 24 1 (fun g _ -> I.string A.(bg (gray g)) " ")
+
+  let c_cube_ix =
+    I.tabulate 6 1
+    @@ fun r _ ->
+    I.hpad 0 1 @@ I.tabulate 6 6 @@ fun b g -> I.string A.(bg (rgb ~r ~g ~b)) " "
+
+
+  let c_cube_rgb =
+    let f x = [| 0x00; 0x5f; 0x87; 0xaf; 0xd7; 0xff |].(x) in
+    I.tabulate 6 1
+    @@ fun r _ ->
+    I.hpad 0 1
+    @@ I.tabulate 6 6
+    @@ fun b g -> I.string A.(bg (rgb_888 ~r:(f r) ~g:(f g) ~b:(f b))) " "
+
+
+  let c_rainbow w h =
+    let pi2 = 2. *. 3.14159 in
+    let pi2_3 = pi2 /. 3.
+    and f t off = (sin (t +. off) *. 128.) +. 128. |> truncate in
+    let color t = A.rgb_888 ~r:(f t (-.pi2_3)) ~g:(f t 0.) ~b:(f t pi2_3) in
+    I.tabulate (w - 1) 1
+    @@ fun x _ ->
+    let t = (pi2 *. float x /. float w) +. 3.7 in
+    I.char A.(bg (color t)) ' ' 1 h
+
+
+  (* U+25CF BLACK CIRCLE *)
+  let dot color = I.string (A.fg color) "●"
+
+  (* U+25AA BLACK SMALL SQUARE *)
+  let square color = I.string (A.fg color) "▪"
+
+  let rec cantor = function
+    | 0 -> square A.lightblue
+    | n ->
+      let sub = cantor (pred n) in
+      I.hcat (List.replicate (pow 3 n) (square A.lightblue))
+      <-> (sub <|> I.void (pow 3 (n - 1)) 0 <|> sub)
+
+
+  let checker n m i =
+    let w = I.width i in
+    I.(tile (n / 2) (m / 2) (hpad 0 w i <-> hpad w 0 i))
+
+
+  let checker1 = checker 20 20 I.(char A.(bg magenta) ' ' 2 1)
+
+  let rec sierp c n =
+    I.(
+      if n > 1
+      then (
+        let ss = sierp c (pred n) in
+        ss <-> (ss <|> ss))
+      else hpad 1 0 (square c))
+
+
+  let grid xxs = xxs |> List.map I.hcat |> I.vcat
+
+  let outline attr i =
+    let w, h = I.(width i, height i) in
+    let chr x = I.uchar attr (Uchar.of_int x) 1 1
+    and hbar = I.uchar attr (Uchar.of_int 0x2500) w 1
+    and vbar = I.uchar attr (Uchar.of_int 0x2502) 1 h in
+    let a, b, c, d = chr 0x256d, chr 0x256e, chr 0x256f, chr 0x2570 in
+    grid [ [ a; hbar; b ]; [ vbar; i; vbar ]; [ d; hbar; c ] ]
+end
+
+let halfblock = "▄"
+
+let pxmatrix w h f =
+  I.tabulate w h
+  @@ fun x y ->
+  let y = y * 2 in
+  I.string A.(bg (f x y) ++ fg (f x (y + 1))) halfblock
+
+
+module Term = Notty_unix.Term
+
+let simpleterm ~imgf ~f ~s =
+  let term = Term.create () in
+  let imgf (w, h) s = I.(string A.(fg lightblack) "[ESC quits.]" <-> imgf (w, h - 1) s) in
+  let rec go s =
+    Term.image term (imgf (Term.size term) s);
+    match Term.event term with
+    | `End | `Key (`Escape, []) | `Key (`ASCII 'C', [ `Ctrl ]) -> ()
+    | `Resize _ -> go s
+    | #Unescape.event as e ->
+      (match f s e with
+       | Some s -> go s
+       | _ -> ())
+  in
+  go s
diff --git a/bin/dune b/bin/dune
@@ -0,0 +1,12 @@
+(executable
+ (public_name stitch)
+ (name main)
+ (libraries
+  stitch
+  unix
+  cmdliner
+  str
+  notty
+  notty.unix
+  shexp.process
+  lambda-term))
diff --git a/bin/main.ml b/bin/main.ml
@@ -0,0 +1,77 @@
+open Notty
+open Common
+
+let content = Stitch.get_headlines () |> Stitch.parse_headlines
+let content_pretty = content |> Stitch.pretty_format
+
+let rec main t (((x, y) as pos), scroll) =
+  let img =
+    let dot = I.string A.(fg black) ">" |> I.pad ~l:0 ~t:(y - scroll)
+    and elements =
+      Array.mapi
+        (fun i el -> I.strf ~attr:A.(fg black) "%s" el |> I.pad ~l:2 ~t:i)
+        (Array.to_seq content_pretty |> Seq.drop scroll |> Array.of_seq)
+    in
+    let open I in
+    Array.fold_left (fun sum el -> el </> sum) dot elements
+  in
+  let _, size_y = Term.size t in
+  Term.image t img;
+  let content_length = Array.length content_pretty in
+  let scroll_up () =
+    let scroll = if y - scroll = 0 then max (scroll - 1) 0 else scroll in
+    main t @@ ((x, max (y - 1) 0), scroll)
+  in
+  let scroll_down () =
+    let scroll = if y - scroll >= size_y - 1 then scroll + 1 else scroll in
+    main t @@ ((x, min (y + 1) content_length), scroll)
+  in
+  match Term.event t with
+  | `End | `Key (`Escape, []) | `Key (`ASCII 'q', []) | `Key (`ASCII 'C', [ `Ctrl ]) -> ()
+  | `Mouse (`Press (`Scroll s), _, _) ->
+    (match s with
+     | `Down -> scroll_down ()
+     | `Up -> scroll_up ())
+  | `Resize _ -> main t (pos, scroll)
+  | `Mouse ((`Press _ | `Drag), (_, y), _) -> main t ((0, min y content_length), scroll)
+  | `Key (`ASCII 'j', []) | `Key (`ASCII 'N', [ `Ctrl ]) -> scroll_down ()
+  | `Key (`ASCII 'k', []) | `Key (`ASCII 'P', [ `Ctrl ]) ->
+    let scroll = if y - scroll = 0 then max (scroll - 1) 0 else scroll in
+    main t @@ ((x, max (y - 1) 0), scroll)
+  | `Key (`Arrow d, _) ->
+    (match d with
+     | `Up -> scroll_up ()
+     | `Down -> scroll_down ()
+     | _ -> main t ((x, y), scroll))
+  | `Key (`ASCII 'e', []) | `Key (`Enter, []) ->
+    (* Editor might be set with extra args, in that case we need to separate these *)
+    let[@warning "-8"] (editor :: args) =
+      String.split_on_char ' ' (Sys.getenv "EDITOR")
+    in
+    let selected_file, _ = Array.get content y in
+    let full_path_file = Stitch.execution_directory ^ "/" ^ selected_file in
+    let full_args = Array.append (Array.of_list args) [| full_path_file |] in
+    Term.cursor t (Some (0, 0));
+    let _ =
+      Unix.create_process_env
+        editor
+        full_args
+        (Unix.environment ())
+        Unix.stdin
+        Unix.stdout
+        Unix.stderr
+    in
+    let rec run_editor () =
+      match Unix.wait () with
+      | _, _ ->
+        Term.cursor t None;
+        main t ((x, y), scroll)
+      (* Capture resizing events *)
+      | exception Unix.Unix_error (Unix.EINTR, _, _) -> run_editor ()
+      | exception Unix.Unix_error (_, _, _) -> failwith "ERROR"
+    in
+    run_editor ()
+  | _ -> main t (pos, scroll)
+
+
+let () = main (Term.create ()) ((0, 0), 0)
diff --git a/dune-project b/dune-project
@@ -0,0 +1,24 @@
+(lang dune 3.11)
+
+(name stitch)
+
+(generate_opam_files true)
+
+
+(authors "Marc Coquand")
+
+(maintainers "Marc Coquand")
+
+(license BSD-3-Clause)
+
+(documentation https://url/to/documentation)
+
+(package
+ (name stitch)
+ (synopsis "A Note Composer")
+ (description "A minimal CLI tool that allows you to compose notes together. Useful as part of a bigger system for building a PKM.")
+ (depends ocaml dune cmdliner notty lambda-term shexp)
+ (tags
+  (productivity minimal)))
+
+; See the complete stanza docs at https://dune.readthedocs.io/en/stable/dune-files.html#dune-project
diff --git a/flake.lock b/flake.lock
@@ -0,0 +1,189 @@
+{
+  "nodes": {
+    "flake-compat": {
+      "flake": false,
+      "locked": {
+        "lastModified": 1627913399,
+        "narHash": "sha256-hY8g6H2KFL8ownSiFeMOjwPC8P0ueXpCVEbxgda3pko=",
+        "owner": "edolstra",
+        "repo": "flake-compat",
+        "rev": "12c64ca55c1014cdc1b16ed5a804aa8576601ff2",
+        "type": "github"
+      },
+      "original": {
+        "owner": "edolstra",
+        "repo": "flake-compat",
+        "type": "github"
+      }
+    },
+    "flake-utils": {
+      "inputs": {
+        "systems": "systems"
+      },
+      "locked": {
+        "lastModified": 1710146030,
+        "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "type": "github"
+      }
+    },
+    "flake-utils_2": {
+      "locked": {
+        "lastModified": 1638122382,
+        "narHash": "sha256-sQzZzAbvKEqN9s0bzWuYmRaA03v40gaJ4+iL1LXjaeI=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "74f7e4319258e287b0f9cb95426c9853b282730b",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "type": "github"
+      }
+    },
+    "mirage-opam-overlays": {
+      "flake": false,
+      "locked": {
+        "lastModified": 1661959605,
+        "narHash": "sha256-CPTuhYML3F4J58flfp3ZbMNhkRkVFKmBEYBZY5tnQwA=",
+        "owner": "dune-universe",
+        "repo": "mirage-opam-overlays",
+        "rev": "05f1c1823d891ce4d8adab91f5db3ac51d86dc0b",
+        "type": "github"
+      },
+      "original": {
+        "owner": "dune-universe",
+        "repo": "mirage-opam-overlays",
+        "type": "github"
+      }
+    },
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1682362401,
+        "narHash": "sha256-/UMUHtF2CyYNl4b60Z2y4wwTTdIWGKhj9H301EDcT9M=",
+        "owner": "nixos",
+        "repo": "nixpkgs",
+        "rev": "884ac294018409e0d1adc0cae185439a44bd6b0b",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nixos",
+        "ref": "nixos-unstable",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "opam-nix": {
+      "inputs": {
+        "flake-compat": "flake-compat",
+        "flake-utils": "flake-utils_2",
+        "mirage-opam-overlays": "mirage-opam-overlays",
+        "nixpkgs": "nixpkgs",
+        "opam-overlays": "opam-overlays",
+        "opam-repository": "opam-repository",
+        "opam2json": "opam2json"
+      },
+      "locked": {
+        "lastModified": 1715087815,
+        "narHash": "sha256-FjIg+rO+aIfVFzSbvNznVhCn/s2MS8HkPhht7LqzLlk=",
+        "owner": "tweag",
+        "repo": "opam-nix",
+        "rev": "d42a3b8f234dd8c922020f8b2f4e326406bf23d1",
+        "type": "github"
+      },
+      "original": {
+        "owner": "tweag",
+        "repo": "opam-nix",
+        "type": "github"
+      }
+    },
+    "opam-overlays": {
+      "flake": false,
+      "locked": {
+        "lastModified": 1654162756,
+        "narHash": "sha256-RV68fUK+O3zTx61iiHIoS0LvIk0E4voMp+0SwRg6G6c=",
+        "owner": "dune-universe",
+        "repo": "opam-overlays",
+        "rev": "c8f6ef0fc5272f254df4a971a47de7848cc1c8a4",
+        "type": "github"
+      },
+      "original": {
+        "owner": "dune-universe",
+        "repo": "opam-overlays",
+        "type": "github"
+      }
+    },
+    "opam-repository": {
+      "flake": false,
+      "locked": {
+        "lastModified": 1705008664,
+        "narHash": "sha256-TTjTal49QK2U0yVOmw6rJhTGYM7tnj3Kv9DiEEiLt7E=",
+        "owner": "ocaml",
+        "repo": "opam-repository",
+        "rev": "fa77046c6497f8ca32926acdb7eb1e61777d4c17",
+        "type": "github"
+      },
+      "original": {
+        "owner": "ocaml",
+        "repo": "opam-repository",
+        "type": "github"
+      }
+    },
+    "opam2json": {
+      "inputs": {
+        "nixpkgs": [
+          "opam-nix",
+          "nixpkgs"
+        ]
+      },
+      "locked": {
+        "lastModified": 1671540003,
+        "narHash": "sha256-5pXfbUfpVABtKbii6aaI2EdAZTjHJ2QntEf0QD2O5AM=",
+        "owner": "tweag",
+        "repo": "opam2json",
+        "rev": "819d291ea95e271b0e6027679de6abb4d4f7f680",
+        "type": "github"
+      },
+      "original": {
+        "owner": "tweag",
+        "repo": "opam2json",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "flake-utils": "flake-utils",
+        "nixpkgs": [
+          "opam-nix",
+          "nixpkgs"
+        ],
+        "opam-nix": "opam-nix"
+      }
+    },
+    "systems": {
+      "locked": {
+        "lastModified": 1681028828,
+        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+        "owner": "nix-systems",
+        "repo": "default",
+        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-systems",
+        "repo": "default",
+        "type": "github"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
diff --git a/flake.nix b/flake.nix
@@ -0,0 +1,63 @@
+{
+  inputs = {
+    nixpkgs.url = "github:NixOS/nixpkgs";
+    opam-nix.url = "github:tweag/opam-nix";
+    flake-utils.url = "github:numtide/flake-utils";
+    nixpkgs.follows = "opam-nix/nixpkgs";
+  };
+  outputs = { self, flake-utils, opam-nix, nixpkgs }:
+    let package = "stitch";
+    in flake-utils.lib.eachDefaultSystem (system:
+      let
+        pkgs = nixpkgs.legacyPackages.${system};
+        on = opam-nix.lib.${system};
+        devPackagesQuery = {
+          # You can add "development" packages here. They will get added to the devShell automatically.
+          ocaml-lsp-server = "*";
+          ocamlformat = "*";
+          ocamlfind = "*";
+          utop = "*";
+          odoc = "*";
+        };
+        query = devPackagesQuery // {
+          ## You can force versions of certain packages here, e.g:
+          ## - force the ocaml compiler to be taken from opam-repository:
+          ocaml-base-compiler = "*";
+          ## - or force the compiler to be taken from nixpkgs and be a certain version:
+          # ocaml-system = "4.14.0";
+          ## - or force ocamlfind to be a certain version:
+        };
+        scope = on.buildOpamProject' { } ./. query;
+        overlay = final: prev: {
+          # You can add overrides here
+          ${package} = prev.${package}.overrideAttrs (_: {
+            # Prevent the ocaml dependencies from leaking into dependent environments
+            doNixSupport = false;
+            buildInputs =
+              [ pkgs."gmp" pkgs."libev" pkgs."openssl" pkgs."libargon2" ];
+            DUNE_PROFILE = "release";
+          });
+        };
+        scope' = scope.overrideScope' overlay;
+        # The main package containing the executable
+        main = scope'.${package};
+        # Packages from devPackagesQuery
+        devPackages = builtins.attrValues
+          (pkgs.lib.getAttrs (builtins.attrNames devPackagesQuery) scope');
+      in {
+        legacyPackages = scope';
+
+        packages.default = main;
+
+        apps.stitch.default = {
+          type = "app";
+          program = "${main}/bin/stitch";
+        };
+
+        devShells.default = pkgs.mkShell {
+          inputsFrom = [ main ];
+          buildInputs = devPackages; 
+        };
+      });
+}
+
diff --git a/lib/dune b/lib/dune
@@ -0,0 +1,7 @@
+(library
+ (name stitch)
+ (libraries
+  str
+  shexp.process
+  cmdliner
+  lambda-term))
diff --git a/lib/stitch.ml b/lib/stitch.ml
@@ -0,0 +1,69 @@
+let execution_directory =
+  Sys.getenv_opt "STICH_DIRECTORY" |> Option.value ~default:"/home/mccd/notes-example"
+
+
+let grep_cmd = Sys.getenv_opt "STICH_GREP_CMD" |> Option.value ~default:"ugrep"
+
+let run_print ~dir args =
+  let open Shexp_process in
+  let open Shexp_process.Infix in
+  eval (chdir dir (call args |- read_all))
+
+
+let get_headlines () =
+  run_print
+    ~dir:execution_directory
+    [ grep_cmd; "^\\*"; "-H"; "-r"; "-n"; "--separator=|" ]
+
+
+exception Not_A_Tuple of string * string
+
+(** Returns a tuple of file name and Content *)
+let parse_headlines s =
+  String.split_on_char '\n' s
+  (* Testing in utop it seems like there is maybe a bug with bounded_split, 1 doesn't work for ':'. Therefore using a slower implementation. *)
+  |> List.filter_map (fun message ->
+    if String.equal message ""
+    then None
+    else (
+      let split = Str.bounded_split (Str.regexp "|") message 3 in
+      match split with
+      (* file, line, content *)
+      | [ file_name; _; content ] -> Some (file_name, content)
+      | _ -> raise (Not_A_Tuple (String.concat " SPLIT " split, message))))
+  |> Array.of_list
+
+
+(** Used for pretty printing *)
+let get_padding list =
+  Array.fold_left (fun n (file_name, _) -> Int.max n (String.length file_name)) 0 list
+
+
+let pad str n =
+  let padding = n - String.length str in
+  String.concat "" [ str; String.make padding ' ' ]
+
+
+(** Turns "2024-03-05.org:* Hello world" into "2024-03-05    | * Hello world" *)
+let pretty_format parsed_headlines =
+  let padding = get_padding parsed_headlines in
+  Array.map
+    (fun (file_name, content) -> String.concat " | " [ pad file_name padding; content ])
+    parsed_headlines
+
+
+(** Full body parsing *)
+
+let get_full_content () =
+  run_print
+    ~dir:execution_directory
+    [ grep_cmd; "^\\*"; "-h"; "-r"; "-n"; "-C"; "9999"; "--separator='|'" ]
+
+(* let parse_file_headline collection full = *)
+(*   match full with *)
+(*   | s :: r -> *)
+(*     let split = Str.bounded_split (Str.regexp ":1:") s 1 in *)
+(*     (match split with *)
+(*      (\* file, line, content *\) *)
+(*      | [ file_name; content ] -> file_name, content *)
+(*      | rest ->  *)
diff --git a/lib/zipper.ml b/lib/zipper.ml
@@ -0,0 +1,22 @@
+type zipper =
+  { above : string list
+  ; selected : string
+  ; below : string list
+  }
+
+let up (zipper : zipper) =
+  match zipper.above with
+  | [] -> zipper
+  | a :: rest -> { above = rest; selected = a; below = zipper.selected :: zipper.below }
+
+
+let down (zipper : zipper) =
+  match zipper.below with
+  | [] -> zipper
+  | a :: rest -> { below = rest; selected = a; above = zipper.selected :: zipper.above }
+
+
+let selected zipper = zipper.selected
+
+let to_array zipper =
+  List.concat [ zipper.above; [ zipper.selected ]; zipper.below ] |> Array.of_list
diff --git a/stitch.opam b/stitch.opam
@@ -0,0 +1,32 @@
+# This file is generated by dune, edit dune-project instead
+opam-version: "2.0"
+synopsis: "A Terminal Note Taking App"
+description: ""
+maintainer: ["Marc Coquand"]
+authors: ["Marc Coquand"]
+license: "GPL-3.0-only"
+tags: ["productivity" "minimal"]
+doc: "https://url/to/documentation"
+depends: [
+  "ocaml"
+  "dune" {>= "3.11"}
+  "cmdliner"
+  "notty"
+  "lambda-term"
+  "shexp"
+  "odoc" {with-doc}
+]
+build: [
+  ["dune" "subst"] {dev}
+  [
+    "dune"
+    "build"
+    "-p"
+    name
+    "-j"
+    jobs
+    "@install"
+    "@runtest" {with-test}
+    "@doc" {with-doc}
+  ]
+]
diff --git a/test/dune b/test/dune
@@ -0,0 +1,2 @@
+(test
+ (name test_apbox))
diff --git a/test/test_apbox.ml b/test/test_apbox.ml