{-# LANGUAGE OverloadedStrings #-} -- | -- Module: NetSpider.Timestamp -- Description: Timestamp type -- Maintainer: Toshio Ito -- -- module NetSpider.Timestamp ( -- * The type Timestamp(..), -- * Construction fromEpochMillisecond, now, -- * Manipulation addSec, -- * Convert to Timestamp parseTimestamp, fromS, fromZonedTime, fromUTCTime, fromSystemTime, fromLocalTime, -- * Convert from Timestamp toTime, toSystemTime, showTimestamp, showEpochTime ) where import Control.Applicative ((<$>), (<*>), (<*), (*>), optional, empty) import Data.Aeson (FromJSON(..), ToJSON(..), Value(..), (.:), (.=)) import qualified Data.Aeson as Aeson import Data.Char (isDigit) import Data.Int (Int64) import Data.List (sortOn) import Data.Monoid ((<>)) import Data.Text (Text, pack, unpack) import qualified Data.Text as Text import Data.Time.Calendar (Day, fromGregorian) import Data.Time.Clock (UTCTime) import Data.Time.Clock.System (utcToSystemTime, SystemTime(..), systemToUTCTime) import qualified Data.Time.Format as DTFormat import Data.Time.LocalTime ( TimeZone(..), getZonedTime, ZonedTime(..), zonedTimeToUTC, LocalTime(LocalTime), localTimeToUTC, TimeOfDay(TimeOfDay), utcToLocalTime, utcToZonedTime ) import qualified Data.Time.LocalTime as LocalTime import qualified Text.ParserCombinators.ReadP as P import Text.Read (readEither) import Text.Printf (printf) import NetSpider.GraphML.Attribute ( ToAttributes(..), AttributeValue(..) ) -- | Timestamp when graph elements are observed. data Timestamp = Timestamp { epochTime :: Int64, -- ^ Milliseconds since the epoch. The epoch is usually the -- beginning of year 1970. timeZone :: Maybe TimeZone } deriving (Show,Eq) -- | Compare by 'epochTime' only. 'timeZone' is not used. instance Ord Timestamp where compare l r = compare (epochTime l) (epochTime r) -- | It can parse JSON string or object. If the input is a JSON -- string, it is parsed by 'parseTimestamp'. -- -- @since 0.4.1.0 instance FromJSON Timestamp where parseJSON (String t) = maybe (fail err_msg) return $ parseTimestamp ts where ts = unpack t err_msg = "Invalid Timestamp string: " ++ ts parseJSON (Object o) = Timestamp <$> (o .: "epoch_time") <*> parseTZ o where parseTZ ob = optional $ TimeZone <$> (ob .: "tz_offset_min") <*> (ob .: "tz_summer_only") <*> (ob .: "tz_name") parseJSON _ = empty -- | Convert to a JSON object. -- -- @since 0.4.1.0 instance ToJSON Timestamp where toJSON t = Aeson.object $ [ "epoch_time" .= epochTime t ] ++ tz_fields where tz_fields = (fmap . fmap) toJSON $ map fixKeyPrefix $ toAttributes $ timeZone t fixKeyPrefix (k, v) = (Text.tail k, v) -- | @since 0.4.1.0 instance ToAttributes Timestamp where toAttributes t = [ ("@timestamp", AttrLong $ toInteger $ epochTime t), ("@timestamp_str", AttrString $ showTimestamp t) ] ++ timezone_attrs where timezone_attrs = maybe [] toAttributes $ timeZone t -- | Make 'Timestamp' from milliseconds from the epoch. 'timeZone' is -- 'Nothing'. -- -- @since 0.2.0.0 fromEpochMillisecond :: Int64 -> Timestamp fromEpochMillisecond msec = Timestamp msec Nothing -- | Show 'Timestamp' with a basic ISO 8601 format. -- -- >>> showTimestamp $ fromS "2019-10-20T12:45:00" -- "2019-10-20T12:45:00.000" -- >>> showTimestamp $ fromS "1999-03-21T10:11Z" -- "1999-03-21T10:11:00.000Z" -- >>> showTimestamp $ fromS "2016-11-30T22:03:00.034+09:00" -- "2016-11-30T22:03:00.034+09:00" -- >>> showTimestamp $ fromS "2000-04-07T09:31-05:00" -- "2000-04-07T09:31:00.000-05:00" -- -- @since 0.3.1.0 showTimestamp :: Timestamp -> Text showTimestamp = pack . either simpleFormat formatZT . toTime where dtFormat :: DTFormat.FormatTime t => String -> t -> String dtFormat = DTFormat.formatTime DTFormat.defaultTimeLocale simpleFormat :: DTFormat.FormatTime t => t -> String simpleFormat = dtFormat "%Y-%m-%dT%H:%M:%S.%03q" formatZT zt = simpleFormat zt <> formatZone (zonedTimeZone zt) formatZone z = if timeZoneName z == "" then formatOffset $ timeZoneMinutes z else if z == LocalTime.utc then "Z" else dtFormat "%Z" z formatOffset o = sign <> hour <> ":" <> minute where sign = if o < 0 then "-" else "+" abo = abs o hour = printf "%02d" (abo `div` 60) minute = printf "%02d" (abo `mod` 60) -- | Show 'epochTime' of 'Timestamp' as 'Text'. -- -- @since 0.2.0.0 showEpochTime :: Timestamp -> Text showEpochTime = pack . show . epochTime -- | Convert to 'LocalTime' (if the 'Timestamp' has no time zone) or -- 'ZonedTime' (otherwise). If it makes the 'LocalTime' as if the time -- zone was UTC. -- -- @since 0.3.1.0 toTime :: Timestamp -> Either LocalTime ZonedTime toTime ts = maybe (Left localtime) (Right . toZT) $ timeZone ts where utctime = systemToUTCTime $ toSystemTime ts localtime = utcToLocalTime LocalTime.utc utctime toZT tz = utcToZonedTime tz utctime -- | Convert 'Timestamp' to 'SystemTime'. It discards 'timeZone' -- field. -- -- >>> toSystemTime $ fromEpochMillisecond 1043221 -- MkSystemTime {systemSeconds = 1043, systemNanoseconds = 221000000} -- >>> toSystemTime $ fromEpochMillisecond (-192332) -- MkSystemTime {systemSeconds = -193, systemNanoseconds = 668000000} -- -- @since 0.3.1.0 toSystemTime :: Timestamp -> SystemTime toSystemTime ts = MkSystemTime sec nsec where epoch_time = epochTime ts sec = epoch_time `div` 1000 nsec = fromIntegral (epoch_time `mod` 1000) * 1000000 -- | Get the current system time. -- -- @since 0.2.0.0 now :: IO Timestamp now = fmap fromZonedTime $ getZonedTime -- | @since 0.2.0.0 fromZonedTime :: ZonedTime -> Timestamp fromZonedTime zt = (fromUTCTime $ zonedTimeToUTC zt) { timeZone = Just $ zonedTimeZone zt } -- | @since 0.2.0.0 fromUTCTime :: UTCTime -> Timestamp fromUTCTime ut = (fromSystemTime $ utcToSystemTime ut) { timeZone = Just LocalTime.utc } -- | @since 0.2.0.0 fromSystemTime :: SystemTime -> Timestamp fromSystemTime stime = Timestamp { epochTime = epoch_time, timeZone = Nothing } where epoch_time = (systemSeconds stime * 1000) + fromIntegral (systemNanoseconds stime `div` 1000000) -- | Covert 'LocalTime' to 'Timestamp' assuming it's in UTC time -- zone. The 'timeZone' field is 'Nothing'. -- -- @since 0.2.0.0 fromLocalTime :: LocalTime -> Timestamp fromLocalTime lt = (fromUTCTime $ localTimeToUTC LocalTime.utc lt) { timeZone = Nothing } -- | Add time difference (in seconds) to the 'Timestamp'. -- -- @since 0.2.0.0 addSec :: Int64 -> Timestamp -> Timestamp addSec diff ts = ts { epochTime = (+ (diff * 1000)) $ epochTime ts } -- | Unsafe version of 'parseTimestamp'. -- -- @since 0.2.0.0 fromS :: String -> Timestamp fromS s = maybe (error msg) id $ parseTimestamp s where msg = "Fail to parse " ++ s -- | Parse a string into 'Timestamp'. The format is like ISO8601 with -- a little relaxation. -- -- >>> let timeAndOffset ts = (epochTime ts, fmap timeZoneMinutes $ timeZone ts) -- >>> fmap timeAndOffset $ parseTimestamp "2018-10-11T11:20:10" -- Just (1539256810000,Nothing) -- >>> fmap timeAndOffset $ parseTimestamp "2018-10-11 11:20:10" -- Just (1539256810000,Nothing) -- >>> fmap timeAndOffset $ parseTimestamp "2015-03-23 03:33Z" -- Just (1427081580000,Just 0) -- >>> fmap timeAndOffset $ parseTimestamp "1999-01-05 20:34:44.211+09:00" -- Just (915536084211,Just 540) -- >>> fmap timeAndOffset $ parseTimestamp "2007/08/20T22:25-07:00" -- Just (1187673900000,Just (-420)) -- -- @since 0.2.0.0 parseTimestamp :: String -> Maybe Timestamp parseTimestamp s = toTs $ sortByLeftover $ P.readP_to_S parserTimestamp s where sortByLeftover = sortOn $ \(_, leftover) -> length leftover toTs ((ret, _) : _) = Just ret toTs [] = Nothing parserTimestamp :: P.ReadP Timestamp parserTimestamp = do day <- parserDay <* delim time <- parserTime mtz <- optional (parserUTC P.+++ parserOffset) let ltime = LocalTime day time case mtz of Nothing -> return $ fromLocalTime ltime Just tz -> return $ fromZonedTime $ ZonedTime ltime tz where delim = P.choice $ map P.char " T" parserRead :: Read a => String -> P.ReadP a parserRead input = either fail return $ readEither input parserDec :: Read a => P.ReadP a parserDec = parserRead =<< P.munch1 isDigit parserFracDec :: Read a => P.ReadP a parserFracDec = do int <- P.munch1 isDigit frac <- fmap (maybe "" id) $ optional ((:) <$> P.char '.' <*> P.munch1 isDigit) return $ read (int ++ frac) parserDay :: P.ReadP Day parserDay = fromGregorian <$> (parserDec <* delim) <*> (parserDec <* delim) <*> parserDec where delim = P.choice $ map P.char "-/" parserTime :: P.ReadP TimeOfDay parserTime = TimeOfDay <$> parserDec <*> (delim *> parserDec) <*> ((delim *> parserFracDec) P.<++ pure 0) where delim = P.char ':' parserUTC :: P.ReadP TimeZone parserUTC = do s <- P.get case s of 'Z' -> return LocalTime.utc c -> fail ("Not a UTC symbol: " ++ show c) data OffsetSign = OffsetPlus | OffsetMinus deriving (Show,Eq,Ord,Enum,Bounded) parserOffset :: P.ReadP TimeZone parserOffset = offsetToTz <$> osign <*> (parserDec <* delim) <*> parserDec where osign = do s <- P.get case s of '+' -> return OffsetPlus '-' -> return OffsetMinus c -> fail ("Not a sign symbol: " ++ show c) delim = optional $ P.char ':' offsetToTz :: OffsetSign -> Int -> Int -> TimeZone offsetToTz osign h m = TimeZone { timeZoneMinutes = intsign * (h * 60 + m), timeZoneSummerOnly = False, timeZoneName = "" } where intsign = case osign of OffsetPlus -> 1 OffsetMinus -> -1