├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── ci.yml ├── FUNDING.yml ├── app └── Main.hs ├── test ├── Spec.hs └── Test │ ├── Sauron.hs │ └── Sauron │ ├── Top.hs │ └── Top │ ├── Client.hs │ └── Tweet.hs ├── cabal.project ├── CHANGELOG.md ├── src ├── Sauron.hs └── Sauron │ ├── Env.hs │ ├── App.hs │ ├── Settings.hs │ ├── Top │ ├── User.hs │ ├── Json.hs │ ├── Tweet.hs │ └── Client.hs │ ├── Cli.hs │ └── Top.hs ├── .gitignore ├── .stylish-haskell.yaml ├── README.md ├── example.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── sauron.cabal └── LICENSE /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @chshersh -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [chshersh] 2 | -------------------------------------------------------------------------------- /app/Main.hs: -------------------------------------------------------------------------------- 1 | module Main (main) where import Sauron (main) -------------------------------------------------------------------------------- /test/Spec.hs: -------------------------------------------------------------------------------- 1 | module Main (main) where 2 | 3 | import Test.Hspec (hspec) 4 | 5 | import Test.Sauron (sauronSpec) 6 | 7 | 8 | main :: IO () 9 | main = hspec sauronSpec 10 | -------------------------------------------------------------------------------- /cabal.project: -------------------------------------------------------------------------------- 1 | packages: . 2 | 3 | source-repository-package 4 | type: git 5 | location: https://github.com/uhbif19/colourista.git 6 | tag: 8148a0446bf61814f79d6a0c497007fde72b31eb -------------------------------------------------------------------------------- /test/Test/Sauron.hs: -------------------------------------------------------------------------------- 1 | module Test.Sauron (sauronSpec) where 2 | 3 | import Test.Hspec (Spec, describe) 4 | 5 | import Test.Sauron.Top (topSpec) 6 | 7 | 8 | sauronSpec :: Spec 9 | sauronSpec = describe "Sauron" $ do 10 | topSpec 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | `sauron` uses [PVP Versioning][1]. 4 | The changelog is available [on GitHub][2]. 5 | 6 | ## 0.0.0.0 7 | 8 | * Initially created. 9 | 10 | [1]: https://pvp.haskell.org 11 | [2]: https://github.com/chshersh/sauron/releases 12 | -------------------------------------------------------------------------------- /test/Test/Sauron/Top.hs: -------------------------------------------------------------------------------- 1 | module Test.Sauron.Top (topSpec) where 2 | 3 | import Test.Hspec (Spec, describe) 4 | 5 | import Test.Sauron.Top.Client (clientSpec) 6 | import Test.Sauron.Top.Tweet (tweetSpec) 7 | 8 | 9 | topSpec :: Spec 10 | topSpec = describe "Top" $ do 11 | clientSpec 12 | tweetSpec 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | commit-message: 8 | prefix: "GA" 9 | include: "scope" 10 | labels: 11 | - "CI" 12 | - "library :books:" 13 | 14 | -------------------------------------------------------------------------------- /src/Sauron.hs: -------------------------------------------------------------------------------- 1 | {- | 2 | Copyright: (c) 2022 Dmitrii Kovanikov 3 | SPDX-License-Identifier: MPL-2.0 4 | Maintainer: Dmitrii Kovanikov 5 | 6 | The eye that watches everything you did on Twitter 7 | -} 8 | 9 | module Sauron 10 | ( main 11 | ) where 12 | 13 | import Sauron.App (App, runApp) 14 | import Sauron.Cli (Cmd (..)) 15 | import Sauron.Top (runTop) 16 | 17 | import qualified Iris 18 | 19 | 20 | main :: IO () 21 | main = runApp app 22 | 23 | app :: App () 24 | app = Iris.asksCliEnv Iris.cliEnvCmd >>= \case 25 | Top topArgs -> runTop topArgs 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Haskell 2 | dist 3 | dist-* 4 | cabal-dev 5 | *.o 6 | *.hi 7 | *.chi 8 | *.chs.h 9 | *.dyn_o 10 | *.dyn_hi 11 | *.prof 12 | *.aux 13 | *.hp 14 | *.eventlog 15 | .virtualenv 16 | .hsenv 17 | .hpc 18 | .cabal-sandbox/ 19 | cabal.sandbox.config 20 | cabal.config 21 | cabal.project.local 22 | .ghc.environment.* 23 | .HTF/ 24 | .hie/ 25 | # Stack 26 | .stack-work/ 27 | stack.yaml.lock 28 | 29 | ### IDE/support 30 | # Vim 31 | [._]*.s[a-v][a-z] 32 | [._]*.sw[a-p] 33 | [._]s[a-v][a-z] 34 | [._]sw[a-p] 35 | *~ 36 | tags 37 | 38 | # IntellijIDEA 39 | .idea/ 40 | .ideaHaskellLib/ 41 | *.iml 42 | 43 | # Atom 44 | .haskell-ghc-mod.json 45 | 46 | # VS 47 | .vscode/ 48 | 49 | # Emacs 50 | *# 51 | .dir-locals.el 52 | TAGS 53 | 54 | # other 55 | .DS_Store 56 | -------------------------------------------------------------------------------- /src/Sauron/Env.hs: -------------------------------------------------------------------------------- 1 | {- | 2 | Copyright: (c) 2022 Dmitrii Kovanikov 3 | SPDX-License-Identifier: MPL-2.0 4 | Maintainer: Dmitrii Kovanikov 5 | 6 | Sauron application monad environment. 7 | -} 8 | 9 | module Sauron.Env 10 | ( Env (..) 11 | , mkEnv 12 | ) where 13 | 14 | import Control.Exception (throwIO) 15 | import Network.HTTP.Client (Manager) 16 | import Network.HTTP.Client.TLS (newTlsManager) 17 | import System.IO.Error (userError) 18 | 19 | 20 | -- | A type storing all app environment fields. 21 | data Env = Env 22 | { envManager :: Manager 23 | , envToken :: Text 24 | } 25 | 26 | -- | Create application environment. 27 | mkEnv :: IO Env 28 | mkEnv = do 29 | envManager <- newTlsManager 30 | 31 | envToken <- lookupEnv "TWITTER_TOKEN" >>= \case 32 | Just token -> pure $ toText token 33 | Nothing -> throwIO $ userError "The environment variable 'TWITTER_TOKEN' not found" 34 | 35 | pure Env{..} 36 | -------------------------------------------------------------------------------- /src/Sauron/App.hs: -------------------------------------------------------------------------------- 1 | {- | 2 | Copyright: (c) 2022 Dmitrii Kovanikov 3 | SPDX-License-Identifier: MPL-2.0 4 | Maintainer: Dmitrii Kovanikov 5 | 6 | Main application monad and its environment to run the app. 7 | -} 8 | 9 | module Sauron.App 10 | ( App (..) 11 | , runApp 12 | ) where 13 | 14 | import Sauron.Cli (Cmd) 15 | import Sauron.Env (Env, mkEnv) 16 | import Sauron.Settings (mkSettings) 17 | 18 | import qualified Iris 19 | 20 | -- | Sauron CLI application monad. 21 | newtype App a = App 22 | { unApp :: Iris.CliApp Cmd Env a 23 | } deriving newtype 24 | ( Functor 25 | , Applicative 26 | , Monad 27 | , MonadIO 28 | , MonadReader (Iris.CliEnv Cmd Env) 29 | ) 30 | 31 | {- | Run the Sauron CLI application monad by: 32 | 33 | 1. Creating application environment. 34 | 2. Constructing application settings. 35 | 3. Running the monadic action. 36 | -} 37 | runApp :: App a -> IO a 38 | runApp app = do 39 | env <- mkEnv 40 | let settings = mkSettings env 41 | Iris.runCliApp settings $ unApp app 42 | -------------------------------------------------------------------------------- /src/Sauron/Settings.hs: -------------------------------------------------------------------------------- 1 | {- | 2 | Copyright: (c) 2022 Dmitrii Kovanikov 3 | SPDX-License-Identifier: MPL-2.0 4 | Maintainer: Dmitrii Kovanikov 5 | 6 | Settings for Iris. 7 | -} 8 | 9 | module Sauron.Settings 10 | ( mkSettings 11 | ) where 12 | 13 | import Sauron.Cli (Cmd, cmdP) 14 | import Sauron.Env (Env) 15 | 16 | import qualified Iris 17 | import qualified Paths_sauron as Autogen 18 | 19 | 20 | -- | Sauron application settings 21 | mkSettings :: Env -> Iris.CliEnvSettings Cmd Env 22 | mkSettings env = Iris.defaultCliEnvSettings 23 | { Iris.cliEnvSettingsCmdParser = cmdP 24 | 25 | , Iris.cliEnvSettingsAppEnv = env 26 | 27 | , Iris.cliEnvSettingsHeaderDesc = 28 | "sauron - an evil eye that watches your Twitter" 29 | 30 | , Iris.cliEnvSettingsProgDesc = 31 | "A CLI tool to get insights from Twitter (top tweets, etc.)" 32 | 33 | , Iris.cliEnvSettingsVersionSettings = 34 | Just (Iris.defaultVersionSettings Autogen.version) 35 | { Iris.versionSettingsMkDesc = \v -> "Sauron v" <> v 36 | } 37 | 38 | , Iris.cliEnvSettingsRequiredTools = [] 39 | } 40 | -------------------------------------------------------------------------------- /test/Test/Sauron/Top/Client.hs: -------------------------------------------------------------------------------- 1 | module Test.Sauron.Top.Client (clientSpec) where 2 | 3 | import Servant.Client.Core.BaseUrl (showBaseUrl) 4 | import Servant.Links (allLinks, linkURI) 5 | import Test.Hspec (Spec, describe, it, shouldBe) 6 | 7 | import Sauron.Top.Client (GetTweets, twitterBaseUrl) 8 | import Sauron.Top.User (UserId (..)) 9 | 10 | 11 | clientSpec :: Spec 12 | clientSpec = describe "Client" $ do 13 | it "the GetTweets URL is correct" $ 14 | generatedUrl `shouldBe` expectedUrl 15 | 16 | generatedUrl :: Text 17 | generatedUrl = mconcat 18 | [ toText $ showBaseUrl twitterBaseUrl 19 | , "/" 20 | , path 21 | ] 22 | where 23 | path :: Text 24 | path = show 25 | $ linkURI 26 | $ allLinks 27 | (Proxy @GetTweets) 28 | (UserId "2164623379") 29 | (Just 5) 30 | (Just "retweets") 31 | (Just "created_at,public_metrics") 32 | (Just "2022-09-01T13:52:14Z") 33 | Nothing 34 | 35 | expectedUrl :: Text 36 | expectedUrl = "https://api.twitter.com/2/users/2164623379/tweets?max_results=5&exclude=retweets&tweet.fields=created_at%2Cpublic_metrics&end_time=2022-09-01T13%3A52%3A14Z" 37 | -------------------------------------------------------------------------------- /.stylish-haskell.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - simple_align: 3 | cases: true 4 | top_level_patterns: true 5 | records: true 6 | 7 | # Import cleanup 8 | - imports: 9 | align: none 10 | list_align: after_alias 11 | pad_module_names: false 12 | long_list_align: inline 13 | empty_list_align: inherit 14 | list_padding: 4 15 | separate_lists: true 16 | space_surround: false 17 | 18 | - language_pragmas: 19 | style: vertical 20 | remove_redundant: true 21 | 22 | # Remove trailing whitespace 23 | - trailing_whitespace: {} 24 | 25 | columns: 100 26 | 27 | newline: native 28 | 29 | language_extensions: 30 | - BangPatterns 31 | - ConstraintKinds 32 | - DataKinds 33 | - DefaultSignatures 34 | - DeriveAnyClass 35 | - DeriveDataTypeable 36 | - DeriveGeneric 37 | - DerivingStrategies 38 | - DerivingVia 39 | - ExplicitNamespaces 40 | - FlexibleContexts 41 | - FlexibleInstances 42 | - FunctionalDependencies 43 | - GADTs 44 | - GeneralizedNewtypeDeriving 45 | - InstanceSigs 46 | - KindSignatures 47 | - LambdaCase 48 | - MultiParamTypeClasses 49 | - MultiWayIf 50 | - NamedFieldPuns 51 | - OverloadedStrings 52 | - QuasiQuotes 53 | - RecordWildCards 54 | - ScopedTypeVariables 55 | - StandaloneDeriving 56 | - TemplateHaskell 57 | - TupleSections 58 | - TypeApplications 59 | - TypeFamilies 60 | - ViewPatterns 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sauron 2 | 3 | [![GitHub CI](https://github.com/chshersh/sauron/workflows/CI/badge.svg)](https://github.com/chshersh/sauron/actions) 4 | [![Hackage](https://img.shields.io/hackage/v/sauron.svg?logo=haskell)](https://hackage.haskell.org/package/sauron) 5 | [![MPL-2.0 license](https://img.shields.io/badge/license-MPL--2.0-blue.svg)](LICENSE) 6 | 7 | 👁 `sauron` is a CLI tool that fetches info from Twitter and analyses it. 8 | 9 | > 🌈 `sauron` is a demo project implemented using [Iris][iris] — a Haskell CLI 10 | > framework. 11 | 12 | [iris]: https://github.com/chshersh/iris 13 | 14 | ## Features 15 | 16 | Features currently supported by `sauron`: 17 | 18 | * Get top tweets of a Twitter account (limited by only 3200 recent tweets) 19 | * Save intermediate results to a file (to avoid hitting Twitter API limit too early) 20 | * Read cached results from a file 21 | 22 | ## How to use? 23 | 24 | 1. [Generate your own Twitter token][token] and export it as the 25 | `$TWITTER_TOKEN` variable. 26 | 27 | 2. Clone the project. 28 | 29 | ```shell 30 | git clone git@github.com:chshersh/sauron.git 31 | cd sauron 32 | ``` 33 | 34 | 3. Build and run the tool 35 | 36 | > ⚠️ Requires GHC 9.2 37 | 38 | ```shell 39 | cabal run sauron -- top @ --max=20 --to-file=path/to/save/results.json 40 | ``` 41 | 42 | [token]: https://developer.twitter.com/en/docs/twitter-api/getting-started/getting-access-to-the-twitter-api -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | types: [synchronize, opened, reopened] 6 | push: 7 | branches: [main] 8 | schedule: 9 | # additionally run once per week (At 00:00 on Sunday) to maintain cache 10 | - cron: '0 0 * * 0' 11 | 12 | jobs: 13 | cabal: 14 | name: ${{ matrix.os }} / ghc ${{ matrix.ghc }} 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest] 19 | cabal: ["3.6"] 20 | ghc: 21 | - "9.2.4" 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | 26 | - uses: haskell/actions/setup@v2.0 27 | id: setup-haskell-cabal 28 | name: Setup Haskell 29 | with: 30 | ghc-version: ${{ matrix.ghc }} 31 | cabal-version: ${{ matrix.cabal }} 32 | 33 | - name: Configure 34 | run: | 35 | cabal configure --enable-tests --enable-benchmarks --enable-documentation --test-show-details=direct --write-ghc-environment-files=always 36 | 37 | - name: Freeze 38 | run: | 39 | cabal freeze 40 | 41 | - uses: actions/cache@v3 42 | name: Cache ~/.cabal/store 43 | with: 44 | path: ${{ steps.setup-haskell-cabal.outputs.cabal-store }} 45 | key: ${{ runner.os }}-${{ matrix.ghc }}-${{ hashFiles('cabal.project.freeze') }} 46 | 47 | - name: Install dependencies 48 | run: | 49 | cabal build all --only-dependencies 50 | 51 | - name: Build 52 | run: | 53 | cabal build all 54 | 55 | - name: Test 56 | run: | 57 | cabal test all 58 | 59 | - name: Documentation 60 | run: | 61 | cabal haddock 62 | -------------------------------------------------------------------------------- /src/Sauron/Top/User.hs: -------------------------------------------------------------------------------- 1 | {- | 2 | Copyright: (c) 2022 Dmitrii Kovanikov 3 | SPDX-License-Identifier: MPL-2.0 4 | Maintainer: Dmitrii Kovanikov 5 | 6 | User-related types in the 'sauron top' command. 7 | -} 8 | 9 | module Sauron.Top.User 10 | ( -- * Core types 11 | User (..) 12 | , UserId (..) 13 | , Username (..) 14 | , mkUsername 15 | ) where 16 | 17 | import Data.Aeson (FromJSON (..), withObject, (.:)) 18 | import Servant.API (ToHttpApiData) 19 | 20 | import qualified Data.Text as Text 21 | 22 | 23 | -- | A Twitter User ID like: "2244994945" 24 | newtype UserId = UserId 25 | { unUserId :: Text 26 | } deriving newtype (FromJSON, ToHttpApiData) 27 | 28 | {- | Stores Twitter Username handle without \@. 29 | 30 | Use 'mkUsername' for safe creation. 31 | -} 32 | newtype Username = Username 33 | { unUsername :: Text 34 | } deriving newtype (ToHttpApiData) 35 | 36 | -- | Strips \@ from the username 37 | mkUsername :: Text -> Username 38 | mkUsername username 39 | = Username 40 | $ fromMaybe username 41 | $ Text.stripPrefix "@" username 42 | 43 | data User = User 44 | { userId :: UserId 45 | , userTweetCount :: Int 46 | } 47 | 48 | 49 | {- | Parses 'UserId' from JSON (excluding the data part, it's handled 50 | by the 'Data' type): 51 | 52 | @ 53 | { 54 | "data": { 55 | "id": "2164623379", 56 | "username": "ChShersh", 57 | "name": "Dmitrii Kovanikov", 58 | "public_metrics": { 59 | "followers_count": 2977, 60 | "following_count": 489, 61 | "tweet_count": 6502, 62 | "listed_count": 59 63 | } 64 | } 65 | } 66 | @ 67 | -} 68 | instance FromJSON User where 69 | parseJSON = withObject "User" $ \o -> do 70 | userId <- o .: "id" 71 | 72 | publicMetrics <- o .: "public_metrics" 73 | userTweetCount <- publicMetrics .: "tweet_count" 74 | 75 | pure User{..} 76 | -------------------------------------------------------------------------------- /src/Sauron/Top/Json.hs: -------------------------------------------------------------------------------- 1 | {- | 2 | Copyright: (c) 2022 Dmitrii Kovanikov 3 | SPDX-License-Identifier: MPL-2.0 4 | Maintainer: Dmitrii Kovanikov 5 | 6 | JSON parsing helpers and common utilities 7 | -} 8 | 9 | module Sauron.Top.Json 10 | ( Data (..) 11 | , Meta (..) 12 | , Page (..) 13 | ) where 14 | 15 | import Data.Aeson (FromJSON (..), Result (..), Value (Object), fromJSON, withObject, (.:), (.:?)) 16 | import Relude.Extra.Type (typeName) 17 | 18 | 19 | {- | Newtype wrapper for parsing JSON data inside the "data" 20 | field. For example: 21 | 22 | @ 23 | { "data": ... 24 | @ 25 | -} 26 | newtype Data a = Data 27 | { unData :: a 28 | } deriving stock (Show) 29 | deriving newtype (Eq) 30 | 31 | instance (FromJSON a, Typeable a) => FromJSON (Data a) where 32 | parseJSON = withObject ("Data " <> type_) $ \o -> do 33 | data_ <- o .: "data" 34 | pure $ Data data_ 35 | where 36 | type_ :: String 37 | type_ = toString $ typeName @a 38 | 39 | {- | Parses relevant parts of the "meta" object 40 | 41 | @ 42 | "meta": { 43 | "result_count": 5, 44 | "newest_id": "1565283709275803650", 45 | "oldest_id": "1565246649185849344", 46 | "next_token": "7140dibdnow9c7btw4232rrxi2153ga6h81clvyarutkk" 47 | } 48 | @ 49 | -} 50 | data Meta = Meta 51 | { metaResultCount :: Int 52 | , metaNextToken :: Maybe Text 53 | } deriving stock (Show, Eq) 54 | 55 | instance FromJSON Meta where 56 | parseJSON = withObject "Meta" $ \o -> do 57 | metaResultCount <- o .: "result_count" 58 | metaNextToken <- o .:? "next_token" 59 | pure Meta{..} 60 | 61 | {- | A result of a pagination request 62 | -} 63 | data Page a = Page 64 | { pageData :: Data a 65 | , pageMeta :: Meta 66 | } deriving stock (Show, Eq) 67 | 68 | instance (FromJSON a, Typeable a) => FromJSON (Page a) where 69 | parseJSON = withObject ("Page " <> type_) $ \o -> do 70 | pageData <- case fromJSON @(Data a) (Object o) of 71 | Error err -> fail err 72 | Success a -> pure a 73 | pageMeta <- o .: "meta" 74 | pure Page{..} 75 | where 76 | type_ :: String 77 | type_ = toString $ typeName @a 78 | -------------------------------------------------------------------------------- /src/Sauron/Cli.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE ApplicativeDo #-} 2 | 3 | {- | 4 | Copyright: (c) 2022 Dmitrii Kovanikov 5 | SPDX-License-Identifier: MPL-2.0 6 | Maintainer: Dmitrii Kovanikov 7 | 8 | Command-line arguments parsing 9 | -} 10 | 11 | module Sauron.Cli 12 | ( Cmd (..) 13 | , cmdP 14 | 15 | -- * sauron top 16 | , TopArgs (..) 17 | , CacheMode (..) 18 | ) where 19 | 20 | import Options.Applicative as Opt 21 | 22 | 23 | data Cmd 24 | = Top TopArgs 25 | 26 | data TopArgs = TopArgs 27 | { topArgsUsername :: Text -- ^ Twitter username handle 28 | , topArgsMax :: Int -- ^ Max top tweets to extract 29 | , topArgsCacheMode :: CacheMode -- ^ Store to file or read from file 30 | } 31 | 32 | data CacheMode 33 | = ToFile FilePath 34 | | FromFile FilePath 35 | 36 | -- | All possible commands. 37 | cmdP :: Opt.Parser Cmd 38 | cmdP = Opt.subparser $ mconcat 39 | [ Opt.command "top" 40 | $ Opt.info (Opt.helper <*> topP) 41 | $ Opt.progDesc "Top tweets of a user" 42 | ] 43 | 44 | topP :: Opt.Parser Cmd 45 | topP = do 46 | topArgsUsername <- Opt.strArgument $ mconcat 47 | [ Opt.metavar "TWITTER_NAME" 48 | , Opt.help "Twitter user handle: @my-name or my-name" 49 | ] 50 | 51 | topArgsMax <- Opt.option Opt.auto $ mconcat 52 | [ Opt.long "max" 53 | , Opt.short 'm' 54 | , Opt.metavar "POSITIVE_NUMBER" 55 | , Opt.help "Max number of tweets to output in the terminal" 56 | ] 57 | 58 | topArgsCacheMode <- cacheModeP 59 | 60 | pure $ Top TopArgs{..} 61 | 62 | cacheModeP :: Opt.Parser CacheMode 63 | cacheModeP = (ToFile <$> toFileP) <|> (FromFile <$> fromFileP) 64 | where 65 | toFileP :: Opt.Parser FilePath 66 | toFileP = Opt.strOption $ mconcat 67 | [ Opt.long "to-file" 68 | , Opt.metavar "FILE_PATH" 69 | , Opt.help "Save the Twitter output to a file (to avoid hitting the fetch limit)" 70 | ] 71 | 72 | fromFileP :: Opt.Parser FilePath 73 | fromFileP = Opt.strOption $ mconcat 74 | [ Opt.long "from-file" 75 | , Opt.metavar "FILE_PATH" 76 | , Opt.help "Read data from a previously saved file with the '--to-file' option" 77 | ] 78 | -------------------------------------------------------------------------------- /example.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "public_metrics": { 4 | "retweet_count": 0, 5 | "reply_count": 0, 6 | "like_count": 0, 7 | "quote_count": 0 8 | }, 9 | "text": "@klarkc In this particular case, laziness wasn't the problem. Something weirder was going on 🤔", 10 | "created_at": "2022-09-01T10:21:32.000Z", 11 | "id": "1565283709275803650" 12 | }, 13 | { 14 | "public_metrics": { 15 | "retweet_count": 0, 16 | "reply_count": 2, 17 | "like_count": 1, 18 | "quote_count": 0 19 | }, 20 | "text": "If you haven't finished your tea, I invite you to read one of my previous journeys into the wonders of the SQLite type system 🗺️🫖🚂\n\nhttps://t.co/4RElElHu0Z", 21 | "created_at": "2022-09-01T07:54:18.000Z", 22 | "id": "1565246658128056320" 23 | }, 24 | { 25 | "public_metrics": { 26 | "retweet_count": 0, 27 | "reply_count": 1, 28 | "like_count": 1, 29 | "quote_count": 0 30 | }, 31 | "text": "🔮 I still had some theories about the space leak. All the metrics show that it's not the Haskell process that leaks memory.\n\nI was even suspecting space leaks in SQLite itself as my setup was unusual. But I didn't have the time to verify my hypothesis so we'll never know...", 32 | "created_at": "2022-09-01T07:54:18.000Z", 33 | "id": "1565246655103995905" 34 | }, 35 | { 36 | "public_metrics": { 37 | "retweet_count": 0, 38 | "reply_count": 1, 39 | "like_count": 6, 40 | "quote_count": 0 41 | }, 42 | "text": "Are you still here? Do you still want to know how I solved this space leak? Very simple:\n\n🏝️ I left my job\n\nNow, it's no longer my problem and somebody else will continue what I've started 👶", 43 | "created_at": "2022-09-01T07:54:17.000Z", 44 | "id": "1565246652121767936" 45 | }, 46 | { 47 | "public_metrics": { 48 | "retweet_count": 0, 49 | "reply_count": 1, 50 | "like_count": 1, 51 | "quote_count": 0 52 | }, 53 | "text": "🗺️ Now, real digging started. I had to use old Unix tools as my grandpa did in the good old times.\n\nI sampled the output of the /proc/rss values to check the real memory usage of my process. I've even looked at the diff between mmaped memory regions.\n\nStill, no results 🙅", 54 | "created_at": "2022-09-01T07:54:16.000Z", 55 | "id": "1565246649185849344" 56 | } 57 | ] 58 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | This code of conduct outlines our expectations for all those who 4 | participate in the `sauron` project development. 5 | 6 | We invite all those who participate in `sauron` development to help us to 7 | create safe, inclusive and positive experiences. 8 | 9 | ## Our Standards 10 | 11 | The main concern of this project developers is friendliness and inclusion. 12 | As such, we are committed to providing a friendly, safe and welcoming environment for all. 13 | So, we are using the following standards in our organization. 14 | 15 | ### Be inclusive. 16 | 17 | We welcome and support people of all backgrounds and identities. This includes, 18 | but is not limited to members of any sexual orientation, gender identity and expression, 19 | race, ethnicity, culture, national origin, social and economic class, educational level, 20 | colour, immigration status, sex, age, size, family status, political belief, religion, 21 | and mental and physical ability. 22 | 23 | ### Be respectful. 24 | 25 | We won't all agree all the time but disagreement is no excuse for disrespectful behaviour. 26 | We will all experience frustration from time to time, but we cannot allow that frustration 27 | to become personal attacks. An environment where people feel uncomfortable or threatened 28 | is not a productive or creative one. 29 | 30 | ### Choose your words carefully. 31 | 32 | Always conduct yourself professionally. Be kind to others. Do not insult or put down others. 33 | Harassment and exclusionary behaviour aren't acceptable. This includes, but is not limited to: 34 | 35 | * Threats of violence. 36 | * Discriminatory language. 37 | * Personal insults, especially those using racist or sexist terms. 38 | * Advocating for, or encouraging, any of the above behaviours. 39 | 40 | ### Don't harass. 41 | 42 | In general, if someone asks you to stop something, then stop. When we disagree, try to understand why. 43 | Differences of opinion and disagreements are mostly unavoidable. What is important is that we resolve 44 | disagreements and differing views constructively. 45 | 46 | ### Make differences into strengths. 47 | 48 | Different people have different perspectives on issues, 49 | and that can be valuable for solving problems or generating new ideas. Being unable to understand why 50 | someone holds a viewpoint doesn’t mean that they’re wrong. Don’t forget that we all make mistakes, 51 | and blaming each other doesn’t get us anywhere. 52 | 53 | Instead, focus on resolving issues and learning from mistakes. 54 | 55 | ## Reporting Guidelines 56 | 57 | If you are subject to or witness unacceptable behaviour, or have any other concerns, 58 | please notify Dmitrii Kovainikov (the current admin of `sauron`) as soon as possible. 59 | 60 | You can reach them via the following email address: 61 | 62 | * kovanikov@gmail.com 63 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to sauron 2 | 3 | This document describes contributing guidelines for the `sauron` project. 4 | 5 | You're encouraged to read this document before your first contribution 6 | to the project. Spending your time familiarising yourself with these 7 | guidelines is much appreciated because following this guide ensures 8 | the most positive outcome for contributors and maintainers! 💖 9 | 10 | ## How to contribute 11 | 12 | Everyone is welcome to contribute as long as they follow our 13 | [rules for polite and respectful communication](https://github.com/chshersh/sauron/blob/main/CODE_OF_CONDUCT.md)! 14 | 15 | And you can contribute to `sauron` in multiple ways: 16 | 17 | 1. Share your success stories or confusion moments in 18 | [Discussions](https://github.com/chshersh/sauron/discussions). 19 | 2. Open [Issues](https://github.com/chshersh/sauron/issues) with bug 20 | reports or feature suggestions. 21 | 3. Open [Pull Requests (PRs)](https://github.com/chshersh/sauron/pulls) 22 | with documentation improvements, changes to the code or even 23 | implementation of desired changes! 24 | 25 | If you would like to open a PR, **create the issue first** if it 26 | doesn't exist. Discussing implementation details or various 27 | architecture decisions avoids spending time inefficiently. 28 | 29 | > You may argue that sometimes it's easier to share your vision with 30 | > exact code changes via a PR. Still, it's better to start a 31 | > Discussion or an Issue first by mentioning that you'll open a PR 32 | > later with your thoughts laid out in code. 33 | 34 | If you want to take an existing issue, please, share your intention to 35 | work on something in the comment section under the corresponding 36 | issue. This avoids the situation when multiple people are working on 37 | the same problem concurrently. 38 | 39 | ## Pull Requests requirements 40 | 41 | Generally, the process of submitting, reviewing and accepting PRs 42 | should be as lightweight as possible if you've discussed the 43 | implementation beforehand. However, there're still a few requirements: 44 | 45 | 1. Be polite and respectful in communications. Follow our 46 | [Code of Conduct](https://github.com/chshersh/sauron/blob/main/CODE_OF_CONDUCT.md). 47 | 2. The code should be formatted with `rustfmt` using formatting settings from 48 | this project. 49 | 50 | That's all so far! 51 | 52 | > ℹ️ **NOTE:** PRs are merged to the `main` branch using the 53 | > "Squash and merge" button. You can produce granular commit history 54 | > to make the review easier or if it's your preferred workflow. But 55 | > all commits will be squashed when merged to `main`. 56 | 57 | ## Write access to the repository 58 | 59 | If you want to gain write access to the repository, open a 60 | [discussion with the Commit Bits category](https://github.com/chshersh/sauron/discussions/categories/commit-bits) 61 | and mention your willingness to have it. 62 | 63 | I ([@chshersh](https://github.com/chshersh)) 64 | grant write access to everyone who contributed to `sauron`. 65 | -------------------------------------------------------------------------------- /src/Sauron/Top/Tweet.hs: -------------------------------------------------------------------------------- 1 | {- | 2 | Copyright: (c) 2022 Dmitrii Kovanikov 3 | SPDX-License-Identifier: MPL-2.0 4 | Maintainer: Dmitrii Kovanikov 5 | 6 | The 'Tweet' type in the JSON API response. 7 | -} 8 | 9 | module Sauron.Top.Tweet 10 | ( Tweet (..) 11 | , topTweets 12 | 13 | -- * Internals 14 | , parseTime 15 | , showTime 16 | , subtractSecond 17 | ) where 18 | 19 | import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, (.!=), (.:), (.:?), (.=)) 20 | import Data.Time.Clock (UTCTime, addUTCTime) 21 | import Data.Time.Format (defaultTimeLocale, formatTime) 22 | import Data.Time.Format.ISO8601 (formatParseM, iso8601Format) 23 | 24 | import Sauron.Top.User (Username (..)) 25 | 26 | import qualified Data.Text as Text 27 | 28 | 29 | data Tweet = Tweet 30 | { tweetId :: Text 31 | , tweetText :: Text 32 | , tweetCreatedAt :: UTCTime 33 | , tweetLikeCount :: Int 34 | , tweetRetweetCount :: Int 35 | , tweetReplyCount :: Int 36 | , tweetQuoteCount :: Int 37 | } deriving stock (Show, Eq) 38 | 39 | parseTime :: String -> Maybe UTCTime 40 | parseTime = formatParseM iso8601Format 41 | 42 | showTime :: UTCTime -> String 43 | showTime = formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" 44 | 45 | subtractSecond :: UTCTime -> UTCTime 46 | subtractSecond = addUTCTime (-1) 47 | 48 | {- | Parses Tweet from the following JSON object: 49 | 50 | @ 51 | { 52 | "public_metrics": { 53 | "retweet_count": 0, 54 | "reply_count": 1, 55 | "like_count": 1, 56 | "quote_count": 0 57 | }, 58 | "text": "🔮 I still had some theories about the space leak...", 59 | "created_at": "2022-09-01T07:54:18.000Z", 60 | "id": "1565246655103995905" 61 | } 62 | @ 63 | -} 64 | instance FromJSON Tweet where 65 | parseJSON = withObject "Tweet" $ \o -> do 66 | tweetId <- o .: "id" 67 | tweetText <- o .: "text" 68 | 69 | createdAt <- o .: "created_at" 70 | tweetCreatedAt <- case parseTime createdAt of 71 | Nothing -> fail $ "Error parsing time: " <> createdAt 72 | Just time -> pure time 73 | 74 | publicMetrics <- o .: "public_metrics" 75 | tweetLikeCount <- publicMetrics .: "like_count" 76 | tweetRetweetCount <- publicMetrics .:? "retweet_count" .!= 0 77 | tweetReplyCount <- publicMetrics .:? "reply_count" .!= 0 78 | tweetQuoteCount <- publicMetrics .:? "quote_count" .!= 0 79 | 80 | pure Tweet{..} 81 | 82 | instance ToJSON Tweet where 83 | toJSON Tweet{..} = object 84 | [ "id" .= tweetId 85 | , "created_at" .= showTime tweetCreatedAt 86 | , "text" .= tweetText 87 | , "public_metrics" .= object 88 | [ "like_count" .= tweetLikeCount 89 | , "retweet_count" .= tweetRetweetCount 90 | , "reply_count" .= tweetReplyCount 91 | , "quote_count" .= tweetQuoteCount 92 | ] 93 | ] 94 | 95 | -- | Extract top N tweets and pretty format them. 96 | topTweets :: Int -> Username -> [Tweet] -> Text 97 | topTweets maxTweets username 98 | = formatTweets username 99 | . take maxTweets 100 | . sortWith (Down . tweetLikeCount) 101 | 102 | formatTweets :: Username -> [Tweet] -> Text 103 | formatTweets _ [] = "No tweets found" 104 | formatTweets username tweets = foldMap formatTweet tweets 105 | where 106 | formatTweet :: Tweet -> Text 107 | formatTweet Tweet{..} = unlines 108 | [ "URL : " <> tweetUrl 109 | , "Tweeted at : " <> toText (showTime tweetCreatedAt) 110 | , "Likes : " <> show tweetLikeCount 111 | , "Awesome text :" 112 | , chunkedText 113 | ] 114 | where 115 | tweetUrl :: Text 116 | tweetUrl = mconcat 117 | [ "https://twitter.com/" 118 | , unUsername username 119 | , "/status/" 120 | , tweetId 121 | ] 122 | 123 | chunkedText :: Text 124 | chunkedText 125 | = unlines 126 | $ map (" " <>) 127 | $ Text.chunksOf 60 tweetText 128 | -------------------------------------------------------------------------------- /src/Sauron/Top/Client.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DataKinds #-} 2 | {-# LANGUAGE TypeOperators #-} 3 | 4 | {- | 5 | Copyright: (c) 2022 Dmitrii Kovanikov 6 | SPDX-License-Identifier: MPL-2.0 7 | Maintainer: Dmitrii Kovanikov 8 | 9 | Functions for querying Twitter API. 10 | 11 | __Auth:__ 12 | 13 | @ 14 | Authorization: Bearer $TWITTER_TOKEN 15 | @ 16 | -} 17 | 18 | module Sauron.Top.Client 19 | ( -- * User 20 | getUserByUsername 21 | , getTweets 22 | 23 | -- * Tweets 24 | , GetTweets 25 | 26 | -- * Internals 27 | , twitterBaseUrl 28 | ) where 29 | 30 | import Control.Exception (throwIO) 31 | import Servant.API (Capture, Get, Header', JSON, QueryParam, Required, Strict, (:>)) 32 | import Servant.Client (BaseUrl (..), ClientM, Scheme (Https), client, mkClientEnv, runClientM) 33 | import Servant.Client.Core (ClientError) 34 | 35 | import Sauron.App (App) 36 | import Sauron.Env (Env (..)) 37 | import Sauron.Top.Json (Data (..), Page (..)) 38 | import Sauron.Top.Tweet (Tweet) 39 | import Sauron.Top.User (User, UserId, Username (..)) 40 | 41 | import qualified Iris 42 | 43 | 44 | -- | URL for acessing Twitter API v2 45 | twitterBaseUrl :: BaseUrl 46 | twitterBaseUrl = BaseUrl 47 | { baseUrlScheme = Https 48 | , baseUrlHost = "api.twitter.com" 49 | , baseUrlPort = 443 50 | , baseUrlPath = "/2" 51 | } 52 | 53 | type RequiredHeader = Header' '[Required, Strict] 54 | 55 | {- | API type for the following URL: 56 | 57 | @ 58 | https://api.twitter.com/2/users/by/username/:username 59 | @ 60 | -} 61 | type GetUserByUsername 62 | = RequiredHeader "Authorization" Text 63 | :> "users" 64 | :> "by" 65 | :> "username" 66 | :> Capture "username" Username 67 | :> QueryParam "user.fields" Text 68 | :> Get '[JSON] (Data User) 69 | 70 | getUserByUsernameClient :: Text -> Username -> Maybe Text -> ClientM (Data User) 71 | getUserByUsernameClient = client $ Proxy @GetUserByUsername 72 | 73 | getUserByUsername :: Username -> App User 74 | getUserByUsername username = do 75 | manager <- Iris.asksAppEnv envManager 76 | let clientEnv = mkClientEnv manager twitterBaseUrl 77 | 78 | token <- Iris.asksAppEnv envToken 79 | let auth = "Bearer " <> token 80 | 81 | let request = getUserByUsernameClient auth username (Just "public_metrics") 82 | 83 | res <- liftIO $ runClientM request clientEnv 84 | case res of 85 | Right (Data userId) -> pure userId 86 | Left err -> do 87 | putStrLn $ "Error getting user ID: " ++ show err 88 | liftIO $ throwIO err 89 | 90 | {- | API type for the following URL: 91 | 92 | @ 93 | https://api.twitter.com/2/users/2164623379/tweets?max_results=5&exclude=retweets&tweet.fields=created_at,public_metrics&end_time=2022-09-01T13%3A52%3A14Z 94 | @ 95 | -} 96 | type GetTweets 97 | = RequiredHeader "Authorization" Text 98 | :> "users" 99 | :> Capture "id" UserId 100 | :> "tweets" 101 | :> QueryParam "max_results" Int 102 | :> QueryParam "exclude" Text 103 | :> QueryParam "tweet.fields" Text 104 | :> QueryParam "end_time" String 105 | :> QueryParam "pagination_token" Text 106 | :> Get '[JSON] (Page [Tweet]) 107 | 108 | getTweetsClient 109 | :: Text 110 | -> UserId 111 | -> Maybe Int 112 | -> Maybe Text 113 | -> Maybe Text 114 | -> Maybe String 115 | -> Maybe Text 116 | -> ClientM (Page [Tweet]) 117 | getTweetsClient = client $ Proxy @GetTweets 118 | 119 | getTweetsClientRequest :: Text -> UserId -> String -> Maybe Text -> ClientM (Page [Tweet]) 120 | getTweetsClientRequest authToken userId endTime paginationToken = getTweetsClient 121 | authToken 122 | userId 123 | (Just 100) -- Return 100 requests (max allowed) 124 | (Just "retweets") -- exclude retweets & replies 125 | (Just "created_at,public_metrics") -- extra fields to return 126 | (Just endTime) 127 | paginationToken 128 | 129 | getTweets :: UserId -> String -> Maybe Text -> App (Either ClientError (Page [Tweet])) 130 | getTweets userId endTime paginationToken = do 131 | manager <- Iris.asksAppEnv envManager 132 | let clientEnv = mkClientEnv manager twitterBaseUrl 133 | 134 | token <- Iris.asksAppEnv envToken 135 | let auth = "Bearer " <> token 136 | 137 | let request = getTweetsClientRequest 138 | auth 139 | userId 140 | endTime 141 | paginationToken 142 | 143 | liftIO $ runClientM request clientEnv 144 | -------------------------------------------------------------------------------- /sauron.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 3.0 2 | name: sauron 3 | version: 0.0.0.0 4 | synopsis: The eye that watches everything you did on Twitter 5 | description: 6 | The eye that watches everything you did on Twitter. 7 | See [README.md](https://github.com/chshersh/sauron#sauron) for more details. 8 | homepage: https://github.com/chshersh/sauron 9 | bug-reports: https://github.com/chshersh/sauron/issues 10 | license: MPL-2.0 11 | license-file: LICENSE 12 | author: Dmitrii Kovanikov 13 | maintainer: Dmitrii Kovanikov 14 | copyright: 2022 Dmitrii Kovanikov 15 | build-type: Simple 16 | extra-doc-files: README.md 17 | CHANGELOG.md 18 | tested-with: GHC == 9.2.4 19 | 20 | source-repository head 21 | type: git 22 | location: https://github.com/chshersh/sauron.git 23 | 24 | common common-options 25 | build-depends: base ^>= 4.16 26 | , relude ^>= 1.1 27 | 28 | mixins: base hiding (Prelude) 29 | , relude (Relude as Prelude) 30 | , relude 31 | 32 | ghc-options: -Wall 33 | -Wcompat 34 | -Widentities 35 | -Wincomplete-uni-patterns 36 | -Wincomplete-record-updates 37 | -Wredundant-constraints 38 | -Wnoncanonical-monad-instances 39 | if impl(ghc >= 8.2) 40 | ghc-options: -fhide-source-paths 41 | if impl(ghc >= 8.4) 42 | ghc-options: -Wmissing-export-lists 43 | -Wpartial-fields 44 | if impl(ghc >= 8.8) 45 | ghc-options: -Wmissing-deriving-strategies 46 | -fwrite-ide-info 47 | -hiedir=.hie 48 | if impl(ghc >= 9.0) 49 | ghc-options: -Winvalid-haddock 50 | if impl(ghc >= 9.2) 51 | ghc-options: -Wredundant-bang-patterns 52 | -Woperator-whitespace 53 | 54 | default-language: Haskell2010 55 | default-extensions: ConstraintKinds 56 | DeriveGeneric 57 | DerivingStrategies 58 | GeneralizedNewtypeDeriving 59 | InstanceSigs 60 | KindSignatures 61 | LambdaCase 62 | NumericUnderscores 63 | OverloadedStrings 64 | RecordWildCards 65 | ScopedTypeVariables 66 | StandaloneDeriving 67 | StrictData 68 | TupleSections 69 | TypeApplications 70 | ViewPatterns 71 | 72 | library 73 | import: common-options 74 | hs-source-dirs: src 75 | 76 | autogen-modules: Paths_sauron 77 | other-modules: Paths_sauron 78 | 79 | exposed-modules: 80 | Sauron 81 | Sauron.App 82 | Sauron.Cli 83 | Sauron.Env 84 | Sauron.Settings 85 | Sauron.Top 86 | Sauron.Top.Client 87 | Sauron.Top.Json 88 | Sauron.Top.User 89 | Sauron.Top.Tweet 90 | 91 | build-depends: 92 | , aeson ^>= 2.1 93 | , aeson-pretty ^>= 0.8 94 | , http-client ^>= 0.7 95 | , http-client-tls ^>= 0.3 96 | , iris ^>= 0.0 97 | , optparse-applicative ^>= 0.17 98 | , servant ^>= 0.19 99 | , servant-client ^>= 0.19 100 | , servant-client-core ^>= 0.19 101 | , time ^>= 1.11 102 | 103 | executable sauron 104 | import: common-options 105 | hs-source-dirs: app 106 | main-is: Main.hs 107 | build-depends: sauron 108 | ghc-options: -threaded 109 | -rtsopts 110 | -with-rtsopts=-N 111 | 112 | test-suite sauron-test 113 | import: common-options 114 | type: exitcode-stdio-1.0 115 | hs-source-dirs: test 116 | main-is: Spec.hs 117 | 118 | other-modules: 119 | Test.Sauron 120 | Test.Sauron.Top 121 | Test.Sauron.Top.Client 122 | Test.Sauron.Top.Tweet 123 | 124 | build-depends: 125 | , sauron 126 | , aeson 127 | , hedgehog ^>= 1.2 128 | , hspec >= 2.9.7 && < 2.11 129 | , hspec-hedgehog ^>= 0.0 130 | , servant 131 | , servant-client-core 132 | , time 133 | 134 | ghc-options: -threaded 135 | -rtsopts 136 | -with-rtsopts=-N -------------------------------------------------------------------------------- /test/Test/Sauron/Top/Tweet.hs: -------------------------------------------------------------------------------- 1 | module Test.Sauron.Top.Tweet (tweetSpec) where 2 | 3 | import Data.Time.Calendar (fromGregorian) 4 | import Data.Time.Clock (DiffTime, UTCTime (..), secondsToDiffTime) 5 | import Data.Time.Format (defaultTimeLocale, parseTimeM) 6 | import Hedgehog (Gen, forAll, tripping) 7 | import Test.Hspec (Spec, describe, it, shouldBe, shouldReturn) 8 | import Test.Hspec.Hedgehog (hedgehog) 9 | 10 | import Sauron.Top.Tweet (Tweet (..), parseTime, showTime, subtractSecond) 11 | 12 | import qualified Data.Aeson as Aeson 13 | import qualified Hedgehog.Gen as Gen 14 | import qualified Hedgehog.Range as Range 15 | 16 | 17 | tweetSpec :: Spec 18 | tweetSpec = describe "Tweet" $ do 19 | it "parses the time" $ do 20 | parseTime "2022-09-01T07:54:18.000Z" `shouldBe` 21 | Just (UTCTime (fromGregorian 2022 9 1) (parseTod "07:54:18")) 22 | 23 | it "shows the time" $ do 24 | showTime (UTCTime (fromGregorian 2022 9 1) (parseTod "07:54:18")) 25 | `shouldBe` "2022-09-01T07:54:18Z" 26 | 27 | it "subtracts one second" $ do 28 | subtractSecond (UTCTime (fromGregorian 2022 9 1) (parseTod "07:54:18")) 29 | `shouldBe` (UTCTime (fromGregorian 2022 9 1) (parseTod "07:54:17")) 30 | 31 | it "parses the example JSON tweet timeline" $ 32 | Aeson.eitherDecodeFileStrict "example.json" `shouldReturn` Right exampleTweets 33 | 34 | it "decodeJSON . encodeJSON = id" $ hedgehog $ do 35 | tweet <- forAll genTweet 36 | tripping tweet Aeson.encode Aeson.eitherDecode 37 | 38 | parseTod :: String -> DiffTime 39 | parseTod 40 | = fromMaybe (error "parsing time") 41 | . parseTimeM True defaultTimeLocale "%H:%M:%S" 42 | 43 | exampleTweets :: [Tweet] 44 | exampleTweets = 45 | [ Tweet 46 | { tweetId = "1565283709275803650" 47 | , tweetText = "@klarkc In this particular case, laziness wasn't the problem. Something weirder was going on 🤔" 48 | , tweetCreatedAt = UTCTime (fromGregorian 2022 9 1) (parseTod "10:21:32") 49 | , tweetLikeCount = 0 50 | , tweetRetweetCount = 0 51 | , tweetReplyCount = 0 52 | , tweetQuoteCount = 0 53 | } 54 | , Tweet 55 | { tweetId = "1565246658128056320" 56 | , tweetText = "If you haven't finished your tea, I invite you to read one of my previous journeys into the wonders of the SQLite type system 🗺️🫖🚂\n\nhttps://t.co/4RElElHu0Z" 57 | , tweetCreatedAt = UTCTime (fromGregorian 2022 9 1) (parseTod "07:54:18") 58 | , tweetLikeCount = 1 59 | , tweetRetweetCount = 0 60 | , tweetReplyCount = 2 61 | , tweetQuoteCount = 0 62 | } 63 | , Tweet 64 | { tweetId = "1565246655103995905" 65 | , tweetText = "🔮 I still had some theories about the space leak. All the metrics show that it's not the Haskell process that leaks memory.\n\nI was even suspecting space leaks in SQLite itself as my setup was unusual. But I didn't have the time to verify my hypothesis so we'll never know..." 66 | , tweetCreatedAt = UTCTime (fromGregorian 2022 9 1) (parseTod "07:54:18") 67 | , tweetLikeCount = 1 68 | , tweetRetweetCount = 0 69 | , tweetReplyCount = 1 70 | , tweetQuoteCount = 0 71 | } 72 | , Tweet 73 | { tweetId = "1565246652121767936" 74 | , tweetText = "Are you still here? Do you still want to know how I solved this space leak? Very simple:\n\n🏝️ I left my job\n\nNow, it's no longer my problem and somebody else will continue what I've started 👶" 75 | , tweetCreatedAt = UTCTime (fromGregorian 2022 9 1) (parseTod "07:54:17") 76 | , tweetLikeCount = 6 77 | , tweetRetweetCount = 0 78 | , tweetReplyCount = 1 79 | , tweetQuoteCount = 0 80 | } 81 | , Tweet 82 | { tweetId = "1565246649185849344" 83 | , tweetText = "🗺️ Now, real digging started. I had to use old Unix tools as my grandpa did in the good old times.\n\nI sampled the output of the /proc/rss values to check the real memory usage of my process. I've even looked at the diff between mmaped memory regions.\n\nStill, no results 🙅" 84 | , tweetCreatedAt = UTCTime (fromGregorian 2022 9 1) (parseTod "07:54:16") 85 | , tweetLikeCount = 1 86 | , tweetRetweetCount = 0 87 | , tweetReplyCount = 1 88 | , tweetQuoteCount = 0 89 | } 90 | ] 91 | 92 | genTweet :: Gen Tweet 93 | genTweet = Tweet 94 | <$> Gen.text (Range.linear 1 20) Gen.digit 95 | <*> Gen.text (Range.linear 1 280) Gen.unicode 96 | <*> genUTCTime 97 | <*> Gen.int (Range.linear 0 100_000) 98 | <*> Gen.int (Range.linear 0 100_000) 99 | <*> Gen.int (Range.linear 0 100_000) 100 | <*> Gen.int (Range.linear 0 100_000) 101 | 102 | genUTCTime :: Gen UTCTime 103 | genUTCTime = do 104 | y <- toInteger <$> Gen.int (Range.constant 2000 2022) 105 | m <- Gen.int (Range.constant 1 12) 106 | d <- Gen.int (Range.constant 1 28) 107 | let day = fromGregorian y m d 108 | secs <- toInteger <$> Gen.int (Range.constant 0 86401) 109 | let diff = secondsToDiffTime secs 110 | pure $ UTCTime day diff 111 | -------------------------------------------------------------------------------- /src/Sauron/Top.hs: -------------------------------------------------------------------------------- 1 | {- | 2 | Copyright: (c) 2022 Dmitrii Kovanikov 3 | SPDX-License-Identifier: MPL-2.0 4 | Maintainer: Dmitrii Kovanikov 5 | 6 | The 'sauron top' command. 7 | 8 | -} 9 | 10 | module Sauron.Top 11 | ( runTop 12 | ) where 13 | 14 | import Data.Aeson.Encode.Pretty (encodePretty) 15 | import Data.List (minimum) 16 | import Data.Time.Clock (UTCTime, getCurrentTime) 17 | import Servant.Client.Core (ClientError) 18 | 19 | import Sauron.App (App) 20 | import Sauron.Cli (CacheMode (..), TopArgs (..)) 21 | import Sauron.Top.Client (getTweets, getUserByUsername) 22 | import Sauron.Top.Json (Data (..), Meta (..), Page (..)) 23 | import Sauron.Top.Tweet (Tweet (..), showTime, subtractSecond, topTweets) 24 | import Sauron.Top.User (User (..), UserId (..), mkUsername) 25 | 26 | import qualified Data.Aeson as Aeson 27 | 28 | 29 | runTop :: TopArgs -> App () 30 | runTop TopArgs{..} = do 31 | let username = mkUsername topArgsUsername 32 | User{..} <- getUserByUsername username 33 | 34 | putTextLn $ "[info] User id of " <> topArgsUsername <> " is: " <> unUserId userId 35 | putTextLn $ "[info] Total number of tweets: " <> show userTweetCount 36 | 37 | tweets <- case topArgsCacheMode of 38 | ToFile toFile -> timelineLoop userId toFile 39 | FromFile fromFile -> parseCachedTweets fromFile 40 | 41 | putTextLn $ topTweets topArgsMax username tweets 42 | 43 | -- | Parse already saved tweets in the file 44 | parseCachedTweets :: FilePath -> App [Tweet] 45 | parseCachedTweets path = 46 | liftIO (Aeson.eitherDecodeFileStrict @[Tweet] path) >>= \case 47 | Right tweets -> pure tweets 48 | Left err -> do 49 | putStrLn $ "Error parsing " <> path <> ": " <> err 50 | exitFailure 51 | 52 | {- | A data type representing the current state of fetching the 53 | Twitter timeline 54 | -} 55 | data TimelineState 56 | -- | Fetching the timeline for the first time 57 | = Start 58 | 59 | -- | An error occurred; dump the current tweets to the file and exit with error 60 | | Error TimelineError [Tweet] 61 | 62 | -- | We haven't exhausted our queries 63 | | NextPage 64 | UTCTime -- ^ Timestamp used to fetch the previous page(s) 65 | Text -- ^ Pagination token 66 | [Tweet] 67 | 68 | -- | We've reached the 3200 limit. Now change the end date. 69 | | NextDate 70 | UTCTime -- ^ Time of the last tweet minus one second 71 | [Tweet] 72 | 73 | -- | Successfully fetched the entire timeline! 74 | | Finish [Tweet] 75 | 76 | data TimelineError 77 | = InvalidRequest ClientError 78 | 79 | displayTimelineError :: TimelineError -> Text 80 | displayTimelineError = \case 81 | InvalidRequest err -> "[InvalidRequest] " <> show err 82 | 83 | timelineLoop :: UserId -> FilePath -> App [Tweet] 84 | timelineLoop userId savePath = loop Start 85 | where 86 | loop :: TimelineState -> App [Tweet] 87 | loop Start = do 88 | now <- liftIO getCurrentTime 89 | 90 | let endTime = showTime now 91 | putStrLn $ "[debug] Current time: " <> endTime 92 | 93 | getTweets userId endTime Nothing >>= \case 94 | Left err -> loop $ Error (InvalidRequest err) [] 95 | Right Page{..} -> do 96 | let tweets = unData pageData 97 | case metaNextToken pageMeta of 98 | Nothing -> 99 | loop $ Finish $ unData pageData 100 | Just nextToken -> 101 | loop $ NextPage now nextToken tweets 102 | 103 | loop (Error err tweets) = do 104 | putTextLn $ "[error] " <> displayTimelineError err 105 | saveTweets savePath tweets 106 | exitFailure 107 | 108 | loop (Finish tweets) = do 109 | saveTweets savePath tweets 110 | pure tweets 111 | 112 | loop (NextPage time currentToken tweets) = do 113 | debugTweets tweets 114 | 115 | let endTime = showTime time 116 | getTweets userId endTime (Just currentToken) >>= \case 117 | Left err -> loop $ Error (InvalidRequest err) tweets 118 | Right Page{..} -> do 119 | let newTweets = unData pageData 120 | case metaNextToken pageMeta of 121 | -- We fetched all pages 122 | Nothing -> do 123 | let earliestTime = minimum $ map tweetCreatedAt tweets 124 | let newEndTime = subtractSecond earliestTime 125 | loop $ NextDate newEndTime (newTweets ++ tweets) 126 | 127 | -- More pages to fetch 128 | Just nextToken -> 129 | loop $ NextPage time nextToken (newTweets ++ tweets) 130 | 131 | loop (NextDate time tweets) = do 132 | putTextLn $ "[debug] Next date: " <> show time 133 | debugTweets tweets 134 | 135 | let endTime = showTime time 136 | getTweets userId endTime Nothing >>= \case 137 | Left err -> loop $ Error (InvalidRequest err) tweets 138 | Right Page{..} -> do 139 | let newTweets = unData pageData 140 | case metaNextToken pageMeta of 141 | -- We fetched all the tweets 142 | Nothing -> 143 | loop $ Finish (newTweets ++ tweets) 144 | 145 | -- More pages to fetch 146 | Just nextToken -> 147 | loop $ NextPage time nextToken (newTweets ++ tweets) 148 | 149 | 150 | debugTweets :: [Tweet] -> App () 151 | debugTweets tweets = do 152 | let len = length tweets 153 | putTextLn $ "[debug] Fetched total tweets: " <> show len 154 | 155 | saveTweets :: FilePath -> [Tweet] -> App () 156 | saveTweets savePath tweets = do 157 | let tweetsCount = length tweets 158 | putTextLn $ "[info] Saving " <> show tweetsCount <> " tweets to: " <> toText savePath 159 | 160 | let json = encodePretty tweets 161 | writeFileLBS savePath json 162 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | --------------------------------------------------------------------------------