packages feed

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

{-# LANGUAGE OverloadedStrings #-}

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

Converts raw CloudKit records into the Notes domain types ('NoteSummary',
'NoteFolder', 'Note').
-}
module Network.HStratus.Internal.Notes.NoteData
  ( noteRecordToSummary
  , noteRecordToFolder
  , noteRecordToNote
  , parseSummariesFromQuery
  , parseFoldersFromQuery
  , parseSummariesFromChanges
  , parseFoldersFromChanges
  )
where

import Control.Monad (guard)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Base64 as B64
import Data.Int (Int64)
import qualified Data.Map.Strict as Map
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import qualified Data.Text.Encoding as TE
import Data.Time (UTCTime)
import Network.HStratus.Internal.Notes.CloudKit
  ( CKField (..)
  , CKQueryResponse (..)
  , CKRecord (..)
  , CKRecordRef (..)
  , CKZoneChangesResponse (..)
  , CKZoneChangesZone (..)
  , parseMillisTimestamp
  )
import Network.HStratus.Internal.Notes.Note
  ( FolderId (..)
  , Note (..)
  , NoteFolder (..)
  , NoteId (..)
  , NoteSummary (..)
  )


-- | Convert a CloudKit record to a 'NoteSummary'; returns @Nothing@ if the record type is not @Note@ or @PasswordProtectedNote@.
noteRecordToSummary :: CKRecord -> Maybe NoteSummary
noteRecordToSummary rec = do
  rt <- crType rec
  guard (rt == "Note" || rt == "PasswordProtectedNote" || rt == "SearchIndexes")
  pure
    NoteSummary
      { nsId = NoteId (crName rec)
      , nsTitle = fieldEncryptedBytesAsText "TitleEncrypted" rec
      , nsSnippet = fieldEncryptedBytesAsText "SnippetEncrypted" rec
      , nsModified = fieldTimestamp "ModificationDate" rec
      , nsFolderId = fieldFolderId "Folder" rec
      , nsDeleted = maybe False (/= 0) (fieldInt64 "Deleted" rec)
      , nsLocked = rt == "PasswordProtectedNote"
      }


-- | Convert a CloudKit record to a 'NoteFolder'; returns @Nothing@ if the record type is not @Folder@.
noteRecordToFolder :: CKRecord -> Maybe NoteFolder
noteRecordToFolder rec = do
  rt <- crType rec
  guard (rt == "Folder")
  pure
    NoteFolder
      { nfId = FolderId (crName rec)
      , nfName = fieldEncryptedBytesAsText "TitleEncrypted" rec
      }


-- | Convert a CloudKit record to a 'Note' with decoded body bytes; returns @Nothing@ if the record is not a note or the body is absent.
noteRecordToNote :: CKRecord -> Maybe Note
noteRecordToNote rec = do
  summary <- noteRecordToSummary rec
  bodyText <- fieldEncryptedBytes "TextDataEncrypted" rec
  bodyBytes <- decodeBase64Text bodyText
  pure Note{noteInfo = summary, noteBodyBytes = bodyBytes}


-- | Extract all note summaries from a CloudKit query response.
parseSummariesFromQuery :: CKQueryResponse -> [NoteSummary]
parseSummariesFromQuery = mapMaybe noteRecordToSummary . qrRecords


-- | Extract all note folders from a CloudKit query response.
parseFoldersFromQuery :: CKQueryResponse -> [NoteFolder]
parseFoldersFromQuery = mapMaybe noteRecordToFolder . qrRecords


-- | Extract all note summaries from a CloudKit zone-changes response.
parseSummariesFromChanges :: CKZoneChangesResponse -> [NoteSummary]
parseSummariesFromChanges = mapMaybe noteRecordToSummary . allZoneRecords


-- | Extract all note folders from a CloudKit zone-changes response.
parseFoldersFromChanges :: CKZoneChangesResponse -> [NoteFolder]
parseFoldersFromChanges = mapMaybe noteRecordToFolder . allZoneRecords


-- Helpers

allZoneRecords :: CKZoneChangesResponse -> [CKRecord]
allZoneRecords = concatMap zczRecords . zcrZones


fieldInt64 :: Text -> CKRecord -> Maybe Int64
fieldInt64 key rec = case Map.lookup key (crFields rec) of
  Just (CKInt64Field i) -> Just i
  _otherwise -> Nothing


fieldTimestamp :: Text -> CKRecord -> Maybe UTCTime
fieldTimestamp key rec = case Map.lookup key (crFields rec) of
  Just (CKTimestampField ms) -> Just (parseMillisTimestamp ms)
  _otherwise -> Nothing


fieldFolderId :: Text -> CKRecord -> Maybe FolderId
fieldFolderId key rec = case Map.lookup key (crFields rec) of
  Just (CKReferenceField ref) -> Just (FolderId (rrRecordName ref))
  _otherwise -> Nothing


fieldEncryptedBytes :: Text -> CKRecord -> Maybe Text
fieldEncryptedBytes key rec = case Map.lookup key (crFields rec) of
  Just (CKEncryptedBytesField t) -> Just t
  _otherwise -> Nothing


-- Decode a base64-encoded ENCRYPTED_BYTES field to UTF-8 text.
-- For unprotected accounts, titles and snippets are plain UTF-8 in base64.
fieldEncryptedBytesAsText :: Text -> CKRecord -> Maybe Text
fieldEncryptedBytesAsText key rec = do
  b64 <- fieldEncryptedBytes key rec
  bs <- decodeBase64Text b64
  case TE.decodeUtf8' bs of
    Left _ -> Nothing
    Right t -> Just t


decodeBase64Text :: Text -> Maybe ByteString
decodeBase64Text t = case B64.decode (TE.encodeUtf8 t) of
  Left _ -> Nothing
  Right bs -> Just bs