├── lib ├── watch_tower │ ├── cli │ │ ├── .gitkeep │ │ ├── version.rb │ │ ├── open.rb │ │ ├── start.rb │ │ └── install.rb │ ├── core_ext │ │ └── .gitkeep │ ├── editor │ │ ├── .gitkeep │ │ ├── extensions │ │ │ └── watchtower.vim │ │ ├── textmate.rb │ │ ├── xcode.rb │ │ ├── base_ps.rb │ │ ├── base_appscript.rb │ │ └── vim.rb │ ├── project │ │ ├── .gitkeep │ │ ├── any_based.rb │ │ ├── init.rb │ │ ├── git_based.rb │ │ └── path_based.rb │ ├── server │ │ ├── .gitkeep │ │ ├── views │ │ │ ├── .gitkeep │ │ │ ├── index.haml │ │ │ ├── project.haml │ │ │ ├── _project.haml │ │ │ └── layout.haml │ │ ├── assets │ │ │ ├── images │ │ │ │ ├── WatchTower.jpg │ │ │ │ └── percentage.png │ │ │ ├── javascripts │ │ │ │ ├── application.js │ │ │ │ ├── percentage.coffee │ │ │ │ ├── datepicker.coffee │ │ │ │ └── file_tree.coffee │ │ │ └── stylesheets │ │ │ │ ├── date.sass │ │ │ │ ├── application.css │ │ │ │ ├── file_tree.sass │ │ │ │ ├── project.sass │ │ │ │ └── global.sass │ │ ├── vendor │ │ │ └── assets │ │ │ │ ├── images │ │ │ │ ├── calendar.gif │ │ │ │ ├── calendar-blue.gif │ │ │ │ └── calendar-green.gif │ │ │ │ └── stylesheets │ │ │ │ └── jquery.datepick.css │ │ ├── public │ │ │ └── assets │ │ │ │ ├── calendar-379834cd6e6321a940b662ace47f3032.gif │ │ │ │ ├── WatchTower-58eff0713efffbc6054defddc879e0b1.jpg │ │ │ │ ├── percentage-d0176e99520c95e93eee63738ef5d487.png │ │ │ │ ├── calendar-blue-d6aa74feef7ee4287532761db99a6c0a.gif │ │ │ │ ├── calendar-green-3752fe2996091379c8d321f759039385.gif │ │ │ │ └── jquery.datepick-9c8dfe3a4d40bcafc7b182e194c13836.css │ │ ├── db │ │ │ └── migrate │ │ │ │ ├── 008_rename_editor_to_editor_name_in_times_entries.rb │ │ │ │ ├── 009_remove_editor_index_from_time_entries.rb │ │ │ │ ├── 006_add_hash_to_files.rb │ │ │ │ ├── 010_add_editor_version_to_times_entries.rb │ │ │ │ ├── 005_add_hash_to_time_entries.rb │ │ │ │ ├── 007_add_editor_to_times_entries.rb │ │ │ │ ├── 003_create_time_entries.rb │ │ │ │ ├── 004_create_durations.rb │ │ │ │ ├── 001_create_projects.rb │ │ │ │ └── 002_create_files.rb │ │ ├── presenters │ │ │ ├── file_presenter.rb │ │ │ ├── project_presenter.rb │ │ │ └── application_presenter.rb │ │ ├── configurations.rb │ │ ├── presenters.rb │ │ ├── helpers.rb │ │ ├── models │ │ │ ├── duration.rb │ │ │ ├── file.rb │ │ │ ├── project.rb │ │ │ └── time_entry.rb │ │ ├── helpers │ │ │ ├── asset.rb │ │ │ ├── presenters.rb │ │ │ └── improved_partials.rb │ │ ├── configurations │ │ │ └── asset.rb │ │ ├── app.rb │ │ └── database.rb │ ├── core_ext.rb │ ├── project.rb │ ├── cli.rb │ ├── version.rb │ ├── editor.rb │ ├── templates │ │ ├── watchtower.plist.erb │ │ └── config.yml │ ├── appscript.rb │ ├── errors.rb │ ├── config.rb │ ├── server.rb │ ├── file_tree.rb │ └── eye.rb └── watch_tower.rb ├── spec ├── watch_tower │ ├── server_spec.rb │ ├── server │ │ ├── presenters │ │ │ ├── file_presenter_spec.rb │ │ │ ├── application_presenter_spec.rb │ │ │ └── project_presenter_spec.rb │ │ └── models │ │ │ ├── time_entry_spec.rb │ │ │ └── duration_spec.rb │ ├── cli │ │ ├── open_spec.rb │ │ ├── install_spec.rb │ │ └── start_spec.rb │ ├── appscript_spec.rb │ ├── cli_spec.rb │ ├── editor_spec.rb │ ├── editor │ │ ├── xcode_spec.rb │ │ ├── base_appscript_spec.rb │ │ ├── textmate_spec.rb │ │ └── vim_spec.rb │ ├── project_spec.rb │ ├── project │ │ ├── git_based_spec.rb │ │ └── path_based_spec.rb │ ├── file_tree_spec.rb │ ├── config_spec.rb │ └── eye_spec.rb ├── support │ ├── launchy.rb │ ├── timecop.rb │ ├── factory_girl.rb │ ├── sinatra.rb │ └── active_record.rb ├── factories.rb └── spec_helper.rb ├── ci ├── adapters │ ├── ruby-postgresql.yml │ ├── jruby-postgresql.yml │ ├── ruby-sqlite.yml │ ├── jruby-sqlite.yml │ ├── jruby-mysql.yml │ └── ruby-mysql.yml └── travis.rb ├── .gitignore ├── .travis.yml ├── bin └── watchtower ├── Guardfile ├── Gemfile ├── MIT-LICENSE ├── Rakefile ├── CREDITS.md ├── TODO ├── watch_tower.gemspec ├── .todo └── README.md /lib/watch_tower/cli/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/watch_tower/core_ext/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/watch_tower/editor/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/watch_tower/project/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/watch_tower/server/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/watch_tower/server/views/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/watch_tower/server_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Server do 4 | end 5 | -------------------------------------------------------------------------------- /spec/support/launchy.rb: -------------------------------------------------------------------------------- 1 | require 'capybara' 2 | # Put html files in a special folder 3 | Capybara.save_and_open_page_path = 'tmp/capybara/' -------------------------------------------------------------------------------- /spec/support/timecop.rb: -------------------------------------------------------------------------------- 1 | require 'timecop' 2 | 3 | RSpec.configure do |config| 4 | config.before(:each) do 5 | Timecop.return 6 | end 7 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/assets/images/WatchTower.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalbasit/watch_tower/HEAD/lib/watch_tower/server/assets/images/WatchTower.jpg -------------------------------------------------------------------------------- /lib/watch_tower/server/assets/images/percentage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalbasit/watch_tower/HEAD/lib/watch_tower/server/assets/images/percentage.png -------------------------------------------------------------------------------- /lib/watch_tower/server/vendor/assets/images/calendar.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalbasit/watch_tower/HEAD/lib/watch_tower/server/vendor/assets/images/calendar.gif -------------------------------------------------------------------------------- /ci/adapters/ruby-postgresql.yml: -------------------------------------------------------------------------------- 1 | database: 2 | test: 3 | adapter: postgresql 4 | encoding: unicode 5 | database: watch_tower_test 6 | pool: 5 -------------------------------------------------------------------------------- /ci/adapters/jruby-postgresql.yml: -------------------------------------------------------------------------------- 1 | database: 2 | test: 3 | adapter: jdbcpostgresql 4 | encoding: unicode 5 | database: watch_tower_test 6 | pool: 5 -------------------------------------------------------------------------------- /lib/watch_tower/server/vendor/assets/images/calendar-blue.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalbasit/watch_tower/HEAD/lib/watch_tower/server/vendor/assets/images/calendar-blue.gif -------------------------------------------------------------------------------- /ci/adapters/ruby-sqlite.yml: -------------------------------------------------------------------------------- 1 | database: 2 | test: 3 | adapter: sqlite3 4 | database: ~/.watch_tower/databases/test.sqlite3 5 | pool: 5 6 | timeout: 5000 -------------------------------------------------------------------------------- /lib/watch_tower/server/vendor/assets/images/calendar-green.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalbasit/watch_tower/HEAD/lib/watch_tower/server/vendor/assets/images/calendar-green.gif -------------------------------------------------------------------------------- /ci/adapters/jruby-sqlite.yml: -------------------------------------------------------------------------------- 1 | database: 2 | test: 3 | adapter: jdbcsqlite3 4 | database: ~/.watch_tower/databases/test.sqlite3 5 | pool: 5 6 | timeout: 5000 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | .rbx 6 | .rvmrc 7 | tmp 8 | 9 | # Tranmuter output 10 | README.pdf 11 | README.html 12 | 13 | # Sass 14 | .sass-cache 15 | -------------------------------------------------------------------------------- /spec/watch_tower/server/presenters/file_presenter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Server 4 | module Presenters 5 | describe FilePresenter do 6 | end 7 | end 8 | end -------------------------------------------------------------------------------- /ci/adapters/jruby-mysql.yml: -------------------------------------------------------------------------------- 1 | database: 2 | test: 3 | adapter: jdbcmysql 4 | encoding: utf8 5 | reconnect: false 6 | database: watch_tower_test 7 | pool: 5 8 | username: root -------------------------------------------------------------------------------- /ci/adapters/ruby-mysql.yml: -------------------------------------------------------------------------------- 1 | database: 2 | test: 3 | adapter: mysql2 4 | encoding: utf8 5 | reconnect: false 6 | database: watch_tower_test 7 | pool: 5 8 | username: root 9 | -------------------------------------------------------------------------------- /lib/watch_tower/server/public/assets/calendar-379834cd6e6321a940b662ace47f3032.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalbasit/watch_tower/HEAD/lib/watch_tower/server/public/assets/calendar-379834cd6e6321a940b662ace47f3032.gif -------------------------------------------------------------------------------- /lib/watch_tower/core_ext.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'active_support/core_ext' 4 | require 'active_support/dependencies/autoload' 5 | 6 | Dir["#{File.dirname(__FILE__)}/core_ext/**/*.rb"].each { |f| require f } -------------------------------------------------------------------------------- /lib/watch_tower/server/public/assets/WatchTower-58eff0713efffbc6054defddc879e0b1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalbasit/watch_tower/HEAD/lib/watch_tower/server/public/assets/WatchTower-58eff0713efffbc6054defddc879e0b1.jpg -------------------------------------------------------------------------------- /lib/watch_tower/server/public/assets/percentage-d0176e99520c95e93eee63738ef5d487.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalbasit/watch_tower/HEAD/lib/watch_tower/server/public/assets/percentage-d0176e99520c95e93eee63738ef5d487.png -------------------------------------------------------------------------------- /lib/watch_tower/server/public/assets/calendar-blue-d6aa74feef7ee4287532761db99a6c0a.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalbasit/watch_tower/HEAD/lib/watch_tower/server/public/assets/calendar-blue-d6aa74feef7ee4287532761db99a6c0a.gif -------------------------------------------------------------------------------- /lib/watch_tower/server/public/assets/calendar-green-3752fe2996091379c8d321f759039385.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalbasit/watch_tower/HEAD/lib/watch_tower/server/public/assets/calendar-green-3752fe2996091379c8d321f759039385.gif -------------------------------------------------------------------------------- /spec/support/factory_girl.rb: -------------------------------------------------------------------------------- 1 | # Factory girl 2 | require 'factory_girl' 3 | 4 | # Tell factory_girl where to find migrations 5 | FactoryGirl.definition_file_paths << File.expand_path("#{ROOT_PATH}/spec/factories") 6 | FactoryGirl.find_definitions -------------------------------------------------------------------------------- /lib/watch_tower/server/db/migrate/008_rename_editor_to_editor_name_in_times_entries.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | class RenameEditorToEditorNameInTimesEntries < ActiveRecord::Migration 4 | rename_column :time_entries, :editor, :editor_name 5 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/db/migrate/009_remove_editor_index_from_time_entries.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | class RemoveEditorIndexFromTimeEntries < ActiveRecord::Migration 4 | def change 5 | remove_index :time_entries, :editor 6 | end 7 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/presenters/file_presenter.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module Server 5 | module Presenters 6 | class FilePresenter < ApplicationPresenter 7 | end 8 | end 9 | end 10 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | //= require jquery 2 | //= require jquery_ujs 3 | //= require jquery-ui 4 | //= require jquery-datepick 5 | //= require jquery-datepick-ext 6 | //= require jquery-datepick-validation 7 | //= require_tree . -------------------------------------------------------------------------------- /lib/watch_tower/server/db/migrate/006_add_hash_to_files.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | class AddHashToFiles < ActiveRecord::Migration 4 | def change 5 | add_column :files, :file_hash, :string, null: false, default: "" 6 | add_index :files, :file_hash 7 | end 8 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/configurations.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module Server 5 | module Configurations 6 | extend ::ActiveSupport::Autoload 7 | 8 | # Sinatra configurations 9 | autoload :Asset 10 | end 11 | end 12 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/db/migrate/010_add_editor_version_to_times_entries.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | class AddEditorVersionToTimesEntries < ActiveRecord::Migration 4 | def change 5 | add_column :time_entries, :editor_version, :string, null: false, default: "" 6 | end 7 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/db/migrate/005_add_hash_to_time_entries.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | class AddHashToTimeEntries < ActiveRecord::Migration 4 | def change 5 | add_column :time_entries, :file_hash, :string, null: false, default: "" 6 | add_index :time_entries, :file_hash 7 | end 8 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/db/migrate/007_add_editor_to_times_entries.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | class AddEditorToTimesEntries < ActiveRecord::Migration 4 | def change 5 | add_column :time_entries, :editor, :string, null: false, default: "" 6 | add_index :time_entries, :editor 7 | end 8 | end -------------------------------------------------------------------------------- /spec/support/sinatra.rb: -------------------------------------------------------------------------------- 1 | require 'capybara' 2 | require 'capybara/dsl' 3 | 4 | Capybara.app = WatchTower::Server::App 5 | 6 | RSpec.configure do |config| 7 | config.include Capybara::DSL, :example_group => { 8 | :file_path => config.escaped_path(%w[spec watch_tower server]) 9 | } 10 | end 11 | -------------------------------------------------------------------------------- /spec/watch_tower/cli/open_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CLI do 4 | describe "Open" do 5 | before(:all) do 6 | @valid_initialize_options = [] 7 | end 8 | 9 | subject { CLI::Runner.new(@valid_initialize_options) } 10 | 11 | it { should respond_to :open } 12 | 13 | end 14 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/presenters.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module Server 5 | module Presenters 6 | extend ::ActiveSupport::Autoload 7 | 8 | autoload :ApplicationPresenter 9 | autoload :ProjectPresenter 10 | autoload :FilePresenter 11 | end 12 | end 13 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/views/index.haml: -------------------------------------------------------------------------------- 1 | %section#projects 2 | - if @projects.any? 3 | %header 4 | .name Project 5 | .percentage Percentage 6 | .elapsed Total time 7 | .clearfix 8 | = partial('project', collection: @projects) 9 | - else 10 | No projects available for the selected date range. -------------------------------------------------------------------------------- /lib/watch_tower/project.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'watch_tower/project/any_based' 4 | require 'watch_tower/project/init' 5 | 6 | module WatchTower 7 | class Project 8 | extend ::ActiveSupport::Autoload 9 | 10 | autoload :GitBased 11 | autoload :PathBased 12 | 13 | include AnyBased 14 | include Init 15 | end 16 | end -------------------------------------------------------------------------------- /spec/watch_tower/appscript_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'watch_tower/appscript' 3 | 4 | module ::Appscript 5 | describe "Module" do 6 | subject do 7 | Class.new do 8 | extend ::Appscript 9 | end 10 | end 11 | 12 | describe "#app" do 13 | it { should respond_to :app } 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/watch_tower/server/helpers.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'sinatra-snap' 4 | 5 | module WatchTower 6 | module Server 7 | module Helpers 8 | extend ::ActiveSupport::Autoload 9 | 10 | # Sinatra helpers 11 | autoload :ImprovedPartials 12 | autoload :Asset 13 | autoload :Presenters 14 | end 15 | end 16 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/assets/stylesheets/date.sass: -------------------------------------------------------------------------------- 1 | aside#date 2 | float: right 3 | width: 200px 4 | height: 30px 5 | padding: 1px 6 | border: 1px solid #CDCDCD 7 | -webkit-border-radius: 5px 8 | -moz-border-radius: 5px 9 | border-radius: 5px 10 | 11 | &:hover 12 | border: 1px solid #AAAAAA 13 | 14 | input 15 | border: none 16 | width: 199px 17 | height: 21px -------------------------------------------------------------------------------- /lib/watch_tower/server/db/migrate/003_create_time_entries.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | class CreateTimeEntries < ActiveRecord::Migration 4 | def change 5 | create_table :time_entries do |t| 6 | t.references :file, null: false 7 | t.datetime :mtime, null: false 8 | 9 | t.timestamps 10 | end 11 | 12 | add_index :time_entries, :file_id 13 | end 14 | end -------------------------------------------------------------------------------- /spec/watch_tower/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CLI do 4 | before(:all) do 5 | @valid_initialize_options = [] 6 | end 7 | 8 | subject { CLI::Runner.new(@valid_initialize_options) } 9 | 10 | describe "Thor definition" do 11 | subject { CLI::Runner } 12 | it { should respond_to(:desc) } 13 | it { should respond_to(:method_option) } 14 | end 15 | end -------------------------------------------------------------------------------- /spec/watch_tower/cli/install_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CLI do 4 | describe "Install" do 5 | before(:all) do 6 | @valid_initialize_options = [] 7 | end 8 | 9 | subject { CLI::Runner.new(@valid_initialize_options) } 10 | 11 | it { should respond_to :install } 12 | 13 | it "should copy the config file" 14 | it "should copy the bootloader" 15 | end 16 | end -------------------------------------------------------------------------------- /lib/watch_tower/cli.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'thor' 4 | 5 | # Load all modules 6 | Dir["#{LIB_PATH}/cli/**/*.rb"].each { |f| require f } 7 | 8 | module WatchTower 9 | module CLI 10 | class Runner < ::Thor 11 | # Include cli modules 12 | include CLI::Version 13 | include CLI::Install 14 | include CLI::Open 15 | include CLI::Start 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/watch_tower/version.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | MAJOR = 0 5 | MINOR = 0 6 | TINY = 3 7 | PRE = '' 8 | 9 | def self.version 10 | # Init the version 11 | version = [MAJOR, MINOR, TINY] 12 | # Add the pre if available 13 | version << PRE unless PRE.nil? || PRE !~ /\S/ 14 | # Return the version joined by a dot 15 | version.join('.') 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | script: 'ci/travis.rb' 2 | branches: 3 | only: 4 | - master 5 | - stable 6 | rvm: 7 | - 1.9.2 8 | - 1.9.3 9 | # - jruby 10 | # - rbx-2.0 11 | env: 12 | - WATCH_TOWER_ENV=test RBXOPT="${RBXOPT} -X19" JRUBY_OPTS="${JRUBY_OPTS} --1.9" ADAPTERS="sqlite:mysql:postgresql" 13 | notifications: 14 | recipients: 15 | - wael.nasreddine@gmail.com 16 | email: 17 | on_success: change 18 | on_failure: always -------------------------------------------------------------------------------- /lib/watch_tower/server/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll automatically include all the stylesheets available in this directory 3 | * and any sub-directories. You're free to add application-wide styles to this file and they'll appear at 4 | * the top of the compiled file, but it's generally better to create a new file per style scope. 5 | *= require_self 6 | *= require jquery.datepick 7 | *= require_tree . 8 | */ -------------------------------------------------------------------------------- /lib/watch_tower/server/db/migrate/004_create_durations.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | class CreateDurations < ActiveRecord::Migration 4 | def change 5 | create_table :durations do |t| 6 | t.references :file, null: false 7 | t.date :date, null: false 8 | t.integer :duration, default: 0 9 | 10 | t.timestamps 11 | end 12 | 13 | add_index :durations, :file_id 14 | add_index :durations, :date 15 | end 16 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/db/migrate/001_create_projects.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | class CreateProjects < ActiveRecord::Migration 4 | def change 5 | create_table :projects do |t| 6 | t.string :name, null: false 7 | t.string :path, null: false 8 | t.integer :elapsed_time, default: 0 9 | t.integer :files_count 10 | 11 | t.timestamps 12 | end 13 | 14 | add_index :projects, :name 15 | add_index :projects, :path 16 | end 17 | end -------------------------------------------------------------------------------- /spec/watch_tower/editor_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Editor do 4 | describe "#editors" do 5 | it { should respond_to :editors} 6 | 7 | it "should return an array of editors" do 8 | subject.editors.should be_instance_of Array 9 | end 10 | 11 | it "should return Textmate" do 12 | subject.editors.should include Editor::Textmate 13 | end 14 | 15 | it "should return Xcode" do 16 | subject.editors.should include Editor::Xcode 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/watch_tower/cli/start_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CLI do 4 | describe "Start" do 5 | before(:all) do 6 | @valid_initialize_options = [] 7 | end 8 | 9 | subject { CLI::Runner.new(@valid_initialize_options) } 10 | 11 | it { should respond_to :start } 12 | 13 | it "should allow running in the foreground" 14 | it "should allow setting the host/port of the sinatra app" 15 | it "should be able to determine if the bootloader called watchtower or not" 16 | end 17 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/db/migrate/002_create_files.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | class CreateFiles < ActiveRecord::Migration 4 | def change 5 | create_table :files do |t| 6 | t.references :project, null: false 7 | t.string :path, null: false 8 | t.integer :elapsed_time, default: 0 9 | t.integer :time_entries_count 10 | t.integer :durations_count 11 | 12 | t.timestamps 13 | end 14 | 15 | add_index :files, :project_id 16 | add_index :files, :path, unique: true 17 | end 18 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/views/project.haml: -------------------------------------------------------------------------------- 1 | - present @project do |presenter| 2 | .back 3 | %a{:href => '/'} « Home 4 | .clearfix 5 | %article#project 6 | %header 7 | %h1.project_name= presenter.name.camelcase 8 | .approximate_elapsed= "(about #{presenter.approximate_elapsed})" 9 | .clearfix 10 | %h2.project_path= presenter.path 11 | %section#files 12 | - if @files.any? 13 | = presenter.file_tree(@files) 14 | - else 15 | No files available for the selected date range. -------------------------------------------------------------------------------- /lib/watch_tower/project/any_based.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | class Project 5 | module AnyBased 6 | 7 | def self.included(base) 8 | base.send :include, InstanceMethods 9 | base.extend ClassMethods 10 | end 11 | 12 | module InstanceMethods 13 | attr_reader :name, :path 14 | end 15 | 16 | module ClassMethods 17 | protected 18 | def expand_path(path) 19 | File.expand_path(path) 20 | end 21 | end 22 | end 23 | end 24 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/views/_project.haml: -------------------------------------------------------------------------------- 1 | - present project do |presenter| 2 | %article.project[project] 3 | .name 4 | %a{href: path_to(:project).with(presenter.id), alt: presenter.path} 5 | = presenter.name.camelcase 6 | .percentage_img_container 7 | .percentage 8 | %img.percentage_img{src: asset_path('percentage.png'), alt: "#{presenter.percent}% of total time", 'data-width' => presenter.percent}/ 9 | .elapsed= presenter.elapsed(project.durations.date_range(session[:date_filtering][:from_date], session[:date_filtering][:to_date]).sum(:duration)) 10 | .clearfix -------------------------------------------------------------------------------- /lib/watch_tower/cli/version.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module CLI 5 | module Version 6 | 7 | def self.included(base) 8 | base.send :include, InstanceMethods 9 | end 10 | 11 | module InstanceMethods 12 | def self.included(base) 13 | base.class_eval <<-END, __FILE__, __LINE__ + 1 14 | desc "version", "Prints watchtower version and exits." 15 | def version 16 | puts "WatchTower version \#{WatchTower.version}" 17 | end 18 | END 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/watch_tower/server/presenters/project_presenter.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module Server 5 | module Presenters 6 | class ProjectPresenter < ApplicationPresenter 7 | 8 | # Return a file tree representation of a bunch of files 9 | # 10 | # @return [String] HTML representation of a file tree 11 | def file_tree(files) 12 | # Create a FileTree 13 | file_tree = FileTree.new(files.first.project.path, files) 14 | # Parse and return the tree 15 | parse_file_tree(file_tree, true) 16 | end 17 | end 18 | end 19 | end 20 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/assets/javascripts/percentage.coffee: -------------------------------------------------------------------------------- 1 | window.percentage = 2 | applyPercentage: -> 3 | ($ '.percentage_img').each (index, element) -> 4 | width = ($ element).attr('data-width') 5 | percentage.animateGrowth(element, width * 3) 6 | 7 | animateGrowth: (domId, width, overGrow = false) -> 8 | overGrowth = width + 50 9 | overGrowth = 300 if overGrowth > 300 10 | if overGrow 11 | ($ domId).effect 'size', { to: { width: width } }, 1000 12 | else 13 | ($ domId).effect 'size', { to: { width: overGrowth } }, 1000, -> 14 | percentage.animateGrowth domId, width, true 15 | 16 | 17 | jQuery -> 18 | percentage.applyPercentage() -------------------------------------------------------------------------------- /lib/watch_tower/editor.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module Editor 5 | extend ::ActiveSupport::Autoload 6 | 7 | autoload :BaseAppscript 8 | autoload :BasePs 9 | autoload :Textmate 10 | autoload :Xcode 11 | autoload :Vim 12 | 13 | def self.editors 14 | Editor.constants. # Collect the defined constants 15 | collect { |c| "::WatchTower::Editor::#{c}"}. # Access them under the Server module 16 | collect(&:constantize). # Make them a constant 17 | select { |c| c.class == Class } # Keep only classes 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/watch_tower/templates/watchtower.plist.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Label 6 | fr.technogate.WatchTower 7 | ProgramArguments 8 | 9 | <%= ruby_binary %> 10 | <%= watch_tower_binary %> 11 | start 12 | --bootloader 13 | --foreground 14 | 15 | KeepAlive 16 | 17 | OnDemand 18 | 19 | RunAtLoad 20 | 21 | 22 | -------------------------------------------------------------------------------- /lib/watch_tower/editor/extensions/watchtower.vim: -------------------------------------------------------------------------------- 1 | " This function prints the list of open files 2 | " Usage: 3 | " vim --servername "" --remote-expr "watchtower#ls()" 4 | 5 | " Make sure the function is loaded only once 6 | if exists("loaded_watchtower_ls_function") 7 | finish 8 | endif 9 | let loaded_watchtower_ls_function = 1 10 | 11 | " Provided by Marcin Szamotulski 12 | " Modified by Wael Nasreddine 13 | " http://groups.google.com/group/vim_use/msg/3dfb796c366b2e50 14 | function! watchtower#ls() 15 | let list=[] 16 | for i in range(1, bufnr('$')) 17 | call add(list, fnamemodify(bufname(i), ':p')) 18 | endfor 19 | return list 20 | endfunction 21 | -------------------------------------------------------------------------------- /lib/watch_tower/appscript.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | begin 4 | require 'rubygems' 5 | require 'appscript' 6 | rescue LoadError 7 | require 'rbconfig' 8 | if RbConfig::CONFIG['target_os'] =~ /darwin/i 9 | STDERR.puts "Please install 'rb-appscript' to use this gem with Textmate and Xcode" 10 | STDERR.puts "gem install rb-appscript" 11 | end 12 | 13 | # Define a part of the Appscript gem so WatchTower is fully operational 14 | module ::Appscript 15 | CommandError = Class.new(Exception) 16 | def app(*args) 17 | raise ::WatchTower::AppscriptNotLoadedError 18 | end 19 | end 20 | 21 | module ::FindApp 22 | ApplicationNotFoundError = Class.new(Exception) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/watch_tower/server/models/duration.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module Server 5 | class Duration < ::ActiveRecord::Base 6 | # Scopes 7 | scope :before_date, lambda { |date| where('date <= ?', Date.strptime(date, '%m/%d/%Y')) } 8 | scope :after_date, lambda { |date| where('date >= ?', Date.strptime(date, '%m/%d/%Y')) } 9 | scope :date_range, lambda { |from, to| after_date(from).before_date(to) } 10 | 11 | # Validations 12 | validates :file_id, presence: true 13 | validates :date, presence: true 14 | validates :duration, presence: true 15 | 16 | # Associations 17 | belongs_to :file, counter_cache: true 18 | end 19 | end 20 | end -------------------------------------------------------------------------------- /lib/watch_tower/cli/open.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module CLI 5 | module Open 6 | 7 | def self.included(base) 8 | base.send :include, InstanceMethods 9 | end 10 | 11 | module InstanceMethods 12 | def self.included(base) 13 | base.class_eval <<-END, __FILE__, __LINE__ + 1 14 | # Open the WatchTower server in the browser 15 | # 16 | # TODO: Should be able to determine the port of the server. 17 | desc "open", "Open the WatchTower in the browser" 18 | def open 19 | system "open http://localhost:9282" 20 | end 21 | END 22 | end 23 | end 24 | end 25 | end 26 | end -------------------------------------------------------------------------------- /bin/watchtower: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # -*- encoding: utf-8 -*- 3 | 4 | # Set the environment to production unless explicitly set 5 | ENV['WATCH_TOWER_ENV'] ||= 'production' 6 | # Add the lib folder to the load path 7 | $:.push File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')) 8 | # Load watch_tower 9 | require 'watch_tower' 10 | # Verify that mac users have rb-appscript installed 11 | require 'rbconfig' 12 | if RbConfig::CONFIG['target_os'] =~ /darwin/i 13 | begin 14 | require 'rubygems' 15 | require 'appscript' 16 | rescue LoadError 17 | STDERR.puts "Please install 'rb-appscript' to use this gem with Textmate and Xcode" 18 | STDERR.puts "gem install rb-appscript" 19 | exit 20 | end 21 | end 22 | # Start the CLI 23 | WatchTower::CLI::Runner.start 24 | -------------------------------------------------------------------------------- /lib/watch_tower/editor/textmate.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module Editor 5 | class Textmate 6 | include BaseAppscript 7 | 8 | protected 9 | # This method returns an instance of ::Appscript::Application 10 | # 11 | # returns [::Appscript::Application | nil] 12 | def editor 13 | app 'Textmate' 14 | rescue AppscriptNotLoadedError 15 | # This is expected if appscriot not loaded, on linux for example 16 | rescue ::FindApp::ApplicationNotFoundError 17 | LOG.debug "#{__FILE__}:#{__LINE__ - 4}: Textmate application can't be found, maybe not installed?" 18 | nil 19 | rescue ::Appscript::CommandError => e 20 | LOG.error "#{__FILE__}:#{__LINE__ - 7}: Command error #{e}" 21 | nil 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path(File.join(File.dirname(__FILE__), 'lib')) 2 | require 'watch_tower' 3 | 4 | guard 'bundler' do 5 | watch('Gemfile') 6 | watch(/^.+\.gemspec/) 7 | end 8 | 9 | guard 'sprockets2', 10 | sprockets: WatchTower::Server::App.sprockets, 11 | assets_path: 'lib/watch_tower/server/public/assets', 12 | gz: false do 13 | watch(%r{^lib/watch_tower/server/assets/.+$}) 14 | watch('lib/watch_tower/server/app.rb') 15 | end 16 | 17 | guard 'rspec', :version => 2 do 18 | # All specs 19 | watch(%r{^spec/.+_spec\.rb$}) 20 | watch('spec/spec_helper.rb') { "spec" } 21 | watch(%r{spec/factories/(.+)\.rb} ) { "spec" } 22 | watch(%r{spec/support/(.+)\.rb} ) { "spec" } 23 | 24 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 25 | watch(%r(^lib/watch_tower/server/(views|extensions|presenters)/(.+)$)) { "spec/watch_tower/server/app_spec.rb" } 26 | end 27 | -------------------------------------------------------------------------------- /lib/watch_tower/server/helpers/asset.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module Server 5 | module Helpers 6 | module Asset 7 | 8 | def self.included(base) 9 | base.send :include, InstanceMethods 10 | end 11 | 12 | module InstanceMethods 13 | 14 | # Define partial as a helper 15 | helpers do 16 | # Get the asset path of a given source 17 | # 18 | # Code taken from 19 | # https://github.com/stevehodgkiss/sinatra-asset-pipeline/blob/master/app.rb#L11 20 | # 21 | # @param [String] The source file 22 | # @return [String] The path to the asset 23 | def asset_path(source) 24 | "/assets/" + settings.sprockets.find_asset(source).digest_path 25 | end 26 | end 27 | end 28 | end 29 | end 30 | end 31 | end -------------------------------------------------------------------------------- /spec/factories.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | factory :project, class: Server::Project do 3 | name 4 | path 5 | end 6 | 7 | factory :file, class: Server::File do 8 | project 9 | file_hash 10 | path 11 | end 12 | 13 | factory :time_entry, class: Server::TimeEntry do 14 | file 15 | file_hash 16 | editor_name "Textmate" 17 | editor_version "1.5.10" 18 | mtime 19 | end 20 | 21 | factory :duration, class: Server::Duration do 22 | file 23 | date 24 | duration { Random.rand(1000) } 25 | end 26 | 27 | sequence :name do |n| 28 | "project_#{n}" 29 | end 30 | 31 | sequence :path do |n| 32 | "/path/to/project_#{n}" 33 | end 34 | 35 | sequence :mtime do |n| 36 | Time.now + 2 * n 37 | end 38 | 39 | sequence :date do |n| 40 | Time.now + 2 * n 41 | end 42 | 43 | sequence :file_hash do |n| 44 | require 'digest/sha1' 45 | Digest::SHA1.hexdigest('WatchTower' * n) 46 | end 47 | end -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')) 3 | SPEC_PATH = File.expand_path(File.dirname(__FILE__)) 4 | 5 | ENV['WATCH_TOWER_ENV'] = 'test' 6 | 7 | require 'rubygems' 8 | require 'rspec' 9 | 10 | # Require the library 11 | require 'watch_tower' 12 | 13 | include WatchTower 14 | 15 | RSpec.configure do |config| 16 | def config.escaped_path(*parts) 17 | Regexp.compile(parts.join('[\\\/]')) 18 | end unless config.respond_to? :escaped_path 19 | 20 | # == Mock Framework 21 | # 22 | # If you prefer to use mocha, flexmock or RR, uncomment the appropriate line: 23 | # 24 | config.mock_with :mocha 25 | # config.mock_with :flexmock 26 | # config.mock_with :rr 27 | # config.mock_with :rspec 28 | 29 | # Treat symbols as metadata keys with true values 30 | config.treat_symbols_as_metadata_keys_with_true_values = true 31 | end 32 | 33 | # Require support files 34 | Dir[ROOT_PATH + "/spec/support/**/*.rb"].each {|f| require f} 35 | -------------------------------------------------------------------------------- /lib/watch_tower/server/assets/stylesheets/file_tree.sass: -------------------------------------------------------------------------------- 1 | .file_tree 2 | background: #f7f7f7 3 | padding: 10px 4 | min-height: 16px 5 | border: 1px solid #cdcdcd 6 | -webkit-border-radius: 5px 7 | -moz-border-radius: 5px 8 | border-radius: 5px 9 | 10 | .collapsed, .expanded 11 | float: left 12 | text-align: center 13 | width: 10px 14 | height: 9px 15 | margin-right: 10px 16 | background: #fff 17 | padding: 0 3px 7px 3px 18 | -webkit-border-radius: 29px 19 | -moz-border-radius: 29px 20 | border-radius: 29px 21 | 22 | &:hover 23 | background: #5D4BFF 24 | color: #fff 25 | cursor: pointer 26 | 27 | .name 28 | float: left 29 | .path 30 | float: left 31 | width: auto !important 32 | .elapsed_time 33 | float: right 34 | .files 35 | display: none 36 | list-style: none 37 | margin: 0 38 | 39 | .folder_wrapper, .file 40 | border-bottom: 1px dashed #BCBCBC 41 | 42 | .nested_folder 43 | display: none 44 | & 45 | margin-left: 15px -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # Sources 2 | source "http://rubygems.org" 3 | 4 | # Parse watch_tower.gemspec 5 | gemspec 6 | 7 | #### 8 | # For development or testing 9 | ### 10 | 11 | # Require rbconfig to figure out the target OS 12 | require 'rbconfig' 13 | 14 | platforms :jruby do 15 | gem 'activerecord-jdbcmysql-adapter' 16 | gem 'activerecord-jdbcpostgresql-adapter' 17 | gem 'activerecord-jdbcsqlite3-adapter' 18 | end 19 | 20 | platforms :ruby do 21 | gem 'mysql2' 22 | gem 'sqlite3' 23 | 24 | unless ENV['TRAVIS'] 25 | if RbConfig::CONFIG['target_os'] =~ /darwin/i 26 | gem 'rb-fsevent', require: false 27 | gem 'ruby-growl', require: false 28 | gem 'growl', require: false 29 | end 30 | if RbConfig::CONFIG['target_os'] =~ /linux/i 31 | gem 'rb-inotify', require: false 32 | gem 'libnotify', require: false 33 | gem 'therubyracer', require: false 34 | end 35 | end 36 | end 37 | 38 | platforms :mri do 39 | gem 'pg' 40 | 41 | if RbConfig::CONFIG['target_os'] =~ /darwin/i 42 | gem 'rb-appscript', '~>0.6.1' 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/watch_tower/server/helpers/presenters.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module Server 5 | module Helpers 6 | module Presenters 7 | 8 | def self.included(base) 9 | base.send :include, InstanceMethods 10 | end 11 | 12 | module InstanceMethods 13 | 14 | # Define partial as a helper 15 | helpers do 16 | # Present an object 17 | # Usually called with a block, the method yields the presenter into 18 | # the block 19 | # 20 | # @param [ActiveRecord::Base] Object: The model to present 21 | # @param [Nil | Object] klass: The klass to present 22 | def present(object, klass = nil) 23 | klass ||= "::WatchTower::Server::Presenters::#{object.class.to_s.split('::').last}Presenter".constantize 24 | presenter = klass.new(object, self) 25 | yield presenter if block_given? 26 | presenter 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 TechnoGate 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/watch_tower/editor/xcode.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module Editor 5 | class Xcode 6 | include BaseAppscript 7 | 8 | protected 9 | # This method returns an instance of ::Appscript::Application 10 | # 11 | # returns [::Appscript::Application | nil] 12 | def editor 13 | # Cannot use app('Xcode') because it fails when multiple Xcode versions are installed 14 | # Taken from timetap 15 | # https://github.com/apalancat/timetap/blob/editors/lib/time_tap/editors.rb#L25 16 | pid = app('System Events').processes[its.name.eq('Xcode')].first.unix_id.get 17 | app.by_pid(pid) 18 | rescue AppscriptNotLoadedError 19 | # This is expected if appscriot not loaded, on linux for example 20 | rescue ::FindApp::ApplicationNotFoundError 21 | LOG.debug "#{__FILE__}:#{__LINE__ - 5}: Xcode application can't be found, maybe not installed?" 22 | nil 23 | rescue ::Appscript::CommandError 24 | LOG.debug "#{__FILE__}:#{__LINE__ - 7}: Xcode is not running." 25 | nil 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/watch_tower/server/assets/javascripts/datepicker.coffee: -------------------------------------------------------------------------------- 1 | window.date_filtering = 2 | handleResponse: (response, textStatus, jqXHR) -> 3 | ($ '.page').html(response) 4 | percentage.applyPercentage() 5 | file_tree.bind_event_on_buttons() 6 | 7 | updateMetaTags: (dates) -> 8 | ($ 'meta[name="date_filtering_from"]').attr 'content', $.datepick.formatDate(dates[0]) 9 | ($ 'meta[name="date_filtering_to"]').attr 'content', $.datepick.formatDate(dates[1]) 10 | 11 | updateDatePickerDates: -> 12 | from_date = ($ 'meta[name="date_filtering_from"]').attr 'content' 13 | to_date = ($ 'meta[name="date_filtering_to"]').attr 'content' 14 | ($ '#date input').datepick 'setDate', [from_date, to_date] 15 | 16 | handleDatePicker: (dates) -> 17 | options = { from_date: $.datepick.formatDate(dates[0]), to_date: $.datepick.formatDate(dates[1]) } 18 | url = window.location.pathname 19 | date_filtering.updateMetaTags dates 20 | $.get url, options, date_filtering.handleResponse 21 | 22 | jQuery -> 23 | ($ '#date input').datepick 24 | rangeSelect: true, 25 | monthsToShow: 2, 26 | alignment: 'bottomRight', 27 | onClose: date_filtering.handleDatePicker 28 | date_filtering.updateDatePickerDates() 29 | -------------------------------------------------------------------------------- /lib/watch_tower/server/assets/javascripts/file_tree.coffee: -------------------------------------------------------------------------------- 1 | window.file_tree = 2 | bind_event_on_buttons: -> 3 | ($ '.collapsed').each (index, element) -> 4 | ($ element).bind 'click', -> 5 | file_tree.handle_button element 6 | handle_button: (domId) -> 7 | if ($ domId).hasClass "collapsed" 8 | ($ domId).removeClass "collapsed" 9 | ($ domId).addClass "expanded" 10 | ($ domId).text '-' 11 | file_tree.show_folder domId 12 | else 13 | ($ domId).removeClass "expanded" 14 | ($ domId).addClass "collapsed" 15 | ($ domId).text '+' 16 | file_tree.hide_folder domId 17 | 18 | show_folder: (button_domId) -> 19 | ($ button_domId).parent().parent().children('.nested_folder').each (index, element) -> 20 | ($ element).show() 21 | ($ button_domId).parent().parent().children('.files').each (index, element) -> 22 | ($ element).show() 23 | 24 | 25 | hide_folder: (button_domId) -> 26 | ($ button_domId).parent().parent().children('.nested_folder').each (index, element) -> 27 | ($ element).hide() 28 | ($ button_domId).parent().parent().children('.files').each (index, element) -> 29 | ($ element).hide() 30 | 31 | jQuery ($) -> 32 | file_tree.bind_event_on_buttons() 33 | -------------------------------------------------------------------------------- /lib/watch_tower/editor/base_ps.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module Editor 5 | module BasePs 6 | def self.included(base) 7 | base.send :include, InstanceMethods 8 | end 9 | 10 | module InstanceMethods 11 | def self.included(base) 12 | base.class_eval <<-END, __FILE__, __LINE__ + 1 13 | # Returns the name of the editor 14 | # 15 | # Child class should implement this method 16 | def name 17 | raise NotImplementedError, "Please define this function in your class." 18 | end 19 | 20 | # Returns the version of the editor 21 | # 22 | # Child class should implement this method 23 | def version 24 | raise NotImplementedError, "Please define this function in your class." 25 | end 26 | 27 | # The editor's name for the log 28 | # Child classes can overwrite this method 29 | # 30 | # @return [String] 31 | def to_s 32 | "\#{self.class.to_s.split('::').last} Editor Version \#{version}" 33 | end 34 | END 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/watch_tower/server/assets/stylesheets/project.sass: -------------------------------------------------------------------------------- 1 | =project_name 2 | float: left 3 | width: 200px 4 | 5 | =percentage 6 | width: 310px 7 | float: left 8 | .percentage_img 9 | height: 20px 10 | 11 | =elapsed 12 | float: left 13 | 14 | =header_text 15 | font-size: 20px 16 | text-decoration: underline 17 | 18 | #projects 19 | header 20 | .name 21 | +project_name 22 | +header_text 23 | 24 | .percentage 25 | +percentage 26 | +header_text 27 | .elapsed 28 | +elapsed 29 | +header_text 30 | 31 | .project 32 | .name 33 | +project_name 34 | .percentage_img_container 35 | +percentage 36 | .elapsed 37 | +elapsed 38 | 39 | #project 40 | header 41 | .project_name 42 | float: left 43 | .approximate_elapsed 44 | float: left 45 | color: #898989 46 | margin: 20px 0 0 10px 47 | font-size: 1.5em 48 | #files 49 | header 50 | .path 51 | +header_text 52 | float: left 53 | width: 600px 54 | .elapsed 55 | +header_text 56 | float: left 57 | 58 | .path 59 | float: left 60 | width: 600px 61 | .elapsed 62 | float: left 63 | .percentage_img_container 64 | width: 310px 65 | float: left 66 | .percentage_img 67 | height: 20px 68 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | # Require RSpec tasks 4 | require 'rspec/core/rake_task' 5 | RSpec::Core::RakeTask.new(:spec) 6 | 7 | # Monkey patch Bundler::GemHelper 8 | # 9 | # Git flow create the tag after finishing a release however this breaks 10 | # rake release because it expects that no tag for the current version 11 | # is present, this patch overrides this behaviour to skip version tagging if 12 | # the tag already exists instead of raising an exception 13 | Bundler::GemHelper.class_eval <<-END, __FILE__, __LINE__ + 1 14 | # Tag the current version 15 | def tag_version 16 | unless already_tagged? 17 | sh %(git tag -a -m "Version \#{version}" \#{version_tag}) 18 | Bundler.ui.confirm "Tagged \#{version_tag}" 19 | end 20 | yield if block_given? 21 | rescue 22 | Bundler.ui.error "Untagged \#{version_tag} due to error" 23 | sh_with_code "git tag -d \#{version_tag}" 24 | raise 25 | end 26 | 27 | # The original method raises an exception, we should override it 28 | def guard_already_tagged 29 | end 30 | 31 | # This method check if the tag has already been tagged 32 | def already_tagged? 33 | if sh('git tag').split(/\n/).include?(version_tag) 34 | true 35 | else 36 | false 37 | end 38 | end 39 | END 40 | 41 | # The default task is tests 42 | task :default => :spec -------------------------------------------------------------------------------- /spec/watch_tower/server/models/time_entry_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Server 4 | describe TimeEntry do 5 | describe "Attributes" do 6 | it { should respond_to :mtime } 7 | end 8 | 9 | describe "Validations" do 10 | it { should_not be_valid } 11 | 12 | it "should require an mtime" do 13 | t = FactoryGirl.build :time_entry, mtime: nil 14 | t.should_not be_valid 15 | end 16 | 17 | it "should require a hash" do 18 | m = FactoryGirl.build :time_entry, file_hash: nil 19 | m.should_not be_valid 20 | end 21 | 22 | it "should require an editor_name" do 23 | m = FactoryGirl.build :time_entry, editor_name: nil 24 | m.should_not be_valid 25 | end 26 | 27 | it "should require an editor_version" do 28 | m = FactoryGirl.build :time_entry, editor_version: nil 29 | m.should_not be_valid 30 | end 31 | 32 | it "should be valid if attributes requirements are met" do 33 | t = FactoryGirl.build :time_entry 34 | t.should be_valid 35 | end 36 | end 37 | 38 | describe "Associations" do 39 | it "should belong to a file" do 40 | f = FactoryGirl.create :file 41 | t = FactoryGirl.create :time_entry, file: f 42 | 43 | t.file.should == f 44 | end 45 | end 46 | end 47 | end -------------------------------------------------------------------------------- /lib/watch_tower/errors.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | # Global Error 5 | WatchTowerError = Class.new Exception 6 | 7 | # Exceptions raised by the Project module 8 | ProjectError = Class.new WatchTowerError 9 | FileNotFound = Class.new ProjectError 10 | 11 | # Exception raised by the Path module 12 | PathError = Class.new ProjectError 13 | PathNotUnderCodePath = Class.new PathError 14 | 15 | # Appscript errors 16 | AppscriptNotLoadedError = Class.new WatchTowerError 17 | 18 | # Exception raised by the Editor module 19 | EditorError = Class.new WatchTowerError 20 | TextmateError = Class.new EditorError 21 | XcodeError = Class.new EditorError 22 | VimError = Class.new EditorError 23 | VimVersionNotPrinted = Class.new VimError 24 | 25 | # Exceptions raised by the Server module 26 | ServerError = Class.new WatchTowerError 27 | DatabaseError = Class.new ServerError 28 | DatabaseConfigNotFoundError = Class.new DatabaseError 29 | 30 | # Exceptions raised by the Eye module 31 | EyeError = Class.new WatchTowerError 32 | 33 | # Exceptions raised by the Config module 34 | ConfigError = Class.new WatchTowerError 35 | ConfigNotReadableError = Class.new ConfigError 36 | ConfigNotFound = Class.new ConfigError 37 | ConfigNotDefinedError = Class.new ConfigError 38 | ConfigNotValidError = Class.new ConfigError 39 | end 40 | -------------------------------------------------------------------------------- /lib/watch_tower/server/views/layout.haml: -------------------------------------------------------------------------------- 1 | !!! 5 2 | %html 3 | %head 4 | / Title 5 | %title= "Watch Tower - #{@title}" 6 | 7 | / Meta tags 8 | %meta{:charset => "utf-8"}/ 9 | %meta{:content => "IE=edge,chrome=1", "http-equiv" => "X-UA-Compatible"}/ 10 | %meta{:content => "width=device-width,initial-scale=1", :name => "viewport"}/ 11 | %meta{name: 'date_filtering_from', content: session[:date_filtering][:from_date] }/ 12 | %meta{name: 'date_filtering_to', content: session[:date_filtering][:to_date]}/ 13 | 14 | / Link tags 15 | %link{:href => asset_path('application.css'), :rel => "stylesheet"}/ 16 | 17 | / Script tahs 18 | %script{:src => asset_path('application.js')} 19 | %body 20 | %section#wrapper 21 | 22 | %header 23 | #logo 24 | %a{href: "/"} 25 | %h1 Watch Tower 26 | 27 | %section#main 28 | %aside#date 29 | %input{type: 'text'} 30 | .page= yield 31 | 32 | %section#footer 33 | %a{href: 'https://github.com/TechnoGate/watch_tower', target: '_blank'} 34 | WatchTower 35 | Version 36 | %a{href: "https://github.com/TechnoGate/watch_tower/tree/v#{WatchTower.version}", target: '_blank'} 37 | = WatchTower.version 38 | %br/ 39 | © Copyright 2011 40 | %a{href: 'https://github.com/TechnoGate', target: '_blank'} 41 | TechnoGate 42 | -------------------------------------------------------------------------------- /lib/watch_tower/project/init.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | class Project 5 | module Init 6 | def self.included(base) 7 | base.extend ClassMethods 8 | base.send :include, InstanceMethods 9 | end 10 | 11 | module ClassMethods 12 | # Create a new project from a path (to a file or a folder) 13 | # 14 | # @param [String] path, the path to the file 15 | # @return [Project] a new initialized project 16 | def new_from_path(path) 17 | raise FileNotFound unless path && File.exists?(path) 18 | LOG.debug("#{__FILE__}:#{__LINE__}: Creating a project from #{path}") 19 | if GitBased.active_for_path?(path) 20 | Project.new GitBased.project_name(path), GitBased.working_directory(path) 21 | else 22 | Project.new PathBased.project_name(path), PathBased.working_directory(path) 23 | end 24 | end 25 | end 26 | 27 | module InstanceMethods 28 | # Initialize a project using a name and a path 29 | # 30 | # @param [String] name: the name of the project 31 | # @param [String] path: The path of the project 32 | def initialize(name, path) 33 | LOG.debug("#{__FILE__}:#{__LINE__}: Created project #{name} located at #{path}") 34 | @name = name 35 | @path = path 36 | end 37 | end 38 | end 39 | end 40 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/models/file.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module Server 5 | class File < ::ActiveRecord::Base 6 | # Scopes 7 | default_scope order('files.elapsed_time DESC') 8 | scope :worked_on, -> { where('files.elapsed_time > ?', 0) } 9 | 10 | # Validations 11 | validates :project_id, presence: true 12 | validates :path, presence: true 13 | validates_uniqueness_of :path, sope: :project_id 14 | 15 | # Associations 16 | belongs_to :project, counter_cache: true 17 | has_many :time_entries, dependent: :destroy 18 | has_many :durations, dependent: :destroy 19 | 20 | # Return the percent of this file 21 | def percent 22 | (elapsed_time * 100) / project.files.sum_elapsed_time 23 | end 24 | 25 | # Return an ActiveRelation limited to a date range 26 | # 27 | # @param [String] From date 28 | # @param [String] To date 29 | # @return [ActiveRelation] 30 | def self.date_range(from, to) 31 | from = Date.strptime(from, '%m/%d/%Y') 32 | to = Date.strptime(to, '%m/%d/%Y') 33 | 34 | joins(:durations => :file). 35 | where('durations.date >= ?', from). 36 | where('durations.date <= ?', to). 37 | select('DISTINCT files.*') 38 | end 39 | 40 | # Returns the sum of all elapsed time 41 | # 42 | # @return [Integer] 43 | def self.sum_elapsed_time 44 | sum(:elapsed_time) 45 | end 46 | end 47 | end 48 | end -------------------------------------------------------------------------------- /CREDITS.md: -------------------------------------------------------------------------------- 1 | # Credits 2 | 3 | This project is heavily inspired by 4 | [timetap](https://github.com/elia/timetap) created by [Elia 5 | Schito](https://github.com/elia). 6 | 7 | It also uses a number of open source 8 | projects including, but not limited to: 9 | 10 | - [rb-appscript](http://appscript.sourceforge.net/) 11 | - [activesupport](https://github.com/rails/rails) 12 | - [activerecord](https://github.com/rails/rails) 13 | - [sinatra](https://github.com/sinatra/sinatra) 14 | - [sinatra-snap](http://github.com/bcarlso/snap) 15 | - [haml](http://haml-lang.com) 16 | - [grit](https://github.com/mojombo/grit) 17 | - [coffee-script](http://github.com/josh/ruby-coffee-script) 18 | - [uglifier](https://github.com/lautis/uglifier) 19 | - [sass](http://sass-lang.com) 20 | - [sprockets](http://getsprockets.org/) 21 | - [guard](https://github.com/guard/guard) 22 | - [guard-bundler](https://github.com/guard/guard-bundler) 23 | - [guard-rspec](https://github.com/guard/guard-rspec) 24 | - [guard-sprockets2](https://github.com/stevehodgkiss/guard-sprockets2) 25 | - [yard](http://github.com/lsegal/yard) 26 | - [rspec](http://github.com/rspec/rspec) 27 | - [rspec-rails](http://github.com/rspec/rspec-rails) 28 | - [capybara](https://github.com/jnicklas/capybara) 29 | - [launchy](https://github.com/copiousfreetime/launchy) 30 | - [mocha](https://github.com/floehopper/mocha) 31 | - [factory_girl](https://github.com/thoughtbot/factory_girl) 32 | - [timecop](https://github.com/jtrupiano/timecop) 33 | - [pry](https://github.com/pry/pry) 34 | - [cronedit](http://cronedit.rubyforge.org) 35 | -------------------------------------------------------------------------------- /lib/watch_tower/server/helpers/improved_partials.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module Server 5 | module Helpers 6 | module ImprovedPartials 7 | 8 | def self.included(base) 9 | base.send :include, InstanceMethods 10 | end 11 | 12 | module InstanceMethods 13 | 14 | # Define partial as a helper 15 | helpers do 16 | # Render a partial with support for collections 17 | # 18 | # stolen from http://github.com/cschneid/irclogger/blob/master/lib/partials.rb 19 | # and made a lot more robust by Sam Elliott 20 | # https://gist.github.com/119874 21 | # 22 | # @param [Symbol] The template to render 23 | # @param [Hash] Options 24 | def partial(template, *args) 25 | template_array = template.to_s.split('/') 26 | template = template_array[0..-2].join('/') + "/_#{template_array[-1]}" 27 | options = args.last.is_a?(Hash) ? args.pop : {} 28 | options.merge!(:layout => false) 29 | if collection = options.delete(:collection) then 30 | collection.inject([]) do |buffer, member| 31 | buffer << haml(:"#{template}", options.merge(:layout => 32 | false, :locals => {template_array[-1].to_sym => member})) 33 | end.join("\n") 34 | else 35 | haml(:"#{template}", options) 36 | end 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | end -------------------------------------------------------------------------------- /spec/watch_tower/editor/xcode_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Editor 4 | describe Xcode do 5 | it { should respond_to :current_path } 6 | 7 | it { should respond_to :name } 8 | pending(:name) { should_not raise_error NotImplementedError } 9 | 10 | it { should respond_to :version } 11 | pending(:version) { should_not raise_error NotImplementedError } 12 | 13 | describe "#is_running?" do 14 | it { should respond_to :is_running? } 15 | 16 | it "should return wether Xcode is running or not" do 17 | app = mock() 18 | app.expects(:is_running?).returns(true).once 19 | Xcode.any_instance.stubs(:editor).returns(app) 20 | 21 | subject.is_running?.should be_true 22 | end 23 | 24 | it "should return the current_path if textmate running" do 25 | app = mock() 26 | app.expects(:is_running?).returns(true).once 27 | documents = mock 28 | document = mock 29 | path = mock 30 | path.expects(:get).returns('/path/to/file.rb') 31 | document.expects(:path).returns(path).once 32 | documents.expects(:get).returns([document]).once 33 | app.expects(:document).returns(documents).once 34 | Xcode.any_instance.stubs(:editor).returns(app) 35 | 36 | subject.current_path.should == '/path/to/file.rb' 37 | end 38 | 39 | it "should return nil if textmate ain't running" do 40 | app = mock() 41 | app.expects(:is_running?).returns(false).once 42 | Xcode.any_instance.stubs(:editor).returns(app) 43 | 44 | subject.current_path.should be_nil 45 | end 46 | 47 | end 48 | end 49 | end -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | - Figure out how to load assets from gems and remove the bundled jQuery files. 2 | (added Mon Oct 10 16:13:36 2011, incomplete, priority high) 3 | 4 | - Make it possible to add a new project, a project that can't be recognised by GitBased nor PathBased. 5 | (added Sun Oct 23 18:15:52 2011, incomplete, priority high) 6 | 7 | - The Config module need some tests. 8 | (added Thu Oct 6 12:02:34 2011, incomplete, priority medium) 9 | 10 | - Convert the Editor module to be Enumerable for easier access to the list of editors 11 | (added Fri Oct 7 11:47:53 2011, incomplete, priority medium) 12 | 13 | - maybe use sinatra-simple-navigation gem 14 | (added Fri Oct 7 16:07:44 2011, incomplete, priority medium) 15 | 16 | - Add tracking by git branch, needs a branches table that belongs to a project and has many time_entries 17 | (added Sat Oct 8 23:35:02 2011, incomplete, priority medium) 18 | 19 | - Maybe use a Rails engine instead of Sinatra ? 20 | (added Mon Oct 10 16:52:53 2011, incomplete, priority medium) 21 | 22 | - Add support for ViM 23 | (added Sun Oct 23 18:12:56 2011, incomplete, priority medium) 24 | 25 | - Add support for Netbeans 26 | (added Sun Oct 23 18:13:05 2011, incomplete, priority medium) 27 | 28 | - Add support for Eclipse 29 | (added Sun Oct 23 18:13:14 2011, incomplete, priority medium) 30 | 31 | - Add support for Linux (it should work after adding ViM, Eclipse and Netbeans) 32 | (added Sun Oct 23 18:14:24 2011, incomplete, priority medium) 33 | 34 | - Add support for Microsoft Windows 35 | (added Sun Oct 23 18:14:36 2011, incomplete, priority medium) 36 | 37 | - Add support Microsoft Office (Word, Excel, Powerpoint) 38 | (added Sun Oct 23 18:14:59 2011, incomplete, priority medium) 39 | -------------------------------------------------------------------------------- /spec/support/active_record.rb: -------------------------------------------------------------------------------- 1 | # Include only Rspec's active record functionalities 2 | require 'rspec/rails/extensions/active_record/base' 3 | require 'rspec/rails/adapters' 4 | require 'rspec/rails/matchers/be_a_new' 5 | require 'rspec/rails/matchers/be_new_record' 6 | require 'rspec/rails/matchers/have_extension' 7 | # TODO: Uncomment When rspec-rails-2.6.2 is released 8 | # require 'rspec/rails/matchers/relation_match_array' 9 | require 'rspec/rails/fixture_support' 10 | require 'rspec/rails/mocks' 11 | require 'rspec/rails/example/rails_example_group' 12 | require 'rspec/rails/example/model_example_group' 13 | 14 | RSpec::configure do |config| 15 | config.include RSpec::Rails::ModelExampleGroup, :type => :model, :example_group => { 16 | :file_path => config.escaped_path(%w[spec watch_tower server models]) 17 | } 18 | 19 | config.around(:each) do |example| 20 | # Make sure the connection is open before open a transaction 21 | WatchTower::Server::Database.start! 22 | # Increment the number of open transactions 23 | ActiveRecord::Base.connection.increment_open_transactions 24 | # Begin a database transaction 25 | ActiveRecord::Base.connection.begin_db_transaction 26 | begin 27 | # Call the example 28 | example.call 29 | ensure 30 | # Rollback the database transaction 31 | ActiveRecord::Base.connection.rollback_db_transaction 32 | # Decrement the number of open transactions 33 | ActiveRecord::Base.connection.decrement_open_transactions 34 | end 35 | end 36 | 37 | # Start the server before all examples 38 | config.before(:all) do 39 | WatchTower::Server::Database.start! 40 | end 41 | 42 | # Stop the server after all examples 43 | config.after(:all) do 44 | WatchTower::Server::Database.stop! 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/watch_tower/server/models/duration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Server 4 | describe Duration do 5 | describe "Attributes" do 6 | it { should respond_to :date } 7 | 8 | it { should respond_to :duration } 9 | end 10 | 11 | describe "Validations" do 12 | it { should_not be_valid } 13 | 14 | it "should require a file" do 15 | d = FactoryGirl.build :duration, file: nil 16 | d.should_not be_valid 17 | end 18 | 19 | it "should require a date" do 20 | d = FactoryGirl.build :duration, date: nil 21 | d.should_not be_valid 22 | end 23 | 24 | it "should require a duration" do 25 | d = FactoryGirl.build :duration, duration: nil 26 | d.should_not be_valid 27 | end 28 | end 29 | 30 | describe "Associations" do 31 | it "should belong to a file" do 32 | f = FactoryGirl.create :file 33 | d = FactoryGirl.create :duration, file: f 34 | 35 | d.file.should == f 36 | end 37 | end 38 | 39 | describe "#duration" do 40 | it "should default to 0 for a project with 0 elapsed time" do 41 | p = FactoryGirl.create :project 42 | f = FactoryGirl.create :file, project: p 43 | t = FactoryGirl.create :time_entry, file: f 44 | 45 | p.durations.inject(0) { |count, d| count += d.duration }.should == 0 46 | end 47 | 48 | it "should correctly be calculated" do 49 | now = Time.now 50 | p = FactoryGirl.create :project 51 | f = FactoryGirl.create :file, project: p 52 | t1 = FactoryGirl.create :time_entry, file: f, mtime: now 53 | t2 = FactoryGirl.create :time_entry, file: f, mtime: now + 10.seconds 54 | 55 | p.durations.inject(0) { |count, d| count += d.duration }.should == 10 56 | end 57 | end 58 | 59 | end 60 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/configurations/asset.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | # Asset Pipeline 4 | require 'coffee-script' 5 | require 'uglifier' 6 | require 'sass' 7 | require 'sprockets' 8 | 9 | module WatchTower 10 | module Server 11 | module Configurations 12 | module Asset 13 | def self.included(base) 14 | base.class_eval <<-END, __FILE__, __LINE__ + 1 15 | # Code taken from 16 | # https://github.com/stevehodgkiss/sinatra-asset-pipeline/blob/master/app.rb#L11 17 | set :sprockets, Sprockets::Environment.new(SERVER_PATH) 18 | set :precompile, [ /\w+\.(?!js|css).+/, /application.(css|js)$/ ] 19 | set :assets_prefix, 'assets' 20 | set :assets_path, ::File.join(SERVER_PATH, 'public', assets_prefix) 21 | 22 | configure do 23 | # Lib 24 | sprockets.append_path(::File.join(SERVER_PATH, 'lib', 'assets', 'stylesheets')) 25 | sprockets.append_path(::File.join(SERVER_PATH, 'lib', 'assets', 'javascripts')) 26 | sprockets.append_path(::File.join(SERVER_PATH, 'lib', 'assets', 'images')) 27 | 28 | # Vendor 29 | sprockets.append_path(::File.join(SERVER_PATH, 'vendor', 'assets', 'stylesheets')) 30 | sprockets.append_path(::File.join(SERVER_PATH, 'vendor', 'assets', 'javascripts')) 31 | sprockets.append_path(::File.join(SERVER_PATH, 'vendor', 'assets', 'images')) 32 | 33 | # Assets 34 | sprockets.append_path(::File.join(SERVER_PATH, 'assets', 'stylesheets')) 35 | sprockets.append_path(::File.join(SERVER_PATH, 'assets', 'javascripts')) 36 | sprockets.append_path(::File.join(SERVER_PATH, 'assets', 'images')) 37 | 38 | sprockets.context_class.send :extend, Helpers::Asset 39 | end 40 | END 41 | end 42 | end 43 | end 44 | end 45 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/assets/stylesheets/global.sass: -------------------------------------------------------------------------------- 1 | $footer_text_color: #000 2 | $footer_bg: #D6AFAF 3 | $link_color: +darken($footer_bg, 20%) 4 | 5 | h1 6 | font-size: 2em 7 | margin: 15px 0 8 | h2 9 | font-size: 1em 10 | 11 | #logo 12 | a 13 | color: #000 14 | text-decoration: none 15 | 16 | // Background stylesheet 17 | display: block 18 | // TODO: Very bad bad bad idea to hardcode assets, how can we reference an asset from sass ? 19 | background: url('WatchTower-58eff0713efffbc6054defddc879e0b1.jpg') no-repeat 20 | width: 390px 21 | height: 94px 22 | 23 | &:visited, &:hover 24 | text-decoration: none 25 | 26 | h1 27 | float: left 28 | margin-top: 30px 29 | margin-left: 180px 30 | 31 | .clearfix 32 | clear: both 33 | 34 | .back 35 | margin-bottom: 2em 36 | a 37 | background-color: #222 38 | -webkit-border-radius: 0.5em 39 | -moz-border-radius: 0.5em 40 | border-radius: 0.5em 41 | color: white 42 | padding: 0.5em 43 | text-decoration: none 44 | 45 | &:hover 46 | background-color: #444 47 | 48 | a 49 | color: $link_color 50 | text-decoration: none 51 | 52 | &:hover, &:visited 53 | text-decoration: none 54 | 55 | body 56 | background: #f7f7f7 57 | color: #000 58 | font-family: Arial, Helvetica, sans-serif 59 | 60 | #wrapper 61 | width: 960px 62 | margin: 0 auto 63 | 64 | #main 65 | border: 1px solid #cdcdcd 66 | -webkit-border-radius: 5px 5px 0 0 67 | -moz-border-radius: 5px 5px 0 0 68 | border-radius: 5px 5px 0 0 69 | padding: 10px 70 | min-height: 200px 71 | background: #fff 72 | 73 | .page 74 | padding: 40px 0 0 75 | 76 | #footer 77 | width: 960px 78 | text-align: center 79 | background: $footer_bg 80 | color: $footer_text_color 81 | padding: 5px 0 82 | font-size: 12px 83 | 84 | a 85 | color: $footer_text_color 86 | text-decoration: none 87 | 88 | &:visited, &:hover 89 | text-decoration: none -------------------------------------------------------------------------------- /lib/watch_tower/server/models/project.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module Server 5 | class Project < ::ActiveRecord::Base 6 | # Scopes 7 | default_scope order('projects.elapsed_time DESC') 8 | scope :worked_on, -> { where('projects.elapsed_time > ?', 0) } 9 | 10 | # Validations 11 | validates :name, presence: true 12 | validates :path, presence: true 13 | 14 | # Associations 15 | has_many :files, dependent: :destroy 16 | has_many :time_entries, through: :files 17 | has_many :durations, through: :files 18 | 19 | # Return the percent of this file 20 | def percent 21 | (elapsed_time * 100) / self.class.sum_elapsed_time 22 | end 23 | 24 | # Return an ActiveRelation limited to a date range 25 | # 26 | # @param [String] From date 27 | # @param [String] To date 28 | # @return [ActiveRelation] 29 | def self.date_range(from, to) 30 | from = Date.strptime(from, '%m/%d/%Y') 31 | to = Date.strptime(to, '%m/%d/%Y') 32 | 33 | joins(:durations => :file). 34 | where('durations.date >= ?', from). 35 | where('durations.date <= ?', to). 36 | select('DISTINCT projects.*') 37 | end 38 | 39 | # Returns the sum of all elapsed time 40 | # 41 | # @return [Integer] 42 | def self.sum_elapsed_time 43 | sum(:elapsed_time) 44 | end 45 | 46 | # Recalucate the elapsed time 47 | def recalculate_elapsed_time 48 | # Reset the elapsed_time of the project 49 | self.elapsed_time = 0 50 | # Save the project 51 | self.save! 52 | # Operate on all files 53 | files.each do |f| 54 | # Reset the elapsed_time of the files 55 | f.elapsed_time = 0 56 | # Save the file 57 | f.save! 58 | # Delete all durations 59 | f.durations.delete_all 60 | # Send calculate_elapsed_time to all time_entries ordered by 61 | # their id 62 | f.time_entries.order('id ASC').each do |t| 63 | t.send(:calculate_elapsed_time) 64 | end 65 | end 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/watch_tower/config.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'active_support/hash_with_indifferent_access' 4 | 5 | module WatchTower 6 | module Config 7 | extend self 8 | 9 | # Define the config class variable 10 | @@config = nil 11 | 12 | # Return a particular config variable from the parsed config file 13 | # 14 | # @param [String|Symbol] config 15 | # @return mixed 16 | # @raise [Void] 17 | def [](config) 18 | if @@config.nil? 19 | check_config_file 20 | @@config ||= parse_config_file 21 | end 22 | 23 | @@config[:watch_tower].send(:[], config) 24 | end 25 | 26 | # Get the config file 27 | # 28 | # @return [String] Absolute path to the config file 29 | def config_file 30 | File.join(USER_PATH, 'config.yml') 31 | end 32 | 33 | protected 34 | # Initialize the configuration file 35 | def initialize_config_file 36 | File.open(config_file, 'w') do |f| 37 | f.write(File.read(File.join(TEMPLATE_PATH, 'config.yml'))) 38 | end 39 | end 40 | 41 | # Check the config file 42 | def check_config_file 43 | # Check that config_file is defined 44 | raise ConfigNotDefinedError unless config_file 45 | # Check that the config file exists 46 | initialize_config_file unless ::File.exists?(config_file) 47 | # Check that the config file is readable? 48 | raise ConfigNotReadableError unless ::File.readable?(config_file) 49 | end 50 | 51 | # Parse the config file 52 | # 53 | # @return [HashWithIndifferentAccess] The config 54 | def parse_config_file 55 | begin 56 | parsed_yaml = YAML.parse_file config_file 57 | rescue Psych::SyntaxError => e 58 | raise ConfigNotValidError, 59 | "Not valid YAML file: #{e.message}." 60 | end 61 | raise ConfigNotValidError, 62 | "Not valid YAML file: The YAML does not respond_to to_ruby." unless parsed_yaml.respond_to?(:to_ruby) 63 | config = HashWithIndifferentAccess.new(parsed_yaml.to_ruby) 64 | raise ConfigNotValidError, 65 | "Not valid YAML file: It doesn't contain watch_tower root key." unless config.has_key?(:watch_tower) 66 | 67 | config 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/watch_tower/server.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module Server 5 | extend ::ActiveSupport::Autoload 6 | 7 | autoload :Database 8 | autoload :Duration, ::File.join(MODELS_PATH, 'duration.rb') 9 | autoload :Project, ::File.join(MODELS_PATH, 'project.rb') 10 | autoload :File, ::File.join(MODELS_PATH, 'file.rb') 11 | autoload :TimeEntry, ::File.join(MODELS_PATH, 'time_entry.rb') 12 | autoload :Helpers 13 | autoload :Configurations 14 | autoload :Presenters 15 | autoload :App 16 | 17 | # Start the server 18 | # This method starts the database and then starts the server 19 | # 20 | # @param [Hash] options 21 | def self.start(options = {}) 22 | # Start the Database 23 | Database.start!(options) 24 | 25 | # Start the Sinatra application 26 | start_web_server(options) 27 | end 28 | 29 | # Start the Server, a method invoked from the Watch Tower command line interface 30 | # 31 | # @param [Hash] options 32 | def self.start!(options = {}) 33 | start(options) 34 | end 35 | 36 | protected 37 | # Start the web_server 38 | # This method starts the web server (The Sinatra app) 39 | # 40 | # @param [Hash] options 41 | def self.start_web_server(options = {}) 42 | LOG.debug("#{__FILE__}:#{__LINE__}: Starting the Sinatra App") 43 | 44 | # Abort execution if the Thread raised an error. 45 | Thread.abort_on_exception = true 46 | 47 | WatchTower.threads[:web_server] = Thread.new do 48 | LOG.debug("#{__FILE__}:#{__LINE__}: Starting a new Thread for the web server.") 49 | 50 | # Signal handling 51 | Signal.trap("INT") { exit } 52 | Signal.trap("TERM") { exit } 53 | 54 | begin 55 | LOG.debug("#{__FILE__}:#{__LINE__}: Starting the web server in the new Thread.") 56 | 57 | # Start the server 58 | App.run!(options) 59 | 60 | LOG.debug("#{__FILE__}:#{__LINE__}: The server has stopped.") 61 | 62 | # Exit this thread immediately 63 | exit 64 | rescue => e 65 | LOG.fatal "#{__FILE__}:#{__LINE__ - 4}: #{e}" unless e.message == 'exit' 66 | raise e 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/watch_tower/editor/base_appscript.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'watch_tower/appscript' 4 | 5 | module WatchTower 6 | module Editor 7 | module BaseAppscript 8 | def self.included(base) 9 | base.send :include, InstanceMethods 10 | end 11 | 12 | module InstanceMethods 13 | def self.included(base) 14 | base.class_eval <<-END, __FILE__, __LINE__ + 1 15 | # Include AppScript 16 | include ::Appscript 17 | 18 | # Is the editor running ? 19 | # 20 | # @return [Boolean] 21 | def is_running? 22 | editor.is_running? if editor 23 | end 24 | 25 | # Returns the name of the editor 26 | # 27 | # Child class should implement this method 28 | def name 29 | @name ||= editor.try(:name).try(:get) 30 | end 31 | 32 | # Returns the version of the editor 33 | # 34 | # Child class should implement this method 35 | def version 36 | @version ||= editor.try(:version).try(:get) 37 | end 38 | 39 | # Return the path of the document being edited 40 | # Child classes can override this method if the behaviour is different 41 | # 42 | # @return [String] path to the document currently being edited 43 | def current_path 44 | current_paths.try(:first) 45 | end 46 | 47 | # Return the pathes of the documents being edited 48 | # Child classes can override this method if the behaviour is different 49 | # 50 | # @return [Array] pathes to the documents currently being edited 51 | def current_paths 52 | if is_running? && editor.respond_to?(:document) 53 | editor.document.get.collect(&:path).collect(&:get) 54 | end 55 | end 56 | 57 | # The editor's name for the log 58 | # Child classes can overwrite this method 59 | # 60 | # @return [String] 61 | def to_s 62 | "\#{self.class.to_s.split('::').last} Editor Version \#{version}" 63 | end 64 | END 65 | end 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/watch_tower/server/presenters/application_presenter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Server 4 | module Presenters 5 | describe ApplicationPresenter do 6 | 7 | describe "Instance Methods" do 8 | before(:each) do 9 | @model = mock 10 | @model.stubs(:id).returns(1) 11 | @model.stubs(:elapsed_time).returns(1234) 12 | end 13 | 14 | subject { ApplicationPresenter.new(@model, nil) } 15 | 16 | describe "#humanize_time" do 17 | it "should return 1 second" do 18 | subject.send(:humanize_time, 1).should == '1 second' 19 | end 20 | 21 | it "should return 2 seconds" do 22 | subject.send(:humanize_time, 2).should == '2 seconds' 23 | end 24 | 25 | it "should return 1 minute" do 26 | subject.send(:humanize_time, 1.minute).should == '1 minute' 27 | end 28 | 29 | it "should return 2 minutes" do 30 | subject.send(:humanize_time, 2.minutes).should == '2 minutes' 31 | end 32 | 33 | it "should return 1 hour" do 34 | subject.send(:humanize_time, 1.hour).should == '1 hour' 35 | end 36 | 37 | it "should return 2 hours" do 38 | subject.send(:humanize_time, 2.hours).should == '2 hours' 39 | end 40 | 41 | it "should return 1 day" do 42 | subject.send(:humanize_time, 1.day).should == '1 day' 43 | end 44 | 45 | it "should return 2 days" do 46 | subject.send(:humanize_time, 2.days).should == '2 days' 47 | end 48 | 49 | it "should return 1 day, 2 hours, 3 minutes and 34 seconds" do 50 | time = 1.day + 2.hours + 3.minutes + 34.seconds 51 | subject.send(:humanize_time, time).should == '1 day, 2 hours, 3 minutes and 34 seconds' 52 | end 53 | end 54 | end 55 | 56 | describe "Class Methods" do 57 | subject { ApplicationPresenter } 58 | 59 | describe "#presents" do 60 | it { should respond_to :presents } 61 | 62 | it "should create a method the same name as the argument passed to presents" do 63 | Class.new(subject) do 64 | presents :foo 65 | end.new(@project, nil).should respond_to :foo 66 | end 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/watch_tower/editor/base_appscript_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Editor 4 | describe BaseAppscript do 5 | 6 | subject do 7 | Class.new do 8 | include BaseAppscript 9 | end.new 10 | end 11 | 12 | before(:each) do 13 | @editor = mock 14 | @editor.stubs(:is_running?).returns(true) 15 | @documents = mock 16 | @document = mock 17 | @file_path = '/path/to/file.rb' 18 | @path = mock 19 | @path.stubs(:get).returns(@file_path) 20 | @document.stubs(:path).returns(@path) 21 | @documents.stubs(:get).returns([@document]) 22 | @editor.stubs(:document).returns(@documents) 23 | 24 | subject.class.any_instance.stubs(:editor).returns(@editor) 25 | end 26 | 27 | describe "#is_running" do 28 | it "should return true if the editor is running" do 29 | @editor.expects(:is_running?).returns(true).once 30 | 31 | subject.is_running?.should be_true 32 | end 33 | 34 | it "should return false if the editor is running" do 35 | @editor.expects(:is_running?).returns(false).once 36 | 37 | subject.is_running?.should be_false 38 | end 39 | end 40 | 41 | describe "#cuurent_path" do 42 | it { should respond_to :current_path } 43 | 44 | it "should return the current_path if textmate running" do 45 | @path.expects(:get).returns(@file_path).once 46 | @document.expects(:path).returns(@path).once 47 | @documents.expects(:get).returns([@document]).once 48 | 49 | subject.current_path.should == @file_path 50 | end 51 | 52 | it "should return nil if textmate ain't running" do 53 | @editor.stubs(:is_running?).returns(false) 54 | 55 | subject.current_path.should be_nil 56 | end 57 | end 58 | 59 | describe "#cuurent_path" do 60 | it { should respond_to :current_paths } 61 | 62 | it "should return the current_paths if textmate running" do 63 | @path.expects(:get).returns(@file_path).once 64 | @document.expects(:path).returns(@path).once 65 | @documents.expects(:get).returns([@document]).once 66 | 67 | subject.current_paths.should == [@file_path] 68 | end 69 | 70 | it "should return nil if textmate ain't running" do 71 | @editor.stubs(:is_running?).returns(false) 72 | 73 | subject.current_paths.should be_nil 74 | end 75 | 76 | it "should return nil if the application does not respond to document" do 77 | @editor.stubs(:respond_to?).with(:document).returns(false) 78 | 79 | subject.current_paths.should be_nil 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /ci/travis.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # -*- encoding: utf-8 -*- 3 | # This file has been taken from rails 4 | # https://github.com/rails/rails/blob/master/ci/travis.rb 5 | require 'fileutils' 6 | include FileUtils 7 | 8 | commands = [ 9 | 'mysql -e "drop database if exists watch_tower_test;"', 10 | 'mysql -e "create database watch_tower_test;"', 11 | 'psql -c "drop database if exists watch_tower_test;" -U postgres', 12 | 'psql -c "create database watch_tower_test;" -U postgres' 13 | ] 14 | 15 | commands.each do |command| 16 | system("#{command} > /dev/null 2>&1") 17 | end 18 | 19 | class Build 20 | attr_reader :options 21 | 22 | def initialize(options = {}) 23 | @options = options 24 | end 25 | 26 | def run!(options = {}) 27 | self.options.update(options) 28 | create_config_file 29 | announce(heading) 30 | rake(*tasks) 31 | end 32 | 33 | def create_config_file 34 | commands = [ 35 | "rm -rf ~/.watch_tower", 36 | "mkdir -p ~/.watch_tower", 37 | "cp lib/watch_tower/templates/config.yml ~/.watch_tower/config.yml", 38 | "cat ci/adapters/#{ruby_platform}-#{adapter}.yml >> ~/.watch_tower/config.yml" 39 | ] 40 | 41 | commands.each do |command| 42 | system("#{command}") 43 | end 44 | end 45 | 46 | def ruby_platform 47 | RUBY_PLATFORM == 'java' ? 'jruby' : 'ruby' 48 | end 49 | 50 | def announce(heading) 51 | puts "\n\e[1;33m[Travis CI] #{heading}\e[m\n" 52 | end 53 | 54 | def heading 55 | heading = [gem] 56 | heading << "with #{adapter}" 57 | heading.join(' ') 58 | end 59 | 60 | def tasks 61 | "spec" 62 | end 63 | 64 | def gem 65 | 'watch_tower' 66 | end 67 | 68 | def adapter 69 | @options[:adapter] 70 | end 71 | 72 | def rake(*tasks) 73 | tasks.each do |task| 74 | cmd = "bundle exec rake #{task}" 75 | puts "Running command: #{cmd}" 76 | return false unless system(cmd) 77 | end 78 | true 79 | end 80 | end 81 | 82 | results = {} 83 | 84 | ENV['ADAPTERS'].split(':').each do |adapter| 85 | # PG is not working on RBX 86 | # Probably a bug on Travis, to investigate of course 87 | if adapter == 'postgresql' && RUBY_ENGINE == 'rbx' 88 | results[adapter] = true 89 | else 90 | build = Build.new(adapter: adapter) 91 | results[adapter] = build.run! 92 | end 93 | end 94 | 95 | failures = results.select { |key, value| value == false } 96 | 97 | if failures.empty? 98 | puts 99 | puts "WatchTower build finished sucessfully" 100 | exit(true) 101 | else 102 | puts 103 | puts "WatchTower build FAILED" 104 | puts "Failed adapters: #{failures.keys.join(', ')}" 105 | exit(false) 106 | end -------------------------------------------------------------------------------- /lib/watch_tower/file_tree.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | # This class is used by the server to provided a FileTree representation 5 | # of the files and their elapsed time 6 | class FileTree 7 | 8 | attr_reader :base_path, :files, :nested_tree, :elapsed_time 9 | 10 | # Initialize 11 | # 12 | # @param [String] base_path: The base path of all files, usually the project's path 13 | # @param [Array] files: The files and their elapsed time 14 | # The Array elements should be Hashes with two required keys 15 | # :path and :elapsed_time 16 | def initialize(base_path, files) 17 | # Init with args 18 | @base_path = base_path 19 | @all_files = remove_base_path_from_files(@base_path, files) 20 | # Init with defaults 21 | @elapsed_time = 0 22 | @files = Array.new 23 | @nested_tree = Hash.new 24 | # Process the tree 25 | process 26 | end 27 | 28 | protected 29 | # Process the Tree 30 | def process 31 | # Parse files 32 | parse_files 33 | # Parse folders 34 | parse_folders 35 | end 36 | 37 | # Removes the base_path from the files 38 | def remove_base_path_from_files(base_path, files) 39 | files.collect do |f| 40 | f[:path] = f[:path].gsub(%r(#{base_path}#{File::SEPARATOR}), '') 41 | f 42 | end 43 | end 44 | 45 | # Parses only files under the current base_path 46 | def parse_files 47 | return if @files.any? 48 | 49 | @all_files.each do |f| 50 | unless f[:path] =~ %r(#{File::SEPARATOR}) 51 | @elapsed_time += f[:elapsed_time] 52 | @files << f 53 | end 54 | end 55 | end 56 | 57 | # Parses only folders under the current base_path 58 | def parse_folders 59 | return if @nested_tree.any? 60 | 61 | @all_files.each do |f| 62 | if f[:path] =~ %r(#{File::SEPARATOR}) 63 | # Get the base_path 64 | base_path = f[:path].split(File::SEPARATOR).first 65 | # Do not continue if we already parsed this path 66 | next if @nested_tree.has_key?(base_path) 67 | # Get the nested files 68 | nested_files = remove_base_path_from_files base_path, 69 | @all_files.select { |f| f[:path] =~ %r(^#{base_path}#{File::SEPARATOR}) } 70 | # Create a tree 71 | @nested_tree[base_path] = self.class.new("#{@base_path}#{File::SEPARATOR}#{base_path}", nested_files) 72 | # Add the elapsed_time of the tree 73 | @elapsed_time += @nested_tree[base_path].elapsed_time 74 | end 75 | end 76 | end 77 | end 78 | end -------------------------------------------------------------------------------- /spec/watch_tower/project_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Project do 4 | describe "Class Methods" do 5 | subject { Project } 6 | 7 | before(:all) do 8 | # Arguments 9 | @code = '/home/user/Code' 10 | @file_path = '/home/user/Code/OpenSource/watch_tower/lib/watch_tower/server/models/time_entries.rb' 11 | 12 | # Expected results 13 | @project_path = '/home/user/Code/OpenSource/watch_tower' 14 | @project_name = 'watch_tower' 15 | end 16 | 17 | before(:each) do 18 | ::File.stubs(:exists?).returns(true) 19 | end 20 | 21 | describe "#new_from_path" do 22 | 23 | it { should respond_to :new_from_path } 24 | 25 | describe "path based" do 26 | before(:each) do 27 | Project::GitBased.expects(:active_for_path?).returns(false).once 28 | Project::PathBased.expects(:working_directory).returns(@project_path).once 29 | Project::PathBased.expects(:project_name).returns(@project_name).once 30 | end 31 | 32 | it "should create a project based off of git" do 33 | p = subject.new_from_path(@file_path) 34 | p.should be_instance_of subject 35 | end 36 | 37 | it "should return the name of the project" do 38 | p = subject.new_from_path(@file_path) 39 | p.name.should == @project_name 40 | end 41 | 42 | it "should return the path of the project" do 43 | p = subject.new_from_path(@file_path) 44 | p.path.should == @project_path 45 | end 46 | end 47 | 48 | describe "git based" do 49 | before(:each) do 50 | Project::GitBased.expects(:active_for_path?).returns(true).once 51 | Project::GitBased.expects(:working_directory).returns(@project_path).once 52 | Project::GitBased.expects(:project_name).returns(@project_name).once 53 | end 54 | 55 | it "should create a project based off of git" do 56 | p = subject.new_from_path(@file_path) 57 | p.should be_instance_of subject 58 | end 59 | 60 | it "should return the name of the project" do 61 | p = subject.new_from_path(@file_path) 62 | p.name.should == @project_name 63 | end 64 | 65 | it "should return the path of the project" do 66 | p = subject.new_from_path(@file_path) 67 | p.path.should == @project_path 68 | end 69 | end 70 | end 71 | end 72 | 73 | describe "Instance Methods" do 74 | subject { Project.new @project_name, @project_path } 75 | 76 | it { should respond_to :name } 77 | it { should respond_to :path } 78 | 79 | its(:name) { should == @project_name } 80 | its(:path) { should == @project_path } 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /watch_tower.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "watch_tower/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "watch_tower" 7 | s.version = WatchTower.version 8 | s.authors = ["Wael Nasreddine"] 9 | s.email = ["wael.nasreddine@gmail.com"] 10 | s.homepage = "https://github.com/TechnoGate/watch_tower" 11 | s.summary = <<-MSG 12 | WatchTower helps you track how much time you spend on all 13 | of your projects. 14 | MSG 15 | s.description = s.summary 16 | 17 | s.files = `git ls-files`.split("\n") 18 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 19 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 20 | s.require_paths = ["lib"] 21 | 22 | s.required_ruby_version = Gem::Requirement.new(">= 1.9.2") 23 | 24 | #### 25 | # Run-time dependencies 26 | #### 27 | 28 | # Thor 29 | s.add_dependency 'thor', '~>0.14.6' 30 | 31 | # Active Support 32 | s.add_dependency 'activesupport', '~>3.1.1' 33 | s.add_dependency 'i18n', '~>0.6.0' 34 | 35 | # Active Record 36 | s.add_dependency 'activerecord', '~>3.1.1' 37 | 38 | # Sinatra 39 | s.add_dependency 'sinatra', '~> 1.3.0' 40 | s.add_dependency 'sinatra-snap', '~>0.3.2' 41 | s.add_dependency 'haml', '~>3.1.3' 42 | 43 | # Git 44 | s.add_dependency 'grit', '~>2.4.1' 45 | 46 | # Asset Pipeline 47 | s.add_dependency 'coffee-script', '~>2.2.0' 48 | s.add_dependency 'uglifier', '~>1.0.3' 49 | s.add_dependency 'sass', '~>3.1.10' 50 | s.add_dependency 'sprockets', '~>2.0.2' 51 | 52 | # Crontab editor 53 | s.add_dependency 'cronedit', '~>0.3.0' 54 | 55 | #### 56 | # Development dependencies 57 | #### 58 | 59 | # Guard 60 | s.add_development_dependency 'guard', '~>0.8.4' 61 | s.add_development_dependency 'guard-bundler', '~>0.1.3' 62 | s.add_development_dependency 'guard-rspec', '~>0.4.5' 63 | s.add_development_dependency 'guard-sprockets2', '~>0.0.5' 64 | 65 | # Documentation 66 | s.add_development_dependency 'yard', '~>0.7.2' 67 | 68 | #### 69 | # Development / Test dependencies 70 | #### 71 | 72 | # RSpec / Capybara 73 | s.add_development_dependency 'rspec', '~>2.6.0' 74 | s.add_development_dependency 'rspec-rails', '~>2.6.1' 75 | s.add_development_dependency 'capybara', '~>1.1.1' 76 | s.add_development_dependency 'launchy', '~>2.0.5' 77 | 78 | # Mocha 79 | s.add_development_dependency 'mocha', '~>0.10.0' 80 | 81 | # Factory Girl 82 | s.add_development_dependency 'factory_girl', '~>2.1.2' 83 | 84 | # Timecop 85 | s.add_development_dependency 'timecop', '~>0.3.5' 86 | 87 | #### 88 | # Debugging 89 | #### 90 | s.add_development_dependency 'pry', '~>0.9.6.2' 91 | end 92 | -------------------------------------------------------------------------------- /lib/watch_tower/server/models/time_entry.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module Server 5 | class TimeEntry < ::ActiveRecord::Base 6 | 7 | # Default pause time, in case nothing was given in the config file 8 | DEFAULT_PAUSE_TIME = 30.minutes 9 | 10 | # Validations 11 | validates :file_id, presence: true 12 | validates :mtime, presence: true 13 | validates_uniqueness_of :mtime, scope: :file_id 14 | validates :file_hash, presence: true 15 | validates :editor_name, presence: true 16 | validates :editor_version, presence: true 17 | 18 | 19 | # Associations 20 | belongs_to :file, counter_cache: true 21 | has_many :durations 22 | 23 | # Callbacks 24 | after_create :calculate_elapsed_time 25 | 26 | protected 27 | # Calculate the elapsed time between this time entry and the last one 28 | # then update the durations table with the time difference, either by 29 | # updating the duration for this day or by creating a new one for the 30 | # next day 31 | def calculate_elapsed_time 32 | # Gather information about this and last time entry for this file 33 | this_time_entry = self 34 | last_time_entry = file.time_entries.where('id < ?', this_time_entry.id).order('id DESC').limit(1).first 35 | # Check the hash first 36 | return if this_time_entry.file_hash == last_time_entry.try(:file_hash) 37 | # Update the file's hash 38 | file.file_hash = this_time_entry.file_hash 39 | # Parse the date of the mtime 40 | this_time_entry_date = self.mtime.to_date 41 | last_time_entry_date = last_time_entry.mtime.to_date rescue nil 42 | # Act upon the date 43 | if this_time_entry_date == last_time_entry_date 44 | # Calculate the time 45 | time_entry_elapsed = this_time_entry.mtime - last_time_entry.mtime rescue 0 46 | if time_entry_elapsed <= pause_time && time_entry_elapsed >= 0 47 | # Update the file elapsed time 48 | file.elapsed_time += time_entry_elapsed 49 | # Update the project's elapsed time 50 | file.project.elapsed_time += time_entry_elapsed 51 | # Add this time to the durations table 52 | d = file.durations.find_or_create_by_date(this_time_entry_date) 53 | d.duration += time_entry_elapsed 54 | d.save 55 | end 56 | end 57 | 58 | # Save the file and project 59 | file.save 60 | file.project.save 61 | end 62 | 63 | # Get the 64 | def pause_time 65 | eval(Config[:pause_time]) rescue DEFAULT_PAUSE_TIME 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /.todo: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The elapsed time of the project should change when the duration changes 5 | 6 | 7 | Remove the last_id from File and Project as it's not needed. 8 | 9 | 10 | Add a default_scope to sort files and projects by their elapsed time 11 | 12 | 13 | Add a config file, a yaml file to configure watch_tower a section under database/environment should be added to configure the server's database... watch_tower should not be run unless the config file has been edited. 14 | 15 | 16 | Add a ci/travis.rb script which runs the tests under Travis-CI, needed because the config file added in todo number 3 won't exist and the current setup (sqlite) does not work with JRuby 17 | 18 | 19 | Figure out how to load assets from gems and remove the bundled jQuery files. 20 | 21 | 22 | Make it possible to add a new project, a project that can't be recognised by GitBased nor PathBased. 23 | 24 | 25 | The Config module need some tests. 26 | 27 | 28 | Convert the Editor module to be Enumerable for easier access to the list of editors 29 | 30 | 31 | maybe use sinatra-simple-navigation gem 32 | 33 | 34 | Add tracking by git branch, needs a branches table that belongs to a project and has many time_entries 35 | 36 | 37 | Maybe use a Rails engine instead of Sinatra ? 38 | 39 | 40 | Add support for ViM 41 | 42 | 43 | Add support for Netbeans 44 | 45 | 46 | Add support for Eclipse 47 | 48 | 49 | Add support for Linux (it should work after adding ViM, Eclipse and Netbeans) 50 | 51 | 52 | Add support for Microsoft Windows 53 | 54 | 55 | Add support Microsoft Office (Word, Excel, Powerpoint) 56 | 57 | 58 | -------------------------------------------------------------------------------- /lib/watch_tower/server/app.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | # Sinatra 4 | require 'sinatra' 5 | 6 | module WatchTower 7 | module Server 8 | class App < ::Sinatra::Application 9 | # Helper 10 | include Helpers::ImprovedPartials 11 | include Helpers::Asset 12 | include Helpers::Presenters 13 | 14 | # Configurations 15 | include Configurations::Asset 16 | 17 | # Routes 18 | paths :root => '/' 19 | paths :rehash => '/rehash' 20 | paths :project => '/project/:id' 21 | 22 | # Enable sessions 23 | enable :sessions 24 | 25 | # Before filter 26 | before do 27 | # Parse the from/to date from params and add it to the session 28 | if params[:from_date] && params[:to_date] 29 | if params[:from_date].present? && params[:to_date].present? 30 | session[:date_filtering] = { 31 | from_date: params[:from_date], 32 | to_date: params[:to_date] 33 | } 34 | else 35 | session[:date_filtering] = nil 36 | end 37 | end 38 | 39 | # Make sure we have a default date filtering 40 | unless session.try(:[], :date_filtering).try(:[], :from_date) && session.try(:[], :date_filtering).try(:[], :to_date) 41 | session[:date_filtering] = { 42 | from_date: Time.now.to_date.beginning_of_month.strftime('%m/%d/%Y'), 43 | to_date: Time.now.to_date.strftime('%m/%d/%Y') 44 | } 45 | end 46 | end 47 | 48 | # The index action 49 | get :root do 50 | @title = "Projects" 51 | @durations = Duration.date_range(session[:date_filtering][:from_date], session[:date_filtering][:to_date]) 52 | @projects = @durations.collect(&:file).collect(&:project).uniq.sort_by { |p| p.elapsed_time }.reverse 53 | 54 | haml :index, layout: (request.xhr? ? false : :layout) 55 | end 56 | 57 | # The project action 58 | get :project do 59 | @project = Project.find(params[:id]) 60 | @title = "Project - #{@project.name.camelcase}" 61 | @durations = @project.durations.date_range(session[:date_filtering][:from_date], session[:date_filtering][:to_date]) 62 | @files = @durations.collect(&:file).uniq.sort_by { |f| f.elapsed_time }.reverse 63 | 64 | haml :project, layout: (request.xhr? ? false : :layout) 65 | end 66 | 67 | # Rehash the elapsed_times 68 | get :rehash do 69 | # Pause the eye to avoid conflicts 70 | $pause_eye = true 71 | # Iterate over all projects and recalculate_elapsed_time 72 | Project.all.each do |p| 73 | p.recalculate_elapsed_time 74 | end 75 | # Resume the eye 76 | $pause_eye = false 77 | # Redirect back to the home page. 78 | redirect path_to(:root) 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/watch_tower/project/git_based.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'grit' 4 | 5 | module WatchTower 6 | class Project 7 | module GitBased 8 | extend self 9 | include AnyBased 10 | 11 | # Cache for working_directory by path 12 | # The key is the path to a file, the value is the working directory of 13 | # this path 14 | @@working_cache = Hash.new 15 | 16 | # Cache for project_name by path 17 | # The key is the path to a file, the value is the project's name 18 | @@project_name_cache = Hash.new 19 | 20 | # Cache for project git path 21 | # The key is the path to a file, the value is the project's parts 22 | @@project_git_folder_path = Hash.new 23 | 24 | # Check if the path is under Git 25 | # 26 | # @param path The path we should check if it's under Git control 27 | # @param [Hash] options A hash of options 28 | # @return boolean 29 | def active_for_path?(path, options = {}) 30 | path = expand_path path 31 | project_git_folder_path(path).present? 32 | end 33 | 34 | # Return the working directory (the project's path if you will) from a path 35 | # to any file inside the project 36 | # 37 | # @param path The path to look the project path from 38 | # @param [Hash] options A hash of options 39 | # @return [String] the project's folder 40 | def working_directory(path, options = {}) 41 | path = expand_path path 42 | return @@working_cache[path] if @@working_cache.key?(path) 43 | 44 | @@working_cache[path] = File.dirname(project_git_folder_path(path)) 45 | @@working_cache[path] 46 | end 47 | 48 | # Return the project's name from a path to any file inside the project 49 | # 50 | # @param path The path to look the project path from 51 | # @param [Hash] options A hash of options 52 | # @return [String] the project's name 53 | def project_name(path, options = {}) 54 | path = expand_path path 55 | return @@project_name_cache[path] if @@project_name_cache.key?(path) 56 | 57 | @@project_name_cache[path] = File.basename working_directory(path, options) 58 | @@project_name_cache[path] 59 | end 60 | 61 | def head(path) 62 | log(path).first 63 | end 64 | 65 | def log(path) 66 | g = Grit::Repo.new(path) 67 | g.commits 68 | end 69 | 70 | protected 71 | def project_git_folder_path(path) 72 | return @@project_git_folder_path[path] if @@project_git_folder_path.key?(path) 73 | 74 | # Define the start 75 | n = 0 76 | # Define the maximum search folder 77 | max_n = path.split('/').size 78 | 79 | until File.exists?(File.expand_path File.join(path, (%w{..} * n).flatten, '.git')) || n > max_n 80 | n = n + 1 81 | end 82 | 83 | @@project_git_folder_path[path] = n <= max_n ? File.expand_path(File.join(path, (%w{..} * n).flatten, '.git')) : nil 84 | @@project_git_folder_path[path] 85 | end 86 | end 87 | end 88 | end -------------------------------------------------------------------------------- /spec/watch_tower/editor/textmate_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Editor 4 | describe Textmate do 5 | before(:each) do 6 | @file_path = '/path/to/file.rb' 7 | @app = mock() 8 | @app.stubs(:is_running?).returns(true) 9 | @documents = mock 10 | @document = mock 11 | @path = mock 12 | @path.stubs(:get).returns(@file_path) 13 | @document.stubs(:path).returns(@path) 14 | @documents.stubs(:get).returns([@document]) 15 | @app.stubs(:document).returns(@documents) 16 | @name = mock 17 | @name.stubs(:get).returns("TextMate") 18 | @app.stubs(:name).returns(@name) 19 | @version = mock 20 | @version.stubs(:get).returns("1.5.10") 21 | @app.stubs(:version).returns(@version) 22 | Textmate.any_instance.stubs(:editor).returns(@app) 23 | end 24 | 25 | it { should respond_to :current_path } 26 | 27 | it { should respond_to :name } 28 | its(:name) { should_not raise_error NotImplementedError } 29 | its(:name) { should_not be_empty } 30 | 31 | it { should respond_to :version } 32 | its(:version) { should_not raise_error NotImplementedError } 33 | its(:version) { should_not be_empty } 34 | 35 | describe "#is_running?" do 36 | it { should respond_to :is_running? } 37 | 38 | it "should return wether Textmate is running or not" do 39 | @app.expects(:is_running?).returns(true).once 40 | 41 | subject.is_running?.should be_true 42 | end 43 | end 44 | 45 | describe "#cuurent_path" do 46 | it { should respond_to :current_path } 47 | 48 | it "should return the current_path if textmate running" do 49 | @app.expects(:is_running?).returns(true).once 50 | @path.expects(:get).returns(@file_path).once 51 | @document.expects(:path).returns(@path).once 52 | @documents.expects(:get).returns([@document]).once 53 | @app.expects(:document).returns(@documents).once 54 | Textmate.any_instance.stubs(:editor).returns(@app) 55 | 56 | subject.current_path.should == @file_path 57 | end 58 | 59 | it "should return nil if textmate ain't running" do 60 | @app.expects(:is_running?).returns(false).once 61 | Textmate.any_instance.stubs(:editor).returns(@app) 62 | 63 | subject.current_path.should be_nil 64 | end 65 | end 66 | 67 | describe "#cuurent_path" do 68 | it { should respond_to :current_paths } 69 | 70 | it "should return the current_paths if textmate running" do 71 | @app.expects(:is_running?).returns(true).once 72 | @path.expects(:get).returns(@file_path).once 73 | @document.expects(:path).returns(@path).once 74 | @documents.expects(:get).returns([@document]).once 75 | @app.expects(:document).returns(@documents).once 76 | Textmate.any_instance.stubs(:editor).returns(@app) 77 | 78 | subject.current_paths.should == [@file_path] 79 | end 80 | 81 | it "should return nil if textmate ain't running" do 82 | @app.expects(:is_running?).returns(false).once 83 | Textmate.any_instance.stubs(:editor).returns(@app) 84 | 85 | subject.current_paths.should be_nil 86 | end 87 | end 88 | 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/watch_tower.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | # RubyGems is needed. 4 | require 'rubygems' 5 | 6 | # Require bundler and setup load pathes 7 | require "bundler/setup" 8 | 9 | # Require daemon from active_support's core_ext allows us to fork really quickly 10 | require "active_support/core_ext/process/daemon" 11 | 12 | # External requirements 13 | require "fileutils" 14 | require "logger" 15 | require "active_record" 16 | 17 | # Define a few pathes 18 | ROOT_PATH = File.expand_path(File.join(File.dirname(__FILE__), '..')) 19 | LIB_PATH = File.join(ROOT_PATH, 'lib', 'watch_tower') 20 | SERVER_PATH = File.join(LIB_PATH, 'server') 21 | MODELS_PATH = File.join(SERVER_PATH, 'models') 22 | EXTENSIONS_PATH = File.join(SERVER_PATH, 'extensions') 23 | MIGRATIONS_PATH = File.join(SERVER_PATH, 'db', 'migrate') 24 | TEMPLATE_PATH = File.join(LIB_PATH, 'templates') 25 | USER_PATH = File.expand_path(File.join(ENV['HOME'], '.watch_tower')) 26 | DATABASE_PATH = File.join(USER_PATH, 'databases') 27 | LOG_PATH = File.join(USER_PATH, 'log') 28 | EDITOR_EXTENSIONS_PATH = File.join(LIB_PATH, 'editor', 'extensions') 29 | 30 | # Define the environment by default set to development 31 | ENV['WATCH_TOWER_ENV'] ||= 'development' 32 | 33 | # Make sure the USER_PATH exist 34 | FileUtils.mkdir_p USER_PATH 35 | FileUtils.mkdir_p DATABASE_PATH 36 | FileUtils.mkdir_p LOG_PATH 37 | 38 | # module WatchTower 39 | module WatchTower 40 | # Make sure all the methods are available as both Class and Instance methods. 41 | extend self 42 | 43 | # Create a logger 44 | LOG = Logger.new(File.join(LOG_PATH, "#{ENV['WATCH_TOWER_ENV']}.log")) 45 | 46 | # Make it by default warn level 47 | LOG.level = Logger::INFO 48 | 49 | # Threads 50 | # Hash 51 | @@threads = {} 52 | 53 | # Returh the threads 54 | # 55 | # @return [Hash] Threads 56 | def threads 57 | @@threads 58 | end 59 | 60 | # Get WatchTower's environment 61 | # 62 | # @return [String] The current environment 63 | def env 64 | ENV['WATCH_TOWER_ENV'] 65 | end 66 | 67 | # Set WatchTower's environment 68 | # 69 | # @param [String] The environment 70 | def env=(environment) 71 | ENV['WATCH_TOWER_ENV'] = environment 72 | end 73 | 74 | # Cross-platform way of finding an executable in the $PATH. 75 | # 76 | # Taken from hub 77 | # https://github.com/defunkt/hub/blob/master/lib/hub/context.rb#L186 78 | # 79 | # which('ruby') #=> /usr/bin/ruby 80 | def which(cmd) 81 | exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : [''] 82 | ENV['PATH'].split(File::PATH_SEPARATOR).each do |path| 83 | exts.each { |ext| 84 | exe = "#{path}/#{cmd}#{ext}" 85 | return exe if File.executable? exe 86 | } 87 | end 88 | return nil 89 | end 90 | end 91 | 92 | # Make sure we are running UTF-8 93 | Encoding.default_external = 'utf-8' 94 | 95 | # Require watch_tower's libraries 96 | require "watch_tower/version" 97 | require "watch_tower/errors" 98 | require "watch_tower/core_ext" 99 | require "watch_tower/config" 100 | require "watch_tower/cli" 101 | require "watch_tower/editor" 102 | require "watch_tower/project" 103 | require "watch_tower/eye" 104 | require "watch_tower/file_tree" 105 | require "watch_tower/server" 106 | -------------------------------------------------------------------------------- /lib/watch_tower/server/database.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'active_record' 4 | 5 | module WatchTower 6 | module Server 7 | module Database 8 | extend self 9 | 10 | # Start the database server 11 | # 12 | # see #connect! 13 | # see #migrate! 14 | # @param [Hash] options 15 | def start!(options = {}) 16 | LOG.debug("#{__FILE__}:#{__LINE__}: Starting the database server.") 17 | 18 | # Connect to the Database 19 | connect! 20 | 21 | # Migrate the database 22 | migrate! 23 | rescue DatabaseConfigNotFoundError 24 | raise ConfigNotFound, 25 | "Database configurations are missing, please edit #{Config.config_file} and try again." 26 | rescue ::ActiveRecord::ConnectionNotEstablished => e 27 | raise DatabaseError, 28 | "There was an error connecting to the database: #{error}" 29 | end 30 | 31 | # Stop the database server 32 | # 33 | # see #disconnect! 34 | # @param [Hash] options 35 | def stop!(options = {}) 36 | # Disconnect from the database 37 | disconnect! 38 | rescue DatabaseConfigNotFoundError 39 | raise ConfigNotFound, 40 | "Database configurations are missing, please edit #{Config.config_file} and try again." 41 | rescue ::ActiveRecord::ConnectionNotEstablished => e 42 | raise DatabaseError, 43 | "There was an error connecting to the database: #{error}" 44 | end 45 | 46 | def is_connected? 47 | ActiveRecord::Base.connected? 48 | end 49 | 50 | def is_migrated? 51 | ActiveRecord::Migrator.current_version == 52 | ActiveRecord::Migrator.migrations(MIGRATIONS_PATH).last.version 53 | end 54 | 55 | protected 56 | # Connect to the database 57 | def connect! 58 | return if is_connected? 59 | LOG.debug("#{__FILE__}:#{__LINE__}: Connecting to the database.") 60 | # Create a connection 61 | ActiveRecord::Base.establish_connection(db_config) 62 | 63 | # Create a looger 64 | logger unless ENV['WATCH_TOWER_ENV'] == 'production' 65 | end 66 | 67 | # Disconnect from the database 68 | def disconnect! 69 | ActiveRecord::Base.remove_connection 70 | end 71 | 72 | # Migrate the database 73 | def migrate! 74 | return if is_migrated? 75 | LOG.debug("#{__FILE__}:#{__LINE__}: Migrating the database.") 76 | # Connect to the database 77 | connect! 78 | 79 | # Migrate the database 80 | ActiveRecord::Migrator.migrate(MIGRATIONS_PATH) 81 | end 82 | 83 | # Get the database configuration 84 | def db_config 85 | db_config = Config.try(:[], :database).try(:[], ENV['WATCH_TOWER_ENV']) 86 | raise DatabaseConfigNotFoundError unless db_config 87 | if db_config[:adapter] =~ /sqlite/ && db_config[:database] != ":memory:" 88 | db_config[:database] = ::File.expand_path(db_config[:database]) 89 | end 90 | db_config 91 | end 92 | 93 | # Set the logger 94 | def logger 95 | ActiveRecord::Base.logger ||= Logger.new ::File.open(log_path, 'a') 96 | end 97 | 98 | # Get the log file's absolute path 99 | # 100 | # @return [String] The database log file depending on the environment 101 | def log_path 102 | ::File.join(LOG_PATH, "#{ENV['WATCH_TOWER_ENV']}_database.log") 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /spec/watch_tower/project/git_based_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class Project 4 | describe GitBased do 5 | before(:all) do 6 | # Arguments 7 | @code = '/home/user/Code' 8 | @file_path = '/home/user/Code/OpenSource/watch_tower/lib/watch_tower/server/models/time_entries.rb' 9 | 10 | # Expected results 11 | @project_path = '/home/user/Code/OpenSource/watch_tower' 12 | @project_git_folder_path = @project_path + '/.git' 13 | @project_name = 'watch_tower' 14 | end 15 | 16 | before(:each) do 17 | # Project.stubs(:expand_path).returns 18 | end 19 | 20 | describe "#project_git_folder_path" do 21 | it "should respond to :project_git_folder_path" do 22 | -> { subject.send(:project_git_folder_path) }.should_not raise_error NoMethodError 23 | end 24 | 25 | it "should return a path if exists" do 26 | @file_path.split('/').each_index do |i| 27 | path = @file_path.split('/')[0..i].join('/') + '/.git' 28 | if @project_git_folder_path == path 29 | File.stubs(:exists?).with(path).returns(true) 30 | else 31 | File.stubs(:exists?).with(path).returns(false) 32 | end 33 | end 34 | 35 | subject.send(:project_git_folder_path, @file_path).should == @project_git_folder_path 36 | end 37 | 38 | it "should return nil if path does not exist" do 39 | file_path = @file_path.gsub(%r{#{@code}}, '/some/other/path') 40 | file_path.split('/').each_index do |i| 41 | path = file_path.split('/')[0..i].join('/') 42 | File.stubs(:exists?).with(path + '/.git').returns(false) 43 | end 44 | 45 | subject.send(:project_git_folder_path, file_path).should be_nil 46 | end 47 | 48 | it "should cache it" do 49 | File.expects(:exists?).never 50 | 51 | subject.send(:project_git_folder_path, @file_path).should == @project_git_folder_path 52 | end 53 | end 54 | 55 | 56 | describe "#active_for_path?" do 57 | it { should respond_to(:active_for_path?) } 58 | 59 | it "should be able to determine if a path is git-ized" do 60 | GitBased.expects(:project_git_folder_path).returns(@project_path) 61 | 62 | subject.active_for_path?(@file_path).should be_true 63 | end 64 | 65 | it "should be able to determine if a path is not git-ized" do 66 | GitBased.expects(:project_git_folder_path).returns(nil) 67 | 68 | subject.active_for_path?(@file_path).should be_false 69 | end 70 | end 71 | 72 | describe "#working_directory" do 73 | it { should respond_to(:working_directory) } 74 | 75 | it "should return the working directory of a path" do 76 | GitBased.stubs(:project_git_folder_path).returns(@project_git_folder_path) 77 | 78 | subject.working_directory(@file_path).should == @project_path 79 | end 80 | 81 | it "should cache it" do 82 | GitBased.expects(:project_git_folder_path).never 83 | 84 | subject.working_directory(@file_path).should == @project_path 85 | end 86 | end 87 | 88 | describe "#project_name" do 89 | it { should respond_to(:project_name) } 90 | 91 | it "should return the working directory of a path" do 92 | GitBased.stubs(:project_git_folder_path).returns(@project_git_folder_path) 93 | 94 | subject.project_name(@file_path).should == @project_name 95 | end 96 | 97 | it "should cache it" do 98 | GitBased.expects(:project_git_folder_path).never 99 | 100 | subject.project_name(@file_path).should == @project_name 101 | end 102 | end 103 | 104 | describe "#head" do 105 | before(:each) do 106 | GitBased.stubs(:active_for_path?).returns(true) 107 | GitBased.stubs(:working_directory).returns(@project_path) 108 | end 109 | 110 | it { should respond_to :head } 111 | 112 | it "should create a Grit::Repo object" do 113 | commit = mock 114 | git_base = mock 115 | git_base.stubs(:commits).returns([commit]) 116 | Grit::Repo.expects(:new).with(@project_path).returns(git_base).once 117 | 118 | subject.head(@project_path) 119 | end 120 | 121 | it "should return the head revision" do 122 | commit = mock 123 | git_base = mock 124 | git_base.stubs(:commits).returns([commit]) 125 | Grit::Repo.stubs(:new).with(@project_path).returns(git_base) 126 | 127 | subject.head(@project_path).should == commit 128 | end 129 | end 130 | end 131 | end -------------------------------------------------------------------------------- /lib/watch_tower/templates/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | watch_tower: 3 | # Change this to true once you have reviewed the file 4 | enabled: false 5 | 6 | # Start WatchTower on boot? 7 | launch_on_boot: true 8 | 9 | # Where do you store your code? 10 | # This is not needed if all your projects are using Git for versioning control 11 | # as the project's name and path would be determined from the folder name that 12 | # contains the .git folder, if that's the case, wherever you store your code, 13 | # the project name and path can be retrived very easily. 14 | code_path: ~/Code 15 | 16 | # How do you nest your code folder? 17 | # Again this setting is only effective if not all your projects are using Git 18 | # For example consider this layout of the code folder 19 | # 20 | # ~/Code/ 21 | # Clients/ 22 | # AcmeCorp/ 23 | # website/ 24 | # intranet 25 | # BetaCorp/ 26 | # skunkworks/ 27 | # OpenSource/ 28 | # project_one/ 29 | # timetap/ 30 | # 31 | # A nested_project_layers setting of 2 would mean we track "AcmeCorp", "BetaCorp", and everything 32 | # under OpenSource, as their own projects 33 | # 34 | nested_project_layers: 2 35 | 36 | # Pause time, default: 30 minutes 37 | # The pause time is the time to be considered as a pause time, if you save 38 | # a file 31 minutes after your last save, the time passed won't be considered 39 | # as a work time and thus won't be counted in the elapsed time of the project 40 | pause_time: 30.minutes 41 | 42 | # Database configuration 43 | # Uncomment the section best fit for your adapter and edit it 44 | database: 45 | # MySQL adapter 46 | # 47 | # Ruby: 48 | # Make sure you have the mysql2 gem 49 | # gem install mysql2 50 | # 51 | # Jruby: 52 | # Make sure you have the activerecord-jdbcmysql-adapter gem 53 | # gem install activerecord-jdbcmysql-adapter 54 | # And replace mysql2 with jdbcmysql 55 | # 56 | # development: 57 | # adapter: mysql2 58 | # encoding: utf8 59 | # reconnect: false 60 | # database: watch_tower_development 61 | # pool: 5 62 | # username: root 63 | # password: 64 | # socket: /tmp/mysql.sock 65 | # test: 66 | # adapter: mysql2 67 | # encoding: utf8 68 | # reconnect: false 69 | # database: watch_tower_test 70 | # pool: 5 71 | # username: root 72 | # password: 73 | # socket: /tmp/mysql.sock 74 | # production: 75 | # adapter: mysql2 76 | # encoding: utf8 77 | # reconnect: false 78 | # database: watch_tower_production 79 | # pool: 5 80 | # username: root 81 | # password: 82 | # socket: /tmp/mysql.sock 83 | # 84 | # 85 | # PostgresSQL adapter 86 | # 87 | # Ruby: 88 | # Make sure you have the pg adapter installed 89 | # gem install pg 90 | # 91 | # Jruby: 92 | # Make sure you have the activerecord-jdbcpostgresql-adapter gem 93 | # gem install activerecord-jdbcpostgresql-adapter 94 | # And replace postgresql with jdbcpostgresql 95 | # 96 | # development: 97 | # adapter: postgresql 98 | # encoding: unicode 99 | # database: watch_tower_development 100 | # pool: 5 101 | # username: watch_tower 102 | # password: 103 | # test: 104 | # adapter: postgresql 105 | # encoding: unicode 106 | # database: watch_tower_test 107 | # pool: 5 108 | # username: watch_tower 109 | # password: 110 | # 111 | # production: 112 | # adapter: postgresql 113 | # encoding: unicode 114 | # database: watch_tower_production 115 | # pool: 5 116 | # username: watch_tower 117 | # password: 118 | # 119 | # 120 | # Sqlite3 Adapter 121 | # 122 | # Ruby: 123 | # Make sure you have the sqlite3 124 | # gem install sqlite3 125 | # 126 | # Jruby: 127 | # Make sure you have the activerecord-jdbcsqlite3-adapter gem 128 | # gem install activerecord-jdbcsqlite3-adapter 129 | # And replace sqlite3 with jdbcsqlite3 130 | # 131 | # development: 132 | # adapter: sqlite3 133 | # database: ~/.watch_tower/databases/development.sqlite3 134 | # pool: 5 135 | # timeout: 5000 136 | # test: 137 | # adapter: sqlite3 138 | # database: ~/.watch_tower/databases/test.sqlite3 139 | # pool: 5 140 | # timeout: 5000 141 | # 142 | # production: 143 | # adapter: sqlite3 144 | # database: ~/.watch_tower/databases/production.sqlite3 145 | # pool: 5 146 | # timeout: 5000 147 | -------------------------------------------------------------------------------- /spec/watch_tower/project/path_based_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class Project 4 | describe PathBased do 5 | before(:all) do 6 | # Arguments 7 | @code = '/home/user/Code' 8 | @file_path = '/home/user/Code/OpenSource/watch_tower/lib/watch_tower/server/models/time_entries.rb' 9 | @nested_project_layers = 2 10 | @args = [@code, @file_path, @nested_project_layers] 11 | @options = { 12 | code: @code, 13 | nested_project_layers: @nested_project_layers 14 | } 15 | 16 | # Expected results 17 | @project_path = '/home/user/Code/OpenSource/watch_tower' 18 | @project_name = 'watch_tower' 19 | @project_path_parts = ['/home/user/Code', 'OpenSource', 'watch_tower'] 20 | end 21 | 22 | before(:each) do 23 | PathBased.stubs(:code_path).returns(@code) 24 | PathBased.stubs(:code_path).returns(@code) 25 | end 26 | 27 | describe "#project_path_part" do 28 | it "should respond to #project_path_part" do 29 | -> { subject.send :project_path_part, *@args }.should_not raise_error NoMethodError 30 | end 31 | 32 | it "should be able to return the project name from the path Given the correct path" do 33 | subject.send(:project_path_part, *@args).should == @project_path_parts 34 | end 35 | 36 | it "should raises PathNotUnderCodePath if the path is not nested under code" do 37 | file_path = @file_path.gsub(%r{#{@code}}, '/some/other/path') 38 | -> { subject.send(:project_path_part, @code, file_path, @nested_project_layers) }.should raise_error PathNotUnderCodePath 39 | end 40 | 41 | it "should cache the path it" do 42 | file_path = @file_path 43 | file_path.expects(:scan).never 44 | 45 | subject.send(:project_path_part, @code, file_path, @nested_project_layers).should == @project_path_parts 46 | end 47 | end 48 | 49 | describe "#project_name_from_nested_path" do 50 | it "should respond to #project_name_from_nested_path" do 51 | -> { subject.send :project_name_from_nested_path, *@args }.should_not raise_error NoMethodError 52 | end 53 | 54 | it "should be able to return the project path parts from the path Given the correct path" do 55 | subject.send(:project_name_from_nested_path, *@args).should == @project_name 56 | end 57 | 58 | it "should raises PathNotUnderCodePath if the path is not nested under code" do 59 | file_path = @file_path.gsub(%r{#{@code}}, '/some/other/path') 60 | -> { subject.send(:project_name_from_nested_path, @code, file_path, @nested_project_layers) }.should raise_error PathNotUnderCodePath 61 | end 62 | end 63 | 64 | describe "#project_path_from_nested_path" do 65 | it "should respond to #project_path_from_nested_path" do 66 | -> { subject.send :project_path_from_nested_path, *@args }.should_not raise_error NoMethodError 67 | end 68 | 69 | it "should be able to return the project name from the path Given the correct path" do 70 | subject.send(:project_path_from_nested_path, *@args).should == @project_path 71 | end 72 | 73 | it "should raises PathNotUnderCodePath if the path is not nested under code" do 74 | file_path = @file_path.gsub(%r{#{@code}}, '/some/other/path') 75 | -> { subject.send(:project_path_from_nested_path, @code, file_path, @nested_project_layers) }.should raise_error PathNotUnderCodePath 76 | end 77 | end 78 | 79 | describe "#working_directory" do 80 | it { should respond_to :working_directory } 81 | 82 | it "should return the project path from nested path of the given path" do 83 | PathBased.expects(:project_path_from_nested_path).with(*@args).returns(@project_path).once 84 | 85 | subject.working_directory(@file_path, @options).should == @project_path 86 | end 87 | 88 | it "should cache the path it" do 89 | PathBased.expects(:project_path_from_nested_path).with(*@args).returns(@project_path).never 90 | 91 | subject.working_directory(@file_path, @options).should == @project_path 92 | end 93 | end 94 | 95 | describe "#project_name" do 96 | it { should respond_to :project_name } 97 | 98 | it "should return the project path from nested path of the given path" do 99 | PathBased.expects(:project_name_from_nested_path).with(*@args).returns(@project_name).once 100 | 101 | subject.project_name(@file_path, @options).should == @project_name 102 | end 103 | 104 | it "should cache the path it" do 105 | PathBased.expects(:project_name_from_nested_path).with(*@args).returns(@project_name).never 106 | 107 | subject.project_name(@file_path, @options).should == @project_name 108 | end 109 | end 110 | end 111 | end -------------------------------------------------------------------------------- /lib/watch_tower/editor/vim.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'open3' 4 | 5 | module WatchTower 6 | module Editor 7 | class Vim 8 | include BasePs 9 | 10 | VIM_EXTENSION_PATH = File.join(EDITOR_EXTENSIONS_PATH, 'watchtower.vim') 11 | 12 | # Set the attributes read/write of this class. 13 | attr_reader :version 14 | 15 | def initialize 16 | # Get the list of supported vims 17 | supported_vims 18 | # Fetch the version 19 | @version ||= fetch_version 20 | end 21 | 22 | # Return the name of the Editor 23 | # 24 | # @return [String] The editor's name 25 | def name 26 | "ViM" 27 | end 28 | 29 | # Is it running ? 30 | # 31 | # @return [Boolean] Is ViM running ? 32 | def is_running? 33 | servers && servers.any? 34 | end 35 | 36 | # Return the open documents of all vim servers 37 | # 38 | # @return [Array] Absolute paths to all open documents 39 | def current_paths 40 | if is_running? 41 | # Init documents 42 | documents = [] 43 | servers.each do |server| 44 | stdin, stdout, stderr, wait_thr = Open3.popen3 "#{editor} --servername #{server} --remote-expr 'watchtower#ls()'" 45 | 46 | if stderr.read =~ /Invalid expression received: Send expression failed/i 47 | # Close the pipes 48 | [stdin, stdout, stderr].each { |p| p.try(:close) } 49 | # Send the extenstion to the ViM server 50 | send_extensions_to_editor 51 | # Ask ViM for the documents again 52 | stdin, stdout, stderr, wait_thr = Open3.popen3 "#{editor} --servername #{server} --remote-expr 'watchtower#ls()'" 53 | end 54 | 55 | documents += stdout.read.split("\n") 56 | 57 | # Close the pipes 58 | [stdin, stdout, stderr].each { |p| p.try(:close) } 59 | end 60 | 61 | documents.uniq 62 | end 63 | end 64 | 65 | protected 66 | # Fetch the version 67 | # 68 | # @return [String] The editor's version 69 | def fetch_version 70 | if editor 71 | editor_command = editor 72 | begin 73 | version = nil 74 | Open3.popen2 "#{editor_command} --version" do |stdin, stdout, wait_thr| 75 | parsed_stdout = stdout.read.scan(/^VIM - Vi IMproved (\d+\.\d+).*/) 76 | LOG.debug "#{__FILE__}:#{__LINE__ - 1}: Parsed vim --version: #{parsed_stdout.inspect}" 77 | version = parsed_stdout.try(:first).try(:first) 78 | end 79 | 80 | raise VimVersionNotPrinted if version.nil? 81 | rescue VimVersionNotPrinted 82 | if editor_command =~ /gvim|mvim/ 83 | editor_command = WatchTower.which('vim') 84 | retry 85 | end 86 | end 87 | end 88 | version || 'Not installed' 89 | end 90 | 91 | # Return a list of supported vim commands 92 | # 93 | # @return [Array] A list of supported vim commands. 94 | def supported_vims 95 | @vims ||= ['mvim', 'gvim', 'vim'].collect do |vim| 96 | # Get the absolute path of the command 97 | vim_path = WatchTower.which(vim) 98 | # Print the help of the command 99 | stdin, stdout, wait_thr = Open3.popen2 "#{vim_path} --help" if vim_path 100 | # This command is compatible if it exists and if it respond to --remote 101 | r = vim_path && (vim != 'vim' || stdout.read =~ %r(--remote)) ? vim_path : nil 102 | # Close the pipes 103 | [stdin, stdout].each { |p| p.try(:close) } 104 | r 105 | end.reject { |vim| vim.nil? } 106 | end 107 | 108 | # Return the editor 109 | # 110 | # @return [String|nil] The editor command 111 | def editor 112 | @vims && @vims.any? ? @vims.first : nil 113 | end 114 | 115 | # Returns the running servers 116 | # 117 | # @return [Array] Name of running ViM Servers 118 | def servers 119 | servers = nil 120 | # Tell vim to print the server list 121 | Open3.popen2 "#{editor} --serverlist" do |stdin, stdout, wait_thr| 122 | # Read the server list 123 | servers = stdout.read.split("\n") 124 | end 125 | servers 126 | end 127 | 128 | # Send WatchTower extensions to vim 129 | def send_extensions_to_editor 130 | servers.each do |server| 131 | # Tell vim to source the extensions 132 | stdin, stdout, wait_thr = Open3.popen2 "#{editor} --servername #{server} --remote-send ':source #{VIM_EXTENSION_PATH}'" 133 | # Close the pipes 134 | [stdin, stdout].each { |p| p.try(:close) } 135 | end 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /spec/watch_tower/file_tree_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe FileTree do 4 | before(:each) do 5 | @base_path = "/project" 6 | @simple_files = [ 7 | { path: "#{@base_path}/file1.rb", elapsed_time: 3600 }, 8 | { path: "#{@base_path}/file2.rb", elapsed_time: 1800 }, 9 | ] 10 | @files = @simple_files.dup 11 | @files << { path: "#{@base_path}/folder/file_under_folder1.rb", elapsed_time: 1800 } 12 | @files << { path: "#{@base_path}/folder/file_under_folder2.rb", elapsed_time: 900 } 13 | 14 | @simple_files_elapsed_times = { 15 | @base_path => 5400 16 | } 17 | 18 | @files_elapsed_times = { 19 | @base_path => 8100, 20 | "#{@base_path}/folder" => 2700 21 | } 22 | end 23 | 24 | describe "#remove_base_path_from_files" do 25 | subject { FileTree.new(@base_path, @files) } 26 | 27 | it { should respond_to :remove_base_path_from_files } 28 | 29 | it "should remove the base_path from all the files in the @files array" do 30 | files = subject.send(:remove_base_path_from_files, @base_path, @simple_files) 31 | 32 | files.each do |f| 33 | f[:path].should_not match %r(#{@base_path}/) 34 | end 35 | end 36 | end 37 | 38 | describe "#process" do 39 | subject { FileTree.new(@base_path, @files) } 40 | 41 | it { should respond_to :process } 42 | 43 | it "should be called on initialize" do 44 | FileTree.any_instance.expects(:process).once 45 | 46 | FileTree.new(@base_path, @files) 47 | end 48 | 49 | it "should call parse_files" do 50 | FileTree.any_instance.expects(:parse_files).once 51 | FileTree.any_instance.stubs(:parse_folders) 52 | 53 | FileTree.new(@base_path, @files) 54 | end 55 | 56 | it "should call parse_folders" do 57 | FileTree.any_instance.stubs(:parse_files) 58 | FileTree.any_instance.expects(:parse_folders).once 59 | 60 | FileTree.new(@base_path, @files) 61 | end 62 | end 63 | 64 | describe "simple files without folders" do 65 | subject { FileTree.new(@base_path, @simple_files) } 66 | 67 | describe "#files" do 68 | it { should respond_to :files } 69 | 70 | it "should return an array" do 71 | subject.files.should be_instance_of Array 72 | end 73 | 74 | it "should include all the files with their elapsed times" do 75 | subject.files.each do |f| 76 | f.should be_instance_of Hash 77 | f[:path].should =~ /^file(1|2)\.rb$/ 78 | f[:elapsed_time].to_s.should =~ /^(3600|1800)$/ 79 | end 80 | end 81 | end 82 | 83 | describe "#elapsed_time" do 84 | it { should respond_to :elapsed_time } 85 | 86 | it "should return elapsed_time" do 87 | subject.elapsed_time.should == @simple_files_elapsed_times[@base_path] 88 | end 89 | end 90 | 91 | describe "#nested_tree" do 92 | it { should respond_to :nested_tree } 93 | 94 | it "should have an empty nested_tree" do 95 | subject.nested_tree.should be_empty 96 | end 97 | 98 | end 99 | end 100 | 101 | describe "files with folders" do 102 | subject { FileTree.new(@base_path, @files) } 103 | 104 | describe "#files" do 105 | it { should respond_to :files } 106 | 107 | it "should return an array" do 108 | subject.files.should be_instance_of Array 109 | end 110 | 111 | it "should include all the files with their elapsed times" do 112 | subject.files.each do |f| 113 | f.should be_instance_of Hash 114 | f[:path].should =~ /^file(1|2)\.rb$/ 115 | f[:elapsed_time].to_s.should =~ /^(3600|1800)$/ 116 | end 117 | end 118 | end 119 | 120 | describe "#elapsed_time" do 121 | it { should respond_to :elapsed_time } 122 | 123 | it "should return elapsed_time" do 124 | subject.elapsed_time.should == @files_elapsed_times[@base_path] 125 | end 126 | end 127 | 128 | describe "#nested_tree" do 129 | it { should respond_to :nested_tree } 130 | 131 | it "should not have an empty nested_tree" do 132 | subject.nested_tree.should_not be_empty 133 | end 134 | 135 | it "should be a Hash" do 136 | subject.nested_tree.should be_instance_of Hash 137 | end 138 | 139 | it "should return a FileTree for each element of the hash" do 140 | subject.nested_tree['folder'].should be_instance_of FileTree 141 | end 142 | 143 | it "should include all the files under the folder" do 144 | subject.nested_tree['folder'].files.each do |f| 145 | f.should be_instance_of Hash 146 | f[:path].should =~ /^file_under_folder(1|2)\.rb$/ 147 | f[:elapsed_time].to_s.should =~ /^(1800|900)$/ 148 | end 149 | end 150 | 151 | it "should return the elapsed time of the folder" do 152 | subject.nested_tree['folder'].elapsed_time.should == @files_elapsed_times["#{@base_path}/folder"] 153 | end 154 | end 155 | end 156 | end -------------------------------------------------------------------------------- /lib/watch_tower/server/vendor/assets/stylesheets/jquery.datepick.css: -------------------------------------------------------------------------------- 1 | /* Default styling for jQuery Datepicker v4.0.6. */ 2 | .datepick { 3 | background-color: #fff; 4 | color: #000; 5 | border: 1px solid #444; 6 | border-radius: 0.25em; 7 | -moz-border-radius: 0.25em; 8 | -webkit-border-radius: 0.25em; 9 | font-family: Arial,Helvetica,Sans-serif; 10 | font-size: 90%; 11 | } 12 | .datepick-rtl { 13 | direction: rtl; 14 | } 15 | .datepick-popup { 16 | z-index: 1000; 17 | } 18 | .datepick-disable { 19 | position: absolute; 20 | z-index: 100; 21 | background-color: white; 22 | opacity: 0.5; 23 | filter: alpha(opacity=50); 24 | } 25 | .datepick a { 26 | color: #fff; 27 | text-decoration: none; 28 | } 29 | .datepick a.datepick-disabled { 30 | color: #888; 31 | cursor: auto; 32 | } 33 | .datepick button { 34 | margin: 0.25em; 35 | padding: 0.125em 0em; 36 | background-color: #fcc; 37 | border: none; 38 | border-radius: 0.25em; 39 | -moz-border-radius: 0.25em; 40 | -webkit-border-radius: 0.25em; 41 | font-weight: bold; 42 | } 43 | .datepick-nav, .datepick-ctrl { 44 | float: left; 45 | width: 100%; 46 | background-color: #000; 47 | color: #fff; 48 | font-size: 90%; 49 | font-weight: bold; 50 | } 51 | .datepick-ctrl { 52 | background-color: #600; 53 | } 54 | .datepick-cmd { 55 | width: 30%; 56 | } 57 | .datepick-cmd:hover { 58 | background-color: #777; 59 | } 60 | .datepick-ctrl .datepick-cmd:hover { 61 | background-color: #f08080; 62 | } 63 | .datepick-cmd-prevJump, .datepick-cmd-nextJump { 64 | width: 8%; 65 | } 66 | a.datepick-cmd { 67 | height: 1.5em; 68 | } 69 | button.datepick-cmd { 70 | text-align: center; 71 | } 72 | .datepick-cmd-prev, .datepick-cmd-prevJump, .datepick-cmd-clear { 73 | float: left; 74 | padding-left: 2%; 75 | } 76 | .datepick-cmd-current, .datepick-cmd-today { 77 | float: left; 78 | width: 35%; 79 | text-align: center; 80 | } 81 | .datepick-cmd-next, .datepick-cmd-nextJump, .datepick-cmd-close { 82 | float: right; 83 | padding-right: 2%; 84 | text-align: right; 85 | } 86 | .datepick-rtl .datepick-cmd-prev, .datepick-rtl .datepick-cmd-prevJump, 87 | .datepick-rtl .datepick-cmd-clear { 88 | float: right; 89 | padding-left: 0%; 90 | padding-right: 2%; 91 | text-align: right; 92 | } 93 | .datepick-rtl .datepick-cmd-current, .datepick-rtl .datepick-cmd-today { 94 | float: right; 95 | } 96 | .datepick-rtl .datepick-cmd-next, .datepick-rtl .datepick-cmd-nextJump, 97 | .datepick-rtl .datepick-cmd-close { 98 | float: left; 99 | padding-left: 2%; 100 | padding-right: 0%; 101 | text-align: left; 102 | } 103 | .datepick-month-nav { 104 | float: left; 105 | background-color: #777; 106 | text-align: center; 107 | } 108 | .datepick-month-nav div { 109 | float: left; 110 | width: 12.5%; 111 | margin: 1%; 112 | padding: 1%; 113 | } 114 | .datepick-month-nav span { 115 | color: #888; 116 | } 117 | .datepick-month-row { 118 | clear: left; 119 | } 120 | .datepick-month { 121 | float: left; 122 | width: 15em; 123 | border: 1px solid #444; 124 | text-align: center; 125 | } 126 | .datepick-month-header, .datepick-month-header select, .datepick-month-header input { 127 | height: 1.5em; 128 | background-color: #444; 129 | color: #fff; 130 | font-weight: bold; 131 | } 132 | .datepick-month-header select, .datepick-month-header input { 133 | height: 1.4em; 134 | border: none; 135 | } 136 | .datepick-month-header input { 137 | position: absolute; 138 | display: none; 139 | } 140 | .datepick-month table { 141 | width: 100%; 142 | border-collapse: collapse; 143 | } 144 | .datepick-month thead { 145 | border-bottom: 1px solid #aaa; 146 | } 147 | .datepick-month th, .datepick-month td { 148 | margin: 0em; 149 | padding: 0em; 150 | font-weight: normal; 151 | text-align: center; 152 | } 153 | .datepick-month th { 154 | border: 1px solid #777; 155 | } 156 | .datepick-month th, .datepick-month th a { 157 | background-color: #777; 158 | color: #fff; 159 | } 160 | .datepick-month td { 161 | background-color: #eee; 162 | border: 1px solid #aaa; 163 | } 164 | .datepick-month td.datepick-week { 165 | border: 1px solid #777; 166 | } 167 | .datepick-month td.datepick-week * { 168 | background-color: #777; 169 | color: #fff; 170 | border: none; 171 | } 172 | .datepick-month a { 173 | display: block; 174 | width: 100%; 175 | padding: 0.125em 0em; 176 | background-color: #eee; 177 | color: #000; 178 | text-decoration: none; 179 | } 180 | .datepick-month span { 181 | display: block; 182 | width: 100%; 183 | padding: 0.125em 0em; 184 | } 185 | .datepick-month td span { 186 | color: #888; 187 | } 188 | .datepick-month td .datepick-other-month { 189 | background-color: #fff; 190 | } 191 | .datepick-month td .datepick-weekend { 192 | background-color: #ddd; 193 | } 194 | .datepick-month td .datepick-today { 195 | background-color: #f0c0c0; 196 | } 197 | .datepick-month td .datepick-highlight { 198 | background-color: #f08080; 199 | } 200 | .datepick-month td .datepick-selected { 201 | background-color: #777; 202 | color: #fff; 203 | } 204 | .datepick-month th.datepick-week { 205 | background-color: #777; 206 | color: #fff; 207 | } 208 | .datepick-status { 209 | clear: both; 210 | background-color: #ddd; 211 | text-align: center; 212 | } 213 | .datepick-clear-fix { 214 | clear: both; 215 | } 216 | .datepick-cover { 217 | display: none; 218 | display/**/: block; 219 | position: absolute; 220 | z-index: -1; 221 | filter: mask(); 222 | top: -1px; 223 | left: -1px; 224 | width: 100px; 225 | height: 100px; 226 | } 227 | -------------------------------------------------------------------------------- /lib/watch_tower/server/public/assets/jquery.datepick-9c8dfe3a4d40bcafc7b182e194c13836.css: -------------------------------------------------------------------------------- 1 | /* Default styling for jQuery Datepicker v4.0.6. */ 2 | 3 | .datepick { 4 | background-color: #fff; 5 | color: #000; 6 | border: 1px solid #444; 7 | border-radius: 0.25em; 8 | -moz-border-radius: 0.25em; 9 | -webkit-border-radius: 0.25em; 10 | font-family: Arial,Helvetica,Sans-serif; 11 | font-size: 90%; 12 | } 13 | .datepick-rtl { 14 | direction: rtl; 15 | } 16 | .datepick-popup { 17 | z-index: 1000; 18 | } 19 | .datepick-disable { 20 | position: absolute; 21 | z-index: 100; 22 | background-color: white; 23 | opacity: 0.5; 24 | filter: alpha(opacity=50); 25 | } 26 | .datepick a { 27 | color: #fff; 28 | text-decoration: none; 29 | } 30 | .datepick a.datepick-disabled { 31 | color: #888; 32 | cursor: auto; 33 | } 34 | .datepick button { 35 | margin: 0.25em; 36 | padding: 0.125em 0em; 37 | background-color: #fcc; 38 | border: none; 39 | border-radius: 0.25em; 40 | -moz-border-radius: 0.25em; 41 | -webkit-border-radius: 0.25em; 42 | font-weight: bold; 43 | } 44 | .datepick-nav, .datepick-ctrl { 45 | float: left; 46 | width: 100%; 47 | background-color: #000; 48 | color: #fff; 49 | font-size: 90%; 50 | font-weight: bold; 51 | } 52 | .datepick-ctrl { 53 | background-color: #600; 54 | } 55 | .datepick-cmd { 56 | width: 30%; 57 | } 58 | .datepick-cmd:hover { 59 | background-color: #777; 60 | } 61 | .datepick-ctrl .datepick-cmd:hover { 62 | background-color: #f08080; 63 | } 64 | .datepick-cmd-prevJump, .datepick-cmd-nextJump { 65 | width: 8%; 66 | } 67 | a.datepick-cmd { 68 | height: 1.5em; 69 | } 70 | button.datepick-cmd { 71 | text-align: center; 72 | } 73 | .datepick-cmd-prev, .datepick-cmd-prevJump, .datepick-cmd-clear { 74 | float: left; 75 | padding-left: 2%; 76 | } 77 | .datepick-cmd-current, .datepick-cmd-today { 78 | float: left; 79 | width: 35%; 80 | text-align: center; 81 | } 82 | .datepick-cmd-next, .datepick-cmd-nextJump, .datepick-cmd-close { 83 | float: right; 84 | padding-right: 2%; 85 | text-align: right; 86 | } 87 | .datepick-rtl .datepick-cmd-prev, .datepick-rtl .datepick-cmd-prevJump, 88 | .datepick-rtl .datepick-cmd-clear { 89 | float: right; 90 | padding-left: 0%; 91 | padding-right: 2%; 92 | text-align: right; 93 | } 94 | .datepick-rtl .datepick-cmd-current, .datepick-rtl .datepick-cmd-today { 95 | float: right; 96 | } 97 | .datepick-rtl .datepick-cmd-next, .datepick-rtl .datepick-cmd-nextJump, 98 | .datepick-rtl .datepick-cmd-close { 99 | float: left; 100 | padding-left: 2%; 101 | padding-right: 0%; 102 | text-align: left; 103 | } 104 | .datepick-month-nav { 105 | float: left; 106 | background-color: #777; 107 | text-align: center; 108 | } 109 | .datepick-month-nav div { 110 | float: left; 111 | width: 12.5%; 112 | margin: 1%; 113 | padding: 1%; 114 | } 115 | .datepick-month-nav span { 116 | color: #888; 117 | } 118 | .datepick-month-row { 119 | clear: left; 120 | } 121 | .datepick-month { 122 | float: left; 123 | width: 15em; 124 | border: 1px solid #444; 125 | text-align: center; 126 | } 127 | .datepick-month-header, .datepick-month-header select, .datepick-month-header input { 128 | height: 1.5em; 129 | background-color: #444; 130 | color: #fff; 131 | font-weight: bold; 132 | } 133 | .datepick-month-header select, .datepick-month-header input { 134 | height: 1.4em; 135 | border: none; 136 | } 137 | .datepick-month-header input { 138 | position: absolute; 139 | display: none; 140 | } 141 | .datepick-month table { 142 | width: 100%; 143 | border-collapse: collapse; 144 | } 145 | .datepick-month thead { 146 | border-bottom: 1px solid #aaa; 147 | } 148 | .datepick-month th, .datepick-month td { 149 | margin: 0em; 150 | padding: 0em; 151 | font-weight: normal; 152 | text-align: center; 153 | } 154 | .datepick-month th { 155 | border: 1px solid #777; 156 | } 157 | .datepick-month th, .datepick-month th a { 158 | background-color: #777; 159 | color: #fff; 160 | } 161 | .datepick-month td { 162 | background-color: #eee; 163 | border: 1px solid #aaa; 164 | } 165 | .datepick-month td.datepick-week { 166 | border: 1px solid #777; 167 | } 168 | .datepick-month td.datepick-week * { 169 | background-color: #777; 170 | color: #fff; 171 | border: none; 172 | } 173 | .datepick-month a { 174 | display: block; 175 | width: 100%; 176 | padding: 0.125em 0em; 177 | background-color: #eee; 178 | color: #000; 179 | text-decoration: none; 180 | } 181 | .datepick-month span { 182 | display: block; 183 | width: 100%; 184 | padding: 0.125em 0em; 185 | } 186 | .datepick-month td span { 187 | color: #888; 188 | } 189 | .datepick-month td .datepick-other-month { 190 | background-color: #fff; 191 | } 192 | .datepick-month td .datepick-weekend { 193 | background-color: #ddd; 194 | } 195 | .datepick-month td .datepick-today { 196 | background-color: #f0c0c0; 197 | } 198 | .datepick-month td .datepick-highlight { 199 | background-color: #f08080; 200 | } 201 | .datepick-month td .datepick-selected { 202 | background-color: #777; 203 | color: #fff; 204 | } 205 | .datepick-month th.datepick-week { 206 | background-color: #777; 207 | color: #fff; 208 | } 209 | .datepick-status { 210 | clear: both; 211 | background-color: #ddd; 212 | text-align: center; 213 | } 214 | .datepick-clear-fix { 215 | clear: both; 216 | } 217 | .datepick-cover { 218 | display: none; 219 | display/**/: block; 220 | position: absolute; 221 | z-index: -1; 222 | filter: mask(); 223 | top: -1px; 224 | left: -1px; 225 | width: 100px; 226 | height: 100px; 227 | } 228 | -------------------------------------------------------------------------------- /spec/watch_tower/config_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe WatchTower::Config do 4 | before(:each) do 5 | @config = {watch_tower: {enabled: true}} 6 | @config_path = '/valid/path' 7 | @invalid_config_path = '/invalid/path' 8 | @yaml = mock 9 | @yaml.stubs(:to_ruby).returns(@config) 10 | Psych.stubs(:parse_file).with(@config_path).returns(@yaml) 11 | WatchTower::Config.stubs(:config_file).returns(@config_path) 12 | 13 | ::File.stubs(:exists?).with(@config_path).returns(true) 14 | ::File.stubs(:readable?).with(@config_path).returns(true) 15 | 16 | ::File.stubs(:exists?).with(@invalid_config_path).returns(false) 17 | ::File.stubs(:readable?).with(@invalid_config_path).returns(false) 18 | end 19 | 20 | describe "@@config" do 21 | it "should have and class_variable @@config" do 22 | -> { subject.send(:class_variable_get, :@@config) }.should_not raise_error NameError 23 | end 24 | end 25 | 26 | describe "#check_config_file" do 27 | before(:each) do 28 | WatchTower::Config.stubs(:initialize_config_file) 29 | end 30 | 31 | it { should respond_to :check_config_file } 32 | 33 | it "should call File.exists?" do 34 | ::File.expects(:exists?).with(@config_path).returns(true).once 35 | 36 | subject.send(:check_config_file) 37 | end 38 | 39 | it "should call File.readable?" do 40 | ::File.expects(:readable?).with(@config_path).returns(true).once 41 | 42 | subject.send(:check_config_file) 43 | end 44 | end 45 | 46 | describe "#initialize_config_file" do 47 | it { should respond_to :initialize_config_file } 48 | 49 | it "should be able to create the config file from the template" do 50 | config_file = mock 51 | config_file.expects(:write).once 52 | File.expects(:open).with(WatchTower::Config.config_file, 'w').yields(config_file).once 53 | 54 | subject.send :initialize_config_file 55 | end 56 | end 57 | 58 | describe "#parse_config_file" do 59 | before(:each) do 60 | WatchTower::Config.send(:class_variable_set, :@@config, nil) 61 | WatchTower::Config.stubs(:initialize_config_file) 62 | end 63 | 64 | it { should respond_to :parse_config_file } 65 | 66 | it "should parse the config file and return an instance of HashWithIndifferentAccess" do 67 | subject.send(:parse_config_file).should be_instance_of HashWithIndifferentAccess 68 | end 69 | 70 | it "should handle the case where config is not a valid YAML file." do 71 | Psych.stubs(:parse_file).raises(Psych::SyntaxError) 72 | 73 | -> { subject.send :parse_config_file }.should raise_error ConfigNotValidError 74 | end 75 | 76 | it "should handle the case where Psych returns nil." do 77 | Psych.stubs(:parse_file).with(@config_path).returns(nil) 78 | 79 | -> { subject.send :parse_config_file }.should raise_error ConfigNotValidError 80 | end 81 | 82 | it "should handle the case where :watch_tower key does not exist" do 83 | config = {} 84 | yaml = mock 85 | yaml.stubs(:to_ruby).returns(config) 86 | Psych.stubs(:parse_file).with(@config_path).returns(yaml) 87 | 88 | -> { subject.send :parse_config_file }.should raise_error ConfigNotValidError 89 | end 90 | end 91 | 92 | describe "#[]" do 93 | before(:each) do 94 | WatchTower::Config.send(:class_variable_set, :@@config, nil) 95 | WatchTower::Config.stubs(:initialize_config_file) 96 | end 97 | 98 | it "should call check_config_file" do 99 | WatchTower::Config.expects(:check_config_file).once 100 | 101 | subject[:enabled] 102 | end 103 | 104 | it "should call config_file" do 105 | WatchTower::Config.expects(:config_file).returns(@config_path).once 106 | 107 | subject[:enabled] 108 | end 109 | 110 | it "should call YAML.parse_file" do 111 | Psych.expects(:parse_file).with(@config_path).returns(@yaml).once 112 | 113 | subject[:enabled] 114 | end 115 | 116 | it "should call to_ruby on the YAML result" do 117 | @yaml.expects(:to_ruby).returns(@config).once 118 | 119 | subject[:enabled] 120 | end 121 | 122 | it "should create a new HashWithIndifferentAccess" do 123 | HashWithIndifferentAccess.expects(:new).returns(@config).once 124 | 125 | subject[:enabled] 126 | end 127 | 128 | it "should raise ConfigNotDefinedError if config not found" do 129 | WatchTower::Config.stubs(:config_file).returns(nil) 130 | ::File.stubs(:exists?).with(@invalid_config_path).returns(false) 131 | 132 | -> { subject[:enabled] }.should raise_error ConfigNotDefinedError 133 | end 134 | 135 | it "should raise ConfigNotReadableError if config not found" do 136 | WatchTower::Config.stubs(:config_file).returns(@invalid_config_path) 137 | ::File.stubs(:readable?).with(@invalid_config_path).returns(false) 138 | 139 | -> { subject[:enabled] }.should raise_error ConfigNotReadableError 140 | end 141 | 142 | it "should handle the case where config is not a valid YAML file." do 143 | Psych.stubs(:parse_file).with(@config_path).returns(nil) 144 | 145 | -> { subject[:enabled] }.should raise_error ConfigNotValidError 146 | end 147 | 148 | it "should handle the case where :watch_tower key does not exist" do 149 | config = {} 150 | yaml = mock 151 | yaml.stubs(:to_ruby).returns(config) 152 | Psych.stubs(:parse_file).with(@config_path).returns(yaml) 153 | 154 | -> { subject[:enabled] }.should raise_error ConfigNotValidError 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /lib/watch_tower/eye.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'digest/sha1' 4 | 5 | module WatchTower 6 | module Eye 7 | extend self 8 | 9 | # Ignore paths 10 | IGNORED_PATHS = %r(/(.git|.svn)/) 11 | 12 | # Start the watch loop 13 | # 14 | # @param [Hash] options 15 | # @raise [EyeError] 16 | def start(options = {}) 17 | LOG.debug("#{__FILE__}:#{__LINE__}: The Eye loop has just started") 18 | loop do 19 | # Try getting the mtime of the document opened by each editor in the 20 | # editors list. 21 | Editor.editors.each do |editor| 22 | # Create an instance of the editor 23 | # TODO: Should be used as a class instead 24 | editor = editor.new 25 | # Check if the editor is running 26 | if editor.is_running? 27 | LOG.debug("#{__FILE__}:#{__LINE__}: #{editor.to_s} is running") 28 | # Get the currently being edited file from the editor 29 | files_paths = editor.current_paths 30 | # Do not continue if no files were returned 31 | next unless files_paths && files_paths.respond_to?(:each) 32 | # Iterate over the files to fill the database 33 | files_paths.each do |file_path| 34 | begin 35 | next unless file_path 36 | LOG.debug("#{__FILE__}:#{__LINE__}: Ignoring #{file_path}") and next if file_path =~ IGNORED_PATHS 37 | LOG.debug("#{__FILE__}:#{__LINE__}: #{file_path} does not exist.") and next unless File.exists?(file_path) 38 | LOG.debug("#{__FILE__}:#{__LINE__}: #{file_path} is not a file") and next unless File.file?(file_path) 39 | # Get the file_hash of the file 40 | file_hash = Digest::SHA1.file(file_path).hexdigest 41 | LOG.debug("#{__FILE__}:#{__LINE__ - 1}: The hash of #{file_path} is #{file_hash}.") 42 | # Create a project from the file_path 43 | project = Project.new_from_path(file_path) 44 | rescue PathNotUnderCodePath 45 | LOG.debug("#{__FILE__}:#{__LINE__ - 2}: The file '#{file_path}' is not located under '#{Config[:code_path]}', it has been ignored") 46 | next 47 | rescue FileNotFound 48 | LOG.debug "#{__FILE__}:#{__LINE__ - 5}: The file '#{file_path}' does not exist, it has been ignored" 49 | next 50 | end 51 | 52 | begin 53 | # Create (or fetch) a project 54 | project_model = Server::Project.find_or_create_by_name_and_path(project.name, project.path) 55 | LOG.debug("#{__FILE__}:#{__LINE__ - 1}: Created (or fetched) the project with the id #{project_model.id}") 56 | 57 | # Create (or fetch) a file 58 | file_model = project_model.files.find_or_create_by_path(file_path) 59 | LOG.debug("#{__FILE__}:#{__LINE__ - 1}: Created (or fetched) the file with the id #{file_model.id}") 60 | begin 61 | # Create a time entry 62 | time_entry_model = file_model.time_entries.create! mtime: ::File.stat(file_path).mtime, 63 | file_hash: file_hash, 64 | editor_name: editor.name, 65 | editor_version: editor.version 66 | LOG.debug("#{__FILE__}:#{__LINE__ - 4}: Created a time_entry with the id #{time_entry_model.id}") 67 | rescue ActiveRecord::RecordInvalid => e 68 | if e.message =~ /Validation failed: Mtime has already been taken/ 69 | # This should happen if the mtime is already present 70 | LOG.debug("#{__FILE__}:#{__LINE__ - 8}: The time_entry already exists, nothing were created, error message is #{e.message}") 71 | elsif e.message =~ /Validation failed: / 72 | # This should not happen 73 | LOG.fatal("#{__FILE__}:#{__LINE__ - 11}: The time_entry did not pass validations, #{e.message}.") 74 | $close_eye = true 75 | else 76 | # Some other error happened 77 | LOG.fatal("#{__FILE__}:#{__LINE__ - 15}: An unknown error has been raised while creating the time_entry, #{e.message}") 78 | $close_eye = true 79 | end 80 | end 81 | rescue ActiveRecord::RecordInvalid => e 82 | # This should not happen 83 | LOG.fatal("#{__FILE__}:#{__LINE__}: #{e}") 84 | $close_eye = true 85 | end 86 | end 87 | else 88 | LOG.debug("#{__FILE__}:#{__LINE__}: #{editor.to_s} is not running") 89 | end 90 | end unless $pause_eye 91 | 92 | # If $stop global is set, please stop, otherwise sleep for 10 seconds. 93 | if $close_eye 94 | LOG.debug("#{__FILE__}:#{__LINE__}: Closing eye has been requested, end the loop") 95 | break 96 | else 97 | # TODO: This should be in the config file, let the user decide how often the loop should start 98 | sleep 10 99 | end 100 | end 101 | end 102 | 103 | # Start the Eye, a method invoked from the Watch Tower command line interface 104 | # 105 | # @param [Hash] options 106 | def start!(options = {}) 107 | # Signal handling 108 | Signal.trap("INT") { $close_eye = true } 109 | Signal.trap("TERM") { $close_eye = true } 110 | 111 | start(options) 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/watch_tower/eye_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Eye do 4 | before(:each) do 5 | # We don't want to locked in a loop 6 | $close_eye = true 7 | 8 | # Set the project's name and path 9 | @file_path = '/home/user/Code/OpenSource/watch_tower/lib/watch_tower/server/models/time_entries.rb' 10 | @project_path = '/home/user/Code/OpenSource/watch_tower' 11 | @project_name = 'watch_tower' 12 | ::File.stubs(:exists?).with(@file_path).returns(true) 13 | ::File.stubs(:file?).with(@file_path).returns(true) 14 | 15 | # Mock the editor 16 | @editor = mock 17 | @editor.stubs(:new).returns(@editor) 18 | @editor.stubs(:is_running?).returns(true) 19 | @editor.stubs(:current_paths).returns([@file_path]) 20 | @editor.stubs(:name).returns("Textmate") 21 | @editor.stubs(:version).returns("1.5.10") 22 | Editor.stubs(:editors).returns([@editor]) 23 | 24 | # Mock the project 25 | @project = mock 26 | @project.stubs(:path).returns(@project_path) 27 | @project.stubs(:name).returns(@project_name) 28 | Project.stubs(:new_from_path).returns(@project) 29 | 30 | # Stub the mtime 31 | @mtime = Time.now 32 | @file_stat = mock 33 | @file_stat.stubs(:mtime).returns(@mtime) 34 | ::File.stubs(:stat).returns(@file_stat) 35 | 36 | # Stub the file_hash 37 | @file_hash = mock 38 | @file_hash.stubs(:hexdigest).returns('b1843f2aeea08c34a4a0b978b117256cd4615a6c') 39 | Digest::SHA1.stubs(:file).with(@file_path).returns(@file_hash) 40 | end 41 | 42 | describe "#start workflow" do 43 | before(:each) do 44 | # Mock the project's model 45 | # @time_entry = stub_everything('time_entry') 46 | # @file_model = stub_everything('file', time_entries: [@time_entries]) 47 | # @project_model = stub_everything('project', files: [@file_model]) 48 | # Server::Project.stubs(:find_or_create_by_name_and_path).returns(@project_model) 49 | end 50 | 51 | it { should respond_to :start } 52 | 53 | it "should tries to get the editors list" do 54 | Editor.expects(:editors).returns([]).once 55 | 56 | subject.start 57 | end 58 | 59 | it "should call is_running? on the editor" do 60 | @editor.expects(:is_running?).returns(false).once 61 | Editor.stubs(:editors).returns([@editor]) 62 | 63 | subject.start 64 | end 65 | 66 | it "should call current_paths on the editor to determine the file path" do 67 | @editor.expects(:current_paths).returns([@file_path]).once 68 | 69 | subject.start 70 | end 71 | 72 | it "should not explode if file_paths is nil" do 73 | @editor.expects(:current_paths).returns(nil).once 74 | 75 | -> { subject.start }.should_not raise_error 76 | end 77 | 78 | it "should call File.exists?" do 79 | ::File.expects(:exists?).with(@file_path).returns(true).once 80 | 81 | subject.start 82 | end 83 | 84 | it "should call File.file?" do 85 | ::File.expects(:file?).with(@file_path).returns(true).once 86 | 87 | subject.start 88 | end 89 | 90 | it "shouldn't add the file if it matches the ignore list" do 91 | ignored_path = '/path/to/project/.git/COMMIT_MESSAGE' 92 | ::File.stubs(:exists?).with(ignored_path).returns(true) 93 | @editor.stubs(:current_paths).returns([ignored_path]) 94 | Digest::SHA1.expects(:file).with(ignored_path).never 95 | 96 | subject.start 97 | end 98 | 99 | it "should get the file's hash from Digest::SHA1" do 100 | Digest::SHA1.expects(:file).with(@file_path).returns(@file_hash).once 101 | 102 | subject.start 103 | end 104 | 105 | it "should create a new project from the file path" do 106 | Project.expects(:new_from_path).returns(@project).once 107 | 108 | subject.start 109 | end 110 | 111 | it "should ask the project for the project's name" do 112 | @project.expects(:name).returns(@project_name).once 113 | 114 | subject.start 115 | end 116 | 117 | it "should ask the project for the project's path" do 118 | @project.expects(:path).returns(@project_path).once 119 | 120 | subject.start 121 | end 122 | 123 | it "should create a new (or fetch existing) Project in the database" 124 | it "should create a new (or fetch existing) File in the database" 125 | it "should create a new TimeEntry in the database" 126 | 127 | it "should ask the file for mtime" do 128 | @file_stat.expects(:mtime).returns(Time.now).once 129 | ::File.expects(:stat).with(@file_path).returns(@file_stat) 130 | 131 | subject.start 132 | end 133 | 134 | it "should ask the editor for the name" do 135 | @editor.expects(:name).returns("Textmate").once 136 | 137 | subject.start 138 | end 139 | 140 | it "should ask the editor for the version" do 141 | @editor.expects(:version).returns("1.5.10").once 142 | 143 | subject.start 144 | end 145 | end 146 | 147 | describe "#start database validations" do 148 | it "should create a new (or fetch existing) Project in the database" do 149 | subject.start 150 | 151 | Server::Project.last.name.should == @project_name 152 | Server::Project.last.path.should == @project_path 153 | end 154 | 155 | it "should create a new (or fetch existing) File in the database" do 156 | subject.start 157 | 158 | Server::File.last.path.should == @file_path 159 | end 160 | 161 | it "should create a new TimeEntry in the database" # do 162 | # subject.start 163 | # 164 | # Server::TimeEntry.last.mtime.should == @mtime 165 | # end 166 | 167 | it "should not raise an error if the time entry for the file already exists" do 168 | subject.start 169 | 170 | -> { subject.start }.should_not raise_error 171 | end 172 | end 173 | 174 | describe "#pause" do 175 | before(:each) do 176 | Editor.expects(:editors).never 177 | $pause_eye = true 178 | end 179 | 180 | after(:each) do 181 | $pause_eye = false 182 | end 183 | 184 | it "should not run if it is paused" do 185 | 186 | subject.start 187 | end 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /lib/watch_tower/project/path_based.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | class Project 5 | # The module contains Path specific project methods, methods like name, path 6 | # The module can be included into another module/class or used on it's own, 7 | # it does extend itself so any methods defined here is available to both class 8 | # and instance level 9 | module PathBased 10 | include AnyBased 11 | extend self 12 | 13 | # Cache for working_directory by path 14 | # The key is the path to a file, the value is the working directory of 15 | # this path 16 | @@working_cache = Hash.new 17 | 18 | # Cache for project_name by path 19 | # The key is the path to a file, the value is the project's name 20 | @@project_name_cache = Hash.new 21 | 22 | # Cache for project path parts 23 | # The key is the path to a file, the value is the project's parts 24 | @@project_path_part_cache = Hash.new 25 | 26 | # Return the working directory (the project's path if you will) from a path 27 | # to any file inside the project 28 | # 29 | # @param [String] path The path to look the project path from 30 | # @param [Hash] options A hash of options 31 | # @return [String] the project's folder 32 | def working_directory(path, options = {}) 33 | return @@working_cache[path] if @@working_cache.key?(path) 34 | 35 | 36 | @@working_cache[path] = project_path_from_nested_path(code_path(options), 37 | path, nested_project_layers(options)) 38 | @@working_cache[path] 39 | end 40 | 41 | # Return the project's name from a path to any file inside the project 42 | # 43 | # @param path The path to look the project path from 44 | # @param [Hash] options A hash of options 45 | # @return [String] the project's name 46 | def project_name(path, options = {}) 47 | return @@project_name_cache[path] if @@project_name_cache.key?(path) 48 | 49 | @@project_name_cache[path] = project_name_from_nested_path(code_path(options), 50 | path, nested_project_layers(options)) 51 | @@project_name_cache[path] 52 | end 53 | 54 | protected 55 | 56 | # Get the code path from the options, if not found use the one from the 57 | # configurations 58 | # 59 | # @param [Hash] options 60 | # @return [String] The Code path 61 | def code_path(options = {}) 62 | options[:code_path] || Config[:code_path] 63 | end 64 | 65 | # Get the nested_project_layers from the options, if not found use the 66 | # one from the configurations 67 | # 68 | # @param [Hash] options 69 | # @return [String] The nested_project_layers 70 | def nested_project_layers(options = {}) 71 | options[:nested_project_layers] || Config[:nested_project_layers] 72 | end 73 | 74 | # Taken from timetap 75 | # https://github.com/elia/timetap/blob/master/lib/time_tap/project.rb#L40 76 | # 77 | # Find out the path parts of the project that's currently being worked on, 78 | # under the code path, it uses the param nested_project_layers to determine 79 | # the project name from the entire expanded path to any file under the 80 | # project 81 | # 82 | # nested project layers works "how many folders inside your code folder 83 | # do you keep projects. 84 | # 85 | # For example, if your directory structure looks like: 86 | # ~/Code/ 87 | # Clients/ 88 | # AcmeCorp/ 89 | # website/ 90 | # intranet 91 | # BetaCorp/ 92 | # skunkworks/ 93 | # OpenSource/ 94 | # project_one/ 95 | # timetap/ 96 | # 97 | # A nested_project_layers setting of 2 would mean we track "AcmeCorp", "BetaCorp", and everything 98 | # under OpenSource, as their own projects 99 | # 100 | # @param code The path you store all the projects under 101 | # @param path The path to look the project name from 102 | # @param nested_project_layers How many folders, defaults to 2 103 | # @return [Array] The project path's parts 104 | # @raise [WatchTower::PathNotUnderCodePath] if the path is not nested under code 105 | def project_path_part(code, path, nested_project_layers = 2) 106 | return @@project_path_part_cache[path] if @@project_path_part_cache.key?(path) 107 | 108 | # Expand pathes 109 | code = expand_path code 110 | path = expand_path path 111 | 112 | regex_suffix = "([^/]+)" 113 | regex_suffix = [regex_suffix] * nested_project_layers 114 | regex_suffix = regex_suffix.join("/") 115 | 116 | path.scan(%r{(#{code})/#{regex_suffix}}).flatten.collect(&:chomp). 117 | tap { |r| raise PathNotUnderCodePath unless r.any? }. 118 | tap { |ppp| @@project_path_part_cache[path] = ppp } 119 | end 120 | 121 | # Find out the project's name 122 | # See #project_path_part 123 | # 124 | # @param code The path you store all the projects under 125 | # @param path The path to look the project name from 126 | # @param nested_project_layers How many folders, defaults to 2 127 | # @return [String] The project's name 128 | # @raise [WatchTower::PathNotUnderCodePath] if the path is not nested under code 129 | def project_name_from_nested_path(code, path, nested_project_layers = 2) 130 | project_path_part(code, path, nested_project_layers)[nested_project_layers] 131 | end 132 | 133 | # Find out the project's path 134 | # See #project_path_part 135 | # 136 | # @param code The path you store all the projects under 137 | # @param path The path to look the project name from 138 | # @param nested_project_layers How many folders, defaults to 2 139 | # @return [String] The project's path 140 | # @raise [WatchTower::PathNotUnderCodePath] if the path is not nested under code 141 | def project_path_from_nested_path(code, path, nested_project_layers = 2) 142 | project_path_part(code, path, nested_project_layers)[0..nested_project_layers].join('/') 143 | end 144 | end 145 | end 146 | end -------------------------------------------------------------------------------- /lib/watch_tower/cli/start.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module CLI 5 | module Start 6 | 7 | def self.included(base) 8 | base.send :include, InstanceMethods 9 | end 10 | 11 | module InstanceMethods 12 | def self.included(base) 13 | base.class_eval <<-END, __FILE__, __LINE__ + 1 14 | # Mappings (aliases) 15 | map "-s" => :start 16 | 17 | # Start watchtower 18 | desc "start", "Start the Watch Tower" 19 | method_option :bootloader, 20 | type: :boolean, 21 | required: false, 22 | aliases: "-b", 23 | default: false, 24 | desc: "Is it invoked from the bootloader?" 25 | method_option :foreground, 26 | type: :boolean, 27 | required: false, 28 | aliases: "-f", 29 | default: false, 30 | desc: "Do not run in the background." 31 | method_option :host, 32 | type: :string, 33 | required: false, 34 | aliases: "-h", 35 | default: 'localhost', 36 | desc: "Set the server's host" 37 | method_option :port, 38 | type: :numeric, 39 | required: false, 40 | aliases: "-p", 41 | default: 9282, 42 | desc: "Set the server's port." 43 | method_option :debug, 44 | type: :boolean, 45 | required: false, 46 | aliases: "-d", 47 | default: false, 48 | desc: "Run in debug mode." 49 | def start 50 | # Set the logger to debug mode if necessary 51 | LOG.level = Logger::DEBUG if options[:debug] 52 | 53 | begin 54 | if Config[:enabled] && 55 | (!options[:bootloader] || (options[:bootloader] && Config[:launch_on_boot])) 56 | LOG.info "#{__FILE__}:#{__LINE__}: Starting WatchTower." 57 | start! 58 | LOG.info "#{__FILE__}:#{__LINE__}: WatchTower has finished." 59 | else 60 | abort "You need to edit the config file located at \#{Config.config_file}." 61 | end 62 | rescue ConfigNotReadableError => e 63 | LOG.fatal "#{__FILE__}:#{__LINE__}: The config file is not readable." 64 | STDERR.puts "The config file is not readable, please make sure \#{Config.config_file} exists and you have the necessary permissions to read it." 65 | exit(1) 66 | rescue ConfigNotValidError => e 67 | LOG.fatal "#{__FILE__}:#{__LINE__}: The config file is not valid: \#{e.message}." 68 | STDERR.puts "The config file \#{Config.config_file} is not valid: \#{e.message}." 69 | exit(1) 70 | end 71 | end 72 | 73 | protected 74 | def start! 75 | if options[:foreground] 76 | LOG.debug "#{__FILE__}:#{__LINE__}: Running WatchTower in foreground." 77 | 78 | # Start WatchTower 79 | start_watch_tower 80 | else 81 | LOG.debug "#{__FILE__}:#{__LINE__}: Running WatchTower in the background." 82 | pid = fork do 83 | begin 84 | # Try to replace ruby with WatchTower in the command line (for ps) 85 | $0 = 'watchtower' unless $0 == 'watchtower' 86 | 87 | # Tell ruby that we are a daemon 88 | Process.daemon 89 | 90 | # Start WatchTower 91 | start_watch_tower 92 | rescue => e 93 | LOG.fatal "#{__FILE__}:#{__LINE__ - 2}: The process raised an exception \#{e.message}" 94 | LOG.fatal "#{__FILE__}:#{__LINE__ - 3}: ==== Backtrace ====" 95 | e.backtrace.each do |trace| 96 | LOG.fatal "#{__FILE__}:#{__LINE__ - 5}: \#{trace}" 97 | end 98 | end 99 | end 100 | end 101 | end 102 | 103 | # Start watch tower 104 | # This method just start the watch tower it doesn't know 105 | # or care if we are in a forked process or not, all it cares about 106 | # is starting the database server before starting the eye 107 | # 108 | # see #start_server 109 | # see #start_eye 110 | def start_watch_tower 111 | # Start the server 112 | start_server 113 | 114 | # Wait until the database starts 115 | until Server::Database.is_connected? do 116 | sleep(1) 117 | end 118 | 119 | # Wait until the database has migrated 120 | until Server::Database.is_migrated? do 121 | sleep(1) 122 | end 123 | 124 | # Start the eye now. 125 | start_eye 126 | end 127 | 128 | # Start the eye 129 | # This method just start the watch tower it doesn't know 130 | # or care if we are in a forked process or not 131 | def start_eye 132 | LOG.debug "#{__FILE__}:#{__LINE__}: Starting the eye." 133 | Eye.start!(watch_tower_options) 134 | end 135 | 136 | # Start the web server 137 | # This method just start the watch tower it doesn't know 138 | # or care if we are in a forked process or not 139 | def start_server 140 | LOG.debug "#{__FILE__}:#{__LINE__}: Starting the web server." 141 | Server.start!(watch_tower_options) 142 | end 143 | 144 | # Return Watch Tower options 145 | # same as options but modified to correspond to WatchTower options 146 | # instead of CLI options 147 | # 148 | # @return [Hash] options 149 | def watch_tower_options 150 | return @watch_tower_options if @watch_tower_options 151 | 152 | @watch_tower_options = options.dup 153 | @watch_tower_options.delete(:bootloader) 154 | 155 | # Log the options as a Debug 156 | LOG.debug "#{__FILE__}:#{__LINE__}: Options are \#{@watch_tower_options.inspect}." 157 | 158 | # Return the options 159 | @watch_tower_options 160 | end 161 | END 162 | end 163 | end 164 | end 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Watch Tower [![Build Status](http://travis-ci.org/TechnoGate/watch_tower.png)](http://travis-ci.org/TechnoGate/watch_tower) [![Still Maintained](http://stillmaintained.com/TechnoGate/watch_tower.png)](http://stillmaintained.com/TechnoGate/watch_tower) 2 | 3 | [![Click here to lend your support to: Open Source Projects and make a donation at www.pledgie.com!](http://pledgie.com/campaigns/16123.png?skin_name=chrome)](http://www.pledgie.com/campaigns/16123) 4 | 5 | WatchTower helps you track how much time you spend on all of your projects, at 6 | the project, directory, and file level. 7 | 8 | # Introduction 9 | 10 | Did you ever want to keep track of how much time you _really_ spend on 11 | all of your projects? Sure, you can try to remember to keep running 12 | estimates of your time in the hope that you can aggregate those 13 | estimates later into some meaningful data. But sometimes you forget, or 14 | an error creeps into your estimate. And those errors add up. Quickly. 15 | 16 | You can try some tracking software that depends on you to start and stop 17 | timers. But what happens if you forget to start or stop one of those 18 | timers? 19 | 20 | What you need is a passive system that will take care of all of this 21 | for you, so you can focus on the actual work, which is where WatchTower 22 | comes into play. 23 | 24 | WatchTower runs in the background and keeps track of the time spent 25 | editing each file with one of the supported editors (listed below). 26 | Since WatchTower keeps track of the time spent on each file, and it 27 | knows which project each file belongs to, you can view details and 28 | statistics on each project, right down to the file level. 29 | 30 | # Features 31 | 32 | - Tracks the supported editors (listed below) and records the time spent on 33 | all files as specified via the customized configuration file (Git and 34 | __code_path__ are supported). 35 | 36 | - A WatchTower Home Page where you can see how much time you've spent on all 37 | watched projects, as well as a total summary. The default display includes 38 | all projects worked on during the current month, but the page includes a 39 | date picker for easy selection. You can select a project to view the 40 | project's Detail Page. 41 | 42 | ![Example: WatchTower Home Page](http://f.cl.ly/items/1C0W1W0V2L3s3k2o313f/home_page.png) 43 | 44 | - A Project Detail Page that displays a detailed report of the time spent on 45 | the project, each directory within the project, and each file. The default 46 | display includes all files worked on during the current month, but the page 47 | includes a date picker for easy selection. 48 | 49 | ![Example: Project Detail Page](http://f.cl.ly/items/3T263A350w261b0b2U1x/project_page.png) 50 | 51 | # Supported Editors 52 | 53 | - TextMate 54 | - Xcode 55 | - ViM (gVim and MacVim are also supported) 56 | 57 | # Supported Operating Systems 58 | 59 | - Mac OS X 60 | - Linux 61 | 62 | # Getting Started 63 | 64 | 1. Install the WatchTower gem: 65 | 66 | ```bash 67 | $ gem install watch_tower 68 | ``` 69 | 2. Followed by: 70 | 71 | ```bash 72 | $ watchtower install 73 | $ watchtower load_bootloader 74 | ``` 75 | 76 | 3. __Review the self-explanatory configuration file__ located at 77 | __~/.watch_tower/config.yml__ and make any necessary changes. 78 | 79 | # Update 80 | 81 | Run 82 | 83 | ```bash 84 | $ watchtower install_bootloader 85 | $ watchtower reload_bootloader 86 | ``` 87 | 88 | to update the path to the WatchTower binary in the boot loader. 89 | 90 | # Usage 91 | 92 | ## Opening the web interface 93 | 94 | The installation process creates a launcher on login that starts 95 | __WatchTower__. You can view your WatchTower Home Page via the web interface 96 | by going to http://localhost:9282, or from the command line: 97 | 98 | ```bash 99 | $ watchtower open 100 | ``` 101 | 102 | ## Rehash 103 | 104 | If for some reason, you think that the elapsed 105 | time of any of your projects is wrong ( c.f 106 | [#9](https://github.com/TechnoGate/watch_tower/issues/9) ) then you can 107 | recalculate the elapsed time of all projects and all their files by 108 | visiting [http://localhost:9282/rehash](http://localhost:9282/rehash), 109 | but please keep in mind that this can take a long time to process. 110 | 111 | # Commands 112 | 113 | For more information on available commands, you can take a look at the 114 | [WatchTower Command Line wiki 115 | page](https://github.com/TechnoGate/watch_tower/wiki/WatchTower-Command-Line). 116 | 117 | # Contributing 118 | 119 | Please feel free to fork and send pull requests, but please follow the 120 | following guidelines: 121 | 122 | - Prefix each commit message with the filename or the module followed by a 123 | colon and a space, for example 'README: fix a typo' or 'Server/Project: Fix 124 | a typo'. 125 | - Include tests. 126 | - __Do not change the version__, We will take care of that. 127 | 128 | You can also take a look at the [TODO 129 | list](https://github.com/TechnoGate/watch_tower/blob/master/TODO) for what's 130 | in mind for the project 131 | 132 | # Contact 133 | 134 | For bugs and feature request, please use __Github issues__, for other 135 | requests, you may use: 136 | 137 | - [Google Groups](http://groups.google.com/group/watch-tower) 138 | - [Github private message](https://github.com/inbox/new/eMxyzptlk) 139 | - Email: [contact@technogate.fr](mailto:contact@technogate.fr) 140 | 141 | Don't forget to follow me on [Github](https://github.com/eMxyzptlk) and 142 | [Twitter](https://twitter.com/eMxyzptlk) for news and updates. 143 | 144 | # Credits 145 | 146 | Please see the 147 | [CREDITS.md](https://github.com/TechnoGate/watch_tower/blob/master/CREDITS.md) file. 148 | 149 | # License 150 | 151 | ## This code is free to use under the terms of the MIT license. 152 | 153 | Copyright (c) 2011 TechnoGate <support@technogate.fr> 154 | 155 | Permission is hereby granted, free of charge, to any person obtaining 156 | a copy of this software and associated documentation files (the 157 | "Software"), to deal in the Software without restriction, including 158 | without limitation the rights to use, copy, modify, merge, publish, 159 | distribute, sublicense, and/or sell copies of the Software, and to 160 | permit persons to whom the Software is furnished to do so, subject to 161 | the following conditions: 162 | 163 | The above copyright notice and this permission notice shall be included 164 | in all copies or substantial portions of the Software. 165 | 166 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 167 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 168 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 169 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 170 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 171 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 172 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 173 | -------------------------------------------------------------------------------- /lib/watch_tower/server/presenters/application_presenter.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module Server 5 | module Presenters 6 | class ApplicationPresenter 7 | 8 | attr_reader :model, :template 9 | 10 | # Presents a model 11 | # 12 | # @param [Symbol] name 13 | def self.presents(name) 14 | define_method name do 15 | @model 16 | end 17 | end 18 | 19 | # Initialise the presenter 20 | # 21 | # @param [ActiveRecord::Base] Model 22 | # @param [Object] Template 23 | def initialize(model, template) 24 | @model = model 25 | @template = template 26 | end 27 | 28 | # Overwrite Kernel#method_missing to invoke either the model or the 29 | # template's method if present, if not Kernel#method_missing will be 30 | # called 31 | # 32 | # @param [Symbol] method: The method name 33 | # @param [Array] arguments 34 | # @param [Block] 35 | def method_missing(method, *args, &block) 36 | if model.respond_to?(method) 37 | model.send(method, *args, &block) 38 | elsif template.respond_to?(method) 39 | template.send(method, *args, &block) 40 | else 41 | super 42 | end 43 | end 44 | 45 | # Returns a human formatted time 46 | # 47 | # @param [Integer] elapsed_time 48 | # @return [String] The elapsed time formatted 49 | def elapsed(elapsed_time = nil) 50 | return "" if elapsed_time.nil? && !model.respond_to?(:elapsed_time) 51 | elapsed_time ||= model.elapsed_time 52 | humanize_time elapsed_time 53 | end 54 | 55 | # Returns an approximate elapsed time 56 | # 57 | # @param [Integer] elapsed_time 58 | # @return [String] The approximate elapsed time 59 | def approximate_elapsed(elapsed_time = nil) 60 | return "" if elapsed_time.nil? && !model.respond_to?(:elapsed_time) 61 | elapsed_time ||= model.elapsed_time 62 | 63 | if elapsed_time > 1.day 64 | elapsed_t = (elapsed_time / 1.day).to_i * 1.day 65 | elapsed_f = elapsed(elapsed_t) 66 | 67 | if elapsed_time % 1.day >= 20.hours 68 | elapsed(elapsed_t + 1.day) 69 | elsif elapsed_time % 1.day >= 5.hours 70 | "#{elapsed_f} and a half" 71 | else 72 | elapsed_f 73 | end 74 | elsif elapsed_time > 1.hour 75 | elapsed_t = (elapsed_time / 1.hour).to_i * 1.hour 76 | elapsed_f = elapsed(elapsed_t) 77 | 78 | if elapsed_time % 1.hour >= 50.minutes 79 | elapsed(elapsed_t + 1.hour) 80 | elsif elapsed_time % 1.hour >= 25.minutes 81 | "#{elapsed_f} and a half" 82 | else 83 | elapsed_f 84 | end 85 | elsif elapsed_time > 60.seconds 86 | elapsed_time = (elapsed_time / 60).to_i * 60 87 | elapsed(elapsed_time) 88 | else 89 | '1 minute' 90 | end 91 | end 92 | 93 | protected 94 | def pluralize(num, word) 95 | if num > 1 96 | "#{num} #{word.pluralize}" 97 | else 98 | "#{num} #{word}" 99 | end 100 | end 101 | 102 | # Humanize time 103 | # 104 | # @param [Integer] The number of seconds 105 | # @return [String] 106 | def humanize_time(time) 107 | case 108 | when time >= 1.day 109 | humanize_day(time) 110 | when time >= 1.hour 111 | humanize_hour(time) 112 | when time >= 1.minute 113 | humanize_minute(time) 114 | else 115 | pluralize time, "second" 116 | end 117 | end 118 | 119 | [:day, :hour, :minute].each do |t| 120 | class_eval <<-END, __FILE__, __LINE__ + 1 121 | protected 122 | def humanize_#{t}(time) 123 | seconds = 1.#{t} 124 | num = (time / seconds).to_i 125 | rest = time % seconds 126 | 127 | time_str = pluralize num, "#{t}" 128 | 129 | unless rest == 0 130 | "\#{time_str}#{t == :minute ? ' and' : ','} \#{humanize_time(rest)}" 131 | else 132 | time_str 133 | end 134 | end 135 | END 136 | end 137 | 138 | # Parse a file tree 139 | # 140 | # @param [FileTree] tree 141 | # @param [Boolean] root 142 | # @return [String] HTML of the file tree 143 | def parse_file_tree(tree, root = false) 144 | # Create the root element 145 | if root 146 | html = '
' 147 | html << '
' 148 | else 149 | folder_name = ::File.basename(tree.base_path) 150 | html = %(
) 151 | end 152 | # Open the wrapper 153 | html << '
' 154 | # Add the collapsed span 155 | html << '' 156 | # Add the name 157 | if root 158 | html << 'Project' 159 | else 160 | html << %(#{folder_name}) 161 | end 162 | # Add the elapsed time 163 | html << %(#{elapsed(tree.elapsed_time)}) 164 | # End with a clearfix element 165 | html << '
' 166 | # Close the wrapper 167 | html << '
' 168 | # Add the nested_tree if available 169 | if tree.nested_tree.any? 170 | tree.nested_tree.each_pair do |folder, nested_tree| 171 | html << parse_file_tree(nested_tree) 172 | end 173 | end 174 | # Add the files 175 | if tree.files.any? 176 | # Open the files 's ul 177 | html << '
    ' 178 | tree.files.each do |file| 179 | # Open the file's li 180 | html << '
  • ' 181 | # Add the path 182 | html << %(#{file[:path]}) 183 | # Add the elapsed time 184 | html << %(#{elapsed(file[:elapsed_time])}) 185 | # End with a clearfix element 186 | html << '
    ' 187 | # Close the file's li 188 | html << '
  • ' 189 | end 190 | # Close the files 's ul 191 | html << '
