feat: wire up cook log DB at server startup
This commit is contained in:
+118
-5
@@ -14,6 +14,7 @@ module Roux.Html (
|
||||
importResultPage,
|
||||
indexPage,
|
||||
recipePage,
|
||||
cookHistoryPage,
|
||||
urlEncode,
|
||||
urlDecode,
|
||||
) where
|
||||
@@ -35,6 +36,9 @@ import Text.Blaze.Html5 as H
|
||||
import Text.Blaze.Html5.Attributes as A
|
||||
|
||||
import Data.CookLang
|
||||
import Data.Time.Calendar (Day)
|
||||
import qualified Data.Time.Format as Time
|
||||
import qualified Roux.CookLog as CookLog
|
||||
import qualified Roux.RecipeIndex as Idx
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -625,6 +629,54 @@ titleEditJs =
|
||||
, "})();"
|
||||
]
|
||||
|
||||
-- | Inline JavaScript for cook log form submission.
|
||||
cookLogJs :: Text
|
||||
cookLogJs =
|
||||
T.unlines
|
||||
[ "(function(){"
|
||||
, "'use strict';"
|
||||
, "var form = document.querySelector('.roux-cook-log-form');"
|
||||
, "if (!form) return;"
|
||||
, "var dateInput = form.querySelector('[name=cooked-date]');"
|
||||
, "if (dateInput && !dateInput.value) dateInput.value = new Date().toISOString().slice(0,10);"
|
||||
, "form.addEventListener('submit', async function(e) {"
|
||||
, " e.preventDefault();"
|
||||
, " var fd = new FormData(form);"
|
||||
, " var filename = form.dataset.filename;"
|
||||
, " var res = await fetch('/cook-log/' + encodeURIComponent(filename), {"
|
||||
, " method: 'POST',"
|
||||
, " body: fd"
|
||||
, " });"
|
||||
, " if (!res.ok) {"
|
||||
, " var err = await res.json();"
|
||||
, " alert(err.error || 'Failed to log cooking');"
|
||||
, " return;"
|
||||
, " }"
|
||||
, " var date = fd.get('cooked-date');"
|
||||
, " var comment = fd.get('comment');"
|
||||
, " var entryDiv = document.createElement('div');"
|
||||
, " entryDiv.style.margin = '0.3rem 0';"
|
||||
, " entryDiv.style.fontSize = '0.9rem';"
|
||||
, " var dateLine = document.createElement('strong');"
|
||||
, " dateLine.style.display = 'block';"
|
||||
, " dateLine.style.textAlign = 'center';"
|
||||
, " dateLine.style.color = 'var(--muted)';"
|
||||
, " dateLine.textContent = '\x2014\x2014 ' + date + ' \x2014\x2014';"
|
||||
, " entryDiv.appendChild(dateLine);"
|
||||
, " if (comment) {"
|
||||
, " var commentSpan = document.createElement('span');"
|
||||
, " commentSpan.style.display = 'block';"
|
||||
, " commentSpan.style.marginTop = '0.2rem';"
|
||||
, " commentSpan.textContent = comment;"
|
||||
, " entryDiv.appendChild(commentSpan);"
|
||||
, " }"
|
||||
, " var container = form.nextElementSibling;"
|
||||
, " if (container) container.prepend(entryDiv);"
|
||||
, " form.querySelector('[name=comment]').value = '';"
|
||||
, "});"
|
||||
, "})();"
|
||||
]
|
||||
|
||||
sseIndexJs :: Text
|
||||
sseIndexJs =
|
||||
T.unlines
|
||||
@@ -766,8 +818,8 @@ importResultPage (ImportError msg) =
|
||||
H.h2 "Import failed"
|
||||
H.p $ H.toHtml msg
|
||||
|
||||
recipePage :: Idx.RecipeInfo -> ByteString
|
||||
recipePage info =
|
||||
recipePage :: Idx.RecipeInfo -> [CookLog.CookEntry] -> ByteString
|
||||
recipePage info entries =
|
||||
case Idx.riRecipe info of
|
||||
Left err ->
|
||||
page "Parse Error" $ do
|
||||
@@ -778,7 +830,7 @@ recipePage info =
|
||||
sseScript
|
||||
Right recipe ->
|
||||
page (titleText recipe) $ do
|
||||
renderRecipe (Idx.riFilename info) recipe
|
||||
renderRecipe (Idx.riFilename info) recipe entries
|
||||
hiddenRecipeSpan info
|
||||
sseScript
|
||||
where
|
||||
@@ -790,10 +842,41 @@ recipePage info =
|
||||
H.script ! A.type_ "text/javascript" $ H.preEscapedText timerJs
|
||||
H.script ! A.type_ "text/javascript" $ H.preEscapedText recipeImageUploadJs
|
||||
H.script ! A.type_ "text/javascript" $ H.preEscapedText titleEditJs
|
||||
H.script ! A.type_ "text/javascript" $ H.preEscapedText cookLogJs
|
||||
|
||||
-- | Render the cook log section in the marginalia column.
|
||||
renderCookLog :: FilePath -> [CookLog.CookEntry] -> Html
|
||||
renderCookLog _ [] = pure ()
|
||||
renderCookLog filename entries = do
|
||||
H.h2 "Cook history"
|
||||
H.form
|
||||
! A.class_ "roux-cook-log-form"
|
||||
! H.dataAttribute "filename" (H.toValue (T.pack filename))
|
||||
! A.style "display: flex; gap: 0.3rem; margin-bottom: 0.5rem;" $ do
|
||||
H.input ! A.type_ "date" ! A.name "cooked-date"
|
||||
H.input ! A.type_ "text" ! A.name "comment" ! A.placeholder "Optional note..."
|
||||
! A.maxlength "500" ! A.style "flex: 1;"
|
||||
H.button ! A.type_ "submit" $ "Log it"
|
||||
H.div ! A.class_ "roux-cook-entries" $ do
|
||||
mapM_ renderEntry entries
|
||||
|
||||
-- | Render a single cook log entry.
|
||||
renderEntry :: CookLog.CookEntry -> Html
|
||||
renderEntry entry = do
|
||||
H.p ! A.style "margin: 0.3rem 0; font-size: 0.9rem;" $ do
|
||||
H.strong ! A.style "display: block; text-align: center; color: var(--muted);" $
|
||||
H.toHtml ("\x2014\x2014 " <> formatDay (CookLog.ceCookedDate entry) <> " \x2014\x2014")
|
||||
unless (T.null (CookLog.ceComment entry)) $
|
||||
H.span ! A.style "display: block; margin-top: 0.2rem;" $
|
||||
H.toHtml (CookLog.ceComment entry)
|
||||
|
||||
-- | Format a Day as "Mon DD" e.g. "Mar 15".
|
||||
formatDay :: Day -> Text
|
||||
formatDay = T.pack . Time.formatTime Time.defaultTimeLocale "%b %e"
|
||||
|
||||
-- | Render the body of a recipe page (no page shell).
|
||||
renderRecipe :: FilePath -> Recipe -> Html
|
||||
renderRecipe filename recipe = do
|
||||
renderRecipe :: FilePath -> Recipe -> [CookLog.CookEntry] -> Html
|
||||
renderRecipe filename recipe entries = do
|
||||
H.nav ! A.class_ "roux-navbar" $ do
|
||||
H.ul $ H.li $ H.a ! A.class_ "roux-logo" ! A.href "/" $ "Roux"
|
||||
H.ul $ H.li $ H.a ! A.href "/" $ "Recipes"
|
||||
@@ -847,6 +930,7 @@ renderRecipe filename recipe = do
|
||||
H.div ! A.class_ "note" $ do
|
||||
H.h2 "Tags"
|
||||
H.p $ H.toHtml (T.intercalate " · " recipeTags)
|
||||
renderCookLog filename entries
|
||||
unless (null notes) $ do
|
||||
H.h2 "Notes"
|
||||
mapM_ renderMarginalNote notes
|
||||
@@ -1112,3 +1196,32 @@ isLeft _ = False
|
||||
-- | Check if an 'Either' is 'Right'.
|
||||
isRight :: Either a b -> Bool
|
||||
isRight = not . isLeft
|
||||
|
||||
-- | Render the cook history page (all entries across recipes).
|
||||
cookHistoryPage :: [(Text, [CookLog.CookEntry])] -> ByteString
|
||||
cookHistoryPage grouped =
|
||||
page "Cook History" $ do
|
||||
H.nav ! A.class_ "roux-navbar" $ do
|
||||
H.ul $ H.li $ H.a ! A.class_ "roux-logo" ! A.href "/" $ "Roux"
|
||||
H.ul $ do
|
||||
H.li $ H.a ! A.href "/" $ "Recipes"
|
||||
H.li $ H.a ! A.class_ "active" ! A.href "/cook-history" $ "Cook History"
|
||||
H.h1 "Cook History"
|
||||
mapM_ renderMonthGroup grouped
|
||||
|
||||
renderMonthGroup :: (Text, [CookLog.CookEntry]) -> Html
|
||||
renderMonthGroup (month, entries) = do
|
||||
H.h2 $ H.toHtml month
|
||||
H.ul ! A.style "list-style: none; padding: 0;" $ do
|
||||
mapM_ renderHistoryEntry entries
|
||||
|
||||
renderHistoryEntry :: CookLog.CookEntry -> Html
|
||||
renderHistoryEntry entry = do
|
||||
H.li ! A.style "margin-bottom: 0.5rem;" $ do
|
||||
H.strong $ H.toHtml (formatDay (CookLog.ceCookedDate entry))
|
||||
" — "
|
||||
H.a ! A.href (H.toValue ("/recipes/" <> CookLog.ceRecipeFilename entry <> ".cook")) $
|
||||
H.toHtml (CookLog.ceRecipeFilename entry)
|
||||
unless (T.null (CookLog.ceComment entry)) $ do
|
||||
H.br
|
||||
H.span $ H.toHtml (CookLog.ceComment entry)
|
||||
|
||||
+78
-7
@@ -47,8 +47,13 @@ import qualified Data.Aeson.KeyMap as KM
|
||||
import qualified Data.ByteString as BS
|
||||
import Data.Maybe (fromMaybe, mapMaybe)
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import qualified Database.SQLite.Simple as SQL
|
||||
import Data.Time.Calendar (Day, fromGregorianValid)
|
||||
import qualified Data.Time.Format as Time
|
||||
import Text.Read (readMaybe)
|
||||
import Roux.Config (AnthropicConfig (..), RouxConfig (..))
|
||||
import qualified Roux.CooklangPrint as CooklangPrint
|
||||
import qualified Roux.CookLog as CookLog
|
||||
import qualified Roux.Html as Html
|
||||
import qualified Roux.RecipeIndex as Idx
|
||||
import Roux.SchemaOrg (SchemaOrgRecipe (..), parseSchemaOrgRecipe, schemaOrgToCooklang)
|
||||
@@ -95,7 +100,11 @@ app recipeDir config = do
|
||||
forkIO $
|
||||
watchRecipes absDir state changeLog `catch` \e ->
|
||||
putStrLn $ "[roux] watcher thread crashed: " <> show (e :: SomeException)
|
||||
pure (router absDir config state changeLog)
|
||||
-- Initialize cook log database
|
||||
let dbPath = recipeDir </> "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
|
||||
@@ -177,8 +186,8 @@ sseHandler changeLog _request respond = do
|
||||
go `finally` pure ()
|
||||
|
||||
-- | Route requests — SSE endpoint, recipe pages, or import.
|
||||
router :: FilePath -> RouxConfig -> IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> Wai.Application
|
||||
router recipeDir config state changeLog request respond =
|
||||
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
|
||||
@@ -187,7 +196,16 @@ router recipeDir config state changeLog request respond =
|
||||
["recipes", _, "title"]
|
||||
| Wai.requestMethod request == "PATCH" ->
|
||||
titleUpdateHandler recipeDir state request respond
|
||||
_ -> handleRequest state request respond
|
||||
pathInfo
|
||||
| Just filename <- dispatchLogPost pathInfo
|
||||
, Wai.requestMethod request == "POST" ->
|
||||
handleCookLogPost db filename request respond
|
||||
_ -> handleRequest 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.
|
||||
@@ -604,8 +622,8 @@ reReadRecipe dir path state changeLog = do
|
||||
putStrLn $ "[roux] updated " <> path
|
||||
|
||||
-- | Route an incoming request to the appropriate handler.
|
||||
handleRequest :: IORef (Map FilePath Idx.RecipeInfo) -> Wai.Application
|
||||
handleRequest state request respond = do
|
||||
handleRequest :: IORef (Map FilePath Idx.RecipeInfo) -> SQL.Connection -> Wai.Application
|
||||
handleRequest state db request respond = do
|
||||
recipes <- readIORef state
|
||||
let recipeList = Map.elems recipes
|
||||
case Wai.pathInfo request of
|
||||
@@ -618,10 +636,63 @@ handleRequest state request respond = do
|
||||
-- Recipe detail page
|
||||
["recipes", rawPath] ->
|
||||
case lookupRecipe (urlDecodePath rawPath) recipes of
|
||||
Just info -> respond (htmlResponse (Html.recipePage info))
|
||||
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 filename
|
||||
let body = A.encode entries
|
||||
respond $ Wai.responseLBS HTTP.status200
|
||||
[("Content-Type", "application/json")]
|
||||
body
|
||||
-- Cook history page (all entries across recipes)
|
||||
["cook-log"] -> do
|
||||
entries <- CookLog.fetchAllEntries db
|
||||
let grouped = groupEntries entries
|
||||
respond $ htmlResponse (Html.cookHistoryPage grouped)
|
||||
_ -> respond notFound
|
||||
|
||||
-- | Handle POST /cook-log/{filename}
|
||||
handleCookLogPost :: SQL.Connection -> Text -> Wai.Application
|
||||
handleCookLogPost db filename request respond = do
|
||||
body <- readRequestBody request
|
||||
let params = parseFormBody body
|
||||
dateStr = fromMaybe "" (lookup "cooked-date" params)
|
||||
comment = lookup "comment" params
|
||||
case parseDate dateStr of
|
||||
Just day -> do
|
||||
_ <- CookLog.insertEntry db filename day comment
|
||||
let resp = Wai.responseLBS HTTP.status200 [("Content-Type", "application/json")]
|
||||
(LB.fromStrict (encodeUtf8 "{\"ok\":true}"))
|
||||
respond resp
|
||||
Nothing ->
|
||||
let resp = Wai.responseLBS HTTP.status400
|
||||
[("Content-Type", "application/json")]
|
||||
(LB.fromStrict (encodeUtf8 "{\"error\":\"Invalid date format\"}"))
|
||||
in respond resp
|
||||
|
||||
-- | 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").
|
||||
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]
|
||||
sortedMonths = reverse (Map.keys 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 =
|
||||
|
||||
Reference in New Issue
Block a user