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 +9/−0
- LICENSE +30/−0
- README.md +98/−0
- Setup.hs +4/−0
- hstratus-notes.cabal +117/−0
- src-internal/Network/HStratus/Internal/Notes/CloudKit.hs +316/−0
- src-internal/Network/HStratus/Internal/Notes/Decode.hs +122/−0
- src-internal/Network/HStratus/Internal/Notes/Download.hs +161/−0
- src-internal/Network/HStratus/Internal/Notes/Endpoints.hs +189/−0
- src-internal/Network/HStratus/Internal/Notes/Markdown.hs +302/−0
- src-internal/Network/HStratus/Internal/Notes/Note.hs +139/−0
- src-internal/Network/HStratus/Internal/Notes/NoteData.hs +152/−0
- src-internal/Network/HStratus/Internal/Notes/Proto.hs +155/−0
- src/Network/HStratus/Notes.hs +82/−0
- src/Network/HStratus/Notes/Markdown.hs +17/−0
- src/Network/HStratus/Notes/Note.hs +32/−0
- test/HStratus/Notes/Arbitraries.hs +96/−0
- test/HStratus/Notes/CloudKitSpec.hs +192/−0
- test/HStratus/Notes/DecodeSpec.hs +151/−0
- test/HStratus/Notes/EndpointsSpec.hs +137/−0
- test/HStratus/Notes/MarkdownSpec.hs +362/−0
- test/HStratus/Notes/NoteDataSpec.hs +232/−0
- test/HStratus/Notes/ProtoSpec.hs +158/−0
- test/HStratus/Notes/TestHelper.hs +171/−0
- test/HStratus/NotesSpec.hs +255/−0
- test/Spec.hs +38/−0
+ 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