packages feed

hstratus-notes-0.1.0.0: src-internal/Network/HStratus/Internal/Notes/CloudKit.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE TypeFamilies #-}

{- |
Module      : Network.HStratus.Internal.Notes.CloudKit
Copyright   : (c) 2026 Tim Emiola
Maintainer  : Tim Emiola <adetokunbo@emio.la>
SPDX-License-Identifier: BSD-3-Clause

CloudKit JSON response types for the Notes CloudKit endpoints, including
records, assets, zone-change responses, and query responses.
-}
module Network.HStratus.Internal.Notes.CloudKit
  ( CKZoneId (..)
  , CKRecordRef (..)
  , CKAsset (..)
  , CKTimestamp (..)
  , CKField (..)
  , CKRecord (..)
  , CKQueryResponse (..)
  , CKLookupResponse (..)
  , CKZoneChangesZone (..)
  , CKZoneChangesResponse (..)
  , parseMillisTimestamp
  )
where

import Control.Monad (guard)
import Data.Aeson
  ( FromJSON (..)
  , Object
  , Value
  , withObject
  , (.!=)
  , (.:)
  , (.:?)
  )
import Data.Aeson.Types (Parser)
import Data.Foldable (asum)
import Data.Int (Int64)
import Data.Map.Strict (Map)
import Data.Proxy (Proxy (..))
import Data.Text (Text, pack)
import Data.Time (UTCTime)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)


-- | Convert a millisecond-precision POSIX timestamp to 'UTCTime'.
parseMillisTimestamp :: Int64 -> UTCTime
parseMillisTimestamp ms = posixSecondsToUTCTime (fromIntegral ms / 1000)


-- | CloudKit zone identifier.
data CKZoneId = CKZoneId
  { czName :: Text
  -- ^ zone name (e.g. @Notes@)
  , czType :: Text
  -- ^ zone type (e.g. @REGULAR_CUSTOM_ZONE@)
  }
  deriving (Eq, Show)


instance FromJSON CKZoneId where
  parseJSON = withObject "CKZoneId" $ \o ->
    CKZoneId
      <$> o .: "zoneName"
      <*> o .: "zoneType"


-- | A reference to another CloudKit record.
data CKRecordRef = CKRecordRef
  { rrRecordName :: Text
  -- ^ @recordName@ of the referenced record
  , rrAction :: Text
  -- ^ referential integrity action (e.g. @NONE@, @DELETE_SELF@)
  }
  deriving (Eq, Show)


instance FromJSON CKRecordRef where
  parseJSON = withObject "CKRecordRef" $ \o ->
    CKRecordRef
      <$> o .: "recordName"
      <*> o .: "action"


-- | A CloudKit asset: an encrypted file attachment associated with a record.
data CKAsset = CKAsset
  { caDownloadUrl :: Text
  -- ^ pre-signed URL from which the asset content can be downloaded
  , caFileChecksum :: Text
  -- ^ SHA-256 checksum of the encrypted asset content
  , caRefChecksum :: Text
  -- ^ reference checksum used when committing an upload
  , caWrappingKey :: Text
  -- ^ encryption wrapping key for the asset
  , caSize :: Int64
  -- ^ byte size of the encrypted asset content
  }
  deriving (Eq, Show)


instance FromJSON CKAsset where
  parseJSON = withObject "CKAsset" $ \o ->
    CKAsset
      <$> o .: "downloadURL"
      <*> o .: "fileChecksum"
      <*> o .: "referenceChecksum"
      <*> o .: "wrappingKey"
      <*> o .: "size"


-- | A CloudKit creation or modification timestamp with the responsible user.
data CKTimestamp = CKTimestamp
  { ctTimestamp :: Int64
  -- ^ millisecond-precision POSIX timestamp
  , ctUserRecordName :: Text
  -- ^ @recordName@ of the user who created or last modified the record
  }
  deriving (Eq, Show)


instance FromJSON CKTimestamp where
  parseJSON = withObject "CKTimestamp" $ \o ->
    CKTimestamp
      <$> o .: "timestamp"
      <*> o .: "userRecordName"


-- Internal newtypes: give distinct Haskell types to CK tags that share a
-- primitive (Text covers "STRING"/"ENCRYPTED_BYTES"; Int64 covers
-- "INT64"/"TIMESTAMP"), making CKFieldTag a total function.
newtype CKString = CKString Text
  deriving (FromJSON)


newtype CKEncryptedBytes = CKEncryptedBytes Text
  deriving (FromJSON)


newtype CKInt64Value = CKInt64Value Int64
  deriving (FromJSON)


newtype CKTimestampValue = CKTimestampValue Int64
  deriving (FromJSON)


-- Single source of truth mapping each value type to its CloudKit "type" tag.
type family CKFieldTag a :: Symbol where
  CKFieldTag CKString = "STRING"
  CKFieldTag CKInt64Value = "INT64"
  CKFieldTag CKTimestampValue = "TIMESTAMP"
  CKFieldTag CKEncryptedBytes = "ENCRYPTED_BYTES"
  CKFieldTag CKRecordRef = "REFERENCE"
  CKFieldTag [CKRecordRef] = "REFERENCE_LIST"
  CKFieldTag CKAsset = "ASSETID"


-- Confirms the pre-parsed "type" tag matches CKFieldTag a, then parses "value".
matchField
  :: forall a
   . (KnownSymbol (CKFieldTag a), FromJSON a)
  => Text
  -> Object
  -> Parser a
matchField typ o = do
  guard (typ == pack (symbolVal (Proxy :: Proxy (CKFieldTag a))))
  o .: "value"


