├── .rspec ├── lib ├── ubcbooker │ ├── version.rb │ ├── scrapers │ │ ├── scraper.rb │ │ ├── base_scraper.rb │ │ └── cs.rb │ ├── color.rb │ ├── cli_validator.rb │ ├── config.rb │ ├── error.rb │ └── cli.rb └── ubcbooker.rb ├── .rubocop.yml ├── .travis.yml ├── bin ├── ubcbooker └── console ├── spec ├── ubcbooker_spec.rb ├── spec_helper.rb ├── scraper_cs_spec.rb ├── config_spec.rb ├── cli_validator_spec.rb └── cli_spec.rb ├── Rakefile ├── Gemfile ├── .gitignore ├── LICENSE ├── ubcbooker.gemspec ├── README.md └── CODE_OF_CONDUCT.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/ubcbooker/version.rb: -------------------------------------------------------------------------------- 1 | module Ubcbooker 2 | VERSION = "1.0.0" 3 | end 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-github: 3 | - config/default.yml 4 | -------------------------------------------------------------------------------- /lib/ubcbooker/scrapers/scraper.rb: -------------------------------------------------------------------------------- 1 | require_relative "base_scraper" 2 | require_relative "cs" 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.5.0 5 | before_install: gem install bundler -v 1.16.1 6 | -------------------------------------------------------------------------------- /bin/ubcbooker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "ubcbooker" 5 | 6 | Ubcbooker::CLI.new.start 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "ubcbooker" 5 | require "pry" 6 | 7 | Pry.start 8 | 9 | -------------------------------------------------------------------------------- /spec/ubcbooker_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Ubcbooker do 2 | it "has a version number" do 3 | expect(Ubcbooker::VERSION).not_to be nil 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | ENV['RACK_ENV'] == 'test' 5 | RSpec::Core::RakeTask.new(:spec) 6 | 7 | task :default => :spec 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in ubcbooker.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "ubcbooker" 3 | 4 | RSpec.configure do |config| 5 | # Disable RSpec exposing methods globally on `Module` and `main` 6 | config.disable_monkey_patching! 7 | 8 | config.expect_with :rspec do |c| 9 | c.syntax = :expect 10 | end 11 | 12 | # Allow exit to happen within the code 13 | config.around(:example) do |ex| 14 | begin 15 | ex.run 16 | rescue SystemExit => e 17 | puts "Got SystemExit: #{e.inspect}. Ignoring" 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.gem 3 | *.rbc 4 | lib/ubcbooker/config.yml 5 | spec/config.yml 6 | /.config 7 | /coverage/ 8 | /InstalledFiles 9 | /pkg/ 10 | /spec/reports/ 11 | /spec/examples.txt 12 | /test/tmp/ 13 | /test/version_tmp/ 14 | /tmp/ 15 | 16 | ## Documentation cache and generated files: 17 | /.yardoc/ 18 | /_yardoc/ 19 | /doc/ 20 | /rdoc/ 21 | 22 | ## Environment normalization: 23 | /.bundle/ 24 | /vendor/bundle 25 | /lib/bundler/man/ 26 | 27 | Gemfile.lock 28 | .ruby-version 29 | .ruby-gemset 30 | .rspec_status 31 | 32 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 33 | .rvmrc 34 | -------------------------------------------------------------------------------- /lib/ubcbooker/color.rb: -------------------------------------------------------------------------------- 1 | class String 2 | def black; "\e[30m#{self}\e[0m" end 3 | def red; "\e[31m#{self}\e[0m" end 4 | def green; "\e[32m#{self}\e[0m" end 5 | def brown; "\e[33m#{self}\e[0m" end 6 | def blue; "\e[34m#{self}\e[0m" end 7 | def magenta; "\e[35m#{self}\e[0m" end 8 | def cyan; "\e[36m#{self}\e[0m" end 9 | def gray; "\e[37m#{self}\e[0m" end 10 | 11 | def bold; "\e[1m#{self}\e[22m" end 12 | def italic; "\e[3m#{self}\e[23m" end 13 | def underline; "\e[4m#{self}\e[24m" end 14 | def blink; "\e[5m#{self}\e[25m" end 15 | def reverse_color; "\e[7m#{self}\e[27m" end 16 | end 17 | -------------------------------------------------------------------------------- /spec/scraper_cs_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Ubcbooker::Scraper::Cs do 2 | 3 | let(:scraper) { Ubcbooker::Scraper::Cs.new("testuser", "testpass") } 4 | 5 | describe "#get_time_slot" do 6 | it "returns a timeslot as range of time str" do 7 | expected_output = ("13:00".."14:00") 8 | expect(scraper.get_time_slot("13:00-14:00")).to eq(expected_output) 9 | end 10 | end 11 | 12 | describe "#ampm_to_time" do 13 | it "returns a timeslot as range of time str" do 14 | expected_output = "14:00" 15 | expect(scraper.ampm_to_time("02:00pm")).to eq(expected_output) 16 | end 17 | end 18 | 19 | describe "#time_to_ampm" do 20 | it "returns a timeslot as range of time str" do 21 | expected_output = "02:00pm" 22 | expect(scraper.time_to_ampm("14:00")).to eq(expected_output) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Atsushi Yamamoto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/ubcbooker.rb: -------------------------------------------------------------------------------- 1 | require "pry" 2 | require "business_time" 3 | require "tty-spinner" 4 | require "mechanize" 5 | require "typhoeus" 6 | require "obscenity" 7 | require "io/console" # For handling password 8 | require "optparse" 9 | require "yaml" 10 | require "keyring" 11 | # Keyring dependencies 12 | # Keyring gem is currently unable to correcly detect the following downlaod 13 | # https://github.com/jheiss/keyring/blob/master/ext/mkrf_conf.rb#L26 14 | # http://en.wikibooks.org/wiki/Ruby_Programming/RubyGems#How_to_install_different_versions_of_gems_depending_on_which_version_of_ruby_the_installee_is_using 15 | case Gem::Platform.local.os 16 | when "linux" 17 | require "gir_ffi-gnome_keyring" 18 | when "darwin" 19 | require "ruby-keychain" 20 | end 21 | 22 | module Ubcbooker 23 | BOOKING_URL = { 24 | cs: "https://my.cs.ubc.ca/docs/project-rooms-and-dlc", 25 | # sauder_ugrad: "https://booking.sauder.ubc.ca/ugr/cwl-login", 26 | } 27 | end 28 | 29 | require "ubcbooker/version" 30 | require "ubcbooker/error" 31 | require "ubcbooker/color" 32 | require "ubcbooker/config" 33 | require "ubcbooker/scrapers/scraper" 34 | require "ubcbooker/cli_validator" 35 | require "ubcbooker/cli" 36 | -------------------------------------------------------------------------------- /lib/ubcbooker/scrapers/base_scraper.rb: -------------------------------------------------------------------------------- 1 | module Ubcbooker 2 | module Scraper 3 | class BaseScraper 4 | def initialize(username, password) 5 | @agent = Mechanize.new do |agent| 6 | agent.user_agent_alias = "Linux Mozilla" 7 | end 8 | @username = username 9 | @password = password 10 | end 11 | 12 | def is_logged_in(page) 13 | page_body = Nokogiri::HTML(page.body) 14 | login_status_text = page_body.css("p").first.text 15 | return !login_status_text.include?("Login Failed") 16 | end 17 | 18 | def populate_account_info(login_page) 19 | username_feild = login_page.form.field_with(name: "j_username") 20 | username_feild.value = @username 21 | password_field = login_page.form.field_with(name: "j_password") 22 | password_field.value = @password 23 | redirect_page = login_page.form.submit 24 | return redirect_page.form.submit 25 | end 26 | 27 | # Do login for UBC CWL system 28 | def login_ubc_cwl(login_page) 29 | begin 30 | after_login_page = populate_account_info(login_page) 31 | if is_logged_in(after_login_page) 32 | return after_login_page 33 | else 34 | raise Ubcbooker::Error::LoginFailed 35 | end 36 | rescue Ubcbooker::Error::LoginFailed => e 37 | puts e.message 38 | exit(1) 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /ubcbooker.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("../lib", __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "ubcbooker/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "ubcbooker" 7 | spec.version = Ubcbooker::VERSION 8 | spec.authors = ["Atsushi Yamamoto"] 9 | spec.email = ["hi@atsushi.me"] 10 | 11 | spec.summary = "CLI tool to book project rooms in UBC" 12 | spec.description = "CLI tool to book project rooms in UBC" 13 | spec.homepage = "https://github.com/jumbosushi/ubcbooker" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 17 | f.match(%r{^(test|spec|features)/}) 18 | end 19 | spec.executables = ["ubcbooker"] 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_dependency "mechanize", "~> 2.7" 23 | spec.add_dependency "business_time", "~> 0.9.3" 24 | spec.add_dependency "typhoeus", "~> 1.1" 25 | spec.add_dependency "obscenity", "~> 1.0.2" 26 | spec.add_dependency "tty-spinner", "~> 0.7.0" 27 | spec.add_dependency "keyring", "~> 0.4.1" 28 | spec.add_dependency "gir_ffi-gnome_keyring", "~> 0.0.3" 29 | spec.add_dependency "ruby-keychain", "~> 0.3.2" 30 | 31 | spec.add_development_dependency "rake", "~> 10.0" 32 | spec.add_development_dependency "rspec", "~> 3.0" 33 | spec.add_development_dependency "pry", "~> 0.11.3" 34 | spec.add_development_dependency "pry-byebug", "~> 3.4" 35 | spec.add_development_dependency "rubocop-github", "~> 0.8.1" 36 | end 37 | -------------------------------------------------------------------------------- /lib/ubcbooker/cli_validator.rb: -------------------------------------------------------------------------------- 1 | module Ubcbooker 2 | class CLI 3 | module Validator 4 | def self.is_valid_department(d) 5 | return BOOKING_URL.keys.include?(d.to_sym) 6 | end 7 | 8 | def self.is_valid_date(d) 9 | date = nil 10 | begin 11 | date = Date.parse(d) 12 | # Expect MM/DD 13 | rescue ArgumentError 14 | return false 15 | end 16 | return /^\d\d\/\d\d$/.match?(d) && # Match format 17 | date.weekday? && # Not on weekend 18 | !date.past? && # Not in the past 19 | (date < Date.today + 7) # Within a week 20 | end 21 | 22 | def self.is_valid_time(t) 23 | if /^\d\d:\d\d-\d\d:\d\d$/.match?(t) 24 | times = t.split("-") 25 | times.each do |time| 26 | begin 27 | DateTime.parse(time) 28 | # Expect HH:MM 29 | rescue ArgumentError 30 | return false 31 | end 32 | end 33 | return true 34 | else 35 | return false 36 | end 37 | end 38 | 39 | def self.is_required_missing(options) 40 | return options[:name].nil? || options[:date].nil? || 41 | options[:time].nil? || options[:department].nil? 42 | end 43 | 44 | # False if the name contains any profanity 45 | def self.is_valid_name(name) 46 | return !Obscenity.profane?(name) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/config_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Ubcbooker::Config do 2 | require "keyring" 3 | require "rubygems/test_case" 4 | 5 | let(:config) { Ubcbooker::Config.new } 6 | let(:keyring) { Keyring.new } 7 | 8 | after(:each) do 9 | config.delete_credentials 10 | end 11 | 12 | describe "#save_credentials" do 13 | context "running on osx / linux" do 14 | it "save params into keyring" do 15 | config.save_credentials("testuser", "testpass") 16 | expect(keyring.get_password("ubcbooker", "username")).to eq("testuser") 17 | expect(keyring.get_password("ubcbooker", "password")).to eq("testpass") 18 | end 19 | end 20 | 21 | context "running on windows" do 22 | it "save params locally" do 23 | Gem.win_platform = true 24 | config.save_credentials("testuser", "testpass") 25 | expect(keyring.get_password("ubcbooker", "username")).to eq(false) 26 | expect(keyring.get_password("ubcbooker", "password")).to eq(false) 27 | expect(config.get_username).to eq("testuser") 28 | expect(config.get_password).to eq("testpass") 29 | Gem.win_platform = false 30 | end 31 | end 32 | end 33 | 34 | describe "#defined?" do 35 | context "when #ask has not been called before" do 36 | it "return false" do 37 | expect(config.defined?).to equal(false) 38 | end 39 | end 40 | 41 | context "when #ask has been called before" do 42 | it "return true" do 43 | allow(config).to receive(:gets).and_return("testuser") 44 | allow(STDIN).to receive(:noecho).and_return("testuser") 45 | config.ask 46 | expect(config.defined?).to equal(true) 47 | end 48 | end 49 | end 50 | 51 | end 52 | -------------------------------------------------------------------------------- /lib/ubcbooker/config.rb: -------------------------------------------------------------------------------- 1 | module Ubcbooker 2 | class Config 3 | # We use system keyring for storing cwl credentials 4 | # https://askubuntu.com/questions/32164/what-does-a-keyring-do 5 | # 6 | # since the keyring gem doesn't suppot Windows, we ask for cred each time 7 | # if the user is on OSX or Linux, username and password will be stored in keyring 8 | def initialize 9 | @keyring = Keyring.new 10 | @win_username = "" 11 | @win_password = "" 12 | end 13 | 14 | def ask 15 | print "Your CWL username: " 16 | username = gets.chomp 17 | print "Your CWL password: " 18 | # Hide the password input 19 | password = STDIN.noecho(&:gets).chomp 20 | save_credentials(username, password) 21 | puts 22 | end 23 | 24 | def save_credentials(username, password) 25 | if is_windows? 26 | @win_username = username 27 | @win_pasword = password 28 | else 29 | @keyring.set_password("ubcbooker", "username", username) 30 | @keyring.set_password("ubcbooker", "password", password) 31 | end 32 | end 33 | 34 | def print_supported_departments 35 | puts "Supported department options in #{Ubcbooker::VERSION}:" 36 | BOOKING_URL.keys.each do |d| 37 | puts " - #{d}" 38 | end 39 | end 40 | 41 | # True if user is on windows platform 42 | def is_windows? 43 | return Gem.win_platform? 44 | end 45 | 46 | # Delete stored user credentials 47 | # Used in specs 48 | def delete_credentials 49 | if is_windows? 50 | @win_username = "" 51 | @win_password = "" 52 | else 53 | @keyring.delete_password("ubcbooker", "username") 54 | @keyring.delete_password("ubcbooker", "password") 55 | end 56 | end 57 | 58 | def get_username 59 | if is_windows? 60 | return @win_username 61 | else 62 | return @keyring.get_password("ubcbooker", "username") 63 | end 64 | end 65 | 66 | def get_password 67 | if is_windows? 68 | return @win_pasword 69 | else 70 | return @keyring.get_password("ubcbooker", "password") 71 | end 72 | end 73 | 74 | def defined? 75 | return !!(get_username && get_password) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ubcbooker 2 | 3 | CLI tool to book project rooms in UBC 4 | 5 | ![Demo](https://i.imgur.com/UhT9zKJ.gif) 6 | 7 | ## Installation 8 | 9 | Install it as: 10 | 11 | $ gem install ubcbooker 12 | 13 | For use in your application, add this line to your Gemfile: 14 | 15 | ```ruby 16 | gem 'ubcbooker' 17 | ``` 18 | 19 | And then execute: 20 | 21 | $ bundle 22 | 23 | ## Usage 24 | 25 | ``` 26 | # Book a room in CS with the name 'Study Group' from 11am to 1pm on March 5th 27 | 28 | $ ubcbooker -b cs -n 'Study Group' -d 03/05 -t 11:00-13:00 29 | ``` 30 | 31 | When you first run the command it will ask for your CWL account auth info. This is saved locally within keyring (if you're using linux) or keychain (if you're using osx) through [keyring gem](https://github.com/jheiss/keyring). Note that saving password is not supported on Windows at the moment. 32 | 33 | 34 | Run `ubcbooker -u` to update the CWL auth info. 35 | 36 | ``` 37 | $ ubcbooker --help 38 | 39 | Usage: ubcbooker [options] 40 | -b, --building BUILDING Specify which department to book rooms from 41 | -d, --date DATE Specify date to book rooms for (MM/DD) 42 | -h, --help Show this help message 43 | -l, --list List supported departments 44 | -n, --name NAME Name of the booking 45 | -t, --time TIME Specify time to book rooms for (HH:MM-HH:MM) 46 | -u, --update Update username and password 47 | -v, --version Show version 48 | 49 | ex. Book a room in CS from 11am to 1pm on March 5th with the name 'Study Group' 50 | $> ubcbooker -b cs -n 'Study Group' -d 03/05 -t 11:00-13:00 51 | ``` 52 | 53 | Currently this app supports project rooms in cs (Computer Science). Run `ubcbooker -l` to check the latest supported departments. 54 | 55 | Feel free to send a PR that supports your department's rooms. 56 | 57 | ## Development 58 | 59 | After checking out the repo, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. To install this gem onto your local machine, run `bundle exec rake install`. 60 | 61 | ## Contributing 62 | 63 | Bug reports and pull requests are welcome. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 64 | 65 | ## License 66 | 67 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 68 | 69 | ## Code of Conduct 70 | 71 | Everyone interacting in the Ubcbooker project’s codebase, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/jumbosushi/ubcbooker/blob/master/CODE_OF_CONDUCT.md). 72 | -------------------------------------------------------------------------------- /lib/ubcbooker/error.rb: -------------------------------------------------------------------------------- 1 | module Ubcbooker 2 | module Error 3 | class UnsupportedDepartment < StandardError 4 | attr_reader :message 5 | def initialize(department = "unknown") 6 | @message = "\"#{department}\" is an unsupported department\n".red << 7 | "Check supported departments with `ubcbooker -l`" 8 | super 9 | end 10 | end 11 | 12 | class UnsupportedDate < StandardError 13 | attr_reader :message, :date 14 | def initialize(date = "unknown") 15 | @date = date 16 | @message = "Error: Unsupported Date\n".red << 17 | "Date must not be:\n" << 18 | " - in the past\n" << 19 | " - in a weekend\n" << 20 | " - beyond a week\n" << 21 | "Please check if the time is in the format of MM/DD\n" << 22 | "Ex. 03/05" 23 | super 24 | end 25 | end 26 | 27 | class UnsupportedTime < StandardError 28 | attr_reader :message, :time 29 | def initialize(time = "unknown") 30 | @time = time 31 | @message = "Error: Unsupported Time\n".red << 32 | "Please check if the time is in the format of HH:MM-HH:MM\n" << 33 | "Ex. 11:00-13:00" 34 | super 35 | end 36 | end 37 | 38 | class MissingRequired < StandardError 39 | attr_reader :message, :time 40 | def initialize() 41 | @message = "Error: Missing Option\n".red << 42 | "One or more of required option are missing values\n" << 43 | "Please check if options -b, -d, -n, and -t all have values passed" 44 | super 45 | end 46 | end 47 | 48 | 49 | class ProfaneName < StandardError 50 | attr_reader :message, :name 51 | def initialize(name = "unknown") 52 | @name = name 53 | @message = "Error: Name includes profanity\n".red << 54 | "Remember that other students might see the booking name\n" << 55 | "Please try again with a different name" 56 | super 57 | end 58 | end 59 | 60 | class NoAvailableRoom < StandardError 61 | attr_reader :message, :time_range 62 | def initialize(time_range) 63 | @time_range = time_range 64 | @message = "Error: No Available Room\n".red << 65 | "There are no room available for #{time_range} range\n" << 66 | "Please try again with a different time range" 67 | super 68 | end 69 | end 70 | 71 | class BookingFailed < StandardError 72 | attr_reader :message 73 | def initialize 74 | @message = "\nBooking Failed :/\n".red << 75 | "Please raise an issue on GitHub" 76 | super 77 | end 78 | end 79 | 80 | class LoginFailed < StandardError 81 | attr_reader :message 82 | def initialize 83 | @message = "\nLogin Failed :/\n".red << 84 | "Please try logging in with a different username or password\n" << 85 | "You can use `-u` flag to update saved accout info" 86 | super 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at yamaatsushi927@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /spec/cli_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Ubcbooker::CLI::Validator do 2 | 3 | let(:validator) { Ubcbooker::CLI::Validator } 4 | 5 | describe "#is_valid_department" do 6 | context "when input is valid department" do 7 | it "returns true" do 8 | expect(validator.is_valid_department("cs")).to eq(true) 9 | end 10 | end 11 | 12 | context "when input is invalid department" do 13 | it "returns false" do 14 | expect(validator.is_valid_department("eng")).to eq(false) 15 | end 16 | end 17 | end 18 | 19 | describe "#is_valid_date" do 20 | context "when input is valid date" do 21 | it "returns true" do 22 | valid_date = Date.today.strftime("%m/%d") 23 | expect(validator.is_valid_date(valid_date)).to eq(true) 24 | end 25 | end 26 | 27 | context "when input is invalid date format" do 28 | it "return false" do 29 | expect(validator.is_valid_date("fail")).to eq(false) 30 | end 31 | end 32 | 33 | context "when input date is in the past" do 34 | it "returns false" do 35 | past_date = (Date.today - 10).strftime("%m/%d") 36 | expect(validator.is_valid_date(past_date)).to eq(false) 37 | end 38 | end 39 | 40 | context "when input date is beyond a week" do 41 | it "returns false" do 42 | future_date = (Date.today + 10).strftime("%m/%d") 43 | expect(validator.is_valid_date(future_date)).to eq(false) 44 | end 45 | end 46 | end 47 | 48 | describe "#is_valid_time" do 49 | context "when input date is valid time format" do 50 | it "returns true" do 51 | valid_time = "10:00-11:00" 52 | expect(validator.is_valid_time(valid_time)).to eq(true) 53 | end 54 | end 55 | 56 | context "when input date is invalid time format" do 57 | it "returns false" do 58 | invalid_time1 = "10:00_11:00" 59 | expect(validator.is_valid_time(invalid_time1)).to eq(false) 60 | 61 | invalid_time2 = "99:99-99:99" 62 | expect(validator.is_valid_time(invalid_time2)).to eq(false) 63 | end 64 | end 65 | end 66 | 67 | describe "#is_required_missing" do 68 | context "when required fields are not nil in option" do 69 | it "returns false" do 70 | option = { 71 | name: "test", 72 | date: "03/12", 73 | time: "10:00-11:00", 74 | department: "cs", 75 | } 76 | expect(validator.is_required_missing(option)).to eq(false) 77 | end 78 | end 79 | 80 | context "when one or more of required fields are nil in option" do 81 | it "returns true" do 82 | option = { 83 | name: "test", 84 | date: nil, 85 | time: "10:00-11:00", 86 | department: "cs", 87 | } 88 | expect(validator.is_required_missing(option)).to eq(true) 89 | end 90 | end 91 | end 92 | 93 | describe "#is_valid_name" do 94 | context "when input does not contain profanity key words" do 95 | it "returns true" do 96 | valid_name = "Group Work" 97 | expect(validator.is_valid_name(valid_name)).to eq(true) 98 | end 99 | end 100 | 101 | context "when input contain profanity key words" do 102 | it "returns false" do 103 | invalid_name = "Shit Group Work" 104 | expect(validator.is_valid_name(invalid_name)).to eq(false) 105 | invalid_name2 = "Fucking study group" 106 | expect(validator.is_valid_name(invalid_name2)).to eq(false) 107 | end 108 | end 109 | end 110 | 111 | end 112 | -------------------------------------------------------------------------------- /spec/cli_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Ubcbooker::CLI do 2 | 3 | # Disable warning msg for overwritting ARGV 4 | before(:all) do 5 | $VERBOSE = nil 6 | end 7 | 8 | # Enable warning msg for overwritting ARGV 9 | after(:all) do 10 | $VERBOSE = false 11 | end 12 | 13 | let(:cli) { Ubcbooker::CLI.new } 14 | let(:tmrw_date) { (Date.today + 1).strftime("%m/%d") } 15 | 16 | describe "#get_department_scraper" do 17 | context "when param is cs" do 18 | it "return CS Scrapper class" do 19 | result = cli.get_department_scraper("cs") 20 | expect(result).to eq(Ubcbooker::Scraper::Cs) 21 | end 22 | end 23 | 24 | context "when param is unknown department" do 25 | it "raise UnsupportedDepartment error" do 26 | expect do 27 | cli.get_department_scraper("boozilogy") 28 | end.to raise_error(Ubcbooker::Error::UnsupportedDepartment) 29 | end 30 | end 31 | end 32 | 33 | describe "#parse_options" do 34 | context "when ARGV is valid" do 35 | it "returns valid options" do 36 | ARGV = ["-b", "cs", 37 | "-n", "test_name", 38 | "-d", tmrw_date, 39 | "-t", "15:30-17:00"] 40 | expected_options = { 41 | name: "test_name", 42 | date: tmrw_date, 43 | time: "15:30-17:00", 44 | department: "cs", 45 | } 46 | result = cli.parse_options 47 | expect(result).to eq(expected_options) 48 | end 49 | end 50 | 51 | context "when required options are missing" do 52 | it "raise Ubcbooker::Error::MissingRequired" do 53 | # Missing -n flag 54 | ARGV = ["-b", "cs", 55 | "-d", tmrw_date, 56 | "-t", "15:30-17:00"] 57 | expect do 58 | cli.parse_options 59 | end.to raise_error(Ubcbooker::Error::MissingRequired) 60 | end 61 | end 62 | 63 | context "when -b is unsupported department" do 64 | it "raise Ubcbooker::Error::UnsupportedDepartment" do 65 | # Missing -n flag 66 | ARGV = ["-b", "booziogy", 67 | "-n", "test_name", 68 | "-d", tmrw_date, 69 | "-t", "15:30-17:00"] 70 | expect do 71 | cli.parse_options 72 | end.to raise_error(Ubcbooker::Error::UnsupportedDepartment) 73 | end 74 | end 75 | 76 | context "when -n contains profanity" do 77 | it "raise Ubcbooker::Error::ProfaneName" do 78 | # Missing -n flag 79 | ARGV = ["-b", "cs", 80 | "-n", "fucking group work", 81 | "-d", tmrw_date, 82 | "-t", "15:30-17:00"] 83 | expect do 84 | cli.parse_options 85 | end.to raise_error(Ubcbooker::Error::ProfaneName) 86 | end 87 | end 88 | 89 | context "when -d is unsupported date" do 90 | it "raise Ubcbooker::Error::UnsupportedDate" do 91 | # Missing -n flag 92 | ARGV = ["-b", "cs", 93 | "-n", "test_name", 94 | "-d", "99/99", 95 | "-t", "15:30-17:00"] 96 | expect do 97 | cli.parse_options 98 | end.to raise_error(Ubcbooker::Error::UnsupportedDate) 99 | end 100 | end 101 | 102 | context "when -u" do 103 | it "call Ubcbooker::Config#ask" do 104 | # Missing -n flag 105 | ARGV = ["-u"] 106 | cli_config = cli.instance_variable_get(:@config) 107 | expect(cli_config).to receive(:ask) 108 | cli.parse_options 109 | end 110 | end 111 | 112 | context "when -v" do 113 | it "prints version" do 114 | # Missing -n flag 115 | ARGV = ["-v"] 116 | expect { cli.parse_options }.to output(Ubcbooker::VERSION).to_stdout 117 | end 118 | end 119 | end 120 | 121 | describe "#get_department_scraper" do 122 | context "when param is cs" do 123 | it "return CS Scrapper class" do 124 | result = cli.get_department_scraper("cs") 125 | expect(result).to eq(Ubcbooker::Scraper::Cs) 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/ubcbooker/cli.rb: -------------------------------------------------------------------------------- 1 | module Ubcbooker 2 | class CLI 3 | attr_accessor :options 4 | 5 | def initialize 6 | @config = Ubcbooker::Config.new 7 | @options = nil 8 | end 9 | 10 | def parse_options 11 | 12 | # This will hold the options we parse 13 | options = { 14 | name: nil, 15 | date: nil, 16 | time: nil, 17 | department: nil, 18 | } 19 | 20 | # TODO: Change department to building 21 | OptionParser.new do |parser| 22 | parser.on("-b", "--building BUILDING", String, "Specify which department to book rooms from") do |v| 23 | if CLI::Validator.is_valid_department(v) 24 | options[:department] = v 25 | else 26 | raise Ubcbooker::Error::UnsupportedDepartment.new(v) 27 | end 28 | end 29 | 30 | parser.on("-d", "--date DATE", String, "Specify date to book rooms for (MM/DD)") do |v| 31 | if CLI::Validator.is_valid_date(v) 32 | options[:date] = v 33 | else 34 | raise Ubcbooker::Error::UnsupportedDate.new(v) 35 | end 36 | end 37 | 38 | parser.on("-h", "--help", "Show this help message") do 39 | puts parser 40 | puts "\nex. Book a room in CS from 11am to 1pm on March 5th with the name 'Study Group'\n" << 41 | " $ ubcbooker -b cs -n 'Study Group' -d 03/05 -t 11:00-13:00" 42 | exit(0) 43 | end 44 | 45 | parser.on("-l", "--list", "List supported departments") do |v| 46 | @config.print_supported_departments 47 | exit(0) 48 | end 49 | 50 | parser.on("-n", "--name NAME", String, "Name of the booking") do |v| 51 | if CLI::Validator.is_valid_name(v) 52 | options[:name] = v 53 | else 54 | raise Ubcbooker::Error::ProfaneName.new(v) 55 | end 56 | end 57 | parser.on("-t", "--time TIME", String, 58 | "Specify time to book rooms for (HH:MM-HH:MM)") do |v| 59 | if CLI::Validator.is_valid_time(v) 60 | options[:time] = v 61 | else 62 | raise Ubcbooker::Error::UnsupportedTime.new(v) 63 | end 64 | end 65 | 66 | parser.on("-u", "--update", "Update username and password") do |v| 67 | @config.ask 68 | exit(0) 69 | end 70 | 71 | parser.on("-v", "--version", "Show version") do |v| 72 | puts Ubcbooker::VERSION 73 | exit(0) 74 | end 75 | end.parse! 76 | 77 | spinner = get_spinner("Verifying inputs") 78 | spinner.success("Done!") # Stop animation 79 | 80 | if CLI::Validator.is_required_missing(options) 81 | raise Ubcbooker::Error::MissingRequired 82 | end 83 | 84 | return options 85 | end 86 | 87 | def get_options 88 | option_errors = [ 89 | Ubcbooker::Error::UnsupportedDepartment, 90 | Ubcbooker::Error::UnsupportedTime, 91 | Ubcbooker::Error::UnsupportedDate, 92 | Ubcbooker::Error::ProfaneName, 93 | Ubcbooker::Error::MissingRequired, 94 | ] 95 | 96 | begin 97 | return parse_options 98 | rescue *option_errors => e 99 | puts e.message 100 | exit(1) 101 | end 102 | end 103 | 104 | def get_department_scraper(department) 105 | case department 106 | when "cs" 107 | return Ubcbooker::Scraper::Cs 108 | when "sauder_ugrad" 109 | return Ubcbooker::Scraper::SauderUgrad 110 | else 111 | raise Ubcbooker::Error::UnsupportedDepartment.new(department) 112 | end 113 | end 114 | 115 | def get_scraper(department, username, password) 116 | scraper_client = get_department_scraper(department) 117 | return scraper_client.new(username, password) 118 | end 119 | 120 | def get_spinner(text) 121 | spinner = ::TTY::Spinner.new("[:spinner] #{text} ... ", format: :dots) 122 | spinner.auto_spin # Automatic animation with default interval 123 | return spinner 124 | end 125 | 126 | def start 127 | book_errors = [ 128 | Ubcbooker::Error::NoAvailableRoom, 129 | Ubcbooker::Error::BookingFailed, 130 | ] 131 | 132 | @options = get_options 133 | if !@config.defined? || @config.is_windows? 134 | @config.ask 135 | end 136 | 137 | @client = get_scraper(@options[:department], 138 | @config.get_username, 139 | @config.get_password) 140 | begin 141 | room_id = @client.book(@options) 142 | puts "Success! #{room_id} is booked".green 143 | rescue *book_errors => e 144 | puts e.message 145 | exit(1) 146 | end 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /lib/ubcbooker/scrapers/cs.rb: -------------------------------------------------------------------------------- 1 | module Ubcbooker 2 | module Scraper 3 | class Cs < BaseScraper 4 | CS_ROOM_BASE_URL = "https://my.cs.ubc.ca/space/" 5 | CS_BOOK_URL = "https://my.cs.ubc.ca/node/add/pr-booking" 6 | CS_ROOMS = ["X139", "X141", "X151", "X153", # 1st Floor 7 | "X237", "X239", "X241", # 2nd Floor 8 | "X337", "X339", "X341"] # 3rd Floor 9 | 10 | def book(options) 11 | login 12 | title = options[:name] 13 | book_date = Date.parse(options[:date]) 14 | book_slot = get_time_slot(options[:time]) 15 | 16 | room_pages = batch_request(CS_ROOMS, book_date) 17 | room_pages.select! { |r| is_available(r, book_date, book_slot) } 18 | 19 | if room_pages.any? 20 | # TODO: If -c CHOOSE option then run CLI-UI here 21 | room = room_pages.first # Choose the first available room 22 | submit_booking(room, title, book_date, book_slot) 23 | return get_room_page_id(room) 24 | else 25 | raise Ubcbooker::Error::NoAvailableRoom.new(options[:time]) 26 | end 27 | end 28 | 29 | def login 30 | spinner = get_spinner("Logging into CWL") 31 | booking_url = BOOKING_URL[:cs] 32 | @agent.get(booking_url) do |page| 33 | login_page = page.link_with(text: "CWL Login Redirect").click 34 | login_ubc_cwl(login_page) 35 | end 36 | spinner.success("Done!") # Stop animation 37 | end 38 | 39 | def submit_booking(room, title, book_date, book_slot) 40 | spinner = get_spinner("Submitting booking request") 41 | booking_form = @agent.get(CS_BOOK_URL).forms[1] 42 | booking_form["title"] = title 43 | book_date_str = book_date.strftime("%Y/%m/%d") # ex 2018/03/08 44 | select_room_option(booking_form, room) 45 | booking_form["field_date[und][0][value][date]"] = book_date_str 46 | booking_form["field_date[und][0][value][time]"] = time_to_ampm(book_slot.min) 47 | booking_form["field_date[und][0][value2][date]"] = book_date_str 48 | booking_form["field_date[und][0][value2][time]"] = time_to_ampm(book_slot.max) 49 | confirmation_page = booking_form.submit 50 | if confirmation_page.search("div.alert-error").empty? 51 | spinner.success("Done!") 52 | else 53 | spinner.fail("Booking rejected") 54 | raise Ubcbooker::Error::BookingFailed.new 55 | end 56 | end 57 | 58 | # Select the form otpion with right room id 59 | def select_room_option(booking_form, page) 60 | room_id = get_room_page_id(page) 61 | select_options = booking_form.field_with(name: "field_space_project_room[und]").options 62 | select_options.each do |o| 63 | if o.text.include?(room_id) 64 | o.click 65 | end 66 | end 67 | end 68 | 69 | def get_room_page_id(page) 70 | return page.form.action.split("/").last 71 | end 72 | 73 | def batch_request(room_list, book_date) 74 | cookie = @agent.cookies.join("; ") 75 | spinner = get_spinner("Checking room availabilities") 76 | 77 | hydra = Typhoeus::Hydra.new 78 | requests = room_list.map do |room_id| 79 | room_url = get_room_cal_url(book_date, room_id) 80 | request = Typhoeus::Request.new(room_url, headers: { Cookie: cookie }) 81 | hydra.queue(request) 82 | request 83 | end 84 | hydra.run # Start requests 85 | spinner.success("Done!") 86 | return typhoeus_to_mechanize(requests) 87 | end 88 | 89 | def get_room_cal_url(book_date, room_id) 90 | if CS_ROOMS.include?(room_id) 91 | room_url = CS_ROOM_BASE_URL + "ICCS" + room_id 92 | if is_next_month(book_date) 93 | today = Date.today 94 | month_query = "?month=" + today.year + "-" + (today.month + 1) 95 | return room_url + month_query 96 | else 97 | return room_url 98 | end 99 | end 100 | end 101 | 102 | # Turn typhoneus obj to mechanize page obj 103 | def typhoeus_to_mechanize(requests) 104 | pages = requests.map do |request| 105 | html = request.response.body 106 | Mechanize::Page.new(nil, {"ontent-type" => "text/html"}, html, nil, @agent) 107 | end 108 | return pages 109 | end 110 | 111 | def is_next_month(date) 112 | return date.month != Date.today.month 113 | end 114 | 115 | def is_available(room_page, book_date, book_slot) 116 | slot_booked = get_slot_booked(room_page, book_date) 117 | return !is_slot_booked(slot_booked, book_slot) 118 | end 119 | 120 | def is_slot_booked(slot_booked, book_slot) 121 | booked = false 122 | slot_booked.each do |s| 123 | if (s.include?(book_slot.min) || 124 | s.include?(book_slot.max) || 125 | book_slot.include?(s)) 126 | booked = true 127 | end 128 | end 129 | return booked 130 | end 131 | 132 | def get_slot_booked(room_page, book_date) 133 | day_div_id = get_date_div_id(book_date) 134 | day_div = room_page.search("td##{day_div_id}").first 135 | slot_num = day_div.search("div.item").size 136 | start_times = day_div.search("span.date-display-start") 137 | end_times = day_div.search("span.date-display-end") 138 | 139 | slot_booked = [] 140 | (0..slot_num-1).each do |i| 141 | slot_start = ampm_to_time(start_times[i].text) # 01:00 142 | slot_end = ampm_to_time(end_times[i].text) # 05:00 143 | slot_booked.push((slot_start..slot_end)) 144 | end 145 | return slot_booked 146 | end 147 | 148 | def get_date_div_id(date) 149 | date_div_base = "calendar_space_entity_project_room-" 150 | date_div_base += "#{date.year}-#{date.strftime("%m")}-#{date.strftime("%d")}-0" 151 | return date_div_base 152 | end 153 | 154 | def get_spinner(text) 155 | spinner = ::TTY::Spinner.new("[:spinner] #{text} ... ", format: :dots) 156 | spinner.auto_spin # Automatic animation with default interval 157 | return spinner 158 | end 159 | 160 | # ========================= 161 | # Time operations 162 | 163 | # Expect HH:MM-HH:MM 164 | def get_time_slot(time_str) 165 | times = time_str.split("-") 166 | return (times[0]..times[1]) 167 | end 168 | 169 | # 05:00pm -> 17:99 170 | def ampm_to_time(str) 171 | time = Time.parse(str) 172 | return time.strftime("%H:%M") 173 | end 174 | 175 | # 17:00 -> 05:00pm 176 | def time_to_ampm(str) 177 | time = Time.parse(str) 178 | return time.strftime("%I:%M%P") 179 | end 180 | end 181 | end 182 | end 183 | --------------------------------------------------------------------------------