├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .editorconfig ├── .github ├── FUNDING.yml ├── actions │ └── setup │ │ └── action.yml └── workflows │ ├── _build.yml │ ├── _publish.yml │ ├── check.yml │ ├── format.yml │ ├── publish.yml │ └── version.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── README.rdoc ├── Rakefile ├── bin └── journal ├── journal-cli.gemspec ├── lib ├── journal-cli.rb └── journal-cli │ ├── array.rb │ ├── checkin.rb │ ├── color.rb │ ├── data.rb │ ├── question.rb │ ├── section.rb │ ├── sections.rb │ ├── string.rb │ ├── version.rb │ └── weather.rb ├── spec ├── module_spec.rb └── spec_helper.rb └── src └── _README.md /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG VARIANT="3" 2 | 3 | FROM mcr.microsoft.com/vscode/devcontainers/ruby:${VARIANT} 4 | 5 | USER vscode 6 | WORKDIR /home/vscode 7 | 8 | RUN mkdir -p .config/git \ 9 | && echo ".vscode/*" >> .config/git/ignore \ 10 | && echo "*.code-workspace" >> .config/git/ignore \ 11 | && echo ".history/" >> .config/git/ignore 12 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Ruby", 3 | "build": { 4 | "dockerfile": "Dockerfile", 5 | "args": { 6 | "VARIANT": "3" 7 | } 8 | }, 9 | "extensions": [ 10 | "rebornix.Ruby", 11 | "ms-vsliveshare.vsliveshare", 12 | "EditorConfig.EditorConfig", 13 | "esbenp.prettier-vscode" 14 | ], 15 | "postCreateCommand": "bundle install", 16 | "remoteUser": "vscode" 17 | } 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [ttscoff] 4 | custom: ['https://brettterpstra.com/support'] 5 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Setup 3 | description: Setup Ruby and install dependencies. 4 | 5 | inputs: 6 | ruby_version: 7 | description: The Ruby version. 8 | required: false 9 | default: '3.2.0' 10 | install_dependencies: 11 | description: Install dependencies. 12 | required: false 13 | default: 'true' 14 | gem_credentials: 15 | description: Gem credentials. 16 | required: false 17 | 18 | runs: 19 | using: composite 20 | steps: 21 | - name: Setup credentials 22 | if: inputs.gem_credentials 23 | shell: bash 24 | run: | 25 | mkdir -p ~/.gem 26 | echo "$GEM_CREDENTIALS" > ~/.gem/credentials 27 | chmod 600 ~/.gem/credentials 28 | env: 29 | GEM_CREDENTIALS: ${{ inputs.gem_credentials }} 30 | - name: Setup Ruby 31 | uses: ruby/setup-ruby@v1 32 | with: 33 | bundler-cache: ${{ inputs.install_dependencies == 'true' }} 34 | ruby-version: ${{ inputs.ruby_version }} 35 | -------------------------------------------------------------------------------- /.github/workflows/_build.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: _build 3 | 4 | on: 5 | workflow_call: 6 | inputs: 7 | ruby_version: 8 | description: The Ruby version. 9 | type: string 10 | required: false 11 | default: '3.2.0' 12 | outputs: 13 | artifact_name: 14 | description: The artifact name. 15 | value: build-${{ github.sha }} 16 | 17 | jobs: 18 | build: 19 | name: Gem 20 | runs-on: ubuntu-latest 21 | timeout-minutes: 30 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v3 25 | - name: Setup 26 | uses: ./.github/actions/setup 27 | with: 28 | ruby_version: ${{ inputs.ruby_version }} 29 | - name: Build 30 | run: bundle exec rake build 31 | - name: Upload artifact 32 | uses: actions/upload-artifact@v3 33 | with: 34 | name: build-${{ github.sha }} 35 | if-no-files-found: error 36 | path: pkg/ 37 | -------------------------------------------------------------------------------- /.github/workflows/_publish.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: _publish 3 | 4 | on: 5 | workflow_call: 6 | inputs: 7 | artifact_name: 8 | description: The artifact name. 9 | type: string 10 | required: true 11 | registry_key: 12 | description: The gem registry credentials key. 13 | type: string 14 | required: true 15 | registry_host: 16 | description: The gem registry host. 17 | type: string 18 | required: true 19 | secrets: 20 | registry_credentials: 21 | description: The gem registry credentials. 22 | required: true 23 | 24 | jobs: 25 | publish: 26 | name: Publish gem 27 | runs-on: ubuntu-latest 28 | timeout-minutes: 30 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v3 32 | - name: Setup 33 | uses: ./.github/actions/setup 34 | with: 35 | install_dependencies: 'false' 36 | gem_credentials: ':${{ inputs.registry_key }}: ${{ secrets.registry_credentials }}' 37 | - name: Download artifact 38 | uses: actions/download-artifact@v3 39 | with: 40 | name: ${{ inputs.artifact_name }} 41 | path: pkg/ 42 | - name: Publish 43 | run: gem push --key $REGISTRY_KEY --host $REGISTRY_HOST $GEM_ARTIFACTS/* 44 | env: 45 | REGISTRY_KEY: ${{ inputs.registry_key }} 46 | REGISTRY_HOST: ${{ inputs.registry_host }} 47 | GEM_ARTIFACTS: pkg 48 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Check 3 | 4 | on: 5 | push: 6 | branches: 7 | - '**' 8 | 9 | jobs: 10 | test: 11 | name: Test (Ruby ${{ matrix.ruby }} on ${{ matrix.os_name }}) 12 | runs-on: ${{ matrix.os }} 13 | timeout-minutes: 30 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: 18 | - ubuntu-latest 19 | - macos-latest 20 | - windows-latest 21 | ruby: 22 | - '3.0' 23 | - '3.1' 24 | - '3.2' 25 | include: 26 | - os: ubuntu-latest 27 | os_name: Linux 28 | - os: macos-latest 29 | os_name: macOS 30 | - os: windows-latest 31 | os_name: Windows 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v3 35 | - name: Setup 36 | uses: ./.github/actions/setup 37 | with: 38 | ruby_version: ${{ matrix.ruby }} 39 | - name: Test 40 | run: bundle exec rake test 41 | lint: 42 | name: Lint (Ruby ${{ matrix.ruby }}) 43 | runs-on: ubuntu-latest 44 | timeout-minutes: 30 45 | strategy: 46 | fail-fast: false 47 | matrix: 48 | ruby: 49 | - '3.0' 50 | - '3.1' 51 | - '3.2' 52 | steps: 53 | - name: Checkout 54 | uses: actions/checkout@v3 55 | - name: Setup 56 | uses: ./.github/actions/setup 57 | with: 58 | ruby_version: ${{ matrix.ruby }} 59 | - name: Lint 60 | run: bundle exec rake lint 61 | build: 62 | name: Build 63 | uses: ./.github/workflows/_build.yml 64 | install: 65 | name: Install (Ruby ${{ matrix.ruby }} on ${{ matrix.os_name }}) 66 | runs-on: ${{ matrix.os }} 67 | timeout-minutes: 30 68 | needs: build 69 | strategy: 70 | fail-fast: false 71 | matrix: 72 | os: 73 | - ubuntu-latest 74 | - macos-latest 75 | - windows-latest 76 | ruby: 77 | - '3.0' 78 | - '3.1' 79 | - '3.2' 80 | include: 81 | - os: ubuntu-latest 82 | os_name: Linux 83 | - os: macos-latest 84 | os_name: macOS 85 | - os: windows-latest 86 | os_name: Windows 87 | steps: 88 | - name: Setup Ruby 89 | uses: ruby/setup-ruby@v1 90 | with: 91 | ruby-version: ${{ matrix.ruby }} 92 | - name: Download artifact 93 | uses: actions/download-artifact@v3 94 | with: 95 | name: ${{ needs.build.outputs.artifact_name }} 96 | path: . 97 | - name: Find gems 98 | uses: tj-actions/glob@v16 99 | id: gems 100 | with: 101 | files: '*.gem' 102 | - name: Create main.rb 103 | uses: DamianReeves/write-file-action@v1.2 104 | with: 105 | write-mode: overwrite 106 | path: main.rb 107 | contents: | 108 | require 'journal-cli' 109 | - name: Install 110 | run: gem install ${{ steps.gems.outputs.paths }} 111 | - name: Run 112 | run: ruby main.rb 113 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Format 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - main 8 | workflow_dispatch: {} 9 | 10 | jobs: 11 | fix: 12 | name: Commit fixes 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 30 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | with: 19 | ref: ${{ github.head_ref }} 20 | token: ${{ secrets.GH_TOKEN }} 21 | - name: Import GPG key 22 | uses: crazy-max/ghaction-import-gpg@v5 23 | with: 24 | git_user_signingkey: true 25 | git_commit_gpgsign: true 26 | git_committer_name: ${{ secrets.GIT_USER_NAME }} 27 | git_committer_email: ${{ secrets.GIT_USER_EMAIL }} 28 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 29 | passphrase: ${{ secrets.GPG_PASSPHRASE }} 30 | - name: Setup 31 | uses: ./.github/actions/setup 32 | - name: Format 33 | run: bundle exec rake format 34 | - name: Commit 35 | uses: stefanzweifel/git-auto-commit-action@v4 36 | if: always() 37 | with: 38 | commit_message: Run format 39 | commit_user_name: ${{ secrets.GIT_USER_NAME }} 40 | commit_user_email: ${{ secrets.GIT_USER_EMAIL }} 41 | commit_author: ${{ secrets.GIT_USER_NAME }} <${{ secrets.GIT_USER_EMAIL }}> 42 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Publish 3 | 4 | run-name: Publish ${{ github.ref_name }} 5 | 6 | on: 7 | push: 8 | tags: 9 | - v* 10 | 11 | jobs: 12 | build: 13 | name: Build 14 | uses: ./.github/workflows/_build.yml 15 | release: 16 | name: GitHub Releases 17 | runs-on: ubuntu-latest 18 | timeout-minutes: 30 19 | needs: build 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v3 23 | - name: Download artifact 24 | uses: actions/download-artifact@v3 25 | with: 26 | name: ${{ needs.build.outputs.artifact_name }} 27 | path: pkg/ 28 | - name: Create GitHub release 29 | uses: softprops/action-gh-release@v1 30 | with: 31 | token: ${{ secrets.GH_TOKEN }} 32 | fail_on_unmatched_files: true 33 | prerelease: ${{ contains(github.ref_name, 'pre') }} 34 | files: pkg/* 35 | rubygems: 36 | name: RubyGems.org 37 | uses: ./.github/workflows/_publish.yml 38 | needs: build 39 | with: 40 | artifact_name: ${{ needs.build.outputs.artifact_name }} 41 | registry_key: rubygems 42 | registry_host: https://rubygems.org 43 | secrets: 44 | registry_credentials: ${{ secrets.RUBYGEMS_API_KEY }} 45 | github: 46 | name: GitHub Packages 47 | uses: ./.github/workflows/_publish.yml 48 | permissions: 49 | packages: write 50 | needs: build 51 | with: 52 | artifact_name: ${{ needs.build.outputs.artifact_name }} 53 | registry_key: github 54 | registry_host: https://rubygems.pkg.github.com/${{ github.repository_owner }} 55 | secrets: 56 | registry_credentials: Bearer ${{ secrets.GITHUB_TOKEN }} 57 | -------------------------------------------------------------------------------- /.github/workflows/version.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Version 3 | 4 | run-name: Cut ${{ github.event.inputs.version }} 5 | 6 | on: 7 | workflow_dispatch: 8 | inputs: 9 | version: 10 | description: Version to cut 11 | required: true 12 | 13 | jobs: 14 | tag: 15 | name: Tag 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 30 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v3 21 | with: 22 | token: ${{ secrets.GH_TOKEN }} 23 | - name: Import GPG key 24 | uses: crazy-max/ghaction-import-gpg@v5 25 | with: 26 | git_user_signingkey: true 27 | git_commit_gpgsign: true 28 | git_committer_name: ${{ secrets.GIT_USER_NAME }} 29 | git_committer_email: ${{ secrets.GIT_USER_EMAIL }} 30 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 31 | passphrase: ${{ secrets.GPG_PASSPHRASE }} 32 | - name: Setup 33 | uses: ./.github/actions/setup 34 | - name: Cut ${{ github.event.inputs.version }} version 35 | run: bundle exec gem bump --no-commit --version ${{ github.event.inputs.version }} 36 | - name: Update Gemfile.lock 37 | run: | 38 | bundle config set --local deployment 'false' 39 | bundle install 40 | - name: Get meta 41 | id: meta 42 | run: | 43 | version=$(bundle exec parse-gemspec-cli parse *.gemspec | jq -r .version) 44 | echo "version=${version}" >> $GITHUB_OUTPUT 45 | - name: Tag ${{ github.event.inputs.version }} version 46 | run: | 47 | git add . 48 | git commit --sign -m "${VERSION}" 49 | git tag --sign "v${VERSION}" -m "${VERSION}" 50 | git push --follow-tags 51 | env: 52 | VERSION: ${{ steps.meta.outputs.version }} 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Parts of this file were adapted from 2 | # GitHub’s collection of .gitignore file templates 3 | # which are Copyright (c) 2023 GitHub, Inc. 4 | # and released under the MIT License. 5 | # For more details, visit the project page: 6 | # https://github.com/github/gitignore 7 | 8 | # rspec failure tracking 9 | .rspec_status 10 | 11 | *.gem 12 | *.rbc 13 | /.config 14 | /coverage/ 15 | /InstalledFiles 16 | /pkg/ 17 | /spec/reports/ 18 | /spec/examples.txt 19 | /test/tmp/ 20 | /test/version_tmp/ 21 | /tmp/ 22 | 23 | # Used by dotenv library to load environment variables. 24 | # .env 25 | 26 | # Ignore Byebug command history file. 27 | .byebug_history 28 | 29 | ## Specific to RubyMotion: 30 | .dat* 31 | .repl_history 32 | build/ 33 | *.bridgesupport 34 | build-iPhoneOS/ 35 | build-iPhoneSimulator/ 36 | 37 | ## Specific to RubyMotion (use of CocoaPods): 38 | # 39 | # We recommend against adding the Pods directory to your .gitignore. However 40 | # you should judge for yourself, the pros and cons are mentioned at: 41 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 42 | # 43 | # vendor/Pods/ 44 | 45 | ## Documentation cache and generated files: 46 | /.yardoc/ 47 | /_yardoc/ 48 | /doc/ 49 | /rdoc/ 50 | 51 | ## Environment normalization: 52 | /.bundle/ 53 | /vendor/bundle 54 | /lib/bundler/man/ 55 | 56 | # for a library or gem, you might want to ignore these files since the code is 57 | # intended to run in multiple environments; otherwise, check them in: 58 | # Gemfile.lock 59 | # .ruby-version 60 | # .ruby-gemset 61 | 62 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 63 | .rvmrc 64 | 65 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 66 | # .rubocop-https?--* 67 | html 68 | *.bak 69 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | --pattern "{spec,lib}/**/*_spec.rb" 5 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Style/StringLiterals: 2 | EnforcedStyle: double_quotes 3 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.0 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 1.0.36 2 | 3 | 2024-07-03 11:31 4 | 5 | #### FIXED 6 | 7 | - Give Day One time to launch and update db when adding entry 8 | 9 | ### 1.0.35 10 | 11 | 2024-06-22 11:45 12 | 13 | #### IMPROVED 14 | 15 | - Display answers to prompt when filling out entry 16 | - Launch Day One before trying to add entry. Day One's database frequently gets updated and the command line tool will throw an error if Day One hasn't been launched. So launch it hidden in the background, and then quit if it wasn't running to begin with. This will cause the database to update. 17 | - Method documentation 18 | 19 | ### 1.0.34 20 | 21 | 2024-06-22 11:01 22 | 23 | #### FIXED 24 | 25 | - Force -W1 so prompts always show up 26 | 27 | ### 1.0.33 28 | 29 | 2024-04-20 13:47 30 | 31 | ### 1.0.32 32 | 33 | 2024-04-20 13:43 34 | 35 | #### IMPROVED 36 | 37 | - Added note to README that zip codes with leading zero must be quoted 38 | 39 | #### FIXED 40 | 41 | - Spelling error in weather exception 42 | 43 | ### 1.0.31 44 | 45 | 2023-12-14 09:12 46 | 47 | #### FIXED 48 | 49 | - Remove debug line 50 | 51 | ### 1.0.30 52 | 53 | 2023-12-13 17:22 54 | 55 | #### FIXED 56 | 57 | - Weather.moon detection 58 | 59 | ### 1.0.29 60 | 61 | 2023-10-01 15:24 62 | 63 | #### IMPROVED 64 | 65 | - Config option `temp_in` can be set to `c` or `f` to control what scale temps are recorded in 66 | 67 | ### 1.0.28 68 | 69 | 2023-09-30 11:01 70 | 71 | #### IMPROVED 72 | 73 | - If creating an entry for a past date, get the historical weather for that date 74 | - Add weather.moon type for getting moon phase, and include moon_phase in general weather data 75 | 76 | ### 1.0.27 77 | 78 | 2023-09-23 08:03 79 | 80 | #### FIXED 81 | 82 | - Time comparisons for past entries failing when using a past date for entry 83 | 84 | ### 1.0.26 85 | 86 | 2023-09-20 19:18 87 | 88 | #### FIXED 89 | 90 | - Nil entry error on conditional questions 91 | 92 | ### 1.0.25 93 | 94 | 2023-09-20 09:40 95 | 96 | #### FIXED 97 | 98 | - Coloring of min/max on numeric questions 99 | 100 | ### 1.0.24 101 | 102 | 2023-09-20 09:12 103 | 104 | ### 1.0.23 105 | 106 | 2023-09-20 08:30 107 | 108 | #### NEW 109 | 110 | - Question types weather.forecast and weather.current allow more specific weather entry types 111 | - Time-based conditions for questions and sections (`condition: before noon` or `condition: < 12pm`) 112 | 113 | #### FIXED 114 | 115 | - Test for existence of dayone2 binary before attempting to write a Day One entry, provide error message 116 | 117 | ### 1.0.22 118 | 119 | 2023-09-18 10:23 120 | 121 | #### IMPROVED 122 | 123 | - Removed colon from timestamp of individual entries to better work with Obsidian Sync 124 | 125 | ### 1.0.21 126 | 127 | 2023-09-13 11:00 128 | 129 | #### IMPROVED 130 | 131 | - If gum is installed, continue using it, but no longer require gum to function 132 | 133 | ### 1.0.20 134 | 135 | 2023-09-12 19:10 136 | 137 | #### IMPROVED 138 | 139 | - Store all numeric entries as floats, allowing decimal entries on the command line 140 | 141 | #### FIXED 142 | 143 | - Ensure local time for date objects 144 | 145 | ### 1.0.19 146 | 147 | 2023-09-11 10:52 148 | 149 | #### IMPROVED 150 | 151 | - Better output of date types in Markdown formats 152 | 153 | ### 1.0.18 154 | 155 | 2023-09-09 12:29 156 | 157 | #### IMPROVED 158 | 159 | - Include the answers to all questions as YAML front matter when writing individual Markdown files. This allows for tools like [obsidian-dataview](https://github.com/blacksmithgu/obsidian-dataview) to be used as parsers 160 | 161 | #### FIXED 162 | 163 | - Daily markdown was being saved to /journal/entries/KEY/entries 164 | - Missing color library 165 | 166 | ### 1.0.17 167 | 168 | 2023-09-08 07:21 169 | 170 | #### IMPROVED 171 | 172 | - More confirmation messages when saving 173 | 174 | ### 1.0.16 175 | 176 | 2023-09-07 11:12 177 | 178 | #### IMPROVED 179 | 180 | - Use optparse for command line options 181 | - Completion-friendly list of journals with `journal -l` 182 | 183 | ### 1.0.15 184 | 185 | 2023-09-07 07:57 186 | 187 | #### FIXED 188 | 189 | - Messed up the fix for nested keys 190 | 191 | ### 1.0.14 192 | 193 | 2023-09-07 07:35 194 | 195 | #### FIXED 196 | 197 | - Keys nested with dot syntax were doubling 198 | 199 | ### 1.0.13 200 | 201 | 2023-09-07 06:57 202 | 203 | #### NEW 204 | 205 | - Allow entries_folder setting for top level and individual journals to put JSON and Markdown files anywhere the user wants 206 | 207 | #### IMPROVED 208 | 209 | - If a custom folder doesn't exist, create it automatically 210 | - Updated documentatioh 211 | 212 | ### 1.0.12 213 | 214 | 2023-09-06 16:43 215 | 216 | #### IMPROVED 217 | 218 | - Add note about ctrl-d to save multiline input 219 | 220 | ### 1.0.11 221 | 222 | 2023-09-06 16:37 223 | 224 | #### IMPROVED 225 | 226 | - Write a demo config file for editing 227 | 228 | ### 1.0.10 229 | 230 | 2023-09-06 16:03 231 | 232 | #### IMPROVED 233 | 234 | - Refactoring code 235 | 236 | ### 1.0.9 237 | 238 | 2023-09-06 11:58 239 | 240 | #### NEW 241 | 242 | - If the second argument is a natural language date, use the parsed result instead of the current time for the entry 243 | 244 | ### 1.0.5 245 | 246 | 2023-09-06 09:24 247 | 248 | ### 1.0.0 249 | 250 | 2023-09-06 09:23 251 | 252 | #### NEW 253 | 254 | - Initial journal command 255 | - Multiple journals, multiple sections 256 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in journal-cli.gemspec. 6 | gemspec 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | journal-cli (1.0.36) 5 | chronic (~> 0.10, >= 0.10.2) 6 | tty-reader (~> 0.9, >= 0.9.0) 7 | tty-which (~> 0.5, >= 0.5.0) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | ansi (1.5.0) 13 | ast (2.4.2) 14 | chronic (0.10.2) 15 | diff-lcs (1.5.0) 16 | docile (1.4.0) 17 | gem-release (2.2.2) 18 | json (2.6.3) 19 | language_server-protocol (3.17.0.2) 20 | multi_json (1.15.0) 21 | parallel (1.22.1) 22 | parse_gemspec (1.0.0) 23 | parse_gemspec-cli (1.0.0) 24 | multi_json 25 | parse_gemspec 26 | thor 27 | parser (3.1.3.0) 28 | ast (~> 2.4.1) 29 | rainbow (3.1.1) 30 | rake (13.0.6) 31 | regexp_parser (2.6.1) 32 | rexml (3.2.5) 33 | rspec (3.12.0) 34 | rspec-core (~> 3.12.0) 35 | rspec-expectations (~> 3.12.0) 36 | rspec-mocks (~> 3.12.0) 37 | rspec-core (3.12.0) 38 | rspec-support (~> 3.12.0) 39 | rspec-expectations (3.12.1) 40 | diff-lcs (>= 1.2.0, < 2.0) 41 | rspec-support (~> 3.12.0) 42 | rspec-mocks (3.12.1) 43 | diff-lcs (>= 1.2.0, < 2.0) 44 | rspec-support (~> 3.12.0) 45 | rspec-support (3.12.0) 46 | rubocop (1.40.0) 47 | json (~> 2.3) 48 | parallel (~> 1.10) 49 | parser (>= 3.1.2.1) 50 | rainbow (>= 2.2.2, < 4.0) 51 | regexp_parser (>= 1.8, < 3.0) 52 | rexml (>= 3.2.5, < 4.0) 53 | rubocop-ast (>= 1.23.0, < 2.0) 54 | ruby-progressbar (~> 1.7) 55 | unicode-display_width (>= 1.4.0, < 3.0) 56 | rubocop-ast (1.24.1) 57 | parser (>= 3.1.1.0) 58 | rubocop-performance (1.15.1) 59 | rubocop (>= 1.7.0, < 2.0) 60 | rubocop-ast (>= 0.4.0) 61 | ruby-progressbar (1.11.0) 62 | simplecov (0.22.0) 63 | docile (~> 1.1) 64 | simplecov-html (~> 0.11) 65 | simplecov_json_formatter (~> 0.1) 66 | simplecov-console (0.9.1) 67 | ansi 68 | simplecov 69 | terminal-table 70 | simplecov-html (0.12.3) 71 | simplecov_json_formatter (0.1.4) 72 | standard (1.20.0) 73 | language_server-protocol (~> 3.17.0.2) 74 | rubocop (= 1.40.0) 75 | rubocop-performance (= 1.15.1) 76 | terminal-table (3.0.2) 77 | unicode-display_width (>= 1.1.1, < 3) 78 | thor (1.2.1) 79 | tty-cursor (0.7.1) 80 | tty-reader (0.9.0) 81 | tty-cursor (~> 0.7) 82 | tty-screen (~> 0.8) 83 | wisper (~> 2.0) 84 | tty-screen (0.8.1) 85 | tty-which (0.5.0) 86 | unicode-display_width (2.3.0) 87 | wisper (2.0.1) 88 | yard (0.9.34) 89 | 90 | PLATFORMS 91 | arm64-darwin-21 92 | x64-mingw-ucr 93 | x64-mingw-ucrt 94 | x64-mingw32 95 | x86_64-darwin 96 | x86_64-linux 97 | 98 | DEPENDENCIES 99 | bundler (~> 2.0) 100 | gem-release (~> 2.2) 101 | journal-cli! 102 | parse_gemspec-cli (~> 1.0) 103 | rake (~> 13.0) 104 | rspec (~> 3.0) 105 | simplecov (~> 0.21) 106 | simplecov-console (~> 0.9) 107 | standard (~> 1.3) 108 | yard (~> 0.9, >= 0.9.26) 109 | 110 | BUNDLED WITH 111 | 2.4.1 112 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Brett Terpstra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![RubyGems.org](https://img.shields.io/gem/v/journal-cli)](https://rubygems.org/gems/journal-cli) 3 | 4 | A CLI for journaling to structured data, Markdown, and Day One 5 | 6 | ## Description 7 | 8 | The `journal` command reads a journal definition and provides command line prompts to fill it out. The results are stored in a JSON database for each journal, and can optionally output to Markdown (individual files per entry, daily digest, or one large file for the journal). 9 | 10 | ## Installation 11 | 12 | Use RubyGems to install journal: 13 | 14 | ``` 15 | $ gem install journal-cli 16 | ``` 17 | 18 | If you run into errors, try running with the `--user-install` flag: 19 | 20 | ``` 21 | $ gem install --user-install journal-cli 22 | ``` 23 | 24 | > I've noticed lately with `asdf` that I have to run `asdf reshim` after installing gems containing binaries. 25 | 26 | If [Gum](https://github.com/charmbracelet/gum) is installed, it will be used for prettier input prompts and editing. The easiest way is with [Homebrew](https://brew.sh/): 27 | 28 | ``` 29 | $ brew install gum 30 | ``` 31 | 32 | If you want to use Day One with Journal, you'll need to [install the Day One CLI](https://dayoneapp.com/guides/tips-and-tutorials/command-line-interface-cli/). It's just one command: 33 | 34 | ``` 35 | $ sudo bash /Applications/Day\ One.app/Contents/Resources/install_cli.sh 36 | ``` 37 | 38 | ## Configuration 39 | 40 | A config must be created at `~/.config/journal/journals.yaml`: 41 | 42 | ``` 43 | $ mkdir -p ~/.config/journal 44 | $ touch ~/.config/journal/journals.yaml 45 | ``` 46 | 47 | A skeleton file will be written the first time Journal is run if the config file doesn't exist. 48 | 49 | This file contains a YAML definition of your journal. Each journal gets a top-level key, which is what you'll specify it with on the command line. It gets a few settings, and then you define sections containing questions. 50 | 51 | ### Weather 52 | 53 | You can include weather data automatically by setting a question type to 'weather'. In order for this to work, you'll need to define `zip` and `weather_api` keys. `zip` is just your zip code, and `weather_api` is a key from WeatherAPI.com. Sign up [here](https://www.weatherapi.com/) for a free plan, and then visit the [profile page](https://www.weatherapi.com/my/) to see your API key at the top. 54 | 55 | > Zip codes beginning with zero (0) must be quoted. Use: 56 | > 57 | > zip: '01001' 58 | 59 | You can optionally set the key `temp_in:` to `f` or `c` to control what scale is used for temperatures. 60 | 61 | If a question type is set to `weather.forecast`, the moon phase and predicted condition, high, and low will be included in the JSON data for the question. A full printout of hourly temps will be included in the Markdown/Day One output. 62 | 63 | If the question type is `weather.current`, only the current condition and temperature will be recorded to the JSON, and a string containing "[TEMP] and [CONDITION]" (e.g. "64 and Sunny") will be recorded to Markdown/Day One for the question. 64 | 65 | If the question type is `weather.moon`, only the moon phase will be output. Moon phase is also included in `weather.forecast` JSON and Markdown output. 66 | 67 | ### Journal Configuration 68 | 69 | Edit the file at `~/.config/journal/journals.yaml` following this structure: 70 | 71 | ```yaml 72 | # Where to save all journal entries (unless this key is defined inside the journal). 73 | # The journal key will be appended to this to keep each journal separate 74 | entries_folder: ~/.local/share/journal/ 75 | journals: 76 | daily: # journal key, will be used on the command line as `journal daily` 77 | dayone: true # Enable or disable Day One integration 78 | journal: Journal # Day One journal to add to (if using Day One integration) 79 | markdown: daily # Type of Markdown file to create, false to skip (can be daily, individual, or digest) 80 | title: Daily Journal # Title for every entry, date will be appended where needed 81 | sections: # Required key 82 | - title: null # The title for the section. If null, no section header will be created 83 | key: journal # The key for the data collected, must be one word, alphanumeric characters and _ only 84 | questions: # Required key 85 | - prompt: How are you feeling? # The question to ask 86 | key: journal # alphanumeric characters and _ only, will be nested in section key 87 | type: multiline # The type of entry expected (numeric, string, or multiline) 88 | ``` 89 | 90 | Keys must be alphanumeric characters and `_` (underscore) only. Titles and questions can be anything, but if they contain a colon (:), you'll need to quote the string. 91 | 92 | The `entries_folder` key can be set to save JSON and Markdown files to a custom, non-default location. The default is `~/.local/share/journal`. This key can also be used within a journal definition to offer custom save locations on a per-journal basis. 93 | 94 | A more complex configuration file can contain multiple journals with multiple questions defined: 95 | 96 | ```yaml 97 | zip: 55987 # Your zip code for weather integration 98 | weather_api: XXXXXXXXXXXX # Your weatherapi.com API key 99 | journals: # required key 100 | mood: # name of the journal 101 | entries_folder: ~/Desktop/Journal/mood # Where to save this specific journal's entries 102 | journal: Mood Journal # Optional, Day One journal to add to 103 | tags: [checkin] # Optional, array of tags to add to Day One entries 104 | markdown: individual # Can be daily or individual, any other value will create a single file 105 | dayone: true # true to log entries to Day One, false to skip 106 | title: "Mood checkin %M" # The title of the entry. Use %M to insert AM or PM 107 | sections: # required key 108 | - title: Weather # Title of the section (will create template sections in Day One) 109 | key: weather # the key to use in the structured data, will contain all of the answers 110 | questions: # required key 111 | - prompt: Current Weather 112 | key: weather.current 113 | type: weather.current 114 | - prompt: Weather Forecast # The prompt shown on the command line, will also become a header in the journal entries (Markdown, Day One) 115 | key: weather.forecast # if a key contains a dot, it will create nested data, e.g. `{ 'weather': { 'forecast': data } }` 116 | type: weather.forecast # Set this to weather for weather data 117 | - title: Health # New section 118 | key: health 119 | questions: 120 | - prompt: Health rating 121 | key: health.rating 122 | type: numeric # type can be numeric, string, or multiline 123 | min: 1 # Only need min/max definitions on numeric types (defaults 1-5) 124 | max: 5 125 | - prompt: Health notes 126 | key: health.notes 127 | type: multiline 128 | - title: Journal # New section 129 | key: journal 130 | questions: 131 | - prompt: Daily notes 132 | key: notes 133 | type: multiline 134 | daily: # New journal 135 | journal: Journal 136 | markdown: daily 137 | dayone: true 138 | title: Daily Journal 139 | sections: 140 | - title: null 141 | key: journal 142 | questions: 143 | - prompt: How are you feeling? 144 | key: journal 145 | type: multiline 146 | ``` 147 | 148 | A journal must contain a `sections` key, and each section must contain a `questions` key with an array of questions. Each question must (at minimum) have a `prompt`, `key`, and `type`. 149 | 150 | If a question has a key `secondary_question`, the prompt will be repeated with the secondary question until it's returned empty, answers will be joined together. 151 | 152 | ### Question Types 153 | 154 | A question `type` can be one of: 155 | 156 | - `text` or `string` will request a single-line string, submitted on return 157 | - `multiline` for multiline strings (opens a readline editor, use ctrl-d to save) 158 | - `weather` will just insert current weather data with no prompt 159 | * `weather.forecast` will insert just the forecast (using weather history for backdated entries) 160 | * `weather.current` will insert just the current temperature and condition (using weather history for backdated entries) 161 | * `weather.moon` will insert the current moon phase for the entry date 162 | - `number` or `float` will request numeric input, stored as a float (decimal) 163 | - `integer` will convert numeric input to the nearest integer 164 | - `date` will request a natural language date which will be parsed into a date object 165 | 166 | ### Conditional Questions 167 | 168 | You can have a question only show up based on conditions. Currently the only condition is time based. Just add a key called `condition` to the question definition, then include a natural language string like `before noon` or `after 3pm`. If the condition is matched, then the question will be displayed, otherwise it will be skipped and its data entry in the JSON will be null. 169 | 170 | Conditions can be applied to individual questions, or to entire sections, depending on where the `condition` key is placed. 171 | 172 | ### Naming Keys 173 | 174 | If you want data stored in a nested object, you can set a question type to `dictionary` and set the prompt to `null` (or just leave the key out), but give it a key that will serve as the parent in the object. Then in the nested questions, give them a key in the dot format `[PARENT_KEY].[CHILD_KEY]`. Section keys automatically nest their questions, but if you want to go deeper, you could have a question with the key `health` and type `dictionary`, then have questions with keys like `health.rating` and `health.notes`. If the section key was `status`, the resulting dictionary would look like this in the JSON: 175 | 176 | ```json 177 | { 178 | "date": "2023-09-08 12:19:40 UTC", 179 | "data": { 180 | "status": { 181 | "health": { 182 | "rating": 4, 183 | "notes": "Feeling much better today. Still a bit groggy." 184 | } 185 | } 186 | } 187 | } 188 | ``` 189 | 190 | If a question has the same key as its parent section, it will be moved up the chain so that you don't get `{ 'journal': { 'journal': 'Journal notes' } }`. You'll just get `{ 'journal': 'Journal notes' }`. This offers a way to organize data with fewer levels of nesting in the output. 191 | 192 | ## Usage 193 | 194 | Once your configuration file is set up, you can just run `journal JOURNAL_KEY` to begin prompting for the answers to the configured questions. 195 | 196 | If a second argument contains a natural language date, the journal entry will be set to that date instead of the current time. For example, `journal mood "yesterday 5pm"` will create a new entry (in the journal configured for `mood`) for yesterday at 5pm. 197 | 198 | Answers will always be written to `~/.local/share/journal/[KEY].json` (where [KEY] is the journal key, one data file for each journal). If you've specified a top-level custom path with `entries_folder` in the config, entries will be written to `[top level folder]/[KEY].json`. If you've specified a custom path using `entries_folder` within the journal, entries will be written to `[custom folder]/[KEY].json`. 199 | 200 | If you've specified `daily` or `individual` Markdown formats, entries will be written to Markdown files in `~/.local/share/journal/[KEY]/entries`, either in a `[KEY]-%Y-%m-%d.md` file (daily), or in timestamped individual files. If `digest` is specified for the `markdown` key, a single file will be created at `~/.local/share/journal/[KEY]/entries/[KEY].md` (or a folder defined by `entries_folder`). 201 | 202 | At present there's no tool for querying the dataset created. You just need to parse the JSON and use your language of choice to extract the data. Numeric entries are stored as numbers, and every entry is timestamped, so you should be able to do some advanced analysis once you have enough data. 203 | 204 | ### Answering prompts 205 | 206 | Questions with numeric answers will have a valid range assigned. Enter just a number within the range and hit return. 207 | 208 | Questions with type 'string' or 'text' will save when you hit return. Pressing return without typing anything will leave that answer blank, and it will be ignored when exporting to Markdown or Day One (an empty value will exist in the JSON database). 209 | 210 | When using the mutiline type, you'll get an edit field that responds to most control-key navigation and allows insertion and movement. To save a multiline field, type CTRL-d. 211 | 212 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = Journal 2 | 3 | A command line tool for journaling to JSON, Markdown, and Day One. 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | require "rdoc/task" 4 | require "standard/rake" 5 | require "yard" 6 | 7 | Rake::RDocTask.new do |rd| 8 | rd.main = "README.rdoc" 9 | rd.rdoc_files.include("README.rdoc", "lib/**/*.rb", "bin/**/*") 10 | rd.title = "Journal" 11 | end 12 | 13 | YARD::Rake::YardocTask.new do |t| 14 | t.files = ["lib/journal-cli/*.rb"] 15 | t.options = ["--markup-provider=redcarpet", "--markup=markdown", "--no-private", "-p", "yard_templates"] 16 | # t.stats_options = ['--list-undoc'] 17 | end 18 | 19 | RSpec::Core::RakeTask.new(:spec) do |t| 20 | t.rspec_opts = "--pattern spec/*_spec.rb" 21 | end 22 | 23 | task default: %i[test] 24 | 25 | desc "Alias for build" 26 | task package: :build 27 | 28 | task test: "spec" 29 | task lint: "standard" 30 | task format: "standard:fix" 31 | 32 | desc "Open an interactive ruby console" 33 | task :console do 34 | require "irb" 35 | require "bundler/setup" 36 | require "journal-cli" 37 | ARGV.clear 38 | IRB.start 39 | end 40 | 41 | desc "Development version check" 42 | task :ver do 43 | gver = `git ver` 44 | cver = IO.read(File.join(File.dirname(__FILE__), "CHANGELOG.md")).match(/^#+ (\d+\.\d+\.\d+(\w+)?)/)[1] 45 | res = `grep VERSION lib/journal-cli/version.rb` 46 | version = res.match(/VERSION *= *['"](\d+\.\d+\.\d+(\w+)?)/)[1] 47 | puts "git tag: #{gver}" 48 | puts "version.rb: #{version}" 49 | puts "changelog: #{cver}" 50 | end 51 | 52 | desc "Changelog version check" 53 | task :cver do 54 | puts IO.read(File.join(File.dirname(__FILE__), "CHANGELOG.md")).match(/^#+ (\d+\.\d+\.\d+(\w+)?)/)[1] 55 | end 56 | 57 | desc "Bump incremental version number" 58 | task :bump, :type do |_, args| 59 | args.with_defaults(type: "inc") 60 | version_file = "lib/journal-cli/version.rb" 61 | content = IO.read(version_file) 62 | content.sub!(/VERSION = ["'](?\d+)\.(?\d+)\.(?\d+)(?
\S+)?["']/) do
63 |     m = Regexp.last_match
64 |     major = m["major"].to_i
65 |     minor = m["minor"].to_i
66 |     inc = m["inc"].to_i
67 |     pre = m["pre"]
68 | 
69 |     case args[:type]
70 |     when /^maj/
71 |       major += 1
72 |       minor = 0
73 |       inc = 0
74 |     when /^min/
75 |       minor += 1
76 |       inc = 0
77 |     else
78 |       inc += 1
79 |     end
80 | 
81 |     $stdout.puts "At version #{major}.#{minor}.#{inc}#{pre}"
82 |     "VERSION = '#{major}.#{minor}.#{inc}#{pre}'"
83 |   end
84 |   File.open(version_file, "w+") { |f| f.puts content }
85 | end
86 | 


--------------------------------------------------------------------------------
/bin/journal:
--------------------------------------------------------------------------------
 1 | #!/usr/bin/env ruby -W1
 2 | 
 3 | $LOAD_PATH.unshift File.join(__dir__, '..', 'lib')
 4 | require 'journal-cli'
 5 | require 'optparse'
 6 | 
 7 | trap('SIGINT') { exit! }
 8 | 
 9 | module Journal
10 |   class << self
11 |     def usage
12 |       puts 'Usage: journal [type] [date]'
13 |       puts
14 |       puts 'Available journal types:'
15 |       list_journals
16 |     end
17 | 
18 |     def list_journals
19 |       config = Journal.config
20 |       puts config['journals'].keys
21 |     end
22 | 
23 |     def run(args)
24 |       if args.count.zero?
25 |         puts "No journal specified"
26 |         usage
27 |         Process.exit 1
28 |       end
29 | 
30 |       journal = args.shift
31 | 
32 |       date = if args.length.positive?
33 |                Chronic.parse(args.join(' '), future: false)
34 |              else
35 |                Time.now
36 |              end
37 | 
38 |       Journal.date = date
39 | 
40 |       if Journal.config['journals'].key?(journal)
41 |         checkin = Journal::Checkin.new(journal)
42 |         checkin.go
43 |       else
44 |         puts "Journal #{journal} not found"
45 |         usage
46 |         Process.exit 1
47 |       end
48 |     end
49 |   end
50 | end
51 | 
52 | optparse = OptionParser.new do |opts|
53 |   opts.banner = 'Usage: journal JOURNAL_KEY [NATURAL LANGUAGE DATE]'
54 | 
55 |   opts.on('-v', '--version', 'Display version') do
56 |     puts "journal v#{Journal::VERSION}"
57 |     Process.exit 0
58 |   end
59 | 
60 |   opts.on('-l', '--list', 'List available journals') do
61 |     Journal.list_journals
62 |     Process.exit 0
63 |   end
64 | 
65 |   Color.coloring = $stdout.isatty
66 |   opts.on('--[no-]color', 'Colorize output') do |c|
67 |     Color.coloring = c
68 |   end
69 | 
70 |   opts.on('-h', '--help', 'Display help') do
71 |     puts opts
72 |     puts
73 |     puts 'Available journal types:'
74 |     config = Journal.config
75 |     puts(config['journals'].keys.map { |k| "- #{k}" })
76 |     Process.exit 0
77 |   end
78 | end
79 | 
80 | optparse.parse!
81 | 
82 | Journal.run(ARGV)
83 | 


--------------------------------------------------------------------------------
/journal-cli.gemspec:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require_relative "lib/journal-cli/version"
 4 | 
 5 | Gem::Specification.new do |spec|
 6 |   spec.name = "journal-cli"
 7 |   spec.version = Journal::VERSION
 8 |   spec.author = "Brett Terpstra"
 9 |   spec.email = "me@brettterpstra.com"
10 | 
11 |   spec.summary = "journal"
12 |   spec.description = "A CLI for journaling to structured data, Markdown, and Day One"
13 |   spec.homepage = "https://github.com/ttscoff/journal-cli"
14 |   spec.license = "MIT"
15 |   spec.required_ruby_version = ">= 3.0.0"
16 | 
17 |   spec.metadata["homepage_uri"] = spec.homepage
18 |   spec.metadata["source_code_uri"] = spec.homepage
19 |   spec.metadata["bug_tracker_uri"] = "#{spec.metadata["source_code_uri"]}/issues"
20 |   spec.metadata["changelog_uri"] = "#{spec.metadata["source_code_uri"]}/blob/main/CHANGELOG.md"
21 |   spec.metadata["github_repo"] = "git@github.com:ttscoff/journal-cli.git"
22 | 
23 |   spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
24 |     `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
25 |   end
26 |   spec.bindir = "bin"
27 |   spec.executables << "journal"
28 |   spec.require_paths << "lib"
29 | 
30 |   spec.add_runtime_dependency("tty-which", "~> 0.5", ">= 0.5.0")
31 |   spec.add_runtime_dependency("tty-reader", "~> 0.9", ">= 0.9.0")
32 |   spec.add_development_dependency "bundler", "~> 2.0"
33 |   spec.add_development_dependency "gem-release", "~> 2.2"
34 |   spec.add_development_dependency "parse_gemspec-cli", "~> 1.0"
35 |   spec.add_development_dependency "rake", "~> 13.0"
36 |   spec.add_development_dependency("yard", "~> 0.9", ">= 0.9.26")
37 |   spec.add_development_dependency "rspec", "~> 3.0"
38 |   spec.add_development_dependency "simplecov", "~> 0.21"
39 |   spec.add_development_dependency "simplecov-console", "~> 0.9"
40 |   spec.add_development_dependency "standard", "~> 1.3"
41 | 
42 |   spec.add_runtime_dependency("chronic", "~> 0.10", ">= 0.10.2")
43 | end
44 | 


--------------------------------------------------------------------------------
/lib/journal-cli.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require "time"
 4 | require "shellwords"
 5 | require "json"
 6 | require "yaml"
 7 | require "chronic"
 8 | require "fileutils"
 9 | 
10 | require "tty-which"
11 | require "tty-reader"
12 | require_relative "journal-cli/version"
13 | require_relative "journal-cli/color"
14 | require_relative "journal-cli/string"
15 | require_relative "journal-cli/data"
16 | require_relative "journal-cli/weather"
17 | require_relative "journal-cli/checkin"
18 | require_relative "journal-cli/sections"
19 | require_relative "journal-cli/section"
20 | require_relative "journal-cli/question"
21 | 
22 | # Main Journal module
23 | module Journal
24 |   class << self
25 |     attr_accessor :date
26 | 
27 |     def notify(string, debug: false, exit_code: nil)
28 |       if debug
29 |         warn "{dw}#{string}{x}".x
30 |       else
31 |         warn "#{string}{x}".x
32 |       end
33 | 
34 |       Process.exit exit_code unless exit_code.nil?
35 |     end
36 | 
37 |     def config
38 |       unless @config
39 |         config = File.expand_path("~/.config/journal/journals.yaml")
40 |         unless File.exist?(config)
41 |           default_config = {
42 |             "weather_api" => "XXXXXXXXXXXXXXXXXx",
43 |             "zip" => "XXXXX",
44 |             "entries_folder" => "~/.local/share/journal/",
45 |             "journals" => {
46 |               "demo" => {
47 |                 "dayone" => false,
48 |                 "markdown" => "single",
49 |                 "title" => "5-minute checkin",
50 |                 "entries_folder" => "~/.local/share/journal/",
51 |                 "sections" => [
52 |                   {"title" => "Quick checkin",
53 |                    "key" => "checkin",
54 |                    "questions" => [
55 |                      {"prompt" => "What's happening?", "key" => "journal", "type" => "multiline"}
56 |                    ]}
57 |                 ]
58 |               }
59 |             }
60 |           }
61 |           File.open(config, "w") { |f| f.puts(YAML.dump(default_config)) }
62 |           puts "New configuration written to #{config}, please edit."
63 |           Process.exit 0
64 |         end
65 |         @config = YAML.load(IO.read(config))
66 | 
67 |         if @config["journals"].key?("demo")
68 |           Journal.notify("{br}Demo journal detected, please edit the configuration file at {bw}#{config}", exit_code: 1)
69 |         end
70 |       end
71 | 
72 |       @config
73 |     end
74 |   end
75 | end
76 | 


--------------------------------------------------------------------------------
/lib/journal-cli/array.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | ##
 4 | ## Array helpers
 5 | ##
 6 | class ::Array
 7 |   ##
 8 |   ## Find the shortest element in an array of strings
 9 |   ##
10 |   ## @return     [String] shortest element
11 |   ##
12 |   def shortest
13 |     inject { |memo, word| (memo.length < word.length) ? memo : word }
14 |   end
15 | 
16 |   ##
17 |   ## Find the longest element in an array of strings
18 |   ##
19 |   ## @return     [String] longest element
20 |   ##
21 |   def longest
22 |     inject { |memo, word| (memo.length > word.length) ? memo : word }
23 |   end
24 | end
25 | 


--------------------------------------------------------------------------------
/lib/journal-cli/checkin.rb:
--------------------------------------------------------------------------------
  1 | module Journal
  2 |   # Main class
  3 |   class Checkin
  4 |     attr_reader :key, :date, :data, :config, :journal, :sections, :title, :output
  5 | 
  6 |     ##
  7 |     ## Initialize a new checkin using a configured journal
  8 |     ##
  9 |     ## @param      journal  [Journal] The journal
 10 |     ##
 11 |     def initialize(journal)
 12 |       @key = journal
 13 |       @output = []
 14 |       @date = Journal.date
 15 |       @date.localtime
 16 | 
 17 |       raise StandardError, "No journal with key #{@key} found" unless Journal.config["journals"].key? @key
 18 | 
 19 |       @journal = Journal.config["journals"][@key]
 20 |       @sections = Sections.new(@journal["sections"])
 21 | 
 22 |       @data = {}
 23 |       meridian = (@date.hour < 13) ? "AM" : "PM"
 24 |       @title = @journal["title"].sub(/%M/, meridian)
 25 |     end
 26 | 
 27 |     ##
 28 |     ## Add a title (Markdown) to the output
 29 |     ##
 30 |     ## @param      string  [String] The string
 31 |     ##
 32 |     def add_title(string)
 33 |       @output << "\n## #{string}\n" unless string.nil?
 34 |     end
 35 | 
 36 |     ##
 37 |     ## Add a question header (Markdown) to the output
 38 |     ##
 39 |     ## @param      string  [String] The string
 40 |     ##
 41 |     def header(string)
 42 |       @output << "\n##### #{string}\n" unless string.nil?
 43 |     end
 44 | 
 45 |     ##
 46 |     ## Add a section header (Markdown) to the output
 47 |     ##
 48 |     ## @param      string  [String] The string
 49 |     ##
 50 |     def section(string)
 51 |       @output << "\n###### #{string}\n" unless string.nil?
 52 |     end
 53 | 
 54 |     ##
 55 |     ## Add a newline to the output
 56 |     ##
 57 |     def newline
 58 |       @output << "\n"
 59 |     end
 60 | 
 61 |     ##
 62 |     ## Add a horizontal rule (Markdown) to the output
 63 |     ##
 64 |     def hr
 65 |       @output << "\n* * * * * *\n"
 66 |     end
 67 | 
 68 |     ##
 69 |     ## Finalize the checkin, saving data to JSON, Day One,
 70 |     ## and Markdown as configured
 71 |     ##
 72 |     def go
 73 |       @sections.each { |key, section| @data[key] = section }
 74 | 
 75 |       save_data
 76 |       save_day_one_entry if @journal["dayone"]
 77 | 
 78 |       return unless @journal["markdown"]
 79 | 
 80 |       case @journal["markdown"]
 81 |       when /^da(y|ily)/
 82 |         save_daily_markdown
 83 |       when /^(ind|sep)/
 84 |         save_individual_markdown
 85 |       else
 86 |         save_single_markdown
 87 |       end
 88 |     end
 89 | 
 90 |     ##
 91 |     ## Launch Day One and quit if it wasn't running
 92 |     ##
 93 |     def launch_day_one
 94 |       # Launch Day One to ensure database is up-to-date
 95 |       # test if Day One is open
 96 |       @running = !`ps ax | grep "/MacOS/Day One" | grep -v grep`.strip.empty?
 97 |       # -g do not bring app to foreground
 98 |       # -j launch hidden
 99 |       `/usr/bin/open -gj -a "Day One"`
100 |       sleep 3
101 |     end
102 | 
103 |     ##
104 |     ## Save journal entry to Day One using the command line tool
105 |     ##
106 |     def save_day_one_entry
107 |       unless TTY::Which.exist?("dayone2")
108 |         Journal.notify("{br}Day One CLI not installed, no Day One entry created")
109 |         return
110 |       end
111 | 
112 |       launch_day_one
113 | 
114 |       @date.localtime
115 |       cmd = ["dayone2"]
116 |       cmd << %(-j "#{@journal["journal"]}") if @journal.key?("journal")
117 |       cmd << %(-t #{@journal["tags"].join(" ")}) if @journal.key?("tags")
118 |       cmd << %(-date "#{@date.strftime("%Y-%m-%d %I:%M %p")}")
119 |       `echo #{Shellwords.escape(to_markdown(yaml: false, title: true))} | #{cmd.join(" ")} -- new`
120 |       Journal.notify("{bg}Entered one entry into Day One")
121 | 
122 |       # quit if it wasn't running
123 |       `osascript -e 'tell app "Day One" to quit'` if !@running
124 |     end
125 | 
126 |     ##
127 |     ## Save entry to an existing Markdown file
128 |     ##
129 |     def save_single_markdown
130 |       dir = if @journal.key?("entries_folder")
131 |         File.join(File.expand_path(@journal["entries_folder"]), "entries")
132 |       elsif Journal.config.key?("entries_folder")
133 |         File.join(File.expand_path(Journal.config["entries_folder"]), @key)
134 |       else
135 |         File.expand_path("~/.local/share/journal/#{@key}/entries")
136 |       end
137 | 
138 |       FileUtils.mkdir_p(dir) unless File.directory?(dir)
139 |       filename = "#{@key}.md"
140 |       @date.localtime
141 |       target = File.join(dir, filename)
142 |       File.open(target, "a") do |f|
143 |         f.puts
144 |         f.puts "## #{@title} #{@date.strftime("%x %X")}"
145 |         f.puts
146 |         f.puts to_markdown(yaml: false, title: false)
147 |       end
148 |       Journal.notify "{bg}Added new entry to {bw}#{target}"
149 |     end
150 | 
151 |     ##
152 |     ## Save journal entry to daily Markdown file
153 |     ##
154 |     def save_daily_markdown
155 |       dir = if @journal.key?("entries_folder")
156 |         File.join(File.expand_path(@journal["entries_folder"]), "entries")
157 |       elsif Journal.config.key?("entries_folder")
158 |         File.join(File.expand_path(Journal.config["entries_folder"]), @key)
159 |       else
160 |         File.join(File.expand_path("~/.local/share/journal/#{@key}/entries"))
161 |       end
162 | 
163 |       FileUtils.mkdir_p(dir) unless File.directory?(dir)
164 |       @date.localtime
165 |       filename = "#{@key}_#{@date.strftime("%Y-%m-%d")}.md"
166 |       target = File.join(dir, filename)
167 |       if File.exist? target
168 |         File.open(target, "a") { |f| f.puts to_markdown(yaml: false, title: true, date: false, time: true) }
169 |       else
170 |         File.open(target, "w") { |f| f.puts to_markdown(yaml: true, title: true, date: false, time: true) }
171 |       end
172 |       Journal.notify "{bg}Saved daily Markdown to {bw}#{target}"
173 |     end
174 | 
175 |     ##
176 |     ## Save journal entry to an new individual Markdown file
177 |     ##
178 |     def save_individual_markdown
179 |       dir = if @journal.key?("entries_folder")
180 |         File.join(File.expand_path(@journal["entries_folder"]), "entries")
181 |       elsif Journal.config.key?("entries_folder")
182 |         File.join(File.expand_path(Journal.config["entries_folder"]), @key,
183 |           "entries")
184 |       else
185 |         File.join(File.expand_path("~/.local/share/journal"), @key, "entries")
186 |       end
187 | 
188 |       FileUtils.mkdir_p(dir) unless File.directory?(dir)
189 |       @date.localtime
190 |       filename = @date.strftime("%Y-%m-%d_%H%M.md")
191 |       target = File.join(dir, filename)
192 |       File.open(target, "w") { |f| f.puts to_markdown(yaml: true, title: true) }
193 |       puts "Saved new entry to #{target}"
194 |     end
195 | 
196 |     def print_answer(prompt, type, key, data)
197 |       return if data.nil? || !data.key?(key) || data[key].nil?
198 | 
199 |       case type
200 |       when /^(weather|forecast|moon)/
201 |         header prompt
202 |         @output << case type
203 |         when /current$/
204 |           data[key].current
205 |         when /moon$/
206 |           "Moon phase: #{data[key].moon}"
207 |         else
208 |           data[key].to_markdown
209 |         end
210 |       when /^(int|num)/
211 |         @output << "#{prompt}: #{data[key]}  " unless data[key].nil?
212 |       when /^date/
213 |         @output << "#{prompt}: #{data[key].strftime("%Y-%m-%d %H:%M")}" unless data[key].nil?
214 |       else
215 |         unless data[key].strip.empty?
216 |           header prompt
217 |           @output << data[key]
218 |         end
219 |         hr
220 |       end
221 |     end
222 | 
223 |     def weather_to_yaml(answers)
224 |       data = {}
225 |       answers.each do |k, v|
226 |         case v.class.to_s
227 |         when /String/
228 |           next
229 |         when /Hash/
230 |           data[k] = weather_to_yaml(v)
231 |         when /Date/
232 |           v.localtime
233 |           data[k] = v.strftime("%Y-%m-%d %H:%M")
234 |         when /Weather/
235 |           data[k] = case k
236 |           when /current$/
237 |             v.current
238 |           when /forecast$/
239 |             data[k] = v.forecast
240 |           when /moon(_?phase)?$/
241 |             data[k] = v.moon
242 |           else
243 |             data[k] = v.to_s
244 |           end
245 |         else
246 |           data[k] = v
247 |         end
248 |       end
249 |       data
250 |     end
251 | 
252 |     def to_markdown(yaml: false, title: false, date: false, time: false)
253 |       @output = []
254 | 
255 |       if yaml
256 |         @date.localtime
257 |         yaml_data = {"title" => @title, "date" => @date.strftime("%x %X")}
258 |         @data.each do |key, data|
259 |           yaml_data = yaml_data.merge(weather_to_yaml(data.answers))
260 |         end
261 | 
262 |         @output << YAML.dump(yaml_data).strip
263 |         @output << "---"
264 |       end
265 | 
266 |       if title
267 |         if date || time
268 |           fmt = ""
269 |           fmt += "%x" if date
270 |           fmt += "%X" if time
271 |           add_title "#{@title} #{@date.strftime(fmt)}"
272 |         else
273 |           add_title @title
274 |         end
275 |       end
276 | 
277 |       @sections.each do |key, section|
278 |         section section.title
279 | 
280 |         section.questions.each do |question|
281 |           if /\./.match?(question.key)
282 |             res = section.answers.dup
283 |             keys = question.key.split(".")
284 |             keys.each_with_index do |key, i|
285 |               next if i == keys.count - 1
286 | 
287 |               res = res[key]
288 |             end
289 |             print_answer(question.prompt, question.type, keys.last, res)
290 |           else
291 |             print_answer(question.prompt, question.type, question.key, section.answers)
292 |           end
293 |         end
294 |       end
295 | 
296 |       @output.join("\n")
297 |     end
298 | 
299 |     def save_data
300 |       @date.localtime
301 |       dir = if @journal.key?("entries_folder")
302 |         File.expand_path(@journal["entries_folder"])
303 |       elsif Journal.config.key?("entries_folder")
304 |         File.expand_path(Journal.config["entries_folder"])
305 |       else
306 |         File.expand_path("~/.local/share/journal")
307 |       end
308 |       FileUtils.mkdir_p(dir) unless File.directory?(dir)
309 |       db = File.join(dir, "#{@key}.json")
310 |       data = if File.exist?(db)
311 |         JSON.parse(IO.read(db))
312 |       else
313 |         []
314 |       end
315 |       date = @date.utc
316 |       output = {}
317 | 
318 |       @data.each do |jk, journal|
319 |         output[jk] = {}
320 |         journal.answers.each do |k, v|
321 |           if v.is_a? Hash
322 |             v.each do |key, value|
323 |               result = case value.class.to_s
324 |               when /Weather/
325 |                 case key
326 |                 when /current$/
327 |                   {
328 |                     "temp" => value.data[:temp],
329 |                     "condition" => value.data[:current_condition]
330 |                   }
331 |                 when /moon(_?phase)?$/
332 |                   {
333 |                     "phase" => value.data[:moon_phase]
334 |                   }
335 |                 else
336 |                   {
337 |                     "high" => value.data[:high],
338 |                     "low" => value.data[:low],
339 |                     "condition" => value.data[:condition],
340 |                     "moon_phase" => value.data[:moon_phase]
341 |                   }
342 |                 end
343 |               else
344 |                 value
345 |               end
346 |               if jk == k
347 |                 output[jk][key] = result
348 |               else
349 |                 output[jk][k] ||= {}
350 |                 output[jk][k][key] = result
351 |               end
352 |             end
353 |           elsif jk == k
354 |             output[jk] = v
355 |           else
356 |             output[jk][k] = v
357 |           end
358 |         end
359 |       end
360 |       data << {"date" => date, "data" => output}
361 |       data.map! do |d|
362 |         {
363 |           "date" => d["date"].is_a?(String) ? Time.parse(d["date"]) : d["date"],
364 |           "data" => d["data"]
365 |         }
366 |       end
367 | 
368 |       data.sort_by! { |e| e["date"] }
369 | 
370 |       File.open(db, "w") { |f| f.puts JSON.pretty_generate(data) }
371 |       Journal.notify "{bg}Saved {bw}#{db}"
372 |     end
373 |   end
374 | end
375 | 


--------------------------------------------------------------------------------
/lib/journal-cli/color.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | # Cribbed from 
  4 | # Terminal output color functions.
  5 | module Color
  6 |   # Regexp to match excape sequences
  7 |   ESCAPE_REGEX = /(?<=\[)(?:(?:(?:[349]|10)[0-9]|[0-9])?;?)+(?=m)/
  8 | 
  9 |   # All available color names. Available as methods and string extensions.
 10 |   #
 11 |   # @example Use a color as a method. Color reset will be added to end of string.
 12 |   #   Color.yellow('This text is yellow') => "\e[33mThis text is yellow\e[0m"
 13 |   #
 14 |   # @example Use a color as a string extension. Color reset added automatically.
 15 |   #   'This text is green'.green => "\e[1;32mThis text is green\e[0m"
 16 |   #
 17 |   # @example Send a text string as a color
 18 |   #   Color.send('red') => "\e[31m"
 19 |   ATTRIBUTES = [
 20 |     [:clear, 0], # String#clear is already used to empty string in Ruby 1.9
 21 |     [:reset, 0], # synonym for :clear
 22 |     [:bold, 1],
 23 |     [:dark, 2],
 24 |     [:italic, 3], # not widely implemented
 25 |     [:underline, 4],
 26 |     [:underscore, 4], # synonym for :underline
 27 |     [:blink, 5],
 28 |     [:rapid_blink, 6], # not widely implemented
 29 |     [:negative, 7], # no reverse because of String#reverse
 30 |     [:concealed, 8],
 31 |     [:strikethrough, 9], # not widely implemented
 32 |     [:strike, 9], # not widely implemented
 33 |     [:black, 30],
 34 |     [:red, 31],
 35 |     [:green, 32],
 36 |     [:yellow, 33],
 37 |     [:blue, 34],
 38 |     [:magenta, 35],
 39 |     [:purple, 35],
 40 |     [:cyan, 36],
 41 |     [:white, 37],
 42 |     [:bgblack, 40],
 43 |     [:bgred, 41],
 44 |     [:bggreen, 42],
 45 |     [:bgyellow, 43],
 46 |     [:bgblue, 44],
 47 |     [:bgmagenta, 45],
 48 |     [:bgpurple, 45],
 49 |     [:bgcyan, 46],
 50 |     [:bgwhite, 47],
 51 |     [:boldblack, 90],
 52 |     [:boldred, 91],
 53 |     [:boldgreen, 92],
 54 |     [:boldyellow, 93],
 55 |     [:boldblue, 94],
 56 |     [:boldmagenta, 95],
 57 |     [:boldpurple, 95],
 58 |     [:boldcyan, 96],
 59 |     [:boldwhite, 97],
 60 |     [:boldbgblack, 100],
 61 |     [:boldbgred, 101],
 62 |     [:boldbggreen, 102],
 63 |     [:boldbgyellow, 103],
 64 |     [:boldbgblue, 104],
 65 |     [:boldbgmagenta, 105],
 66 |     [:boldbgpurple, 105],
 67 |     [:boldbgcyan, 106],
 68 |     [:boldbgwhite, 107],
 69 |     [:softpurple, "0;35;40"],
 70 |     [:hotpants, "7;34;40"],
 71 |     [:knightrider, "7;30;40"],
 72 |     [:flamingo, "7;31;47"],
 73 |     [:yeller, "1;37;43"],
 74 |     [:whiteboard, "1;30;47"],
 75 |     [:chalkboard, "1;37;40"],
 76 |     [:led, "0;32;40"],
 77 |     [:redacted, "0;30;40"],
 78 |     [:alert, "1;31;43"],
 79 |     [:error, "1;37;41"],
 80 |     [:default, "0;39"]
 81 |   ].map(&:freeze).freeze
 82 | 
 83 |   # Array of attribute keys only
 84 |   ATTRIBUTE_NAMES = ATTRIBUTES.transpose.first
 85 | 
 86 |   # Returns true if Color supports the +feature+.
 87 |   #
 88 |   # The feature :clear, that is mixing the clear color attribute into String,
 89 |   # is only supported on ruby implementations, that do *not* already
 90 |   # implement the String#clear method. It's better to use the reset color
 91 |   # attribute instead.
 92 |   def support?(feature)
 93 |     case feature
 94 |     when :clear
 95 |       !String.instance_methods(false).map(&:to_sym).include?(:clear)
 96 |     end
 97 |   end
 98 | 
 99 |   # Template coloring
100 |   class ::String
101 |     ##
102 |     ## Extract the longest valid %color name from a string.
103 |     ##
104 |     ## Allows %colors to bleed into other text and still
105 |     ## be recognized, e.g. %greensomething still finds
106 |     ## %green.
107 |     ##
108 |     ## @return     [String] a valid color name
109 |     ##
110 |     def validate_color
111 |       valid_color = nil
112 |       compiled = ""
113 |       normalize_color.chars.each do |char|
114 |         compiled += char
115 |         valid_color = compiled if Color.attributes.include?(compiled.to_sym) || compiled =~ /^([fb]g?)?#([a-f0-9]{6})$/i
116 |       end
117 | 
118 |       valid_color
119 |     end
120 | 
121 |     ##
122 |     ## Normalize a color name, removing underscores,
123 |     ## replacing "bright" with "bold", and converting
124 |     ## bgbold to boldbg
125 |     ##
126 |     ## @return     [String] Normalized color name
127 |     ##
128 |     def normalize_color
129 |       delete("_").sub(/bright/i, "bold").sub(/bgbold/, "boldbg")
130 |     end
131 | 
132 |     # Get the calculated ANSI color at the end of the
133 |     # string
134 |     #
135 |     # @return     ANSI escape sequence to match color
136 |     #
137 |     def last_color_code
138 |       m = scan(ESCAPE_REGEX)
139 | 
140 |       em = ["0"]
141 |       fg = nil
142 |       bg = nil
143 |       rgbf = nil
144 |       rgbb = nil
145 | 
146 |       m.each do |c|
147 |         case c
148 |         when "0"
149 |           em = ["0"]
150 |           fg, bg, rgbf, rgbb = nil
151 |         when /^[34]8/
152 |           case c
153 |           when /^3/
154 |             fg = nil
155 |             rgbf = c
156 |           when /^4/
157 |             bg = nil
158 |             rgbb = c
159 |           end
160 |         else
161 |           c.split(";").each do |i|
162 |             x = i.to_i
163 |             if x <= 9
164 |               em << x
165 |             elsif x >= 30 && x <= 39
166 |               rgbf = nil
167 |               fg = x
168 |             elsif x >= 40 && x <= 49
169 |               rgbb = nil
170 |               bg = x
171 |             elsif x >= 90 && x <= 97
172 |               rgbf = nil
173 |               fg = x
174 |             elsif x >= 100 && x <= 107
175 |               rgbb = nil
176 |               bg = x
177 |             end
178 |           end
179 |         end
180 |       end
181 | 
182 |       escape = "\e[#{em.join(";")}m"
183 |       escape += "\e[#{rgbb}m" if rgbb
184 |       escape += "\e[#{rgbf}m" if rgbf
185 |       escape + "\e[#{[fg, bg].delete_if(&:nil?).join(";")}m"
186 |     end
187 |   end
188 | 
189 |   class << self
190 |     # Returns true if the coloring function of this module
191 |     # is switched on, false otherwise.
192 |     def coloring?
193 |       @coloring
194 |     end
195 | 
196 |     attr_writer :coloring
197 | 
198 |     ##
199 |     ## Enables colored output
200 |     ##
201 |     ## @example Turn color on or off based on TTY
202 |     ##   Color.coloring = STDOUT.isatty
203 |     def coloring
204 |       @coloring ||= true
205 |     end
206 | 
207 |     ##
208 |     ## Convert a template string to a colored string.
209 |     ## Colors are specified with single letters inside
210 |     ## curly braces. Uppercase changes background color.
211 |     ##
212 |     ## w: white, k: black, g: green, l: blue, y: yellow, c: cyan,
213 |     ## m: magenta, r: red, b: bold, u: underline, i: italic,
214 |     ## x: reset (remove background, color, emphasis)
215 |     ##
216 |     ## Also accepts {#RGB} and {#RRGGBB} strings. Put a b before
217 |     ## the hash to make it a background color
218 |     ##
219 |     ## @example Convert a templated string
220 |     ##   Color.template('{Rwb}Warning:{x} {w}you look a little {g}ill{x}')
221 |     ##
222 |     ## @example Convert using RGB colors
223 |     ##   Color.template('{#f0a}This is an RGB color')
224 |     ##
225 |     ## @param      input  [String, Array] The template
226 |     ##                    string. If this is an array, the
227 |     ##                    elements will be joined with a
228 |     ##                    space.
229 |     ##
230 |     ## @return     [String] Colorized string
231 |     ##
232 |     def template(input)
233 |       input = input.join(" ") if input.is_a? Array
234 |       return input.gsub(/(?s" }.join("")
244 |       end
245 | 
246 |       colors = {w: white, k: black, g: green, l: blue,
247 |                 y: yellow, c: cyan, m: magenta, r: red,
248 |                 W: bgwhite, K: bgblack, G: bggreen, L: bgblue,
249 |                 Y: bgyellow, C: bgcyan, M: bgmagenta, R: bgred,
250 |                 d: dark, b: bold, u: underline, i: italic, x: reset}
251 | 
252 |       format(fmt, colors)
253 |     end
254 |   end
255 | 
256 |   ATTRIBUTES.each do |c, v|
257 |     new_method = <<-EOSCRIPT
258 |       # Color string as #{c}
259 |       def #{c}(string = nil)
260 |         result = ''
261 |         result << "\e[#{v}m" if Color.coloring?
262 |         if block_given?
263 |           result << yield
264 |         elsif string.respond_to?(:to_str)
265 |           result << string.to_str
266 |         elsif respond_to?(:to_str)
267 |           result << to_str
268 |         else
269 |           return result #only switch on
270 |         end
271 |         result << "\e[0m" if Color.coloring?
272 |         result
273 |       end
274 |     EOSCRIPT
275 | 
276 |     module_eval(new_method)
277 | 
278 |     next unless /bold/.match?(c)
279 | 
280 |     # Accept brightwhite in addition to boldwhite
281 |     new_method = <<-EOSCRIPT
282 |       # color string as #{c}
283 |       def #{c.to_s.sub(/bold/, "bright")}(string = nil)
284 |         result = ''
285 |         result << "\e[#{v}m" if Color.coloring?
286 |         if block_given?
287 |           result << yield
288 |         elsif string.respond_to?(:to_str)
289 |           result << string.to_str
290 |         elsif respond_to?(:to_str)
291 |           result << to_str
292 |         else
293 |           return result #only switch on
294 |         end
295 |         result << "\e[0m" if Color.coloring?
296 |         result
297 |       end
298 |     EOSCRIPT
299 | 
300 |     module_eval(new_method)
301 |   end
302 | 
303 |   ##
304 |   ## Generate escape codes for hex colors
305 |   ##
306 |   ## @param      hex   [String] The hexadecimal color code
307 |   ##
308 |   ## @return     [String] ANSI escape string
309 |   ##
310 |   def rgb(hex)
311 |     is_bg = /^bg?#/.match?(hex)
312 |     hex_string = hex.sub(/^([fb]g?)?#/, "")
313 | 
314 |     if hex_string.length == 3
315 |       parts = hex_string.match(/(?.)(?.)(?.)/)
316 | 
317 |       t = []
318 |       %w[r g b].each do |e|
319 |         t << parts[e]
320 |         t << parts[e]
321 |       end
322 |       hex_string = t.join("")
323 |     end
324 | 
325 |     parts = hex_string.match(/(?..)(?..)(?..)/)
326 |     t = []
327 |     %w[r g b].each do |e|
328 |       t << parts[e].hex
329 |     end
330 | 
331 |     "\e[#{is_bg ? "48" : "38"};2;#{t.join(";")}m"
332 |   end
333 | 
334 |   # Regular expression that is used to scan for ANSI-sequences while
335 |   # uncoloring strings.
336 |   COLORED_REGEXP = /\e\[(?:(?:(?:[349]|10)[0-9]|[0-9])?;?)+m/
337 | 
338 |   # Returns an uncolored version of the string, that is all
339 |   # ANSI-sequences are stripped from the string.
340 |   def uncolor(string = nil) # :yields:
341 |     if block_given?
342 |       yield.to_str.gsub(COLORED_REGEXP, "")
343 |     elsif string.respond_to?(:to_str)
344 |       string.to_str.gsub(COLORED_REGEXP, "")
345 |     elsif respond_to?(:to_str)
346 |       to_str.gsub(COLORED_REGEXP, "")
347 |     else
348 |       ""
349 |     end
350 |   end
351 | 
352 |   # Returns an array of all Color attributes as symbols.
353 |   def attributes
354 |     ATTRIBUTE_NAMES
355 |   end
356 |   extend self
357 | end
358 | 
359 | class ::String
360 |   def x
361 |     Color.template(self)
362 |   end
363 | end
364 | 


--------------------------------------------------------------------------------
/lib/journal-cli/data.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | module Journal
 4 |   # Data handler
 5 |   class Data < Hash
 6 |     attr_reader :questions
 7 | 
 8 |     def initialize(questions)
 9 |       @questions = questions
10 |       super
11 |     end
12 | 
13 |     ##
14 |     ## Convert Data object to a hash
15 |     ##
16 |     ## @return     [Hash] Data representation of the object.
17 |     ##
18 |     def to_data
19 |       output = {}
20 |       @questions.each do |q|
21 |         output[q["key"]] = self[q["key"]]
22 |       end
23 |       output
24 |     end
25 |   end
26 | end
27 | 


--------------------------------------------------------------------------------
/lib/journal-cli/question.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | module Journal
  4 |   # Individual question
  5 |   class Question
  6 |     attr_reader :key, :type, :min, :max, :prompt, :secondary_prompt, :gum, :condition
  7 | 
  8 |     ##
  9 |     ## Initializes the given question.
 10 |     ##
 11 |     ## @param      question  [Hash] The question with key, prompt, and type, optionally min and max
 12 |     ##
 13 |     ## @return     [Question] the question object
 14 |     ##
 15 |     def initialize(question)
 16 |       @key = question["key"]
 17 |       @type = question["type"]
 18 |       @min = question["min"]&.to_i || 1
 19 |       @max = question["max"]&.to_i || 5
 20 |       @prompt = question["prompt"] || nil
 21 |       @secondary_prompt = question["secondary_prompt"] || nil
 22 |       @gum = TTY::Which.exist?("gum")
 23 |       @condition = question.key?("condition") ? question["condition"].parse_condition : true
 24 |     end
 25 | 
 26 |     ##
 27 |     ## Ask the question, prompting for input based on type
 28 |     ##
 29 |     ## @return     [Number, String] the response based on @type
 30 |     ##
 31 |     def ask(condition)
 32 |       return nil if @prompt.nil?
 33 | 
 34 |       return nil unless @condition && condition
 35 | 
 36 |       res = case @type
 37 |             when /^int/i
 38 |               read_number(integer: true)
 39 |             when /^(float|num)/i
 40 |               read_number
 41 |             when /^(text|string|line)/i
 42 |               read_line
 43 |             when /^(weather|forecast)/i
 44 |               Weather.new(Journal.config["weather_api"], Journal.config["zip"], Journal.config["temp_in"])
 45 |             when /^multi/i
 46 |               read_lines
 47 |             when /^(date|time)/i
 48 |               read_date
 49 |             end
 50 |       Journal.notify("{dw}#{prompt}: {dy}#{res}{x}".x)
 51 |       res
 52 |     end
 53 | 
 54 |     private
 55 | 
 56 |     ##
 57 |     ## Read a numeric entry using gum or TTY::Reader
 58 |     ##
 59 |     ## @param      [Boolean] integer  Round result to nearest integer
 60 |     ##
 61 |     ## @return     [Number] integer response
 62 |     ##
 63 |     ##
 64 |     def read_number(integer: false)
 65 |       Journal.notify("{by}#{@prompt} {xc}({bw}#{@min}{xc}-{bw}#{@max}{xc})")
 66 | 
 67 |       res = @gum ? read_number_gum : read_line_tty
 68 | 
 69 |       res = integer ? res.to_f.round : res.to_f
 70 | 
 71 |       res = read_number if res < @min || res > @max
 72 |       res
 73 |     end
 74 | 
 75 |     def read_date(prompt: nil)
 76 |       prompt ||= @prompt
 77 |       Journal.notify("{by}#{prompt} (natural language)")
 78 |       line = @gum ? read_line_gum(prompt) : read_line_tty
 79 |       Chronic.parse(line)
 80 |     end
 81 | 
 82 |     ##
 83 |     ## Reads a line.
 84 |     ##
 85 |     ## @param      prompt  [String] If not nil, will trigger
 86 |     ##                     asking for a secondary response
 87 |     ##                     until a blank entry is given
 88 |     ##
 89 |     ## @return     [String] the single-line response
 90 |     ##
 91 |     def read_line(prompt: nil)
 92 |       output = []
 93 |       prompt ||= @prompt
 94 |       Journal.notify("{by}#{prompt}")
 95 | 
 96 |       line = @gum ? read_line_gum(prompt) : read_line_tty
 97 |       return output.join("\n") if /^ *$/.match?(line)
 98 | 
 99 |       output << line
100 |       output << read_line(prompt: @secondary_prompt) unless @secondary_prompt.nil?
101 |       output.join("\n").strip
102 |     end
103 | 
104 |     ##
105 |     ## Reads multiple lines.
106 |     ##
107 |     ## @param      prompt  [String] if not nil, will trigger
108 |     ##                     asking for a secondary response
109 |     ##                     until a blank entry is given
110 |     ##
111 |     ## @return     [String] the multi-line response
112 |     ##
113 |     def read_lines(prompt: nil)
114 |       output = []
115 |       prompt ||= @prompt
116 |       Journal.notify("{by}#{prompt} {c}({bw}CTRL-d{c} to save)'")
117 |       line = @gum ? read_multiline_gum(prompt) : read_mutliline_tty
118 |       return output.join("\n") if line.strip.empty?
119 | 
120 |       output << line
121 |       output << read_lines(prompt: @secondary_prompt) unless @secondary_prompt.nil?
122 |       output.join("\n").strip
123 |     end
124 | 
125 |     ##
126 |     ## Read a numeric entry using gum
127 |     ##
128 |     ## @param      [Boolean] integer  Round result to nearest integer
129 |     ##
130 |     ## @return     [Number] integer response
131 |     ##
132 |     ##
133 |     def read_number_gum
134 |       trap("SIGINT") { exit! }
135 |       res = `gum input --placeholder "#{@min}-#{@max}"`.strip
136 |       return nil if res.strip.empty?
137 | 
138 |       res
139 |     end
140 | 
141 |     ##
142 |     ## Read a single line entry using TTY::Reader
143 |     ##
144 |     ## @param      [Boolean] integer  Round result to nearest integer
145 |     ##
146 |     ## @return     [Number] integer response
147 |     ##
148 |     def read_line_tty
149 |       trap("SIGINT") { exit! }
150 |       reader = TTY::Reader.new
151 |       res = reader.read_line(">> ")
152 |       return nil if res.strip.empty?
153 | 
154 |       res
155 |     end
156 | 
157 |     ##
158 |     ## Read a single line entry using gum
159 |     ##
160 |     ## @param      [Boolean] integer  Round result to nearest integer
161 |     ##
162 |     ## @return     [Number] integer response
163 |     ##
164 |     def read_line_gum(prompt)
165 |       trap("SIGINT") { exit! }
166 |       `gum input --placeholder "#{prompt} (blank to end answer)"`
167 |     end
168 | 
169 |     ##
170 |     ## Read a multiline entry using TTY::Reader
171 |     ##
172 |     ## @return     [string] multiline input
173 |     ##
174 |     def read_mutliline_tty
175 |       trap("SIGINT") { exit! }
176 |       reader = TTY::Reader.new
177 |       res = reader.read_multiline
178 |       res.join("\n")
179 |     end
180 | 
181 |     ##
182 |     ## Read a multiline entry using gum
183 |     ##
184 |     ## @return     [string] multiline input
185 |     ##
186 |     def read_multiline_gum(prompt)
187 |       trap("SIGINT") { exit! }
188 |       `gum write --placeholder "#{prompt}" --width 80 --char-limit 0`
189 |     end
190 |   end
191 | end
192 | 


--------------------------------------------------------------------------------
/lib/journal-cli/section.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | module Journal
 4 |   class Section
 5 |     attr_accessor :key, :title, :questions, :answers, :condition
 6 | 
 7 |     ##
 8 |     ## Initializes the given section.
 9 |     ##
10 |     ## @param      section  [Hash] The section as defined in
11 |     ##                      configuration
12 |     ##
13 |     ## @return     [Section] the configured section
14 |     ##
15 |     def initialize(section)
16 |       @key = section["key"]
17 |       @title = section["title"]
18 |       @condition = section.key?("condition") ? section["condition"].parse_condition : true
19 |       @questions = section["questions"].map { |question| Question.new(question) }
20 |       @questions.delete_if { |q| q.prompt.nil? }
21 |       @answers = {}
22 |       ask_questions
23 |     end
24 | 
25 |     ##
26 |     ## Ask the questions detailed in the 'questions' section of the configuration
27 |     ##
28 |     ## @return [Hash] the question responses
29 |     ##
30 |     def ask_questions
31 |       @questions.each do |question|
32 |         if /\./.match?(question.key)
33 |           res = @answers
34 |           keys = question.key.split(".")
35 |           keys.each_with_index do |key, i|
36 |             next if i == keys.count - 1
37 | 
38 |             res[key] = {} unless res.key?(key)
39 |             res = res[key]
40 |           end
41 | 
42 |           res[keys.last] = question.ask(@condition)
43 |         else
44 |           @answers[question.key] = question.ask(@condition)
45 |         end
46 |       end
47 |     end
48 |   end
49 | end
50 | 


--------------------------------------------------------------------------------
/lib/journal-cli/sections.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | module Journal
 4 |   class Sections < Hash
 5 |     def initialize(sections)
 6 |       sections.each do |sect|
 7 |         section = Section.new(sect)
 8 |         self[section.key] = section
 9 |       end
10 |       super
11 |     end
12 |   end
13 | end
14 | 


--------------------------------------------------------------------------------
/lib/journal-cli/string.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | # String helpers
 4 | class ::String
 5 | 
 6 |   ##
 7 |   ## Parse and test a condition
 8 |   ##
 9 |   ## @return     [Boolean] whether test passed
10 |   ##
11 |   def parse_condition
12 |     condition = dup
13 |     time_rx = /(?[<>=]{1,2}|before|after) +(?