' 192 | end 193 | # Close the root div 194 | html << "
" 195 | # Clode the article if it is the root element 196 | html << "
" if root 197 | # Finally return the whole thing 198 | html 199 | end 200 | end 201 | end 202 | end 203 | end -------------------------------------------------------------------------------- /spec/watch_tower/server/presenters/project_presenter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Server 4 | module Presenters 5 | describe ProjectPresenter do 6 | 7 | describe "#elapsed" do 8 | before(:each) do 9 | @project = FactoryGirl.create :project 10 | end 11 | 12 | subject { ProjectPresenter.new(@project, nil) } 13 | 14 | it "should return a formatted elapsed time" do 15 | time = 1.day + 2.hours + 3.minutes + 34.seconds 16 | model = mock 17 | model.stubs(:elapsed_time).returns(time) 18 | subject.stubs(:model).returns(model) 19 | subject.elapsed.should == '1 day, 2 hours, 3 minutes and 34 seconds' 20 | end 21 | end 22 | 23 | describe "#approximate_elapsed" do 24 | before(:each) do 25 | @project = FactoryGirl.create :project 26 | end 27 | 28 | subject { ProjectPresenter.new(@project, nil) } 29 | 30 | it { should respond_to :approximate_elapsed } 31 | 32 | it "should return 1 minutes for elapsed_times less than a minute" do 33 | time = 0.minutes + 3.seconds 34 | subject.approximate_elapsed(time).should == '1 minute' 35 | end 36 | 37 | it "should return 10 minutes for elapsed_times equal to 10m30s" do 38 | time = 10.minutes + 30.seconds 39 | subject.approximate_elapsed(time).should == '10 minutes' 40 | end 41 | 42 | it "should return 1 day for elapsed_times equal to 1 day and 2 hours" do 43 | time = 1.day + 2.hours 44 | subject.approximate_elapsed(time).should == '1 day' 45 | end 46 | 47 | it "should return 1 day and a half for elapsed_times equal to 1 day and 5 hours" do 48 | time = 1.day + 7.hours 49 | subject.approximate_elapsed(time).should == '1 day and a half' 50 | end 51 | 52 | it "should return 1 hour for elapsed_times equal to 1 hour and 2 minutes" do 53 | time = 1.hour + 2.minutes 54 | subject.approximate_elapsed(time).should == '1 hour' 55 | end 56 | 57 | it "should return 1 hour and a half for elapsed_times equal to 1 hour and 25 minutes" do 58 | time = 1.hour + 25.minutes 59 | subject.approximate_elapsed(time).should == '1 hour and a half' 60 | end 61 | 62 | it "should display 2 hours if elapsed_times equal 1 hour and 50 minutes" do 63 | time = 1.hour + 50.minutes 64 | subject.approximate_elapsed(time).should == '2 hours' 65 | end 66 | 67 | it "should display 2 days if elapsed_times equal 1 day and 20 hours" do 68 | time = 1.day + 20.hours 69 | subject.approximate_elapsed(time).should == '2 days' 70 | end 71 | end 72 | 73 | 74 | describe "File tree" do 75 | before(:each) do 76 | @project = FactoryGirl.create :project 77 | @project_path = @project.path 78 | @file_paths = [ 79 | "#{@project_path}/file1.rb", 80 | "#{@project_path}/file2.rb", 81 | "#{@project_path}/folder/file_under_folder1.rb", 82 | "#{@project_path}/folder/file_under_folder2.rb", 83 | ] 84 | @file_paths.each do |fp| 85 | f = FactoryGirl.create :file, project: @project, path: fp 86 | 2.times do 87 | Timecop.freeze(Time.now + 1) 88 | FactoryGirl.create :time_entry, file: f, mtime: Time.now 89 | end 90 | end 91 | 92 | @files = @project.reload.files 93 | @tree = FileTree.new(@project_path, @project.reload.files) 94 | @elapsed_time = @project.elapsed_time 95 | end 96 | 97 | subject { ProjectPresenter.new(@project, nil) } 98 | 99 | describe "#parse_file_tree" do 100 | it { should respond_to :parse_file_tree } 101 | 102 | it "should nest everything under an article with class file_tree" do 103 | subject.send(:parse_file_tree, @tree, true).should =~ %r(^
]*>.*
$) 104 | end 105 | 106 | it "shouldn't nest everything under an article with class file_tree if it is not the root element" do 107 | subject.send(:parse_file_tree, @tree.nested_tree['folder']).should_not =~ %r(^
]*>.*
$) 108 | end 109 | 110 | it "should return a div with id root" do 111 | subject.send(:parse_file_tree, @tree, true).should =~ %r(
]*>.*
) 112 | end 113 | 114 | it "should return a div with id nested_folder" do 115 | subject.send(:parse_file_tree, @tree.nested_tree['folder']).should =~ %r(^
]*>.*
$) 116 | end 117 | 118 | it "should return a span for the expand/collapse for the root" do 119 | subject.send(:parse_file_tree, @tree, true).should =~ 120 | %r(
]*>.*.*
) 121 | end 122 | 123 | it "should return a span for the expand/collapse for the nested_folder" do 124 | subject.send(:parse_file_tree, @tree.nested_tree['folder']).should =~ 125 | %r(
]*>.*.*
) 126 | end 127 | 128 | it "should return a span for the name, Project for the root" do 129 | subject.send(:parse_file_tree, @tree, true).should =~ 130 | %r(
]*>.*\s*Project\s*.*
) 131 | end 132 | 133 | it "should return a span for the name, folder for the nested folder" do 134 | subject.send(:parse_file_tree, @tree.nested_tree['folder']).should =~ 135 | %r(^
]*>.*\s*folder\s*.*
$) 136 | end 137 | 138 | it "should return a span for the elapsed time (root)" do 139 | subject.send(:parse_file_tree, @tree, true).should =~ 140 | %r(
]*>.*\s*#{subject.elapsed(@elapsed_time)}\s*.*
) 141 | end 142 | 143 | it "should return a span for the elapsed time (folder)" do 144 | subject.send(:parse_file_tree, @tree.nested_tree['folder']).should =~ 145 | %r(^
]*>.*\s*#{subject.elapsed(@elapsed_time / 2)}\s*.*
$) 146 | end 147 | 148 | it "should return the folder tree inside the tree" do 149 | subject.send(:parse_file_tree, @tree, true).should =~ 150 | %r(
]*>.*
]*>.*
) 151 | end 152 | 153 | it "should return the files" do 154 | subject.send(:parse_file_tree, @tree, true).should =~ 155 | %r(
]*>.*
    ]*>.*
