├── .github ├── pull_request_template.md └── workflows │ └── rspec.yml ├── .gitignore ├── .rspec ├── .travis.yml ├── CONTRIBUTORS ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── dev_t ├── t └── timetrap ├── completions ├── bash │ └── timetrap-autocomplete.bash └── zsh │ └── _t ├── lib ├── Getopt │ ├── Declare.rb │ └── DelimScanner.rb ├── timetrap.rb └── timetrap │ ├── auto_sheets.rb │ ├── auto_sheets │ ├── dotfiles.rb │ ├── nested_dotfiles.rb │ └── yaml_cwd.rb │ ├── cli.rb │ ├── config.rb │ ├── formatters.rb │ ├── formatters │ ├── csv.rb │ ├── factor.rb │ ├── ical.rb │ ├── ids.rb │ ├── json.rb │ └── text.rb │ ├── helpers.rb │ ├── models.rb │ ├── schema.rb │ ├── timer.rb │ └── version.rb ├── spec ├── dotfile │ ├── .timetrap-sheet │ └── nested │ │ ├── .timetrap-sheet │ │ └── no-sheet │ │ └── .gitkeep ├── spec_helper.rb ├── support │ ├── local_time.rb │ ├── with_rounding_on.rb │ └── with_stubbed_config.rb └── timetrap_spec.rb ├── timetrap-1.14.3.gem └── timetrap.gemspec /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Motivation and Context 7 | 8 | 9 | 10 | ## How Has This Been Tested? 11 | 12 | 13 | 14 | 15 | ## Screenshots (if appropriate): 16 | 17 | ## Types of changes 18 | 19 | - [ ] Bug fix (non-breaking change which fixes an issue) 20 | - [ ] New feature (non-breaking change which adds functionality) 21 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 22 | 23 | ## Checklist: 24 | 25 | 26 | - [ ] My code follows the code style of this project. 27 | - [ ] I have updated the documentation accordingly. 28 | - [ ] I have added tests to cover my changes. 29 | - [ ] All new and existing tests passed. 30 | -------------------------------------------------------------------------------- /.github/workflows/rspec.yml: -------------------------------------------------------------------------------- 1 | name: rspec 2 | on: 3 | - push 4 | - pull_request 5 | 6 | jobs: 7 | test: 8 | name: Run Rspec 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: ruby/setup-ruby@v1 13 | with: 14 | ruby-version: '2.7' 15 | - run: bundle install 16 | - run: rspec spec 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tags 2 | pkg 3 | *.swp 4 | Gemfile.lock 5 | .bundle 6 | vendor/bundle 7 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | before_install: 2 | - gem uninstall -v '< 2' -i $(rvm gemdir)@global -ax bundler || true 3 | - gem install bundler -v '~> 2' 4 | language: ruby 5 | rvm: 6 | - 2.4.0 7 | - 2.5.0 8 | - 2.6.0 9 | - 2.7.0 10 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | * Sam Goldstein 2 | * Ian Smith-Heisters 3 | * Ammar Ali 4 | * Brian V. Hughes 5 | * Thorben Schröder 6 | * Ryan Funduk 7 | * Christopher Peplin 8 | * Johan Venant 9 | * Steven Ghyselbrecht 10 | * Matthew M. Keeler 11 | * Toby Foster 12 | * Michael Moen 13 | * Miles Matthias 14 | * Devon Blandin (@dblandin) 15 | * Marc Addeo 16 | * Patrick Davey 17 | * Kyle Brett 18 | * Nam D. Nguyen (namdnguyen) 19 | * Bèr Kessels (berkes) 20 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | http://www.opensource.org/licenses/mit-license.php 2 | 3 | The MIT License 4 | 5 | Copyright (c) 2009 Sam Goldstein 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Timetrap [![Build Status](https://secure.travis-ci.org/samg/timetrap.png)](http://travis-ci.org/samg/timetrap) 2 | ======== 3 | 4 | Timetrap is a simple command line time tracker written in ruby. It provides an 5 | easy to use command line interface for tracking what you spend your time on. 6 | 7 | Getting Started 8 | --------------- 9 | 10 | To install: 11 | 12 | $ gem install timetrap 13 | 14 | This will place a ``t`` executable in your path. 15 | 16 | If you would like to build the latest master from source: 17 | 18 | $ git clone https://github.com/samg/timetrap 19 | $ cd timetrap 20 | $ gem build timetrap 21 | $ gem install timetrap_X.X.X.gem 22 | 23 | If you have errors while parsing the documentation, use `--no-document` option when installing the gem, or other option is to `gem install rdoc` before installing the `timetrap`. This is a known issue from [rdoc](https://github.com/ruby/rdoc/commit/5f9603f35d8e520c761015810c005e4a5beb97c3) 24 | 25 | ### Basic Usage 26 | 27 | $ # get help 28 | $ timetrap --help 29 | $ # or 30 | $ t --help 31 | 32 | Timetrap maintains a list of *timesheets*. 33 | 34 | $ # create the "coding" timesheet 35 | $ t sheet coding 36 | Switching to sheet coding 37 | 38 | All commands can be abbreviated. 39 | 40 | $ # same as "t sheet coding" 41 | $ t s coding 42 | Switching to sheet coding 43 | 44 | Each timesheet contains *entries*. Each entry has a start and end time, and a 45 | note associated with it. An entry without an end time set is considered to be 46 | running. 47 | 48 | You check in to the current sheet with the `in` command. 49 | 50 | $ # check in with "document timetrap" note 51 | $ t in document timetrap 52 | Checked into sheet "coding". 53 | 54 | Commands like `display` and `now` will show you the running entry. 55 | 56 | $ t display 57 | Timesheet: coding 58 | Day Start End Duration Notes 59 | Sun Nov 28, 2010 12:26:10 - 0:00:03 document timetrap 60 | 0:00:03 61 | --------------------------------------------------------- 62 | Total 0:00:03 63 | 64 | $ t now 65 | *coding: 0:01:02 (document timetrap) 66 | 67 | If you make a mistake use the `edit` command. 68 | 69 | $ # edit the running entry's note 70 | $ t edit writing readme 71 | Editing running entry 72 | 73 | You check out with the `out` command. 74 | 75 | $ t out 76 | Checked out of entry "document timetrap" in sheet "coding" 77 | 78 | Running `edit` when you're checked out will edit the last entry you checked out 79 | of. 80 | 81 | $ t edit --append "oh and that" 82 | Editing last entry you checked out of 83 | 84 | You can edit entries that aren't running using `edit`'s `--id` or `-i` flag. 85 | `t display --ids` (or `t display -v`) will tell you the ids. 86 | 87 | $ # note id column in output 88 | $ t d -v 89 | Timesheet: coding 90 | Id Day Start End Duration Notes 91 | 43 Sun Nov 28, 2010 12:26:10 - 13:41:03 1:14:53 writing readme 92 | 1:14:53 93 | --------------------------------------------------------- 94 | Total 1:14:53 95 | 96 | $ # -i43 to edit entry 43 97 | $ t e -i43 --end "2010-11-28 13:45" 98 | Editing entry with id 43 99 | 100 | $ t d 101 | Timesheet: coding 102 | Day Start End Duration Notes 103 | Sun Nov 28, 2010 12:26:10 - 13:45:00 1:18:50 writing readme 104 | 1:18:50 105 | --------------------------------------------------------- 106 | Total 1:18:50 107 | 108 | 109 | ### Natural Language Times 110 | 111 | Commands such as `in`, `out`, `edit`, and `display` have flags that accept 112 | times as arguments. Any time you pass Timetrap a time it will try to parse it 113 | as a natural language time. 114 | 115 | This is very handy if you start working and forget to start Timetrap. You can 116 | check in 5 minutes ago using `in`'s `--at` flag. 117 | 118 | $ t in --at "5 minutes ago" 119 | 120 | Command line flags also have short versions. 121 | 122 | $ # equivalent to the command above 123 | $ t i -a "5 minutes ago" 124 | 125 | You can consult the Chronic gem (https://github.com/mojombo/chronic) for a full 126 | list of parsable time formats, but all of these should work. 127 | 128 | $ t out --at "in 30 minutes" 129 | $ t edit --start "last monday at 10:30am" 130 | $ t edit --end "tomorrow at noon" 131 | $ t display --start "10am" --end "2pm" 132 | $ t i -a "2010-11-29 12:30:00" 133 | 134 | ### Output Formats 135 | 136 | #### Built-in Formatters 137 | 138 | Timetrap has built-in support for 6 output formats. 139 | 140 | These are **text**, **csv**, **ical**, **json**, and **ids** 141 | 142 | The default is a plain **text** format. (You can change the default format using 143 | `t configure`). 144 | 145 | $ t display 146 | Timesheet: coding 147 | Day Start End Duration Notes 148 | Mon Apr 13, 2009 15:46:51 - 17:03:50 1:16:59 improved display functionality 149 | 17:25:59 - 17:26:02 0:00:03 150 | 18:38:07 - 18:38:52 0:00:45 working on list 151 | 22:37:38 - 23:38:43 1:01:05 work on kill 152 | 2:18:52 153 | Tue Apr 14, 2009 00:41:16 - 01:40:19 0:59:03 gem packaging 154 | 10:20:00 - 10:48:10 0:28:10 working on readme 155 | 1:27:13 156 | --------------------------------------------------------- 157 | Total 3:46:05 158 | 159 | The **CSV** formatters is easy to import into a spreadsheet. 160 | 161 | $ t display --format csv 162 | start,end,note,sheet 163 | "2010-08-21 11:19:05","2010-08-21 12:12:04","migrated site","coding" 164 | "2010-08-21 12:44:09","2010-08-21 12:48:46","DNS emails and install email packages","coding" 165 | "2010-08-21 12:49:57","2010-08-21 13:10:12","A records","coding" 166 | "2010-08-21 15:09:37","2010-08-21 16:32:26","setup for wiki","coding" 167 | "2010-08-25 20:42:55","2010-08-25 21:41:49","rewrote index","coding" 168 | "2010-08-29 15:44:39","2010-08-29 16:21:53","recaptcha","coding" 169 | "2010-08-29 21:15:58","2010-08-29 21:30:31","backups","coding" 170 | "2010-08-29 21:40:56","2010-08-29 22:32:26","backups","coding" 171 | 172 | **iCal** format lets you get your time into your favorite calendar program 173 | (remember commands can be abbreviated). 174 | 175 | $ t d -f ical > MyTimeSheet.ics 176 | 177 | The **ids** formatter is provided to facilitate scripting within timetrap. It only 178 | outputs numeric id for the entries. This is handy if you want to move all entries 179 | from one sheet to another sheet. You could do something like this: 180 | 181 | $ for id in `t display sheet1 -f ids`; do t edit --id $id --move sheet2; done 182 | editing entry #36 183 | editing entry #37 184 | editing entry #44 185 | editing entry #46 186 | 187 | A *json* formatter is provided because hackers love json. 188 | 189 | $ t d -fjson 190 | 191 | #### Custom Formatters 192 | 193 | Timetrap tries to make it easy to define custom output formats. 194 | 195 | You're encouraged to submit these back to timetrap for inclusion in a future 196 | version. 197 | 198 | To create a custom formatter you create a ruby class and implement two methods 199 | on it. 200 | 201 | As an example we'll create a formatter that only outputs the notes from 202 | entries. 203 | 204 | To ensure that timetrap can find your formatter put it in 205 | `~/.timetrap/formatters/notes.rb`. The filename should be the same as the 206 | string you will pass to `t d --format` to invoke it. If you want to put your 207 | formatter in a different place you can run `t configure` and edit the 208 | `formatter_search_paths` option. 209 | 210 | All timetrap formatters live under the namespace `Timetrap::Formatters` so 211 | define your class like this: 212 | 213 | ```ruby 214 | class Timetrap::Formatters::Notes 215 | end 216 | ``` 217 | 218 | When `t display` is invoked, timetrap initializes a new instance of the 219 | formatter passing it an Array of entries. It then calls `#output` which should 220 | return a string to be printed to the screen. 221 | 222 | This means we need to implement an `#initialize` method and an `#output` 223 | method for the class. Something like this: 224 | 225 | ```ruby 226 | class Timetrap::Formatters::Notes 227 | def initialize(entries) 228 | @entries = entries 229 | end 230 | 231 | def output 232 | @entries.map{|entry| entry[:note]}.join("\n") 233 | end 234 | end 235 | ``` 236 | 237 | Now when I invoke it: 238 | 239 | $ t d -f notes 240 | working on issue #123 241 | working on issue #234 242 | 243 | #### Timetrap Formatters Repository 244 | 245 | A community focused repository of custom formatters is available at 246 | https://github.com/samg/timetrap_formatters. 247 | 248 | #### Harvest Integration 249 | 250 | For timetrap users who use [Harvest][harvest] to manage timesheets, 251 | [Devon Blandin][dblandin] created [timetrap-harvest][timetrap-harvest], a custom 252 | formatter which allows you to easily submit your timetrap entries to Harvest 253 | timesheets. 254 | 255 | See its [README][timetrap-harvest] for more details. 256 | 257 | #### Toggl Integration 258 | 259 | For timetrap users who use [Toggl][toggl] to manage timesheets, 260 | [Miguel Palhas][naps62] created [timetrap-toggl][timetrap-toggl] (a fork of the 261 | [timetrap-harvest][timetrap-harvest] integration mentioned above. 262 | 263 | Like the Harvest integration, this one allows you to easily submit your timetrap entries to Toggl. 264 | 265 | See its [README][timetrap-toggl] for more details. 266 | 267 | ### AutoSheets 268 | 269 | Timetrap has a feature called auto sheets that allows you to automatically 270 | select which timesheet to check into. 271 | 272 | Timetrap ships with a couple auto sheets. The default auto sheet is called 273 | `dotfiles` and will read the sheetname to check into from a `.timetrap-sheet` 274 | file in the current directory. 275 | 276 | [Here are all the included auto sheets](lib/timetrap/auto_sheets) 277 | 278 | You can specify which auto sheet logic you want to use in `~/.timetrap.yml` by 279 | changing the `auto_sheet` value. 280 | 281 | #### Custom AutoSheets 282 | 283 | It's also easy to write your own auto sheet logic that matches your personal 284 | workflow. You're encouraged to submit these back to timetrap for inclusion in 285 | a future version. 286 | 287 | To create a custom auto sheet module you create a ruby class and implement one 288 | method on it `#sheet`. This method should return the name of the sheet 289 | timetrap should use (as a string) or `nil` if a sheet shouldn't be 290 | automatically selected. 291 | 292 | All timetrap auto sheets live under the namespace `Timetrap::AutoSheets` 293 | 294 | To ensure that timetrap can find your auto sheet put it in 295 | `~/.timetrap/auto_sheets/`. The filename should be the same as the 296 | string you will set in the configuration (for example 297 | `~/.timetrap/auto_sheets/dotfiles.rb`. If you want to put your auto sheet in a 298 | different place you can run `t configure` and edit the 299 | `auto_sheet_search_paths` option. 300 | 301 | As an example here's the dotfiles auto sheet 302 | 303 | ```ruby 304 | module Timetrap 305 | module AutoSheets 306 | class Dotfiles 307 | def sheet 308 | dotfile = File.join(Dir.pwd, '.timetrap-sheet') 309 | File.read(dotfile).chomp if File.exist?(dotfile) 310 | end 311 | end 312 | end 313 | end 314 | ``` 315 | 316 | Commands 317 | -------- 318 | 319 | **archive** 320 | Archive the selected entries (by moving them to a sheet called ``_[SHEET]``) 321 | These entries can be seen by running ``t display _[SHEET]``. 322 | 323 | usage: ``t archive [--start DATE] [--end DATE] [--grep REGEX] [SHEET]`` 324 | 325 | **backend** 326 | Run an interactive database session on the timetrap database. Requires the 327 | sqlite3 command. 328 | 329 | usage: ``t backend`` 330 | 331 | **configure** 332 | Create a config file at ``~/.timetrap.yml`` or ``ENV['TIMETRAP_CONFIG_FILE']`` if 333 | one doesn't exist. If one does exist, update it with new 334 | configuration options preserving any user overrides. Prints path to config 335 | file. This file may contain ERB. 336 | 337 | usage: ``t configure`` 338 | 339 | **display** 340 | Display a given timesheet. If no timesheet is specified, show the current 341 | timesheet. If ``all`` is passed as SHEET display all timesheets. If ``full`` 342 | is passed as SHEET archived timesheets are displayed as well. Accepts an 343 | optional ``--ids`` flag which will include the entries' ids in the output. 344 | This is useful when editing an non running entry with ``edit``. 345 | 346 | Display is designed to support a variety of export formats that can be 347 | specified by passing the ``--format`` flag. This currently defaults to 348 | text. iCal, csv, json, and numeric id output are also supported. 349 | 350 | Display also allows the use of a ``--round`` or ``-r`` flag which will round 351 | all times in the output. See global options below. 352 | 353 | usage: ``t display [--ids] [--round] [--start DATE] [--end DATE] [--format FMT] [--grep REGEX] [SHEET | all | full]`` 354 | 355 | **edit** 356 | Insert a note associated with the an entry in the timesheet, or edit the 357 | start or end times. Defaults to the current entry, or previously running 358 | entry. An ``--id`` flag can be passed with the entry's id (see display.) 359 | 360 | usage: ``t edit [--id ID] [--start TIME] [--end TIME] [--append] [NOTES]`` 361 | 362 | **in** 363 | Start the timer for the current timesheet. Must be called before out. Notes 364 | may be specified for this period. This is exactly equivalent to 365 | ``t in; t edit NOTES``. Accepts an optional --at flag. 366 | 367 | usage: ``t in [--at TIME] [NOTES]`` 368 | 369 | **kill** 370 | Delete a timesheet or an entry. Entries are referenced using an ``--id`` 371 | flag (see display). Sheets are referenced by name. 372 | 373 | usage: ``t kill [--id ID] [TIMESHEET]`` 374 | 375 | **list** 376 | List the available timesheets. 377 | 378 | usage: ``t list`` 379 | 380 | **now** 381 | Print a description of all running entries. 382 | 383 | usage: ``t now`` 384 | 385 | **out** 386 | Stop the timer for the current timesheet. Must be called after in. Accepts an 387 | optional --at flag. Accepts an optional TIMESHEET name to check out of a 388 | running, non-current sheet. Will check out of all running sheets if the 389 | auto_checkout configuration option is enabled. 390 | 391 | usage: ``t out [--at TIME] [TIMESHEET]`` 392 | 393 | **resume** 394 | Start the timer for the current time sheet for an entry. Defaults to the 395 | active entry. 396 | 397 | usage: ``t resume [--id ID] [--at TIME]`` 398 | 399 | **sheet** 400 | Switch to a timesheet creating it if necessary. The default timesheet is 401 | called "default". When no sheet is specified list all existing sheets. 402 | The special timesheet name '-' will switch to the last active sheet. 403 | 404 | usage: ``t sheet [TIMESHEET]`` 405 | 406 | **today** 407 | Shortcut for display with start date as the current day 408 | 409 | usage: ``t today [--ids] [--format FMT] [SHEET | all]`` 410 | 411 | **yesterday** 412 | Shortcut for display with start and end dates as the day before the current 413 | day 414 | 415 | usage: ``t yesterday [--ids] [--format FMT] [SHEET | all]`` 416 | 417 | **week** 418 | Shortcut for display with start date set to a day of this week. The default 419 | start of the week is Monday. 420 | 421 | usage: ``t week [--ids] [--end DATE] [--format FMT] [TIMESHEET | all]`` 422 | 423 | **month** 424 | Shortcut for display with start date set to the beginning of this month 425 | (or a specified month) and end date set to the end of the month. 426 | 427 | usage: ``t month [--ids] [--start MONTH] [--format FMT] [TIMESHEET | all]`` 428 | 429 | 430 | ### Global Options 431 | 432 | **rounding** 433 | passing a ``--round`` or ``-r`` flag to any command will round entry start 434 | and end times to the closest 15 minute increment. This flag only affects the 435 | display commands (e.g. display, list, week, etc.) and is non-destructive. 436 | The actual start and end time stored by Timetrap are unaffected. 437 | 438 | See `configure` command to change rounding increment from 15 minutes. 439 | 440 | **non-interactive** 441 | passing a ``--yes`` or ``-y`` flag will cause any command that requires 442 | confirmation (such as ``kill``) to assume an affirmative response to any 443 | prompt. This is useful when timetrap is used in a scripted environment. 444 | 445 | ### Configuration 446 | 447 | Configuration of Timetrap's behavior can be done through an ERB interpolated 448 | YAML config file. 449 | 450 | See ``t configure`` for details. Currently supported options are: 451 | 452 | **round_in_seconds**: The duration of time to use for rounding with the -r flag 453 | 454 | **database_file**: The file path of the sqlite database 455 | 456 | **append_notes_delimiter**: delimiter used when appending notes via 457 | `t edit --append` 458 | 459 | **formatter_search_paths**: an array of directories to search for user 460 | defined fomatter classes 461 | 462 | **default_formatter**: The format to use when display is invoked without a 463 | `--format` option 464 | 465 | **default_command**: The default command to invoke when you call `t` 466 | 467 | **auto_checkout**: Automatically check out of running entries when you check 468 | in or out 469 | 470 | **require_note**: Prompt for a note if one isn't provided when checking in 471 | 472 | **auto_sheet**: Which auto sheet module to use. 473 | 474 | **auto_sheet_search_paths**: an array of directories to search for user 475 | defined auto_sheet classes 476 | 477 | **note_editor**: The command to start editing notes. Defaults to false which 478 | means no external editor is used. Please see the section below 479 | on Notes Editing for tips on using non-terminal based editors. 480 | Example: note_editor: "vim" 481 | 482 | **week_start**: The day of the week to use as the start of the week for t week. 483 | 484 | ### Autocomplete 485 | 486 | Timetrap has some basic support for autocomplete in bash and zsh. 487 | There are completions for commands and for sheets. 488 | 489 | **HINT** If you don't know where timetrap is installed, 490 | have a look in the directories listed in `echo $GEM_PATH`. 491 | 492 | #### bash 493 | 494 | If it isn't already, add the following to your `.bashrc`/`.bash_profile`: 495 | 496 | ```bash 497 | if [ -f /etc/bash_completion ]; then 498 | . /etc/bash_completion 499 | fi 500 | ``` 501 | 502 | Then add this to source the completions: 503 | 504 | ```bash 505 | source /path/to/timetrap-1.x.y/gem/completions/bash/timetrap-autocomplete.bash 506 | ``` 507 | 508 | #### zsh 509 | 510 | If it isn't already, add the following to your `.zshrc`: 511 | 512 | ```bash 513 | autoload -U compinit 514 | compinit 515 | ``` 516 | 517 | Then add this to source the completions: 518 | 519 | ```bash 520 | fpath=(/path/to/timetrap-1.x.y/gem/completions/zsh $fpath) 521 | ``` 522 | 523 | #### Notes editing 524 | 525 | If you use the note_editor setting, then it is possible to use 526 | an editor for writing your notes. If you use a non terminal based 527 | editor (like atom, sublime etc.) then you will need to make timetrap 528 | wait until the editor has finished. If you're using the "core.editor" 529 | flag in git, then it'll be the same flags you'll use. 530 | 531 | As of when this command was added, for atom you would use `atom --wait` 532 | and for sublime `subl -w`. If you use a console based editor (vim, emacs, 533 | nano) then it should just work. 534 | 535 | Development 536 | ----------- 537 | 538 | Get `bundler` in case you don't have it: 539 | 540 | gem install bundler 541 | 542 | Set a local path for the project's dependencies: 543 | 544 | bundle config set --local path 'vendor/bundle' 545 | 546 | Install timetrap's dependencies: 547 | 548 | bundle install 549 | 550 | Now you can run your local timetrap installation: 551 | 552 | bundle exec t 553 | 554 | Or run the test suite: 555 | 556 | bundle exec rspec 557 | 558 | Special Thanks 559 | -------------- 560 | 561 | The initial version of Timetrap was heavily inspired by Trevor Caira's 562 | Timebook, a small python utility. 563 | 564 | Original Timebook available at: 565 | https://github.com/trevorc/timebook 566 | 567 | Bugs and Feature Requests 568 | -------- 569 | Submit to http://github.com/samg/timetrap/issues 570 | 571 | [harvest]: http://www.getharvest.com 572 | [timetrap-harvest]: https://github.com/dblandin/timetrap-harvest 573 | [dblandin]: https://github.com/dblandin 574 | [toggl]: https://toggl.com 575 | [timetrap-toggl]: https://github.com/naps62/timetrap-toggl 576 | [naps62]: https://github.com/naps62 577 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems/package_task' 2 | require 'rspec/core/rake_task' 3 | begin 4 | # use psych for YAML parsing if available 5 | require 'psych' 6 | rescue LoadError 7 | # use syck 8 | end 9 | 10 | desc 'Default: run specs.' 11 | task :default => :spec 12 | 13 | desc "Run specs" 14 | RSpec::Core::RakeTask.new do |t| 15 | t.pattern = "./spec/**/*_spec.rb" # don't need this, it's default. 16 | # Put spec opts in a file named .rspec in root 17 | end 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /bin/dev_t: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # Executable with absolute path to lib for hacking and development 3 | require File.expand_path(File.join( File.dirname(__FILE__), '..', 'lib', 'timetrap')) 4 | Timetrap::CLI.invoke 5 | -------------------------------------------------------------------------------- /bin/t: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.expand_path(File.dirname(__FILE__) + '/../lib/timetrap') 3 | Timetrap::CLI.invoke 4 | -------------------------------------------------------------------------------- /bin/timetrap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.expand_path(File.dirname(__FILE__) + '/../lib/timetrap') 3 | Timetrap::CLI.invoke 4 | -------------------------------------------------------------------------------- /completions/bash/timetrap-autocomplete.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | _timetrap () 3 | { 4 | cur="${COMP_WORDS[COMP_CWORD]}" 5 | cmd="${COMP_WORDS[1]}" 6 | 7 | if [[ ( $cmd = s* || $cmd = d* ) && "$COMP_CWORD" = 2 ]]; then 8 | COMPREPLY=($(compgen -W "$(echo "select distinct sheet from entries where sheet not like '\_%';" | t b)" $cur)) 9 | return 10 | elif [[ "$COMP_CWORD" = 1 ]]; then 11 | CMDS="archive backend configure display edit in kill list now out resume sheet week month" 12 | COMPREPLY=($(compgen -W "$CMDS" $cur)) 13 | fi 14 | 15 | } 16 | 17 | 18 | complete -F _timetrap 't' 19 | -------------------------------------------------------------------------------- /completions/zsh/_t: -------------------------------------------------------------------------------- 1 | #compdef t 2 | 3 | _t() { 4 | local curcontext="$curcontext" state line 5 | typeset -A opt_args 6 | 7 | _arguments \ 8 | '1: :->t_command'\ 9 | '2: :->first_arg' 10 | 11 | case $state in 12 | t_command) 13 | compadd "$@" archive backend configure display edit in kill\ 14 | list now out resume sheet week month 15 | ;; 16 | 17 | first_arg) 18 | # If the first argument starts with s or d (sheet or display), 19 | # the second argument can be autocompleted to one of the existing 20 | # non-archived sheets. 21 | if [[ $words[2] == s* || $words[2] == d* ]]; then 22 | query='SELECT DISTINCT(sheet) FROM entries WHERE sheet NOT LIKE "\_%" ESCAPE "\";' 23 | echo $query | t b | while read sheet; do 24 | compadd "$@" $sheet 25 | done 26 | fi 27 | ;; 28 | esac 29 | } 30 | 31 | _t "$@" 32 | -------------------------------------------------------------------------------- /lib/Getopt/Declare.rb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samg/timetrap/edacc04d357c8a693cb22fab36c65f04dd673d99/lib/Getopt/Declare.rb -------------------------------------------------------------------------------- /lib/Getopt/DelimScanner.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # 3 | # A derivative of StringScanner that can scan for delimited constructs in 4 | # addition to regular expressions. It is a loose port of the Text::Balanced 5 | # module for Perl by Damian Conway . 6 | # 7 | # == Synopsis 8 | # 9 | # se = DelimScanner::new( myString ) 10 | # 11 | # == Authors 12 | # 13 | # * Michael Granger 14 | # * Gonzalo Garramuno 15 | # 16 | # Copyright (c) 2002, 2003 The FaerieMUD Consortium. Most rights reserved. 17 | # 18 | # This work is licensed under the Creative Commons Attribution License. To view 19 | # a copy of this license, visit http://creativecommons.org/licenses/by/1.0 or 20 | # send a letter to Creative Commons, 559 Nathan Abbott Way, Stanford, California 21 | # 94305, USA. 22 | # 23 | # == Version 24 | # 25 | # $Id: DelimScanner.rb,v 1.2 2003/01/12 20:56:51 deveiant Exp $ 26 | # 27 | # == History 28 | # 29 | # - Added :suffix hash key for returning rest (right) of matches, like Perl's 30 | # Text::Balanced, on several methods. 31 | # - Added one or two \ for backquoting brackets, as new ruby1.8 complains 32 | # 33 | 34 | require 'strscan' 35 | require 'forwardable' 36 | 37 | ### Add some stuff to the String class to allow easy transformation to Regexp 38 | ### and in-place interpolation. 39 | class String 40 | def to_re( casefold=false, extended=false ) 41 | return Regexp::new( self.dup ) 42 | end 43 | 44 | ### Ideas for String-interpolation stuff courtesy of Hal E. Fulton 45 | ### via ruby-talk 46 | 47 | def interpolate( scope ) 48 | unless scope.is_a?( Binding ) 49 | raise TypeError, "Argument to interpolate must be a Binding, not "\ 50 | "a #{scope.class.name}" 51 | end 52 | 53 | # $stderr.puts ">>> Interpolating '#{self}'..." 54 | 55 | copy = self.gsub( /"/, %q:\": ) 56 | eval( '"' + copy + '"', scope ) 57 | end 58 | 59 | end 60 | 61 | 62 | ### A derivative of StringScanner that can scan for delimited constructs in 63 | ### addition to regular expressions. 64 | class DelimScanner 65 | 66 | ### Scanner exception classes 67 | class MatchFailure < RuntimeError ; end 68 | class DelimiterError < RuntimeError ; end 69 | 70 | 71 | extend Forwardable 72 | StringScanner.must_C_version 73 | 74 | 75 | ### Class constants 76 | Version = /([\d\.]+)/.match( %q{$Revision: 1.2 $} )[1] 77 | Rcsid = %q$Id: DelimScanner.rb,v 1.2 2003/01/12 20:56:51 deveiant Exp $ 78 | 79 | # Pattern to match a valid XML name 80 | XmlName = '[a-zA-Z_:][a-zA-Z0-9:.-]*' 81 | 82 | 83 | ### Namespace module for DelimString constants 84 | module Default 85 | 86 | # The list of default opening => closing codeblock delimiters to use for 87 | # scanCodeblock. 88 | CodeblockDelimiters = { 89 | '{' => '}', 90 | 'begin' => 'end', 91 | 'do' => 'end', 92 | } 93 | 94 | # Default scanMultiple operations and their arguments 95 | MultipleFunctions = [ 96 | :scanVariable => [], 97 | :scanQuotelike => [], 98 | :scanCodeblock => [], 99 | ] 100 | 101 | end 102 | include Default 103 | 104 | 105 | ### Define delegating methods that cast their argument to a Regexp from a 106 | ### String. This allows the scanner's scanning methods to be called with 107 | ### Strings in addition to Regexps. This was mostly stolen from 108 | ### forwardable.rb. 109 | def self.def_casting_delegators( *methods ) 110 | methods.each {|methodName| 111 | class_eval( <<-EOF, "(--def_casting_delegators--)", 1 ) 112 | def #{methodName}( pattern ) 113 | pattern = pattern.to_s.to_re unless pattern.is_a?( Regexp ) 114 | @scanner.#{methodName}( pattern ) 115 | end 116 | EOF 117 | } 118 | end 119 | 120 | 121 | ### Create a new DelimScanner object for the specified string. If 122 | ### dup is true, a duplicate of the target string will be 123 | ### used instead of the one given. The target string will be frozen after 124 | ### the scanner is created. 125 | def initialize( string, dup=true ) 126 | @scanner = StringScanner::new( string, dup ) 127 | @matchError = nil 128 | @debugLevel = 0 129 | end 130 | 131 | 132 | 133 | ###### 134 | public 135 | ###### 136 | 137 | # Here, some delegation trickery is done to make a DelimScanner behave like 138 | # a StringScanner. Some methods are directly delegated, while some are 139 | # delegated via a method which casts its argument to a Regexp first so some 140 | # scanner methods can be called with Strings as well as Regexps. 141 | 142 | # A list of delegated methods that need casting. 143 | NeedCastingDelegators = :scan, :skip, :match?, :check, 144 | :scan_until, :skip_until, :exist?, :check_until 145 | 146 | # Delegate all StringScanner instance methods to the associated scanner 147 | # object, except those that need a casting delegator, which uses an indirect 148 | # delegation method. 149 | def_delegators :@scanner, 150 | *( StringScanner.instance_methods(false) - 151 | NeedCastingDelegators.collect {|sym| sym.id2name} ) 152 | 153 | def_casting_delegators( *NeedCastingDelegators ) 154 | 155 | 156 | 157 | # The last match error encountered by the scanner 158 | attr_accessor :matchError 159 | protected :matchError= ; # ; is to work around a ruby-mode indent bug 160 | 161 | # Debugging level 162 | attr_accessor :debugLevel 163 | 164 | 165 | 166 | ### Returns true if the scanner has encountered a match error. 167 | def matchError? 168 | return ! @matchError.nil? 169 | end 170 | 171 | 172 | ### Starting at the scan pointer, try to match a substring delimited by the 173 | ### specified delimiters, skipping the specified prefix 174 | ### and any character escaped by the specified escape 175 | ### character/s. If matched, advances the scan pointer and returns a Hash 176 | ### with the following key/value pairs on success: 177 | ### 178 | ### [:match] 179 | ### The text of the match, including delimiters. 180 | ### [:prefix] 181 | ### The matched prefix, if any. 182 | ### 183 | ### If the match fails, returns nil. 184 | def scanDelimited( delimiters="'\"`", prefix='\\s*', escape='\\' ) 185 | delimiters ||= "'\"`" 186 | prefix ||= '\\s*' 187 | escape ||= '\\' 188 | 189 | debugMsg( 1, "Scanning for delimited text: delim = (%s), prefix=(%s), escape=(%s)", 190 | delimiters, prefix, escape ) 191 | self.matchError = nil 192 | 193 | # Try to match the prefix first to get the length 194 | unless (( prefixLength = self.match?(prefix.to_re) )) 195 | self.matchError = "Failed to match prefix '%s' at offset %d" % 196 | [ prefix, self.pointer ] 197 | return nil 198 | end 199 | 200 | # Now build a delimited pattern with the specified parameters. 201 | delimPattern = makeDelimPattern( delimiters, escape, prefix ) 202 | debugMsg( 2, "Delimiter pattern is %s" % delimPattern.inspect ) 203 | 204 | # Fail if no match 205 | unless (( matchedString = self.scan(delimPattern) )) 206 | self.matchError = "No delimited string found." 207 | return nil 208 | end 209 | 210 | return { 211 | :match => matchedString[prefixLength .. -1], 212 | :prefix => matchedString[0..prefixLength-1], 213 | } 214 | end 215 | 216 | 217 | ### Match using the #scanDelimited method, but only return the match or nil. 218 | def extractDelimited( *args ) 219 | rval = scanDelimited( *args ) or return nil 220 | return rval[:match] 221 | end 222 | 223 | 224 | ### Starting at the scan pointer, try to match a substring delimited by the 225 | ### specified delimiters, skipping the specified prefix 226 | ### and any character escaped by the specified escape 227 | ### character/s. If matched, advances the scan pointer and returns the 228 | ### length of the matched string; if it fails the match, returns nil. 229 | def skipDelimited( delimiters="'\"`", prefix='\\s*', escape='\\' ) 230 | delimiters ||= "'\"`" 231 | prefix ||= '\\s*' 232 | escape ||= '\\' 233 | 234 | self.matchError = nil 235 | return self.skip( makeDelimPattern(delimiters, escape, prefix) ) 236 | end 237 | 238 | 239 | ### Starting at the scan pointer, try to match a substring delimited by 240 | ### balanced delimiters of the type specified, after skipping the 241 | ### specified prefix. On a successful match, this method advances 242 | ### the scan pointer and returns a Hash with the following key/value pairs: 243 | ### 244 | ### [:match] 245 | ### The text of the match, including the delimiting brackets. 246 | ### [:prefix] 247 | ### The matched prefix, if any. 248 | ### 249 | ### On failure, returns nil. 250 | def scanBracketed( delimiters="{([<", prefix='\s*' ) 251 | delimiters ||= "{([<" 252 | prefix ||= '\s*' 253 | 254 | prefix = prefix.to_re unless prefix.kind_of?( Regexp ) 255 | 256 | debugMsg( 1, "Scanning for bracketed text: delimiters = (%s), prefix = (%s)", 257 | delimiters, prefix ) 258 | 259 | self.matchError = nil 260 | 261 | # Split the left-delimiters (brackets) from the quote delimiters. 262 | ldel = delimiters.dup 263 | qdel = ldel.squeeze.split(//).find_all {|char| char =~ /["'`]/ }.join('|') 264 | qdel = nil if qdel.empty? 265 | quotelike = true if ldel =~ /q/ 266 | 267 | # Change all instances of delimiters to the left-hand versions, and 268 | # strip away anything but bracketing delimiters 269 | ldel = ldel.tr( '[](){}<>', '[[(({{<<' ).gsub(/[^#{Regexp.quote('[\\](){}<>')}]+/, '').squeeze 270 | 271 | ### Now build the right-delim equivalent of the left delim string 272 | rdel = ldel.dup 273 | unless rdel.tr!( '[({<', '])}>' ) 274 | raise DelimiterError, "Did not find a suitable bracket in delimiter: '#{delimiters}'" 275 | end 276 | 277 | # Build regexps from both bracketing delimiter strings 278 | ldel = ldel.split(//).collect {|ch| Regexp.quote(ch)}.join('|') 279 | rdel = rdel.split(//).collect {|ch| Regexp.quote(ch)}.join('|') 280 | 281 | depth = self.scanDepth 282 | result = nil 283 | startPos = self.pointer 284 | 285 | begin 286 | result = matchBracketed( prefix, ldel, qdel, quotelike, rdel ) 287 | rescue MatchFailure => e 288 | debugMsg( depth + 1, "Match error: %s" % e.message ) 289 | self.matchError = e.message 290 | self.pointer = startPos 291 | result = nil 292 | rescue => e 293 | self.pointer = startPos 294 | Kernel::raise 295 | end 296 | 297 | return result 298 | end 299 | 300 | 301 | ### Match using the #scanBracketed method, but only return the match or nil. 302 | def extractBracketed( *args ) 303 | rval = scanBracketed( *args ) or return nil 304 | return rval[:match] 305 | end 306 | 307 | 308 | ### Starting at the scan pointer, try to match a substring with 309 | ### #scanBracketed. On a successful match, this method advances the scan 310 | ### pointer and returns the length of the match, including the delimiters 311 | ### and any prefix that was skipped. On failure, returns nil. 312 | def skipBracketed( *args ) 313 | startPos = self.pointer 314 | 315 | match = scanBracketed( *args ) 316 | 317 | return nil unless match 318 | return match.length + prefix.length 319 | ensure 320 | debugMsg( 2, "Resetting scan pointer." ) 321 | self.pointer = startPos 322 | end 323 | 324 | 325 | ### Extracts and segments text from the scan pointer forward that occurs 326 | ### between (balanced) specified tags, after skipping the specified 327 | ### prefix. If the opentag argument is nil, a pattern which 328 | ### will match any standard HTML/XML tag will be used. If the 329 | ### closetag argument is nil, a pattern is created which 330 | ### prepends a / character to the matched opening tag, after any 331 | ### bracketing characters. The options argument is a Hash of one or 332 | ### more options which govern the matching operation. They are described in 333 | ### more detail in the Description section of 'lib/DelimScanner.rb'. On a 334 | ### successful match, this method advances the scan pointer and returns an 335 | ### 336 | ### [:match] 337 | ### The text of the match, including the delimiting tags. 338 | ### [:prefix] 339 | ### The matched prefix, if any. 340 | ### 341 | ### On failure, returns nil. 342 | def scanTagged( opentag=nil, closetag=nil, prefix='\s*', options={} ) 343 | prefix ||= '\s*' 344 | 345 | ldel = opentag || %Q,<\\w+(?:#{ makeDelimPattern(%q:'":) }|[^>])*>, 346 | rdel = closetag 347 | raise ArgumentError, "Options argument must be a hash" unless options.kind_of?( Hash ) 348 | 349 | failmode = options[:fail] 350 | bad = if options[:reject].is_a?( Array ) then 351 | options[:reject].join("|") 352 | else 353 | (options[:reject] || '') 354 | end 355 | ignore = if options[:ignore].is_a?( Array ) then 356 | options[:ignore].join("|") 357 | else 358 | (options[:ignore] || '') 359 | end 360 | 361 | self.matchError = nil 362 | result = nil 363 | startPos = self.pointer 364 | 365 | depth = self.scanDepth 366 | 367 | begin 368 | result = matchTagged( prefix, ldel, rdel, failmode, bad, ignore ) 369 | rescue MatchFailure => e 370 | debugMsg( depth + 1, "Match error: %s" % e.message ) 371 | self.matchError = e.message 372 | self.pointer = startPos 373 | result = nil 374 | rescue => e 375 | self.pointer = startPos 376 | Kernel::raise 377 | end 378 | 379 | return result 380 | end 381 | 382 | 383 | ### Match using the #scanTagged method, but only return the match or nil. 384 | def extractTagged( *args ) 385 | rval = scanTagged( *args ) or return nil 386 | return rval[:match] 387 | end 388 | 389 | 390 | ### Starting at the scan pointer, try to match a substring with 391 | ### #scanTagged. On a successful match, this method advances the scan 392 | ### pointer and returns the length of the match, including any delimiters 393 | ### and any prefix that was skipped. On failure, returns nil. 394 | def skipTagged( *args ) 395 | startPos = self.pointer 396 | 397 | match = scanTagged( *args ) 398 | 399 | return nil unless match 400 | return match.length + prefix.length 401 | ensure 402 | debugMsg( 2, "Resetting scan pointer." ) 403 | self.pointer = startPos 404 | end 405 | 406 | 407 | # :NOTE: 408 | # Since the extract_quotelike function isn't documented at all in 409 | # Text::Balanced, I'm only guessing this is correct... 410 | 411 | ### Starting from the scan pointer, try to match any one of the various Ruby 412 | ### quotes and quotelike operators after skipping the specified 413 | ### prefix. Nested backslashed delimiters, embedded balanced 414 | ### bracket delimiters (for the quotelike operators), and trailing modifiers 415 | ### are all caught. If matchRawRegex is true, inline 416 | ### regexen (eg., /pattern/) are matched as well. Advances the scan 417 | ### pointer and returns a Hash with the following key/value pairs on 418 | ### success: 419 | ### 420 | ### [:match] 421 | ### The entire text of the match. 422 | ### [:prefix] 423 | ### The matched prefix, if any. 424 | ### [:quoteOp] 425 | ### The name of the quotelike operator (if any) (eg., '%Q', '%r', etc). 426 | ### [:leftDelim] 427 | ### The left delimiter of the first block of the operation. 428 | ### [:delimText] 429 | ### The text of the first block of the operation. 430 | ### [:rightDelim] 431 | ### The right delimiter of the first block of the operation. 432 | ### [:modifiers] 433 | ### The trailing modifiers on the operation (if any). 434 | ### 435 | ### On failure, returns nil. 436 | def scanQuotelike( prefix='\s*', matchRawRegex=true ) 437 | 438 | self.matchError = nil 439 | result = nil 440 | startPos = self.pointer 441 | 442 | depth = self.scanDepth 443 | 444 | begin 445 | result = matchQuotelike( prefix, matchRawRegex ) 446 | rescue MatchFailure => e 447 | debugMsg( depth + 1, "Match error: %s" % e.message ) 448 | self.matchError = e.message 449 | self.pointer = startPos 450 | result = nil 451 | rescue => e 452 | self.pointer = startPos 453 | Kernel::raise 454 | end 455 | 456 | return result 457 | end 458 | 459 | 460 | ### Match using the #scanQuotelike method, but only return the match or nil. 461 | def extractQuotelike( *args ) 462 | rval = scanQuotelike( *args ) or return nil 463 | return rval[:match] 464 | end 465 | 466 | 467 | ### Starting at the scan pointer, try to match a substring with 468 | ### #scanQuotelike. On a successful match, this method advances the scan 469 | ### pointer and returns the length of the match, including any delimiters 470 | ### and any prefix that was skipped. On failure, returns nil. 471 | def skipQuotelike( *args ) 472 | startPos = self.pointer 473 | 474 | match = scanQuotelike( *args ) 475 | 476 | return nil unless match 477 | return match.length + prefix.length 478 | ensure 479 | debugMsg( 2, "Resetting scan pointer." ) 480 | self.pointer = startPos 481 | end 482 | 483 | 484 | ### Starting from the scan pointer, try to match a Ruby variable after 485 | ### skipping the specified prefix. 486 | def scanVariable( prefix='\s*' ) 487 | self.matchError = nil 488 | result = nil 489 | startPos = self.pointer 490 | 491 | depth = self.scanDepth 492 | 493 | begin 494 | result = matchVariable( prefix ) 495 | rescue MatchFailure => e 496 | debugMsg( depth + 1, "Match error: %s" % e.message ) 497 | self.matchError = e.message 498 | self.pointer = startPos 499 | result = nil 500 | rescue => e 501 | self.pointer = startPos 502 | Kernel::raise 503 | end 504 | 505 | return result 506 | end 507 | 508 | 509 | ### Match using the #scanVariable method, but only return the match or nil. 510 | def extractVariable( *args ) 511 | rval = scanVariable( *args ) or return nil 512 | return rval[:match] 513 | end 514 | 515 | 516 | ### Starting at the scan pointer, try to match a substring with 517 | ### #scanVariable. On a successful match, this method advances the scan 518 | ### pointer and returns the length of the match, including any delimiters 519 | ### and any prefix that was skipped. On failure, returns nil. 520 | def skipVariable( *args ) 521 | startPos = self.pointer 522 | 523 | match = scanVariable( *args ) 524 | 525 | return nil unless match 526 | return match.length + prefix.length 527 | ensure 528 | debugMsg( 2, "Resetting scan pointer." ) 529 | self.pointer = startPos 530 | end 531 | 532 | 533 | ### Starting from the scan pointer, and skipping the specified 534 | ### prefix, try to to recognize and match a balanced bracket-, 535 | ### do/end-, or begin/end-delimited substring that may contain unbalanced 536 | ### delimiters inside quotes or quotelike operations. 537 | def scanCodeblock( innerDelim=CodeblockDelimiters, prefix='\s*', outerDelim=innerDelim ) 538 | self.matchError = nil 539 | result = nil 540 | startPos = self.pointer 541 | 542 | prefix ||= '\s*' 543 | innerDelim ||= CodeblockDelimiters 544 | outerDelim ||= innerDelim 545 | 546 | depth = caller(1).find_all {|frame| 547 | frame =~ /in `scan(Variable|Tagged|Codeblock|Bracketed|Quotelike)'/ 548 | }.length 549 | 550 | begin 551 | debugMsg 3, "------------------------------------" 552 | debugMsg 3, "Calling matchCodeBlock( %s, %s, %s )", 553 | prefix.inspect, innerDelim.inspect, outerDelim.inspect 554 | debugMsg 3, "------------------------------------" 555 | result = matchCodeblock( prefix, innerDelim, outerDelim ) 556 | rescue MatchFailure => e 557 | debugMsg( depth + 1, "Match error: %s" % e.message ) 558 | self.matchError = e.message 559 | self.pointer = startPos 560 | result = nil 561 | rescue => e 562 | self.pointer = startPos 563 | Kernel::raise 564 | end 565 | 566 | return result 567 | end 568 | 569 | 570 | ### Match using the #scanCodeblock method, but only return the match or nil. 571 | def extractCodeblock( *args ) 572 | rval = scanCodeblock( *args ) or return nil 573 | return rval[:match] 574 | end 575 | 576 | 577 | ### Starting at the scan pointer, try to match a substring with 578 | ### #scanCodeblock. On a successful match, this method advances the scan 579 | ### pointer and returns the length of the match, including any delimiters 580 | ### and any prefix that was skipped. On failure, returns nil. 581 | def skipCodeblock( *args ) 582 | startPos = self.pointer 583 | 584 | match = scanCodeblock( *args ) 585 | 586 | return nil unless match 587 | return match.length + prefix.length 588 | ensure 589 | debugMsg( 2, "Resetting scan pointer." ) 590 | self.pointer = startPos 591 | end 592 | 593 | 594 | 595 | 596 | ######### 597 | protected 598 | ######### 599 | 600 | ### Scan the string from the scan pointer forward, skipping the specified 601 | ### prefix and trying to match a string delimited by bracketing 602 | ### delimiters ldel and rdel (Regexp objects), and quoting 603 | ### delimiters qdel (Regexp). If quotelike is 604 | ### true, Ruby quotelike constructs will also be honored. 605 | def matchBracketed( prefix, ldel, qdel, quotelike, rdel ) 606 | startPos = self.pointer 607 | debugMsg( 2, "matchBracketed starting at pos = %d: prefix = %s, "\ 608 | "ldel = %s, qdel = %s, quotelike = %s, rdel = %s", 609 | startPos, prefix.inspect, ldel.inspect, qdel.inspect, quotelike.inspect, 610 | rdel.inspect ) 611 | 612 | # Test for the prefix, failing if not found 613 | raise MatchFailure, "Did not find prefix: #{prefix.inspect}" unless 614 | self.skip( prefix ) 615 | 616 | # Mark this position as the left-delimiter pointer 617 | ldelpos = self.pointer 618 | debugMsg( 3, "Found prefix. Left delim pointer at %d", ldelpos ) 619 | 620 | # Match opening delimiter or fail 621 | unless (( delim = self.scan(ldel) )) 622 | raise MatchFailure, "Did not find opening bracket after prefix: '%s' (%d)" % 623 | [ self.string[startPos..ldelpos].chomp, ldelpos ] 624 | end 625 | 626 | # A stack to keep track of nested delimiters 627 | nesting = [ delim ] 628 | debugMsg( 3, "Found opening bracket. Nesting = %s", nesting.inspect ) 629 | 630 | while self.rest? 631 | 632 | debugMsg( 5, "Starting scan loop. Nesting = %s", nesting.inspect ) 633 | 634 | # Skip anything that's backslashed 635 | if self.skip( /\\./ ) 636 | debugMsg( 4, "Skipping backslashed literal at offset %d: '%s'", 637 | self.pointer - 2, self.string[ self.pointer - 2, 2 ].chomp ) 638 | next 639 | end 640 | 641 | # Opening bracket (left delimiter) 642 | if self.scan(ldel) 643 | delim = self.matched 644 | debugMsg( 4, "Found opening delim %s at offset %d", 645 | delim.inspect, self.pointer - 1 ) 646 | nesting.push delim 647 | 648 | # Closing bracket (right delimiter) 649 | elsif self.scan(rdel) 650 | delim = self.matched 651 | 652 | debugMsg( 4, "Found closing delim %s at offset %d", 653 | delim.inspect, self.pointer - 1 ) 654 | 655 | # :TODO: When is this code reached? 656 | if nesting.empty? 657 | raise MatchFailure, "Unmatched closing bracket '%s' at offset %d" % 658 | [ delim, self.pointer - 1 ] 659 | end 660 | 661 | # Figure out what the compliment of the bracket next off the 662 | # stack should be. 663 | expected = nesting.pop.tr( '({[<', ')}]>' ) 664 | debugMsg( 4, "Got a '%s' bracket off nesting stack", expected ) 665 | 666 | # Check for mismatched brackets 667 | if expected != delim 668 | raise MatchFailure, "Mismatched closing bracket at offset %d: "\ 669 | "Expected '%s', but found '%s' instead." % 670 | [ self.pointer - 1, expected, delim ] 671 | end 672 | 673 | # If we've found the closing delimiter, stop scanning 674 | if nesting.empty? 675 | debugMsg( 4, "Finished with scan: nesting stack empty." ) 676 | break 677 | end 678 | 679 | # Quoted chunk (quoted delimiter) 680 | elsif qdel && self.scan(qdel) 681 | match = self.matched 682 | 683 | if self. scan( /[^\\#{match}]*(?:\\.[^\\#{match}]*)*(#{Regexp::quote(match)})/ ) 684 | debugMsg( 4, "Skipping quoted chunk. Scan pointer now at offset %d", self.pointer ) 685 | next 686 | end 687 | 688 | raise MatchFailure, "Unmatched embedded quote (%s) at offset %d" % 689 | [ match, self.pointer - 1 ] 690 | 691 | # Embedded quotelike 692 | elsif quotelike && self.scanQuotelike 693 | debugMsg( 4, "Matched a quotelike. Scan pointer now at offset %d", self.pointer ) 694 | next 695 | 696 | # Skip word characters, or a single non-word character 697 | else 698 | self.skip( /(?:[a-zA-Z0-9]+|.)/m ) 699 | debugMsg 5, "Skipping '%s' at offset %d." % 700 | [ self.matched, self.pointer ] 701 | end 702 | 703 | end 704 | 705 | # If there's one or more brackets left on the delimiter stack, we're 706 | # missing a closing delim. 707 | unless nesting.empty? 708 | raise MatchFailure, "Unmatched opening bracket(s): %s.. at offset %d" % 709 | [ nesting.join('..'), self.pointer ] 710 | end 711 | 712 | rval = { 713 | :match => self.string[ ldelpos .. (self.pointer - 1) ], 714 | :prefix => self.string[ startPos, (ldelpos-startPos) ], 715 | :suffix => self.string[ self.pointer..-1 ], 716 | } 717 | debugMsg 1, "matchBracketed succeeded: %s" % rval.inspect 718 | return rval 719 | end 720 | 721 | 722 | ### Starting from the scan pointer, skip the specified prefix, and 723 | ### try to match text bracketed by the given left and right tag-delimiters 724 | ### (ldel and rdel). 725 | def matchTagged( prefix, ldel, rdel, failmode, bad, ignore ) 726 | failmode = failmode.to_s.intern if failmode 727 | startPos = self.pointer 728 | debugMsg 2, "matchTagged starting at pos = %d: prefix = %s, "\ 729 | "ldel = %s, rdel = %s, failmode = %s, bad = %s, ignore = %s", 730 | startPos, prefix.inspect, ldel.inspect, rdel.inspect, 731 | failmode.inspect, bad.inspect, ignore.inspect 732 | 733 | rdelspec = '' 734 | openTagPos, textPos, paraPos, closeTagPos, endPos = ([nil] * 5) 735 | match = nil 736 | 737 | # Look for the prefix 738 | raise MatchFailure, "Did not find prefix: /#{prefix.inspect}/" unless 739 | self.skip( prefix ) 740 | 741 | openTagPos = self.pointer 742 | debugMsg 3, "Found prefix. Pointer now at offset %d" % self.pointer 743 | 744 | # Look for the opening delimiter 745 | unless (( match = self.scan(ldel) )) 746 | raise MatchFailure, "Did not find opening tag %s at offset %d" % 747 | [ ldel.inspect, self.pointer ] 748 | end 749 | 750 | textPos = self.pointer 751 | debugMsg 3, "Found left delimiter '%s': offset now %d" % [ match, textPos ] 752 | 753 | # Make a right delim out of the tag we found if none was specified 754 | if rdel.nil? 755 | rdelspec = makeClosingTag( match ) 756 | debugMsg 3, "Generated right-delimiting tag: %s" % rdelspec.inspect 757 | else 758 | # Make the regexp-related globals from the match 759 | rdelspec = rdel.gsub( /(\A|[^\\])\$([1-9])/, '\1self[\2]' ).interpolate( binding ) 760 | debugMsg 3, "Right delimiter (after interpolation) is: %s" % rdelspec.inspect 761 | end 762 | 763 | # Process until we reach the end of the string or find a closing tag 764 | while self.rest? && closeTagPos.nil? 765 | 766 | # Skip backslashed characters 767 | if (( self.skip( /^\\./ ) )) 768 | debugMsg 4, "Skipping backslashed literal at offset %d" % self.pointer 769 | next 770 | 771 | # Match paragraphs-break for fail == :para 772 | elsif (( matchlength = self.skip( /^(\n[ \t]*\n)/ ) )) 773 | paraPos ||= self.pointer - matchlength 774 | debugMsg 4, "Found paragraph position at offset %d" % paraPos 775 | 776 | # Match closing tag 777 | elsif (( matchlength = self.skip( rdelspec ) )) 778 | closeTagPos = self.pointer - matchlength 779 | debugMsg 3, "Found closing tag at offset %d" % closeTagPos 780 | 781 | # If we're ignoring anything, try to match and move beyond it 782 | elsif ignore && !ignore.empty? && self.skip(ignore) 783 | debugMsg 3, "Skipping ignored text '%s' at offset %d" % 784 | [ self.matched, self.pointer - self.matched_size ] 785 | next 786 | 787 | # If there's a "bad" pattern, try to match it, shorting the 788 | # outer loop if it matches in para or max mode, or failing with 789 | # a match error if not. 790 | elsif bad && !bad.empty? && self.match?( bad ) 791 | if failmode == :para || failmode == :max 792 | break 793 | else 794 | raise MatchFailure, "Found invalid nested tag '%s' at offset %d" % 795 | [ match, self.pointer ] 796 | end 797 | 798 | # If there's another opening tag, make a recursive call to 799 | # ourselves to move the cursor beyond it 800 | elsif (( match = self.scan( ldel ) )) 801 | tag = match 802 | self.unscan 803 | 804 | unless self.matchTagged( prefix, ldel, rdel, failmode, bad, ignore ) 805 | break if failmode == :para || failmode == :max 806 | 807 | raise MatchFailure, "Found unbalanced nested tag '%s' at offset %d" % 808 | [ tag, self.pointer ] 809 | end 810 | 811 | else 812 | self.pointer += 1 813 | debugMsg 5, "Advanced scan pointer to offset %d" % self.pointer 814 | end 815 | end 816 | 817 | # If the closing hasn't been found, then it's a "short" match, which is 818 | # okay if the failmode indicates we don't care. Otherwise, it's an error. 819 | unless closeTagPos 820 | debugMsg 3, "No close tag position found. " 821 | 822 | if failmode == :max || failmode == :para 823 | closeTagPos = self.pointer - 1 824 | debugMsg 4, "Failmode %s tolerates no closing tag. Close tag position set to %d" % 825 | [ failmode.inspect, closeTagPos ] 826 | 827 | # Sync the scan pointer and the paragraph marker if it's set. 828 | if failmode == :para && paraPos 829 | self.pointer = paraPos + 1 830 | end 831 | else 832 | raise MatchFailure, "No closing tag found." 833 | end 834 | end 835 | 836 | rval = { 837 | :match => self.string[ openTagPos .. (self.pointer - 1) ], 838 | :prefix => self.string[ startPos, (openTagPos-startPos) ], 839 | :suffix => self.string[ self.pointer..-1 ], 840 | } 841 | debugMsg 1, "matchTagged succeeded: %s" % rval.inspect 842 | return rval 843 | end 844 | 845 | 846 | ### Starting from the scan pointer, skip the specified prefix, and 847 | ### try to match text inside a Ruby quotelike construct. If 848 | ### matchRawRegex is true, the regex construct 849 | ### /pattern/ is also matched. 850 | def matchQuotelike( prefix, matchRawRegex ) 851 | startPos = self.pointer 852 | debugMsg 2, "matchQuotelike starting at pos = %d: prefix = %s, "\ 853 | "matchRawRegex = %s", 854 | startPos, prefix.inspect, matchRawRegex.inspect 855 | 856 | # Init position markers 857 | rval = oppos = preldpos = ldpos = strpos = rdpos = modpos = nil 858 | 859 | # Look for the prefix 860 | raise MatchFailure, "Did not find prefix: /#{prefix.inspect}/" unless 861 | self.skip( prefix ) 862 | oppos = self.pointer 863 | 864 | 865 | # Peek at the next character 866 | # If the initial quote is a simple quote, our job is easy 867 | if self.check(/^["`']/) || ( matchRawRegex && self.check(%r:/:) ) 868 | 869 | initial = self.matched 870 | 871 | # Build the pattern for matching the simple string 872 | pattern = "%s [^\\%s]* (\\.[^\\%s]*)* %s" % 873 | [ Regexp.quote(initial), 874 | initial, initial, 875 | Regexp.quote(initial) ] 876 | debugMsg 2, "Matching simple quote at offset %d with /%s/" % 877 | [ self.pointer, pattern ] 878 | 879 | # Search for it, raising an exception if it's not found 880 | unless self.scan( /#{pattern}/xism ) 881 | raise MatchFailure, 882 | "Did not find closing delimiter to match '%s' at '%s...' (offset %d)" % 883 | [ initial, self.string[ oppos, 20 ].chomp, self.pointer ] 884 | end 885 | 886 | modpos = self.pointer 887 | rdpos = modpos - 1 888 | 889 | # If we're matching a regex, look for any trailing modifiers 890 | if initial == '/' 891 | pattern = if RUBY_VERSION >= "1.7.3" then /[imoxs]*/ else /[imox]*/ end 892 | self.scan( pattern ) 893 | end 894 | 895 | rval = { 896 | :prefix => self.string[ startPos, (oppos-startPos) ], 897 | :match => self.string[ oppos .. (self.pointer - 1) ], 898 | :leftDelim => self.string[ oppos, 1 ], 899 | :delimText => self.string[ (oppos+1) .. (rdpos-1) ], 900 | :rightDelim => self.string[ rdpos, 1 ], 901 | :modifiers => self.string[ modpos, (self.pointer-modpos) ], 902 | :suffix => self.string[ self.pointer.. -1 ], 903 | } 904 | 905 | # If it's one of the fancy quotelike operators, our job is somewhat 906 | # complicated (though nothing like Perl's, thank the Goddess) 907 | elsif self.scan( %r:%[rwqQx]?(?=\S): ) 908 | op = self.matched 909 | debugMsg 2, "Matching a real quotelike ('%s') at offset %d" % 910 | [ op, self.pointer ] 911 | modifiers = nil 912 | 913 | ldpos = self.pointer 914 | strpos = ldpos + 1 915 | 916 | # Peek ahead to see what the delimiter is 917 | ldel = self.check( /\S/ ) 918 | 919 | # If it's a bracketing character, just use matchBracketed 920 | if ldel =~ /[\[(<{]/ 921 | rdel = ldel.tr( '[({<', '])}>' ) 922 | debugMsg 4, "Left delim is a bracket: %s; looking for compliment: %s" % 923 | [ ldel, rdel ] 924 | self.matchBracketed( '', Regexp::quote(ldel), nil, nil, Regexp::quote(rdel) ) 925 | else 926 | debugMsg 4, "Left delim isn't a bracket: '#{ldel}'; looking for closing instance" 927 | self.scan( /#{ldel}[^\\#{ldel}]*(\\.[^\\#{ldel}]*)*#{ldel}/ ) or 928 | raise MatchFailure, 929 | "Can't find a closing delimiter '%s' at '%s...' (offset %d)" % 930 | [ ldel, self.rest[0,20].chomp, self.pointer ] 931 | end 932 | rdelpos = self.pointer - 1 933 | 934 | # Match modifiers for Regexp quote 935 | if op == '%r' 936 | pattern = if RUBY_VERSION >= "1.7.3" then /[imoxs]*/ else /[imox]*/ end 937 | modifiers = self.scan( pattern ) || '' 938 | end 939 | 940 | rval = { 941 | :prefix => self.string[ startPos, (oppos-startPos) ], 942 | :match => self.string[ oppos .. (self.pointer - 1) ], 943 | :quoteOp => op, 944 | :leftDelim => self.string[ ldpos, 1 ], 945 | :delimText => self.string[ strpos, (rdelpos-strpos) ], 946 | :rightDelim => self.string[ rdelpos, 1 ], 947 | :modifiers => modifiers, 948 | :suffix => self.string[ self.pointer.. -1 ], 949 | } 950 | 951 | # If it's a here-doc, things get even hairier. 952 | elsif self.scan( %r:<<(-)?: ) 953 | debugMsg 2, "Matching a here-document at offset %d" % self.pointer 954 | op = self.matched 955 | 956 | # If there was a dash, start with optional whitespace 957 | indent = self[1] ? '\s*' : '' 958 | ldpos = self.pointer 959 | label = '' 960 | 961 | # Plain identifier 962 | if self.scan( /[A-Za-z_]\w*/ ) 963 | label = self.matched 964 | debugMsg 3, "Setting heredoc terminator to bare identifier '%s'" % label 965 | 966 | # Quoted string 967 | elsif self.scan( / ' ([^'\\]* (?:\\.[^'\\]*)*) ' /sx ) || 968 | self.scan( / " ([^"\\]* (?:\\.[^"\\]*)*) " /sx ) || 969 | self.scan( / ` ([^`\\]* (?:\\.[^`\\]*)*) ` /sx ) 970 | label = self[1] 971 | debugMsg 3, "Setting heredoc terminator to quoted identifier '%s'" % label 972 | 973 | # Ruby, unlike Perl, requires a terminal, even if it's only an empty 974 | # string 975 | else 976 | raise MatchFailure, 977 | "Missing heredoc terminator before end of line at "\ 978 | "'%s...' (offset %d)" % 979 | [ self.rest[0,20].chomp, self.pointer ] 980 | end 981 | extrapos = self.pointer 982 | 983 | # Advance to the beginning of the string 984 | self.skip( /.*\n/ ) 985 | strpos = self.pointer 986 | debugMsg 3, "Scanning until /\\n#{indent}#{label}\\n/m" 987 | 988 | # Match to the label 989 | unless self.scan_until( /\n#{indent}#{label}\n/m ) 990 | raise MatchFailure, 991 | "Couldn't find heredoc terminator '%s' after '%s...' (offset %d)" % 992 | [ label, self.rest[0,20].chomp, self.pointer ] 993 | end 994 | 995 | rdpos = self.pointer - self.matched_size 996 | 997 | rval = { 998 | :prefix => self.string[ startPos, (oppos-startPos) ], 999 | :match => self.string[ oppos .. (self.pointer - 1) ], 1000 | :quoteOp => op, 1001 | :leftDelim => self.string[ ldpos, (extrapos-ldpos) ], 1002 | :delimText => self.string[ strpos, (rdpos-strpos) ], 1003 | :rightDelim => self.string[ rdpos, (self.pointer-rdpos) ], 1004 | :suffix => self.string[ self.pointer.. -1 ], 1005 | } 1006 | 1007 | else 1008 | raise MatchFailure, 1009 | "No quotelike operator found after prefix at '%s...'" % 1010 | self.rest[0,20].chomp 1011 | end 1012 | 1013 | 1014 | debugMsg 1, "matchQuotelike succeeded: %s" % rval.inspect 1015 | return rval 1016 | end 1017 | 1018 | 1019 | ### Starting from the scan pointer, skip the specified prefix, and 1020 | ### try to match text that is a valid Ruby variable or identifier, ...? 1021 | def matchVariable( prefix ) 1022 | startPos = self.pointer 1023 | debugMsg 2, "matchVariable starting at pos = %d: prefix = %s", 1024 | startPos, prefix.inspect 1025 | 1026 | # Look for the prefix 1027 | raise MatchFailure, "Did not find prefix: /#{prefix.inspect}/" unless 1028 | self.skip( prefix ) 1029 | 1030 | varPos = self.pointer 1031 | 1032 | # If the variable matched is a predefined global, no need to look for an 1033 | # identifier 1034 | unless self.scan( %r~\$(?:[!@/\\,;.<>$?:_\~&`'+]|-\w|\d+)~ ) 1035 | 1036 | debugMsg 2, "Not a predefined global at '%s...' (offset %d)" % 1037 | [ self.rest[0,20].chomp, self.pointer ] 1038 | 1039 | # Look for a valid identifier 1040 | unless self.scan( /\*?(?:[$@]|::)?(?:[a-z_]\w*(?:::\s*))*[_a-z]\w*/is ) 1041 | raise MatchFailure, "No variable found: Bad identifier (offset %d)" % self.pointer 1042 | end 1043 | end 1044 | 1045 | debugMsg 2, "Matched '%s' at offset %d" % [ self.matched, self.pointer ] 1046 | 1047 | # Match methodchain with trailing codeblock 1048 | while self.rest? 1049 | # Match a regular chained method 1050 | next if scanCodeblock( {"("=>")", "do"=>"end", "begin"=>"end", "{"=>"}"}, 1051 | /\s*(?:\.|::)\s*[a-zA-Z_]\w+\s*/ ) 1052 | 1053 | # Match a trailing block or an element ref 1054 | next if scanCodeblock( nil, /\s*/, {'{' => '}', '[' => ']'} ) 1055 | 1056 | # This matched a dereferencer in Perl, which doesn't have any 1057 | # equivalent in Ruby. 1058 | #next if scanVariable( '\s*(\.|::)\s*' ) 1059 | 1060 | # Match a method call without parens (?) 1061 | next if self.scan( '\s*(\.|::)\s*\w+(?![{(\[])' ) 1062 | 1063 | break 1064 | end 1065 | 1066 | rval = { 1067 | :match => self.string[ varPos .. (self.pointer - 1) ], 1068 | :prefix => self.string[ startPos, (varPos-startPos) ], 1069 | :suffix => self.string[ self.pointer..-1 ], 1070 | } 1071 | debugMsg 1, "matchVariable succeeded: %s" % rval.inspect 1072 | return rval 1073 | end 1074 | 1075 | 1076 | ### Starting from the scan pointer, skip the specified prefix, and 1077 | ### try to match text inside a Ruby code block construct which must be 1078 | ### delimited by the specified outerDelimPairs. It may optionally 1079 | ### contain sub-blocks delimited with the given innerDelimPairs. 1080 | def matchCodeblock( prefix, innerDelimPairs, outerDelimPairs ) 1081 | startPos = self.pointer 1082 | debugMsg 2, "Starting matchCodeblock at offset %d (%s)", startPos, self.rest.inspect 1083 | 1084 | # Look for the prefix 1085 | raise MatchFailure, "Did not find prefix: /#{prefix.inspect}/" unless 1086 | self.skip( prefix ) 1087 | codePos = self.pointer 1088 | debugMsg 3, "Skipped prefix '%s' to offset %d" % 1089 | [ self.matched, codePos ] 1090 | 1091 | # Build a regexp for the outer delimiters 1092 | ldelimOuter = "(" + outerDelimPairs.keys .uniq.collect {|delim| Regexp::quote(delim)}.join('|') + ")" 1093 | rdelimOuter = "(" + outerDelimPairs.values.uniq.collect {|delim| Regexp::quote(delim)}.join('|') + ")" 1094 | debugMsg 4, "Using /%s/ as the outer delim regex" % ldelimOuter 1095 | 1096 | unless self.scan( ldelimOuter ) 1097 | raise MatchFailure, %q:Did not find opening bracket at "%s..." offset %d: % 1098 | [ self.rest[0,20].chomp, codePos ] 1099 | end 1100 | 1101 | # Look up the corresponding outer delimiter 1102 | closingDelim = outerDelimPairs[self.matched] or 1103 | raise DelimiterError, "Could not find closing delimiter for '%s'" % 1104 | self.matched 1105 | 1106 | debugMsg 3, "Scanning for closing delim '#{closingDelim}'" 1107 | matched = '' 1108 | patvalid = true 1109 | 1110 | # Scan until the end of the text or until an explicit break 1111 | while self.rest? 1112 | debugMsg 5, "Scanning from offset %d (%s)", self.pointer, self.rest.inspect 1113 | matched = '' 1114 | 1115 | # Skip comments 1116 | debugMsg 5, "Trying to match a comment" 1117 | if self.scan( /\s*#.*/ ) 1118 | debugMsg 4, "Skipping comment '%s' to offset %d" % 1119 | [ self.matched, self.pointer ] 1120 | next 1121 | end 1122 | 1123 | # Look for (any) closing delimiter 1124 | debugMsg 5, "Trying to match a closing outer delimiter with /\s*(#{rdelimOuter})/" 1125 | if self.scan( /\s*(#{rdelimOuter})/ ) 1126 | debugMsg 4, "Found a right delimiter '#{self.matched}'" 1127 | 1128 | # If it's the delimiter we're looking for, stop the scan 1129 | if self.matched.strip == closingDelim 1130 | matched = self.matched 1131 | debugMsg 3, "Found the closing delimiter we've been looking for (#{matched.inspect})." 1132 | break 1133 | 1134 | # Otherwise, it's an error, as we've apparently seen a closing 1135 | # delimiter without a corresponding opening one. 1136 | else 1137 | raise MatchFailure, 1138 | %q:Mismatched closing bracket at "%s..." (offset %s). Expected '%s': % 1139 | [ self.rest[0,20], self.pointer, closingDelim ] 1140 | end 1141 | end 1142 | 1143 | # Try to match a variable or a quoted phrase 1144 | debugMsg 5, "Trying to match either a variable or quotelike" 1145 | if self.scanVariable( '\s*' ) || self.scanQuotelike( '\s*', patvalid ) 1146 | debugMsg 3, "Matched either a variable or quotelike. Offset now %d" % self.pointer 1147 | patvalid = false 1148 | next 1149 | end 1150 | 1151 | # Match some operators 1152 | # :TODO: This hasn't really been ruby-ified 1153 | debugMsg 5, "Trying to match an operator" 1154 | if self.scan( %r:\s*([-+*x/%^&|.]=? 1155 | | [!=]~ 1156 | | =(?!>) 1157 | | (\*\*|&&|\|\||<<|>>)=? 1158 | | split|grep|map|return 1159 | ):x ) 1160 | debugMsg 3, "Skipped miscellaneous operator '%s' to offset %d." % 1161 | [ self.matched, self.pointer ] 1162 | patvalid = true 1163 | next 1164 | end 1165 | 1166 | # Try to match an embedded codeblock 1167 | debugMsg 5, "Trying to match an embedded codeblock with delim pairs: %s", 1168 | innerDelimPairs.inspect 1169 | if self.scanCodeblock( innerDelimPairs ) 1170 | debugMsg 3, "Skipped inner codeblock to offset %d." % self.pointer 1171 | patvalid = true 1172 | next 1173 | end 1174 | 1175 | # Try to match a stray outer-left delimiter 1176 | debugMsg 5, "Trying to match a stray outer-left delimiter (#{ldelimOuter})" 1177 | if self.match?( ldelimOuter ) 1178 | raise MatchFailure, "Improperly nested codeblock at offset %d: %s... " % 1179 | [ self.pointer, self.rest[0,20] ] 1180 | end 1181 | 1182 | patvalid = false 1183 | self.scan( /\s*(\w+|[-=>]>|.|\Z)/m ) 1184 | debugMsg 3, "Skipped '%s' to offset %d" % 1185 | [ self.matched, self.pointer ] 1186 | end 1187 | 1188 | 1189 | unless matched 1190 | raise MatchFailure, "No match found for opening bracket" 1191 | end 1192 | 1193 | rval = { 1194 | :match => self.string[codePos .. (self.pointer - 1)], 1195 | :prefix => self.string[startPos, (codePos-startPos)], 1196 | :suffix => self.string[ self.pointer..-1 ], 1197 | } 1198 | debugMsg 1, "matchCodeblock succeeded: %s" % rval.inspect 1199 | return rval 1200 | end 1201 | 1202 | 1203 | ### Attempt to derive and return the number of scan methods traversed up to 1204 | ### this point by examining the call stack. 1205 | def scanDepth 1206 | return caller(2).find_all {|frame| 1207 | frame =~ /in `scan(Variable|Tagged|Codeblock|Bracketed|Quotelike)'/ 1208 | }.length 1209 | end 1210 | 1211 | 1212 | ####### 1213 | private 1214 | ####### 1215 | 1216 | ### Print the specified message to STDERR if the scanner's 1217 | ### debugging level is greater than or equal to level. 1218 | def debugMsg( level, msgFormat, *args ) 1219 | return unless level.nonzero? && self.debugLevel >= level 1220 | msg = if args.empty? then msgFormat else format(msgFormat, *args) end 1221 | $stderr.puts( (" " * (level-1) * 2) + msg ) 1222 | end 1223 | 1224 | 1225 | ### Given a series of one or more bracket characters (eg., '<', '[', '{', 1226 | ### etc.), return the brackets reversed in order and direction. 1227 | def revbracket( bracket ) 1228 | return bracket.to_s.reverse.tr( '<[{(', '>]})' ) 1229 | end 1230 | 1231 | 1232 | ### Given an opening tag of the sort matched by #scanTagged, 1233 | ### construct and return a closing tag. 1234 | def makeClosingTag( tag ) 1235 | debugMsg 3, "Making a closing tag for '%s'" % tag 1236 | 1237 | closingTag = tag.gsub( /^([[(<{]+)(#{XmlName}).*/ ) { 1238 | Regexp.quote( "#{$1}/#{$2}" + revbracket($1) ) 1239 | } 1240 | 1241 | raise MatchFailure, "Unable to construct closing tag to match: #{tag}" unless closingTag 1242 | return closingTag 1243 | end 1244 | 1245 | 1246 | ### Make and return a new Regexp which matches substrings bounded by the 1247 | ### specified +delimiters+, not counting those which have been escaped with 1248 | ### the escape characters in +escapes+. 1249 | def makeDelimPattern( delimiters, escapes='\\', prefix='\\s*' ) 1250 | delimiters = delimiters.to_s 1251 | escapes = escapes.to_s 1252 | 1253 | raise DelimiterError, "Illegal delimiter '#{delimiter}'" unless delimiters =~ /\S/ 1254 | 1255 | # Pad the escapes string to the same length as the delimiters 1256 | escapes.concat( escapes[-1,1] * (delimiters.length - escapes.length) ) 1257 | patParts = [] 1258 | 1259 | # Escape each delimiter and a corresponding escape character, and then 1260 | # build a pattern part from them 1261 | delimiters.length.times do |i| 1262 | del = Regexp.escape( delimiters[i, 1] ) 1263 | esc = Regexp.escape( escapes[i, 1] ) 1264 | 1265 | if del == esc then 1266 | patParts.push "#{del}(?:[^#{del}]*(?:(?:#{del}#{del})[^#{del}]*)*)#{del}" 1267 | else 1268 | patParts.push "#{del}(?:[^#{esc}#{del}]*(?:#{esc}.[^#{esc}#{del}]*)*)#{del}"; 1269 | end 1270 | end 1271 | 1272 | # Join all the parts together and return one big pattern 1273 | return Regexp::new( "#{prefix}(?:#{patParts.join("|")})" ) 1274 | end 1275 | 1276 | end # class StringExtractor 1277 | 1278 | 1279 | -------------------------------------------------------------------------------- /lib/timetrap.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | 3 | require 'chronic' 4 | require 'tempfile' 5 | require 'sequel' 6 | require 'yaml' 7 | require 'erb' 8 | require 'sequel/extensions/inflector' 9 | require File.expand_path(File.join(File.dirname(__FILE__), 'timetrap', 'version')) 10 | require File.expand_path(File.join(File.dirname(__FILE__), 'Getopt/Declare')) 11 | require File.expand_path(File.join(File.dirname(__FILE__), 'timetrap', 'config')) 12 | require File.expand_path(File.join(File.dirname(__FILE__), 'timetrap', 'helpers')) 13 | require File.expand_path(File.join(File.dirname(__FILE__), 'timetrap', 'cli')) 14 | require File.expand_path(File.join(File.dirname(__FILE__), 'timetrap', 'timer')) 15 | require File.expand_path(File.join(File.dirname(__FILE__), 'timetrap', 'formatters')) 16 | require File.expand_path(File.join(File.dirname(__FILE__), 'timetrap', 'auto_sheets')) 17 | module Timetrap 18 | DB_NAME = defined?(TEST_MODE) ? nil : Timetrap::Config['database_file'] 19 | # connect to database. This will create one if it doesn't exist 20 | DB = Sequel.sqlite DB_NAME 21 | # only declare cli options when run as standalone 22 | if %w[dev_t t timetrap].include?(File.basename($PROGRAM_NAME)) || defined?(TEST_MODE) 23 | CLI.args = Getopt::Declare.new(<<-EOF) 24 | #{CLI::USAGE} 25 | EOF 26 | end 27 | end 28 | require File.expand_path(File.join(File.dirname(__FILE__), 'timetrap', 'schema')) 29 | require File.expand_path(File.join(File.dirname(__FILE__), 'timetrap', 'models')) 30 | -------------------------------------------------------------------------------- /lib/timetrap/auto_sheets.rb: -------------------------------------------------------------------------------- 1 | # Namespace for auto_sheet classes 2 | module Timetrap 3 | module AutoSheets 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/timetrap/auto_sheets/dotfiles.rb: -------------------------------------------------------------------------------- 1 | module Timetrap 2 | module AutoSheets 3 | class Dotfiles 4 | def sheet 5 | dotfile = File.join(Dir.pwd, '.timetrap-sheet') 6 | File.read(dotfile).chomp if File.exist?(dotfile) 7 | end 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/timetrap/auto_sheets/nested_dotfiles.rb: -------------------------------------------------------------------------------- 1 | module Timetrap 2 | module AutoSheets 3 | # 4 | # Check the current dir and all parent dirs for .timetrap-sheet 5 | # 6 | class NestedDotfiles 7 | def check_sheet(dir) 8 | dotfile = File.join(dir, '.timetrap-sheet') 9 | File.read(dotfile).chomp if File.exist?(dotfile) 10 | end 11 | 12 | def sheet 13 | dir = Dir.pwd 14 | while true do 15 | sheet = check_sheet dir 16 | break if nil != sheet 17 | new_dir = File.expand_path("..", dir) 18 | break if new_dir == dir 19 | dir = new_dir 20 | end 21 | return sheet 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/timetrap/auto_sheets/yaml_cwd.rb: -------------------------------------------------------------------------------- 1 | module Timetrap 2 | module AutoSheets 3 | ### auto_sheet_paths 4 | # 5 | # Specify which sheet to automatically use in which directories in with the 6 | # following format in timetrap.yml: 7 | # 8 | # auto_sheet_paths: 9 | # Sheet name: /path/to/directory 10 | # More specific sheet: /path/to/directory/that/is/nested 11 | # Other sheet: 12 | # - /path/to/first/directory 13 | # - /path/to/second/directory 14 | # 15 | # **Note** Timetrap will always use the sheet specified in the config file 16 | # if you are in that directory (or in its tree). To use a different sheet, 17 | # you must be in a different directory. 18 | # 19 | class YamlCwd 20 | def sheet 21 | auto_sheet = nil 22 | cwd = "#{Dir.getwd}/" 23 | most_specific = 0 24 | Array(Timetrap::Config['auto_sheet_paths']).each do |sheet, dirs| 25 | Array(dirs).each do |dir| 26 | if cwd.start_with?(dir) && dir.length > most_specific 27 | most_specific = dir.length 28 | auto_sheet = sheet 29 | end 30 | end 31 | end 32 | auto_sheet 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/timetrap/cli.rb: -------------------------------------------------------------------------------- 1 | module Timetrap 2 | module CLI 3 | extend Helpers 4 | attr_accessor :args 5 | extend self 6 | 7 | USAGE = <<-EOF 8 | 9 | Timetrap - Simple Time Tracking 10 | 11 | Usage: #{File.basename $0} COMMAND [OPTIONS] [ARGS...] 12 | 13 | COMMAND can be abbreviated. For example `t in` and `t i` are equivalent. 14 | 15 | COMMAND is one of: 16 | 17 | * archive - Move entries to a hidden sheet (by default named '_[SHEET]') so 18 | they're out of the way. 19 | usage: t archive [--start DATE] [--end DATE] [SHEET] 20 | -s, --start Include entries that start on this date or later 21 | -e, --end Include entries that start on this date or earlier 22 | -g, --grep Include entries where the note matches this regexp. 23 | 24 | * backend - Open an sqlite shell to the database. 25 | usage: t backend 26 | 27 | * configure - Write out a YAML config file. Print path to config file. The 28 | file may contain ERB. 29 | usage: t configure 30 | Currently supported options are: 31 | round_in_seconds: The duration of time to use for rounding with 32 | the -r flag 33 | database_file: The file path of the sqlite database 34 | append_notes_delimiter: delimiter used when appending notes via 35 | t edit --append 36 | formatter_search_paths: an array of directories to search for user 37 | defined fomatter classes 38 | default_formatter: The format to use when display is invoked without a 39 | `--format` option 40 | default_command: The default command to run when calling t. 41 | auto_checkout: Automatically check out of running entries when 42 | you check in or out 43 | require_note: Prompt for a note if one isn't provided when 44 | checking in 45 | note_editor: Command to launch notes editor or false if no editor use. 46 | If you use a non terminal based editor (e.g. sublime, atom) 47 | please read the notes in the README. 48 | week_start: The day of the week to use as the start of the 49 | week for t week. 50 | 51 | * display - Display the current timesheet or a specific. Pass `all' as SHEET 52 | to display all unarchived sheets or `full' to display archived and 53 | unarchived sheets. 54 | usage: t display [--ids] [--start DATE] [--end DATE] [--format FMT] [SHEET | all | full] 55 | -v, --ids Print database ids (for use with edit) 56 | -s, --start Include entries that start on this date or later 57 | -e, --end Include entries that start on this date or earlier 58 | -f, --format The output format. Valid built-in formats are 59 | ical, csv, json, ids, factor, and text (default). 60 | Documentation on defining custom formats can be 61 | found in the README included in this 62 | distribution. 63 | -g, --grep Include entries where the note matches this regexp. 64 | 65 | * edit - Alter an entry's note, start, or end time. Defaults to the active 66 | entry. Defaults to the last entry to be checked out of if no entry is active. 67 | usage: t edit [--id ID] [--start TIME] [--end TIME] [--append] [NOTES] 68 | -i, --id Alter entry with id instead of the running entry 69 | -s, --start Change the start time to