diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for zettelkast
+
+## 0.1.0.0 -- 2020-06-11
+
+* First version. Released on an unsuspecting world.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2020, Mats Rauhala
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Mats Rauhala nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,111 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TypeApplications #-}
+module Main where
+
+import Options.Generic
+
+import GHC.Generics
+       (Generic)
+
+import System.Process
+
+import System.Environment
+
+import qualified Data.Text.IO as T
+
+import Data.Maybe
+       (catMaybes, fromMaybe)
+
+import Data.Path
+import Data.Zettel
+       (readZettel, zettels)
+import Data.ZettelGraph
+import Data.ZettelID
+import Data.ZettelMeta
+import Data.ZettelPath
+
+import qualified Data.Set as Set
+
+import Data.Foldable
+       (for_, traverse_)
+import Data.List
+       (sortOn)
+import Data.Traversable
+       (for)
+
+import Control.Lens
+       (from, (^.))
+
+import Control.Monad.Trans.Maybe
+       (MaybeT(..), runMaybeT)
+
+import Data.Time
+       (getCurrentTime, utctDay)
+
+import System.Directory
+       (createDirectoryIfMissing, setCurrentDirectory)
+
+data Commands
+  = New
+  | Graph
+  | Open Text
+  | List
+  deriving (Show, Generic)
+
+instance ParseRecord Commands
+
+getEditor :: IO FilePath
+getEditor = fromMaybe "vim" <$> lookupEnv "EDITOR"
+
+getZettelBase :: IO (Path Root Dir)
+getZettelBase = fromPosixPath <$> getEnv "ZETTEL_ROOT"
+
+withZettelRoot :: (ZettelRoot -> IO a) -> IO a
+withZettelRoot f = do
+  zettelBase <- getZettelBase
+  let path = ZettelRoot zettelBase
+  createDirectoryIfMissing True (toPosixPath zettelBase)
+  setCurrentDirectory (toPosixPath zettelBase)
+  f path
+
+createNew :: ZettelRoot -> IO ()
+createNew base = do
+  editor <- getEditor
+  nextZettel <- createZettelID <$> (concatSet <$> zettels base) <*> (utctDay <$> getCurrentTime)
+  for_ nextZettel $ \zid ->
+    callProcess editor [toPosixPath ((base ^. from _ZettelRoot) ./ toPath zid)]
+  where
+    concatSet = Set.foldl' (\acc -> maybe acc (`Set.insert` acc)) Set.empty
+
+open :: ZettelRoot -> Text -> IO ()
+open base name = for_ (parse name) $ \zid -> do
+  editor <- getEditor
+  callProcess editor [toPosixPath ((base ^. from _ZettelRoot) ./ toPath zid)]
+
+createGraph :: ZettelRoot -> IO ()
+createGraph base = do
+  contents <- dot <$> zettelLinks base
+  T.putStrLn contents
+
+list :: ZettelRoot -> IO ()
+list base = do
+  zs <- zettels @[] base
+  meta <- for zs $ \mzid -> runMaybeT $ do
+    zid <- MaybeT (pure mzid)
+    zettel <- MaybeT (either (const Nothing) pure <$> readZettel base zid)
+    MaybeT $ pure $ zettelMeta zid zettel
+  traverse_ (T.putStrLn . format) . sortOn zettelID . catMaybes $ meta
+  where
+    format :: ZettelMeta -> Text
+    format ZettelMeta{..} = render zettelID <> ":\t\t" <> title
+
+main :: IO ()
+main = withZettelRoot $ \base ->
+  getRecord "zettelkast" >>= \case
+    New -> createNew base
+    Graph -> createGraph base
+    Open name -> open base name
+    List -> list base
diff --git a/src/Data/Path.hs b/src/Data/Path.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Path.hs
@@ -0,0 +1,66 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE StandaloneDeriving #-}
+module Data.Path where
+-- https://github.com/Gabriel439/Haskell-Turtle-Library/issues/54
+
+import Control.Category
+import Data.Text
+       (Text)
+import qualified Data.Text as T
+import Prelude hiding
+       (id, (.))
+
+import Data.List (foldl')
+
+import qualified System.FilePath.Posix as FilePath
+
+data Root
+data Dir
+data File
+
+data Path a b where
+  Nil ::                            Path a a
+  ConsRoot ::        Path a Root -> Path a Dir
+  ConsDir ::  Text -> Path a Dir -> Path a Dir
+  ConsFile :: Text -> Path a Dir -> Path a File
+
+deriving instance Show (Path a b)
+
+instance Category Path where
+  id = Nil
+  p1 . p2 =
+    case p1 of
+         Nil -> p2
+         ConsRoot p1' -> ConsRoot (p1' . p2)
+         ConsDir str p1' -> ConsDir str (p1' . p2)
+         ConsFile str p1' -> ConsFile str (p1' . p2)
+
+root :: Path Root Dir
+root = ConsRoot Nil
+
+dir :: Text -> Path Dir Dir
+dir p = ConsDir p Nil
+
+file :: Text -> Path Dir File
+file p = ConsFile p Nil
+
+
+toPosixPath :: Path a b -> FilePath
+toPosixPath = \case
+  Nil -> mempty
+  ConsRoot p -> "/" <> toPosixPath p
+  ConsDir path p -> toPosixPath p FilePath.</> T.unpack path
+  ConsFile path p -> toPosixPath p FilePath.</> T.unpack path
+
+-- Not entirely happy with this. It only supports root paths
+fromPosixPath :: FilePath -> Path Root Dir
+fromPosixPath = foldl' go root . FilePath.splitDirectories
+  where
+    go :: Path Root Dir -> FilePath -> Path Root Dir
+    go _ "/" = root
+    go p path = dir (T.pack path) . p
+
+(./) :: Path a b -> Path b c -> Path a c
+(./) = (>>>)
diff --git a/src/Data/Zettel.hs b/src/Data/Zettel.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Zettel.hs
@@ -0,0 +1,25 @@
+module Data.Zettel where
+
+import qualified Data.Text as T
+import qualified Data.Text.IO as T
+
+import System.Directory (listDirectory)
+
+import Data.Path
+import Data.ZettelID
+import Data.ZettelPath
+
+import Data.Pointed (Pointed, point)
+
+import Text.Pandoc
+
+zettels :: (Monoid (f (Maybe ZettelID)), Pointed f) => ZettelRoot -> IO (f (Maybe ZettelID))
+zettels (ZettelRoot r) = do
+  paths <- listDirectory (toPosixPath r)
+  let files = map (\p -> r ./ file (T.pack p))  paths
+  pure $ foldMap (point . fromPath) files
+
+readZettel :: ZettelRoot -> ZettelID -> IO (Either PandocError Pandoc)
+readZettel (ZettelRoot base) zid = do
+  contents <- T.readFile (toPosixPath (base ./ toPath zid))
+  runIO (readMarkdown def contents)
diff --git a/src/Data/ZettelGraph.hs b/src/Data/ZettelGraph.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/ZettelGraph.hs
@@ -0,0 +1,66 @@
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ViewPatterns #-}
+module Data.ZettelGraph where
+
+import Data.Text
+       (Text)
+import qualified Data.Text as T
+
+import Text.Pandoc
+-- import Text.Pandoc.Readers.Markdown
+import Text.Pandoc.Walk
+
+import Data.Path
+import Data.Zettel
+import Data.ZettelID
+import Data.ZettelMeta
+import Data.ZettelPath
+
+import Data.Maybe
+       (catMaybes, fromMaybe)
+
+import System.FilePath
+       (takeFileName)
+
+import System.Directory
+       (listDirectory)
+
+import Control.Lens
+       (from, (^.))
+
+zettelLinks :: ZettelRoot -> IO [(ZettelMeta, [ZettelID])]
+zettelLinks base = do
+  paths <- listDirectory (toPosixPath (base ^. from _ZettelRoot))
+  catMaybes <$> traverse go paths
+  where
+    go :: FilePath -> IO (Maybe (ZettelMeta, [ZettelID]))
+    go (fromPath . file . T.pack -> Just zid) = do
+      zettel <- readZettel base zid
+      pure $ either (const Nothing) (Just . internalLinks zid) zettel
+    go _ = pure Nothing
+
+extractLink :: Inline -> [ZettelID]
+extractLink = \case
+  Link _ _ (u, _) -> catMaybes [fromPath (path u)]
+  _ -> []
+  where
+    path = file . T.pack . takeFileName
+
+internalLinks :: ZettelID -> Pandoc -> (ZettelMeta, [ZettelID])
+internalLinks f p = (fromMaybe unknown (zettelMeta f p), query extractLink p)
+  where
+    unknown = ZettelMeta f "No title"
+
+dot :: [(ZettelMeta, [ZettelID])] -> Text
+dot links = header <> nodes <> "\n" <> edges <> footer
+  where
+    nodes = T.unlines (map (\(f,_) -> node f) links)
+    edges = T.unlines (concatMap (\(f,ts) -> map (edge (zettelID f)) ts) links)
+    node :: ZettelMeta -> Text
+    node ZettelMeta{..} = render zettelID <> " [label=\""<> title <>"\"];"
+    edge :: ZettelID -> ZettelID -> Text
+    edge f t = render f <> " -> " <> render t <> ";"
+    header = "digraph g {\n"
+    footer = "}"
diff --git a/src/Data/ZettelID.hs b/src/Data/ZettelID.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/ZettelID.hs
@@ -0,0 +1,76 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+module Data.ZettelID where
+
+import Text.Read
+       (readMaybe)
+
+import Data.Path
+
+import Data.Text
+       (Text)
+import qualified Data.Text as T
+
+import Text.Printf
+       (printf)
+
+import Data.Set
+       (Set)
+import qualified Data.Set as Set
+
+import Data.Monoid
+       (First(..))
+
+import Data.Time
+       (Day, defaultTimeLocale)
+import Data.Time.Format
+       (formatTime, parseTimeM)
+
+-- | Uniquely identifying zettel
+data ZettelID
+  = ZettelID { day :: Day -- ^ The day when it was authored
+             , counter :: Int -- ^ Increasing counter
+             }
+  deriving (Show, Eq, Ord)
+
+-- | Render a zettel id as a text
+--
+-- The rendered id is "%y%m%d0000" where the latter number is the counter.
+-- For example the second zettel for 11.6.2020 would be "2006110002"
+render :: ZettelID -> Text
+render ZettelID{..} = T.pack (dayFormat <> counterFormat)
+  where
+    dayFormat = formatTime defaultTimeLocale "%y%m%d" day
+    counterFormat = printf "%03d" counter
+
+-- | Parse the rendered zetteled id back into a 'ZettelID'
+--
+-- See 'render' for the format
+parse :: Text -> Maybe ZettelID
+parse path = ZettelID <$> dayParse path <*> counterParse path
+  where
+    dayParse = parseTimeM False defaultTimeLocale "%y%m%d" . T.unpack . T.take 6
+    counterParse = readMaybe . T.unpack . T.drop 6 . T.takeWhile (/= '.')
+
+-- | Convert a zettel id into a filename
+toPath :: ZettelID -> Path Dir File
+toPath zid = file (render zid <> ".md")
+
+-- | Parse a filename into a zettel id
+fromPath :: Path a File -> Maybe ZettelID
+fromPath = \case
+  ConsFile path _ -> parse (T.dropEnd 2 path)
+  Nil -> Nothing
+
+-- | Create a new unique zettel id
+createZettelID :: Set ZettelID -> Day -> Maybe ZettelID
+createZettelID history today = getFirst (foldMap firstFree zettelIDs)
+  where
+    firstFree :: ZettelID -> First ZettelID
+    firstFree zid | zid `Set.member` history = First Nothing
+                  | otherwise = First (Just zid)
+    zettelIDs :: [ZettelID]
+    zettelIDs = ZettelID today <$> [0..]
diff --git a/src/Data/ZettelMeta.hs b/src/Data/ZettelMeta.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/ZettelMeta.hs
@@ -0,0 +1,32 @@
+{-# LANGUAGE LambdaCase #-}
+module Data.ZettelMeta where
+
+import Text.Pandoc
+import Text.Pandoc.Walk
+
+import Data.Text
+       (Text)
+import qualified Data.Text as T
+
+import Data.Maybe
+       (listToMaybe)
+
+import Data.ZettelID
+
+data ZettelMeta
+  = ZettelMeta { zettelID :: ZettelID
+               , title :: Text
+               }
+  deriving Show
+
+extractTitle :: Block -> [Text]
+extractTitle = \case
+  Header 1 _attr inlines -> [T.unwords (query strings inlines)]
+  _ -> []
+  where
+    strings = \case
+      Str x -> [T.pack x]
+      _ -> []
+
+zettelMeta :: ZettelID -> Pandoc -> Maybe ZettelMeta
+zettelMeta zid p = ZettelMeta zid <$> listToMaybe (query extractTitle p)
diff --git a/src/Data/ZettelPath.hs b/src/Data/ZettelPath.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/ZettelPath.hs
@@ -0,0 +1,25 @@
+module Data.ZettelPath where
+
+import Data.Path
+
+import Control.Lens
+       (Iso', Lens', iso, lens, set, view)
+
+newtype ZettelRoot = ZettelRoot (Path Root Dir)
+
+_ZettelRoot :: Iso' (Path Root Dir) ZettelRoot
+_ZettelRoot = iso ZettelRoot (\(ZettelRoot r) -> r)
+
+class HasZettelRoot a where
+  {-# MINIMAL getZettelRoot, setZettelRoot | zettelRoot #-}
+  getZettelRoot :: a -> ZettelRoot
+  getZettelRoot = view zettelRoot
+
+  setZettelRoot :: a -> ZettelRoot -> a
+  setZettelRoot = flip (set zettelRoot)
+
+  zettelRoot :: Lens' a ZettelRoot
+  zettelRoot = lens getZettelRoot setZettelRoot
+
+instance HasZettelRoot ZettelRoot where
+  zettelRoot = lens id (\_ b -> b)
diff --git a/test/MyLibTest.hs b/test/MyLibTest.hs
new file mode 100644
--- /dev/null
+++ b/test/MyLibTest.hs
@@ -0,0 +1,4 @@
+module Main (main) where
+
+main :: IO ()
+main = putStrLn "Test suite not yet implemented."
diff --git a/zettelkast.cabal b/zettelkast.cabal
new file mode 100644
--- /dev/null
+++ b/zettelkast.cabal
@@ -0,0 +1,70 @@
+cabal-version:       2.4
+
+name:                zettelkast
+version:             0.1.0.0
+synopsis: Command-line utility for working with zettelkast files
+description:
+  Command-line tool for managing zettelkast documents. The tool primarily focuses
+  on providing unique ids and showing a graph of document connections. It tries
+  to be as unintrusive as possible.
+-- bug-reports:
+license:             BSD-3-Clause
+license-file:        LICENSE
+author:              Mats Rauhala
+maintainer:          mats.rauhala@iki.fi
+-- copyright:
+category:            Text
+extra-source-files:  CHANGELOG.md
+
+source-repository head
+  type: git
+  location: https://github.com/MasseR/zettelkast
+
+library
+  exposed-modules:     Data.ZettelID
+                       Data.ZettelPath
+                       Data.ZettelMeta
+                       Data.Zettel
+                       Data.Path
+                       Data.ZettelGraph
+  -- other-modules:
+  -- other-extensions:
+  build-depends:       base ^>=4.12.0.0
+                     , containers >= 0.6.0 && < 0.7
+                     , directory >= 1.3.3 && < 1.4
+                     , filepath >= 1.4.2 && < 1.5
+                     , lens >= 4.17 && < 4.18
+                     , mtl  >= 2.2.2 && < 2.3
+                     , pandoc >= 2.7.1 && < 2.8
+                     , pandoc-types >= 1.17.5 && < 1.18
+                     , pointed >= 5.0.1 && < 5.1
+                     , process >= 1.6.5 && < 1.7
+                     , text >= 1.2.3 && < 1.3
+                     , time >= 1.8.0 && < 1.9
+  hs-source-dirs:      src
+  default-language:    Haskell2010
+
+executable zettelkast
+  main-is:             Main.hs
+  -- other-modules:
+  -- other-extensions:
+  build-depends:       base ^>=4.12.0.0
+                     , zettelkast
+                     , containers >= 0.6.0 && < 0.7
+                     , directory >= 1.3.3 && < 1.4
+                     , lens >= 4.17 && < 4.18
+                     , optparse-generic >= 1.3.0 && < 1.4
+                     , process >= 1.6.5 && < 1.7
+                     , text >= 1.2.3 && < 1.3
+                     , time >= 1.8.0 && < 1.9
+                     , transformers >= 0.5.6 && < 0.6
+  hs-source-dirs:      app
+  default-language:    Haskell2010
+  ghc-options:       -threaded
+
+test-suite zettelkast-test
+  default-language:    Haskell2010
+  type:                exitcode-stdio-1.0
+  hs-source-dirs:      test
+  main-is:             MyLibTest.hs
+  build-depends:       base ^>=4.12.0.0
