packages feed

hstratus-notes (empty) → 0.1.0.0

raw patch · 26 files changed

+3717/−0 lines, 26 filesdep +QuickCheckdep +aesondep +basesetup-changed

Dependencies added: QuickCheck, aeson, base, base64-bytestring, benri-hspec, bytestring, containers, hspec, hstratus-auth, hstratus-notes, http-client, http-types, proto3-suite, proto3-wire, temporary, text, time, wai, warp, zlib

Files

+ ChangeLog.md view
@@ -0,0 +1,9 @@+# Revision history for icloud-notes++`icloud-notes` uses [PVP Versioning][1].++## 0.1.0.0 -- 2026-07-28++* Initial version.++[1]: https://pvp.haskell.org
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright (c) 2023, Tim Emiola++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of Tim Emiola nor the names of other+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,98 @@+# hstratus-notes — access to iCloud Notes++`hstratus-notes` reads notes and folders from iCloud Notes using an authenticated+session from [`hstratus-auth`](https://github.com/adetokunbo/hstratus/tree/main/hstratus-auth/).++The library provides read-only access to the Notes CloudKit database: listing+folders, fetching recent notes, and downloading note content.+++## Disclaimer — use at your own risk++- This library is **unofficial** and not supported by Apple.+- The iCloud Notes API it uses is undocumented and may change without notice.+++## Usage++After a successful login with `hstratus-auth`, construct a `NotesApi` value and+use it to browse notes.++### Listing recent notes++```haskell+import Network.HStratus.Http (mkApi, login, AuthState (..))+import Network.HStratus.Http.Endpoints (Realm (..))+import Network.HStratus.Notes++example :: IO ()+example = do+  api <- mkApi Usual+  result <- login api+  case result of+    Authenticated sess ad -> do+      na    <- mkNotesApi ad sess api+      notes <- recentNotes na+      mapM_ print notes+    _ -> putStrLn "Unexpected result"+```++### Listing folders++```haskell+foldersExample :: NotesApi -> IO ()+foldersExample na = do+  folders <- noteFolders na+  mapM_ print folders+```++### Listing notes in a folder++```haskell+folderNotesExample :: NotesApi -> FolderId -> IO ()+folderNotesExample na fid = do+  notes <- notesInFolder na fid+  mapM_ print notes+```++### Fetching and decoding a note body++```haskell+import qualified Data.Text.IO as TIO+import Network.HStratus.Notes+import Network.HStratus.Notes.Markdown (noteToMarkdown)++getNoteExample :: NotesApi -> NoteId -> IO ()+getNoteExample na nid = do+  mnote <- getNote na nid+  case mnote of+    Nothing   -> putStrLn "Note not found"+    Just note -> do+      result <- decodeNoteBody (noteBodyBytes note)+      case result of+        Left err -> putStrLn $ "Decode error: " <> err+        Right nt -> TIO.putStrLn (noteToMarkdown nt)+```++`getNote` returns `Nothing` when the note has been deleted since the listing+was fetched.  `decodeNoteBody` returns `Left` when the compressed protobuf+cannot be decoded.+++## CLI usage++A command-line interface using this behaviour is provided by the [`hstratus`](https://github.com/adetokunbo/hstratus/tree/main/hstratus/#readme)+package.++| Command | Description |+|---------|-------------|+| [`hstratus notes list-note-folders`](https://github.com/adetokunbo/hstratus/tree/main/hstratus/#hstratus-notes-list-note-folders) | List all Notes folders |+| [`hstratus notes list-notes`](https://github.com/adetokunbo/hstratus/tree/main/hstratus/#hstratus-notes-list-notes) | List notes, optionally filtered by folder name |+| [`hstratus notes get`](https://github.com/adetokunbo/hstratus/tree/main/hstratus/#hstratus-notes-get) | Fetch and display a note body |+| [`hstratus notes export-folder`](https://github.com/adetokunbo/hstratus/tree/main/hstratus/#hstratus-notes-export-folder) | Download all notes in a folder to local files |+++---++Apple and the Apple logo are trademarks of Apple Inc., registered in the U.S. and other countries and regions.+iCloud is a service mark of Apple Inc., registered in the U.S. and other countries and regions.
+ Setup.hs view
@@ -0,0 +1,4 @@+import Distribution.Simple+++main = defaultMain
+ hstratus-notes.cabal view
@@ -0,0 +1,117 @@+cabal-version:      3.0+name:               hstratus-notes+version:            0.1.0.0+synopsis:           Access iCloud Notes+description:+  Browse notes and folders from iCloud Notes using an authenticated session+  from the @hstratus-auth@ library.++  Provides read-only access to the Notes CloudKit database: listing folders,+  fetching recent notes, and downloading note content.++  This library is unofficial and not supported by Apple. It may break+  without warning if Apple changes their API.++license:            BSD-3-Clause+license-file:       LICENSE+author:             Tim Emiola+maintainer:         Tim Emiola <adetokunbo@emio.la>+copyright:          (c) 2026 Tim Emiola+category:           Network+build-type:         Simple+tested-with:+    GHC == 9.2.8+  , GHC == 9.4.8+  , GHC == 9.6.7+  , GHC == 9.8.4+  , GHC == 9.10.2+  , GHC == 9.12.1+extra-doc-files:+  ChangeLog.md+  README.md++source-repository head+  type:     git+  location: https://github.com/adetokunbo/hstratus.git+  subdir:   hstratus-notes++library+  exposed-modules:+    Network.HStratus.Notes+    Network.HStratus.Notes.Markdown+    Network.HStratus.Notes.Note+  hs-source-dirs:   src+  build-depends:+    , base                         >=4.12 && <5+    , bytestring                   >=0.10.8 && <0.11 || >=0.11.3 && <0.13+    , hstratus-auth                >=0.1 && <0.2+    , hstratus-notes:hstratus-notes-internal+    , text                         >=1.2.3 && <2.2+    , time                         >=1.9 && <1.15+  default-language: Haskell2010+  ghc-options:      -Wall -Wincomplete-uni-patterns -Wpartial-fields -fwarn-tabs++library hstratus-notes-internal+  exposed-modules:+    Network.HStratus.Internal.Notes.CloudKit+    Network.HStratus.Internal.Notes.Decode+    Network.HStratus.Internal.Notes.Download+    Network.HStratus.Internal.Notes.Endpoints+    Network.HStratus.Internal.Notes.Markdown+    Network.HStratus.Internal.Notes.Note+    Network.HStratus.Internal.Notes.NoteData+    Network.HStratus.Internal.Notes.Proto+  hs-source-dirs:   src-internal+  build-depends:+    , aeson                >=2.0   && <2.3+    , base                 >=4.12 && <5+    , base64-bytestring    >=1.0 && <2.1+    , bytestring           >=0.10.8 && <0.11 || >=0.11.3 && <0.13+    , containers           >=0.6 && <0.8+    , http-client          >=0.5 && <0.8+    , http-types           >=0.12.1 && <0.13+    , hstratus-auth        >=0.1 && <0.2+    , proto3-suite         >=0.7 && <0.11+    , proto3-wire          >=1.0 && <2+    , text                 >=1.2.3 && <2.2+    , time                 >=1.9 && <1.15+    , zlib                 >=0.6 && <0.8+  default-language: Haskell2010+  ghc-options:      -Wall -Wincomplete-uni-patterns -Wpartial-fields -fwarn-tabs++test-suite test+  type:             exitcode-stdio-1.0+  main-is:          Spec.hs+  hs-source-dirs:   test+  other-modules:+    HStratus.Notes.Arbitraries+    HStratus.Notes.CloudKitSpec+    HStratus.Notes.DecodeSpec+    HStratus.Notes.EndpointsSpec+    HStratus.Notes.MarkdownSpec+    HStratus.Notes.NoteDataSpec+    HStratus.Notes.ProtoSpec+    HStratus.Notes.TestHelper+    HStratus.NotesSpec+  default-language: Haskell2010+  ghc-options:      -threaded -rtsopts -with-rtsopts=-N -Wall -fwarn-tabs+  build-depends:+    , aeson                        >=2.0   && <2.3+    , base+    , bytestring                   >=0.10.8 && <0.11 || >=0.11.3 && <0.13+    , containers                   >=0.6 && <0.8+    , benri-hspec                  >=0.1 && <0.3+    , hspec                        >=2.1 && <3+    , http-client                  >=0.5 && <0.8+    , http-types                   >=0.12.1 && <0.13+    , hstratus-auth                >=0.1 && <0.2+    , hstratus-notes+    , hstratus-notes:hstratus-notes-internal+    , proto3-wire                  >=1.0 && <2+    , QuickCheck                   >=2.14 && <3+    , temporary                    >=1.2 && <1.4+    , text                         >=1.2.3 && <2.2+    , time                         >=1.9 && <1.15+    , wai                          >=3.2 && <3.3+    , warp                         >=3.2 && <3.5+    , zlib                         >=0.6 && <0.8
+ src-internal/Network/HStratus/Internal/Notes/CloudKit.hs view
@@ -0,0 +1,316 @@+{-# 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"
+ src-internal/Network/HStratus/Internal/Notes/Decode.hs view
@@ -0,0 +1,122 @@+{-# LANGUAGE NamedFieldPuns #-}++{- |+Module      : Network.HStratus.Internal.Notes.Decode+Copyright   : (c) 2026 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD-3-Clause++Decodes a compressed protobuf note body (gzip or zlib) into the 'NoteText' domain type,+bridging the wire representation in "Network.HStratus.Internal.Notes.Proto"+and the public 'NoteText', 'NoteRun', and 'NoteStyle' types.+-}+module Network.HStratus.Internal.Notes.Decode+  ( decodeNoteBody+  )+where++import qualified Codec.Compression.GZip as GZip+import qualified Codec.Compression.Zlib as Zlib+import Control.Exception (SomeException, evaluate, try)+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Lazy as LBS+import Data.Maybe (fromMaybe)+import qualified Data.Text as T+import qualified Data.Text.Lazy as LT+import Network.HStratus.Internal.Notes.Note+  ( NoteRun (..)+  , NoteStyle (..)+  , NoteText (..)+  )+import Network.HStratus.Internal.Notes.Proto+  ( ProtoAttributeRun (..)+  , ProtoNote (..)+  , ProtoParagraphStyle (..)+  , decodeNoteStoreProto+  )+++{- | Decode a note body.  The input is the raw bytes from the CloudKit+@TextDataEncrypted@ field after base64-decoding (Phase 1 does this in+'noteRecordToNote').  The encoding is: compress( protobuf( NoteStoreProto ) )+where the compressor is gzip (magic @\\x1f\\x8b@) or zlib (magic @\\x78@).+Returns @Left@ if decompression fails or the protobuf cannot be parsed.+-}+decodeNoteBody :: ByteString -> IO (Either String NoteText)+decodeNoteBody bs = do+  decompressed <- try (evaluate (LBS.toStrict (decomp (LBS.fromStrict bs))))+  pure $ case (decompressed :: Either SomeException ByteString) of+    Left e -> Left ("decompression failed: " <> show e)+    Right strict -> fmap toNoteText (decodeNoteStoreProto strict)+ where+  decomp = if isGzip bs then GZip.decompress else Zlib.decompress+++isGzip :: ByteString -> Bool+isGzip bs = BS.length bs >= 2 && BS.index bs 0 == 0x1f && BS.index bs 1 == 0x8b+++-- Map ProtoNote → NoteText.  The proto layer mirrors the wire schema; this+-- function applies the domain interpretation (e.g. font_weight int → bold/italic+-- booleans, empty link string → Nothing).+toNoteText :: ProtoNote -> NoteText+toNoteText ProtoNote{pnNoteText, pnAttributeRuns} =+  NoteText+    { ntText = pnNoteText+    , ntRuns = map toNoteRun pnAttributeRuns+    }+++toNoteRun :: ProtoAttributeRun -> NoteRun+toNoteRun+  ProtoAttributeRun+    { parLength+    , parParagraphStyle+    , parFontWeight+    , parUnderlined+    , parStrikethrough+    , parAttachmentId+    , parLink+    } =+    NoteRun+      { nrLength = parLength+      , nrStyle = parParagraphStyle >>= toNoteStyle+      , -- FontWeight enum: 1=bold, 2=italic, 3=bold+italic+        nrBold = parFontWeight == 1 || parFontWeight == 3+      , nrItalic = parFontWeight == 2 || parFontWeight == 3+      , nrUnderline = parUnderlined /= 0+      , nrStrikethrough = parStrikethrough /= 0+      , nrAttachmentId = fmap LT.toStrict parAttachmentId+      , -- proto3-wire yields lazy Text; empty string means no link+        nrLink = let t = LT.toStrict parLink in if T.null t then Nothing else Just t+      }+++-- StyleType enum from notes.proto.  Values not in this list represent future+-- or unknown styles; if block_quote is set they map to StyleBody True,+-- otherwise to Nothing (rendered as plain body text).+-- style_type 0 (title) defers to block_quote: a paragraph_style with only+-- block_quote set has style_type = 0 by proto3 default, so we give blockquote+-- priority over title for that case.+toNoteStyle :: ProtoParagraphStyle -> Maybe NoteStyle+toNoteStyle+  ProtoParagraphStyle+    { ppsStyleType+    , ppsIndent+    , ppsChecked+    , ppsListStart+    , ppsBlockQuote+    } =+    let indent = fromIntegral ppsIndent+        listStart = fmap fromIntegral ppsListStart+     in case ppsStyleType of+          0 -> if ppsBlockQuote then Just (StyleBody True) else Just StyleTitle+          1 -> Just StyleHeading+          2 -> Just StyleSubheading+          4 -> Just StyleMonospaced+          100 -> Just (StyleBullet indent)+          101 -> Just (StyleDash indent)+          102 -> Just (StyleNumbered indent listStart)+          103 -> Just (StyleChecklist indent (fromMaybe False ppsChecked))+          _ -> if ppsBlockQuote then Just (StyleBody True) else Nothing
+ src-internal/Network/HStratus/Internal/Notes/Download.hs view
@@ -0,0 +1,161 @@+{-# 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
+ src-internal/Network/HStratus/Internal/Notes/Endpoints.hs view
@@ -0,0 +1,189 @@+{-# 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]+    ]
+ src-internal/Network/HStratus/Internal/Notes/Markdown.hs view
@@ -0,0 +1,302 @@+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE OverloadedStrings #-}++{- |+Module      : Network.HStratus.Internal.Notes.Markdown+Copyright   : (c) 2026 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD-3-Clause++Two-phase Markdown renderer for decoded note bodies: Phase 1 splits a+'NoteText' into paragraphs ('splitIntoParagraphs'); Phase 2 renders them+as Markdown text ('noteToMarkdown').+-}+module Network.HStratus.Internal.Notes.Markdown+  ( RawParagraph (..)+  , RawSegment (..)+  , noteToMarkdown+  , splitIntoParagraphs+  )+where++import qualified Data.IntMap.Strict as IM+import Data.List (foldl')+import Data.Text (Text)+import qualified Data.Text as T+import Network.HStratus.Internal.Notes.Note+  ( NoteRun (..)+  , NoteStyle (..)+  , NoteText (..)+  )+++{- | A paragraph extracted from a 'NoteText', with its resolved style and+ordered inline segments.+-}+data RawParagraph = RawParagraph+  { rpStyle :: Maybe NoteStyle+  -- ^ Style of the paragraph; 'Nothing' for plain body text.+  , rpSegments :: [RawSegment]+  -- ^ Inline segments in order; may be empty for blank paragraphs.+  }+  deriving (Eq, Show)+++-- | One inline span within a 'RawParagraph'.+data RawSegment = RawSegment+  { rsText :: Text+  -- ^ plain text content of the span+  , rsBold :: Bool+  -- ^ @True@ when the span should be rendered bold+  , rsItalic :: Bool+  -- ^ @True@ when the span should be rendered italic+  , rsStrikethrough :: Bool+  -- ^ @True@ when the span should be rendered with strikethrough+  , rsUnderline :: Bool+  -- ^ @True@ when the span is underlined (no Markdown equivalent; dropped during rendering)+  , rsLink :: Maybe Text+  -- ^ hyperlink URL; @Nothing@ when the span is not a link+  }+  deriving (Eq, Show)+++-- Internal state for the markdown renderer.+data RenderState = RenderState+  { rsCounters :: IM.IntMap Int+  -- ^ Counter per indent level for numbered lists.+  , rsPrevStyle :: Maybe NoteStyle+  -- ^ Style of the previous paragraph, for numbered-list group-start detection.+  }+++-- Internal fold state.+data SplitState = SplitState+  { ssRemaining :: Text+  , ssCurrentStyle :: Maybe NoteStyle+  , ssCurrentSegs :: [RawSegment] -- reversed; reversed on paragraph close+  , ssDone :: [RawParagraph] -- reversed; reversed in finalize+  }+++{- | Split a 'NoteText' into paragraphs.++Each @\\n@ in the note text closes the current paragraph and opens a new one.+Runs with @nrStyle = Nothing@ (neutral/inline-only) are absorbed into the+current paragraph rather than starting a new one; the paragraph's style is+taken from the first run in that paragraph that carries a non-'Nothing'+'nrStyle'.++@\\xFFFC@ (Unicode object replacement character) in each run's text is+replaced with @[attachment: \<id\>]@ when 'nrAttachmentId' is present, or+@[attachment]@ otherwise.+-}+splitIntoParagraphs :: NoteText -> [RawParagraph]+splitIntoParagraphs NoteText{ntText, ntRuns} =+  let finalState = foldl' processRun initialState ntRuns+   in finalize finalState+ where+  initialState =+    SplitState+      { ssRemaining = ntText+      , ssCurrentStyle = Nothing+      , ssCurrentSegs = []+      , ssDone = []+      }++  processRun :: SplitState -> NoteRun -> SplitState+  processRun st run =+    let n = max 0 (fromIntegral (nrLength run))+        (slice, remaining') = T.splitAt n (ssRemaining st)+        slice' = replaceAttachment (nrAttachmentId run) slice+        parts = T.splitOn "\n" slice'+        newStyle = maybe (nrStyle run) Just (ssCurrentStyle st)+        mkSeg txt =+          RawSegment+            { rsText = txt+            , rsBold = nrBold run+            , rsItalic = nrItalic run+            , rsStrikethrough = nrStrikethrough run+            , rsUnderline = nrUnderline run+            , rsLink = nrLink run+            }+        addSeg txt segs = if T.null txt then segs else mkSeg txt : segs+        closePara style segs =+          RawParagraph{rpStyle = style, rpSegments = reverse segs}+     in case parts of+          [] ->+            st{ssRemaining = remaining', ssCurrentStyle = newStyle}+          [single] ->+            st+              { ssRemaining = remaining'+              , ssCurrentStyle = newStyle+              , ssCurrentSegs = addSeg single (ssCurrentSegs st)+              }+          (firstPart : moreParts) ->+            let segsWithFirst = addSeg firstPart (ssCurrentSegs st)+                closedFirst = closePara newStyle segsWithFirst+                (finalDone, finalSegs) =+                  foldPartsAfterFirst mkSeg moreParts (closedFirst : ssDone st)+             in st+                  { ssRemaining = remaining'+                  , ssCurrentStyle = Nothing+                  , ssCurrentSegs = finalSegs+                  , ssDone = finalDone+                  }++  finalize :: SplitState -> [RawParagraph]+  finalize st =+    let lastPara =+          RawParagraph+            { rpStyle = ssCurrentStyle st+            , rpSegments = reverse (ssCurrentSegs st)+            }+     in reverse (lastPara : ssDone st)+++-- After the first '\n' in a run, fold over the remaining parts: all but the+-- last are closed as single-segment paragraphs; the last stays open.+foldPartsAfterFirst+  :: (Text -> RawSegment)+  -> [Text]+  -> [RawParagraph]+  -> ([RawParagraph], [RawSegment])+foldPartsAfterFirst _ [] done = (done, [])+foldPartsAfterFirst mk [p] done =+  (done, if T.null p then [] else [mk p])+foldPartsAfterFirst mk (p : ps) done =+  let segs = if T.null p then [] else [mk p]+      para = RawParagraph{rpStyle = Nothing, rpSegments = segs}+   in foldPartsAfterFirst mk ps (para : done)+++replaceAttachment :: Maybe Text -> Text -> Text+replaceAttachment mId = T.replace "\xFFFC" placeholder+ where+  placeholder =+    maybe "[attachment]" (\i -> "[attachment: " <> i <> "]") mId+++{- | Render a 'NoteText' as Markdown.++Consecutive list paragraphs are separated by a single newline; all other+paragraph boundaries use a double newline.  Empty paragraphs are dropped.+Supported paragraph styles:++* 'StyleTitle'         → @# …@+* 'StyleHeading'       → @## …@+* 'StyleSubheading'    → @### …@+* 'StyleBody True'     → @> …@ (block-quote)+* 'StyleBullet i'      → @- …@ (indented by @i × 2@ spaces)+* 'StyleDash i'        → @- …@ (indented by @i × 2@ spaces)+* 'StyleNumbered i ms' → @N. …@ (auto-counter per indent level)+* 'StyleChecklist i b' → @- [x] …@ or @- [ ] …@++'StyleMonospaced' is deferred; it renders as plain body text for now.++Inline formatting: bold (@**@), italic (@_@), strikethrough (@~~@),+link (@[text](url)@).  Underline has no Markdown equivalent and is dropped.+-}+noteToMarkdown :: NoteText -> Text+noteToMarkdown nt =+  T.concat (go (RenderState{rsCounters = IM.empty, rsPrevStyle = Nothing}) (filter hasContent (splitIntoParagraphs nt)))+ where+  go _ [] = []+  go st [p] =+    let (_, rendered) = renderParagraphWith st p+        suffix = if isMonoPara p then "\n```" else ""+     in [rendered <> suffix]+  go st (p : rest@(next : _)) =+    let (st', rendered) = renderParagraphWith st p+        sep+          | isMonoPara p && isMonoPara next = "\n"+          | isMonoPara p = "\n```\n\n"+          | isListPara p && isListPara next = "\n"+          | otherwise = "\n\n"+     in rendered : sep : go st' rest+++isMonoPara :: RawParagraph -> Bool+isMonoPara RawParagraph{rpStyle} = rpStyle == Just StyleMonospaced+++isListPara :: RawParagraph -> Bool+isListPara RawParagraph{rpStyle} = case rpStyle of+  Just (StyleBullet _) -> True+  Just (StyleDash _) -> True+  Just (StyleNumbered _ _) -> True+  Just (StyleChecklist _ _) -> True+  _ -> False+++hasContent :: RawParagraph -> Bool+hasContent = any (not . T.null . rsText) . rpSegments+++renderParagraphWith :: RenderState -> RawParagraph -> (RenderState, Text)+renderParagraphWith st RawParagraph{rpStyle, rpSegments} =+  let (st', prefix) = resolvePrefix st rpStyle+      content = T.concat (map renderSegment rpSegments)+   in (st', prefix <> content)+++resolvePrefix :: RenderState -> Maybe NoteStyle -> (RenderState, Text)+resolvePrefix st style =+  let st' = st{rsPrevStyle = style}+   in case style of+        Just (StyleBullet i) -> (st', indentText i <> "- ")+        Just (StyleDash i) -> (st', indentText i <> "- ")+        Just (StyleChecklist i b) ->+          (st', indentText i <> if b then "- [x] " else "- [ ] ")+        Just (StyleNumbered i ms) ->+          let (n, counters') = nextCounter (rsCounters st) i ms (rsPrevStyle st)+           in (st'{rsCounters = counters'}, indentText i <> T.pack (show n) <> ". ")+        Just StyleMonospaced ->+          let prefix = case rsPrevStyle st of+                Just StyleMonospaced -> ""+                _ -> "```\n"+           in (st', prefix)+        _ -> (st', staticPrefix style)+++staticPrefix :: Maybe NoteStyle -> Text+staticPrefix (Just StyleTitle) = "# "+staticPrefix (Just StyleHeading) = "## "+staticPrefix (Just StyleSubheading) = "### "+staticPrefix (Just (StyleBody True)) = "> "+staticPrefix _ = ""+++{- | Returns the counter value to emit and the updated counter map.+Group-start (first item or resume after non-numbered paragraph):+resets to @ms@ (or 1 if absent).  Continuation: uses the running counter,+ignoring @ms@ even if Apple Notes emits it on every item.+-}+nextCounter :: IM.IntMap Int -> Int -> Maybe Int -> Maybe NoteStyle -> (Int, IM.IntMap Int)+nextCounter counters i ms prevStyle =+  let isGroupStart = case prevStyle of+        Just (StyleNumbered j _) -> j /= i+        _ -> True+      startVal+        | isGroupStart = maybe 1 id ms+        | otherwise = IM.findWithDefault 1 i counters+   in (startVal, IM.insert i (startVal + 1) counters)+++indentText :: Int -> Text+indentText i = T.replicate (max 0 i * 2) " "+++renderSegment :: RawSegment -> Text+renderSegment RawSegment{rsText, rsBold, rsItalic, rsStrikethrough, rsLink} =+  let inner = applyBoldItalic rsBold rsItalic rsText+      withStrike = if rsStrikethrough then "~~" <> inner <> "~~" else inner+   in case rsLink of+        Nothing -> withStrike+        Just url -> "[" <> withStrike <> "](" <> url <> ")"+++applyBoldItalic :: Bool -> Bool -> Text -> Text+applyBoldItalic True True t = "**_" <> t <> "_**"+applyBoldItalic True False t = "**" <> t <> "**"+applyBoldItalic False True t = "_" <> t <> "_"+applyBoldItalic False False t = t
+ src-internal/Network/HStratus/Internal/Notes/Note.hs view
@@ -0,0 +1,139 @@+{-# LANGUAGE StrictData #-}++{- |+Module      : Network.HStratus.Internal.Notes.Note+Copyright   : (c) 2026 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD-3-Clause++Domain types for iCloud Notes: note and folder identifiers, note summaries+and full notes, and the structured note-body representation ('NoteText',+'NoteRun', 'NoteStyle').+-}+module Network.HStratus.Internal.Notes.Note+  ( NoteId (..)+  , FolderId (..)+  , NoteSummary (..)+  , NoteFolder (..)+  , Note (..)+  , NoteText (..)+  , NoteRun (..)+  , NoteStyle (..)+  )+where++import Data.ByteString (ByteString)+import Data.Int (Int32)+import Data.Text (Text)+import Data.Time (UTCTime)+++-- | CloudKit record name for a note (e.g. @\"68567409-5528-458C-9A00-7A2AB485CAD6\"@).+newtype NoteId = NoteId+  { unNoteId :: Text+  -- ^ The raw CloudKit record name.+  }+  deriving (Eq, Ord, Show)+++-- | CloudKit record name for a folder (e.g. @\"4C3FC840-3B07-4215-8E61-128AB3EB425E\"@).+newtype FolderId = FolderId+  { unFolderId :: Text+  -- ^ The raw CloudKit record name.+  }+  deriving (Eq, Ord, Show)+++-- | Lightweight summary of a note returned by list and query operations.+data NoteSummary = NoteSummary+  { nsId :: NoteId+  -- ^ Stable CloudKit record identifier.+  , nsTitle :: Maybe Text+  -- ^ Decrypted title; 'Nothing' when the field is absent or unreadable.+  , nsSnippet :: Maybe Text+  -- ^ Decrypted snippet; 'Nothing' when absent or unreadable.+  , nsModified :: Maybe UTCTime+  -- ^ Last-modified timestamp from the @ModificationDate@ field.+  , nsFolderId :: Maybe FolderId+  -- ^ Containing folder; 'Nothing' for notes in the default folder.+  , nsDeleted :: Bool+  -- ^ 'True' when the note has been moved to the trash.+  , nsLocked :: Bool+  -- ^ 'True' when the record type is @PasswordProtectedNote@.+  }+  deriving (Eq, Ord, Show)+++-- | A Notes folder returned by 'Network.HStratus.Notes.noteFolders'.+data NoteFolder = NoteFolder+  { nfId :: FolderId+  -- ^ Stable CloudKit record identifier.+  , nfName :: Maybe Text+  -- ^ Decrypted folder name; 'Nothing' when absent or unreadable.+  }+  deriving (Eq, Ord, Show)+++-- | A full note including its raw (compressed protobuf) body bytes.+data Note = Note+  { noteInfo :: NoteSummary+  -- ^ Summary metadata for this note.+  , noteBodyBytes :: ByteString+  {- ^ Raw @TextDataEncrypted@ bytes (gzip- or zlib-compressed protobuf).+  Pass to 'Network.HStratus.Notes.decodeNoteBody' to get 'NoteText'.+  -}+  }+  deriving (Eq, Ord, Show)+++-- | Decoded plain-text content of a note with formatting runs.+data NoteText = NoteText+  { ntText :: Text+  -- ^ Full plain-text content of the note.+  , ntRuns :: [NoteRun]+  -- ^ Formatting runs parallel to 'ntText'.+  }+  deriving (Eq, Ord, Show)+++-- | A single formatting run within a 'NoteText'.+data NoteRun = NoteRun+  { nrLength :: Int32+  -- ^ Number of characters this run covers in 'ntText'.+  , nrStyle :: Maybe NoteStyle+  -- ^ Paragraph style, if any.+  , nrBold :: Bool+  -- ^ 'True' when the run is bold.+  , nrItalic :: Bool+  -- ^ 'True' when the run is italic.+  , nrUnderline :: Bool+  -- ^ 'True' when the run is underlined.+  , nrStrikethrough :: Bool+  -- ^ 'True' when the run is struck through.+  , nrAttachmentId :: Maybe Text+  -- ^ CloudKit attachment identifier, present when the run covers a @\xFFFC@ placeholder.+  , nrLink :: Maybe Text+  -- ^ Hyperlink URL, if any.+  }+  deriving (Eq, Ord, Show)+++-- | Paragraph style variants that can appear in a 'NoteRun'.+data NoteStyle+  = StyleTitle+  | StyleHeading+  | StyleSubheading+  | StyleMonospaced+  | -- | Plain body paragraph. 'True' when the paragraph is a blockquote.+    StyleBody Bool+  | -- | Bullet list item. Argument is the indent level (0 = top).+    StyleBullet Int+  | -- | Dash list item. Argument is the indent level (0 = top).+    StyleDash Int+  | {- | Numbered list item. Arguments are indent level and optional+    list-start override (@'Nothing'@ means continue from current counter).+    -}+    StyleNumbered Int (Maybe Int)+  | -- | Checklist item. Arguments are indent level and checked state.+    StyleChecklist Int Bool+  deriving (Eq, Ord, Show)
+ src-internal/Network/HStratus/Internal/Notes/NoteData.hs view
@@ -0,0 +1,152 @@+{-# 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
+ src-internal/Network/HStratus/Internal/Notes/Proto.hs view
@@ -0,0 +1,155 @@+{- |+Module      : Network.HStratus.Internal.Notes.Proto+Copyright   : (c) 2026 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD-3-Clause++Hand-written proto3-wire decoders for the Apple Notes protobuf schema.++Uses proto3-wire's lower-level @Parser@ API rather than code generation+because the schema has non-consecutive field numbers (e.g. @note_text = 2@,+@attribute_run = 5@); explicit @\`at\` N@ bindings prevent the silent+field-number mismatches that Generic derivation would introduce.+Fields not listed in the decoders are silently ignored by the wire decoder.+-}+module Network.HStratus.Internal.Notes.Proto+  ( ProtoNote (..)+  , ProtoAttributeRun (..)+  , ProtoParagraphStyle (..)+  , decodeNoteStoreProto+  )+where++import Data.ByteString (ByteString)+import Data.Int (Int32)+import Data.Text (Text)+import qualified Data.Text.Lazy as LT+import Proto3.Wire.Decode+  ( Parser+  , RawMessage+  , at+  , embedded+  , embedded'+  , int32+  , one+  , parse+  , repeated+  , text+  )+++-- Proto types mirror the schema closely; conversion to domain NoteText/NoteRun+-- types happens in Decode.hs where gzip decompression also lives.++-- | Top-level note content decoded from the protobuf payload.+data ProtoNote = ProtoNote+  { pnNoteText :: Text+  -- ^ full plain-text content of the note (proto field 2)+  , pnAttributeRuns :: [ProtoAttributeRun]+  -- ^ attribute runs describing inline and paragraph formatting (proto field 5)+  }+  deriving (Eq, Show)+++-- | A single attribute run from the protobuf schema.+data ProtoAttributeRun = ProtoAttributeRun+  { parLength :: Int32+  -- ^ number of UTF-16 code units this run covers+  , parParagraphStyle :: Maybe ProtoParagraphStyle+  -- ^ paragraph-level formatting; @Nothing@ when the run has no paragraph style+  , parFontWeight :: Int32+  -- ^ font weight: 0 = none, 1 = bold, 2 = italic, 3 = bold+italic+  , parUnderlined :: Int32+  -- ^ non-zero when the run is underlined+  , parStrikethrough :: Int32+  -- ^ non-zero when the run has strikethrough+  , parAttachmentId :: Maybe LT.Text+  -- ^ attachment identifier; @Nothing@ when absent or empty in the wire bytes+  , parLink :: LT.Text+  -- ^ hyperlink URL; empty string when no link is present+  }+  deriving (Eq, Show)+++-- | Paragraph-level formatting for an attribute run.+data ProtoParagraphStyle = ProtoParagraphStyle+  { ppsStyleType :: Int32+  -- ^ style_type field 1: 0=title, 1=heading, 2=subheading, 4=monospaced, 100=bullet, 101=dash, 102=numbered, 103=checklist+  , ppsIndent :: Int32+  -- ^ indent_amount field 4; 0 when absent.+  , ppsChecked :: Maybe Bool+  -- ^ checklist.done field 5 sub-field 2; 'Nothing' when checklist sub-message absent.+  , ppsListStart :: Maybe Int32+  -- ^ starting_list_item_number field 7; 'Nothing' when absent or zero.+  , ppsBlockQuote :: Bool+  -- ^ block_quote field 8 non-zero.+  }+  deriving (Eq, Show)+++{- | Decode a gzip-decompressed protobuf ByteString into a 'ProtoNote'.+Returns 'Left' with a message if the outer document or note field is absent,+which would indicate a malformed or empty payload rather than a real note.+-}+decodeNoteStoreProto :: ByteString -> Either String ProtoNote+decodeNoteStoreProto bs =+  case parse parseNoteStoreProto bs of+    Left err -> Left (show err)+    Right Nothing -> Left "NoteStoreProto: document field absent"+    Right (Just Nothing) -> Left "Document: note field absent"+    Right (Just (Just note)) -> Right note+++-- Drill straight through NoteStoreProto (field 2) → Document (field 3) → Note+-- without defining a separate Document record type.  Each `embedded` call wraps+-- the result in Maybe: Nothing means the field was absent in the wire bytes.+parseNoteStoreProto :: Parser RawMessage (Maybe (Maybe ProtoNote))+parseNoteStoreProto = embedded (embedded parseProtoNote `at` 3) `at` 2+++-- `one text LT.empty` reads a singular string field, returning the default+-- (empty) when the field is absent.  `fmap LT.toStrict` converts the lazy+-- Text that proto3-wire produces to the strict Text used in ProtoNote.+-- `repeated (embedded' ...)` collects all occurrences of a length-delimited+-- field into a list; embedded' (vs embedded) is used inside repeated because+-- the field is known to be present (not optional) at each occurrence.+parseProtoNote :: Parser RawMessage ProtoNote+parseProtoNote =+  ProtoNote+    <$> (fmap LT.toStrict (one text LT.empty) `at` 2)+    <*> (repeated (embedded' parseProtoAttributeRun) `at` 5)+++-- Scalar optional fields (font_weight, underlined, strikethrough) use+-- `one int32 0` — the proto3 default of 0 means "absent / no effect" for+-- all of them (0 = FONT_WEIGHT_UNKNOWN, 0 = not underlined, etc.).+-- `embedded` for paragraph_style returns Maybe: Nothing when the run carries+-- no paragraph-level formatting.+parseProtoAttributeRun :: Parser RawMessage ProtoAttributeRun+parseProtoAttributeRun =+  ProtoAttributeRun+    <$> (one int32 0 `at` 1)+    <*> (embedded parseParagraphStyle `at` 2)+    <*> (one int32 0 `at` 5) -- font_weight (fields 3 and 4 are absent in schema)+    <*> (one int32 0 `at` 6) -- underlined+    <*> (one int32 0 `at` 7) -- strikethrough+    <*> fmap (>>= \t -> if LT.null t then Nothing else Just t) (embedded parseAttachmentInfo `at` 12)+    <*> (one text LT.empty `at` 9) -- link (field 8 skipped: superscript)+++parseAttachmentInfo :: Parser RawMessage LT.Text+parseAttachmentInfo = one text LT.empty `at` 1+++parseParagraphStyle :: Parser RawMessage ProtoParagraphStyle+parseParagraphStyle =+  ProtoParagraphStyle+    <$> (one int32 0 `at` 1)+    <*> (one int32 0 `at` 4)+    <*> (embedded parseChecklist `at` 5)+    <*> (fmap (\n -> if n == 0 then Nothing else Just n) (one int32 0) `at` 7)+    <*> (fmap (/= 0) (one int32 0) `at` 8)+++parseChecklist :: Parser RawMessage Bool+parseChecklist = fmap (/= 0) (one int32 0 `at` 2)
+ src/Network/HStratus/Notes.hs view
@@ -0,0 +1,82 @@+{-# LANGUAGE NamedFieldPuns #-}++{- |+Module      : Network.HStratus.Notes+Copyright   : (c) 2026 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD-3-Clause++High-level API for reading iCloud Notes: listing folders, fetching recent+notes, and downloading note content.+-}+module Network.HStratus.Notes+  ( -- * Setup+    NotesApi+  , mkNotesApi++    -- * Querying+  , recentNotes+  , noteFolders+  , notesInFolder+  , getNote++    -- * Decoding note bodies+  , decodeNoteBody++    -- * Errors+  , NotesError (..)++    -- * Re-exports+  , module Network.HStratus.Notes.Note+  )+where++import Network.HStratus.Http (Api)+import Network.HStratus.Internal.Notes.Decode (decodeNoteBody)+import Network.HStratus.Internal.Notes.Download+  ( NotesError (..)+  , fetchFolders+  , fetchNote+  , fetchNotesInFolder+  , fetchRecent+  )+import Network.HStratus.Internal.Notes.Endpoints+  ( NotesEndpoints+  , mkNotesEndpoints+  )+import Network.HStratus.Notes.Note+import Network.HStratus.Session (AccountData, Session)+++{- | A bundled handle pairing a logged-in 'Api' with its notes endpoints.+Construct with 'mkNotesApi'; pass to all notes operations.+-}+data NotesApi = NotesApi+  { nApi :: !Api+  , nEp :: !NotesEndpoints+  }+++-- | Pair a logged-in 'Api' with notes endpoints derived from its session data.+mkNotesApi :: AccountData -> Session -> Api -> IO NotesApi+mkNotesApi ad sess api = NotesApi api <$> mkNotesEndpoints ad sess+++-- | Fetch recent notes, sorted by modification time descending.+recentNotes :: NotesApi -> IO [NoteSummary]+recentNotes NotesApi{nApi, nEp} = fetchRecent nApi nEp+++-- | Fetch all Notes folders.+noteFolders :: NotesApi -> IO [NoteFolder]+noteFolders NotesApi{nApi, nEp} = fetchFolders nApi nEp+++-- | Fetch notes belonging to the given folder.+notesInFolder :: NotesApi -> FolderId -> IO [NoteSummary]+notesInFolder NotesApi{nApi, nEp} = fetchNotesInFolder nApi nEp+++-- | Fetch a single note by ID. Returns 'Nothing' if the note has been deleted.+getNote :: NotesApi -> NoteId -> IO (Maybe Note)+getNote NotesApi{nApi, nEp} = fetchNote nApi nEp
+ src/Network/HStratus/Notes/Markdown.hs view
@@ -0,0 +1,17 @@+{- |+Module      : Network.HStratus.Notes.Markdown+Copyright   : (c) 2026 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD-3-Clause++Re-exports 'noteToMarkdown' for converting a decoded note body to Markdown text.+-}+module Network.HStratus.Notes.Markdown+  ( noteToMarkdown+  )+where++import Network.HStratus.Internal.Notes.Markdown+  ( noteToMarkdown+  )+
+ src/Network/HStratus/Notes/Note.hs view
@@ -0,0 +1,32 @@+{- |+Module      : Network.HStratus.Notes.Note+Copyright   : (c) 2026 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD-3-Clause++Re-exports the core iCloud Notes domain types: identifiers, summaries,+folders, note content, and inline-run styles.+-}+module Network.HStratus.Notes.Note+  ( NoteId (..)+  , FolderId (..)+  , NoteSummary (..)+  , NoteFolder (..)+  , Note (..)+  , NoteText (..)+  , NoteRun (..)+  , NoteStyle (..)+  )+where++import Network.HStratus.Internal.Notes.Note+  ( FolderId (..)+  , Note (..)+  , NoteFolder (..)+  , NoteId (..)+  , NoteRun (..)+  , NoteStyle (..)+  , NoteSummary (..)+  , NoteText (..)+  )+
+ test/HStratus/Notes/Arbitraries.hs view
@@ -0,0 +1,96 @@+{-# LANGUAGE NamedFieldPuns #-}+{-# OPTIONS_GHC -Wno-orphans #-}++{- |+Module      : HStratus.Notes.Arbitraries+Copyright   : (c) 2026 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD-3-Clause++Orphan 'Arbitrary' instances for proto and domain types used in property tests.+Import this module to bring the instances into scope; nothing else is exported.+-}+module HStratus.Notes.Arbitraries () where++import qualified Data.Text as T+import Network.HStratus.Internal.Notes.Note+  ( NoteRun (..)+  , NoteStyle (..)+  , NoteText (..)+  )+import Network.HStratus.Internal.Notes.Proto (ProtoParagraphStyle (..))+import Test.QuickCheck+++{- | Generates 'ProtoParagraphStyle' values that survive a proto3 encode/decode+roundtrip.  Two constraints apply:++  * 'ppsListStart' is 'Nothing' or 'Just n' with n >= 1, because proto3+    cannot distinguish an absent int32 from a zero int32 on the wire.++  * 'ppsChecked' is 'Just _' only when 'ppsStyleType' == 103 (checklist),+    mirroring what 'parseParagraphStyle' produces for real notes.+-}+instance Arbitrary ProtoParagraphStyle where+  arbitrary = do+    ppsStyleType <- elements [0, 1, 2, 4, 100, 101, 102, 103]+    ppsIndent <- choose (0, 4)+    ppsChecked <- case ppsStyleType of+      103 -> Just <$> arbitrary+      _ -> pure Nothing+    ppsListStart <- case ppsStyleType of+      102 -> oneof [pure Nothing, Just <$> choose (1, 9)]+      _ -> pure Nothing+    ppsBlockQuote <- case ppsStyleType of+      0 -> arbitrary+      _ -> pure False+    pure ProtoParagraphStyle{ppsStyleType, ppsIndent, ppsChecked, ppsListStart, ppsBlockQuote}+++{- | Generates 'NoteStyle' values that are in the image of 'toNoteStyle'.+'StyleBody False' is excluded: 'toNoteStyle' maps both style_type=0 with+block_quote=False and style_type=0 with block_quote absent to 'StyleTitle',+never to 'StyleBody False'.+-}+instance Arbitrary NoteStyle where+  arbitrary =+    oneof+      [ pure StyleTitle+      , pure StyleHeading+      , pure StyleSubheading+      , pure StyleMonospaced+      , pure (StyleBody True)+      , StyleBullet <$> choose (0, 4)+      , StyleDash <$> choose (0, 4)+      , StyleNumbered <$> choose (0, 4) <*> oneof [pure Nothing, Just <$> choose (1, 9)]+      , StyleChecklist <$> choose (0, 4) <*> arbitrary+      ]+++{- | Generates 'NoteRun' values.  'nrLength' is kept in [1, 20] to bound the+total text length generated by 'Arbitrary NoteText'.  'nrLink' is always+'Nothing' (deferred; encoding links requires additional TestHelper support).+-}+instance Arbitrary NoteRun where+  arbitrary =+    NoteRun+      <$> choose (1, 20)+      <*> arbitrary+      <*> arbitrary+      <*> arbitrary+      <*> arbitrary+      <*> arbitrary+      <*> pure Nothing -- nrAttachmentId (deferred)+      <*> pure Nothing -- nrLink (deferred)+++{- | Generates 'NoteText' values whose 'ntText' length equals the sum of all+'nrLength' fields.  Runs are generated first; the text is filled with+lowercase ASCII to match the exact character count.+-}+instance Arbitrary NoteText where+  arbitrary = do+    runs <- listOf arbitrary+    let totalLen = fromIntegral (sum (map nrLength runs)) :: Int+    chars <- vectorOf totalLen (elements ['a' .. 'z'])+    pure NoteText{ntText = T.pack chars, ntRuns = runs}
+ test/HStratus/Notes/CloudKitSpec.hs view
@@ -0,0 +1,192 @@+{-# LANGUAGE OverloadedStrings #-}++{- |+Module      : HStratus.Notes.CloudKitSpec+Copyright   : (c) 2026 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD-3-Clause++Tests for the CloudKit JSON response decoders in+'Network.HStratus.Internal.Notes.CloudKit'.+-}+module HStratus.Notes.CloudKitSpec (spec) where++import Data.Aeson (FromJSON, eitherDecode)+import qualified Data.ByteString.Lazy as LBS+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import Network.HStratus.Internal.Notes.CloudKit+  ( CKField (..)+  , CKLookupResponse (..)+  , CKQueryResponse (..)+  , CKRecord (..)+  , CKRecordRef (..)+  , CKZoneChangesResponse (..)+  , CKZoneChangesZone (..)+  )+import Test.Hspec+++spec :: Spec+spec = describe "Network.HStratus.Internal.Notes.CloudKit" $ do+  describe "CKQueryResponse" $ do+    it "parses a folders query response" $ do+      r <- decodeOrFail queryFoldersJson :: IO CKQueryResponse+      qrContinuationMarker r `shouldBe` Nothing+      case qrRecords r of+        [] -> expectationFailure "expected non-empty records"+        rec : _ -> do+          crName rec `shouldBe` "Folder/FOLDER-FIXTURE"+          crType rec `shouldBe` Just "SearchIndexes"+          Map.lookup "HasSubfolder" (crFields rec)+            `shouldBe` Just (CKInt64Field 1)+    it "parses an empty query response" $ do+      r <- decodeOrFail emptyQueryJson :: IO CKQueryResponse+      qrRecords r `shouldBe` []++  describe "CKLookupResponse" $ do+    it "parses a note lookup response" $ do+      r <- decodeOrFail lookupNoteJson :: IO CKLookupResponse+      lrSyncToken r `shouldBe` Just "notes-lookup-sync-token-fixture"+      case lrRecords r of+        [] -> expectationFailure "expected non-empty records"+        rec : _ -> do+          crName rec `shouldBe` "Note/NOTE-FIXTURE"+          Map.lookup "TextDataEncrypted" (crFields rec)+            `shouldBe` Just (CKEncryptedBytesField "c3ludGhldGljIG5vdGUgYm9keQ==")+          Map.lookup "Folder" (crFields rec)+            `shouldBe` Just (CKReferenceField (mkRef "Folder/FOLDER-FIXTURE"))+          Map.lookup "Attachments" (crFields rec)+            `shouldBe` Just (CKReferenceListField [mkRef "Attachment/ATTACHMENT-FIXTURE"])+    it "parses an attachment lookup response" $ do+      r <- decodeOrFail lookupAttachmentJson :: IO CKLookupResponse+      lrSyncToken r `shouldBe` Nothing+      case lrRecords r of+        [] -> expectationFailure "expected non-empty records"+        rec : _ -> do+          crName rec `shouldBe` "Attachment/ATTACHMENT-FIXTURE"+          Map.lookup "AttachmentUTI" (crFields rec)+            `shouldBe` Just (CKStringField "public.url")+          Map.lookup "Size" (crFields rec)+            `shouldBe` Just (CKInt64Field 128)++  describe "CKZoneChangesResponse" $ do+    it "parses zone changes with records" $ do+      r <- decodeOrFail zoneChangesJson :: IO CKZoneChangesResponse+      case zcrZones r of+        [] -> expectationFailure "expected non-empty zones"+        zone : _ -> do+          zczSyncToken zone `shouldBe` Just "notes-zone-sync-token-fixture"+          zczMoreComing zone `shouldBe` Just False+          case zczRecords zone of+            [_, _, deleted] -> do+              crName deleted `shouldBe` "Note/NOTE-DELETED-FIXTURE"+              crDeleted deleted `shouldBe` Just True+            recs -> expectationFailure $ "expected 3 records, got " <> show (length recs)+    it "parses zone changes with no records" $ do+      r <- decodeOrFail zoneChangesEmptyJson :: IO CKZoneChangesResponse+      case zcrZones r of+        [] -> expectationFailure "expected non-empty zones"+        zone : _ -> do+          zczRecords zone `shouldBe` []+          zczSyncToken zone `shouldBe` Just "notes-changes-sync-token-fixture"+++-- Helpers++decodeOrFail :: (FromJSON a) => LBS.ByteString -> IO a+decodeOrFail bs = either fail pure (eitherDecode bs)+++mkRef :: Text -> CKRecordRef+mkRef name = CKRecordRef{rrRecordName = name, rrAction = "VALIDATE"}+++-- Fixtures++queryFoldersJson :: LBS.ByteString+queryFoldersJson =+  "{\"records\":[{\"recordName\":\"Folder/FOLDER-FIXTURE\"\+  \,\"recordType\":\"SearchIndexes\"\+  \,\"recordChangeTag\":\"folder-change-tag-fixture\"\+  \,\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\+  \,\"fields\":{\"TitleEncrypted\":{\"type\":\"STRING\",\"value\":\"Synthetic Folder\",\"isEncrypted\":true}\+  \,\"HasSubfolder\":{\"type\":\"INT64\",\"value\":1}}}]\+  \,\"continuationMarker\":null}"+++emptyQueryJson :: LBS.ByteString+emptyQueryJson = "{\"records\":[]}"+++lookupNoteJson :: LBS.ByteString+lookupNoteJson =+  "{\"records\":[{\"recordName\":\"Note/NOTE-FIXTURE\"\+  \,\"recordType\":\"Note\"\+  \,\"recordChangeTag\":\"note-change-tag-fixture\"\+  \,\"created\":{\"timestamp\":1735689600000,\"userRecordName\":\"_synthetic_user\"}\+  \,\"modified\":{\"timestamp\":1735776000000,\"userRecordName\":\"_synthetic_user\"}\+  \,\"deleted\":false\+  \,\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\+  \,\"fields\":{\+  \\"TitleEncrypted\":{\"type\":\"STRING\",\"value\":\"Synthetic note\",\"isEncrypted\":true}\+  \,\"SnippetEncrypted\":{\"type\":\"STRING\",\"value\":\"Synthetic snippet\",\"isEncrypted\":true}\+  \,\"TextDataEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"c3ludGhldGljIG5vdGUgYm9keQ==\"}\+  \,\"ModificationDate\":{\"type\":\"TIMESTAMP\",\"value\":1735776000000}\+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}\+  \,\"Attachments\":{\"type\":\"REFERENCE_LIST\",\"value\":[{\"recordName\":\"Attachment/ATTACHMENT-FIXTURE\",\"action\":\"VALIDATE\"}]}\+  \}}]\+  \,\"syncToken\":\"notes-lookup-sync-token-fixture\"}"+++lookupAttachmentJson :: LBS.ByteString+lookupAttachmentJson =+  "{\"records\":[{\"recordName\":\"Attachment/ATTACHMENT-FIXTURE\"\+  \,\"recordType\":\"Attachment\"\+  \,\"recordChangeTag\":\"attachment-change-tag-fixture\"\+  \,\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\+  \,\"fields\":{\+  \\"AttachmentIdentifier\":{\"type\":\"STRING\",\"value\":\"ATTACHMENT-ALIAS-FIXTURE\"}\+  \,\"AttachmentUTI\":{\"type\":\"STRING\",\"value\":\"public.url\"}\+  \,\"Filename\":{\"type\":\"STRING\",\"value\":\"synthetic-link.webloc\"}\+  \,\"Size\":{\"type\":\"INT64\",\"value\":128}\+  \,\"PrimaryAsset\":{\"type\":\"ASSETID\",\"value\":{\+  \\"downloadURL\":\"https://example.test/notes/asset\"\+  \,\"fileChecksum\":\"notes-asset-checksum-fixture\"\+  \,\"referenceChecksum\":\"notes-asset-reference-fixture\"\+  \,\"wrappingKey\":\"notes-asset-wrapping-key-fixture\"\+  \,\"size\":128}}}}]}"+++zoneChangesJson :: LBS.ByteString+zoneChangesJson =+  "{\"zones\":[{\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\+  \,\"syncToken\":\"notes-zone-sync-token-fixture\"\+  \,\"moreComing\":false\+  \,\"records\":[\+  \{\"recordName\":\"Note/NOTE-FIXTURE\"\+  \,\"recordType\":\"Note\"\+  \,\"recordChangeTag\":\"note-change-tag-fixture\"\+  \,\"fields\":{\+  \\"TitleEncrypted\":{\"type\":\"STRING\",\"value\":\"Synthetic note\",\"isEncrypted\":true}\+  \,\"SnippetEncrypted\":{\"type\":\"STRING\",\"value\":\"Synthetic snippet\",\"isEncrypted\":true}\+  \,\"ModificationDate\":{\"type\":\"TIMESTAMP\",\"value\":1735776000000}\+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}}}\+  \,{\"recordName\":\"Folder/FOLDER-FIXTURE\"\+  \,\"recordType\":\"SearchIndexes\"\+  \,\"recordChangeTag\":\"folder-change-tag-fixture\"\+  \,\"fields\":{\+  \\"TitleEncrypted\":{\"type\":\"STRING\",\"value\":\"Synthetic Folder\",\"isEncrypted\":true}\+  \,\"HasSubfolder\":{\"type\":\"INT64\",\"value\":1}}}\+  \,{\"recordName\":\"Note/NOTE-DELETED-FIXTURE\",\"deleted\":true}\+  \]}]}"+++zoneChangesEmptyJson :: LBS.ByteString+zoneChangesEmptyJson =+  "{\"zones\":[{\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\+  \,\"records\":[]\+  \,\"syncToken\":\"notes-changes-sync-token-fixture\"\+  \,\"moreComing\":false}]}"
+ test/HStratus/Notes/DecodeSpec.hs view
@@ -0,0 +1,151 @@+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE OverloadedStrings #-}++{- |+Module      : HStratus.Notes.DecodeSpec+Copyright   : (c) 2026 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD-3-Clause++Tests for the protobuf-to-domain decoder in+'Network.HStratus.Internal.Notes.Decode'.+-}+module HStratus.Notes.DecodeSpec (spec) where++import Data.ByteString (ByteString)+import HStratus.Notes.Arbitraries ()+import HStratus.Notes.TestHelper+import Network.HStratus.Internal.Notes.Decode (decodeNoteBody)+import Network.HStratus.Internal.Notes.Note (NoteRun (..), NoteStyle (..), NoteText (..))+import Test.Hspec+import Test.Hspec.Benri (endsLeft_, endsRight)+import Test.Hspec.QuickCheck (prop)+import Test.QuickCheck (ioProperty, (===))+++spec :: Spec+spec = describe "decodeNoteBody" $ do+  it "decodes a gzip-compressed protobuf note and extracts the text" $+    decodeNoteBody fixtureBytes+      `endsRight` NoteText{ntText = "Step 6b test", ntRuns = []}++  it "decodes a zlib-compressed protobuf note and extracts the text" $+    decodeNoteBody zlibFixtureBytes+      `endsRight` NoteText{ntText = "Step 6b test", ntRuns = []}++  it "returns Left for bytes that are valid gzip but empty protobuf" $+    endsLeft_ $+      decodeNoteBody emptyNoteBytes++  it "returns Left for a corrupt payload" $+    endsLeft_ $+      decodeNoteBody "\x00\x01\x02\x03"++  it "propagates strikethrough to nrStrikethrough" $+    decodeNoteBody strikethroughFixtureBytes+      `endsRight` NoteText+        { ntText = "hi"+        , ntRuns = [baseRun{nrLength = 2, nrStrikethrough = True}]+        }++  it "decodes indent_amount into StyleBullet level" $+    decodeNoteBody bulletIndent1FixtureBytes+      `endsRight` NoteText+        { ntText = "hi"+        , ntRuns = [baseRun{nrStyle = Just (StyleBullet 1)}]+        }++  it "decodes checklist done into StyleChecklist True" $+    decodeNoteBody checklistDoneFixtureBytes+      `endsRight` NoteText+        { ntText = "hi"+        , ntRuns = [baseRun{nrStyle = Just (StyleChecklist 0 True)}]+        }++  it "decodes checklist undone into StyleChecklist False" $+    decodeNoteBody checklistUndoneFixtureBytes+      `endsRight` NoteText+        { ntText = "hi"+        , ntRuns = [baseRun{nrStyle = Just (StyleChecklist 0 False)}]+        }++  it "decodes starting_list_item_number into StyleNumbered list start" $+    decodeNoteBody numberedListStart3FixtureBytes+      `endsRight` NoteText+        { ntText = "hi"+        , ntRuns = [baseRun{nrStyle = Just (StyleNumbered 0 (Just 3))}]+        }++  it "decodes block_quote into StyleBody True" $+    decodeNoteBody blockQuoteFixtureBytes+      `endsRight` NoteText+        { ntText = "hi"+        , ntRuns = [baseRun{nrStyle = Just (StyleBody True)}]+        }++  it "propagates attachment_identifier to nrAttachmentId" $+    decodeNoteBody attachmentIdFixtureBytes+      `endsRight` NoteText+        { ntText = "\xFFFC"+        , ntRuns = [baseRun{nrAttachmentId = Just "att-1"}]+        }++  prop "encode/decode roundtrip preserves NoteText" $ \nt ->+    ioProperty $ do+      result <- decodeNoteBody (encodeNoteText nt)+      pure (result === Right nt)+++-- Base NoteRun with all defaults; individual tests override specific fields.+baseRun :: NoteRun+baseRun =+  NoteRun+    { nrLength = 1+    , nrStyle = Nothing+    , nrBold = False+    , nrItalic = False+    , nrUnderline = False+    , nrStrikethrough = False+    , nrAttachmentId = Nothing+    , nrLink = Nothing+    }+++fixtureBytes :: ByteString+fixtureBytes = mkNoteGzip "Step 6b test" []+++zlibFixtureBytes :: ByteString+zlibFixtureBytes = mkNoteZlib "Step 6b test" []+++emptyNoteBytes :: ByteString+emptyNoteBytes = mkEmptyGzip+++strikethroughFixtureBytes :: ByteString+strikethroughFixtureBytes = mkNoteGzip "hi" [runFields 2 [(7, 1)]]+++bulletIndent1FixtureBytes :: ByteString+bulletIndent1FixtureBytes = mkNoteGzip "hi" [runWith 1 (psStyleType 100 <> psIndentAmount 1)]+++checklistDoneFixtureBytes :: ByteString+checklistDoneFixtureBytes = mkNoteGzip "hi" [runWith 1 (psStyleType 103 <> psChecklist True)]+++checklistUndoneFixtureBytes :: ByteString+checklistUndoneFixtureBytes = mkNoteGzip "hi" [runWith 1 (psStyleType 103 <> psChecklist False)]+++numberedListStart3FixtureBytes :: ByteString+numberedListStart3FixtureBytes = mkNoteGzip "hi" [runWith 1 (psStyleType 102 <> psListStart 3)]+++blockQuoteFixtureBytes :: ByteString+blockQuoteFixtureBytes = mkNoteGzip "hi" [runWith 1 psBlockQuote]+++attachmentIdFixtureBytes :: ByteString+attachmentIdFixtureBytes = mkNoteGzip "\xFFFC" [encodeNoteRun baseRun{nrAttachmentId = Just "att-1"}]
+ test/HStratus/Notes/EndpointsSpec.hs view
@@ -0,0 +1,137 @@+{-# LANGUAGE OverloadedStrings #-}++{- |+Module      : HStratus.Notes.EndpointsSpec+Copyright   : (c) 2026 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD-3-Clause++Tests for the Notes CloudKit endpoint and request-body builders in+'Network.HStratus.Internal.Notes.Endpoints'.+-}+module HStratus.Notes.EndpointsSpec (spec) where++import Control.Monad (forM_)+import Data.Aeson (Value (..), object)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8+import qualified Data.ByteString.Lazy as LBS+import qualified Data.Map.Strict as Map+import Network.HStratus.Internal.Notes.Endpoints+  ( changesBody+  , changesReq+  , foldersBody+  , lookupBody+  , lookupReq+  , mkNotesEndpoints+  , queryReq+  , recentsBody+  )+import Network.HStratus.Session (AccountData (..), Credentials (..), Session (..), Webservice (..))+import Network.HTTP.Client (Request (..))+import Network.HTTP.Types (methodPost)+import Test.Hspec+++spec :: Spec+spec = describe "Network.HStratus.Internal.Notes.Endpoints" $ do+  ep <- runIO (mkNotesEndpoints testAccountData testSession)++  let reqs =+        [ ("queryReq", queryReq ep, "/records/query")+        , ("lookupReq", lookupReq ep, "/records/lookup")+        , ("changesReq", changesReq ep, "/changes/zone")+        ]+  forM_ reqs $ \(name, req, suffix) ->+    describe name $ do+      it ("targets " <> suffix) $+        path req `shouldSatisfy` BS.isSuffixOf (BS8.pack suffix)+      it "uses POST" $+        method req `shouldBe` methodPost+      it "includes required query params" $ do+        queryString req `shouldSatisfy` BS.isInfixOf "remapEnums=true"+        queryString req `shouldSatisfy` BS.isInfixOf "getCurrentSyncToken=true"+        queryString req `shouldSatisfy` BS.isInfixOf "clientId=auth-test-client-id"++  describe "foldersBody" $ do+    it "queries SearchIndexes with parentless filter" $+      foldersBody 10 Nothing+        `shouldSatisfy` lbsContains "\"recordType\":\"SearchIndexes\""+    it "includes Notes zoneID with zoneType" $+      foldersBody 10 Nothing+        `shouldSatisfy` lbsContains "\"zoneType\":\"REGULAR_CUSTOM_ZONE\""+    it "includes the requested resultsLimit" $+      foldersBody 50 Nothing+        `shouldSatisfy` lbsContains "\"resultsLimit\":50"+    it "clamps resultsLimit to 200" $+      foldersBody 999 Nothing+        `shouldSatisfy` lbsContains "\"resultsLimit\":200"+    it "includes continuationMarker when provided" $+      foldersBody 10 (Just (String "test-marker"))+        `shouldSatisfy` lbsContains "\"continuationMarker\""++  describe "recentsBody" $ do+    it "queries SearchIndexes records" $+      recentsBody 10 Nothing+        `shouldSatisfy` lbsContains "\"recordType\":\"SearchIndexes\""+    it "includes Notes zoneID with zoneType" $+      recentsBody 10 Nothing+        `shouldSatisfy` lbsContains "\"zoneType\":\"REGULAR_CUSTOM_ZONE\""+    it "includes the requested resultsLimit" $+      recentsBody 42 Nothing+        `shouldSatisfy` lbsContains "\"resultsLimit\":42"+    it "clamps resultsLimit to 200" $+      recentsBody 999 Nothing+        `shouldSatisfy` lbsContains "\"resultsLimit\":200"+    it "sorts by modTime descending" $+      recentsBody 10 Nothing+        `shouldSatisfy` lbsContains "\"fieldName\":\"modTime\""++  describe "lookupBody" $ do+    it "includes each record name" $+      lookupBody ["Note/ABC", "Note/DEF"]+        `shouldSatisfy` lbsContains "\"recordName\":\"Note/ABC\""+    it "includes Notes zoneID with zoneType" $+      lookupBody ["Note/ABC"]+        `shouldSatisfy` lbsContains "\"zoneType\":\"REGULAR_CUSTOM_ZONE\""++  describe "changesBody" $ do+    it "includes REGULAR_CUSTOM_ZONE zoneType" $+      changesBody Nothing+        `shouldSatisfy` lbsContains "\"zoneType\":\"REGULAR_CUSTOM_ZONE\""+    it "requests Note record type" $+      changesBody Nothing+        `shouldSatisfy` lbsContains "\"Note\""+    it "includes syncToken when provided" $+      changesBody (Just "test-sync-token")+        `shouldSatisfy` lbsContains "\"syncToken\":\"test-sync-token\""+++-- Helpers++lbsContains :: BS.ByteString -> LBS.ByteString -> Bool+lbsContains needle = BS.isInfixOf needle . LBS.toStrict+++-- Fixtures++testAccountData :: AccountData+testAccountData =+  AccountData+    { adHsaVersion = 2+    , adHsaChallengeRequired = False+    , adHsaTrustedBrowser = Just True+    , adWebservices =+        Map.fromList+          [("ckdatabasews", Webservice "https://p31-ckdatabasews.icloud.com" Nothing)]+    , adRaw = object []+    }+++testSession :: Session+testSession =+  Session+    { sessionCreds = Credentials{credAccountName = "test@example.com", credPassword = "test-pass"}+    , sessionTopDir = "/tmp/test"+    , sessionClientId = "auth-test-client-id"+    }
+ test/HStratus/Notes/MarkdownSpec.hs view
@@ -0,0 +1,362 @@+{-# LANGUAGE OverloadedStrings #-}++{- |+Module      : HStratus.Notes.MarkdownSpec+Copyright   : (c) 2026 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD-3-Clause++Tests for the Markdown renderer in+'Network.HStratus.Internal.Notes.Markdown'.+-}+module HStratus.Notes.MarkdownSpec (spec) where++import Data.Text (Text)+import Network.HStratus.Internal.Notes.Markdown+import Network.HStratus.Internal.Notes.Note+import Test.Hspec+++spec :: Spec+spec = do+  describe "noteToMarkdown" $ do+    it "renders StyleTitle as h1" $+      noteToMarkdown+        NoteText+          { ntText = "hello"+          , ntRuns = [baseRun{nrLength = 5, nrStyle = Just StyleTitle}]+          }+        `shouldBe` "# hello"++    it "renders StyleHeading as h2" $+      noteToMarkdown+        NoteText+          { ntText = "section"+          , ntRuns = [baseRun{nrLength = 7, nrStyle = Just StyleHeading}]+          }+        `shouldBe` "## section"++    it "renders StyleSubheading as h3" $+      noteToMarkdown+        NoteText+          { ntText = "sub"+          , ntRuns = [baseRun{nrLength = 3, nrStyle = Just StyleSubheading}]+          }+        `shouldBe` "### sub"++    it "renders plain body text without prefix" $+      noteToMarkdown+        NoteText{ntText = "plain", ntRuns = [baseRun{nrLength = 5}]}+        `shouldBe` "plain"++    it "renders StyleBody True as blockquote" $+      noteToMarkdown+        NoteText+          { ntText = "quoted"+          , ntRuns = [baseRun{nrLength = 6, nrStyle = Just (StyleBody True)}]+          }+        `shouldBe` "> quoted"++    it "renders bold segment with ** markers" $+      noteToMarkdown+        NoteText{ntText = "bold", ntRuns = [baseRun{nrLength = 4, nrBold = True}]}+        `shouldBe` "**bold**"++    it "renders italic segment with _ markers" $+      noteToMarkdown+        NoteText{ntText = "ital", ntRuns = [baseRun{nrLength = 4, nrItalic = True}]}+        `shouldBe` "_ital_"++    it "renders bold+italic segment with **_ markers" $+      noteToMarkdown+        NoteText+          { ntText = "bi"+          , ntRuns = [baseRun{nrLength = 2, nrBold = True, nrItalic = True}]+          }+        `shouldBe` "**_bi_**"++    it "renders strikethrough segment with ~~ markers" $+      noteToMarkdown+        NoteText+          { ntText = "strike"+          , ntRuns = [baseRun{nrLength = 6, nrStrikethrough = True}]+          }+        `shouldBe` "~~strike~~"++    it "renders link segment as [text](url)" $+      noteToMarkdown+        NoteText+          { ntText = "click"+          , ntRuns = [baseRun{nrLength = 5, nrLink = Just "https://example.com"}]+          }+        `shouldBe` "[click](https://example.com)"++    it "drops underline with no markup" $+      noteToMarkdown+        NoteText+          { ntText = "under"+          , ntRuns = [baseRun{nrLength = 5, nrUnderline = True}]+          }+        `shouldBe` "under"++    it "renders bold text inside a link" $+      noteToMarkdown+        NoteText+          { ntText = "bold link"+          , ntRuns =+              [ baseRun+                  { nrLength = 9+                  , nrBold = True+                  , nrLink = Just "https://example.com"+                  }+              ]+          }+        `shouldBe` "[**bold link**](https://example.com)"++    it "filters blank paragraphs and joins with double newline" $+      noteToMarkdown+        NoteText{ntText = "a\n\nb", ntRuns = [baseRun{nrLength = 4}]}+        `shouldBe` "a\n\nb"++    it "joins two plain paragraphs with double newline" $+      noteToMarkdown+        NoteText{ntText = "first\nsecond", ntRuns = [baseRun{nrLength = 12}]}+        `shouldBe` "first\n\nsecond"++  describe "noteToMarkdown monospaced" $ do+    it "renders a single StyleMonospaced paragraph as a fenced code block" $+      noteToMarkdown+        NoteText{ntText = "code", ntRuns = [baseRun{nrLength = 4, nrStyle = Just StyleMonospaced}]}+        `shouldBe` "```\ncode\n```"++    it "merges consecutive StyleMonospaced paragraphs into one fence" $+      noteToMarkdown+        NoteText+          { ntText = "line1\nline2"+          , ntRuns =+              [ baseRun{nrLength = 6, nrStyle = Just StyleMonospaced}+              , baseRun{nrLength = 5, nrStyle = Just StyleMonospaced}+              ]+          }+        `shouldBe` "```\nline1\nline2\n```"++    it "closes the fence before a following body paragraph" $+      noteToMarkdown+        NoteText+          { ntText = "code\nbody"+          , ntRuns =+              [ baseRun{nrLength = 5, nrStyle = Just StyleMonospaced}+              , baseRun{nrLength = 4}+              ]+          }+        `shouldBe` "```\ncode\n```\n\nbody"++    it "opens the fence after a preceding body paragraph" $+      noteToMarkdown+        NoteText+          { ntText = "body\ncode"+          , ntRuns =+              [ baseRun{nrLength = 5}+              , baseRun{nrLength = 4, nrStyle = Just StyleMonospaced}+              ]+          }+        `shouldBe` "body\n\n```\ncode\n```"++  describe "noteToMarkdown list styles" $ do+    it "renders StyleBullet 0 as top-level bullet" $+      noteToMarkdown+        NoteText{ntText = "item", ntRuns = [baseRun{nrLength = 4, nrStyle = Just (StyleBullet 0)}]}+        `shouldBe` "- item"++    it "renders StyleBullet 1 indented by 2 spaces" $+      noteToMarkdown+        NoteText{ntText = "nested", ntRuns = [baseRun{nrLength = 6, nrStyle = Just (StyleBullet 1)}]}+        `shouldBe` "  - nested"++    it "renders StyleBullet 2 indented by 4 spaces" $+      noteToMarkdown+        NoteText{ntText = "deep", ntRuns = [baseRun{nrLength = 4, nrStyle = Just (StyleBullet 2)}]}+        `shouldBe` "    - deep"++    it "renders StyleDash 0 as top-level bullet" $+      noteToMarkdown+        NoteText{ntText = "item", ntRuns = [baseRun{nrLength = 4, nrStyle = Just (StyleDash 0)}]}+        `shouldBe` "- item"++    it "separates consecutive list items with a single newline" $+      noteToMarkdown+        NoteText+          { ntText = "first\nsecond"+          , ntRuns =+              [ baseRun{nrLength = 6, nrStyle = Just (StyleBullet 0)}+              , baseRun{nrLength = 6, nrStyle = Just (StyleBullet 0)}+              ]+          }+        `shouldBe` "- first\n- second"++    it "separates a list item from body text with a double newline" $+      noteToMarkdown+        NoteText+          { ntText = "item\nbody"+          , ntRuns =+              [ baseRun{nrLength = 5, nrStyle = Just (StyleBullet 0)}+              , baseRun{nrLength = 4}+              ]+          }+        `shouldBe` "- item\n\nbody"++    it "numbers two consecutive StyleNumbered items" $+      noteToMarkdown+        NoteText+          { ntText = "first\nsecond"+          , ntRuns =+              [ baseRun{nrLength = 6, nrStyle = Just (StyleNumbered 0 Nothing)}+              , baseRun{nrLength = 6, nrStyle = Just (StyleNumbered 0 Nothing)}+              ]+          }+        `shouldBe` "1. first\n2. second"++    it "starts numbered list at ms when ms is Just" $+      noteToMarkdown+        NoteText+          { ntText = "item"+          , ntRuns = [baseRun{nrLength = 4, nrStyle = Just (StyleNumbered 0 (Just 3))}]+          }+        `shouldBe` "3. item"++    it "resets numbered counter after a non-numbered paragraph" $+      noteToMarkdown+        NoteText+          { ntText = "first\nsecond\nbody\nthird"+          , ntRuns =+              [ baseRun{nrLength = 6, nrStyle = Just (StyleNumbered 0 Nothing)}+              , baseRun{nrLength = 7, nrStyle = Just (StyleNumbered 0 Nothing)}+              , baseRun{nrLength = 5}+              , baseRun{nrLength = 5, nrStyle = Just (StyleNumbered 0 Nothing)}+              ]+          }+        `shouldBe` "1. first\n2. second\n\nbody\n\n1. third"++    it "does not reset counter when ms is Just on every item" $+      noteToMarkdown+        NoteText+          { ntText = "first\nsecond"+          , ntRuns =+              [ baseRun{nrLength = 6, nrStyle = Just (StyleNumbered 0 (Just 1))}+              , baseRun{nrLength = 6, nrStyle = Just (StyleNumbered 0 (Just 1))}+              ]+          }+        `shouldBe` "1. first\n2. second"++    it "renders StyleNumbered 1 indented by 2 spaces" $+      noteToMarkdown+        NoteText+          { ntText = "nested"+          , ntRuns = [baseRun{nrLength = 6, nrStyle = Just (StyleNumbered 1 Nothing)}]+          }+        `shouldBe` "  1. nested"++    it "renders StyleChecklist True as checked item" $+      noteToMarkdown+        NoteText+          { ntText = "done"+          , ntRuns = [baseRun{nrLength = 4, nrStyle = Just (StyleChecklist 0 True)}]+          }+        `shouldBe` "- [x] done"++    it "renders StyleChecklist False as unchecked item" $+      noteToMarkdown+        NoteText+          { ntText = "todo"+          , ntRuns = [baseRun{nrLength = 4, nrStyle = Just (StyleChecklist 0 False)}]+          }+        `shouldBe` "- [ ] todo"++  describe "splitIntoParagraphs" $ do+    it "single styled run without newline produces one paragraph" $+      splitIntoParagraphs+        NoteText+          { ntText = "hello"+          , ntRuns = [baseRun{nrLength = 5, nrStyle = Just StyleHeading}]+          }+        `shouldBe` [RawParagraph{rpStyle = Just StyleHeading, rpSegments = [baseSeg "hello"]}]++    it "neutral run is absorbed into the preceding styled paragraph" $+      splitIntoParagraphs+        NoteText+          { ntText = "helloworld"+          , ntRuns =+              [ baseRun{nrLength = 5, nrStyle = Just StyleHeading}+              , baseRun{nrLength = 5}+              ]+          }+        `shouldBe` [ RawParagraph+                       { rpStyle = Just StyleHeading+                       , rpSegments = [baseSeg "hello", baseSeg "world"]+                       }+                   ]++    it "newline in a run closes the current paragraph" $+      splitIntoParagraphs+        NoteText+          { ntText = "a\nb"+          , ntRuns =+              [ baseRun{nrLength = 1, nrStyle = Just StyleHeading}+              , baseRun{nrLength = 1}+              , baseRun{nrLength = 1}+              ]+          }+        `shouldBe` [ RawParagraph{rpStyle = Just StyleHeading, rpSegments = [baseSeg "a"]}+                   , RawParagraph{rpStyle = Nothing, rpSegments = [baseSeg "b"]}+                   ]++    it "inline-only runs with different attributes produce distinct segments in one paragraph" $+      splitIntoParagraphs+        NoteText+          { ntText = "boldnormal"+          , ntRuns = [baseRun{nrLength = 4, nrBold = True}, baseRun{nrLength = 6}]+          }+        `shouldBe` [ RawParagraph+                       { rpStyle = Nothing+                       , rpSegments = [(baseSeg "bold"){rsBold = True}, baseSeg "normal"]+                       }+                   ]++    it "xFFFC with an attachment id is replaced with [attachment: id]" $+      splitIntoParagraphs+        NoteText+          { ntText = "\xFFFC"+          , ntRuns = [baseRun{nrAttachmentId = Just "att-1"}]+          }+        `shouldBe` [RawParagraph{rpStyle = Nothing, rpSegments = [baseSeg "[attachment: att-1]"]}]++    it "xFFFC without an attachment id is replaced with [attachment]" $+      splitIntoParagraphs+        NoteText{ntText = "\xFFFC", ntRuns = [baseRun]}+        `shouldBe` [RawParagraph{rpStyle = Nothing, rpSegments = [baseSeg "[attachment]"]}]+++baseRun :: NoteRun+baseRun =+  NoteRun+    { nrLength = 1+    , nrStyle = Nothing+    , nrBold = False+    , nrItalic = False+    , nrUnderline = False+    , nrStrikethrough = False+    , nrAttachmentId = Nothing+    , nrLink = Nothing+    }+++baseSeg :: Text -> RawSegment+baseSeg t =+  RawSegment+    { rsText = t+    , rsBold = False+    , rsItalic = False+    , rsStrikethrough = False+    , rsUnderline = False+    , rsLink = Nothing+    }
+ test/HStratus/Notes/NoteDataSpec.hs view
@@ -0,0 +1,232 @@+{-# LANGUAGE OverloadedStrings #-}++{- |+Module      : HStratus.Notes.NoteDataSpec+Copyright   : (c) 2026 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD-3-Clause++Tests for the CloudKit-to-domain record converters in+'Network.HStratus.Internal.Notes.NoteData'.+-}+module HStratus.Notes.NoteDataSpec (spec) where++import Data.Aeson (FromJSON, eitherDecode)+import qualified Data.ByteString.Lazy as LBS+import Data.Time.Clock.POSIX (posixSecondsToUTCTime)+import Network.HStratus.Internal.Notes.CloudKit+  ( CKLookupResponse (..)+  , CKQueryResponse (..)+  , CKZoneChangesResponse (..)+  , CKZoneChangesZone (..)+  )+import Network.HStratus.Internal.Notes.NoteData+  ( noteRecordToFolder+  , noteRecordToNote+  , noteRecordToSummary+  , parseFoldersFromChanges+  , parseFoldersFromQuery+  , parseSummariesFromChanges+  , parseSummariesFromQuery+  )+import Network.HStratus.Notes.Note+import Test.Hspec+++spec :: Spec+spec = describe "Network.HStratus.Internal.Notes.NoteData" $ do+  describe "noteRecordToSummary" $ do+    it "parses a note record into a NoteSummary" $ do+      r <- decodeOrFail lookupNoteJson :: IO CKLookupResponse+      case lrRecords r of+        [] -> expectationFailure "expected records"+        rec : _ ->+          noteRecordToSummary rec+            `shouldBe` Just+              NoteSummary+                { nsId = NoteId "Note/NOTE-FIXTURE"+                , nsTitle = Just "Synthetic note"+                , nsSnippet = Just "Synthetic snippet"+                , nsModified = Just (posixSecondsToUTCTime 1735776000)+                , nsFolderId = Just (FolderId "Folder/FOLDER-FIXTURE")+                , nsDeleted = False+                , nsLocked = False+                }+    it "parses a SearchIndexes record into a NoteSummary with nsLocked = False" $ do+      r <- decodeOrFail querySearchIndexJson :: IO CKQueryResponse+      case qrRecords r of+        [] -> expectationFailure "expected records"+        rec : _ ->+          noteRecordToSummary rec+            `shouldBe` Just+              NoteSummary+                { nsId = NoteId "Note/NOTE-FIXTURE"+                , nsTitle = Just "Synthetic note"+                , nsSnippet = Just "Synthetic snippet"+                , nsModified = Just (posixSecondsToUTCTime 1735776000)+                , nsFolderId = Just (FolderId "Folder/FOLDER-FIXTURE")+                , nsDeleted = False+                , nsLocked = False+                }+    it "returns Nothing for a tombstone" $ do+      r <- decodeOrFail zoneChangesJson :: IO CKZoneChangesResponse+      case concatMap zczRecords (zcrZones r) of+        [_, _, tombstone] -> noteRecordToSummary tombstone `shouldBe` Nothing+        recs -> expectationFailure $ "expected 3 records, got " <> show (length recs)+    it "returns Nothing for a folder record" $ do+      r <- decodeOrFail queryFoldersJson :: IO CKQueryResponse+      case qrRecords r of+        [] -> expectationFailure "expected records"+        rec : _ -> noteRecordToSummary rec `shouldBe` Nothing++  describe "noteRecordToFolder" $ do+    it "parses a folder record into a NoteFolder" $ do+      r <- decodeOrFail queryFoldersJson :: IO CKQueryResponse+      case qrRecords r of+        [] -> expectationFailure "expected records"+        rec : _ ->+          noteRecordToFolder rec+            `shouldBe` Just+              NoteFolder+                { nfId = FolderId "Folder/FOLDER-FIXTURE"+                , nfName = Just "Synthetic Folder"+                }+    it "returns Nothing for a note record" $ do+      r <- decodeOrFail lookupNoteJson :: IO CKLookupResponse+      case lrRecords r of+        [] -> expectationFailure "expected records"+        rec : _ -> noteRecordToFolder rec `shouldBe` Nothing++  describe "noteRecordToNote" $ do+    it "decodes the note body from TextDataEncrypted" $ do+      r <- decodeOrFail lookupNoteJson :: IO CKLookupResponse+      case lrRecords r of+        [] -> expectationFailure "expected records"+        rec : _ -> case noteRecordToNote rec of+          Nothing -> expectationFailure "expected Just Note"+          Just n -> noteBodyBytes n `shouldBe` "synthetic note body"+    it "returns Nothing when TextDataEncrypted is absent" $ do+      r <- decodeOrFail zoneChangesJson :: IO CKZoneChangesResponse+      case concatMap zczRecords (zcrZones r) of+        noteRec : _ -> noteRecordToNote noteRec `shouldBe` Nothing+        [] -> expectationFailure "expected records"++  describe "parseSummariesFromQuery" $ do+    it "returns one summary from a note query response" $ do+      r <- decodeOrFail queryNotesJson :: IO CKQueryResponse+      length (parseSummariesFromQuery r) `shouldBe` 1+    it "returns one summary from a SearchIndexes query response" $ do+      r <- decodeOrFail querySearchIndexJson :: IO CKQueryResponse+      length (parseSummariesFromQuery r) `shouldBe` 1+    it "returns empty from a folders-only response" $ do+      r <- decodeOrFail queryFoldersJson :: IO CKQueryResponse+      parseSummariesFromQuery r `shouldBe` []++  describe "parseFoldersFromQuery" $ do+    it "returns one folder from a folders query response" $ do+      r <- decodeOrFail queryFoldersJson :: IO CKQueryResponse+      length (parseFoldersFromQuery r) `shouldBe` 1+    it "returns empty from a notes-only response" $ do+      r <- decodeOrFail queryNotesJson :: IO CKQueryResponse+      parseFoldersFromQuery r `shouldBe` []++  describe "parseSummariesFromChanges" $ do+    it "extracts only Note records, skipping tombstones and folders" $ do+      r <- decodeOrFail zoneChangesJson :: IO CKZoneChangesResponse+      length (parseSummariesFromChanges r) `shouldBe` 1++  describe "parseFoldersFromChanges" $ do+    it "extracts only Folder records" $ do+      r <- decodeOrFail zoneChangesJson :: IO CKZoneChangesResponse+      length (parseFoldersFromChanges r) `shouldBe` 1+++-- Helpers++decodeOrFail :: (FromJSON a) => LBS.ByteString -> IO a+decodeOrFail bs = either fail pure (eitherDecode bs)+++-- Fixtures++queryFoldersJson :: LBS.ByteString+queryFoldersJson =+  "{\"records\":[{\"recordName\":\"Folder/FOLDER-FIXTURE\"\+  \,\"recordType\":\"Folder\"\+  \,\"recordChangeTag\":\"folder-change-tag-fixture\"\+  \,\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\+  \,\"fields\":{\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIEZvbGRlcg==\"}\+  \,\"HasSubfolder\":{\"type\":\"INT64\",\"value\":1}}}]\+  \,\"continuationMarker\":null}"+++querySearchIndexJson :: LBS.ByteString+querySearchIndexJson =+  "{\"records\":[{\"recordName\":\"Note/NOTE-FIXTURE\"\+  \,\"recordType\":\"SearchIndexes\"\+  \,\"fields\":{\+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\+  \,\"SnippetEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIHNuaXBwZXQ=\"}\+  \,\"ModificationDate\":{\"type\":\"TIMESTAMP\",\"value\":1735776000000}\+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}}}]\+  \,\"continuationMarker\":null}"+++queryNotesJson :: LBS.ByteString+queryNotesJson =+  "{\"records\":[{\"recordName\":\"Note/NOTE-FIXTURE\"\+  \,\"recordType\":\"Note\"\+  \,\"fields\":{\+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\+  \,\"SnippetEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIHNuaXBwZXQ=\"}\+  \,\"ModificationDate\":{\"type\":\"TIMESTAMP\",\"value\":1735776000000}\+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}}}]\+  \,\"continuationMarker\":null}"+++lookupNoteJson :: LBS.ByteString+lookupNoteJson =+  "{\"records\":[{\"recordName\":\"Note/NOTE-FIXTURE\"\+  \,\"recordType\":\"Note\"\+  \,\"recordChangeTag\":\"note-change-tag-fixture\"\+  \,\"created\":{\"timestamp\":1735689600000,\"userRecordName\":\"_synthetic_user\"}\+  \,\"modified\":{\"timestamp\":1735776000000,\"userRecordName\":\"_synthetic_user\"}\+  \,\"deleted\":false\+  \,\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\+  \,\"fields\":{\+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\+  \,\"SnippetEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIHNuaXBwZXQ=\"}\+  \,\"TextDataEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"c3ludGhldGljIG5vdGUgYm9keQ==\"}\+  \,\"ModificationDate\":{\"type\":\"TIMESTAMP\",\"value\":1735776000000}\+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}\+  \,\"Attachments\":{\"type\":\"REFERENCE_LIST\",\"value\":[{\"recordName\":\"Attachment/ATTACHMENT-FIXTURE\",\"action\":\"VALIDATE\"}]}\+  \}}]\+  \,\"syncToken\":\"notes-lookup-sync-token-fixture\"}"+++zoneChangesJson :: LBS.ByteString+zoneChangesJson =+  "{\"zones\":[{\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\+  \,\"syncToken\":\"notes-zone-sync-token-fixture\"\+  \,\"moreComing\":false\+  \,\"records\":[\+  \{\"recordName\":\"Note/NOTE-FIXTURE\"\+  \,\"recordType\":\"Note\"\+  \,\"recordChangeTag\":\"note-change-tag-fixture\"\+  \,\"fields\":{\+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\+  \,\"SnippetEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIHNuaXBwZXQ=\"}\+  \,\"ModificationDate\":{\"type\":\"TIMESTAMP\",\"value\":1735776000000}\+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}}}\+  \,{\"recordName\":\"Folder/FOLDER-FIXTURE\"\+  \,\"recordType\":\"Folder\"\+  \,\"recordChangeTag\":\"folder-change-tag-fixture\"\+  \,\"fields\":{\+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIEZvbGRlcg==\"}\+  \,\"HasSubfolder\":{\"type\":\"INT64\",\"value\":1}}}\+  \,{\"recordName\":\"Note/NOTE-DELETED-FIXTURE\",\"deleted\":true}\+  \]}]}"
+ test/HStratus/Notes/ProtoSpec.hs view
@@ -0,0 +1,158 @@+{-# LANGUAGE OverloadedStrings #-}++{- |+Module      : HStratus.Notes.ProtoSpec+Copyright   : (c) 2026 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD-3-Clause++Tests for the proto3-wire decoders in+'Network.HStratus.Internal.Notes.Proto'.+-}+module HStratus.Notes.ProtoSpec (spec) where++import Data.ByteString (ByteString)+import HStratus.Notes.Arbitraries ()+import HStratus.Notes.TestHelper+import Network.HStratus.Internal.Notes.Proto+import Test.Hspec+import Test.Hspec.Benri (endsLeft_, endsRight)+import Test.Hspec.QuickCheck (prop)+import Test.QuickCheck (counterexample, (===))+++spec :: Spec+spec = describe "decodeNoteStoreProto" $ do+  it "decodes a minimal note with text only" $+    pure (decodeNoteStoreProto minimalNoteBytes)+      `endsRight` ProtoNote{pnNoteText = "hello", pnAttributeRuns = []}++  it "returns an error for empty input" $+    endsLeft_ $+      pure (decodeNoteStoreProto "")++  it "decodes attribute run length and paragraph style" $+    case decodeNoteStoreProto noteWithRunBytes of+      Left err -> expectationFailure err+      Right note -> do+        pnNoteText note `shouldBe` "hi"+        case pnAttributeRuns note of+          [run] -> do+            parLength run `shouldBe` 2+            parParagraphStyle run+              `shouldBe` Just+                ProtoParagraphStyle+                  { ppsStyleType = 1+                  , ppsIndent = 0+                  , ppsChecked = Nothing+                  , ppsListStart = Nothing+                  , ppsBlockQuote = False+                  }+          runs -> expectationFailure $ "expected 1 run, got " <> show (length runs)++  it "decodes strikethrough field 7" $+    case decodeNoteStoreProto noteWithStrikethroughBytes of+      Left err -> expectationFailure err+      Right note ->+        case pnAttributeRuns note of+          [run] -> do+            parLength run `shouldBe` 2+            parStrikethrough run `shouldBe` 1+          runs -> expectationFailure $ "expected 1 run, got " <> show (length runs)++  it "decodes indent_amount field 4 into ppsIndent" $+    case decodeNoteStoreProto bulletIndent1Bytes of+      Left err -> expectationFailure err+      Right note ->+        case pnAttributeRuns note of+          [run] ->+            fmap ppsIndent (parParagraphStyle run) `shouldBe` Just 1+          runs -> expectationFailure $ "expected 1 run, got " <> show (length runs)++  it "decodes checklist.done = 1 into ppsChecked = Just True" $+    case decodeNoteStoreProto checklistDoneBytes of+      Left err -> expectationFailure err+      Right note ->+        case pnAttributeRuns note of+          [run] ->+            fmap ppsChecked (parParagraphStyle run) `shouldBe` Just (Just True)+          runs -> expectationFailure $ "expected 1 run, got " <> show (length runs)++  it "decodes checklist.done = 0 into ppsChecked = Just False" $+    case decodeNoteStoreProto checklistUndoneBytes of+      Left err -> expectationFailure err+      Right note ->+        case pnAttributeRuns note of+          [run] ->+            fmap ppsChecked (parParagraphStyle run) `shouldBe` Just (Just False)+          runs -> expectationFailure $ "expected 1 run, got " <> show (length runs)++  it "decodes absent checklist into ppsChecked = Nothing" $+    case decodeNoteStoreProto checklistAbsentBytes of+      Left err -> expectationFailure err+      Right note ->+        case pnAttributeRuns note of+          [run] ->+            fmap ppsChecked (parParagraphStyle run) `shouldBe` Just Nothing+          runs -> expectationFailure $ "expected 1 run, got " <> show (length runs)++  it "decodes starting_list_item_number into ppsListStart = Just 3" $+    case decodeNoteStoreProto numberedListStart3Bytes of+      Left err -> expectationFailure err+      Right note ->+        case pnAttributeRuns note of+          [run] ->+            fmap ppsListStart (parParagraphStyle run) `shouldBe` Just (Just 3)+          runs -> expectationFailure $ "expected 1 run, got " <> show (length runs)++  it "decodes block_quote into ppsBlockQuote = True" $+    case decodeNoteStoreProto blockQuoteBytes of+      Left err -> expectationFailure err+      Right note ->+        case pnAttributeRuns note of+          [run] ->+            fmap ppsBlockQuote (parParagraphStyle run) `shouldBe` Just True+          runs -> expectationFailure $ "expected 1 run, got " <> show (length runs)++  prop "encode/decode roundtrip preserves ProtoParagraphStyle" $ \ps ->+    case decodeNoteStoreProto (mkNote "x" [runWith 1 (encodeParagraphStyle ps)]) of+      Left err -> counterexample err False+      Right note -> case pnAttributeRuns note of+        [run] -> parParagraphStyle run === Just ps+        runs -> counterexample ("expected 1 run, got " <> show (length runs)) False+++minimalNoteBytes :: ByteString+minimalNoteBytes = mkNote "hello" []+++noteWithRunBytes :: ByteString+noteWithRunBytes = mkNote "hi" [runWith 2 (psStyleType 1)]+++noteWithStrikethroughBytes :: ByteString+noteWithStrikethroughBytes = mkNote "hi" [runFields 2 [(7, 1)]]+++bulletIndent1Bytes :: ByteString+bulletIndent1Bytes = mkNote "hi" [runWith 1 (psStyleType 100 <> psIndentAmount 1)]+++checklistDoneBytes :: ByteString+checklistDoneBytes = mkNote "hi" [runWith 1 (psStyleType 103 <> psChecklist True)]+++checklistUndoneBytes :: ByteString+checklistUndoneBytes = mkNote "hi" [runWith 1 (psStyleType 103 <> psChecklist False)]+++checklistAbsentBytes :: ByteString+checklistAbsentBytes = mkNote "hi" [runWith 1 (psStyleType 103)]+++numberedListStart3Bytes :: ByteString+numberedListStart3Bytes = mkNote "hi" [runWith 1 (psStyleType 102 <> psListStart 3)]+++blockQuoteBytes :: ByteString+blockQuoteBytes = mkNote "hi" [runWith 1 psBlockQuote]
+ test/HStratus/Notes/TestHelper.hs view
@@ -0,0 +1,171 @@+{-# LANGUAGE NamedFieldPuns #-}++{- |+Module      : HStratus.Notes.TestHelper+Copyright   : (c) 2026 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD-3-Clause++Programmatic proto\/gzip fixture builders for 'ProtoSpec' and 'DecodeSpec':+encodes domain values with @proto3-wire@ and compresses with @zlib@, avoiding+hand-crafted byte literals.+-}+module HStratus.Notes.TestHelper+  ( mkNote+  , mkNoteGzip+  , mkNoteZlib+  , mkEmptyGzip+  , runWith+  , runFields+  , psStyleType+  , psIndentAmount+  , psChecklist+  , psListStart+  , psBlockQuote+  , encodeParagraphStyle+  , encodeNoteStyle+  , encodeNoteRun+  , encodeNoteText+  )+where++import qualified Codec.Compression.GZip as GZip+import qualified Codec.Compression.Zlib as Zlib+import Data.ByteString (ByteString)+import qualified Data.ByteString.Lazy as LBS+import Data.Int (Int32)+import Data.Text (Text)+import qualified Data.Text.Lazy as TL+import Network.HStratus.Internal.Notes.Note+  ( NoteRun (..)+  , NoteStyle (..)+  , NoteText (..)+  )+import Network.HStratus.Internal.Notes.Proto (ProtoParagraphStyle (..))+import qualified Proto3.Wire.Encode as Encode+++{- | Encode a NoteStoreProto with the given note text and attribute runs.+Returns raw (uncompressed) proto bytes.+-}+mkNote :: Text -> [Encode.MessageBuilder] -> ByteString+mkNote noteText runs =+  LBS.toStrict . Encode.toLazyByteString $+    Encode.embedded 2 $+      Encode.embedded 3 $+        Encode.text 2 (TL.fromStrict noteText)+          <> foldMap (Encode.embedded 5) runs+++-- | Like 'mkNote' but gzip-compressed, suitable for 'decodeNoteBody'.+mkNoteGzip :: Text -> [Encode.MessageBuilder] -> ByteString+mkNoteGzip noteText runs =+  LBS.toStrict . GZip.compress . LBS.fromStrict $ mkNote noteText runs+++-- | Like 'mkNote' but zlib-compressed, suitable for 'decodeNoteBody'.+mkNoteZlib :: Text -> [Encode.MessageBuilder] -> ByteString+mkNoteZlib noteText runs =+  LBS.toStrict . Zlib.compress . LBS.fromStrict $ mkNote noteText runs+++{- | Gzip-compressed empty proto (no document field).+'decodeNoteBody' returns @Left@ for this input.+-}+mkEmptyGzip :: ByteString+mkEmptyGzip = LBS.toStrict (GZip.compress LBS.empty)+++-- | An AttributeRun with the given length and a ParagraphStyle sub-message.+runWith :: Int32 -> Encode.MessageBuilder -> Encode.MessageBuilder+runWith len style = Encode.int32 1 len <> Encode.embedded 2 style+++{- | An AttributeRun with the given length and a list of (fieldNumber, value)+pairs for inline fields (font_weight, underlined, strikethrough, etc.).+-}+runFields :: Int32 -> [(Int32, Int32)] -> Encode.MessageBuilder+runFields len fields =+  Encode.int32 1 len+    <> foldMap (\(fn, v) -> Encode.int32 (fromIntegral fn) v) fields+++-- ParagraphStyle field builders -------------------------------------------++psStyleType :: Int32 -> Encode.MessageBuilder+psStyleType = Encode.int32 1+++psIndentAmount :: Int32 -> Encode.MessageBuilder+psIndentAmount = Encode.int32 4+++-- | Encode a Checklist sub-message (field 5) with the given done state.+psChecklist :: Bool -> Encode.MessageBuilder+psChecklist done =+  Encode.embedded 5 (Encode.int32 2 (if done then 1 else 0))+++psListStart :: Int32 -> Encode.MessageBuilder+psListStart = Encode.int32 7+++psBlockQuote :: Encode.MessageBuilder+psBlockQuote = Encode.int32 8 1+++-- Encoder functions -------------------------------------------------------++{- | Encode a 'ProtoParagraphStyle' back to wire bytes, reusing the ps* helpers.+Proto3 default values (0 / False / Nothing) are omitted to match wire convention.+-}+encodeParagraphStyle :: ProtoParagraphStyle -> Encode.MessageBuilder+encodeParagraphStyle ProtoParagraphStyle{ppsStyleType, ppsIndent, ppsChecked, ppsListStart, ppsBlockQuote} =+  (if ppsStyleType /= 0 then psStyleType ppsStyleType else mempty)+    <> (if ppsIndent /= 0 then psIndentAmount ppsIndent else mempty)+    <> maybe mempty psChecklist ppsChecked+    <> maybe mempty psListStart ppsListStart+    <> (if ppsBlockQuote then psBlockQuote else mempty)+++{- | Encode a 'NoteStyle' as a paragraph_style sub-message.+Inverse of 'toNoteStyle'; 'StyleBody False' is not in the image of+'toNoteStyle' and should not appear in generated test data.+-}+encodeNoteStyle :: NoteStyle -> Encode.MessageBuilder+encodeNoteStyle style = encodeParagraphStyle $ case style of+  StyleTitle -> ProtoParagraphStyle 0 0 Nothing Nothing False+  StyleHeading -> ProtoParagraphStyle 1 0 Nothing Nothing False+  StyleSubheading -> ProtoParagraphStyle 2 0 Nothing Nothing False+  StyleMonospaced -> ProtoParagraphStyle 4 0 Nothing Nothing False+  StyleBody q -> ProtoParagraphStyle 0 0 Nothing Nothing q+  StyleBullet i -> ProtoParagraphStyle 100 (fromIntegral i) Nothing Nothing False+  StyleDash i -> ProtoParagraphStyle 101 (fromIntegral i) Nothing Nothing False+  StyleNumbered i ms -> ProtoParagraphStyle 102 (fromIntegral i) Nothing (fmap fromIntegral ms) False+  StyleChecklist i c -> ProtoParagraphStyle 103 (fromIntegral i) (Just c) Nothing False+++{- | Encode a 'NoteRun' as an AttributeRun sub-message.+'nrLink' is always 'Nothing' in generated runs (deferred).+-}+encodeNoteRun :: NoteRun -> Encode.MessageBuilder+encodeNoteRun NoteRun{nrLength, nrStyle, nrBold, nrItalic, nrUnderline, nrStrikethrough, nrAttachmentId, nrLink} =+  Encode.int32 1 nrLength+    <> maybe mempty (Encode.embedded 2 . encodeNoteStyle) nrStyle+    <> (if fw /= 0 then Encode.int32 5 fw else mempty)+    <> (if nrUnderline then Encode.int32 6 1 else mempty)+    <> (if nrStrikethrough then Encode.int32 7 1 else mempty)+    <> maybe mempty (\t -> Encode.embedded 12 (Encode.text 1 (TL.fromStrict t))) nrAttachmentId+    <> maybe mempty (Encode.text 9 . TL.fromStrict) nrLink+ where+  fw :: Int32+  fw = case (nrBold, nrItalic) of+    (True, True) -> 3+    (True, False) -> 1+    (False, True) -> 2+    (False, False) -> 0+++-- | Encode a 'NoteText' to gzip-compressed proto bytes, suitable for 'decodeNoteBody'.+encodeNoteText :: NoteText -> ByteString+encodeNoteText NoteText{ntText, ntRuns} = mkNoteGzip ntText (map encodeNoteRun ntRuns)
+ test/HStratus/NotesSpec.hs view
@@ -0,0 +1,255 @@+{-# LANGUAGE OverloadedStrings #-}++{- |+Module      : HStratus.NotesSpec+Copyright   : (c) 2026 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD-3-Clause++Tests for the top-level Notes API ('Network.HStratus.Notes').+-}+module HStratus.NotesSpec (spec) where++import Control.Exception (displayException)+import Data.Aeson (object)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8+import qualified Data.ByteString.Lazy as LBS+import Data.IORef (IORef, newIORef, readIORef, writeIORef)+import qualified Data.Map.Strict as Map+import qualified Data.Text as Text+import Network.HStratus.Http (mkApiWith)+import Network.HStratus.Http.Endpoints (Endpoints (..))+import Network.HStratus.Notes+  ( NotesApi+  , NotesError (..)+  , getNote+  , mkNotesApi+  , noteFolders+  , notesInFolder+  , recentNotes+  )+import Network.HStratus.Notes.Note+import Network.HStratus.Session (AccountData (..), Credentials (..), Session (..), Webservice (..))+import Network.HTTP.Client (Request (..), defaultManagerSettings, defaultRequest, newManager)+import Network.HTTP.Types (HeaderName, hContentType, methodPost, status200, status404)+import Network.Wai (Application, rawPathInfo, responseLBS)+import Network.Wai.Handler.Warp (testWithApplication)+import System.IO.Temp (withSystemTempDirectory)+import Test.Hspec+import Test.Hspec.Benri (endsNothing)+++spec :: Spec+spec = describe "Network.HStratus.Notes" $ do+  describe "NotesError displayException" $ do+    it "NotesHttpError" $+      displayException (NotesHttpError 404) `shouldBe` "iCloud Notes: HTTP error 404"+    it "NotesParseError" $+      displayException (NotesParseError "bad json") `shouldBe` "iCloud Notes: parse error: bad json"++  describe "noteFolders" $ do+    it "returns NoteFolder list from a query response" $+      withNotesMock queryFoldersJson "/records/query" $ \na -> do+        folders <- noteFolders na+        case folders of+          [f] -> do+            nfId f `shouldBe` FolderId "Folder/FOLDER-FIXTURE"+            nfName f `shouldBe` Just "Synthetic Folder"+          _ -> expectationFailure $ "expected 1 folder, got " <> show (length folders)++  describe "recentNotes" $ do+    it "returns NoteSummary list from a query response" $+      withNotesMock queryNotesJson "/records/query" $ \na -> do+        notes <- recentNotes na+        case notes of+          [n] -> do+            nsId n `shouldBe` NoteId "Note/NOTE-FIXTURE"+            nsTitle n `shouldBe` Just "Synthetic note"+          _ -> expectationFailure $ "expected 1 note, got " <> show (length notes)+    it "accumulates results across paginated responses" $+      withPaginatedMock $ \na -> do+        notes <- recentNotes na+        length notes `shouldBe` 2++  describe "getNote" $ do+    it "returns Just Note with decoded body for a live record" $+      withNotesMock lookupNoteJson "/records/lookup" $ \na -> do+        result <- getNote na (NoteId "Note/NOTE-FIXTURE")+        case result of+          Nothing -> expectationFailure "expected Just Note"+          Just n -> noteBodyBytes n `shouldBe` "synthetic note body"+    it "returns Nothing for a tombstone record" $+      withNotesMock tombstoneLookupJson "/records/lookup" $ \na ->+        endsNothing $ getNote na (NoteId "Note/NOTE-DELETED-FIXTURE")++  describe "notesInFolder" $ do+    it "returns only notes in the given folder, excluding other folders and deleted notes" $+      withNotesMock folderChangesJson "/changes/zone" $ \na -> do+        notes <- notesInFolder na (FolderId "Folder/FOLDER-FIXTURE")+        case notes of+          [n] -> nsId n `shouldBe` NoteId "Note/NOTE-FIXTURE"+          _ -> expectationFailure $ "expected 1 note, got " <> show (length notes)+++-- Mock servers++withNotesMock+  :: LBS.ByteString+  -> BS.ByteString+  -> (NotesApi -> IO a)+  -> IO a+withNotesMock json pathSuffix action =+  withSystemTempDirectory "icloud-notes-mock" $ \tmpDir ->+    testWithApplication (pure (simpleApp json pathSuffix)) $ \serverPort -> do+      na <- mkTestNotesApi serverPort tmpDir+      action na+++withPaginatedMock :: (NotesApi -> IO a) -> IO a+withPaginatedMock action =+  withSystemTempDirectory "icloud-notes-paginated" $ \tmpDir -> do+    callRef <- newIORef (0 :: Int)+    testWithApplication (pure (paginatedApp callRef)) $ \serverPort -> do+      na <- mkTestNotesApi serverPort tmpDir+      action na+++simpleApp :: LBS.ByteString -> BS.ByteString -> Application+simpleApp json pathSuffix req respond+  | pathSuffix `BS.isSuffixOf` rawPathInfo req =+      respond $ responseLBS status200 jsonHeaders json+  | otherwise =+      respond $ responseLBS status404 [] "not found"+++paginatedApp :: IORef Int -> Application+paginatedApp callRef _req respond = do+  n <- readIORef callRef+  writeIORef callRef (n + 1)+  respond $ responseLBS status200 jsonHeaders (if n == 0 then page1Json else page2Json)+++mkTestNotesApi :: Int -> FilePath -> IO NotesApi+mkTestNotesApi serverPort tmpDir = do+  let baseUrl = Text.pack $ "http://127.0.0.1:" ++ show serverPort+  mgr <- newManager defaultManagerSettings+  api <- mkApiWith (testSession tmpDir) (testAuthEndpoints serverPort) mgr+  mkNotesApi (testAccountData baseUrl) (testSession tmpDir) api+++-- Fixtures++testAccountData :: Text.Text -> AccountData+testAccountData baseUrl =+  AccountData+    { adHsaVersion = 2+    , adHsaChallengeRequired = False+    , adHsaTrustedBrowser = Just True+    , adWebservices = Map.fromList [("ckdatabasews", Webservice baseUrl Nothing)]+    , adRaw = object []+    }+++testSession :: FilePath -> Session+testSession topDir =+  Session+    { sessionCreds = Credentials{credAccountName = "test@example.com", credPassword = "test-pass"}+    , sessionTopDir = topDir+    , sessionClientId = "auth-test-client-id"+    }+++testAuthEndpoints :: Int -> Endpoints+testAuthEndpoints serverPort =+  Endpoints+    { epHome = "http://127.0.0.1:" <> BS8.pack (show serverPort)+    , epAuth = dummyReq "/appleauth/auth"+    , epSetup = dummyReq "/setup/ws/1"+    , epWidgetKey = "test-widget-key"+    }+ where+  dummyReq p =+    defaultRequest+      { host = "127.0.0.1"+      , port = serverPort+      , secure = False+      , method = methodPost+      , path = p+      }+++jsonHeaders :: [(HeaderName, BS8.ByteString)]+jsonHeaders = [(hContentType, "application/json")]+++queryFoldersJson :: LBS.ByteString+queryFoldersJson =+  "{\"records\":[{\"recordName\":\"Folder/FOLDER-FIXTURE\"\+  \,\"recordType\":\"Folder\"\+  \,\"fields\":{\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIEZvbGRlcg==\"}}}]\+  \,\"continuationMarker\":null}"+++queryNotesJson :: LBS.ByteString+queryNotesJson =+  "{\"records\":[{\"recordName\":\"Note/NOTE-FIXTURE\"\+  \,\"recordType\":\"Note\"\+  \,\"fields\":{\+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}}}]\+  \,\"continuationMarker\":null}"+++lookupNoteJson :: LBS.ByteString+lookupNoteJson =+  "{\"records\":[{\"recordName\":\"Note/NOTE-FIXTURE\"\+  \,\"recordType\":\"Note\"\+  \,\"fields\":{\+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\+  \,\"TextDataEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"c3ludGhldGljIG5vdGUgYm9keQ==\"}\+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}}}]}"+++tombstoneLookupJson :: LBS.ByteString+tombstoneLookupJson =+  "{\"records\":[{\"recordName\":\"Note/NOTE-DELETED-FIXTURE\",\"deleted\":true}]}"+++folderChangesJson :: LBS.ByteString+folderChangesJson =+  "{\"zones\":[{\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\+  \,\"syncToken\":\"sync-1\",\"moreComing\":false\+  \,\"records\":[\+  \{\"recordName\":\"Note/NOTE-FIXTURE\",\"recordType\":\"Note\"\+  \,\"fields\":{\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}}}\+  \,{\"recordName\":\"Note/NOTE-OTHER\",\"recordType\":\"Note\"\+  \,\"fields\":{\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"T3RoZXIgTm90ZQ==\"}\+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/OTHER-FOLDER\",\"action\":\"VALIDATE\"}}}}\+  \,{\"recordName\":\"Note/NOTE-DELETED\",\"recordType\":\"Note\"\+  \,\"fields\":{\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"RGVsZXRlZCBOb3Rl\"}\+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":1}\+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}}}]}]}"+++page1Json :: LBS.ByteString+page1Json =+  "{\"records\":[{\"recordName\":\"Note/NOTE-1\"\+  \,\"recordType\":\"Note\"\+  \,\"fields\":{\+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"Tm90ZSAx\"}\+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}}}]\+  \,\"continuationMarker\":\"page-marker-1\"}"+++page2Json :: LBS.ByteString+page2Json =+  "{\"records\":[{\"recordName\":\"Note/NOTE-2\"\+  \,\"recordType\":\"Note\"\+  \,\"fields\":{\+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"Tm90ZSAy\"}\+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}}}]\+  \,\"continuationMarker\":null}"
+ test/Spec.hs view
@@ -0,0 +1,38 @@+{- |+Module      : Main+Copyright   : (c) 2026 Tim Emiola+Maintainer  : Tim Emiola <adetokunbo@emio.la>+SPDX-License-Identifier: BSD-3-Clause++Test suite entry point for the hstratus-notes package.+-}+module Main where++import qualified HStratus.Notes.CloudKitSpec as CloudKit+import qualified HStratus.Notes.DecodeSpec as Decode+import qualified HStratus.Notes.EndpointsSpec as Endpoints+import qualified HStratus.Notes.MarkdownSpec as Markdown+import qualified HStratus.Notes.NoteDataSpec as NoteData+import qualified HStratus.Notes.ProtoSpec as Proto+import qualified HStratus.NotesSpec as Notes+import System.IO+  ( BufferMode (..)+  , hSetBuffering+  , stderr+  , stdout+  )+import Test.Hspec+++main :: IO ()+main = do+  hSetBuffering stdout NoBuffering+  hSetBuffering stderr NoBuffering+  hspec $ do+    CloudKit.spec+    Decode.spec+    Endpoints.spec+    Markdown.spec+    NoteData.spec+    Proto.spec+    Notes.spec