.*
) 156 | end 157 | 158 | it "should return the path of the files" do 159 | subject.send(:parse_file_tree, @tree, true).should =~ 160 | %r(
]*>.*
    ]*>.*
  • .*file1.rb.*
  • .*
.*
) 161 | end 162 | 163 | it "should return the elapsed time of the files" do 164 | subject.send(:parse_file_tree, @tree, true).should =~ 165 | %r(
]*>.*
    ]*>.*
  • .*1 second.*
  • .*
.*
) 166 | end 167 | end 168 | 169 | describe "#file_tree" do 170 | it { should respond_to :file_tree } 171 | 172 | it "should return a div with id root" do 173 | regex = %r(.*root.*name.*elapsed_time.*nested_folder.*files.*file_under_folder(1|2).*files.*file(1|2).*) 174 | 175 | subject.file_tree(@files).should =~ regex 176 | end 177 | end 178 | end 179 | end 180 | end 181 | end -------------------------------------------------------------------------------- /lib/watch_tower/cli/install.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | module WatchTower 4 | module CLI 5 | module Install 6 | 7 | def self.included(base) 8 | base.send :include, InstanceMethods 9 | end 10 | 11 | module InstanceMethods 12 | def self.included(base) 13 | base.class_eval <<-END, __FILE__, __LINE__ + 1 14 | # This module needs Thor::Actions 15 | include ::Thor::Actions 16 | 17 | # Mappings (aliases) 18 | map "-i" => :install 19 | 20 | # Install WatchTower 21 | desc "install", "Install Watch Tower" 22 | def install 23 | # Install the configuration file 24 | install_config_file 25 | # Install the bootloader 26 | install_bootloader 27 | end 28 | 29 | # Install bootloader 30 | desc "install_bootloader", "Install Watch Tower's bootloader" 31 | method_option :force, 32 | type: :boolean, 33 | required: false, 34 | aliases: "-f", 35 | default: false, 36 | desc: "Force the installation of the bootloader" 37 | def install_bootloader 38 | # Install the bootloader 39 | install_bootloader_on_os 40 | end 41 | 42 | # Load bootloader 43 | desc "load_bootloader", "Load Watch Tower's bootloader" 44 | def load_bootloader 45 | # Load the bootloader 46 | load_bootloader_on_os 47 | end 48 | 49 | # Unload bootloader 50 | desc "unload_bootloader", "Unload Watch Tower's bootloader" 51 | def unload_bootloader 52 | # Unload the bootloader 53 | unload_bootloader_on_os 54 | end 55 | 56 | # Load bootloader 57 | desc "reload_bootloader", "Reload Watch Tower's bootloader" 58 | def reload_bootloader 59 | # Reload the bootloader 60 | reload_bootloader_on_os 61 | end 62 | 63 | protected 64 | # Install the configuration file 65 | def install_config_file 66 | self.class.source_root(TEMPLATE_PATH) 67 | copy_file 'config.yml', File.join(USER_PATH, 'config.yml') 68 | end 69 | 70 | # Install bootloader 71 | def install_bootloader_on_os 72 | require 'rbconfig' 73 | case RbConfig::CONFIG['target_os'] 74 | when /darwin/ 75 | install_bootloader_on_mac 76 | when /linux/ 77 | install_bootloader_on_linux 78 | else 79 | puts bootloader_not_supported_on_current_os 80 | end 81 | end 82 | 83 | # Install bootloader on Mac OS X 84 | def install_bootloader_on_mac 85 | self.class.source_root(TEMPLATE_PATH) 86 | create_file bootloader_path_on_mac, force: options[:force] do 87 | template = File.expand_path(find_in_source_paths('watchtower.plist.erb')) 88 | ERB.new(File.read(template)).result(binding) 89 | end 90 | 91 | puts "\nCreated. Now run:\n watchtower load_bootloader\n\n" 92 | end 93 | 94 | # Install bootloader on linux 95 | def install_bootloader_on_linux 96 | require 'cronedit' 97 | # Remove any old entries 98 | uninstall_bootloader_on_linux 99 | # Define the crontab command 100 | crontab_command = "\#{ruby_binary} \#{watch_tower_binary} start --bootloader" 101 | # Create a crontab instance 102 | crontab = CronEdit::Crontab.new 103 | # Add the command 104 | crontab.add Time.now.strftime('%s'), { minute: "@reboot", hour: '', day: '', month: '', weekday: '', command: crontab_command } 105 | # Commit changes 106 | crontab.commit 107 | end 108 | 109 | # Uninstall bootloader on linux 110 | def uninstall_bootloader_on_linux 111 | require 'cronedit' 112 | # Create a crontab instance 113 | crontab = CronEdit::Crontab.new 114 | # Iterate over crontab entries and remove any command having watchtower start 115 | crontab.list.each_pair do |k, c| 116 | if c =~ /watchtower start/ 117 | crontab.remove(k) 118 | end 119 | end 120 | # Commit changes 121 | crontab.commit 122 | end 123 | 124 | # Returns the absolute path to the ruby binary 125 | # 126 | # @return [String] The path to ruby 127 | def ruby_binary 128 | WatchTower.which('ruby') 129 | end 130 | 131 | # Returns the absolute path to the watchtower binary 132 | # 133 | # @return [String] The path to watch tower binary 134 | def watch_tower_binary 135 | File.expand_path(File.join(File.dirname(__FILE__), '..', '..', '..', 'bin', 'watchtower')) 136 | end 137 | 138 | # Load bootloader 139 | def load_bootloader_on_os 140 | require 'rbconfig' 141 | case RbConfig::CONFIG['target_os'] 142 | when /darwin/ 143 | load_bootloader_on_mac 144 | else 145 | puts bootloader_not_supported_on_current_os 146 | end 147 | end 148 | 149 | # Unload bootloader 150 | def unload_bootloader_on_os 151 | require 'rbconfig' 152 | case RbConfig::CONFIG['target_os'] 153 | when /darwin/ 154 | unload_bootloader_on_mac 155 | else 156 | puts bootloader_not_supported_on_current_os 157 | end 158 | end 159 | 160 | # Reload bootloader 161 | def reload_bootloader_on_os 162 | require 'rbconfig' 163 | case RbConfig::CONFIG['target_os'] 164 | when /darwin/ 165 | reload_bootloader_on_mac 166 | else 167 | puts bootloader_not_supported_on_current_os 168 | end 169 | end 170 | # Load the bootloader 171 | def load_bootloader_on_mac 172 | system "launchctl load \#{bootloader_path_on_mac}" 173 | end 174 | 175 | # Unload the bootloader 176 | def unload_bootloader_on_mac 177 | system "launchctl unload \#{bootloader_path_on_mac}" 178 | end 179 | 180 | # Reload the bootloader 181 | def reload_bootloader_on_mac 182 | # Unload bootloader 183 | unload_bootloader_on_mac 184 | # Load bootloader 185 | load_bootloader_on_mac 186 | end 187 | 188 | # Returns the path of the bootloader on mac 189 | # 190 | # @return [String] The path to the bootloader 191 | def bootloader_path_on_mac 192 | File.join(ENV['HOME'], 'Library', 'LaunchAgents', 'fr.technogate.WatchTower.plist') 193 | end 194 | 195 | def bootloader_not_supported_on_current_os 196 | <<-MSG 197 | WatchTower bootloader is not supported on your OS, you'd have to run it manually 198 | for the time being. Support for many editors and many OSes is planned for the 199 | future, if you would like to help, or drop in an issue please don't hesitate to 200 | do so on the project's Github page: https://github.com/TechnoGate/watch_tower 201 | MSG 202 | end 203 | END 204 | end 205 | end 206 | end 207 | end 208 | end 209 | -------------------------------------------------------------------------------- /spec/watch_tower/editor/vim_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | def mock_pipe(out) 4 | pipe = mock 5 | pipe.stubs(:read).returns(out) 6 | pipe.stubs(:close) 7 | pipe 8 | end 9 | 10 | module Editor 11 | describe Vim do 12 | before(:each) do 13 | # Stubs which 14 | WatchTower.stubs(:which).with('vim').returns('/usr/bin/vim') 15 | WatchTower.stubs(:which).with('gvim').returns('/usr/bin/gvim') 16 | WatchTower.stubs(:which).with('mvim').returns(nil) 17 | 18 | # Stub systemu 19 | Open3.stubs(:popen2).with("/usr/bin/vim --help").returns([mock_pipe(""), mock_pipe(""), mock_pipe("")]) 20 | Open3.stubs(:popen2).with("/usr/bin/gvim --help").returns([mock_pipe(""), mock_pipe("--remote-send"), mock_pipe("")]) 21 | Open3.stubs(:popen2).with("/usr/bin/gvim --servername VIM --remote-send ':source #{Vim::VIM_EXTENSION_PATH}'").returns([mock_pipe(""), mock_pipe(""), mock_pipe("")]) 22 | Open3.stubs(:popen3).with("/usr/bin/gvim --servername VIM --remote-expr 'watchtower#ls()'").returns([mock_pipe(""), mock_pipe(<<-EOS), mock_pipe('')]) 23 | /path/to/file.rb 24 | EOS 25 | Open3.stubs(:popen2).with('/usr/bin/gvim --serverlist').yields([mock_pipe(""), mock_pipe(<<-EOC), mock_pipe('')]) 26 | VIM 27 | EOC 28 | version_output = <<-EOV 29 | VIM - Vi IMproved 7.3 (2010 Aug 15) 30 | Included patches: 1-202, 204-222, 224-322 31 | Compiled by 'http://www.opensuse.org/' 32 | Huge version without GUI. Features included (+) or not (-): 33 | +arabic +autocmd -balloon_eval -browse ++builtin_terms +byte_offset +cindent 34 | -clientserver -clipboard +cmdline_compl +cmdline_hist +cmdline_info +comments 35 | +conceal +cryptv +cscope +cursorbind +cursorshape +dialog_con +diff +digraphs 36 | -dnd -ebcdic +emacs_tags +eval +ex_extra +extra_search +farsi +file_in_path 37 | +find_in_path +float +folding -footer +fork() +gettext -hangul_input +iconv 38 | +insert_expand +jumplist +keymap +langmap +libcall +linebreak +lispindent 39 | +listcmds +localmap -lua +menu +mksession +modify_fname +mouse -mouseshape 40 | +mouse_dec -mouse_gpm -mouse_jsbterm +mouse_netterm -mouse_sysmouse 41 | +mouse_xterm +multi_byte +multi_lang -mzscheme +netbeans_intg +path_extra -perl 42 | +persistent_undo +postscript +printer +profile -python -python3 +quickfix 43 | +reltime +rightleft -ruby +scrollbind +signs +smartindent +sniff +startuptime 44 | +statusline -sun_workshop +syntax +tag_binary +tag_old_static -tag_any_white 45 | -tcl +terminfo +termresponse +textobjects +title -toolbar +user_commands 46 | +vertsplit +virtualedit +visual +visualextra +viminfo +vreplace +wildignore 47 | +wildmenu +windows +writebackup -X11 -xfontset -xim -xsmp -xterm_clipboard 48 | -xterm_save 49 | system vimrc file: "/etc/vimrc" 50 | user vimrc file: "$HOME/.vimrc" 51 | user exrc file: "$HOME/.exrc" 52 | fall-back for $VIM: "/etc" 53 | f-b for $VIMRUNTIME: "/usr/share/vim/current" 54 | Compilation: gcc -c -I. -Iproto -DHAVE_CONFIG_H -I/usr/local/include -fmessage-length=0 -O2 -Wall -D_FORTIFY_SOURCE=2 -fstack-protector -funwind-tables -fasynchronous-unwind-tables -g -Wall -pipe -fno-strict-aliasing -fstack-protector-all 55 | Linking: gcc -L/usr/local/lib -Wl,--as-needed -o vim -lm -lnsl -lncurses -lacl -lattr -ldl 56 | EOV 57 | 58 | Open3.stubs(:popen2).with("/usr/bin/vim --version"). 59 | yields [mock_pipe(""), mock_pipe(version_output), mock_pipe('')] 60 | Open3.stubs(:popen2).with("/usr/bin/gvim --version"). 61 | yields [mock_pipe(""), mock_pipe(version_output), mock_pipe('')] 62 | end 63 | 64 | it { should respond_to :name } 65 | its(:name) { should_not raise_error NotImplementedError } 66 | its(:name) { should_not be_empty } 67 | 68 | describe "#fetch_version" do 69 | it { should respond_to :fetch_version } 70 | 71 | it "should be called on initialize" do 72 | Vim.any_instance.expects(:fetch_version).once 73 | Vim.new 74 | end 75 | 76 | it "should return 7.3" do 77 | subject.send(:fetch_version).should == '7.3' 78 | end 79 | end 80 | 81 | it { should respond_to :version } 82 | its(:version) { should_not raise_error NotImplementedError } 83 | its(:version) { should_not be_empty } 84 | its(:version) { should == '7.3' } 85 | 86 | describe "#supported_vims" do 87 | it { should respond_to :supported_vims } 88 | 89 | it "should return gvim" do 90 | WatchTower.expects(:which).with('vim').returns('/usr/bin/vim').once 91 | WatchTower.expects(:which).with('gvim').returns('/usr/bin/gvim').once 92 | WatchTower.expects(:which).with('mvim').returns(nil).once 93 | Open3.expects(:popen2).with("/usr/bin/vim --help").returns([mock_pipe(""), mock_pipe(""), mock_pipe("")]).once 94 | Open3.expects(:popen2).with("/usr/bin/gvim --help").returns([mock_pipe(""), mock_pipe("--remote-send"), mock_pipe("")]).once 95 | 96 | subject.send :supported_vims 97 | subject.instance_variable_get('@vims').should == ['/usr/bin/gvim'] 98 | end 99 | end 100 | 101 | describe "#editor" do 102 | it { should respond_to :editor } 103 | 104 | it "should return /usr/bin/gvim" do 105 | subject.send(:editor).should == '/usr/bin/gvim' 106 | end 107 | 108 | it "should return nil if @vims is []" do 109 | subject.instance_variable_set('@vims', []) 110 | 111 | subject.send(:editor).should == nil 112 | end 113 | 114 | it "should return nil if @vims is nil" do 115 | subject.instance_variable_set('@vims', nil) 116 | 117 | subject.send(:editor).should == nil 118 | end 119 | end 120 | 121 | describe "#servers" do 122 | it { should respond_to :servers } 123 | 124 | it "should return VIM" do 125 | Open3.expects(:popen2).with('/usr/bin/gvim --serverlist').yields([mock_pipe(""), mock_pipe(<<-EOC), mock_pipe('')]).once 126 | VIM 127 | EOC 128 | subject.send(:servers).should == ['VIM'] 129 | end 130 | end 131 | 132 | describe "#send_extensions_to_editor" do 133 | it { should respond_to :send_extensions_to_editor } 134 | 135 | it "should send the extensions to vim" do 136 | Open3.expects(:popen2).with("/usr/bin/gvim --servername VIM --remote-send ':source #{Vim::VIM_EXTENSION_PATH}'").once 137 | 138 | subject.send :send_extensions_to_editor 139 | end 140 | end 141 | 142 | describe "#is_running?" do 143 | it { should respond_to :is_running? } 144 | 145 | it "should return true if ViM is running" do 146 | subject.is_running?.should be_true 147 | end 148 | 149 | it "should return false if servers is []" do 150 | Vim.any_instance.stubs(:servers).returns([]) 151 | 152 | subject.is_running?.should be_false 153 | end 154 | 155 | it "should return false if servers is nil" do 156 | Vim.any_instance.stubs(:servers).returns(nil) 157 | 158 | subject.is_running?.should be_false 159 | end 160 | end 161 | 162 | describe "#current_paths" do 163 | it { should respond_to :current_paths } 164 | 165 | it "should call is_running?" do 166 | Vim.any_instance.expects(:is_running?).returns(false).once 167 | 168 | subject.current_paths 169 | end 170 | 171 | it "should not call send_extensions_to_editor if the function is already loaded" do 172 | Vim.any_instance.expects(:send_extensions_to_editor).never 173 | 174 | subject.current_paths 175 | end 176 | 177 | it "should call send_extensions_to_editor only if the remote did not evaluate the command" do 178 | Open3.expects(:popen3).with("/usr/bin/gvim --servername VIM --remote-expr 'watchtower#ls()'").returns([mock_pipe(""), mock_pipe(""), mock_pipe(<<-EOS)]).twice 179 | E449: Invalid expression received: Send expression failed. 180 | EOS 181 | Vim.any_instance.expects(:send_extensions_to_editor).once 182 | 183 | subject.current_paths 184 | end 185 | 186 | it "should be nil if is_running? is false" do 187 | Vim.any_instance.stubs(:is_running?).returns(false) 188 | 189 | subject.current_paths.should be_nil 190 | end 191 | 192 | it "should be able to parse ls output" do 193 | Open3.expects(:popen3).with("/usr/bin/gvim --servername VIM --remote-expr 'watchtower#ls()'").returns([mock_pipe(""), mock_pipe(<<-EOS), mock_pipe('')]).once 194 | /path/to/file.rb 195 | /path/to/file2.rb 196 | EOS 197 | 198 | documents = subject.current_paths 199 | documents.should include("/path/to/file.rb") 200 | documents.should include("/path/to/file2.rb") 201 | end 202 | 203 | it "should not return duplicate documents" do 204 | Open3.expects(:popen3).with("/usr/bin/gvim --servername VIM --remote-expr 'watchtower#ls()'").returns([mock_pipe(""), mock_pipe(<<-EOS), mock_pipe('')]).once 205 | /path/to/file.rb 206 | /path/to/file.rb 207 | /path/to/file.rb 208 | /path/to/file.rb 209 | /path/to/file.rb 210 | EOS 211 | 212 | documents = subject.current_paths 213 | documents.should include("/path/to/file.rb") 214 | documents.size.should == 1 215 | end 216 | end 217 | 218 | end 219 | end 220 | --------------------------------------------------------------------------------