Skip to content

Commit a08d148

Browse files
committed
Chapter 6.5 - Convert multiple files
1 parent 110a190 commit a08d148

File tree

3 files changed

+213
-3
lines changed

3 files changed

+213
-3
lines changed

hs-blog.cabal

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,12 @@ library
3131
hs-source-dirs: src
3232
build-depends:
3333
base
34+
, directory
35+
, filepath
3436
exposed-modules:
3537
HsBlog
3638
HsBlog.Convert
39+
HsBlog.Directory
3740
HsBlog.Html
3841
HsBlog.Html.Internal
3942
HsBlog.Markup

src/HsBlog.hs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ module HsBlog
44
( convertSingle
55
, convertDirectory
66
, process
7+
, buildIndex
78
)
89
where
910

1011
import qualified HsBlog.Markup as Markup
1112
import qualified HsBlog.Html as Html
1213
import HsBlog.Convert (convert)
14+
import HsBlog.Directory (convertDirectory, buildIndex)
1315

1416
import System.IO
1517

@@ -18,8 +20,5 @@ convertSingle title input output = do
1820
content <- hGetContents input
1921
hPutStrLn output (process title content)
2022

21-
convertDirectory :: FilePath -> FilePath -> IO ()
22-
convertDirectory = error "Not implemented"
23-
2423
process :: Html.Title -> String -> String
2524
process title = Html.render . convert title . Markup.parse

