packages feed

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

{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}

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

Constructs HTTP requests and JSON request bodies for the iCloud Notes
CloudKit API.
-}
module Network.HStratus.Internal.Notes.Endpoints
  ( NotesEndpoints
  , mkNotesEndpoints
  , queryReq
  , lookupReq
  , changesReq
  , foldersBody
  , recentsBody
  , lookupBody
  , changesBody
  )
where

import Data.Aeson (Value, encode, object, (.=))
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as LBS
import Data.Text (Text)
import qualified Data.Text as Text
import Network.HStratus.Http.Common
  ( icloudBrowserHeaders
  , lookupWebservice
  , stripTrailingSlash
  , withHeaders
  )
import Network.HStratus.Session (AccountData (..), Session (..))
import Network.HTTP.Client
  ( Request (..)
  )
import Network.HTTP.Types (methodPost)


-- | Base request for the iCloud Notes CloudKit database API.
data NotesEndpoints = NotesEndpoints
  { neBaseReq :: !Request
  -- ^ base request targeting the Notes CloudKit database path
  , neQueryString :: !BS8.ByteString
  -- ^ shared query string appended to every Notes API request
  }


{- | Construct 'NotesEndpoints' from the account data returned after login.

Fails if the @ckdatabasews@ service URL is absent from the account data.
-}
mkNotesEndpoints :: AccountData -> Session -> IO NotesEndpoints
mkNotesEndpoints ad sess = do
  svcReq <- lookupWebservice "ckdatabasews" (adWebservices ad)
  let baseReq =
        withHeaders icloudBrowserHeaders $
          svcReq
            { path =
                stripTrailingSlash (path svcReq)
                  <> "/database/1/com.apple.notes/production/private"
            }
      qs =
        "remapEnums=true&getCurrentSyncToken=true&clientId="
          <> BS8.pack (Text.unpack (sessionClientId sess))
  pure NotesEndpoints{neBaseReq = baseReq, neQueryString = qs}


-- | Build the @POST /records/query@ request.
queryReq :: NotesEndpoints -> Request
queryReq = notesReq "/records/query"


-- | Build the @POST /records/lookup@ request.
lookupReq :: NotesEndpoints -> Request
lookupReq = notesReq "/records/lookup"


-- | Build the @POST /changes/zone@ request.
changesReq :: NotesEndpoints -> Request
changesReq = notesReq "/changes/zone"


notesReq :: BS8.ByteString -> NotesEndpoints -> Request
notesReq suffix ep =
  (neBaseReq ep)
    { path = path (neBaseReq ep) <> suffix
    , method = methodPost
    , queryString = neQueryString ep
    }


{- | Build the JSON body for a folders query.  Pass the previous response's
@continuationMarker@ to page through results.
-}
foldersBody :: Int -> Maybe Value -> LBS.ByteString
foldersBody limit marker = encode $ object $ base <> cont
 where
  base =
    [ "query"
        .= object
          [ "recordType" .= ("SearchIndexes" :: Text)
          , "filterBy" .= [indexFilter "parentless"]
          ]
    , "zoneID" .= notesZoneId
    , "resultsLimit" .= min notesMaxResults limit
    ]
  cont = maybe [] (\m -> ["continuationMarker" .= m]) marker


{- | Build the JSON body for a recent-notes query.  Pass the previous
response's @continuationMarker@ to page through results.
-}
recentsBody :: Int -> Maybe Value -> LBS.ByteString
recentsBody limit marker = encode $ object $ base <> cont
 where
  base =
    [ "query"
        .= object
          [ "recordType" .= ("SearchIndexes" :: Text)
          , "filterBy" .= [indexFilter "recents"]
          , "sortBy"
              .= [ object
                     [ "fieldName" .= ("modTime" :: Text)
                     , "ascending" .= False
                     ]
                 ]
          ]
    , "zoneID" .= notesZoneId
    , "resultsLimit" .= min notesMaxResults limit
    ]
  cont = maybe [] (\m -> ["continuationMarker" .= m]) marker


-- | Build the JSON body for a record lookup by name.
lookupBody :: [Text] -> LBS.ByteString
lookupBody names =
  encode $
    object
      [ "records" .= map (\n -> object ["recordName" .= n]) names
      , "zoneID" .= notesZoneId
      ]


{- | Build the JSON body for a zone-changes request.  Pass the previous
@syncToken@ to fetch only changes since that token.
-}
changesBody :: Maybe Text -> LBS.ByteString
changesBody syncToken =
  encode $
    object
      ["zones" .= [object $ zoneBase <> syncPart]]
 where
  zoneBase =
    [ "zoneID" .= notesZoneId
    , "desiredRecordTypes" .= [noteRecordType]
    ]
  syncPart = maybe [] (\t -> ["syncToken" .= t]) syncToken


-- Helpers

noteRecordType :: Text
noteRecordType = "Note"


notesMaxResults :: Int
notesMaxResults = 200


notesZoneId :: Value
notesZoneId =
  object
    [ "zoneName" .= ("Notes" :: Text)
    , "zoneType" .= ("REGULAR_CUSTOM_ZONE" :: Text)
    ]


indexFilter :: Text -> Value
indexFilter val =
  object
    [ "comparator" .= ("EQUALS" :: Text)
    , "fieldName" .= ("indexName" :: Text)
    , "fieldValue" .= object ["type" .= ("STRING" :: Text), "value" .= val]
    ]