Compare commits
27
Commits
22867bc229
..
v0.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94ede9a168 | ||
|
|
c38574e005 | ||
|
|
700d53fbb0 | ||
|
|
c5c11ed181 | ||
|
|
36c9b880a7 | ||
|
|
c25ead672b | ||
|
|
4a6e3b2454 | ||
|
|
b739294638 | ||
|
|
27f5b8e23c | ||
|
|
48eb27e218 | ||
|
|
94554ad6e4 | ||
|
|
1ccde9e5fc | ||
|
|
999c9e8c9e | ||
|
|
eff74cecb4 | ||
|
|
e9934276d5 | ||
|
|
72ffa08e18 | ||
|
|
2b85c0fa93 | ||
|
|
05943c902e | ||
|
|
0bf734730b | ||
|
|
f2a136d27c | ||
|
|
85bbc8516a | ||
|
|
ac8e8a404a | ||
|
|
fa60291779 | ||
|
|
dbea1c549f | ||
|
|
82d8ce63d8 | ||
|
|
67127fd28d | ||
|
|
368bbb0124 |
@@ -0,0 +1,27 @@
|
|||||||
|
name: build
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
container:
|
||||||
|
image: node:24-bookworm
|
||||||
|
env:
|
||||||
|
USER: node
|
||||||
|
HOME: /home/node
|
||||||
|
NIX_CONFIG: experimental-features = nix-command flakes
|
||||||
|
volumes:
|
||||||
|
- /var/lib/pbri/docker/nix_runner_nix:/nix
|
||||||
|
- /var/lib/pbri/docker/nix_runner_etc:/etc/nix
|
||||||
|
- /var/lib/pbri/docker/nix_runner_home_node:/home/node
|
||||||
|
options: --user 1000:1000
|
||||||
|
steps:
|
||||||
|
- name: Prep nix env
|
||||||
|
run: |
|
||||||
|
if [ ! -f ~/.nix-profile/etc/profile.d/nix.sh ]; then
|
||||||
|
curl -L https://nixos.org/nix/install | bash -s -- --no-daemon
|
||||||
|
fi
|
||||||
|
. ~/.nix-profile/etc/profile.d/nix.sh
|
||||||
|
env >> "$GITHUB_ENV"
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- run: nix build --log-format raw --print-build-logs .#yore
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: deliver
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
jobs:
|
||||||
|
deliver:
|
||||||
|
container:
|
||||||
|
image: node:24-bookworm
|
||||||
|
env:
|
||||||
|
USER: node
|
||||||
|
HOME: /home/node
|
||||||
|
NIX_CONFIG: experimental-features = nix-command flakes
|
||||||
|
volumes:
|
||||||
|
- /var/lib/pbri/docker/nix_runner_nix:/nix
|
||||||
|
- /var/lib/pbri/docker/nix_runner_etc:/etc/nix
|
||||||
|
- /var/lib/pbri/docker/nix_runner_home_node:/home/node
|
||||||
|
options: --user 1000:1000
|
||||||
|
steps:
|
||||||
|
- name: Prep nix env
|
||||||
|
run: |
|
||||||
|
if [ ! -f ~/.nix-profile/etc/profile.d/nix.sh ]; then
|
||||||
|
curl -L https://nixos.org/nix/install | bash -s -- --no-daemon
|
||||||
|
fi
|
||||||
|
. ~/.nix-profile/etc/profile.d/nix.sh
|
||||||
|
env >> "$GITHUB_ENV"
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Get image meta
|
||||||
|
run: nix build .#yore-meta -o /tmp/yore-meta
|
||||||
|
- name: Version check
|
||||||
|
run: |
|
||||||
|
VERSION=$(cat /tmp/yore-meta/version)
|
||||||
|
[ "$GITHUB_REF_NAME" = v"$VERSION" ]
|
||||||
|
- run: nix build --log-format raw --print-build-logs .#image -o image.tar.gz
|
||||||
|
- run: gunzip -c image.tar.gz > image.tar
|
||||||
|
- run: nix run .#crane -- auth login git.pbrinkmeier.de -u paul -p "$PASSWORD"
|
||||||
|
env:
|
||||||
|
PASSWORD: "${{ secrets.REGISTRY_PASSWORD }}"
|
||||||
|
- run: nix run .#crane -- push image.tar $(cat /tmp/yore-meta/image-tag)
|
||||||
@@ -25,7 +25,7 @@ You can also use it to run a database for development by choosing a `$COMMAND` t
|
|||||||
```
|
```
|
||||||
BASE_DIR=./pgdata scripts/with-db psql "dbname=yore-test port=5433 user=yore-test"
|
BASE_DIR=./pgdata scripts/with-db psql "dbname=yore-test port=5433 user=yore-test"
|
||||||
# cabal repl with env vars set
|
# cabal repl with env vars set
|
||||||
BASE_DIR=./pgdata scripts/with-db scripts/dev
|
BASE_DIR=./pgdata scripts/with-db cabal run
|
||||||
```
|
```
|
||||||
|
|
||||||
By setting `$BASE_DIR` you can persist the database for later runs.
|
By setting `$BASE_DIR` you can persist the database for later runs.
|
||||||
@@ -41,6 +41,10 @@ By setting `$BASE_DIR` you can persist the database for later runs.
|
|||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
- CI
|
- [x] CI
|
||||||
- Docker container (in flake)
|
- [x] Docker container (in flake)
|
||||||
- try fourmolu
|
- [x] try fourmolu
|
||||||
|
- [x] Test leap second handling in the `time` package
|
||||||
|
- [ ] Fix container shutdown delay
|
||||||
|
- [ ] Fix `responseFile` always returning 200
|
||||||
|
- [ ] use queue to sync logging of indexer and main thread
|
||||||
|
|||||||
+123
-68
@@ -10,28 +10,27 @@
|
|||||||
|
|
||||||
module Main (main) where
|
module Main (main) where
|
||||||
|
|
||||||
|
import Control.Concurrent (forkIO)
|
||||||
|
import Control.Monad (forM, forM_)
|
||||||
import Control.Monad.IO.Class (liftIO)
|
import Control.Monad.IO.Class (liftIO)
|
||||||
import Control.Monad.Trans.Except (ExceptT (..), runExceptT)
|
import Control.Monad.Trans.Except (ExceptT (..), catchE, runExceptT)
|
||||||
|
import Data.Bifunctor (Bifunctor (..))
|
||||||
import Data.Proxy (Proxy (..))
|
import Data.Proxy (Proxy (..))
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import Data.Time (Day, toGregorian)
|
import Data.Time (Day, LocalTime (..), TimeOfDay (..), ZonedTime (..), addDays, nominalDiffTimeToSeconds, toGregorian)
|
||||||
import GHC.Generics (Generic)
|
import GHC.Generics (Generic)
|
||||||
import Network.HTTP.Media ((//), (/:))
|
import Network.HTTP.Types (Status (..))
|
||||||
import Network.HTTP.Types (Status (..), mkStatus, status200)
|
import Network.Wai (Middleware, Request (..), responseStatus)
|
||||||
import Servant
|
import Servant
|
||||||
( Accept (..)
|
( Capture
|
||||||
, Capture
|
|
||||||
, Get
|
, Get
|
||||||
, Handler
|
, Handler
|
||||||
, MimeRender (..)
|
, MimeRender (..)
|
||||||
, Raw
|
|
||||||
, ServerError (..)
|
, ServerError (..)
|
||||||
, ServerT
|
, ServerT
|
||||||
, Tagged (..)
|
|
||||||
, err404
|
, err404
|
||||||
, err500
|
, err500
|
||||||
, hoistServer
|
, hoistServer
|
||||||
, runHandler
|
|
||||||
, serve
|
, serve
|
||||||
, (:<|>) (..)
|
, (:<|>) (..)
|
||||||
, (:>)
|
, (:>)
|
||||||
@@ -48,25 +47,25 @@ import qualified Data.ByteString.Lazy as LBS
|
|||||||
import qualified Data.Text as Text
|
import qualified Data.Text as Text
|
||||||
import qualified Data.Text.Encoding as Encoding
|
import qualified Data.Text.Encoding as Encoding
|
||||||
import qualified Database.PostgreSQL.Opium as Opium
|
import qualified Database.PostgreSQL.Opium as Opium
|
||||||
import Network.Wai (Application, Request (..), Response, responseFile, responseLBS)
|
|
||||||
import qualified Network.Wai.Handler.Warp as Warp
|
import qualified Network.Wai.Handler.Warp as Warp
|
||||||
import qualified Text.Blaze.Html5 as H
|
import qualified Text.Blaze.Html5 as H
|
||||||
import qualified Text.Blaze.Html5.Attributes as A
|
import qualified Text.Blaze.Html5.Attributes as A
|
||||||
|
|
||||||
import Envy (type (=@!), type (=@@), type (?))
|
import Envy (type (=@!), type (=@@), type (?))
|
||||||
import Yore.DB (DayFile (..))
|
import Yore.DB (DB, DayFile (..))
|
||||||
|
import Yore.Download (downloadInto)
|
||||||
import Yore.Error (Error (..))
|
import Yore.Error (Error (..))
|
||||||
|
import Yore.Schedule (schedule)
|
||||||
|
import Yore.Scrape (Issue (..), getIssuesByDay)
|
||||||
|
import Yore.Servant (GetSendfile, HTML, Sendfile (..))
|
||||||
import Yore.Time (addYears, getCurrentDay, getSecondsUntilMidnight)
|
import Yore.Time (addYears, getCurrentDay, getSecondsUntilMidnight)
|
||||||
|
|
||||||
|
import Data.Fixed (Pico)
|
||||||
|
import Data.Time.Clock.POSIX (getPOSIXTime)
|
||||||
import qualified Envy
|
import qualified Envy
|
||||||
import qualified Yore.DB as DB
|
import qualified Yore.DB as DB
|
||||||
import qualified Yore.Log as Log
|
import qualified Yore.Log as Log
|
||||||
|
import System.IO (hSetBuffering, BufferMode (..), stdout)
|
||||||
newtype ConnectionString = ConnectionString String
|
|
||||||
deriving (Show)
|
|
||||||
|
|
||||||
instance Envy.ReadEnvVar ConnectionString where
|
|
||||||
readEnvVar = fmap ConnectionString . Envy.readEnvVar
|
|
||||||
|
|
||||||
data ConfigT f = Config
|
data ConfigT f = Config
|
||||||
{ yorePort :: f =@@ Int ? 3000
|
{ yorePort :: f =@@ Int ? 3000
|
||||||
@@ -81,34 +80,69 @@ deriving instance Show Config
|
|||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = do
|
main = do
|
||||||
|
hSetBuffering stdout NoBuffering
|
||||||
|
|
||||||
cfg <-
|
cfg <-
|
||||||
Envy.load @ConfigT >>= \case
|
Envy.load @ConfigT >>= \case
|
||||||
Left err -> do
|
Left errs -> do
|
||||||
Log.error $ printf "failed to read config: %s" err
|
forM_ errs $ Log.error . printf "failed to read config: %s"
|
||||||
exitFailure
|
exitFailure
|
||||||
Right c ->
|
Right c ->
|
||||||
pure c
|
pure c
|
||||||
|
|
||||||
let
|
db <- DB.initDB cfg.yoreDb
|
||||||
|
|
||||||
|
_ <- runExceptT $ doIndex cfg db
|
||||||
|
|
||||||
|
_ <- forkIO $ runIndexer cfg db
|
||||||
|
runServer cfg db
|
||||||
|
|
||||||
|
runIndexer :: Config -> DB -> IO ()
|
||||||
|
runIndexer cfg db =
|
||||||
|
schedule shouldRunAt $ runExceptT $ doIndex cfg db
|
||||||
|
where
|
||||||
|
shouldRunAt zt = case localTimeOfDay $ zonedTimeToLocalTime zt of
|
||||||
|
TimeOfDay 3 0 _ -> True
|
||||||
|
_ -> False
|
||||||
|
|
||||||
|
doIndex :: Config -> DB -> ExceptT Error IO ()
|
||||||
|
doIndex cfg db =
|
||||||
|
catchE
|
||||||
|
( DB.withTransaction db $ \conn -> do
|
||||||
|
forM_ [0, 1, 2] $ \i -> do
|
||||||
|
dayThen <- getTodayWithOffset (-100) i
|
||||||
|
indexDay cfg dayThen conn
|
||||||
|
)
|
||||||
|
(Log.error . show)
|
||||||
|
|
||||||
|
runServer :: Config -> DB -> IO ()
|
||||||
|
runServer cfg db =
|
||||||
|
Warp.runSettings settings $ loggerMiddleware $ serve (Proxy @API) $ hoistServer (Proxy @API) nt $ server cfg db
|
||||||
|
where
|
||||||
settings =
|
settings =
|
||||||
foldr
|
foldr
|
||||||
($)
|
($)
|
||||||
Warp.defaultSettings
|
Warp.defaultSettings
|
||||||
[ Warp.setLogger logger
|
[ Warp.setPort cfg.yorePort
|
||||||
, Warp.setPort cfg.yorePort
|
|
||||||
, Warp.setBeforeMainLoop $ Log.info $ printf "listening on port %d" cfg.yorePort
|
, Warp.setBeforeMainLoop $ Log.info $ printf "listening on port %d" cfg.yorePort
|
||||||
, Warp.setOnException onException
|
, Warp.setOnException onException
|
||||||
]
|
]
|
||||||
|
|
||||||
db <- DB.initDB cfg.yoreDb
|
logger :: Request -> Status -> Pico -> IO ()
|
||||||
Warp.runSettings settings $ serve (Proxy @API) $ hoistServer (Proxy @API) nt $ server cfg db
|
logger req status s = do
|
||||||
where
|
Log.info $ printf "%d %s %ss" (statusCode status) (requestLine req) (formatMetric s)
|
||||||
logger req status _ = do
|
|
||||||
Log.info $
|
loggerMiddleware :: Middleware
|
||||||
printf
|
loggerMiddleware app req respond = do
|
||||||
"%d %s"
|
begin <- getPOSIXTime
|
||||||
(statusCode status)
|
app req $ \res -> do
|
||||||
(requestLine req)
|
rr <- respond res
|
||||||
|
end <- getPOSIXTime
|
||||||
|
logger req (responseStatus res) (nominalDiffTimeToSeconds (end - begin))
|
||||||
|
pure rr
|
||||||
|
|
||||||
|
onException mbReq ex = do
|
||||||
|
Log.error $ printf "unhandled exception%s: %s%s" (maybe "" ((" in " ++) . requestLine) mbReq) (show ex)
|
||||||
|
|
||||||
requestLine :: Request -> String
|
requestLine :: Request -> String
|
||||||
requestLine req =
|
requestLine req =
|
||||||
@@ -117,8 +151,17 @@ main = do
|
|||||||
(BS8.unpack $ requestMethod req)
|
(BS8.unpack $ requestMethod req)
|
||||||
(BS8.unpack $ rawPathInfo req <> rawQueryString req)
|
(BS8.unpack $ rawPathInfo req <> rawQueryString req)
|
||||||
|
|
||||||
onException mbReq ex = do
|
formatMetric :: Pico -> String
|
||||||
Log.error $ printf "unhandled exception%s: %s%s" (maybe "" ((" in " ++) . requestLine) mbReq) (show ex)
|
formatMetric x = go (-9) prefixes
|
||||||
|
where
|
||||||
|
prefixes = ["p", "n", "u", "m", "", "k", "M", "G"]
|
||||||
|
|
||||||
|
go :: Int -> [String] -> String
|
||||||
|
go _ [] = show x
|
||||||
|
go e (p : ps)
|
||||||
|
| x < 10 ^^ e =
|
||||||
|
show @Int (floor $ x * 10 ^^ (-e + 3)) ++ p
|
||||||
|
| otherwise = go (e + 3) ps
|
||||||
|
|
||||||
nt :: ExceptT Error IO a -> Handler a
|
nt :: ExceptT Error IO a -> Handler a
|
||||||
nt action = MkHandler $ do
|
nt action = MkHandler $ do
|
||||||
@@ -130,11 +173,6 @@ nt action = MkHandler $ do
|
|||||||
Right x ->
|
Right x ->
|
||||||
pure $ Right x
|
pure $ Right x
|
||||||
|
|
||||||
type API =
|
|
||||||
Get '[HTML] RootModel
|
|
||||||
:<|> "today" :> "issue" :> Capture "issue" Int :> Get '[HTML] RootModel
|
|
||||||
:<|> "api" :> "today" :> "issue" :> Capture "issue" Int :> "fz.pdf" :> Raw
|
|
||||||
|
|
||||||
toServerError :: Error -> ServerError
|
toServerError :: Error -> ServerError
|
||||||
toServerError = \case
|
toServerError = \case
|
||||||
ConnectionError msg ->
|
ConnectionError msg ->
|
||||||
@@ -149,39 +187,36 @@ toServerError = \case
|
|||||||
encodeUtf8LBS :: String -> LBS.ByteString
|
encodeUtf8LBS :: String -> LBS.ByteString
|
||||||
encodeUtf8LBS = LBS.fromStrict . Encoding.encodeUtf8 . Text.pack
|
encodeUtf8LBS = LBS.fromStrict . Encoding.encodeUtf8 . Text.pack
|
||||||
|
|
||||||
handlerToRaw :: ExceptT Error IO Response -> Tagged t Application
|
type API =
|
||||||
handlerToRaw handler = Tagged $ \_ respond -> do
|
Get '[HTML] RootModel
|
||||||
r <- runHandler $ nt handler
|
:<|> "today" :> "issue" :> Capture "issue" Int :> Get '[HTML] RootModel
|
||||||
case r of
|
:<|> "api" :> "today" :> "issue" :> Capture "issue" Int :> "fz.pdf" :> GetSendfile
|
||||||
Left e ->
|
|
||||||
respond $ responseLBS (mkStatus e.errHTTPCode $ BS8.pack e.errReasonPhrase) e.errHeaders e.errBody
|
|
||||||
Right response ->
|
|
||||||
respond response
|
|
||||||
|
|
||||||
server :: Config -> DB.DB -> ServerT API (ExceptT Error IO)
|
server :: Config -> DB -> ServerT API (ExceptT Error IO)
|
||||||
server cfg db = rootR :<|> todayR :<|> apiTodayR
|
server cfg db = rootR :<|> todayR :<|> apiTodayR
|
||||||
where
|
where
|
||||||
rootR = todayR 0
|
rootR = todayR 0
|
||||||
|
|
||||||
todayR issue = do
|
todayR issue = do
|
||||||
dateThen <- ExceptT get100YearsAgo
|
dateThen <- getTodayWithOffset (-100) 0
|
||||||
count <- ExceptT $ DB.withConn db $ DB.getNumberOfIssues dateThen
|
count <- DB.withConn db $ DB.getNumberOfIssues dateThen
|
||||||
dayFile <- ExceptT $ DB.withConn db $ DB.getDayFileByIssue dateThen issue
|
dayFile <- DB.withConn db $ DB.getDayFileByIssue dateThen issue
|
||||||
pure $ RootModel dateThen dayFile issue count
|
pure $ RootModel dateThen dayFile issue count
|
||||||
|
|
||||||
apiTodayR issue = handlerToRaw $ do
|
apiTodayR issue = do
|
||||||
dateThen <- ExceptT get100YearsAgo
|
dateThen <- getTodayWithOffset (-100) 0
|
||||||
dayFile <- ExceptT $ DB.withConn db $ DB.getDayFileByIssue dateThen issue
|
dayFile <- DB.withConn db $ DB.getDayFileByIssue dateThen issue
|
||||||
let fullPath = cfg.yoreDownloadDir </> dayFile.relative_path
|
let fullPath = cfg.yoreDownloadDir </> dayFile.relative_path
|
||||||
secondsUntilMidnight <- liftIO getSecondsUntilMidnight
|
secondsUntilMidnight <- liftIO getSecondsUntilMidnight
|
||||||
pure $
|
pure
|
||||||
responseFile
|
Sendfile
|
||||||
status200
|
{ headers =
|
||||||
[ ("content-type", "application/pdf")
|
[ ("content-type", "application/pdf")
|
||||||
, ("cache-control", BS8.pack $ printf "public, max-age=%d" secondsUntilMidnight)
|
, ("cache-control", BS8.pack $ printf "public, max-age=%d" secondsUntilMidnight)
|
||||||
]
|
]
|
||||||
fullPath
|
, path =
|
||||||
Nothing
|
fullPath
|
||||||
|
}
|
||||||
|
|
||||||
data RootModel = RootModel Day DB.DayFile Int Int
|
data RootModel = RootModel Day DB.DayFile Int Int
|
||||||
|
|
||||||
@@ -217,11 +252,31 @@ instance MimeRender HTML RootModel where
|
|||||||
|
|
||||||
-- Utils
|
-- Utils
|
||||||
|
|
||||||
data HTML
|
getTodayWithOffset :: Integer -> Integer -> ExceptT Error IO Day
|
||||||
|
getTodayWithOffset yearOffset dayOffset =
|
||||||
|
ExceptT $
|
||||||
|
first (GenericError . (Text.pack (printf "can't go back %d years and go forward %d days: " (-yearOffset) dayOffset) <>))
|
||||||
|
. addYears yearOffset
|
||||||
|
. addDays dayOffset
|
||||||
|
<$> getCurrentDay
|
||||||
|
|
||||||
instance Accept HTML where
|
indexDay :: Config -> Day -> Opium.Connection -> ExceptT Error IO ()
|
||||||
contentType _ = "text" // "html" /: ("charset", "utf-8")
|
indexDay cfg dayThen conn = do
|
||||||
|
-- Transaction-level lock released automatically after transaction
|
||||||
get100YearsAgo :: IO (Either Error Day)
|
DB.lift $ Opium.execute_ "SELECT pg_advisory_xact_lock(42);" conn
|
||||||
get100YearsAgo =
|
mbDi <- DB.readDayIndex dayThen conn
|
||||||
either (Left . GenericError . ("can't go back 100 years: " <>)) Right . addYears (-100) <$> getCurrentDay
|
case mbDi of
|
||||||
|
Just _ ->
|
||||||
|
Log.info $ printf "index for %s already exists." (show dayThen)
|
||||||
|
Nothing -> do
|
||||||
|
Log.info $ printf "scraping issues for %s" (show dayThen)
|
||||||
|
issues <- liftIO $ getIssuesByDay dayThen
|
||||||
|
paths <- forM issues $ \issue -> do
|
||||||
|
Log.info $ printf "downloading %s" issue.url
|
||||||
|
path <- downloadInto cfg.yoreDownloadDir issue.url
|
||||||
|
pure (issue.label, path)
|
||||||
|
Log.info "creating DB entries"
|
||||||
|
dayIndex <- DB.createDayIndex dayThen conn
|
||||||
|
forM_ paths $ \(text, url) ->
|
||||||
|
DB.createDayFile dayIndex.day_index_id text url conn
|
||||||
|
Log.info "done."
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
\restrict CAC30PXzQJosjH02Spia1vzj9Ui3LB1Cq2HI8r0A4eu33HP8ngxbbM2SzRfTj0c
|
||||||
|
|
||||||
|
-- Dumped from database version 17.6
|
||||||
|
-- Dumped by pg_dump version 17.6
|
||||||
|
|
||||||
SET statement_timeout = 0;
|
SET statement_timeout = 0;
|
||||||
SET lock_timeout = 0;
|
SET lock_timeout = 0;
|
||||||
SET idle_in_transaction_session_timeout = 0;
|
SET idle_in_transaction_session_timeout = 0;
|
||||||
@@ -124,6 +129,8 @@ ALTER TABLE ONLY yore.day_file
|
|||||||
-- PostgreSQL database dump complete
|
-- PostgreSQL database dump complete
|
||||||
--
|
--
|
||||||
|
|
||||||
|
\unrestrict CAC30PXzQJosjH02Spia1vzj9Ui3LB1Cq2HI8r0A4eu33HP8ngxbbM2SzRfTj0c
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Dbmate schema migrations
|
-- Dbmate schema migrations
|
||||||
|
|||||||
Generated
+7
-7
@@ -40,11 +40,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1752536923,
|
"lastModified": 1758518877,
|
||||||
"narHash": "sha256-fdgPZR7VFSSRIQKOJLcs3qCJBWM64Uak0gAGtWTYAd8=",
|
"narHash": "sha256-1K1c6vNr/N1PfM77DqKINXQ/oCxA0W1ZJ80sfNzWu0w=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "c665e4d918eda5d78a175ed8d300809c44932160",
|
"rev": "033cc13ab4c1a60b3527b242755aa625001ff1f9",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -64,11 +64,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1756064767,
|
"lastModified": 1759790870,
|
||||||
"narHash": "sha256-SaJK9VYc/oF5aBSMSW+We8TggER4+uTAHvc2ztnLpYo=",
|
"narHash": "sha256-rEsaO38v2wvAThPzgbYPgnv29mu6GxTYLjgci3GYaO4=",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"rev": "af16429d827816cf9bec69026e13f50acc3d2691",
|
"rev": "4db509c1b52ee7b5012abfef5e11e62beab27a16",
|
||||||
"revCount": 65,
|
"revCount": 66,
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.pbrinkmeier.de/paul/opium"
|
"url": "https://git.pbrinkmeier.de/paul/opium"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,11 +26,18 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
# For building the package, we use only the files not ignored by Git as inputs.
|
# For building the package, we use only the files not ignored by Git as inputs.
|
||||||
# Also, flake.nix and flake.lock are not included to avoid rebuilds when
|
# Also, flake.nix, flake.lock and .gitea are not included to avoid rebuilds when
|
||||||
# working on them.
|
# working on them.
|
||||||
src = pkgs.lib.cleanSourceWith {
|
src = pkgs.lib.cleanSourceWith {
|
||||||
src = gitignore.lib.gitignoreSource ./.;
|
src = ./.;
|
||||||
filter = path: type: builtins.elem (builtins.baseNameOf path) [ "flake.nix" "flake.lock" ];
|
filter = gitignore.lib.gitignoreFilterWith {
|
||||||
|
basePath = ./.;
|
||||||
|
extraRules = ''
|
||||||
|
flake.nix
|
||||||
|
flake.lock
|
||||||
|
.gitea
|
||||||
|
'';
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
yore = pkgs.lib.pipe
|
yore = pkgs.lib.pipe
|
||||||
@@ -51,16 +58,43 @@
|
|||||||
# Return only the bin folder to curb image size
|
# Return only the bin folder to curb image size
|
||||||
compose.justStaticExecutables
|
compose.justStaticExecutables
|
||||||
];
|
];
|
||||||
|
|
||||||
|
image =
|
||||||
|
pkgs.dockerTools.buildImage {
|
||||||
|
name = "git.pbrinkmeier.de/paul/yore";
|
||||||
|
tag = yore.version;
|
||||||
|
copyToRoot = [ pkgs.cacert ];
|
||||||
|
config = {
|
||||||
|
Cmd = [ "${entrypoint}/bin/run" ];
|
||||||
|
StopSignal = "SIGINT";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
entrypoint = pkgs.writeShellApplication {
|
||||||
|
name = "run";
|
||||||
|
runtimeInputs = [ pkgs.coreutils pkgs.dbmate yore ];
|
||||||
|
text = ''
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
mkdir -p /workspace/db
|
||||||
|
cp -r ${./db}/* /workspace/db
|
||||||
|
cd /workspace
|
||||||
|
dbmate up
|
||||||
|
|
||||||
|
# Replace bash and inherit PID 1
|
||||||
|
exec yore
|
||||||
|
'';
|
||||||
|
};
|
||||||
in {
|
in {
|
||||||
packages = rec {
|
packages = rec {
|
||||||
docker =
|
|
||||||
pkgs.dockerTools.buildImage {
|
|
||||||
name = "git.pbrinkmeier.de/paul/yore";
|
|
||||||
tag = yore.version;
|
|
||||||
config.Cmd = [ "${yore}/bin/yore" ];
|
|
||||||
};
|
|
||||||
|
|
||||||
default = yore;
|
default = yore;
|
||||||
|
inherit yore image;
|
||||||
|
yore-meta = pkgs.runCommand "yore-meta" {} ''
|
||||||
|
mkdir -p $out
|
||||||
|
echo -n ${yore.version} > $out/version
|
||||||
|
echo -n ${image.drvAttrs.imageName}:${image.drvAttrs.imageTag} > $out/image-tag
|
||||||
|
'';
|
||||||
|
|
||||||
|
|
||||||
opium_ = opium.packages.${system}.opium;
|
opium_ = opium.packages.${system}.opium;
|
||||||
};
|
};
|
||||||
@@ -82,5 +116,11 @@
|
|||||||
overrides = addOpium;
|
overrides = addOpium;
|
||||||
}).env;
|
}).env;
|
||||||
};
|
};
|
||||||
|
apps = {
|
||||||
|
crane = {
|
||||||
|
type = "app";
|
||||||
|
program = "${pkgs.crane}/bin/crane";
|
||||||
|
};
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
YORE_DB="host=localhost port=${DB_PORT} user=${DB_USER} dbname=${DB_DBNAME}" cabal repl exe:yore
|
|
||||||
@@ -26,4 +26,6 @@ fi
|
|||||||
|
|
||||||
DATABASE_URL="postgres://${DB_USER}@localhost:${DB_PORT}/${DB_DBNAME}?sslmode=disable" dbmate up
|
DATABASE_URL="postgres://${DB_USER}@localhost:${DB_PORT}/${DB_DBNAME}?sslmode=disable" dbmate up
|
||||||
|
|
||||||
|
export YORE_DB="host=localhost port=${DB_PORT} user=${DB_USER} dbname=${DB_DBNAME}"
|
||||||
|
|
||||||
"$@"
|
"$@"
|
||||||
|
|||||||
+12
-4
@@ -56,13 +56,13 @@ type t ? d = Optional t d
|
|||||||
load
|
load
|
||||||
:: forall (m :: ConfigVariant -> Type)
|
:: forall (m :: ConfigVariant -> Type)
|
||||||
. (Generic (m Value), GFromEnv (Rep (m Spec)) (Rep (m Value)))
|
. (Generic (m Value), GFromEnv (Rep (m Spec)) (Rep (m Value)))
|
||||||
=> IO (Either String (m Value))
|
=> IO (Either [String] (m Value))
|
||||||
load = do
|
load = do
|
||||||
env <- getEnvironment
|
env <- getEnvironment
|
||||||
pure $ to <$> gFromEnv @(Rep (m Spec)) @(Rep (m Value)) Proxy env
|
pure $ to <$> gFromEnv @(Rep (m Spec)) @(Rep (m Value)) Proxy env
|
||||||
|
|
||||||
class GFromEnv spec value where
|
class GFromEnv spec value where
|
||||||
gFromEnv :: Proxy (spec c) -> [(String, String)] -> Either String (value c)
|
gFromEnv :: Proxy (spec c) -> [(String, String)] -> Either [String] (value c)
|
||||||
|
|
||||||
instance
|
instance
|
||||||
(GFromEnv i o)
|
(GFromEnv i o)
|
||||||
@@ -77,7 +77,15 @@ instance
|
|||||||
gFromEnv Proxy env = M1 <$> gFromEnv @i @o Proxy env
|
gFromEnv Proxy env = M1 <$> gFromEnv @i @o Proxy env
|
||||||
|
|
||||||
instance (GFromEnv i1 o1, GFromEnv i2 o2) => GFromEnv (i1 :*: i2) (o1 :*: o2) where
|
instance (GFromEnv i1 o1, GFromEnv i2 o2) => GFromEnv (i1 :*: i2) (o1 :*: o2) where
|
||||||
gFromEnv Proxy env = (:*:) <$> gFromEnv @i1 @o1 Proxy env <*> gFromEnv @i2 @o2 Proxy env
|
gFromEnv Proxy env =
|
||||||
|
case (eLhs, eRhs) of
|
||||||
|
(Left errs1, Right _) -> Left errs1
|
||||||
|
(Right _, Left errs2) -> Left errs2
|
||||||
|
(Left errs1, Left errs2) -> Left $ errs1 ++ errs2
|
||||||
|
(Right lhs, Right rhs) -> Right $ lhs :*: rhs
|
||||||
|
where
|
||||||
|
eLhs = gFromEnv @i1 @o1 Proxy env
|
||||||
|
eRhs = gFromEnv @i2 @o2 Proxy env
|
||||||
|
|
||||||
instance
|
instance
|
||||||
(KnownSymbol sym, EnvVarSpec s t)
|
(KnownSymbol sym, EnvVarSpec s t)
|
||||||
@@ -86,7 +94,7 @@ instance
|
|||||||
(M1 S meta2 (Rec0 t))
|
(M1 S meta2 (Rec0 t))
|
||||||
where
|
where
|
||||||
gFromEnv Proxy env =
|
gFromEnv Proxy env =
|
||||||
M1 . K1 <$> decodeEnvVar @s @t Proxy varName (lookup varName env)
|
M1 . K1 <$> first (: []) (decodeEnvVar @s @t Proxy varName (lookup varName env))
|
||||||
where
|
where
|
||||||
varName = selectorNameToEnvVarName $ symbolVal $ Proxy @sym
|
varName = selectorNameToEnvVarName $ symbolVal $ Proxy @sym
|
||||||
|
|
||||||
|
|||||||
+49
-20
@@ -9,6 +9,7 @@ module Yore.DB
|
|||||||
, Error (..)
|
, Error (..)
|
||||||
, initDB
|
, initDB
|
||||||
, withConn
|
, withConn
|
||||||
|
, withTransaction
|
||||||
, DayIndex (..)
|
, DayIndex (..)
|
||||||
, DayFile (..)
|
, DayFile (..)
|
||||||
, createDayFile
|
, createDayFile
|
||||||
@@ -17,9 +18,11 @@ module Yore.DB
|
|||||||
, readDayPaths
|
, readDayPaths
|
||||||
, getDayFileByIssue
|
, getDayFileByIssue
|
||||||
, getNumberOfIssues
|
, getNumberOfIssues
|
||||||
|
, lift
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Control.Concurrent (getNumCapabilities)
|
import Control.Concurrent (getNumCapabilities)
|
||||||
|
import Control.Monad.Trans.Except (ExceptT (..), catchE, runExceptT, throwE)
|
||||||
import Data.Functor.Identity (Identity (..))
|
import Data.Functor.Identity (Identity (..))
|
||||||
import Data.Pool (Pool, defaultPoolConfig, newPool, withResource)
|
import Data.Pool (Pool, defaultPoolConfig, newPool, withResource)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
@@ -28,6 +31,7 @@ import GHC.Generics (Generic)
|
|||||||
|
|
||||||
import qualified Database.PostgreSQL.Opium as Opium
|
import qualified Database.PostgreSQL.Opium as Opium
|
||||||
|
|
||||||
|
import Data.Bifunctor (Bifunctor (..))
|
||||||
import Yore.Error (Error (..))
|
import Yore.Error (Error (..))
|
||||||
|
|
||||||
newtype DB = DB (Pool (Either Opium.ConnectionError Opium.Connection))
|
newtype DB = DB (Pool (Either Opium.ConnectionError Opium.Connection))
|
||||||
@@ -45,13 +49,35 @@ initDB connString = do
|
|||||||
maxResources
|
maxResources
|
||||||
)
|
)
|
||||||
|
|
||||||
withConn :: DB -> (Opium.Connection -> IO (Either Opium.Error a)) -> IO (Either Error a)
|
-- TODO: This should probably also do something like bracket...
|
||||||
|
-- For now let's assume no exceptions are thrown in f.
|
||||||
|
withConn :: DB -> (Opium.Connection -> ExceptT Error IO a) -> ExceptT Error IO a
|
||||||
withConn (DB connPool) f =
|
withConn (DB connPool) f =
|
||||||
withResource connPool $ \case
|
ExceptT $ withResource connPool $ \case
|
||||||
Left connectionError ->
|
Left connectionError ->
|
||||||
pure $ Left $ ConnectionError connectionError
|
pure $ Left $ ConnectionError connectionError
|
||||||
Right conn ->
|
Right conn ->
|
||||||
either (Left . DBError) Right <$> f conn
|
runExceptT $ f conn
|
||||||
|
|
||||||
|
withTransaction :: DB -> (Opium.Connection -> ExceptT Error IO a) -> ExceptT Error IO a
|
||||||
|
withTransaction db f =
|
||||||
|
withConn db $ \conn ->
|
||||||
|
( do
|
||||||
|
lift $ Opium.execute_ "BEGIN" conn
|
||||||
|
x <- f conn
|
||||||
|
lift $ Opium.execute_ "COMMIT" conn
|
||||||
|
pure x
|
||||||
|
)
|
||||||
|
`catchE` ( \err -> do
|
||||||
|
-- rollback open transactions and release transaction level locks...
|
||||||
|
lift $ Opium.execute_ "ROLLBACK" conn
|
||||||
|
-- ...then rethrow the error so the app can handle it too.
|
||||||
|
throwE err
|
||||||
|
)
|
||||||
|
|
||||||
|
lift :: IO (Either Opium.Error a) -> ExceptT Error IO a
|
||||||
|
lift f =
|
||||||
|
ExceptT $ first DBError <$> f
|
||||||
|
|
||||||
data DayIndex = DayIndex
|
data DayIndex = DayIndex
|
||||||
{ day_index_id :: Int
|
{ day_index_id :: Int
|
||||||
@@ -72,44 +98,47 @@ newtype CountResult = CountResult
|
|||||||
}
|
}
|
||||||
deriving (Show, Generic, Opium.FromRow)
|
deriving (Show, Generic, Opium.FromRow)
|
||||||
|
|
||||||
createDayIndex :: Day -> Opium.Connection -> IO (Either Opium.Error DayIndex)
|
createDayIndex :: Day -> Opium.Connection -> ExceptT Error IO DayIndex
|
||||||
createDayIndex date =
|
createDayIndex date =
|
||||||
ex runIdentity
|
ex runIdentity
|
||||||
. Opium.fetch
|
. Opium.fetch
|
||||||
"INSERT INTO yore.day_index (day) VALUES ($1) RETURNING *"
|
"INSERT INTO yore.day_index (day) VALUES ($1) RETURNING *"
|
||||||
(Identity date)
|
(Identity date)
|
||||||
|
|
||||||
createDayFile :: Int -> Text -> FilePath -> Opium.Connection -> IO (Either Opium.Error ())
|
createDayFile :: Int -> Text -> FilePath -> Opium.Connection -> ExceptT Error IO ()
|
||||||
createDayFile dayId label path =
|
createDayFile dayId label path =
|
||||||
Opium.execute
|
lift
|
||||||
"INSERT INTO yore.day_file (day_index_id, label, relative_path) VALUES ($1, $2, $3)"
|
. Opium.execute
|
||||||
(dayId, label, path)
|
"INSERT INTO yore.day_file (day_index_id, label, relative_path) VALUES ($1, $2, $3)"
|
||||||
|
(dayId, label, path)
|
||||||
|
|
||||||
readDayIndex :: Day -> Opium.Connection -> IO (Either Opium.Error (Maybe DayIndex))
|
readDayIndex :: Day -> Opium.Connection -> ExceptT Error IO (Maybe DayIndex)
|
||||||
readDayIndex date =
|
readDayIndex date =
|
||||||
Opium.fetch
|
lift
|
||||||
"SELECT * FROM yore.day_index WHERE day = $1"
|
. Opium.fetch
|
||||||
(Identity date)
|
"SELECT * FROM yore.day_index WHERE day = $1"
|
||||||
|
(Identity date)
|
||||||
|
|
||||||
readDayPaths :: Day -> Opium.Connection -> IO (Either Opium.Error [(DayIndex, DayFile)])
|
readDayPaths :: Day -> Opium.Connection -> ExceptT Error IO [(DayIndex, DayFile)]
|
||||||
readDayPaths date =
|
readDayPaths date =
|
||||||
Opium.fetch
|
lift
|
||||||
"SELECT * FROM yore.day_index LEFT JOIN yore.day_file USING (day_index_id) WHERE day = $1"
|
. Opium.fetch
|
||||||
(Identity date)
|
"SELECT * FROM yore.day_index LEFT JOIN yore.day_file USING (day_index_id) WHERE day = $1"
|
||||||
|
(Identity date)
|
||||||
|
|
||||||
getDayFileByIssue :: Day -> Int -> Opium.Connection -> IO (Either Opium.Error DayFile)
|
getDayFileByIssue :: Day -> Int -> Opium.Connection -> ExceptT Error IO DayFile
|
||||||
getDayFileByIssue date issue =
|
getDayFileByIssue date issue =
|
||||||
ex runIdentity
|
ex runIdentity
|
||||||
. Opium.fetch
|
. Opium.fetch
|
||||||
"SELECT yore.day_file.* FROM yore.day_index LEFT JOIN yore.day_file USING (day_index_id) WHERE day = $1 ORDER BY day_file_id LIMIT 1 OFFSET $2"
|
"SELECT yore.day_file.* FROM yore.day_index LEFT JOIN yore.day_file USING (day_index_id) WHERE day = $1 ORDER BY day_file_id LIMIT 1 OFFSET $2"
|
||||||
(date, issue)
|
(date, issue)
|
||||||
|
|
||||||
getNumberOfIssues :: Day -> Opium.Connection -> IO (Either Opium.Error Int)
|
getNumberOfIssues :: Day -> Opium.Connection -> ExceptT Error IO Int
|
||||||
getNumberOfIssues date =
|
getNumberOfIssues date =
|
||||||
ex (count . runIdentity)
|
ex (count . runIdentity)
|
||||||
. Opium.fetch
|
. Opium.fetch
|
||||||
"SELECT count(*) FROM yore.day_index LEFT JOIN yore.day_file USING (day_index_id) WHERE day = $1"
|
"SELECT count(*) FROM yore.day_index LEFT JOIN yore.day_file USING (day_index_id) WHERE day = $1"
|
||||||
(Identity date)
|
(Identity date)
|
||||||
|
|
||||||
ex :: (a -> b) -> IO (Either e a) -> IO (Either e b)
|
ex :: (a -> b) -> IO (Either Opium.Error a) -> ExceptT Error IO b
|
||||||
ex = fmap . fmap
|
ex f = fmap f . lift
|
||||||
|
|||||||
+19
-8
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
module Yore.Download (downloadInto) where
|
module Yore.Download (downloadInto) where
|
||||||
|
|
||||||
import Control.Monad (guard)
|
import Control.Exception (Exception, IOException, catch)
|
||||||
|
import Control.Monad.Trans.Except (ExceptT (..))
|
||||||
import Data.Maybe (fromJust)
|
import Data.Maybe (fromJust)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import Network.HTTP.Req hiding (queryParam)
|
import Network.HTTP.Req hiding (queryParam)
|
||||||
@@ -14,6 +15,8 @@ import qualified Data.Text as Text
|
|||||||
import qualified Data.UUID.V4 as UUID
|
import qualified Data.UUID.V4 as UUID
|
||||||
import qualified Text.URI as URI
|
import qualified Text.URI as URI
|
||||||
|
|
||||||
|
import Yore.Error (Error (..))
|
||||||
|
|
||||||
-- | Download a URL and save it to a directory.
|
-- | Download a URL and save it to a directory.
|
||||||
-- Returns the path of the downloaded file relative to the directory.
|
-- Returns the path of the downloaded file relative to the directory.
|
||||||
downloadInto
|
downloadInto
|
||||||
@@ -21,22 +24,30 @@ downloadInto
|
|||||||
-- ^ Directory where to store the file.
|
-- ^ Directory where to store the file.
|
||||||
-> Text
|
-> Text
|
||||||
-- ^ The URL to download.
|
-- ^ The URL to download.
|
||||||
-> IO FilePath
|
-> ExceptT Error IO FilePath
|
||||||
downloadInto downloadDir textUrl = download >>= save
|
downloadInto downloadDir textUrl =
|
||||||
|
download >>= save
|
||||||
where
|
where
|
||||||
download :: IO LBS.ByteString
|
download :: ExceptT Error IO LBS.ByteString
|
||||||
download = do
|
download = toExceptT (handle @HttpException) $ do
|
||||||
uri <- URI.mkURI textUrl
|
uri <- URI.mkURI textUrl
|
||||||
let (url, opts) = fromJust $ useHttpsURI uri
|
let (url, opts) = fromJust $ useHttpsURI uri
|
||||||
res <- runReq defaultHttpConfig $ req GET url NoReqBody lbsResponse opts
|
res <- runReq defaultHttpConfig $ req GET url NoReqBody lbsResponse opts
|
||||||
guard $ responseStatusCode res == 200
|
|
||||||
pure $ responseBody res
|
pure $ responseBody res
|
||||||
|
|
||||||
save :: LBS.ByteString -> IO FilePath
|
save :: LBS.ByteString -> ExceptT Error IO FilePath
|
||||||
save contents = do
|
save contents = toExceptT (handle @IOException) $ do
|
||||||
let suffix = takeExtension $ Text.unpack textUrl
|
let suffix = takeExtension $ Text.unpack textUrl
|
||||||
uuid <- UUID.nextRandom
|
uuid <- UUID.nextRandom
|
||||||
let fullPath = downloadDir </> show uuid <.> suffix
|
let fullPath = downloadDir </> show uuid <.> suffix
|
||||||
createDirectoryIfMissing True downloadDir
|
createDirectoryIfMissing True downloadDir
|
||||||
LBS.writeFile fullPath contents
|
LBS.writeFile fullPath contents
|
||||||
pure $ makeRelative downloadDir fullPath
|
pure $ makeRelative downloadDir fullPath
|
||||||
|
|
||||||
|
toExceptT :: (Exception e) => (e -> Error) -> IO a -> ExceptT Error IO a
|
||||||
|
toExceptT showException f =
|
||||||
|
ExceptT $ (Right <$> f) `catch` (pure . Left . showException)
|
||||||
|
|
||||||
|
handle :: (Show e) => e -> Error
|
||||||
|
handle =
|
||||||
|
GenericError . Text.pack . show
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
{-# LANGUAGE OverloadedRecordDot #-}
|
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
|
||||||
|
|
||||||
module Yore.Index (createEntries) where
|
|
||||||
|
|
||||||
import Control.Exception (Exception, throwIO)
|
|
||||||
import Control.Monad (forM_)
|
|
||||||
import Data.Text (Text)
|
|
||||||
import Data.Time (Day)
|
|
||||||
|
|
||||||
import qualified Database.PostgreSQL.Opium as Opium
|
|
||||||
|
|
||||||
import qualified Yore.DB as DB
|
|
||||||
|
|
||||||
createEntries :: Day -> [(Text, FilePath)] -> Opium.Connection -> IO ()
|
|
||||||
createEntries date urls conn = do
|
|
||||||
confidently $ Opium.execute_ "BEGIN" conn
|
|
||||||
dayIndex <- confidently $ DB.createDayIndex date conn
|
|
||||||
forM_ urls $ \(text, url) ->
|
|
||||||
confidently $ DB.createDayFile dayIndex.day_index_id text url conn
|
|
||||||
confidently $ Opium.execute_ "COMMIT" conn
|
|
||||||
|
|
||||||
confidently :: (Exception e) => IO (Either e a) -> IO a
|
|
||||||
confidently action = action >>= either throwIO pure
|
|
||||||
+5
-4
@@ -1,18 +1,19 @@
|
|||||||
module Yore.Log (Yore.Log.error, info) where
|
module Yore.Log (Yore.Log.error, info) where
|
||||||
|
|
||||||
|
import Control.Monad.IO.Class (MonadIO (..))
|
||||||
import Data.Time (ZonedTime, getZonedTime)
|
import Data.Time (ZonedTime, getZonedTime)
|
||||||
import Data.Time.Format (defaultTimeLocale, formatTime)
|
import Data.Time.Format (defaultTimeLocale, formatTime)
|
||||||
import GHC.Stack (HasCallStack, SrcLoc (..), callStack, getCallStack)
|
import GHC.Stack (HasCallStack, SrcLoc (..), callStack, getCallStack)
|
||||||
import Text.Printf (printf)
|
import Text.Printf (printf)
|
||||||
|
|
||||||
info :: (HasCallStack) => String -> IO ()
|
info :: (HasCallStack, MonadIO m) => String -> m ()
|
||||||
info = doLog "INF"
|
info = doLog "INF"
|
||||||
|
|
||||||
error :: (HasCallStack) => String -> IO ()
|
error :: (HasCallStack, MonadIO m) => String -> m ()
|
||||||
error = doLog "ERR"
|
error = doLog "ERR"
|
||||||
|
|
||||||
doLog :: (HasCallStack) => String -> String -> IO ()
|
doLog :: (HasCallStack, MonadIO m) => String -> String -> m ()
|
||||||
doLog level msg = do
|
doLog level msg = liftIO $ do
|
||||||
now <- getZonedTime
|
now <- getZonedTime
|
||||||
let location = getLocation $ getCallStack callStack
|
let location = getLocation $ getCallStack callStack
|
||||||
printf "(%s) (%s) (%s) %s\n" (iso8601Show now) level location msg
|
printf "(%s) (%s) (%s) %s\n" (iso8601Show now) level location msg
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
{-# LANGUAGE LambdaCase #-}
|
|
||||||
{-# LANGUAGE OverloadedRecordDot #-}
|
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
|
||||||
|
|
||||||
module Yore.Repl (connect, exec, getToday, indexDay, addYears) where
|
|
||||||
|
|
||||||
import Control.Exception (bracket, throwIO)
|
|
||||||
import Control.Monad (forM)
|
|
||||||
import Data.Time (Day, getZonedTime, localDay, zonedTimeToLocalTime)
|
|
||||||
import Text.Printf (printf)
|
|
||||||
|
|
||||||
import qualified Database.PostgreSQL.Opium as Opium
|
|
||||||
|
|
||||||
import Yore.DB (DayIndex)
|
|
||||||
import Yore.Download (downloadInto)
|
|
||||||
import Yore.Index (createEntries)
|
|
||||||
import Yore.Scrape (Issue (..), getIssuesByDay)
|
|
||||||
import Yore.Time (addYears)
|
|
||||||
|
|
||||||
import qualified Yore.DB as DB
|
|
||||||
|
|
||||||
connect :: IO Opium.Connection
|
|
||||||
connect =
|
|
||||||
Opium.connect "host=localhost port=5433 user=yore-test" >>= \case
|
|
||||||
Left e -> error $ show e
|
|
||||||
Right c -> pure c
|
|
||||||
|
|
||||||
exec :: (Opium.Connection -> IO a) -> IO a
|
|
||||||
exec = bracket connect Opium.close
|
|
||||||
|
|
||||||
getToday :: IO [Issue]
|
|
||||||
getToday = do
|
|
||||||
now <- getZonedTime
|
|
||||||
print now
|
|
||||||
let currentDay = localDay $ zonedTimeToLocalTime now
|
|
||||||
case addYears (-100) currentDay of
|
|
||||||
Right then_ ->
|
|
||||||
getIssuesByDay then_
|
|
||||||
Left _ ->
|
|
||||||
pure []
|
|
||||||
|
|
||||||
indexDay :: Day -> IO ()
|
|
||||||
indexDay date = exec $ \conn -> do
|
|
||||||
either throwIO pure =<< Opium.execute_ "SELECT pg_advisory_lock(42);" conn
|
|
||||||
printf "Checking whether %s already exists...\n" (show date)
|
|
||||||
mbDi <- either throwIO pure =<< DB.readDayIndex date conn
|
|
||||||
case mbDi of
|
|
||||||
Just (_ :: DayIndex) ->
|
|
||||||
putStrLn "Nothing to do."
|
|
||||||
Nothing -> do
|
|
||||||
putStrLn "Retrieving URLs..."
|
|
||||||
issues <- getIssuesByDay date
|
|
||||||
paths <- forM issues $ \issue -> do
|
|
||||||
printf "Downloading %s...\n" issue.url
|
|
||||||
path <- downloadInto "./download" issue.url
|
|
||||||
pure (issue.label, path)
|
|
||||||
putStrLn "Creating DB entries..."
|
|
||||||
createEntries date paths conn
|
|
||||||
putStrLn "Done."
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
module Yore.Schedule (schedule) where
|
||||||
|
|
||||||
|
import Control.Concurrent (forkIO, threadDelay)
|
||||||
|
import Control.Monad (when)
|
||||||
|
import Data.Functor (($>))
|
||||||
|
import Data.Time (ZonedTime, getZonedTime, nominalDiffTimeToSeconds, secondsToNominalDiffTime)
|
||||||
|
import Data.Time.Clock.POSIX (getPOSIXTime)
|
||||||
|
|
||||||
|
schedule :: (ZonedTime -> Bool) -> IO a -> IO ()
|
||||||
|
schedule shouldRunAt f = everyMinute $ \now -> when (shouldRunAt now) (f $> ())
|
||||||
|
|
||||||
|
everyMinute :: (ZonedTime -> IO ()) -> IO ()
|
||||||
|
everyMinute f = do
|
||||||
|
-- Use POSIX time to avoid having to handle leap seconds
|
||||||
|
now <- getPOSIXTime
|
||||||
|
let posixSeconds :: Int
|
||||||
|
posixSeconds = floor $ nominalDiffTimeToSeconds now
|
||||||
|
lastFullMinute = secondsToNominalDiffTime $ fromIntegral $ posixSeconds - posixSeconds `mod` 60
|
||||||
|
secondsSinceLastFullMinute = now - lastFullMinute
|
||||||
|
threadDelay $ ceiling $ (60 - secondsSinceLastFullMinute) * 1_000_000
|
||||||
|
_ <- forkIO $ f =<< getZonedTime
|
||||||
|
everyMinute f
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{-# LANGUAGE OverloadedRecordDot #-}
|
||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
{-# LANGUAGE TypeFamilies #-}
|
||||||
|
|
||||||
|
module Yore.Servant (GetSendfile, Sendfile (..), HTML) where
|
||||||
|
|
||||||
|
import Network.HTTP.Media ((//), (/:))
|
||||||
|
import Network.HTTP.Types (ResponseHeaders, status200)
|
||||||
|
import Network.Wai (responseFile)
|
||||||
|
import Servant (Accept (..), HasServer (..))
|
||||||
|
import Servant.Server.Internal.Delayed (runAction)
|
||||||
|
import Servant.Server.Internal.RouteResult (RouteResult (..))
|
||||||
|
import Servant.Server.Internal.Router (Router' (..))
|
||||||
|
|
||||||
|
-- | Servant route type that responds with a file using @sendfile@.
|
||||||
|
data GetSendfile
|
||||||
|
|
||||||
|
data Sendfile = Sendfile
|
||||||
|
{ headers :: ResponseHeaders
|
||||||
|
-- ^ The headers to send along with the file, e.g. @content-type@ or cache information.
|
||||||
|
, path :: FilePath
|
||||||
|
-- ^ The file to respond with.
|
||||||
|
}
|
||||||
|
deriving (Show)
|
||||||
|
|
||||||
|
instance HasServer GetSendfile context where
|
||||||
|
type ServerT GetSendfile m = m Sendfile
|
||||||
|
|
||||||
|
hoistServerWithContext _ _ nt = nt
|
||||||
|
|
||||||
|
route _ _ action = RawRouter $ \env request respond ->
|
||||||
|
runAction action env request respond $ \sendfile ->
|
||||||
|
Route $ responseFile status200 sendfile.headers sendfile.path Nothing
|
||||||
|
|
||||||
|
data HTML
|
||||||
|
|
||||||
|
instance Accept HTML where
|
||||||
|
contentType _ = "text" // "html" /: ("charset", "utf-8")
|
||||||
+7
-4
@@ -1,7 +1,7 @@
|
|||||||
cabal-version: 3.4
|
cabal-version: 3.4
|
||||||
|
|
||||||
name: yore
|
name: yore
|
||||||
version: 0
|
version: 0.0.5
|
||||||
author: Paul Brinkmeier
|
author: Paul Brinkmeier
|
||||||
maintainer: hallo@pbrinkmeier.de
|
maintainer: hallo@pbrinkmeier.de
|
||||||
copyright: 2023 Paul Brinkmeier
|
copyright: 2023 Paul Brinkmeier
|
||||||
@@ -37,11 +37,11 @@ library
|
|||||||
, Yore.DB
|
, Yore.DB
|
||||||
, Yore.Download
|
, Yore.Download
|
||||||
, Yore.Error
|
, Yore.Error
|
||||||
, Yore.Index
|
|
||||||
, Yore.Log
|
, Yore.Log
|
||||||
, Yore.Repl
|
, Yore.Schedule
|
||||||
, Yore.Scrape
|
, Yore.Scrape
|
||||||
, Yore.Time
|
, Yore.Time
|
||||||
|
, Yore.Servant
|
||||||
hs-source-dirs:
|
hs-source-dirs:
|
||||||
src
|
src
|
||||||
build-depends:
|
build-depends:
|
||||||
@@ -50,16 +50,20 @@ library
|
|||||||
, directory
|
, directory
|
||||||
, filepath
|
, filepath
|
||||||
, html-parse
|
, html-parse
|
||||||
|
, http-media
|
||||||
|
, http-types
|
||||||
, lens
|
, lens
|
||||||
, modern-uri
|
, modern-uri
|
||||||
, mtl
|
, mtl
|
||||||
, opium
|
, opium
|
||||||
, req
|
, req
|
||||||
, resource-pool
|
, resource-pool
|
||||||
|
, servant-server
|
||||||
, text
|
, text
|
||||||
, time
|
, time
|
||||||
, transformers
|
, transformers
|
||||||
, uuid
|
, uuid
|
||||||
|
, wai
|
||||||
|
|
||||||
executable yore
|
executable yore
|
||||||
import: shared-options
|
import: shared-options
|
||||||
@@ -78,7 +82,6 @@ executable yore
|
|||||||
, bytestring
|
, bytestring
|
||||||
, filepath
|
, filepath
|
||||||
, opium
|
, opium
|
||||||
, http-media
|
|
||||||
, http-types
|
, http-types
|
||||||
, servant-server
|
, servant-server
|
||||||
, text
|
, text
|
||||||
|
|||||||
Reference in New Issue
Block a user