├── .ruby-version ├── Procfile ├── .gitignore ├── .github ├── FUNDING.yml └── workflows │ ├── docker-publish.yml │ └── ruby.yml ├── spec ├── fixtures │ ├── config.yml │ ├── config-with-env.yml │ ├── filtered_calendar_without_alarm.ics │ ├── filtered_calendar_with_new_alarm.ics │ ├── filtered_calendar_with_original_alarm.ics │ └── original_calendar.ics ├── spec_helper.rb ├── alarm_trigger_spec.rb ├── ical_filter_proxy_spec.rb ├── calendar_builder_spec.rb ├── calendar_spec.rb ├── filterable_event_adapter_spec.rb └── filter_rule_spec.rb ├── .dockerignore ├── config.ru ├── api └── index.rb ├── lambda.rb ├── config.yml.example ├── Gemfile ├── Dockerfile ├── Rakefile ├── lib ├── ical_filter_proxy │ ├── alarm_trigger.rb │ ├── servers │ │ ├── rack_app.rb │ │ ├── lambda_app.rb │ │ └── vercel_app.rb │ ├── filter_rule.rb │ ├── calendar_builder.rb │ ├── calendar.rb │ └── filterable_event_adapter.rb └── ical_filter_proxy.rb ├── LICENSE ├── Gemfile.lock └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.7 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec rackup -p 8082 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /config.yml 2 | ical-filter-proxy.zip 3 | .idea/ 4 | .vercel 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: darkphnx 4 | -------------------------------------------------------------------------------- /spec/fixtures/config.yml: -------------------------------------------------------------------------------- 1 | rota: 2 | ical_url: https://url-to-calendar.ical 3 | api_key: abc12 4 | rules: 5 | - field: start_time 6 | operator: equals 7 | val: '09:00' 8 | -------------------------------------------------------------------------------- /spec/fixtures/config-with-env.yml: -------------------------------------------------------------------------------- 1 | rota: 2 | ical_url: https://url-to-calendar.ical 3 | api_key: ${ICAL_FILTER_PROXY_API_KEY} 4 | rules: 5 | - field: start_time 6 | operator: equals 7 | val: '09:00' 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | * 3 | 4 | # Allow files and directories 5 | !/Gemfile 6 | !/Gemfile.lock 7 | !/config.ru 8 | !/lib 9 | 10 | # Ignore unnecessary files inside allowed directories 11 | **/lambda_app.rb -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | require_relative 'lib/ical_filter_proxy' 3 | require_relative 'lib/ical_filter_proxy/servers/rack_app' 4 | 5 | calendars = IcalFilterProxy.calendars 6 | app = IcalFilterProxy::Servers::RackApp.new(calendars) 7 | 8 | run app 9 | -------------------------------------------------------------------------------- /api/index.rb: -------------------------------------------------------------------------------- 1 | require_relative '../lib/ical_filter_proxy' 2 | require_relative '../lib/ical_filter_proxy/servers/vercel_app' 3 | 4 | Handler = Proc.new do |request, response| 5 | calendars = IcalFilterProxy.calendars 6 | app = IcalFilterProxy::Servers::VercelApp.new(calendars) 7 | 8 | app.call(request, response) 9 | end 10 | -------------------------------------------------------------------------------- /lambda.rb: -------------------------------------------------------------------------------- 1 | require_relative 'lib/ical_filter_proxy' 2 | require_relative 'lib/ical_filter_proxy/servers/lambda_app' 3 | 4 | # Entry point for AWS Lambda 5 | 6 | def handle(event:, context:) 7 | calendars = IcalFilterProxy.calendars 8 | app = IcalFilterProxy::Servers::LambdaApp.new(calendars) 9 | 10 | app.call(event) 11 | end 12 | -------------------------------------------------------------------------------- /config.yml.example: -------------------------------------------------------------------------------- 1 | rota: 2 | ical_url: https://url-to-calendar.ical 3 | api_key: abc12 4 | rules: 5 | - field: start_time 6 | operator: equals 7 | val: '09:00' 8 | - field: blocking 9 | operator: equals 10 | val: true 11 | alarms: 12 | clear_existing: true 13 | triggers: 14 | - '-P1DT0H0M0S' 15 | - '-P1DT1H1M2S' -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'rspec_junit_formatter' 3 | require 'webmock/rspec' 4 | require 'ical_filter_proxy' 5 | 6 | RSpec.configure do |config| 7 | # Use color in STDOUT 8 | config.color = true 9 | 10 | # Use the specified formatter 11 | config.formatter = :documentation 12 | end 13 | 14 | WebMock.disable_net_connect!(allow_localhost: true) 15 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'activesupport' # Used for it's handy timezone support 4 | gem 'icalendar' 5 | gem 'rake' 6 | gem 'rack', require: false 7 | gem 'rackup', require: false 8 | gem 'puma', require: false 9 | 10 | gem 'rspec', require: false 11 | gem 'rspec_junit_formatter', require: false 12 | gem 'webmock', require: false 13 | gem 'to_regexp', require: false 14 | -------------------------------------------------------------------------------- /spec/fixtures/filtered_calendar_without_alarm.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:icalendar-ruby 4 | CALSCALE:GREGORIAN 5 | BEGIN:VEVENT 6 | DTSTAMP:20170628T174807Z 7 | UID:6fe68d96-c84c-42d6-8fb7-60c70b6eea68 8 | DTSTART:20170628T100000 9 | DTEND:20170628T110000 10 | DESCRIPTION:This event runs from 10am to 11am 11 | SUMMARY:10am Test Event 12 | END:VEVENT 13 | END:VCALENDAR 14 | -------------------------------------------------------------------------------- /spec/fixtures/filtered_calendar_with_new_alarm.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:icalendar-ruby 4 | CALSCALE:GREGORIAN 5 | BEGIN:VEVENT 6 | DTSTAMP:20170628T174807Z 7 | UID:6fe68d96-c84c-42d6-8fb7-60c70b6eea68 8 | DTSTART:20170628T100000 9 | DTEND:20170628T110000 10 | DESCRIPTION:This event runs from 10am to 11am 11 | SUMMARY:10am Test Event 12 | BEGIN:VALARM 13 | ACTION:DISPLAY 14 | TRIGGER:-P1D 15 | DESCRIPTION:10am Test Event 16 | END:VALARM 17 | END:VEVENT 18 | END:VCALENDAR 19 | -------------------------------------------------------------------------------- /spec/fixtures/filtered_calendar_with_original_alarm.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:icalendar-ruby 4 | CALSCALE:GREGORIAN 5 | BEGIN:VEVENT 6 | DTSTAMP:20170628T174807Z 7 | UID:6fe68d96-c84c-42d6-8fb7-60c70b6eea68 8 | DTSTART:20170628T100000 9 | DTEND:20170628T110000 10 | DESCRIPTION:This event runs from 10am to 11am 11 | SUMMARY:10am Test Event 12 | BEGIN:VALARM 13 | ACTION:DISPLAY 14 | TRIGGER:-P1D 15 | DESCRIPTION:This alarm is one day before 16 | END:VALARM 17 | END:VEVENT 18 | END:VCALENDAR 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.4-alpine AS base 2 | RUN apk add --update tzdata 3 | 4 | FROM base AS dependencies 5 | RUN apk add --update build-base 6 | COPY Gemfile Gemfile.lock ./ 7 | RUN bundle config --global frozen 1 && \ 8 | bundle install --jobs=3 --retry=3 9 | 10 | FROM base 11 | RUN adduser -D app 12 | USER app 13 | WORKDIR /app 14 | COPY --from=dependencies /usr/local/bundle/ /usr/local/bundle/ 15 | COPY --chown=app . ./ 16 | 17 | EXPOSE 8000 18 | CMD ["/usr/local/bin/bundle", "exec", "rackup", "--host=0.0.0.0", "--port=8000"] 19 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | 8 | namespace :lambda do 9 | task :build do 10 | abort("Please add a config.yml before continuing") unless File.exist?(File.expand_path('config.yml', __dir__)) 11 | 12 | STDOUT.puts "Fetching dependencies" 13 | `bundle install --deployment` 14 | 15 | STDOUT.puts "Creating archive" 16 | `cd #{File.expand_path(__dir__)} && zip -r ical-filter-proxy.zip *` 17 | 18 | STDOUT.puts "Cleaning up" 19 | `rm -rf #{File.expand_path('vendor', __dir__)}` 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/ical_filter_proxy/alarm_trigger.rb: -------------------------------------------------------------------------------- 1 | module IcalFilterProxy 2 | class AlarmTrigger 3 | 4 | attr_accessor :alarm_trigger 5 | 6 | def initialize(input_trigger) 7 | self.alarm_trigger = generate_iso_format(input_trigger) 8 | end 9 | 10 | private 11 | 12 | def generate_iso_format(alarm_trigger) 13 | case alarm_trigger 14 | when /^(\d+)\s+days?$/i 15 | "-P#{$1}D" 16 | when /^(\d+)\s+hours?$/i 17 | "-PT#{$1}H" 18 | when /^(\d+)\s+minutes?$/i 19 | "-PT#{$1}M" 20 | when /^-P.*/ 21 | alarm_trigger 22 | else 23 | raise "Unknown trigger pattern: #{alarm_trigger}" 24 | end 25 | end 26 | 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/fixtures/original_calendar.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:icalendar-ruby 4 | CALSCALE:GREGORIAN 5 | BEGIN:VEVENT 6 | DTSTAMP:20170628T174746Z 7 | UID:933ce600-854a-447b-ac19-f4da925a9d2c 8 | DTSTART:20170628T090000 9 | DTEND:20170628T150000 10 | DESCRIPTION:This event runs from 9am to 3pm 11 | SUMMARY:9am Test Event 12 | END:VEVENT 13 | BEGIN:VEVENT 14 | DTSTAMP:20170628T174807Z 15 | UID:6fe68d96-c84c-42d6-8fb7-60c70b6eea68 16 | DTSTART:20170628T100000 17 | DTEND:20170628T110000 18 | DESCRIPTION:This event runs from 10am to 11am 19 | SUMMARY:10am Test Event 20 | BEGIN:VALARM 21 | ACTION:DISPLAY 22 | TRIGGER:-P1D 23 | DESCRIPTION:This alarm is one day before 24 | END:VALARM 25 | END:VEVENT 26 | END:VCALENDAR 27 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker Publish 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | 7 | jobs: 8 | docker: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v5 13 | 14 | - name: Login to Docker Hub 15 | uses: docker/login-action@v3 16 | with: 17 | username: ${{ vars.DOCKERHUB_USERNAME }} 18 | password: ${{ secrets.DOCKERHUB_TOKEN }} 19 | 20 | - name: Set up QEMU 21 | uses: docker/setup-qemu-action@v3 22 | 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v3 25 | 26 | - name: Build and push 27 | uses: docker/build-push-action@v6 28 | with: 29 | context: . 30 | push: true 31 | tags: darkphnx/ical-filter-proxy:latest 32 | platforms: linux/amd64,linux/arm64 33 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Ruby 9 | 10 | on: 11 | push: 12 | branches: [ "master" ] 13 | pull_request: 14 | branches: [ "master" ] 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | test: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Set up Ruby 25 | uses: ruby/setup-ruby@v1 26 | with: 27 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 28 | - name: Run tests 29 | run: bundle exec rake 30 | -------------------------------------------------------------------------------- /lib/ical_filter_proxy.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | 4 | require 'rack' 5 | require 'open-uri' 6 | require 'icalendar' 7 | require 'yaml' 8 | require 'forwardable' 9 | require 'to_regexp' 10 | 11 | require_relative 'ical_filter_proxy/alarm_trigger' 12 | require_relative 'ical_filter_proxy/calendar' 13 | require_relative 'ical_filter_proxy/filter_rule' 14 | require_relative 'ical_filter_proxy/calendar_builder' 15 | require_relative 'ical_filter_proxy/filterable_event_adapter' 16 | 17 | module IcalFilterProxy 18 | def self.calendars 19 | config.transform_values do |calendar_config| 20 | CalendarBuilder.new(calendar_config).build 21 | end 22 | end 23 | 24 | def self.config 25 | content = File.read(config_file_path, :encoding => 'UTF-8') 26 | content.gsub! /\${(ICAL_FILTER_PROXY_[^}]+)}/ do 27 | ENV[$1] 28 | end 29 | YAML.safe_load(content) 30 | end 31 | 32 | def self.config_file_path 33 | File.expand_path('../config.yml', __dir__) 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /lib/ical_filter_proxy/servers/rack_app.rb: -------------------------------------------------------------------------------- 1 | module IcalFilterProxy 2 | module Servers 3 | class RackApp 4 | attr_accessor :calendars 5 | 6 | def initialize(calendars) 7 | self.calendars = calendars 8 | end 9 | 10 | def call(env) 11 | request = Rack::Request.new(env) 12 | 13 | requested_calendar = request.path_info.sub(/^\//, '') 14 | 15 | if requested_calendar.strip.empty? 16 | return [200, { 'content-type' => 'text/plain' }, ['Welcome to ical-filter-proxy']] 17 | end 18 | 19 | ical_calendar = calendars[requested_calendar] 20 | 21 | if ical_calendar 22 | if request.params['key'] == ical_calendar.api_key 23 | [200, { 'content-type' => 'text/calendar' }, [ical_calendar.filtered_calendar]] 24 | else 25 | [403, { 'content-type' => 'text/plain' }, ['Authentication Incorrect']] 26 | end 27 | else 28 | [404, { 'content-type' => 'text/plain' }, ['Calendar not found']] 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Dan Wentworth 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/ical_filter_proxy/filter_rule.rb: -------------------------------------------------------------------------------- 1 | module IcalFilterProxy 2 | class FilterRule 3 | 4 | attr_accessor :field, :operator, :values, :negation 5 | 6 | def initialize(field, operator, values) 7 | self.field = field 8 | operator =~ /^(not-)?(\w+)/ 9 | self.negation = !$1.nil? 10 | self.operator = $2 11 | self.values = values 12 | end 13 | 14 | def match_event?(filterable_event) 15 | event_data = filterable_event.send(field.to_sym) 16 | negation ^ evaluate(event_data, values) 17 | end 18 | 19 | private 20 | 21 | def evaluate(event_data, value) 22 | if value.is_a? Array 23 | value.reduce(false) { |r, v| r |= evaluate(event_data, v) } 24 | else 25 | case operator 26 | when 'equals' 27 | event_data == value 28 | when 'startswith' 29 | event_data.start_with?(value) 30 | when 'includes' 31 | event_data.include?(value) 32 | when 'matches' 33 | event_data =~ value.to_regexp 34 | else 35 | raise "Unknown filter rule: " + operator 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/ical_filter_proxy/servers/lambda_app.rb: -------------------------------------------------------------------------------- 1 | module IcalFilterProxy 2 | module Servers 3 | class LambdaApp 4 | attr_accessor :calendars 5 | 6 | def initialize(calendars) 7 | self.calendars = calendars 8 | end 9 | 10 | def call(event) 11 | return render_not_found unless event['queryStringParameters'] 12 | 13 | calendar_name = event['queryStringParameters']['calendar'] 14 | ical_calendar = calendars[calendar_name] 15 | 16 | if ical_calendar 17 | if event['queryStringParameters']['key'] == ical_calendar.api_key 18 | render_calendar(ical_calendar) 19 | else 20 | render_forbidden 21 | end 22 | else 23 | render_not_found 24 | end 25 | end 26 | 27 | private 28 | 29 | def render_calendar(calendar) 30 | { statusCode: 200, headers: { 'content-type' => 'text/calendar' }, body: calendar.filtered_calendar } 31 | end 32 | 33 | def render_not_found 34 | { statusCode: 404, headers: { 'content-type' => 'text/plain' }, body: 'Calendar not found' } 35 | end 36 | 37 | def render_forbidden 38 | { statusCode: 403, headers: { 'content-type' => 'text/plain' }, body: "Authentication incorrect" } 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/ical_filter_proxy/calendar_builder.rb: -------------------------------------------------------------------------------- 1 | module IcalFilterProxy 2 | class CalendarBuilder 3 | 4 | attr_reader :calendar_config, :calendar 5 | 6 | def initialize(calendar_config) 7 | @calendar_config = calendar_config 8 | end 9 | 10 | def build 11 | create_calendar 12 | add_rules 13 | add_alarms 14 | 15 | calendar 16 | end 17 | 18 | private 19 | 20 | def create_calendar 21 | @calendar = Calendar.new(calendar_config["ical_url"], 22 | calendar_config["api_key"], 23 | calendar_config["timezone"]) 24 | end 25 | 26 | def add_rules 27 | rules = calendar_config["rules"] 28 | return unless rules 29 | 30 | rules.each do |rule| 31 | calendar.add_rule(rule["field"], 32 | rule["operator"], 33 | rule["val"]) 34 | end 35 | end 36 | 37 | def add_alarms 38 | alarms = calendar_config["alarms"] 39 | return unless alarms 40 | 41 | calendar.clear_existing_alarms = true if alarms['clear_existing'] 42 | 43 | triggers = alarms["triggers"] 44 | return unless triggers 45 | 46 | triggers.each do |trigger| 47 | calendar.add_alarm_trigger(trigger) 48 | end 49 | end 50 | 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/ical_filter_proxy/servers/vercel_app.rb: -------------------------------------------------------------------------------- 1 | module IcalFilterProxy 2 | module Servers 3 | class VercelApp 4 | attr_accessor :calendars 5 | 6 | def initialize(calendars) 7 | self.calendars = calendars 8 | end 9 | 10 | def call(request, response) 11 | calendar_name = request.query['calendar'] 12 | 13 | return render_not_found(response) unless calendar_name 14 | 15 | ical_calendar = calendars[calendar_name] 16 | 17 | if ical_calendar 18 | if request.query['key'] == ical_calendar.api_key 19 | render_calendar(ical_calendar, response) 20 | else 21 | render_forbidden(response) 22 | end 23 | else 24 | render_not_found(response) 25 | end 26 | end 27 | 28 | private 29 | 30 | def render_calendar(calendar, response) 31 | response.status = 200 32 | response['Content-Type'] = 'text/calendar' 33 | response.body = calendar.filtered_calendar 34 | end 35 | 36 | def render_not_found(response) 37 | response.status = 404 38 | response['Content-Type'] = 'text/plain' 39 | response.body = 'Calendar not found' 40 | end 41 | 42 | def render_forbidden(response) 43 | response.status = 403 44 | response['Content-Type'] = 'text/plain' 45 | response.body = 'Authentication incorrect' 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/alarm_trigger_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe IcalFilterProxy::AlarmTrigger do 4 | describe '.new' do 5 | it 'accepts valid iso trigger' do 6 | alarm_trigger = described_class.new("-P1DT1H1M2S") 7 | 8 | expect(alarm_trigger).to be_a(described_class) 9 | expect(alarm_trigger.alarm_trigger).to eq("-P1DT1H1M2S") 10 | end 11 | 12 | it 'accepts valid day trigger' do 13 | alarm_trigger = described_class.new("10 days") 14 | 15 | expect(alarm_trigger).to be_a(described_class) 16 | expect(alarm_trigger.alarm_trigger).to eq("-P10D") 17 | end 18 | 19 | it 'accepts valid hour trigger' do 20 | alarm_trigger = described_class.new("5 hours") 21 | 22 | expect(alarm_trigger).to be_a(described_class) 23 | expect(alarm_trigger.alarm_trigger).to eq("-PT5H") 24 | end 25 | 26 | it 'accepts valid minute trigger' do 27 | alarm_trigger = described_class.new("10 minutes") 28 | 29 | expect(alarm_trigger).to be_a(described_class) 30 | expect(alarm_trigger.alarm_trigger).to eq("-PT10M") 31 | end 32 | 33 | it 'fails on invalid triggers' do 34 | lambda { described_class.new("lorem ipsum") }.should raise_error 35 | lambda { described_class.new("-10 days") }.should raise_error 36 | lambda { described_class.new("PT10M") }.should raise_error 37 | lambda { described_class.new("5 days 10 minutes") }.should raise_error 38 | end 39 | 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/ical_filter_proxy/calendar.rb: -------------------------------------------------------------------------------- 1 | module IcalFilterProxy 2 | class Calendar 3 | attr_accessor :ical_url, :api_key, :timezone, :filter_rules, :clear_existing_alarms, :alarm_triggers 4 | 5 | def initialize(ical_url, api_key, timezone = 'UTC') 6 | self.ical_url = ical_url 7 | self.api_key = api_key 8 | self.timezone = timezone 9 | 10 | self.filter_rules = [] 11 | self.clear_existing_alarms = false 12 | self.alarm_triggers = [] 13 | end 14 | 15 | def add_rule(field, operator, value) 16 | self.filter_rules << FilterRule.new(field, operator, value) 17 | end 18 | 19 | def add_alarm_trigger(alarm_trigger) 20 | self.alarm_triggers << AlarmTrigger.new(alarm_trigger) 21 | end 22 | 23 | def filtered_calendar 24 | filtered_calendar = Icalendar::Calendar.new 25 | 26 | filtered_events.each do |original_event| 27 | filtered_calendar.add_event(original_event) 28 | end 29 | 30 | filtered_calendar.events.select do |e| 31 | e.alarms.clear if clear_existing_alarms 32 | alarm_triggers.each do |t| 33 | e.alarm do |a| 34 | a.action = "DISPLAY" 35 | a.description = e.summary 36 | a.trigger = t.alarm_trigger 37 | end 38 | end 39 | end 40 | 41 | filtered_calendar.to_ical 42 | end 43 | 44 | private 45 | 46 | def filtered_events 47 | original_ics.events.select do |e| 48 | filter_match?(FilterableEventAdapter.new(e, timezone: timezone)) 49 | end 50 | end 51 | 52 | def filter_match?(event) 53 | filter_rules.empty? || filter_rules.all? { |rule| rule.match_event?(event) } 54 | end 55 | 56 | def original_ics 57 | Icalendar::Calendar.parse(raw_original_ical).first 58 | end 59 | 60 | def raw_original_ical 61 | URI.open(ical_url).read 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/ical_filter_proxy_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe IcalFilterProxy do 4 | describe '.config_file_path' do 5 | it { expect(described_class.config_file_path).to eq(File.expand_path('../config.yml', __dir__)) } 6 | end 7 | 8 | describe '.config' do 9 | 10 | it 'parses the YAML file at config_file_path' do 11 | expect(IcalFilterProxy).to receive(:config_file_path).and_return File.expand_path('./fixtures/config.yml', __dir__) 12 | expect(described_class.config).to eq example_config 13 | end 14 | 15 | it 'parses the YAML file at config_file_path and replaces environment variables' do 16 | ENV['ICAL_FILTER_PROXY_API_KEY'] = "abc12" 17 | expect(IcalFilterProxy).to receive(:config_file_path).and_return File.expand_path('./fixtures/config-with-env.yml', __dir__) 18 | expect(described_class.config).to eq example_config 19 | end 20 | end 21 | 22 | describe '.calendars' do 23 | before do 24 | expect(IcalFilterProxy).to receive(:config).and_return(example_config) 25 | end 26 | 27 | let(:calendars) { described_class.calendars } 28 | 29 | it "has an entry for each calendar in the config file" do 30 | expect(calendars).to have_key('rota') 31 | end 32 | 33 | it 'calls CalendarBuilder#build for each entry and stores the return' do 34 | calendar_builder = instance_double(IcalFilterProxy::CalendarBuilder) 35 | expect(IcalFilterProxy::CalendarBuilder) 36 | .to receive(:new) 37 | .with(example_config['rota']) 38 | .and_return(calendar_builder) 39 | 40 | calendar = instance_double(IcalFilterProxy::CalendarBuilder) 41 | expect(calendar_builder) 42 | .to receive(:build) 43 | .and_return(calendar) 44 | 45 | expect(calendars['rota']).to eq(calendar) 46 | end 47 | end 48 | 49 | def example_config 50 | { 51 | 'rota' => { 52 | 'ical_url' => 'https://url-to-calendar.ical', 53 | 'api_key' => 'abc12', 54 | 'rules' => [ 55 | { 'field' => 'start_time', 'operator' => 'equals', 'val' => '09:00' } 56 | ] 57 | } 58 | } 59 | end 60 | 61 | end 62 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (8.1.1) 5 | base64 6 | bigdecimal 7 | concurrent-ruby (~> 1.0, >= 1.3.1) 8 | connection_pool (>= 2.2.5) 9 | drb 10 | i18n (>= 1.6, < 2) 11 | json 12 | logger (>= 1.4.2) 13 | minitest (>= 5.1) 14 | securerandom (>= 0.3) 15 | tzinfo (~> 2.0, >= 2.0.5) 16 | uri (>= 0.13.1) 17 | addressable (2.8.8) 18 | public_suffix (>= 2.0.2, < 8.0) 19 | base64 (0.3.0) 20 | bigdecimal (3.3.1) 21 | concurrent-ruby (1.3.5) 22 | connection_pool (3.0.2) 23 | crack (1.0.1) 24 | bigdecimal 25 | rexml 26 | diff-lcs (1.6.2) 27 | drb (2.2.3) 28 | hashdiff (1.2.1) 29 | i18n (1.14.7) 30 | concurrent-ruby (~> 1.0) 31 | icalendar (2.12.1) 32 | base64 33 | ice_cube (~> 0.16) 34 | logger 35 | ostruct 36 | ice_cube (0.17.0) 37 | json (2.17.1) 38 | logger (1.7.0) 39 | minitest (5.26.2) 40 | nio4r (2.7.4) 41 | ostruct (0.6.3) 42 | public_suffix (7.0.0) 43 | puma (6.5.0) 44 | nio4r (~> 2.0) 45 | rack (3.2.4) 46 | rackup (2.3.1) 47 | rack (>= 3) 48 | rake (13.3.1) 49 | rexml (3.4.4) 50 | rspec (3.13.2) 51 | rspec-core (~> 3.13.0) 52 | rspec-expectations (~> 3.13.0) 53 | rspec-mocks (~> 3.13.0) 54 | rspec-core (3.13.6) 55 | rspec-support (~> 3.13.0) 56 | rspec-expectations (3.13.5) 57 | diff-lcs (>= 1.2.0, < 2.0) 58 | rspec-support (~> 3.13.0) 59 | rspec-mocks (3.13.7) 60 | diff-lcs (>= 1.2.0, < 2.0) 61 | rspec-support (~> 3.13.0) 62 | rspec-support (3.13.6) 63 | rspec_junit_formatter (0.6.0) 64 | rspec-core (>= 2, < 4, != 2.12.0) 65 | securerandom (0.4.1) 66 | to_regexp (0.2.1) 67 | tzinfo (2.0.6) 68 | concurrent-ruby (~> 1.0) 69 | uri (1.1.1) 70 | webmock (3.26.1) 71 | addressable (>= 2.8.0) 72 | crack (>= 0.3.2) 73 | hashdiff (>= 0.4.0, < 2.0.0) 74 | 75 | PLATFORMS 76 | ruby 77 | 78 | DEPENDENCIES 79 | activesupport 80 | icalendar 81 | puma 82 | rack 83 | rackup 84 | rake 85 | rspec 86 | rspec_junit_formatter 87 | to_regexp 88 | webmock 89 | 90 | BUNDLED WITH 91 | 2.6.9 92 | -------------------------------------------------------------------------------- /lib/ical_filter_proxy/filterable_event_adapter.rb: -------------------------------------------------------------------------------- 1 | module IcalFilterProxy 2 | # Wraps an Icalendar::Event and exposes filterable properties such as start_time or end_time. 3 | class FilterableEventAdapter 4 | attr_reader :raw_event, :options 5 | 6 | extend Forwardable 7 | def_delegators :raw_event, :dtstart, :dtend, :summary, :description 8 | 9 | def initialize(raw_event, options = {}) 10 | @raw_event = raw_event 11 | @options = { timezone: 'UTC' }.merge(options) 12 | end 13 | 14 | # Wraps a DateTime and exposes filterable parts of that time stamp. Avoids writing methods for every dtstart and 15 | # dtend component that might be queried. 16 | class DateComponents 17 | TIME_FORMAT = "%H:%M".freeze 18 | DATE_FORMAT = "%Y-%m-%d".freeze 19 | 20 | attr_reader :timestamp 21 | 22 | def initialize(timestamp, timezone) 23 | @timestamp = timestamp.in_time_zone(timezone) 24 | end 25 | 26 | # @return [String] time component only in the format "HH:MM" 27 | def time 28 | @time ||= timestamp.strftime(TIME_FORMAT) 29 | end 30 | 31 | # @return [String] time component only in the format "YYYY-MM-DD" 32 | def date 33 | @date ||= timestamp.strftime(DATE_FORMAT) 34 | end 35 | end 36 | 37 | # @private We need to use send from method_missing to obtain this, so we can't use private, but this isn't for you 38 | def start_components 39 | @start_components ||= DateComponents.new(dtstart, options[:timezone]) 40 | end 41 | 42 | # @private We need to use send from method_missing to obtain this, so we can't use private, but this isn't for you 43 | def end_components 44 | @end_components ||= DateComponents.new(dtend, options[:timezone]) 45 | end 46 | 47 | # extract and rename TRANSP field to something more obvious 48 | def blocking 49 | raw_event.transp == 'OPAQUE' || raw_event.transp.nil? 50 | end 51 | 52 | def method_missing(method_sym, *args, &block) 53 | if method_sym.to_s =~ /(start|end)\_(\w+)/ 54 | components = self.send("#{$1}_components") 55 | components.send($2) 56 | else 57 | super 58 | end 59 | end 60 | 61 | def respond_to_missing?(method_sym, include_private = false) 62 | if method_sym.to_s =~ /(start|end)\_(\w+)/ 63 | DateComponents.method_defined?($2.to_sym) 64 | else 65 | super 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/calendar_builder_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe IcalFilterProxy::CalendarBuilder do 6 | 7 | subject { described_class.new(example_config) } 8 | 9 | let(:example_config) do 10 | { 11 | 'ical_url' => 'https://url-to-calendar.ical', 12 | 'api_key' => 'abc12', 13 | 'rules' => [ 14 | { 'field' => 'start_time', 'operator' => 'equals', 'val' => '09:00' } 15 | ], 16 | 'alarms' => { 17 | 'clear_existing' => true, 18 | 'triggers'=> [ '10 days' ] 19 | } 20 | } 21 | end 22 | 23 | describe '#build' do 24 | let(:calendar) { subject.build } 25 | 26 | it 'builds a Calendar' do 27 | expect(calendar).to be_a(IcalFilterProxy::Calendar) 28 | end 29 | 30 | it 'adds ical_url to the Calendar object' do 31 | expect(calendar.ical_url).to eq('https://url-to-calendar.ical') 32 | end 33 | 34 | it 'adds api_key to the Calenar object' do 35 | expect(calendar.api_key).to eq('abc12') 36 | end 37 | 38 | it 'adds filter rules to the Calendar object' do 39 | filter_rule = calendar.filter_rules.first 40 | 41 | expect(filter_rule).to be_a(IcalFilterProxy::FilterRule) 42 | expect(filter_rule.field).to eq('start_time') 43 | expect(filter_rule.operator).to eq('equals') 44 | expect(filter_rule.values).to eq('09:00') 45 | end 46 | 47 | it 'sets clear alarms flag on the Calendar object' do 48 | expect(calendar.clear_existing_alarms).to eq(true) 49 | end 50 | 51 | it 'adds alarm triggers to the Calendar object' do 52 | alarm_trigger = calendar.alarm_triggers.first 53 | 54 | expect(alarm_trigger).to be_a(IcalFilterProxy::AlarmTrigger) 55 | expect(alarm_trigger.alarm_trigger).to eq('-P10D') 56 | end 57 | 58 | context 'when no filter rules are present' do 59 | let(:example_config) do 60 | { 61 | 'ical_url' => 'https://url-to-calendar.ical', 62 | 'api_key' => 'abc12', 63 | } 64 | end 65 | 66 | it 'does not attempt to add any rules' do 67 | expect(calendar.filter_rules).to be_empty 68 | end 69 | 70 | end 71 | 72 | context 'when no alarms are present' do 73 | let(:example_config) do 74 | { 75 | 'ical_url' => 'https://url-to-calendar.ical', 76 | 'api_key' => 'abc12', 77 | } 78 | end 79 | 80 | it 'does not attempt to add any alarm' do 81 | expect(calendar.alarm_triggers).to be_empty 82 | end 83 | 84 | it 'does not set clear alarms flag' do 85 | expect(calendar.clear_existing_alarms).to eq(false) 86 | end 87 | 88 | end 89 | end 90 | 91 | end 92 | -------------------------------------------------------------------------------- /spec/calendar_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe IcalFilterProxy::Calendar do 4 | let(:url) { 'http://example.com/ical.ics' } 5 | let(:api_key) { 'abc123' } 6 | let(:cal) { described_class.new(url, api_key) } 7 | 8 | describe '.new' do 9 | it 'accepts a URL as its first arg' do 10 | expect(cal.ical_url).to eq(url) 11 | end 12 | 13 | it 'accepts an API key as it\'s second arg' do 14 | expect(cal.api_key).to eq(api_key) 15 | end 16 | 17 | it 'sets UTC as the default timezone' do 18 | expect(cal.timezone).to eq('UTC') 19 | end 20 | 21 | it 'optionally accepts a timezone as its third arg' do 22 | tz = 'Europe/London' 23 | cal = described_class.new(url, api_key, tz) 24 | 25 | expect(cal.timezone).to eq(tz) 26 | end 27 | 28 | it 'starts with an empty array of filter rules' do 29 | expect(cal.filter_rules.length).to eq(0) 30 | end 31 | 32 | describe '#add_rule' do 33 | it 'adds a new rule to the filter_rules array' do 34 | cal.add_rule('start_time', 'equals', '09:00') 35 | 36 | expect(cal.filter_rules.length).to eq(1) 37 | end 38 | end 39 | 40 | describe '#filtered_calendar' do 41 | before(:each) do 42 | @stub = stub_request(:get, "http://example.com/ical.ics").to_return(body: original_calendar) 43 | end 44 | 45 | it 'fetches the original calendar from the ical_url' do 46 | cal.filtered_calendar 47 | expect(@stub).to have_been_requested 48 | end 49 | 50 | it 'outputs a fresh calendar with only the events that match the filter_rules and the original alarm' do 51 | cal.add_rule('start_time', 'equals', '10:00') 52 | 53 | filtered_ical = cal.filtered_calendar 54 | expect(filtered_ical.gsub("\r\n", "\n")).to eq(filtered_calendar_with_original_alarm.gsub("\r\n", "\n")) 55 | end 56 | 57 | it 'outputs a fresh calendar with only the events that match the filter_rules and removed alarms' do 58 | cal.add_rule('start_time', 'equals', '10:00') 59 | cal.clear_existing_alarms = true 60 | 61 | filtered_ical = cal.filtered_calendar 62 | expect(filtered_ical.gsub("\r\n", "\n")).to eq(filtered_calendar_without_alarm.gsub("\r\n", "\n")) 63 | end 64 | 65 | it 'outputs a fresh calendar with only the events that match the filter_rules and new alarm' do 66 | cal.add_rule('start_time', 'equals', '10:00') 67 | cal.clear_existing_alarms = true 68 | cal.add_alarm_trigger("1 day") 69 | 70 | filtered_ical = cal.filtered_calendar 71 | expect(filtered_ical.gsub(/[\r\n]+/, "\n")).to eq(filtered_calendar_with_new_alarm.gsub(/[\r\n]+/, "\n")) 72 | end 73 | end 74 | end 75 | 76 | private 77 | 78 | def original_calendar 79 | File.read(File.expand_path('../fixtures/original_calendar.ics', __FILE__)) 80 | end 81 | 82 | def filtered_calendar_with_original_alarm 83 | File.read(File.expand_path('../fixtures/filtered_calendar_with_original_alarm.ics', __FILE__)) 84 | end 85 | 86 | def filtered_calendar_without_alarm 87 | File.read(File.expand_path('../fixtures/filtered_calendar_without_alarm.ics', __FILE__)) 88 | end 89 | 90 | def filtered_calendar_with_new_alarm 91 | File.read(File.expand_path('../fixtures/filtered_calendar_with_new_alarm.ics', __FILE__)) 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/filterable_event_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe IcalFilterProxy::FilterableEventAdapter do 4 | let(:start_time) { Time.new(1987, 2, 21, 4, 30, 0, 0) } 5 | let(:end_time) { Time.new(2017, 6, 29, 23, 59, 0, 0) } 6 | let(:summary) { "The life and times of Dan" } 7 | let(:description) { "A 30 year event does seem a little excessive, doesn't it?" } 8 | 9 | let(:test_event) do 10 | event = Icalendar::Event.new 11 | event.dtstart = start_time 12 | event.dtend = end_time 13 | event.summary = summary 14 | event.description = description 15 | event 16 | end 17 | 18 | describe '.new' do 19 | it 'accepts an event as its first arg' do 20 | adapter = described_class.new(test_event) 21 | expect(adapter.raw_event).to eq(test_event) 22 | end 23 | 24 | it 'accepts an optional timezone in an options hash' do 25 | adapter = described_class.new(test_event, timezone: 'Europe/London') 26 | expect(adapter.options[:timezone]).to eq('Europe/London') 27 | end 28 | 29 | it 'defaults to UTC as a timezone' do 30 | adapter = described_class.new(test_event) 31 | expect(adapter.options[:timezone]).to eq('UTC') 32 | end 33 | end 34 | 35 | describe '#start_time' do 36 | it 'translates dtstart into a zero padded 24h timestamp' do 37 | adapter = described_class.new(test_event) 38 | expect(adapter.start_time).to eq("04:30") 39 | end 40 | 41 | it 'uses the timezone specified in the options hash' do 42 | tz = 'Europe/Moscow' 43 | expected_time = start_time.in_time_zone(tz).strftime("%H:%M") 44 | 45 | adapter = described_class.new(test_event, timezone: tz) 46 | expect(adapter.start_time).to eq(expected_time) 47 | end 48 | end 49 | 50 | describe '#end_time' do 51 | it 'translates dtend into a zero padded 24h timestamp' do 52 | adapter = described_class.new(test_event) 53 | expect(adapter.end_time).to eq("23:59") 54 | end 55 | 56 | it 'uses the timezone specified in the options hash' do 57 | tz = 'Europe/Moscow' 58 | expected_time = end_time.in_time_zone(tz).strftime("%H:%M") 59 | 60 | adapter = described_class.new(test_event, timezone: tz) 61 | expect(adapter.end_time).to eq(expected_time) 62 | end 63 | end 64 | 65 | describe '#start_date' do 66 | it 'translates dtstart into a date only' do 67 | adapter = described_class.new(test_event) 68 | expect(adapter.start_date).to eq("1987-02-21") 69 | end 70 | 71 | it 'uses the timezone specified in the options hash' do 72 | tz = 'Europe/Moscow' 73 | adapter = described_class.new(test_event, timezone: tz) 74 | expect(adapter.start_date).to eq('1987-02-21') 75 | end 76 | end 77 | 78 | describe '#end_date' do 79 | it 'translates dtend into a date only' do 80 | adapter = described_class.new(test_event) 81 | expect(adapter.end_date).to eq("2017-06-29") 82 | end 83 | 84 | it 'uses the timezone specified in the options hash' do 85 | tz = 'Europe/Moscow' 86 | adapter = described_class.new(test_event, timezone: tz) 87 | expect(adapter.end_date).to eq('2017-06-30') 88 | end 89 | end 90 | 91 | describe '#blocking' do 92 | it 'returns true if transp is OPAQUE' do 93 | test_event.transp = 'OPAQUE' 94 | adapter = described_class.new(test_event) 95 | expect(adapter.blocking).to be true 96 | end 97 | 98 | it 'returns false if transp is TRANSPARENT' do 99 | test_event.transp = 'TRANSPARENT' 100 | adapter = described_class.new(test_event) 101 | expect(adapter.blocking).to be false 102 | end 103 | 104 | it 'returns true if transp is nil (default)' do 105 | test_event.transp = nil 106 | adapter = described_class.new(test_event) 107 | expect(adapter.blocking).to be true 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ical-filter-proxy 2 | 3 | Got an iCal feed full of nonsense that you don't need? Does your calendar client 4 | have piss-poor filtering options (looking at your Google Calendar)? 5 | 6 | Use ical-filter-proxy to (you guessed it) proxy and filter your iCal feed. 7 | Define your source calendar and filtering options in `config.yml` and you're 8 | good to go. 9 | 10 | In addition, display alarms can be created or cleared. 11 | 12 | ## Configuration 13 | 14 | ```yaml 15 | my_calendar_name: 16 | ical_url: https://source-calendar.com/my_calendar.ics # Source calendar 17 | api_key: myapikey # (optional) append ?key=myapikey to your URL to grant access 18 | timezone: Europe/London # (optional) ensure all time comparisons are done in this TZ 19 | rules: 20 | - field: start_time # start_time and end_time supported 21 | operator: not-equals # equals and not-equals supported 22 | val: "09:00" # A time in 24hour format, zero-padded 23 | - field: summary # summary and description supported 24 | operator: startswith # (not-)startswith, (not-)equals and (not-)includes supported 25 | val: # array of values also supported 26 | - Planning 27 | - Daily Standup 28 | - field: summary # summary and description supported 29 | operator: matches # match against regex pattern 30 | val: # array of values also supported 31 | - '/Team A/i' 32 | - field: blocking # blocking (TRANSP) field supported 33 | operator: equals 34 | val: true # true will filter out non-blocking events 35 | alarms: # (optional) create/clear alarms for filtered events 36 | clear_existing: true # (optional) if true, existing alarms will be removed, default: false 37 | triggers: # (optional) triggers for new alarms. Description will be the alarm summary, action is 'DISPLAY' 38 | - '-P1DT0H0M0S' # iso8601 supported 39 | - 2 days # supports full day[s], hour[s], minute[s], no combination in one trigger 40 | ``` 41 | 42 | ### Variable substitution 43 | 44 | It might be useful to inject configuration values as environment variable. 45 | Variables are substituted if they begin with `ICAL_FILTER_PROXY_` and are 46 | defined in the configuration like `${ICAL_FILTER_PROXY_}`. 47 | 48 | Example: 49 | 50 | ```yaml 51 | api_key: ${ICAL_FILTER_PROXY_API_KEY} 52 | ``` 53 | 54 | If a placeholder is defined but environment variable is missing, it is 55 | substituted with an empty string! 56 | 57 | ## Additional Rules 58 | 59 | At the moment rules are pretty simple, supporting only start times, end times, 60 | equals and not-equals as that satisfies my use case. To add support for 61 | additional rules please extend `lib/ical_filter_proxy/filter_rule.rb`. Pull 62 | requests welcome. 63 | 64 | ## Installing/Running 65 | 66 | After you've created a `config.yml` simply bundle and run rackup. 67 | 68 | ```bash 69 | bundle install 70 | bundle exec rackup -p 8000 71 | ``` 72 | 73 | Voila! Your calendar will now be available at 74 | http://localhost:8000/my_calendar_name?key=myapikey. 75 | 76 | I'd recommend running it behind something like nginx, but you can do what you 77 | like. 78 | 79 | ### Docker 80 | 81 | Create a `config.yml` as shown above. 82 | 83 | ```bash 84 | docker build -t ical-filter-proxy . 85 | docker run -d --name ical-filter-proxy -v $(pwd)/config.yml:/app/config.yml -p 8000:8000 ical-filter-proxy 86 | ``` 87 | 88 | ### Lambda 89 | 90 | ical-filter-proxy can be run as an AWS Lambda process using their API Gateway. 91 | 92 | Create a new API Gateway in the AWS Console and link to to a new Lambda process. 93 | This should create all of the permissions required in AWS land. 94 | 95 | Next we need to package the app up ready for Lambda. First of all, craft your 96 | config.yml and place it in the root of the source directory. A handy rake task 97 | is included which will fetch any dependencies and zip them up ready to be 98 | uploaded. 99 | 100 | ```bash 101 | bundle exec rake lamba:build 102 | ``` 103 | 104 | This task will output the file `ical-filter-proxy.zip`. Note that you must have 105 | the `zip` utility installed locally to use this task. 106 | 107 | When prompted during the Lambda setup, provide this zip file and set the handler 108 | to `lambda.handle`. 109 | 110 | That's it! Your calendar should now be available at 111 | `https://aws-api-gateway-host/default/gateway_name?calendar=my_calendar_name&key=my_api_key` 112 | -------------------------------------------------------------------------------- /spec/filter_rule_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe IcalFilterProxy::FilterRule do 4 | describe '.new' do 5 | it 'accepts field, operator and value' do 6 | filter_rule = described_class.new('start_time', 'equals', '09:00') 7 | 8 | expect(filter_rule).to be_a(described_class) 9 | expect(filter_rule.field).to eq('start_time') 10 | expect(filter_rule.negation).to eq(false) 11 | expect(filter_rule.operator).to eq('equals') 12 | expect(filter_rule.values).to eq('09:00') 13 | end 14 | 15 | it 'accepts field, negative operator and value' do 16 | filter_rule = described_class.new('start_time', 'not-equals', '09:00') 17 | 18 | expect(filter_rule).to be_a(described_class) 19 | expect(filter_rule.field).to eq('start_time') 20 | expect(filter_rule.negation).to eq(true) 21 | expect(filter_rule.operator).to eq('equals') 22 | expect(filter_rule.values).to eq('09:00') 23 | end 24 | 25 | it 'accepts field, operator and value array' do 26 | filter_rule = described_class.new('start_time', 'equals', ['09:00', '10:00']) 27 | 28 | expect(filter_rule).to be_a(described_class) 29 | expect(filter_rule.field).to eq('start_time') 30 | expect(filter_rule.negation).to eq(false) 31 | expect(filter_rule.operator).to eq('equals') 32 | expect(filter_rule.values).to eq(['09:00', '10:00']) 33 | end 34 | end 35 | 36 | describe '#match_event?' do 37 | let(:dummy_event) { instance_double("FilterableEventAdapter", :event, start_time: '09:00', summary: 'Foobar') } 38 | 39 | context 'when operator is equals' do 40 | it 'matches events where the value is the same as the filter value' do 41 | matching_filter_rule = described_class.new('start_time', 'equals', '09:00') 42 | expect(matching_filter_rule.match_event?(dummy_event)).to be true 43 | end 44 | 45 | it 'rejects events where the value is different to the filter value' do 46 | non_matching_filter_rule = described_class.new('start_time', 'equals', '11:00') 47 | expect(non_matching_filter_rule.match_event?(dummy_event)).to be false 48 | end 49 | end 50 | 51 | context 'when the operator is not-equals' do 52 | it 'matches events where the value is differrent to the filter value' do 53 | matching_filter_rule = described_class.new('start_time', 'not-equals', '10:00') 54 | expect(matching_filter_rule.match_event?(dummy_event)).to be true 55 | end 56 | 57 | it 'rejects events where he value is the same as the filter value' do 58 | non_matching_filter_rule = described_class.new('start_time', 'not-equals', '09:00') 59 | expect(non_matching_filter_rule.match_event?(dummy_event)).to be false 60 | end 61 | end 62 | 63 | context 'when the operator is startswith' do 64 | it 'matches events where the value starts with the filter value' do 65 | matching_filter_rule = described_class.new('start_time', 'startswith', '09') 66 | expect(matching_filter_rule.match_event?(dummy_event)).to be true 67 | end 68 | 69 | it 'rejects events where the value does not start with the filter value' do 70 | non_matching_filter_rule = described_class.new('start_time', 'startswith', '10') 71 | expect(non_matching_filter_rule.match_event?(dummy_event)).to be false 72 | end 73 | end 74 | 75 | context 'when the operator is not-startswith' do 76 | it 'matches events where the value does not start with the filter value' do 77 | matching_filter_rule = described_class.new('start_time', 'not-startswith', '08') 78 | expect(matching_filter_rule.match_event?(dummy_event)).to be true 79 | end 80 | 81 | it 'rejects events where the value starts with the filter value' do 82 | non_matching_filter_rule = described_class.new('start_time', 'not-startswith', '09') 83 | expect(non_matching_filter_rule.match_event?(dummy_event)).to be false 84 | end 85 | end 86 | 87 | context 'when operator is startswith and value an array' do 88 | it 'matches events where the value is the same as the filter value' do 89 | matching_filter_rule = described_class.new('summary', 'startswith', ['Foo', 'Nothing']) 90 | expect(matching_filter_rule.match_event?(dummy_event)).to be true 91 | end 92 | 93 | it 'rejects events where the value is different to the filter value' do 94 | non_matching_filter_rule = described_class.new('summary', 'startswith', ['Bar', 'Nothing']) 95 | expect(non_matching_filter_rule.match_event?(dummy_event)).to be false 96 | end 97 | end 98 | 99 | context 'when operator is not-startswith and value an array' do 100 | it 'matches events where the value does not start with the filter value' do 101 | matching_filter_rule = described_class.new('summary', 'not-startswith', ['Bar', 'Nothing']) 102 | expect(matching_filter_rule.match_event?(dummy_event)).to be true 103 | end 104 | 105 | it 'rejects events where the value starts with the filter value' do 106 | non_matching_filter_rule = described_class.new('summary', 'not-startswith', ['Foo', 'Nothing']) 107 | expect(non_matching_filter_rule.match_event?(dummy_event)).to be false 108 | end 109 | end 110 | 111 | context 'when operator is includes and value an array' do 112 | it 'matches events where the value is part of the filter value' do 113 | matching_filter_rule = described_class.new('summary', 'includes', ['oob', 'Nothing']) 114 | expect(matching_filter_rule.match_event?(dummy_event)).to be true 115 | end 116 | 117 | it 'rejects events where the value is not part of to the filter value' do 118 | non_matching_filter_rule = described_class.new('summary', 'includes', ['Bar', 'Nothing']) 119 | expect(non_matching_filter_rule.match_event?(dummy_event)).to be false 120 | end 121 | end 122 | 123 | context 'when operator is not-includes and value an array' do 124 | it 'matches events where the value does not contain the filter value' do 125 | matching_filter_rule = described_class.new('summary', 'not-includes', ['Bar', 'Nothing']) 126 | expect(matching_filter_rule.match_event?(dummy_event)).to be true 127 | end 128 | 129 | it 'rejects events where the value contains the filter value' do 130 | non_matching_filter_rule = described_class.new('summary', 'not-includes', ['oob', 'Nothing']) 131 | expect(non_matching_filter_rule.match_event?(dummy_event)).to be false 132 | end 133 | end 134 | 135 | context 'when operator is matches and value an array' do 136 | it 'matches events where the pattern is matching the filter value' do 137 | matching_filter_rule = described_class.new('summary', 'matches', ['/foobar/i', 'Nothing']) 138 | expect(matching_filter_rule.match_event?(dummy_event)).to be true 139 | end 140 | 141 | it 'matches events where the more complex pattern is matching the filter value' do 142 | matching_filter_rule = described_class.new('summary', 'matches', ['/^Foo[bB]a.$/', 'Nothing']) 143 | expect(matching_filter_rule.match_event?(dummy_event)).to be true 144 | end 145 | 146 | it 'rejects events where the pattern is not matching of to the filter value' do 147 | non_matching_filter_rule = described_class.new('summary', 'matches', ['/foobar/', 'Nothing']) 148 | expect(non_matching_filter_rule.match_event?(dummy_event)).to be false 149 | end 150 | end 151 | 152 | context 'when operator is not-matches and value an array' do 153 | it 'matches events where the pattern does not match the filter value' do 154 | matching_filter_rule = described_class.new('summary', 'not-matches', ['/foobar/', 'Nothing']) 155 | expect(matching_filter_rule.match_event?(dummy_event)).to be true 156 | end 157 | 158 | it 'rejects events where the pattern matches the filter value' do 159 | non_matching_filter_rule = described_class.new('summary', 'not-matches', ['/foobar/i', 'Nothing']) 160 | expect(non_matching_filter_rule.match_event?(dummy_event)).to be false 161 | end 162 | end 163 | 164 | context 'when operator is unknown and value an array' do 165 | it 'raises an exception' do 166 | matching_filter_rule = described_class.new('summary', 'unknown', ['Nothing', 'Even more nothing']) 167 | lambda { matching_filter_rule.match_event?(dummy_event) }.should raise_error 168 | end 169 | end 170 | 171 | end 172 | end 173 | --------------------------------------------------------------------------------