src/HsBlog/Directory.hs

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
-- src/HsBlog/Directory.hs
2+
3+
-- | Process multiple files and convert directories
4+
5+
module HsBlog.Directory
6+
( convertDirectory
7+
, buildIndex
8+
)
9+
where
10+
11+
import qualified HsBlog.Markup as Markup
12+
import qualified HsBlog.Html as Html
13+
import HsBlog.Convert (convert, convertStructure)
14+
15+
import Data.List (partition)
16+
import Data.Traversable (for)
17+
import Control.Monad (void, when)
18+
19+
import System.IO (hPutStrLn, stderr)
20+
import Control.Exception (catch, displayException, SomeException(..))
21+
import System.Exit (exitFailure)
22+
import System.FilePath
23+
( takeExtension
24+
, takeBaseName
25+
, (<.>)
26+
, (</>)
27+
, takeFileName
28+
)
29+
import System.Directory
30+
( createDirectory
31+
, removeDirectoryRecursive
32+
, listDirectory
33+
, doesDirectoryExist
34+
, copyFile
35+
)
36+
37+
-- | Copy files from one directory to another, converting '.txt' files to
38+
-- '.html' files in the process. Recording unsuccessful reads and writes to stderr.
39+
--
40+
-- May throw an exception on output directory creation.
41+
convertDirectory :: FilePath -> FilePath -> IO ()
42+
convertDirectory inputDir outputDir = do
43+
DirContents filesToProcess filesToCopy <- getDirFilesAndContent inputDir
44+
createOutputDirectoryOrExit outputDir
45+
let
46+
outputHtmls = txtsToRenderedHtml filesToProcess
47+
copyFiles outputDir filesToCopy
48+
writeFiles outputDir outputHtmls
49+
putStrLn "Done."
50+
51+
------------------------------------
52+
-- * Read directory content
53+
54+
-- | Returns the directory content
55+
getDirFilesAndContent :: FilePath -> IO DirContents
56+
getDirFilesAndContent inputDir = do
57+
files <- map (inputDir </>) <$> listDirectory inputDir
58+
let
59+
(txtFiles, otherFiles) =
60+
partition ((== ".txt") . takeExtension) files
61+
txtFilesAndContent <-
62+
applyIoOnList readFile txtFiles >>= filterAndReportFailures
63+
pure $ DirContents
64+
{ dcFilesToProcess = txtFilesAndContent
65+
, dcFilesToCopy = otherFiles
66+
}
67+
68+
-- | The relevant directory content for our application
69+
data DirContents
70+
= DirContents
71+
{ dcFilesToProcess :: [(FilePath, String)]
72+
-- ^ File paths and their content
73+
, dcFilesToCopy :: [FilePath]
74+
-- ^ Other file paths, to be copied directly
75+
}
76+
77+
------------------------------------
78+
-- * Build index page
79+
80+
buildIndex :: [(FilePath, Markup.Document)] -> Html.Html
81+
buildIndex files =
82+
let
83+
previews =
84+
map
85+
( \(file, doc) ->
86+
case doc of
87+
Markup.Heading 1 heading : article ->
88+
Html.h_ 3 (Html.link_ file (Html.txt_ heading))
89+
<> foldMap convertStructure (take 2 article)
90+
<> Html.p_ (Html.link_ file (Html.txt_ "..."))
91+
_ ->
92+
Html.h_ 3 (Html.link_ file (Html.txt_ file))
93+
)
94+
files
95+
in
96+
Html.html_
97+
"Blog"
98+
( Html.h_ 1 (Html.link_ "index.html" (Html.txt_ "Blog"))
99+
<> Html.h_ 2 (Html.txt_ "Posts")
100+
<> mconcat previews
101+
)
102+
103+
------------------------------------
104+
-- * Conversion
105+
106+
-- | Convert text files to Markup, build an index, and render as html.
107+
txtsToRenderedHtml :: [(FilePath, String)] -> [(FilePath, String)]
108+
txtsToRenderedHtml txtFiles =
109+
let
110+
txtOutputFiles = map toOutputMarkupFile txtFiles
111+
index = ("index.html", buildIndex txtOutputFiles)
112+
in
113+
map (fmap Html.render) (index : map convertFile txtOutputFiles)
114+
115+
toOutputMarkupFile :: (FilePath, String) -> (FilePath, Markup.Document)
116+
toOutputMarkupFile (file, content) =
117+
(takeBaseName file <.> "html", Markup.parse content)
118+
119+
convertFile :: (FilePath, Markup.Document) -> (FilePath, Html.Html)
120+
convertFile (file, doc) = (file, convert file doc)
121+
122+
------------------------------------
123+
-- * Output to directory
124+
125+
-- | Creates an output directory or terminates the program
126+
createOutputDirectoryOrExit :: FilePath -> IO ()
127+
createOutputDirectoryOrExit outputDir =
128+
whenIO
129+
(not <$> createOutputDirectory outputDir)
130+
(hPutStrLn stderr "Cancelled." *> exitFailure)
131+
132+
-- | Creates the output directory.
133+
-- Returns whether the directory was created or not.
134+
createOutputDirectory :: FilePath -> IO Bool
135+
createOutputDirectory dir = do
136+
dirExists <- doesDirectoryExist dir
137+
create <-
138+
if dirExists
139+
then do
140+
override <- confirm "Output directory exists. Override?"
141+
when override (removeDirectoryRecursive dir)
142+
pure override
143+
else
144+
pure True
145+
when create (createDirectory dir)
146+
pure create
147+
148+
-- | Copy files to a directory, recording errors to stderr.
149+
copyFiles :: FilePath -> [FilePath] -> IO ()
150+
copyFiles outputDir files = do
151+
let
152+
copyFromTo file = copyFile file (outputDir </> takeFileName file)
153+
void $ applyIoOnList copyFromTo files >>= filterAndReportFailures
154+
155+
-- | Write files to a directory, recording errors to stderr.
156+
writeFiles :: FilePath -> [(FilePath, String)] -> IO ()
157+
writeFiles outputDir files = do
158+
let
159+
writeFileContent (file, content) =
160+
writeFile (outputDir </> file) content
161+
void $ applyIoOnList writeFileContent files >>= filterAndReportFailures
162+
163+
------------------------------------
164+
-- * IO work and handling errors
165+
166+
-- | Try to apply an IO function on a list of values, document successes and failures
167+
applyIoOnList :: (a -> IO b) -> [a] -> IO [(a, Either String b)]
168+
applyIoOnList action files = do
169+
for files $ \file -> do
170+
maybeContent <-
171+
catch
172+
(Right <$> action file)
173+
( \(SomeException e) -> do
174+
pure $ Left (displayException e)
175+
)
176+
pure (file, maybeContent)
177+
178+
-- | Filter out unsuccessful operations on files and report errors to stderr.
179+
filterAndReportFailures :: [(a, Either String b)] -> IO [(a, b)]
180+
filterAndReportFailures =
181+
foldMap $ \ (file, contentOrErr) ->
182+
case contentOrErr of
183+
Left err -> do
184+
hPutStrLn stderr err
185+
pure []
186+
Right content ->
187+
pure [(file, content)]
188+
189+
------------------------------------
190+
-- * Utilities
191+
192+
confirm :: String -> IO Bool
193+
confirm question = do
194+
putStrLn (question <> " (y/n)")
195+
answer <- getLine
196+
case answer of
197+
"y" -> pure True
198+
"n" -> pure False
199+
_ -> do
200+
putStrLn "Invalid response. Use y or n."
201+
confirm question
202+
203+
whenIO :: IO Bool -> IO () -> IO ()
204+
whenIO cond action = do
205+
result <- cond
206+
if result
207+
then action
208+
else pure ()

0 commit comments

Comments
 (0)