Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
- Added test cases for the saveGraphJSON function in `Controllers/Graph`
- Added test cases for the getGraphJSON function in `Controllers/Graph`
- Fix unused variable from `Graph.js`, formatting in `Container.js` and `GraphDropdown.js`, and eslint config
- Refactor `Controllers/Graph.hs` and `Controllers/Timetable.hs` to use temporary files
- Remove unused `getTimetableImage` function in `Export/GetImages.hs`

## [0.7.2] - 2025-12-10

Expand Down
16 changes: 12 additions & 4 deletions app/Controllers/Graph.hs
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,23 @@ module Controllers.Graph (graphResponse, index, getGraphJSON, graphImageResponse
import Control.Monad.IO.Class (liftIO)
import Data.Aeson (decode, object, (.=))
import Data.Maybe (fromMaybe)
import Export.ImageConversion
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

always list explicit names that are imported (e.g., createImageFile)

import Happstack.Server (Response, ServerPart, look, lookBS, lookText', ok, toResponse)
import MasterTemplate (masterTemplate)
import Scripts (graphScripts)
import System.IO (hClose)
import System.IO.Temp (withSystemTempFile)
import Text.Blaze ((!))
import qualified Text.Blaze.Html5 as H
import qualified Text.Blaze.Html5.Attributes as A

import Config (runDb)
import Database.Persist.Sqlite (Entity, SelectOpt (Asc), SqlPersistM, selectList, (==.))
import Database.Tables as Tables (EntityField (GraphDynamic, GraphTitle), Graph, SvgJSON, Text)
import Export.GetImages (getActiveGraphImage)
import Export.GetImages (writeActiveGraphImage)
import Models.Graph (getGraph, insertGraph)
import Util.Happstack (createJSONResponse)
import Util.Helpers (returnImageData)
import Util.Helpers (readImageData)

graphResponse :: ServerPart Response
graphResponse =
Expand Down Expand Up @@ -51,8 +54,13 @@ getGraphJSON = do
graphImageResponse :: ServerPart Response
graphImageResponse = do
graphInfo <- look "JsonLocalStorageObj"
(svgFilename, imageFilename) <- liftIO $ getActiveGraphImage graphInfo
liftIO $ returnImageData svgFilename imageFilename
liftIO $ withSystemTempFile "graph.svg" $ \svgPath svgHandle -> do
withSystemTempFile "graph.png" $ \pngPath pngHandle -> do
hClose pngHandle
writeActiveGraphImage graphInfo svgHandle
hClose svgHandle
createImageFile svgPath pngPath
readImageData pngPath

-- | Inserts SVG graph data into Texts, Shapes, and Paths tables
saveGraphJSON :: ServerPart Response
Expand Down
57 changes: 38 additions & 19 deletions app/Controllers/Timetable.hs
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,21 @@ import Data.Time.Calendar.OrdinalDate (fromMondayStartWeek, mondayStartWeek)
import Database.Persist.Sqlite (entityKey, entityVal, selectList, (==.))
import Database.Tables
import Export.GetImages
import Export.ImageConversion
import Export.LatexGenerator
import Export.PdfGenerator
import Happstack.Server
import MasterTemplate
import Models.Meeting (returnMeeting)
import Scripts
import System.Directory (removeFile)
import System.FilePath ((</>))
import System.IO (IOMode (WriteMode), hClose, withFile)
import System.IO.Temp (withSystemTempDirectory, withSystemTempFile)
import Text.Blaze ((!))
import qualified Text.Blaze.Html5 as H
import qualified Text.Blaze.Html5.Attributes as A
import Text.Read (readMaybe)
import Util.Helpers (returnImageData, safeHead)
import Util.Helpers (readImageData, safeHead)

gridResponse :: ServerPart Response
gridResponse =
Expand All @@ -45,37 +48,53 @@ exportTimetableImageResponse :: ServerPart Response
exportTimetableImageResponse = do
session <- lookText' "session"
selectedCourses <- lookText' "courses"
(svgFilename, imageFilename) <- liftIO $ getActiveTimetable selectedCourses session
liftIO $ returnImageData svgFilename imageFilename

liftIO $ withSystemTempFile "timetable.svg" $ \svgPath svgHandle -> do
withSystemTempFile "timetable.png" $ \pngPath pngHandle -> do
hClose svgHandle
hClose pngHandle
getActiveTimetable selectedCourses session svgPath pngPath
readImageData pngPath

-- | Returns a PDF containing graph and timetable requested by the user.
exportTimetablePDFResponse :: ServerPart Response
exportTimetablePDFResponse = do
selectedCourses <- lookText' "courses"
graphInfo <- look "JsonLocalStorageObj"
(graphSvg, graphImg) <- liftIO $ getActiveGraphImage graphInfo
(fallsvgFilename, fallimageFilename) <- liftIO $ getActiveTimetable selectedCourses "Fall"
(springsvgFilename, springimageFilename) <- liftIO $ getActiveTimetable selectedCourses "Spring"
pdfName <- liftIO $ returnPDF graphSvg graphImg fallsvgFilename fallimageFilename springsvgFilename springimageFilename
liftIO $ returnPdfBS pdfName

-- | Returns 64base bytestring of PDF for given name, then deletes PDF from local.
liftIO $ withSystemTempDirectory "timetable-pdf" $ \tempDir -> do
let graphSvgPath = tempDir </> "graph.svg"
graphPngPath = tempDir </> "graph.png"
fallSvgPath = tempDir </> "fall.svg"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall you can simplify the code here by passing tempDir into getActiveTimetable and generateTimetableImg rather than the specific paths. This more closely matches the original code structure.

fallPngPath = tempDir </> "fall.png"
springSvgPath = tempDir </> "spring.svg"
springPngPath = tempDir </> "spring.png"

withFile graphSvgPath WriteMode $ \graphSvgHandle ->
writeActiveGraphImage graphInfo graphSvgHandle
createImageFile graphSvgPath graphPngPath

getActiveTimetable selectedCourses "Fall" fallSvgPath fallPngPath

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's quite a few blank lines in this section. I would remove this one at least, and possibly some others.

getActiveTimetable selectedCourses "Spring" springSvgPath springPngPath

pdfName <- returnPDF graphPngPath fallPngPath springPngPath tempDir
returnPdfBS pdfName

-- | Returns 64base bytestring of PDF for given name.
returnPdfBS :: String -> IO Response
returnPdfBS pdfFilename = do
pdfData <- BS.readFile pdfFilename
_ <- removeFile pdfFilename
return $ toResponseBS "application/pdf" $ BEnc.encode $ L.fromStrict pdfData

-- | Returns the name of a generated pdf that contains graphImg and timetableImg
-- and deletes all of the img and svg files passed as arguments
returnPDF :: String -> String -> String -> String -> String -> String -> IO String
returnPDF graphSvg graphImg fallTimetableSvg fallTimetableImg springTimetableSvg springTimetableImg = do
rand <- randomName
let texName = rand ++ ".tex"
pdfName = rand ++ ".pdf"
-- | Returns the name of a generated pdf that contains graphImg, fallTimetableImg,
-- and springTimetableImg generated in tempDir.
returnPDF :: String -> String -> String -> FilePath -> IO String
returnPDF graphImg fallTimetableImg springTimetableImg tempDir = do
let texName = tempDir </> "timetable.tex"
pdfName = tempDir </> "timetable.pdf"
generateTex [graphImg, fallTimetableImg, springTimetableImg] texName -- generate a temporary TEX file
createPDF texName -- create PDF using TEX and delete the TEX file afterwards
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is no longer accurate (doesn't delete the TEX file afterwards).

mapM_ removeFile [graphSvg, graphImg, fallTimetableSvg, fallTimetableImg, springTimetableSvg, springTimetableImg]
return pdfName


Expand Down
76 changes: 21 additions & 55 deletions app/Export/GetImages.hs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
timetables.

Defines functions for creating images from graphs and timetables, most
functions return the name of the created svg and png files after creation.
functions write to the svg and png files given their paths.
-}
module Export.GetImages
(getActiveGraphImage, getTimetableImage, randomName, getActiveTimetable) where
(getActiveTimetable, writeActiveGraphImage) where

import Config (runDb)
import Data.Aeson (decode)
Expand All @@ -19,32 +19,28 @@ import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import Database.Tables as Tables
import Export.ImageConversion
import Export.TimetableImageCreator (renderTable, renderTableHelper, times)
import Export.TimetableImageCreator (renderTableHelper, times)
import Models.Meeting (getMeetingTime)
import Svg.Generator
import System.Random (genWord32, newStdGen)
import System.IO (Handle)


-- | If there is an active graph available, an image of that graph is created,
-- otherwise the Computer Science graph is created as a default.
-- Either way, the resulting graph's .svg and .png names are returned.
getActiveGraphImage :: String -> IO (String, String)
getActiveGraphImage graphInfo = do
-- | If there is an active graph available, an image of the active graph is written,
-- otherwise the Computer Science graph is written as a default.
writeActiveGraphImage :: String -> Handle -> IO ()
writeActiveGraphImage graphInfo svgHandle = do
let graphInfoMap = fromMaybe M.empty $ decode $ fromStrict $ BC.pack graphInfo :: M.Map T.Text T.Text
graphName = fromMaybe "Computer-Science" $ M.lookup "active-graph" graphInfoMap
getGraphImage graphName graphInfoMap

getGraphImage graphName graphInfoMap svgHandle

-- | If there are selected lectures available, an timetable image of
-- those lectures in specified session is created.
-- Otherwise an empty timetable image is created as default.
-- Either way, the resulting image's .svg and .png names are returned.
getActiveTimetable :: T.Text -> T.Text -> IO (String, String)
getActiveTimetable selectedCourses termSession = do
getActiveTimetable :: T.Text -> T.Text -> FilePath -> FilePath -> IO ()
getActiveTimetable selectedCourses termSession svgPath pngPath = do
let selectedMeetings = parseSelectedCourses selectedCourses termSession
mTimes <- getTimes selectedMeetings
let schedule = getScheduleByTime selectedMeetings mTimes
generateTimetableImg schedule termSession
generateTimetableImg schedule termSession svgPath pngPath

-- | Parses selected courses local storage and returns two lists of information about courses
-- in the format of (code, section, session).
Expand Down Expand Up @@ -96,43 +92,13 @@ addCourseHelper (courseCode, courseSection, courseSession) currentSchedule (day,
timeSchedule' = take day timeSchedule ++ [newDaySchedule] ++ drop (day + 1) timeSchedule
in take courseTime currentSchedule ++ [timeSchedule'] ++ drop (courseTime + 1) currentSchedule

-- | Creates an timetable image based on schedule, and returns the name of the svg
-- used to create the image and the name of the image
generateTimetableImg :: [[[T.Text]]] -> T.Text -> IO (String, String)
generateTimetableImg schedule courseSession = do
rand <- randomName
let svgFilename = rand ++ ".svg"
imageFilename = rand ++ ".png"
renderTableHelper svgFilename (zipWith (:) times schedule) courseSession
createImageFile svgFilename imageFilename
return (svgFilename, imageFilename)

-- | Creates an image, and returns the name of the svg used to create the
-- image and the name of the image
getGraphImage :: T.Text -> M.Map T.Text T.Text -> IO (String, String)
getGraphImage graphName courseMap = do
rand <- randomName
let svgFilename = rand ++ ".svg"
imageFilename = rand ++ ".png"
buildSVG graphName courseMap svgFilename True
createImageFile svgFilename imageFilename
return (svgFilename, imageFilename)

-- | Creates an image, and returns the name of the svg used to create the
-- image and the name of the image
getTimetableImage :: T.Text -> T.Text -> IO (String, String)
getTimetableImage courses termSession = do
-- generate 2 random names
rand <- randomName
let svgFilename = rand ++ ".svg"
imageFilename = rand ++ ".png"
renderTable svgFilename courses termSession
createImageFile svgFilename imageFilename
return (svgFilename, imageFilename)
-- | Creates a timetable image based on schedule.
generateTimetableImg :: [[[T.Text]]] -> T.Text -> FilePath -> FilePath -> IO ()
generateTimetableImg schedule courseSession svgPath pngPath = do
renderTableHelper svgPath (zipWith (:) times schedule) courseSession
createImageFile svgPath pngPath

-- | Generate a string containing random integers
randomName :: IO String
randomName = do
gen <- newStdGen
let (rand, _) = genWord32 gen
return (show rand)
-- | Builds the graph svg, given file handler of the svg
getGraphImage :: T.Text -> M.Map T.Text T.Text -> Handle -> IO ()
getGraphImage graphName courseMap svgHandle = do
buildSVG graphName courseMap svgHandle True
13 changes: 7 additions & 6 deletions app/Svg/Generator.hs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import Database.DataType
import Database.Persist.Sqlite
import Database.Tables hiding (paths, texts)
import Svg.Builder
import System.IO (Handle, hPutStrLn)
import Text.Blaze (toMarkup)
import Text.Blaze.Internal (stringValue, textValue)
import Text.Blaze.Svg.Renderer.String (renderSvg)
Expand All @@ -41,30 +42,30 @@ buildSVG :: T.Text -- ^ The name of the graph that is being built.
-- The data-active attribute is used in the
-- interactive graph to indicate which
-- courses the user has selected.
-> String -- ^ The filename that this graph will be
-> Handle -- ^ The file handler that this graph will be
-- written to.
-> Bool -- ^ Whether to include inline styles.
-> IO ()
buildSVG graphName courseMap filename styled = runDb $ do
buildSVG graphName courseMap fileHandle styled = runDb $ do
maybeGraph :: Maybe (Entity Graph) <- selectFirst [GraphTitle ==. graphName] []
case maybeGraph of
Nothing -> return ()
Just val -> do
liftIO $ buildSVGHelper courseMap filename styled (entityVal val) (entityKey val)
liftIO $ buildSVGHelper courseMap fileHandle styled (entityVal val) (entityKey val)

buildSVGHelper :: M.Map T.Text T.Text -- ^ A map of courses that holds the course
-- ID as a key, and the data-active
-- attribute as the course's value.
-- The data-active attribute is used in the
-- interactive graph to indicate which
-- courses the user has selected.
-> String -- ^ The filename that this graph will be
-> Handle -- ^ The file handler that this graph will be
-- written to.
-> Bool -- ^ Whether to include inline styles.
-> Graph
-> Key Graph
-> IO ()
buildSVGHelper courseMap filename styled sqlGraph gId = runDb $ do
buildSVGHelper courseMap fileHandle styled sqlGraph gId = runDb $ do
sqlRects :: [Entity Shape] <- selectList
[ShapeType_ <-. [Node, Hybrid],
ShapeGraph ==. gId] []
Expand Down Expand Up @@ -102,7 +103,7 @@ buildSVGHelper courseMap filename styled sqlGraph gId = runDb $ do
styled
width
height
liftIO $ writeFile filename stringSVG :: SqlPersistM ()
liftIO $ hPutStrLn fileHandle stringSVG :: SqlPersistM ()
where
keyAsInt :: PersistEntity a => Entity a -> Integer
keyAsInt = fromIntegral . (\(PersistInt64 x) -> x) . safeHead (PersistInt64 0) . keyToValues . entityKey
Expand Down
14 changes: 5 additions & 9 deletions app/Util/Helpers.hs
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,21 @@
Description : Contains general-use helper functions.
-}
module Util.Helpers
(safeHead, returnImageData) where
(safeHead, readImageData) where

import qualified Data.ByteString as BS (readFile)
import qualified Data.ByteString.Base64 as BEnc (encode)
import Happstack.Server (Response, toResponse)
import System.Directory (removeFile)

-- | Given a list and a default value, returns the head of the list, or the default value
-- if the list is empty.
safeHead :: a -> [a] -> a
safeHead listHead [] = listHead
safeHead _ (listHead:_) = listHead

-- | Creates and converts an SVG file to an image file, deletes them both and
-- returns the image data as a response.
returnImageData :: String -> String -> IO Response
returnImageData svgFilename imageFilename = do
imageData <- BS.readFile imageFilename
_ <- removeFile imageFilename
_ <- removeFile svgFilename
-- | Reads the data in an image file and returns the data as a response.
readImageData :: String -> IO Response
readImageData imageFileName = do
imageData <- BS.readFile imageFileName
let encodedData = BEnc.encode imageData
return $ toResponse encodedData
2 changes: 2 additions & 0 deletions courseography.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ library
split,
system-filepath,
tagsoup,
temporary,
text,
time,
tls,
Expand Down Expand Up @@ -271,6 +272,7 @@ executable courseography
stylish-haskell,
system-filepath,
tagsoup,
temporary,
text,
time,
tls,
Expand Down