packages feed

purebred-email-0.5: src/Data/IMF/DateTime.hs

-- This file is part of purebred-email
-- Copyright (C) 2021  Fraser Tweedale
--
-- purebred-email is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program.  If not, see <http://www.gnu.org/licenses/>.

{-# LANGUAGE OverloadedStrings #-}

module Data.IMF.DateTime
  ( dateTime
  ) where

import Control.Applicative ((<|>), optional)
import Control.Monad (guard)
import Data.Functor (($>))

import Data.Attoparsec.ByteString as A
import Data.Attoparsec.ByteString.Char8 (char8, isDigit_w8)
import qualified Data.ByteString as B
import qualified Data.Time
import Data.Time
  ( Day, DayOfWeek(..), LocalTime(LocalTime), TimeOfDay, TimeZone(TimeZone)
  , ZonedTime(ZonedTime), fromGregorianValid, makeTimeOfDayValid
  , minutesToTimeZone, hoursToTimeZone, utc
  )
import Data.IMF.Syntax (fws, optionalCFWS, optionalFWS)

dateTime :: Parser ZonedTime
dateTime = do
  dow <- optional (dayOfWeek <* char8 ',')
  theDate <- date

  -- ensure day of week matches date
  case dow of
    Just dow' | Data.Time.dayOfWeek theDate /= dow'
      -> fail "day of week inconsistent with date"
    _ -> pure ()

  tod <- timeOfDay
  z <- zone
  _ <- optionalCFWS

  pure $ ZonedTime (LocalTime theDate tod) z


dayOfWeek :: Parser DayOfWeek
dayOfWeek = optionalFWS *> dayName

dayName :: Parser DayOfWeek
dayName =
  string "Mon" $> Monday
  <|> string "Tue" $> Tuesday
  <|> string "Wed" $> Wednesday
  <|> string "Thu" $> Thursday
  <|> string "Fri" $> Friday
  <|> string "Sat" $> Saturday
  <|> string "Sun" $> Sunday
  <|> fail "invalid day-of-week"

date :: Parser Day
date = do
  d <- day
  m <- month
  y <- year
  case fromGregorianValid y m d of
    Just r -> pure r
    Nothing -> fail "invalid date"

day :: Parser Int
day = optionalFWS *> go <* fws
  where
  go = (twoDigit <|> digit) >>= check (\n -> n > 0 && n <= 31) "day out of range"

month :: Parser Int
month =
  string "Jan" $> 1
  <|> string "Feb" $> 2
  <|> string "Mar" $> 3
  <|> string "Apr" $> 4
  <|> string "May" $> 5
  <|> string "Jun" $> 6
  <|> string "Jul" $> 7
  <|> string "Aug" $> 8
  <|> string "Sep" $> 9
  <|> string "Oct" $> 10
  <|> string "Nov" $> 11
  <|> string "Dec" $> 12
  <|> fail "invalid month"

year :: Parser Integer
year = fws *> (go >>= check (>= 1900) "year cannot be < 1900") <* fws
  where
  go = fourOrMoreDigit <|> obsYear <|> fail "too few digits in year"
  fourOrMoreDigit = do
    digits <- A.takeWhile isDigit_w8
    guard (B.length digits >= 4)
    pure (B.foldl' step 0 digits)
  step r a = r * 10 + fromIntegral (a - 48)
  obsYear = do
    yy <- twoDigit
    fromIntegral
      . maybe (yy + if yy <= 49 then 2000 else 1900) (1900 + yy * 10 +)
      <$> optional digit

timeOfDay :: Parser TimeOfDay
timeOfDay = do
  hour <- twoDigit
  _ <- char8 ':'
  minute <- twoDigit
  second <- char8 ':' *> twoDigit <|> pure 0
  case makeTimeOfDayValid hour minute (fromIntegral second) of
    Nothing -> fail "invalid time-of-day"
    Just tod -> pure tod

zone :: Parser TimeZone
zone = fws *> (go <|> obsZone)
  where
  go = do
    posNeg <- char8 '+' $> id <|> char8 '-' $> negate
    h <- twoDigit
    m <- twoDigit
    guardFail (m <= 59) "zone minutes must be in range 0..59"
    pure $ minutesToTimeZone (posNeg (h * 60 + m))

obsZone :: Parser TimeZone
obsZone =
  utc <$ (string "GMT" <|> string "UT")
  <|> usZone
  <|> milZone
  where
  usZone = do
    (off, c1) <-
      charVal (-5) 'E'      -- eastern
      <|> charVal (-6) 'C'  -- central
      <|> charVal (-7) 'M'  -- mountain
      <|> charVal (-8) 'P'  -- pacific
    (dst, c2) <- charVal 0 'S' <|> charVal 1 'D'  -- standard / dst
    _ <- char8 'T'
    pure $ TimeZone ((off + dst) * 60) (dst == 1) (c1:c2:"T")
  charVal a c = (a, c) <$ char8 c
  milZone =
    utc <$ (char8 'Z' <|> char8 'z')
    <|> go     id 0x40 0x41 0x49  -- A..I
    <|> go     id 0x41 0x4b 0x4d  -- K..M
    <|> go negate 0x4d 0x4c 0x59  -- N..Y
    <|> go     id 0x60 0x61 0x69  -- a..i
    <|> go     id 0x61 0x6b 0x6d  -- k..m
    <|> go negate 0x6d 0x6e 0x79  -- n..y
  go f off lo hi =
    hoursToTimeZone . f . subtract off . fromIntegral
    <$> satisfy (\c -> c >= lo && c <= hi)


guardFail :: Bool -> String -> Parser ()
guardFail True _ = pure ()
guardFail False s = fail s

check :: (a -> Bool) -> String -> a -> Parser a
check f s a = guardFail (f a) s $> a

digit :: Parser Int
digit = (\c -> fromIntegral (c - 48)) <$> satisfy isDigit_w8

twoDigit :: Parser Int
twoDigit = (\hi lo -> hi * 10 + lo) <$> digit <*> digit