├── .editorconfig ├── .gitignore ├── .rubocop.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── bin └── til ├── lib ├── til-rb.rb └── til │ ├── core.rb │ ├── readme_updater.rb │ └── version.rb ├── test ├── readme_updater_test.rb ├── test_helper.rb └── til_test.rb ├── til-rb.gemspec └── til.gif /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | insert_final_newline = true 9 | max_line_length = 120 10 | trim_trailing_whitespace = true 11 | 12 | [*.rb] 13 | tab_width = 2 14 | indent_size = tab 15 | indent_style = space 16 | charset = utf8 17 | 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | # .env 15 | 16 | # Ignore Byebug command history file. 17 | .byebug_history 18 | 19 | ## Specific to RubyMotion: 20 | .dat* 21 | .repl_history 22 | build/ 23 | *.bridgesupport 24 | build-iPhoneOS/ 25 | build-iPhoneSimulator/ 26 | 27 | ## Specific to RubyMotion (use of CocoaPods): 28 | # 29 | # We recommend against adding the Pods directory to your .gitignore. However 30 | # you should judge for yourself, the pros and cons are mentioned at: 31 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 32 | # 33 | # vendor/Pods/ 34 | 35 | ## Documentation cache and generated files: 36 | /.yardoc/ 37 | /_yardoc/ 38 | /doc/ 39 | /rdoc/ 40 | 41 | ## Environment normalization: 42 | /.bundle/ 43 | /vendor/bundle 44 | /lib/bundler/man/ 45 | 46 | # for a library or gem, you might want to ignore these files since the code is 47 | # intended to run in multiple environments; otherwise, check them in: 48 | # Gemfile.lock 49 | # .ruby-version 50 | # .ruby-gemset 51 | 52 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 53 | .rvmrc 54 | 55 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 56 | # .rubocop-https?--* 57 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Base: https://github.com/rubocop-hq/rubocop/blob/master/config/default.yml 2 | 3 | Layout/LineLength: 4 | Max: 120 5 | 6 | Layout/EmptyLinesAroundModuleBody: 7 | EnforcedStyle: empty_lines_except_namespace 8 | 9 | Layout/EmptyLinesAroundClassBody: 10 | EnforcedStyle: empty_lines_except_namespace 11 | 12 | Layout/EmptyLinesAroundAttributeAccessor: 13 | Enabled: true 14 | 15 | Layout/SpaceAroundMethodCallOperator: 16 | Enabled: true 17 | 18 | Lint/DeprecatedOpenSSLConstant: 19 | Enabled: true 20 | 21 | Lint/MixedRegexpCaptureTypes: 22 | Enabled: true 23 | 24 | Lint/RaiseException: 25 | Enabled: true 26 | 27 | Lint/StructNewOverride: 28 | Enabled: true 29 | 30 | Metrics/AbcSize: 31 | Max: 20 32 | 33 | Metrics/MethodLength: 34 | Max: 50 35 | 36 | Naming/FileName: 37 | Exclude: ['lib/til-rb.rb'] 38 | 39 | Style/ExponentialNotation: 40 | Enabled: true 41 | 42 | Style/HashEachMethods: 43 | Enabled: true 44 | 45 | Style/HashTransformKeys: 46 | Enabled: true 47 | 48 | Style/HashTransformValues: 49 | Enabled: true 50 | 51 | Style/RedundantRegexpCharacterClass: 52 | Enabled: true 53 | 54 | Style/RedundantRegexpEscape: 55 | Enabled: true 56 | 57 | Style/SlicingWithRange: 58 | Enabled: true 59 | 60 | Style/FrozenStringLiteralComment: 61 | Enabled: false 62 | 63 | Style/MutableConstant: 64 | Enabled: false 65 | 66 | Style/Documentation: 67 | Enabled: false 68 | 69 | Style/TrailingCommaInHashLiteral: 70 | EnforcedStyleForMultiline: consistent_comma 71 | 72 | Style/TrailingCommaInArrayLiteral: 73 | EnforcedStyleForMultiline: consistent_comma 74 | 75 | Style/TrailingCommaInArguments: 76 | EnforcedStyleForMultiline: consistent_comma 77 | 78 | Style/PercentLiteralDelimiters: 79 | PreferredDelimiters: 80 | default: () 81 | '%i': '[]' 82 | '%I': '[]' 83 | '%r': '{}' 84 | '%w': '()' 85 | '%W': '()' 86 | 87 | Style/IfUnlessModifier: 88 | Enabled: false 89 | 90 | Style/GuardClause: 91 | Enabled: false 92 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem 'minitest', '~> 5.0' 6 | gem 'rake', '~> 12.0' 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | til-rb (0.0.8) 5 | octokit (~> 4.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | addressable (2.8.0) 11 | public_suffix (>= 2.0.2, < 5.0) 12 | faraday (1.0.1) 13 | multipart-post (>= 1.2, < 3) 14 | minitest (5.14.1) 15 | mocha (1.11.2) 16 | multipart-post (2.1.1) 17 | octokit (4.18.0) 18 | faraday (>= 0.9) 19 | sawyer (~> 0.8.0, >= 0.5.3) 20 | public_suffix (4.0.6) 21 | rake (12.3.3) 22 | sawyer (0.8.2) 23 | addressable (>= 2.3.5) 24 | faraday (> 0.8, < 2.0) 25 | timecop (0.9.1) 26 | 27 | PLATFORMS 28 | ruby 29 | 30 | DEPENDENCIES 31 | minitest (~> 5.0) 32 | mocha (~> 1.11.2) 33 | rake (~> 12.0) 34 | til-rb! 35 | timecop (~> 0.9.1) 36 | 37 | BUNDLED WITH 38 | 2.1.4 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Pierre Jambet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Gem](https://img.shields.io/gem/v/til-rb) 2 | 3 | # til-rb 4 | 5 | A work-in-progress tool to maintain a TIL repo. 6 | 7 | Inspiration: https://github.com/jbranchaud/til 8 | 9 | See it in action below: 10 | 11 | ![til in action](til.gif) 12 | 13 | ## Internals 14 | 15 | I wrote [a blog post](https://blog.pjam.me/posts/til-cli/) about the interesting parts of the til internals, specifically: 16 | 17 | - Using unix pipes to feed data to `fzf` and read its output 18 | - Using an external editor 19 | - Creating a commit with the GitHub API 20 | 21 | ## Installation 22 | 23 | ### Step 1: Install the gem 24 | 25 | ``` 26 | gem install til-rb 27 | ``` 28 | 29 | You will also need `fzf` to run `til`, it's available on homebrew, so unless you already installed it, you'll have to run 30 | 31 | ``` 32 | brew install fzf 33 | ``` 34 | 35 | _fzf is technically not a hard requirement, we really could have a slightly different workflow if it's not available. 36 | Given that I currently am the only user, there's no need to change this at the moment, but if you'd like to use this 37 | gem without `fzf`, let me know and I'll happily work on it!_ 38 | 39 | ### Step 2: Create a GitHub repo 40 | 41 | You need a GitHub repo to store your TILs. The gem has pretty strict expectations about the format of the README.md 42 | file, so I recommend forking [this repo](https://github.com/pjambet/til-template), or just copying the content to a 43 | fresh new repo. 44 | 45 | Note: The format expectations mentioned above are the following: 46 | 47 | - A "categories" section as defined by a leading markdown separator `---`, followed by a blank line, the `### 48 | Categories` title, and a list of all the categories. 49 | - A links section as defined by a leading markdown separator `---`, followed by a blank line and a series of markdown 50 | titles for each categories present in the previous section. 51 | 52 | ### Step 3: Add the environment variables 53 | 54 | Add the following variables to your environment: 55 | 56 | - `TIL_RB_GITHUB_TOKEN`: The only required scope is `public_repo` if your TIL repo is public and `repo` if it is private. You can 57 | create a token [in the GitHub 58 | settings](https://github.com/settings/tokens/new?scopes=public_repo&description=Token%20for%20til-rb) 59 | - `TIL_RB_GITHUB_REPO`: The repo name, e.g. `pjambet/til` 60 | 61 | You might want to add those to either your `.bashrc` or `.zshrc` but please be careful in case you share those publicly 62 | as the token is private and *must not* be shared publicly. 63 | 64 | Note: An earlier version of this gem used different names, `GH_TOKEN` & `GH_REPO`, it still works, but is not the 65 | recommended approach anymore, see [#2](https://github.com/pjambet/til-rb/issues/2). 66 | 67 | ### Step 4: 68 | 69 | Run `til` from the command line 70 | 71 | ## Future improvements 72 | 73 | - An `init` command that will create a repo for you 74 | - A `configure` command that will store the token and the repo name in a file in `~/.config/til/config` or `~/.til` 75 | - Other storage destinations, looks like Gitlab has an API that could be used to replicate the logic we use for GH 76 | 77 | ## Known issues 78 | 79 | The current version (0.0.5) deletes the temporary file before attempting to create the new commit, so if anything goes 80 | wrong there, the content of the file will be lost. 81 | This will be fixed soon, by only deleting the temporary file after the commit was created, but please keep that in mind 82 | if you typed a long TIL and definitely don't want to lose its content. 83 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rake/testtask' 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << 'test' 6 | t.libs << 'lib' 7 | t.test_files = FileList['test/**/*_test.rb'] 8 | end 9 | 10 | desc 'Run tests' 11 | task default: :test 12 | -------------------------------------------------------------------------------- /bin/til: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'til-rb' 4 | require 'optparse' 5 | 6 | options = {} 7 | OptionParser.new do |opts| 8 | opts.banner = 'Usage: til [options]' 9 | 10 | opts.on('-t', '--title=TITLE', 'Set the title from the command line') do |title| 11 | options[:title] = title 12 | end 13 | 14 | opts.on('-h', '--help', 'Prints this help') do 15 | puts opts 16 | exit 17 | end 18 | end.parse! 19 | 20 | Til::Core.run options: options 21 | -------------------------------------------------------------------------------- /lib/til-rb.rb: -------------------------------------------------------------------------------- 1 | require 'til/core' 2 | require 'til/readme_updater' 3 | require 'til/version' 4 | -------------------------------------------------------------------------------- /lib/til/core.rb: -------------------------------------------------------------------------------- 1 | require 'octokit' 2 | require 'tempfile' 3 | require 'readline' 4 | 5 | module Til 6 | class Core 7 | 8 | GH_TOKEN_ENV_VAR_NAME = 'TIL_RB_GITHUB_TOKEN' 9 | GH_REPO_ENV_VAR_NAME = 'TIL_RB_GITHUB_REPO' 10 | 11 | def self.run(options: {}) 12 | # Exit if `fzf` is not available 13 | # Optionally print a spinner 14 | # Grab the list of existing categories 15 | # Feed them into fzf with a new entry to let the user type a category 16 | # Grab the category from fzf 17 | # Handle the case where it's a new category (as in, figure out the new place in the README) 18 | # Open $VISUAL or $EDITOR with a tempfile 19 | # Read the file content 20 | # Update the README.md with a link to the new entry 21 | # Create the new file 22 | # Create a new commit 23 | # Output a link to the file and the link to edit it 24 | til = new(options: options) 25 | til.run 26 | end 27 | 28 | def initialize(options: {}, kernel: Kernel, process: Process, env: ENV, github_client: nil, stderr: $stderr) 29 | @options = options 30 | @kernel = kernel 31 | @process = process 32 | @env = env 33 | @stderr = stderr 34 | @github_client = github_client 35 | @repo_name = nil 36 | @new_category = false 37 | end 38 | 39 | def run 40 | catch(:exit) do 41 | check_dependencies 42 | check_environment_variables 43 | existing_categories = fetch_existing_categories 44 | selected_category = prompt_fzf(existing_categories) 45 | if @new_category 46 | selected_category = prompt_for_new_category 47 | end 48 | prepopulate_tempfile(selected_category, @options[:title]) 49 | open_editor 50 | til_content = read_file 51 | commit_new_til(selected_category, til_content) 52 | end 53 | end 54 | 55 | private 56 | 57 | def check_dependencies 58 | result = @kernel.system('which fzf', out: '/dev/null', err: '/dev/null') 59 | unless result 60 | raise "fzf is required, you can install it on macOS with 'brew install fzf'" 61 | end 62 | end 63 | 64 | def check_environment_variables 65 | if @env[GH_TOKEN_ENV_VAR_NAME].nil? || @env[GH_TOKEN_ENV_VAR_NAME] == '' 66 | if @env['GH_TOKEN'].nil? || @env['GH_TOKEN'] == '' 67 | raise "The #{GH_TOKEN_ENV_VAR_NAME} (with the public_repo or repo scope) environment variable is required" 68 | else 69 | @stderr.puts "Using GH_TOKEN is deprecated, use #{GH_TOKEN_ENV_VAR_NAME} instead" 70 | end 71 | end 72 | 73 | if @env[GH_REPO_ENV_VAR_NAME].nil? || @env[GH_REPO_ENV_VAR_NAME] == '' 74 | if @env['GH_REPO'].nil? || @env['GH_REPO'] == '' 75 | raise "The #{GH_REPO_ENV_VAR_NAME} environment variable is required" 76 | else 77 | @stderr.puts "Using GH_REPO is deprecated, use #{GH_REPO_ENV_VAR_NAME} instead" 78 | end 79 | end 80 | end 81 | 82 | def fetch_existing_categories 83 | existing_categories = github_client.contents(repo_name, path: '').filter do |c| 84 | c['type'] == 'dir' 85 | end 86 | 87 | existing_category_names = existing_categories.map do |category| 88 | category[:name] 89 | end 90 | 91 | existing_category_names << 'Something else?' 92 | end 93 | 94 | def github_client 95 | @github_client ||= Octokit::Client.new(access_token: @env[GH_TOKEN_ENV_VAR_NAME] || @env['GH_TOKEN']) 96 | end 97 | 98 | def repo_name 99 | @repo_name ||= (@env[GH_REPO_ENV_VAR_NAME] || @env['GH_REPO']) 100 | end 101 | 102 | def prompt_fzf(categories) 103 | reader1, writer1 = IO.pipe 104 | reader2, writer2 = IO.pipe 105 | fzf_pid = @process.spawn('fzf', { out: writer1, in: reader2 }) 106 | reader2.close 107 | writer1.close 108 | writer2.puts categories.join("\n") 109 | writer2.close 110 | Process.waitpid(fzf_pid) 111 | selected = reader1.gets.chomp 112 | reader1.close 113 | if selected == 'Something else?' 114 | @new_category = true 115 | end 116 | selected 117 | rescue Errno::EPIPE => e 118 | @stderr.puts "Pipe issue: #{e}" 119 | throw :exit 120 | end 121 | 122 | def prompt_for_new_category 123 | Readline.readline("New category > ").downcase 124 | end 125 | 126 | def prepopulate_tempfile(selected_category, title = 'Title Placeholder') 127 | @tempfile = Tempfile.new('til.md') 128 | @tempfile.write("# #{title}") 129 | @tempfile.write("\n" * 2) 130 | @tempfile.write("What did you learn about #{selected_category} today") 131 | @tempfile.close 132 | end 133 | 134 | def open_editor 135 | editor = ENV['VISUAL'] || ENV['EDITOR'] || 'vi' 136 | system(*editor.split, @tempfile.path) 137 | end 138 | 139 | def read_file 140 | content = File.read(@tempfile) 141 | @tempfile.unlink 142 | content 143 | end 144 | 145 | def new_filename(commit_title) 146 | today = Time.now.strftime '%Y-%m-%d' 147 | name = commit_title.split.map(&:downcase).join('-') 148 | "#{today}_#{name}.md" 149 | end 150 | 151 | def commit_new_til(category, content) 152 | commit_title = content.lines[0].chomp 153 | if commit_title.start_with?('#') 154 | commit_title = commit_title[1..].strip 155 | end 156 | filename = new_filename(commit_title) 157 | 158 | ref = github_client.ref repo_name, 'heads/master' 159 | commit = github_client.commit repo_name, ref.object.sha 160 | tree = github_client.tree repo_name, commit.commit.tree.sha, recursive: true 161 | readme = github_client.readme repo_name 162 | readme_content = Base64.decode64 readme.content 163 | 164 | blob = github_client.create_blob repo_name, content 165 | blobs = tree.tree.filter { |object| 166 | object[:type] == 'blob' && object[:path] != 'README.md' 167 | }.map { |object| 168 | object.to_h.slice(:path, :mode, :type, :sha) 169 | } 170 | 171 | updated_readme_content = update_readme_content(category, commit_title, filename, readme_content) 172 | new_readme_blob = github_client.create_blob repo_name, updated_readme_content 173 | blobs << { path: 'README.md', mode: '100644', type: 'blob', sha: new_readme_blob } 174 | 175 | blobs << { path: "#{category}/#{filename}", mode: '100644', type: 'blob', sha: blob } 176 | 177 | tree = github_client.create_tree repo_name, blobs 178 | commit = github_client.create_commit repo_name, commit_title, tree.sha, ref.object.sha 179 | github_client.update_ref repo_name, 'heads/master', commit.sha 180 | 181 | cgi_escaped_filename = CGI.escape(filename) 182 | til_url = "https://github.com/#{repo_name}/blob/master/#{category}/#{cgi_escaped_filename}" 183 | til_edit_url = "https://github.com/#{repo_name}/edit/master/#{category}/#{cgi_escaped_filename}" 184 | puts "You can see your new TIL at : #{til_url}" 185 | puts "You can edit your new TIL at : #{til_edit_url}" 186 | end 187 | 188 | def update_readme_content(category, commit_title, filename, readme_content) 189 | updater = Til::ReadmeUpdater.new(readme_content) 190 | 191 | if @new_category 192 | updater.add_item_for_new_category(category, commit_title, filename) 193 | else 194 | updater.add_item_for_existing_category(category, commit_title, filename) 195 | end 196 | end 197 | 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /lib/til/readme_updater.rb: -------------------------------------------------------------------------------- 1 | module Til 2 | class ReadmeUpdater 3 | 4 | def initialize(initial_content) 5 | @initial_content = initial_content 6 | end 7 | 8 | def add_item_for_existing_category(category, item_title, filename) 9 | beginning = @initial_content.index('### Categories') + '### Categories'.length 10 | eend = @initial_content.index('---', @initial_content.index('---') + 1) - 1 11 | 12 | # [["[Git](#git)", "Git", "git"], ["[Qux](#qux)", "Qux", "qux"]] 13 | categories = @initial_content[beginning..eend].scan(/(\[(\w+)\]\(#(\w+)\))/) 14 | 15 | existing_cat = categories.find { |c| c[2] == category } 16 | 17 | loc_in_page = @initial_content.index("### #{existing_cat[1]}") 18 | next_cat_location = @initial_content.index('###', loc_in_page + 1) 19 | 20 | new_line = "- [#{item_title}](#{category}/#{CGI.escape(filename)})" 21 | new_readme_content = '' 22 | if next_cat_location 23 | breakpoint = next_cat_location - 2 24 | new_readme_content = @initial_content[0..breakpoint] + new_line + @initial_content[breakpoint..] 25 | else 26 | new_readme_content = @initial_content + new_line + "\n" 27 | end 28 | new_readme_content 29 | end 30 | 31 | def add_item_for_new_category(category, item_title, filename) 32 | # TODO: We'll need some form of validation on the category name 33 | beginning = @initial_content.index('### Categories') + '### Categories'.length 34 | first_dashdashdash = @initial_content.index('---') 35 | eend = @initial_content.index('---', first_dashdashdash + 1) - 1 36 | 37 | # [["[Git](#git)", "Git", "git"], ["[Qux](#qux)", "Qux", "qux"]] 38 | categories = @initial_content[beginning..eend].scan(/(\[(\w+)\]\(#(\w+)\))/) 39 | 40 | insert_at = categories.bsearch_index do |category_triplet| 41 | category_triplet[2] >= category 42 | end 43 | 44 | if insert_at.nil? 45 | # It's the last category 46 | insert_at = categories.length 47 | end 48 | 49 | categories.insert(insert_at, ["[#{category.capitalize}](\##{category})", category.capitalize, category]) 50 | 51 | new_categories_formatted = categories.map do |category| 52 | "* #{category[0]}" 53 | end.join("\n") 54 | 55 | new_categories_formatted.prepend("### Categories\n\n") 56 | 57 | category_sections_found = 0 58 | current_search_index = eend + 1 + 3 59 | 60 | while category_sections_found < insert_at 61 | current_search_index = @initial_content.index('###', current_search_index + 1) 62 | category_sections_found += 1 63 | end 64 | 65 | next_bound = @initial_content.index('###', current_search_index + 1) 66 | 67 | new_line = "- [#{item_title}](#{category}/#{CGI.escape(filename)})" 68 | 69 | if next_bound 70 | new_readme_content = @initial_content[0..(first_dashdashdash + 2)] \ 71 | + "\n\n#{new_categories_formatted}\n" \ 72 | + @initial_content[eend..(next_bound - 2)] \ 73 | + "\n### #{category.capitalize}\n\n#{new_line}\n\n" \ 74 | + @initial_content[next_bound..] 75 | else 76 | new_readme_content = @initial_content[0..(first_dashdashdash + 2)] \ 77 | + "\n\n#{new_categories_formatted}\n" \ 78 | + @initial_content[eend..] \ 79 | + "\n### #{category.capitalize}\n\n#{new_line}\n" 80 | end 81 | 82 | new_readme_content 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/til/version.rb: -------------------------------------------------------------------------------- 1 | module Til 2 | 3 | VERSION = '0.0.8' 4 | 5 | end 6 | -------------------------------------------------------------------------------- /test/readme_updater_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'timecop' 3 | 4 | describe Til::ReadmeUpdater do 5 | 6 | before do 7 | @initial_content = <<~CONTENT 8 | # TIL 9 | 10 | --- 11 | 12 | ### Categories 13 | 14 | * [Git](#git) 15 | * [Git2](#git2) 16 | * [Javascript](#javascript) 17 | 18 | --- 19 | 20 | ### Git 21 | 22 | - [a](git/2020-06-16_a.md) 23 | 24 | ### Git2 25 | 26 | - [a](git2/2020-06-16_a.md) 27 | 28 | ### Javascript 29 | 30 | - [c](javascript/2020-06-21_c.md) 31 | CONTENT 32 | end 33 | 34 | describe 'add_item_for_existing_category' do 35 | it 'works with a category that is not last' do 36 | updater = Til::ReadmeUpdater.new(@initial_content) 37 | expected_string = <<~CONTENT 38 | # TIL 39 | 40 | --- 41 | 42 | ### Categories 43 | 44 | * [Git](#git) 45 | * [Git2](#git2) 46 | * [Javascript](#javascript) 47 | 48 | --- 49 | 50 | ### Git 51 | 52 | - [a](git/2020-06-16_a.md) 53 | - [e](git/2020-06-24_e.md) 54 | 55 | ### Git2 56 | 57 | - [a](git2/2020-06-16_a.md) 58 | 59 | ### Javascript 60 | 61 | - [c](javascript/2020-06-21_c.md) 62 | CONTENT 63 | 64 | assert_equal(expected_string, updater.add_item_for_existing_category('git', 'e', '2020-06-24_e.md')) 65 | end 66 | 67 | it 'works with a category that is not last and a title with url encoded characters' do 68 | updater = Til::ReadmeUpdater.new(@initial_content) 69 | expected_string = <<~CONTENT 70 | # TIL 71 | 72 | --- 73 | 74 | ### Categories 75 | 76 | * [Git](#git) 77 | * [Git2](#git2) 78 | * [Javascript](#javascript) 79 | 80 | --- 81 | 82 | ### Git 83 | 84 | - [a](git/2020-06-16_a.md) 85 | - [Ruby 2.7 adds Enumerable#filter_map](git/2020-06-25_ruby-2.7-adds-enumerable%23filter_map.md) 86 | 87 | ### Git2 88 | 89 | - [a](git2/2020-06-16_a.md) 90 | 91 | ### Javascript 92 | 93 | - [c](javascript/2020-06-21_c.md) 94 | CONTENT 95 | 96 | assert_equal(expected_string, updater.add_item_for_existing_category('git', 'Ruby 2.7 adds Enumerable#filter_map', '2020-06-25_ruby-2.7-adds-enumerable#filter_map.md')) 97 | end 98 | 99 | 100 | it 'works with the last category' do 101 | updater = Til::ReadmeUpdater.new(@initial_content) 102 | expected_string = <<~CONTENT 103 | # TIL 104 | 105 | --- 106 | 107 | ### Categories 108 | 109 | * [Git](#git) 110 | * [Git2](#git2) 111 | * [Javascript](#javascript) 112 | 113 | --- 114 | 115 | ### Git 116 | 117 | - [a](git/2020-06-16_a.md) 118 | 119 | ### Git2 120 | 121 | - [a](git2/2020-06-16_a.md) 122 | 123 | ### Javascript 124 | 125 | - [c](javascript/2020-06-21_c.md) 126 | - [e](javascript/2020-06-24_e.md) 127 | CONTENT 128 | 129 | assert_equal(expected_string, updater.add_item_for_existing_category('javascript', 'e', '2020-06-24_e.md')) 130 | end 131 | 132 | end 133 | 134 | describe 'add_item_for_new_category' do 135 | it 'works with a category that does not end up last or first' do 136 | updater = Til::ReadmeUpdater.new(@initial_content) 137 | expected_string = <<~CONTENT 138 | # TIL 139 | 140 | --- 141 | 142 | ### Categories 143 | 144 | * [Git](#git) 145 | * [Git2](#git2) 146 | * [Haskell](#haskell) 147 | * [Javascript](#javascript) 148 | 149 | --- 150 | 151 | ### Git 152 | 153 | - [a](git/2020-06-16_a.md) 154 | 155 | ### Git2 156 | 157 | - [a](git2/2020-06-16_a.md) 158 | 159 | ### Haskell 160 | 161 | - [e](haskell/2020-06-24_e.md) 162 | 163 | ### Javascript 164 | 165 | - [c](javascript/2020-06-21_c.md) 166 | CONTENT 167 | 168 | assert_equal(expected_string, updater.add_item_for_new_category('haskell', 'e', '2020-06-24_e.md')) 169 | end 170 | 171 | it 'works with a category that is not last and a title with url encoded characters' do 172 | updater = Til::ReadmeUpdater.new(@initial_content) 173 | expected_string = <<~CONTENT 174 | # TIL 175 | 176 | --- 177 | 178 | ### Categories 179 | 180 | * [Git](#git) 181 | * [Git2](#git2) 182 | * [Haskell](#haskell) 183 | * [Javascript](#javascript) 184 | 185 | --- 186 | 187 | ### Git 188 | 189 | - [a](git/2020-06-16_a.md) 190 | 191 | ### Git2 192 | 193 | - [a](git2/2020-06-16_a.md) 194 | 195 | ### Haskell 196 | 197 | - [Ruby 2.7 adds Enumerable#filter_map](haskell/2020-06-25_ruby-2.7-adds-enumerable%23filter_map.md) 198 | 199 | ### Javascript 200 | 201 | - [c](javascript/2020-06-21_c.md) 202 | CONTENT 203 | 204 | assert_equal(expected_string, updater.add_item_for_new_category('haskell', 'Ruby 2.7 adds Enumerable#filter_map', '2020-06-25_ruby-2.7-adds-enumerable#filter_map.md')) 205 | end 206 | 207 | 208 | it 'works with a category that ends up first' do 209 | updater = Til::ReadmeUpdater.new(@initial_content) 210 | expected_string = <<~CONTENT 211 | # TIL 212 | 213 | --- 214 | 215 | ### Categories 216 | 217 | * [Bash](#bash) 218 | * [Git](#git) 219 | * [Git2](#git2) 220 | * [Javascript](#javascript) 221 | 222 | --- 223 | 224 | ### Bash 225 | 226 | - [e](bash/2020-06-24_e.md) 227 | 228 | ### Git 229 | 230 | - [a](git/2020-06-16_a.md) 231 | 232 | ### Git2 233 | 234 | - [a](git2/2020-06-16_a.md) 235 | 236 | ### Javascript 237 | 238 | - [c](javascript/2020-06-21_c.md) 239 | CONTENT 240 | 241 | assert_equal(expected_string, updater.add_item_for_new_category('bash', 'e', '2020-06-24_e.md')) 242 | end 243 | 244 | it 'works with a category that ends up last' do 245 | updater = Til::ReadmeUpdater.new(@initial_content) 246 | expected_string = <<~CONTENT 247 | # TIL 248 | 249 | --- 250 | 251 | ### Categories 252 | 253 | * [Git](#git) 254 | * [Git2](#git2) 255 | * [Javascript](#javascript) 256 | * [Zsh](#zsh) 257 | 258 | --- 259 | 260 | ### Git 261 | 262 | - [a](git/2020-06-16_a.md) 263 | 264 | ### Git2 265 | 266 | - [a](git2/2020-06-16_a.md) 267 | 268 | ### Javascript 269 | 270 | - [c](javascript/2020-06-21_c.md) 271 | 272 | ### Zsh 273 | 274 | - [e](zsh/2020-06-24_e.md) 275 | CONTENT 276 | 277 | assert_equal(expected_string, updater.add_item_for_new_category('zsh', 'e', '2020-06-24_e.md')) 278 | end 279 | end 280 | end 281 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../lib', __dir__) 2 | require 'til-rb' 3 | 4 | require 'minitest/autorun' 5 | require 'mocha/minitest' 6 | -------------------------------------------------------------------------------- /test/til_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'timecop' 3 | 4 | describe Til::Core do 5 | it 'has a run method' do 6 | github_client_mock = Minitest::Mock.new 7 | github_client_mock.expect :contents, [], ['pjambet/til', { path: '' }] 8 | 9 | Process.stubs(:spawn).returns(12) 10 | 11 | Til::Core.new( 12 | process: Process, 13 | stderr: StringIO.new, 14 | env: { 'TIL_RB_GITHUB_TOKEN' => 'abc', 'TIL_RB_GITHUB_REPO' => 'pjambet/til' }, 15 | github_client: github_client_mock, 16 | ).run 17 | end 18 | 19 | it 'exits if TIL_RB_GITHUB_TOKEN is nil or empty' do 20 | error = assert_raises RuntimeError do 21 | Til::Core.new(env: {}).run 22 | end 23 | assert_match 'The TIL_RB_GITHUB_TOKEN (with the public_repo or repo scope) environment variable is required', 24 | error.message 25 | end 26 | 27 | it 'logs a warning if the deprecated GH_TOKEN is used' do 28 | Process.stubs(:spawn).returns(12) 29 | github_client_mock = mock 30 | github_client_mock.expects(:contents).with('a/b', { path: '' }).returns([]).once 31 | stderr = StringIO.new 32 | 33 | Til::Core.new( 34 | env: { 'GH_TOKEN' => 'abc', 'TIL_RB_GITHUB_REPO' => 'a/b' }, 35 | stderr: stderr, 36 | github_client: github_client_mock, 37 | ).run 38 | 39 | stderr.rewind 40 | assert_match(/\AUsing GH_TOKEN is deprecated, use TIL_RB_GITHUB_TOKEN instead$/, 41 | stderr.read) 42 | end 43 | 44 | it 'exits if TIL_RB_GITHUB_REPO is nil or empty' do 45 | error = assert_raises RuntimeError do 46 | Til::Core.new(env: { 'TIL_RB_GITHUB_TOKEN' => 'abc' }).run 47 | end 48 | assert_match 'The TIL_RB_GITHUB_REPO environment variable is required', error.message 49 | end 50 | 51 | it 'logs a warning if the deprecated GH_REPO is used' do 52 | Process.stubs(:spawn).returns(12) 53 | github_client_mock = mock 54 | github_client_mock.expects(:contents).with('a/b', { path: '' }).returns([]).once 55 | stderr = StringIO.new 56 | 57 | Til::Core.new( 58 | env: { 'GH_REPO' => 'a/b', 'TIL_RB_GITHUB_TOKEN' => 'abc' }, 59 | stderr: stderr, 60 | github_client: github_client_mock, 61 | ).run 62 | 63 | stderr.rewind 64 | assert_match(/\AUsing GH_REPO is deprecated, use TIL_RB_GITHUB_REPO instead$/, 65 | stderr.read) 66 | end 67 | 68 | it 'exits if fzf is not available' do 69 | error = assert_raises RuntimeError do 70 | kernel_mock = Minitest::Mock.new 71 | kernel_mock.expect :system, false, ['which fzf', { out: '/dev/null', err: '/dev/null' }] 72 | Til::Core.new(kernel: kernel_mock, env: { 'TIL_RB_GITHUB_TOKEN' => 'abc' }).run 73 | end 74 | assert_match "fzf is required, you can install it on macOS with 'brew install fzf'", error.message 75 | end 76 | 77 | it 'does not escape URLs in filenames' do 78 | # Yeah yeah, I'm testing a private methode, it's 'wrong', but *shrug* 79 | til = Til::Core.new( 80 | env: { 'TIL_RB_GITHUB_TOKEN' => 'abc', 'TIL_RB_GITHUB_REPO' => 'pjambet/til' }, 81 | ) 82 | 83 | Timecop.freeze(Time.local(2020, 6, 25, 12, 0, 0)) do 84 | filename = til.send :new_filename, 'Ruby 2.7 adds Enumerable#filter_map' 85 | 86 | assert_equal '2020-06-25_ruby-2.7-adds-enumerable#filter_map.md', filename 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /til-rb.gemspec: -------------------------------------------------------------------------------- 1 | require_relative 'lib/til/version' 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'til-rb' 5 | s.version = Til::VERSION 6 | s.executables << 'til' 7 | s.date = '2020-06-24' 8 | s.summary = 'A utility to manage a repo of TILs' 9 | s.description = 'til-rb helps you manage a repo of TILs similar to https://github.com/jbranchaud/til' 10 | s.authors = ['Pierre Jambet'] 11 | s.email = 'hello@pjam.me' 12 | s.files = Dir.glob('{bin,lib}/**/*') + %w(LICENSE README.md) - %w(til.gif) 13 | s.homepage = 'https://github.com/pjambet/til-rb/' 14 | s.license = 'MIT' 15 | s.add_runtime_dependency 'octokit', '~> 4.0' 16 | s.add_development_dependency 'mocha', '~> 1.11.2' 17 | s.add_development_dependency 'timecop', '~> 0.9.1' 18 | end 19 | -------------------------------------------------------------------------------- /til.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjambet/til-rb/0e19b72ea04aecf8128470989727e841d36f5e80/til.gif --------------------------------------------------------------------------------