packages feed

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

{-# LANGUAGE OverloadedStrings #-}

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

HTTP fetchers for the iCloud Notes CloudKit endpoints: recent notes, folders,
notes in a folder, and individual note lookup.
-}
module Network.HStratus.Internal.Notes.Download
  ( NotesError (..)
  , fetchFolders
  , fetchRecent
  , fetchNote
  , fetchNotesInFolder
  )
where

import Control.Exception (Exception, throwIO)
import Control.Monad (when)
import Data.Aeson (FromJSON, eitherDecode)
import qualified Data.ByteString.Lazy as LBS
import Data.Maybe (listToMaybe, mapMaybe)
import Network.HStratus.Http (Api, HStratusError, rawRequest)
import Network.HStratus.Internal.Notes.CloudKit
  ( CKLookupResponse (..)
  , CKQueryResponse (..)
  , CKZoneChangesResponse (..)
  , CKZoneChangesZone (..)
  )
import Network.HStratus.Internal.Notes.Endpoints
  ( NotesEndpoints
  , changesBody
  , changesReq
  , foldersBody
  , lookupBody
  , lookupReq
  , queryReq
  , recentsBody
  )
import Network.HStratus.Internal.Notes.Note
  ( FolderId
  , Note
  , NoteFolder
  , NoteId (..)
  , NoteSummary (..)
  )
import Network.HStratus.Internal.Notes.NoteData
  ( noteRecordToNote
  , parseFoldersFromQuery
  , parseSummariesFromChanges
  , parseSummariesFromQuery
  )
import Network.HTTP.Client
  ( Request
  , RequestBody (..)
  , Response (..)
  , requestBody
  , requestHeaders
  )
import Network.HTTP.Types (hContentType, statusCode)


-- | Errors that can occur during iCloud Notes API calls.
data NotesError
  = -- | the server returned an unexpected HTTP status code
    NotesHttpError Int
  | -- | a JSON response could not be decoded into the expected structure
    NotesParseError String


instance Show NotesError where
  show (NotesHttpError n) = "iCloud Notes: HTTP error " <> show n
  show (NotesParseError msg) = "iCloud Notes: parse error: " <> msg


instance Exception NotesError


instance HStratusError NotesError


-- | Fetch all note folders, following continuation markers to retrieve every page.
fetchFolders :: Api -> NotesEndpoints -> IO [NoteFolder]
fetchFolders api ep = go Nothing []
 where
  go marker acc = do
    qr <- fetchAs "fetchFolders" api (jsonReq (foldersBody 200 marker) (queryReq ep))
    let acc' = acc <> parseFoldersFromQuery qr
    case qrContinuationMarker qr of
      Nothing -> pure acc'
      Just m -> go (Just m) acc'


-- | Fetch recent note summaries, following continuation markers to retrieve every page.
fetchRecent :: Api -> NotesEndpoints -> IO [NoteSummary]
fetchRecent api ep = go Nothing []
 where
  go marker acc = do
    qr <- fetchAs "fetchRecent" api (jsonReq (recentsBody 200 marker) (queryReq ep))
    let acc' = acc <> parseSummariesFromQuery qr
    case qrContinuationMarker qr of
      Nothing -> pure acc'
      Just m -> go (Just m) acc'


-- | Fetch a single note by its 'NoteId'; returns @Nothing@ if the record is absent or cannot be decoded.
fetchNote :: Api -> NotesEndpoints -> NoteId -> IO (Maybe Note)
fetchNote api ep nid = do
  lr <- fetchAs "fetchNote" api (jsonReq (lookupBody [unNoteId nid]) (lookupReq ep))
  pure $ listToMaybe $ mapMaybe noteRecordToNote (lrRecords lr)


-- | Fetch all non-deleted note summaries belonging to the given folder, paging through zone changes.
fetchNotesInFolder :: Api -> NotesEndpoints -> FolderId -> IO [NoteSummary]
fetchNotesInFolder api ep fid = go Nothing []
 where
  go mToken acc = do
    cr <- fetchAs "fetchNotesInFolder" api (jsonReq (changesBody mToken) (changesReq ep))
    let acc' = acc <> filter inFolder (parseSummariesFromChanges cr)
    case nextToken cr of
      Nothing -> pure acc'
      Just tok -> go (Just tok) acc'
  inFolder s = nsFolderId s == Just fid && not (nsDeleted s)
  nextToken cr = case zcrZones cr of
    (z : _) | zczMoreComing z == Just True -> zczSyncToken z
    _ -> Nothing


fetchAs :: (FromJSON a) => String -> Api -> Request -> IO a
fetchAs ctx api r = rawRequest' api r >>= decodeAs ctx


rawRequest' :: Api -> Request -> IO (Response LBS.ByteString)
rawRequest' api r = do
  resp <- rawRequest api r
  checkStatus resp
  pure resp


jsonReq :: LBS.ByteString -> Request -> Request
jsonReq body r =
  r
    { requestBody = RequestBodyLBS body
    , requestHeaders = (hContentType, "application/json") : requestHeaders r
    }


checkStatus :: Response a -> IO ()
checkStatus resp =
  let code = statusCode (responseStatus resp)
   in when (code >= 400) $ throwIO (NotesHttpError code)


decodeAs :: (FromJSON a) => String -> Response LBS.ByteString -> IO a
decodeAs ctx resp =
  case eitherDecode (responseBody resp) of
    Left err -> throwIO (NotesParseError (ctx <> ": " <> err))
    Right v -> pure v