-- | A single typed field value in a CloudKit record.
data CKField
  = -- | a plain text (@STRING@) field value
    CKStringField Text
  | -- | a 64-bit integer (@INT64@) field value
    CKInt64Field Int64
  | -- | a millisecond POSIX timestamp (@TIMESTAMP@) field value
    CKTimestampField Int64
  | -- | an encrypted bytes (@ENCRYPTED_BYTES@) field, base64-encoded
    CKEncryptedBytesField Text
  | -- | a single record reference (@REFERENCE@) field value
    CKReferenceField CKRecordRef
  | -- | a list of record references (@REFERENCE_LIST@) field value
    CKReferenceListField [CKRecordRef]
  | -- | an asset identifier (@ASSETID@) field value
    CKAssetIdField CKAsset
  | -- | a field with an unrecognised type tag and its raw JSON value
    CKUnknownField Text Value
  deriving (Eq, Show)


instance FromJSON CKField where
  parseJSON = withObject "CKField" $ \o -> do
    typ <- o .: "type" :: Parser Text
    asum
      [ CKStringField . (\(CKString t) -> t) <$> matchField typ o
      , CKInt64Field . (\(CKInt64Value i) -> i) <$> matchField typ o
      , CKTimestampField . (\(CKTimestampValue i) -> i) <$> matchField typ o
      , CKEncryptedBytesField . (\(CKEncryptedBytes t) -> t) <$> matchField typ o
      , CKReferenceField <$> matchField typ o
      , CKReferenceListField <$> matchField typ o
      , CKAssetIdField <$> matchField typ o
      , CKUnknownField typ <$> o .: "value"
      ]


-- | A CloudKit record with its name, type, fields, and metadata.
data CKRecord = CKRecord
  { crName :: Text
  -- ^ unique identifier for the record within its zone
  , crType :: Maybe Text
  -- ^ record type (e.g. @Note@, @Folder@); @Nothing@ in delete tombstones
  , crChangeTag :: Maybe Text
  -- ^ opaque version tag used for conflict detection
  , crZoneId :: Maybe CKZoneId
  -- ^ zone containing this record; @Nothing@ in some response shapes
  , crFields :: Map Text CKField
  -- ^ typed field values keyed by field name; empty when the record is deleted
  , crCreated :: Maybe CKTimestamp
  -- ^ creation timestamp and user; @Nothing@ when absent
  , crModified :: Maybe CKTimestamp
  -- ^ last-modification timestamp and user; @Nothing@ when absent
  , crDeleted :: Maybe Bool
  -- ^ @Just True@ for delete tombstones; @Nothing@ otherwise
  }
  deriving (Eq, Show)


instance FromJSON CKRecord where
  parseJSON = withObject "CKRecord" $ \o ->
    CKRecord
      <$> o .: "recordName"
      <*> o .:? "recordType"
      <*> o .:? "recordChangeTag"
      <*> o .:? "zoneID"
      <*> o .:? "fields" .!= mempty
      <*> o .:? "created"
      <*> o .:? "modified"
      <*> o .:? "deleted"


-- | Response body for a CloudKit record query.
data CKQueryResponse = CKQueryResponse
  { qrRecords :: [CKRecord]
  -- ^ records returned by the query
  , qrContinuationMarker :: Maybe Value
  -- ^ pagination cursor; @Nothing@ when all results fit in one response
  }
  deriving (Eq, Show)


instance FromJSON CKQueryResponse where
  parseJSON = withObject "CKQueryResponse" $ \o ->
    CKQueryResponse
      <$> o .: "records"
      <*> o .:? "continuationMarker"


-- | Response body for a CloudKit record lookup by name.
data CKLookupResponse = CKLookupResponse
  { lrRecords :: [CKRecord]
  -- ^ records returned by the lookup, in the same order as the request
  , lrSyncToken :: Maybe Text
  -- ^ sync token for subsequent zone-changes requests; @Nothing@ when absent
  }
  deriving (Eq, Show)


instance FromJSON CKLookupResponse where
  parseJSON = withObject "CKLookupResponse" $ \o ->
    CKLookupResponse
      <$> o .: "records"
      <*> o .:? "syncToken"


-- | Per-zone section of a CloudKit zone-changes response.
data CKZoneChangesZone = CKZoneChangesZone
  { zczZoneId :: CKZoneId
  -- ^ identifier of the zone these changes belong to
  , zczSyncToken :: Maybe Text
  -- ^ opaque token for the next zone-changes request for this zone
  , zczMoreComing :: Maybe Bool
  -- ^ @Just True@ when further pages of changes remain for this zone
  , zczRecords :: [CKRecord]
  -- ^ records that changed (or were deleted) in this zone
  }
  deriving (Eq, Show)


instance FromJSON CKZoneChangesZone where
  parseJSON = withObject "CKZoneChangesZone" $ \o ->
    CKZoneChangesZone
      <$> o .: "zoneID"
      <*> o .:? "syncToken"
      <*> o .:? "moreComing"
      <*> o .:? "records" .!= []


-- | Top-level response body for a CloudKit zone-changes request.
newtype CKZoneChangesResponse = CKZoneChangesResponse
  { zcrZones :: [CKZoneChangesZone]
  -- ^ per-zone change sets included in this response
  }
  deriving (Eq, Show)


instance FromJSON CKZoneChangesResponse where
  parseJSON = withObject "CKZoneChangesResponse" $ \o ->
    CKZoneChangesResponse <$> o .: "zones"