460b7d0fdc
Build and Deploy / build-and-deploy (push) Successful in 2m43s
The cook log SQLite DB was created at recipeDir/cook-log.db. In the Docker deployment /recipes is mounted read-only, so SQLite could never create the file there — the cook log has never worked in the container. The writable /data volume was mounted but unused. - initDb now creates the DB's parent directory if missing, so startup initialization is self-contained and idempotent. - app takes a dataDir and places cook-log.db there. - Add optional --data-dir flag (defaults to the recipe dir, preserving local-dev behavior); Docker CMD passes --data-dir /data. Verified end-to-end as root against a read-only recipe dir and a non-existent data dir: the dir and DB are created and cook-log POST/GET round-trips. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
987 lines
44 KiB
Haskell
987 lines
44 KiB
Haskell
{-# LANGUAGE NumericUnderscores #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE ScopedTypeVariables #-}
|
|
|
|
{- | WAI application for the Roux recipe server.
|
|
|
|
Handles HTTP requests directly via wai — no framework.
|
|
Recipes are loaded from disk at startup, then watched live via fsnotify.
|
|
-}
|
|
module Roux.Server (
|
|
app,
|
|
) where
|
|
|
|
import Control.Concurrent (forkIO, threadDelay)
|
|
import Control.Exception (IOException, SomeException, catch, finally, try)
|
|
import Control.Monad (forM_, forever, when)
|
|
import Data.ByteString.Base64 (decode)
|
|
import Data.ByteString.Builder (lazyByteString)
|
|
import Data.ByteString.Lazy (ByteString)
|
|
import qualified Data.ByteString.Lazy as LB
|
|
import Data.Char (digitToInt, isAlphaNum, toLower)
|
|
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef)
|
|
import Data.List (isSuffixOf, sortOn)
|
|
import Data.Map.Strict (Map)
|
|
import qualified Data.Map.Strict as Map
|
|
import Data.Ord (Down (Down))
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import Data.Text.Encoding (encodeUtf8)
|
|
|
|
import qualified Network.HTTP.Types as HTTP
|
|
import qualified Network.Wai as Wai
|
|
import System.Directory (createDirectoryIfMissing, doesFileExist, findExecutable, getModificationTime, makeAbsolute, removeFile)
|
|
import System.FSNotify (
|
|
Event (..),
|
|
EventIsDirectory (..),
|
|
WatchConfig (..),
|
|
WatchMode (..),
|
|
defaultConfig,
|
|
watchDir,
|
|
withManagerConf,
|
|
)
|
|
import System.FilePath (makeRelative, takeBaseName, takeExtension, (</>))
|
|
|
|
import qualified Data.Aeson as A
|
|
import qualified Data.Aeson.KeyMap as KM
|
|
import qualified Data.ByteString as BS
|
|
import Data.Maybe (catMaybes, fromMaybe, mapMaybe)
|
|
import qualified Data.Text.Encoding as TE
|
|
import Data.Time.Calendar (Day, fromGregorianValid)
|
|
import Data.Time.Clock (diffUTCTime, getCurrentTime, utctDay)
|
|
import qualified Data.Time.Format as Time
|
|
import qualified Database.SQLite.Simple as SQL
|
|
import Roux.Config (AnthropicConfig (..), RouxConfig (..))
|
|
import qualified Roux.CookLog as CookLog
|
|
import qualified Roux.CooklangPrint as CooklangPrint
|
|
import qualified Roux.Html as Html
|
|
import qualified Roux.RecipeIndex as Idx
|
|
import Roux.SchemaOrg (SchemaOrgRecipe (..), parseSchemaOrgRecipe, schemaOrgToCooklang)
|
|
import System.Exit (ExitCode (..))
|
|
import System.IO (hSetBinaryMode)
|
|
import System.Process (CreateProcess (..), StdStream (CreatePipe), createProcess, proc, waitForProcess)
|
|
import Text.Read (readMaybe)
|
|
|
|
-- | Read the full request body as a lazy ByteString.
|
|
readRequestBody :: Wai.Request -> IO LB.ByteString
|
|
readRequestBody req = do
|
|
chunks <- gather []
|
|
pure (LB.fromChunks chunks)
|
|
where
|
|
gather acc = do
|
|
chunk <- Wai.getRequestBodyChunk req
|
|
if BS.null chunk
|
|
then pure (reverse acc)
|
|
else gather (chunk : acc)
|
|
|
|
{- | Build the WAI 'Application' given the recipe directory, a writable data
|
|
directory, and config.
|
|
|
|
Recipes are scanned at startup and then watched live via fsnotify. The cook log
|
|
database is created (if absent) in @dataDir@, which must be writable — the
|
|
recipe directory may be mounted read-only (as it is in the Docker deployment).
|
|
-}
|
|
app :: FilePath -> FilePath -> RouxConfig -> IO Wai.Application
|
|
app recipeDir dataDir config = do
|
|
putStrLn $ "[roux] scanning recipes from " <> recipeDir
|
|
recipes <- Idx.scanRecipes recipeDir
|
|
let count = length recipes
|
|
ok = length [() | r <- recipes, isRight (Idx.riRecipe r)]
|
|
errs = count - ok
|
|
putStrLn $ "[roux] found " <> show count <> " recipe(s)"
|
|
when (errs > 0) $
|
|
putStrLn $
|
|
"[roux] " <> show errs <> " had parse errors"
|
|
-- Build initial map keyed by lowercased filename (without .cook extension)
|
|
let recipeMap = Map.fromList [(map toLower (stripCookExt (Idx.riFilename r)), r) | r <- recipes]
|
|
state <- newIORef recipeMap
|
|
-- Resolve to absolute path so makeRelative works with fsnotify events
|
|
absDir <- makeAbsolute recipeDir
|
|
-- Change log for SSE notifications
|
|
changeLog <- newIORef (0, [])
|
|
-- Start the filesystem watcher
|
|
_ <-
|
|
forkIO $
|
|
watchRecipes absDir state changeLog `catch` \e ->
|
|
putStrLn $ "[roux] watcher thread crashed: " <> show (e :: SomeException)
|
|
-- Initialize cook log database in the writable data directory (initDb
|
|
-- creates the directory and file if they are not already present).
|
|
let dbPath = dataDir </> "cook-log.db"
|
|
db <- CookLog.initDb dbPath
|
|
putStrLn $ "[roux] cook log DB: " <> dbPath
|
|
pure (router absDir config state changeLog db)
|
|
where
|
|
isRight (Right _) = True
|
|
isRight (Left _) = False
|
|
|
|
{- | A change log with a monotonic version counter and
|
|
the last N changed filenames (newest first).
|
|
-}
|
|
type ChangeLog = IORef (Int, [(Int, FilePath)])
|
|
|
|
-- | Record a recipe file change in the change log.
|
|
recordChange :: ChangeLog -> FilePath -> IO ()
|
|
recordChange changeLog path = atomicModifyIORef' changeLog $ \(version, entries) ->
|
|
let newVersion = version + 1
|
|
newEntries = (newVersion, path) : take 99 entries
|
|
in ((newVersion, newEntries), ())
|
|
|
|
{- | SSE endpoint — streams recipe-change events to connected clients.
|
|
Polls 'ChangeLog' every 500ms and pushes any new events since the
|
|
client's last seen version. Sends a @ping@ event every 30s as a
|
|
keepalive. Client disconnect is detected by Warp when the write
|
|
action throws.
|
|
-}
|
|
sseHandler :: ChangeLog -> Wai.Application
|
|
sseHandler changeLog _request respond = do
|
|
respond
|
|
$ Wai.responseStream
|
|
HTTP.status200
|
|
[ ("Content-Type", "text/event-stream")
|
|
, ("Cache-Control", "no-cache")
|
|
, ("Connection", "keep-alive")
|
|
]
|
|
$ \write flush -> do
|
|
lastSent <- newIORef 0
|
|
lastPing <- newIORef =<< getCurrentTime
|
|
let sendPing = do
|
|
write "event: ping\ndata: keepalive\n\n"
|
|
flush
|
|
let go = do
|
|
now <- getCurrentTime
|
|
(version, entries) <- readIORef changeLog
|
|
sent <- readIORef lastSent
|
|
when (version > sent) $ do
|
|
let newEntries = takeWhile (\(v, _) -> v > sent) entries
|
|
-- Entries are newest-first. Deduplicate by filename,
|
|
-- keeping the newest (first encountered) entry per file.
|
|
let deduped =
|
|
foldl'
|
|
( \acc e@(_, p) ->
|
|
if any ((== p) . snd) acc
|
|
then acc
|
|
else e : acc
|
|
)
|
|
[]
|
|
newEntries
|
|
forM_ deduped $ \(_v, path) -> do
|
|
write
|
|
( lazyByteString $
|
|
LB.fromStrict $
|
|
encodeUtf8 $
|
|
T.unlines
|
|
[ "event: recipe-changed"
|
|
, "data: " <> T.pack path
|
|
, ""
|
|
, ""
|
|
]
|
|
)
|
|
writeIORef lastSent version
|
|
flush
|
|
pingTime <- readIORef lastPing
|
|
when (diffUTCTime now pingTime > 30) $ do
|
|
writeIORef lastPing now
|
|
sendPing
|
|
threadDelay 500_000 -- 500ms poll interval
|
|
go
|
|
-- Send an initial ping so the client detects connection immediately
|
|
pingTime <- getCurrentTime
|
|
writeIORef lastPing pingTime
|
|
sendPing
|
|
go `finally` pure ()
|
|
|
|
-- | Route requests — SSE endpoint, recipe pages, or import.
|
|
router :: FilePath -> RouxConfig -> IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> SQL.Connection -> Wai.Application
|
|
router recipeDir config state changeLog db request respond =
|
|
case Wai.pathInfo request of
|
|
["events"] -> sseHandler changeLog request respond
|
|
["import"] -> importHandler recipeDir config state request respond
|
|
["upload-image"] -> uploadImageHandler recipeDir state request respond
|
|
["recipe-images", filename] -> serveRecipeImage recipeDir (T.unpack filename) request respond
|
|
["recipes", _, "title"]
|
|
| Wai.requestMethod request == "PATCH" ->
|
|
titleUpdateHandler recipeDir state request respond
|
|
pathInfo
|
|
| Just filename <- dispatchLogPost pathInfo
|
|
, Wai.requestMethod request == "POST" ->
|
|
handleCookLogPost db filename request respond
|
|
_ -> handleRequest recipeDir state db request respond
|
|
|
|
-- | Extract filename from /cook-log/{filename} POST path, if it matches.
|
|
dispatchLogPost :: [Text] -> Maybe Text
|
|
dispatchLogPost ["cook-log", filename] = Just filename
|
|
dispatchLogPost _ = Nothing
|
|
|
|
{- | Handle GET and POST requests to /import.
|
|
GET — show the import form.
|
|
POST — dispatch based on input: URL (existing scraper), text, or file (Anthropic).
|
|
Files are uploaded as base64-encoded content via hidden form fields
|
|
(file-b64, file-name, file-mime) set by client-side JavaScript.
|
|
-}
|
|
importHandler :: FilePath -> RouxConfig -> IORef (Map FilePath Idx.RecipeInfo) -> Wai.Application
|
|
importHandler recipeDir config _state request respond =
|
|
case Wai.requestMethod request of
|
|
"GET" ->
|
|
respond (htmlResponse (Html.importPage Nothing))
|
|
"POST" -> do
|
|
body <- readRequestBody request
|
|
let params = parseFormBody body
|
|
url = fromMaybe "" (lookup "url" params)
|
|
text = fromMaybe "" (lookup "text" params)
|
|
fileB64 = fromMaybe "" (lookup "file-b64" params)
|
|
fileName = fromMaybe "" (lookup "file-name" params)
|
|
fileMime = fromMaybe "" (lookup "file-mime" params)
|
|
hasFile = not (T.null fileB64)
|
|
|
|
putStrLn $
|
|
"[roux] import request: url="
|
|
<> T.unpack url
|
|
<> " text="
|
|
<> show (not (T.null text))
|
|
<> " file="
|
|
<> show hasFile
|
|
|
|
result <- try $ case (hasFile, not (T.null text), not (T.null url)) of
|
|
-- File takes priority, then text, then URL
|
|
(True, _, _) ->
|
|
runFileImport recipeDir config fileName fileMime fileB64
|
|
(_, True, _) ->
|
|
runTextImport recipeDir config text
|
|
(_, _, True) ->
|
|
runImportPipeline recipeDir url
|
|
_ ->
|
|
pure (Left (Html.ImportError "Provide a URL, recipe text, or file to import."))
|
|
|
|
case result of
|
|
Left (e :: SomeException) ->
|
|
respond $
|
|
htmlResponse $
|
|
Html.importResultPage $
|
|
Html.ImportError ("Unexpected error: " <> T.pack (show e))
|
|
Right (Left err) ->
|
|
respond $
|
|
htmlResponse $
|
|
Html.importResultPage err
|
|
Right (Right filename) ->
|
|
respond $
|
|
Wai.responseLBS
|
|
HTTP.status303
|
|
[("Location", "/recipes/" <> TE.encodeUtf8 (T.pack filename))]
|
|
""
|
|
_ -> respond notFound
|
|
|
|
-- | Parse URL-encoded form body into key-value pairs.
|
|
parseFormBody :: LB.ByteString -> [(Text, Text)]
|
|
parseFormBody body =
|
|
let decoded = TE.decodeUtf8 $ LB.toStrict body
|
|
parts = T.splitOn "&" decoded
|
|
in mapMaybe parsePair parts
|
|
where
|
|
parsePair part = case T.splitOn "=" part of
|
|
[key, val] -> Just (urldecode key, urldecode val)
|
|
_ -> Nothing
|
|
-- Proper percent-decode for all %XX sequences (not just %20/%23/%25).
|
|
urldecode = T.pack . go . T.unpack . T.replace "+" " "
|
|
go [] = []
|
|
go ('%' : a : b : rest) =
|
|
let hexVal = digitToInt a * 16 + digitToInt b
|
|
in toEnum hexVal : go rest
|
|
go (c : rest) = c : go rest
|
|
|
|
{- | Run a subprocess and capture stdout/stderr as raw bytes (no locale decoding).
|
|
Reads output eagerly to avoid the classic lazy-IO deadlock where
|
|
the parent blocks on waitForProcess while the child blocks on a
|
|
full pipe buffer.
|
|
-}
|
|
readProcessBytes :: FilePath -> [String] -> IO (ExitCode, LB.ByteString, LB.ByteString)
|
|
readProcessBytes cmd args = do
|
|
let process =
|
|
(proc cmd args)
|
|
{ std_out = CreatePipe
|
|
, std_err = CreatePipe
|
|
}
|
|
result <- createProcess process
|
|
case result of
|
|
(Nothing, Just outH, Just errH, ph) -> do
|
|
hSetBinaryMode outH True
|
|
hSetBinaryMode errH True
|
|
-- Read stdout and stderr strictly before waiting for the process.
|
|
-- Using length forces the lazy ByteString, triggering eager reads
|
|
-- and preventing the pipe buffer from filling up.
|
|
-- hSetBuffering would also work but requires knowing a buffer size.
|
|
out <- LB.hGetContents outH
|
|
err <- LB.hGetContents errH
|
|
-- Force full evaluation before waitForProcess to avoid deadlock.
|
|
let outLen = LB.length out
|
|
errLen = LB.length err
|
|
outLen `seq` errLen `seq` do
|
|
ec <- waitForProcess ph
|
|
pure (ec, out, err)
|
|
_ ->
|
|
ioError $
|
|
userError $
|
|
"[roux] createProcess did not return std_out/std_err pipes for " <> cmd
|
|
|
|
-- | Locate the scrape-recipe script: check PATH first, then relative to project.
|
|
findScrapeScript :: IO FilePath
|
|
findScrapeScript = do
|
|
-- In production Docker, the script is at /usr/local/bin/scrape-recipe (on PATH).
|
|
-- In development from the project root, it is at scripts/scrape-recipe.
|
|
mPath <- findExecutable "scrape-recipe"
|
|
case mPath of
|
|
Just p -> pure p
|
|
Nothing -> pure "scripts/scrape-recipe"
|
|
|
|
-- | Run the full import pipeline: scrape -> parse -> convert -> render -> save.
|
|
runImportPipeline :: FilePath -> Text -> IO (Either Html.ImportError FilePath)
|
|
runImportPipeline recipeDir url = do
|
|
-- Step 1: locate and run the scraper
|
|
script <- findScrapeScript
|
|
putStrLn $ "[roux] importing from " <> T.unpack url <> " using script " <> script
|
|
(exitCode, stdoutBytes, stderrBytes) <-
|
|
readProcessBytes script ["--pretty", T.unpack url]
|
|
case exitCode of
|
|
ExitFailure _ -> do
|
|
putStrLn $ "[roux] scrape-recipe exited with code " <> show exitCode <> " for " <> T.unpack url
|
|
putStrLn $ "[roux] scrape-recipe stderr: " <> T.unpack (TE.decodeUtf8 (LB.toStrict stderrBytes))
|
|
return $
|
|
Left $
|
|
Html.ImportError $
|
|
"Could not scrape URL (" <> url <> "). The scraper exited with: " <> TE.decodeUtf8 (LB.toStrict stderrBytes)
|
|
ExitSuccess -> do
|
|
-- Step 2: decode stdout (raw bytes) as JSON Value
|
|
let jsonVal = A.decode stdoutBytes :: Maybe A.Value
|
|
case jsonVal of
|
|
Nothing -> do
|
|
putStrLn $ "[roux] failed to decode JSON from scrape-recipe stdout for " <> T.unpack url
|
|
return $
|
|
Left $
|
|
Html.ImportError $
|
|
"Could not parse JSON from scraper output (" <> url <> ")."
|
|
Just val -> case parseSchemaOrgRecipe val of
|
|
Nothing -> do
|
|
putStrLn $ "[roux] no schema.org Recipe found in JSON for " <> T.unpack url
|
|
return $
|
|
Left $
|
|
Html.ImportError $
|
|
"Could not parse recipe data from " <> url <> ". The page may not have schema.org recipe data."
|
|
Just schema -> case schemaOrgToCooklang schema of
|
|
Left err -> do
|
|
putStrLn $ "[roux] failed to convert schema.org recipe for " <> T.unpack url <> ": " <> T.unpack (T.pack err)
|
|
return $
|
|
Left $
|
|
Html.ImportError $
|
|
"Could not convert recipe from " <> url <> ": " <> T.pack err
|
|
Right recipe -> do
|
|
-- Step 3: render to Cooklang text
|
|
let cookText = CooklangPrint.renderRecipe recipe
|
|
filename = CooklangPrint.filenameFromTitle (soName schema)
|
|
filepath = recipeDir </> filename
|
|
-- Save raw JSON alongside the .cook for debugging
|
|
jsonFilename = takeBaseName filename <> ".json"
|
|
jsonFilepath = recipeDir </> jsonFilename
|
|
-- Step 4: write raw JSON from scraper
|
|
putStrLn $ "[roux] writing raw scraper JSON to " <> jsonFilepath
|
|
LB.writeFile jsonFilepath stdoutBytes
|
|
-- Step 5: write the .cook file
|
|
putStrLn $ "[roux] writing imported recipe to " <> filepath
|
|
BS.writeFile filepath (TE.encodeUtf8 cookText)
|
|
putStrLn $ "[roux] successfully imported " <> T.unpack url <> " as " <> filename
|
|
return $ Right filename
|
|
|
|
{- | Import a recipe from an uploaded file via the LLM pipeline.
|
|
The file content is base64-encoded in the form data.
|
|
-}
|
|
runFileImport ::
|
|
-- | recipe directory
|
|
FilePath ->
|
|
-- | server config (for Anthropic settings)
|
|
RouxConfig ->
|
|
-- | original filename
|
|
Text ->
|
|
-- | MIME type
|
|
Text ->
|
|
-- | base64-encoded file content
|
|
Text ->
|
|
IO (Either Html.ImportError FilePath)
|
|
runFileImport recipeDir config originalName _mimeType b64Text = do
|
|
-- Decode base64
|
|
case decode (TE.encodeUtf8 b64Text) of
|
|
Left err ->
|
|
pure $
|
|
Left $
|
|
Html.ImportError $
|
|
"Could not decode uploaded file: " <> T.pack err
|
|
Right content -> do
|
|
-- Save the original file to recipe-images/ subdirectory
|
|
let imagesDir = recipeDir </> "recipe-images"
|
|
safeName = map (\c -> if isAlphaNum c || c == '.' || c == '-' || c == '_' then c else '_') (T.unpack originalName)
|
|
sourcePath = imagesDir </> safeName
|
|
sourceUrl = "/recipe-images/" <> T.pack safeName
|
|
createDirectoryIfMissing True imagesDir
|
|
BS.writeFile sourcePath content
|
|
putStrLn $ "[roux] saved uploaded file to " <> sourcePath
|
|
-- Call the LLM pipeline with the source URL
|
|
case rcAnthropic config of
|
|
Nothing ->
|
|
pure $
|
|
Left $
|
|
Html.ImportError
|
|
"LLM import is not configured. Add an anthropic.api_key to the config file."
|
|
Just anthropicConfig -> do
|
|
result <- runLLMPipeline recipeDir anthropicConfig (Just sourceUrl) (Just sourcePath) ""
|
|
case result of
|
|
Right filename -> pure $ Right filename
|
|
Left err -> pure $ Left err
|
|
|
|
-- | Import a recipe from pasted text via the LLM pipeline.
|
|
runTextImport ::
|
|
FilePath ->
|
|
RouxConfig ->
|
|
Text ->
|
|
IO (Either Html.ImportError FilePath)
|
|
runTextImport recipeDir config text = do
|
|
case rcAnthropic config of
|
|
Nothing ->
|
|
pure $
|
|
Left $
|
|
Html.ImportError
|
|
"LLM import is not configured. Add an anthropic.api_key to the config file."
|
|
Just anthropicConfig ->
|
|
runLLMPipeline recipeDir anthropicConfig Nothing Nothing text
|
|
|
|
-- | Run the full LLM import pipeline: call convert-to-cooklang -> parse -> save.
|
|
runLLMPipeline ::
|
|
-- | recipe directory
|
|
FilePath ->
|
|
-- | API config
|
|
AnthropicConfig ->
|
|
-- | Just url = source URL to inject into Cooklang front matter
|
|
Maybe Text ->
|
|
-- | Just path = file path for the script (file upload) / Nothing = use text
|
|
Maybe FilePath ->
|
|
-- | recipe text (used when no file path)
|
|
Text ->
|
|
IO (Either Html.ImportError FilePath)
|
|
runLLMPipeline recipeDir anthropicConfig mSourceUrl mFilePath text = do
|
|
-- Step 1: locate the script
|
|
script <- findConvertScript
|
|
-- Step 2: write a temp config for the subprocess
|
|
let configPath = recipeDir </> ".convert-config.yaml"
|
|
writeConvertConfig configPath anthropicConfig
|
|
-- Step 3: build subprocess args
|
|
let args = case mFilePath of
|
|
Just fp -> ["--config", configPath, "--file", fp]
|
|
Nothing -> ["--config", configPath, "--text", T.unpack text]
|
|
putStrLn $ "[roux] running " <> script <> " for LLM import"
|
|
(exitCode, stdoutBytes, stderrBytes) <-
|
|
readProcessBytes script args
|
|
-- Clean up temp config
|
|
_ <- try (removeFile configPath) :: IO (Either IOException ())
|
|
case exitCode of
|
|
ExitFailure _ -> do
|
|
let errMsg = TE.decodeUtf8 (LB.toStrict stderrBytes)
|
|
putStrLn $ "[roux] convert-to-cooklang failed: " <> T.unpack errMsg
|
|
return $
|
|
Left $
|
|
Html.ImportError $
|
|
"Could not convert recipe: " <> errMsg
|
|
ExitSuccess -> do
|
|
-- Step 4: parse the Cooklang output to extract the title
|
|
let rawCooklang = TE.decodeUtf8 (LB.toStrict stdoutBytes)
|
|
cooklangText = case mSourceUrl of
|
|
Just url -> injectSourceUrl rawCooklang url
|
|
Nothing -> rawCooklang
|
|
case parseRecipeTitle cooklangText of
|
|
Nothing -> do
|
|
putStrLn "[roux] could not extract title from Cooklang output"
|
|
return $
|
|
Left $
|
|
Html.ImportError
|
|
"Could not determine recipe title from AI output."
|
|
Just title -> do
|
|
let filename = CooklangPrint.filenameFromTitle title
|
|
filepath = recipeDir </> filename
|
|
-- Step 5: write the .cook file
|
|
putStrLn $ "[roux] writing imported recipe to " <> filepath
|
|
BS.writeFile filepath (TE.encodeUtf8 cooklangText)
|
|
putStrLn $ "[roux] successfully imported recipe as " <> filename
|
|
return $ Right filename
|
|
|
|
-- | Locate the convert-to-cooklang script.
|
|
findConvertScript :: IO FilePath
|
|
findConvertScript = do
|
|
mPath <- findExecutable "convert-to-cooklang"
|
|
case mPath of
|
|
Just p -> pure p
|
|
Nothing ->
|
|
ioError $
|
|
userError $
|
|
"convert-to-cooklang not found on PATH. "
|
|
<> "Install it to /usr/local/bin/convert-to-cooklang or add it to PATH."
|
|
|
|
-- | Write a temporary YAML config for the subprocess.
|
|
writeConvertConfig :: FilePath -> AnthropicConfig -> IO ()
|
|
writeConvertConfig path ac = do
|
|
let pairs =
|
|
[ "anthropic:"
|
|
, " api_key: " <> T.unpack (acApiKey ac)
|
|
]
|
|
++ case acBaseUrl ac of
|
|
Just url -> [" base_url: " <> T.unpack url]
|
|
Nothing -> []
|
|
++ case acModel ac of
|
|
Just m -> [" model: " <> T.unpack m]
|
|
Nothing -> []
|
|
writeFile path (unlines pairs)
|
|
|
|
{- | Insert or update the 'source:' field in Cooklang YAML front matter.
|
|
If a source field already exists, its value is replaced. Otherwise a new
|
|
source line is added before the closing '---'.
|
|
-}
|
|
injectSourceUrl :: Text -> Text -> Text
|
|
injectSourceUrl cooklang url =
|
|
case T.breakOn "---" cooklang of
|
|
(before, rest) ->
|
|
let afterFirst = T.drop 3 rest
|
|
(frontMatter, afterSecond) = T.breakOn "---" afterFirst
|
|
updated =
|
|
let (beforeSource, afterSource) = T.breakOn "source:" frontMatter
|
|
in if T.null afterSource
|
|
-- No existing source field: append one
|
|
then
|
|
let trimmed = T.stripEnd frontMatter
|
|
in trimmed <> "\nsource: " <> url <> "\n"
|
|
-- Existing source field: replace its value
|
|
else
|
|
let restOfLine = T.drop 1 (T.dropWhile (/= '\n') afterSource)
|
|
in beforeSource <> "source: " <> url <> restOfLine
|
|
in before <> "---" <> updated <> "---" <> afterSecond
|
|
|
|
{- | Parse the recipe title from Cooklang front matter.
|
|
Looks for "title: ..." in the YAML front matter block.
|
|
-}
|
|
parseRecipeTitle :: Text -> Maybe Text
|
|
parseRecipeTitle cooklang =
|
|
case T.breakOn "---" cooklang of
|
|
(_, afterFirst) ->
|
|
let (_separator, inside) = T.breakOn "---" (T.drop 3 afterFirst)
|
|
frontMatter = T.take (T.length inside) (T.drop 3 afterFirst)
|
|
in case T.breakOn "title:" frontMatter of
|
|
(_, afterTitle) ->
|
|
let titleLine = T.takeWhile (/= '\n') (T.drop 6 afterTitle)
|
|
cleaned = T.strip titleLine
|
|
in if T.null cleaned then Nothing else Just cleaned
|
|
|
|
{- | Watch a directory for .cook file changes and update the shared state.
|
|
Uses watchDir callback for simplicity.
|
|
-}
|
|
watchRecipes :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> IO ()
|
|
watchRecipes dir state changeLog = do
|
|
putStrLn $ "[roux] watching " <> dir <> " for changes"
|
|
let cfg = defaultConfig{confWatchMode = WatchModeOS}
|
|
withManagerConf cfg $ \mgr -> do
|
|
_stopListening <- watchDir mgr dir (const True) $ \ev ->
|
|
catch
|
|
( do
|
|
when (eventIsDirectory ev == IsFile && ".cook" `isSuffixOf` eventPath ev) $ do
|
|
let path = makeRelative dir (eventPath ev)
|
|
reReadRecipe dir path state changeLog
|
|
)
|
|
( \e ->
|
|
putStrLn $
|
|
"[roux] watchDir callback error: " <> show (e :: SomeException)
|
|
)
|
|
-- Keep the thread alive (watchDir runs the callback on the manager thread)
|
|
forever $ threadDelay 1_000_000
|
|
|
|
{- | Re-read a single recipe file and update the shared state.
|
|
On read/parse errors, logs the error and keeps the previous version.
|
|
-}
|
|
reReadRecipe :: FilePath -> FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> IO ()
|
|
reReadRecipe dir path state changeLog = do
|
|
let key = map toLower (stripCookExt path)
|
|
let fullPath = dir </> path
|
|
result <- try (doesFileExist fullPath)
|
|
case result of
|
|
Left (_ :: IOException) -> do
|
|
putStrLn $ "[roux] IO error checking " <> path <> ", skipping"
|
|
Right False -> do
|
|
atomicModifyIORef' state $ \m ->
|
|
(Map.delete key m, ())
|
|
recordChange changeLog path
|
|
putStrLn $ "[roux] removed " <> path
|
|
Right True -> do
|
|
content <- try (Idx.loadRecipe dir path)
|
|
case content of
|
|
Left (_ :: IOException) -> do
|
|
putStrLn $ "[roux] IO error reading " <> path <> ", keeping previous version"
|
|
Right info -> case Idx.riRecipe info of
|
|
Left err -> do
|
|
putStrLn $ "[roux] parse error in " <> path <> ": " <> err
|
|
putStrLn "[roux] keeping previous version"
|
|
Right _ -> do
|
|
atomicModifyIORef' state $ \m ->
|
|
(Map.insert key info m, ())
|
|
recordChange changeLog path
|
|
putStrLn $ "[roux] updated " <> path
|
|
|
|
-- | Route an incoming request to the appropriate handler.
|
|
handleRequest :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> SQL.Connection -> Wai.Application
|
|
handleRequest recipeDir state db request respond = do
|
|
recipes <- readIORef state
|
|
let recipeList = Map.elems recipes
|
|
case Wai.pathInfo request of
|
|
-- Landing page (root)
|
|
[] -> do
|
|
allEntries <- CookLog.fetchAllEntries db
|
|
let okRecipes = filter (\r -> case Idx.riRecipe r of Right _ -> True; _ -> False) recipeList
|
|
-- Deduplicate cook entries by recipe filename, keep most recent
|
|
let deduped = deduplicateByRecipe allEntries
|
|
-- Look up recipe info for each recently cooked entry
|
|
lookupInfo e = case Map.lookup (map toLower (stripCookExt (T.unpack (CookLog.ceRecipeFilename e)))) recipes of
|
|
Just info -> Just (e, info)
|
|
Nothing -> Nothing
|
|
cookedWithInfo = mapMaybe lookupInfo (take 10 deduped)
|
|
-- Get recently added recipes (by modification time)
|
|
recentlyAdded <- getRecentlyAdded recipeDir okRecipes
|
|
respond $ htmlResponse (Html.landingPage okRecipes cookedWithInfo recentlyAdded)
|
|
-- Recipe index page
|
|
["recipes"] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipeList))
|
|
["recipes", "sorted", "tags"] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipeList))
|
|
["recipes", "sorted", "course"] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipeList))
|
|
-- Recipe detail page
|
|
["recipes", rawPath] ->
|
|
case lookupRecipe (urlDecodePath rawPath) recipes of
|
|
Just info -> do
|
|
let filename = T.pack (map toLower (stripCookExt (urlDecodePath rawPath)))
|
|
entries <- CookLog.fetchEntries db filename
|
|
respond (htmlResponse (Html.recipePage info entries))
|
|
Nothing -> respond notFound
|
|
-- Cook log JSON endpoint for a specific recipe
|
|
["cook-log", filename] -> do
|
|
entries <- CookLog.fetchEntries db (normalizeRecipeKey filename)
|
|
let body = A.encode entries
|
|
respond $
|
|
Wai.responseLBS
|
|
HTTP.status200
|
|
[("Content-Type", "application/json")]
|
|
body
|
|
-- Cook history page (all entries across recipes)
|
|
["cook-history"] -> do
|
|
entries <- CookLog.fetchAllEntries db
|
|
let grouped = groupEntries entries
|
|
recipeTitleLookup fn =
|
|
maybe
|
|
fn
|
|
Idx.riTitle
|
|
(Map.lookup (map toLower (stripCookExt (T.unpack fn))) recipes)
|
|
respond $ htmlResponse (Html.cookHistoryPage recipeTitleLookup grouped)
|
|
-- Cook log JSON endpoint for all entries
|
|
["cook-log"] -> do
|
|
entries <- CookLog.fetchAllEntries db
|
|
respond $
|
|
Wai.responseLBS
|
|
HTTP.status200
|
|
[("Content-Type", "application/json")]
|
|
(A.encode entries)
|
|
_ -> respond notFound
|
|
|
|
-- | Deduplicate cook entries by recipe filename, keeping the most recent entry per recipe.
|
|
deduplicateByRecipe :: [CookLog.CookEntry] -> [CookLog.CookEntry]
|
|
deduplicateByRecipe entries =
|
|
let seen = foldl' addIfNew [] entries
|
|
in reverse seen
|
|
where
|
|
addIfNew acc e =
|
|
if any ((== CookLog.ceRecipeFilename e) . CookLog.ceRecipeFilename) acc
|
|
then acc
|
|
else e : acc
|
|
|
|
-- | Get recently added recipes sorted by modification time (newest first).
|
|
getRecentlyAdded :: FilePath -> [Idx.RecipeInfo] -> IO [Idx.RecipeInfo]
|
|
getRecentlyAdded recipeDir recipes = do
|
|
times <-
|
|
mapM
|
|
( \r -> do
|
|
let filepath = recipeDir </> Idx.riFilename r
|
|
result <- try (getModificationTime filepath)
|
|
case result of
|
|
Left (_ :: IOException) -> pure Nothing
|
|
Right t -> pure (Just (r, t))
|
|
)
|
|
recipes
|
|
let valid = catMaybes times
|
|
sorted = sortOn (Down . snd) valid
|
|
pure (Prelude.map fst (take 10 sorted))
|
|
|
|
{- | Handle POST /cook-log/{filename}
|
|
| Normalize a recipe filename to match the recipe index key:
|
|
lowercased, with @.cook@ extension stripped.
|
|
-}
|
|
normalizeRecipeKey :: Text -> Text
|
|
normalizeRecipeKey = T.pack . map toLower . stripCookExt . T.unpack
|
|
|
|
handleCookLogPost :: SQL.Connection -> Text -> Wai.Application
|
|
handleCookLogPost db rawFilename request respond = do
|
|
let filename = normalizeRecipeKey rawFilename
|
|
body <- readRequestBody request
|
|
let params = parseFormBody body
|
|
dateStr = fromMaybe "" (lookup "cooked-date" params)
|
|
comment = lookup "comment" params
|
|
let doInsert d = do
|
|
_ <- CookLog.insertEntry db filename d comment
|
|
respond $
|
|
Wai.responseLBS
|
|
HTTP.status200
|
|
[("Content-Type", "application/json")]
|
|
(LB.fromStrict (encodeUtf8 "{\"ok\":true}"))
|
|
case parseDate dateStr of
|
|
Just d -> doInsert d
|
|
Nothing ->
|
|
if T.null dateStr
|
|
then getCurrentDay >>= doInsert
|
|
else
|
|
let resp =
|
|
Wai.responseLBS
|
|
HTTP.status400
|
|
[("Content-Type", "application/json")]
|
|
(LB.fromStrict (encodeUtf8 "{\"error\":\"Invalid date format\"}"))
|
|
in respond resp
|
|
|
|
-- | Get today's date.
|
|
getCurrentDay :: IO Day
|
|
getCurrentDay = utctDay <$> getCurrentTime
|
|
|
|
-- | Parse a YYYY-MM-DD date string.
|
|
parseDate :: Text -> Maybe Day
|
|
parseDate t = case T.splitOn "-" t of
|
|
[y, m, d] -> do
|
|
y' <- readMaybe (T.unpack y)
|
|
m' <- readMaybe (T.unpack m)
|
|
d' <- readMaybe (T.unpack d)
|
|
fromGregorianValid y' m' d'
|
|
_ -> Nothing
|
|
|
|
-- | Group entries by month label (e.g. "March 2026"), sorted newest-first.
|
|
groupEntries :: [CookLog.CookEntry] -> [(Text, [CookLog.CookEntry])]
|
|
groupEntries [] = []
|
|
groupEntries entries =
|
|
let monthKey d = T.pack $ Time.formatTime Time.defaultTimeLocale "%B %Y" d
|
|
groups = Map.fromListWith (++) [(monthKey (CookLog.ceCookedDate e), [e]) | e <- entries]
|
|
-- Sort month groups by the newest entry date in each group, descending.
|
|
-- Every group is non-empty by construction.
|
|
monthDate = foldl max (toEnum 0) . map CookLog.ceCookedDate
|
|
sortedMonths = map fst $ sortOn (Down . monthDate . snd) $ Map.toList groups
|
|
in [(m, Map.findWithDefault [] m groups) | m <- sortedMonths]
|
|
|
|
-- | Look up a recipe by filename (case-insensitive, .cook extension optional).
|
|
lookupRecipe :: FilePath -> Map FilePath Idx.RecipeInfo -> Maybe Idx.RecipeInfo
|
|
lookupRecipe target =
|
|
Map.lookup (map toLower (stripCookExt target))
|
|
|
|
-- | Strip the .cook extension if present.
|
|
stripCookExt :: FilePath -> FilePath
|
|
stripCookExt fp
|
|
| ".cook" `isSuffixOf` fp = take (length fp - 5) fp
|
|
| otherwise = fp
|
|
|
|
-- | Decode a percent-encoded URL path segment to a FilePath.
|
|
urlDecodePath :: Text -> FilePath
|
|
urlDecodePath = Html.urlDecode
|
|
|
|
-- | Build a 200 HTML response.
|
|
htmlResponse :: ByteString -> Wai.Response
|
|
htmlResponse =
|
|
Wai.responseLBS
|
|
HTTP.status200
|
|
[("Content-Type", "text/html; charset=utf-8")]
|
|
|
|
-- | 404 response.
|
|
notFound :: Wai.Response
|
|
notFound =
|
|
Wai.responseLBS
|
|
HTTP.status404
|
|
[("Content-Type", "text/plain")]
|
|
"Not found"
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Image upload and serving
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
{- | Handle image upload requests (POST only).
|
|
Expects form fields: filename (recipe .cook name), file-b64 (base64 content),
|
|
file-name (original filename for extension detection).
|
|
-}
|
|
uploadImageHandler :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> Wai.Application
|
|
uploadImageHandler recipeDir _state request respond =
|
|
case Wai.requestMethod request of
|
|
"POST" -> do
|
|
body <- readRequestBody request
|
|
let params = parseFormBody body
|
|
filename = fromMaybe "" (lookup "filename" params)
|
|
fileB64 = fromMaybe "" (lookup "file-b64" params)
|
|
originalName = fromMaybe "" (lookup "file-name" params)
|
|
if T.null filename
|
|
then respond (jsonError "Missing 'filename' parameter")
|
|
else
|
|
if T.null fileB64
|
|
then respond (jsonError "Missing 'file-b64' parameter")
|
|
else do
|
|
let basename = T.pack (stripCookExt (T.unpack filename))
|
|
ext = takeExtension (T.unpack originalName)
|
|
safeName =
|
|
map
|
|
(\c -> if isAlphaNum c || c == '.' || c == '-' || c == '_' then c else '_')
|
|
(T.unpack basename <> if null ext then ".jpg" else ext)
|
|
imageFilename = T.pack safeName
|
|
imagesDir = recipeDir </> "recipe-images"
|
|
filepath = imagesDir </> safeName
|
|
case decode (TE.encodeUtf8 fileB64) of
|
|
Left err -> respond (jsonError ("Base64 decode failed: " <> T.pack err))
|
|
Right content -> do
|
|
-- Ensure the recipe-images directory exists
|
|
createDirectoryIfMissing True imagesDir
|
|
-- Save the image file
|
|
BS.writeFile filepath content
|
|
putStrLn $ "[roux] saved recipe image to " <> filepath
|
|
-- Update the .cook file with image metadata
|
|
let cookPath = recipeDir </> T.unpack filename
|
|
imageUrl = "/recipe-images/" <> imageFilename
|
|
exists <- doesFileExist cookPath
|
|
if not exists
|
|
then respond (jsonError "Recipe file not found")
|
|
else do
|
|
updateImageMetadata cookPath imageUrl
|
|
putStrLn $ "[roux] updated image metadata for " <> T.unpack filename
|
|
respond (jsonOk [("image", imageUrl)])
|
|
_ -> respond notFound
|
|
|
|
-- | Serve a static recipe image file from the recipe directory.
|
|
serveRecipeImage :: FilePath -> FilePath -> Wai.Application
|
|
serveRecipeImage recipeDir filename _request respond = do
|
|
-- Security: only allow known image extensions
|
|
let ext = map toLower (takeExtension filename)
|
|
if ext `notElem` [".jpg", ".jpeg", ".png", ".gif", ".webp"]
|
|
then respond notFound
|
|
else do
|
|
let filepath = recipeDir </> "recipe-images" </> filename
|
|
exists <- doesFileExist filepath
|
|
if not exists
|
|
then respond notFound
|
|
else do
|
|
content <- BS.readFile filepath
|
|
let contentType = case ext of
|
|
".jpg" -> "image/jpeg"
|
|
".jpeg" -> "image/jpeg"
|
|
".png" -> "image/png"
|
|
".gif" -> "image/gif"
|
|
".webp" -> "image/webp"
|
|
_ -> "application/octet-stream"
|
|
respond $
|
|
Wai.responseLBS
|
|
HTTP.status200
|
|
[("Content-Type", contentType)]
|
|
(LB.fromStrict content)
|
|
|
|
{- | Update the @image:@ key in a recipe's YAML front matter.
|
|
If no front matter exists, one is created.
|
|
-}
|
|
updateImageMetadata :: FilePath -> Text -> IO ()
|
|
updateImageMetadata cookFilePath imageUrl = do
|
|
content <- BS.readFile cookFilePath
|
|
let text = TE.decodeUtf8 content
|
|
updated = case T.stripPrefix "---\n" text of
|
|
Just afterOpen ->
|
|
let (yamlBlock, rest) = T.breakOn "\n---" afterOpen
|
|
newYaml = addOrUpdateYamlKey "image" imageUrl yamlBlock
|
|
in "---\n" <> newYaml <> rest
|
|
Nothing ->
|
|
"---\nimage: " <> imageUrl <> "\n---\n\n" <> text
|
|
BS.writeFile cookFilePath (TE.encodeUtf8 updated)
|
|
|
|
-- | Update the title in a .cook file's YAML front matter.
|
|
updateTitleInCookFile :: FilePath -> Text -> IO ()
|
|
updateTitleInCookFile filepath newTitle = do
|
|
content <- BS.readFile filepath
|
|
let text = TE.decodeUtf8 content
|
|
updated = case T.stripPrefix "---\n" text of
|
|
Just afterOpen ->
|
|
let (yamlBlock, rest) = T.breakOn "\n---" afterOpen
|
|
newYaml = addOrUpdateYamlKey "title" newTitle yamlBlock
|
|
in "---\n" <> newYaml <> rest
|
|
Nothing ->
|
|
"---\ntitle: " <> newTitle <> "\n---\n\n" <> text
|
|
BS.writeFile filepath (TE.encodeUtf8 updated)
|
|
|
|
-- | Add or update a key: value line in a YAML block, preserving other keys.
|
|
addOrUpdateYamlKey :: Text -> Text -> Text -> Text
|
|
addOrUpdateYamlKey key value yaml =
|
|
let yamlLines = T.lines yaml
|
|
keyPrefix = key <> ":"
|
|
hasKey = any (\l -> keyPrefix `T.isPrefixOf` T.stripStart l) yamlLines
|
|
in if hasKey
|
|
then T.unlines (map (replaceKeyLine keyPrefix) yamlLines)
|
|
else yaml <> "\n" <> key <> ": " <> value
|
|
where
|
|
replaceKeyLine prefix line
|
|
| prefix `T.isPrefixOf` T.stripStart line = key <> ": " <> value
|
|
| otherwise = line
|
|
|
|
-- | Build a JSON OK response with a list of key-value pairs.
|
|
jsonOk :: [(Text, Text)] -> Wai.Response
|
|
jsonOk pairs =
|
|
let inner = T.intercalate ", " (map (\(k, v) -> "\"" <> k <> "\":\"" <> v <> "\"") pairs)
|
|
in Wai.responseLBS
|
|
HTTP.status200
|
|
[("Content-Type", "application/json")]
|
|
(LB.fromStrict (encodeUtf8 ("{" <> inner <> "}")))
|
|
|
|
-- | Build a JSON error response.
|
|
jsonError :: Text -> Wai.Response
|
|
jsonError msg =
|
|
Wai.responseLBS
|
|
HTTP.status400
|
|
[("Content-Type", "application/json")]
|
|
(LB.fromStrict (encodeUtf8 ("{\"error\":\"" <> msg <> "\"}")))
|
|
|
|
-- | Handle PATCH /recipes/{filename}/title -- update the recipe title.
|
|
titleUpdateHandler :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> Wai.Application
|
|
titleUpdateHandler recipeDir state request respond = do
|
|
recipes <- readIORef state
|
|
case Wai.pathInfo request of
|
|
["recipes", rawPath, "title"] -> do
|
|
let key = map toLower (stripCookExt (T.unpack rawPath))
|
|
case Map.lookup key recipes of
|
|
Nothing -> respond (jsonError "Recipe not found")
|
|
Just info -> do
|
|
body <- readRequestBody request
|
|
case A.decode body of
|
|
Nothing -> respond (jsonError "Invalid JSON body")
|
|
Just (val :: A.Value) ->
|
|
case titleFromJson val of
|
|
Nothing -> respond (jsonError "Missing 'title' field")
|
|
Just title -> do
|
|
let trimmed = T.strip title
|
|
if T.null trimmed
|
|
then respond (jsonError "Title cannot be empty")
|
|
else do
|
|
let filepath = recipeDir </> Idx.riFilename info
|
|
result <- try $ updateTitleInCookFile filepath trimmed
|
|
case result of
|
|
Left (_ :: IOException) ->
|
|
respond (jsonError "Could not update recipe file")
|
|
Right () ->
|
|
respond $
|
|
jsonOk
|
|
[ ("success", "true")
|
|
, ("title", trimmed)
|
|
]
|
|
_ -> respond notFound
|
|
where
|
|
titleFromJson :: A.Value -> Maybe Text
|
|
titleFromJson (A.Object obj) = case KM.lookup "title" obj of
|
|
Just (A.String t) -> Just t
|
|
_ -> Nothing
|
|
titleFromJson _ = Nothing
|