pax_global_header00006660000000000000000000000064140460016170014511gustar00rootroot0000000000000052 comment=12895efd3f85dc34a244eddea6793da1f0d35e42 flipper-0.21.0/000077500000000000000000000000001404600161700132325ustar00rootroot00000000000000flipper-0.21.0/.codeclimate.yml000066400000000000000000000000561404600161700163050ustar00rootroot00000000000000exclude_patterns: - "lib/flipper/ui/public" flipper-0.21.0/.github/000077500000000000000000000000001404600161700145725ustar00rootroot00000000000000flipper-0.21.0/.github/workflows/000077500000000000000000000000001404600161700166275ustar00rootroot00000000000000flipper-0.21.0/.github/workflows/ci.yml000066400000000000000000000030521404600161700177450ustar00rootroot00000000000000name: CI on: push: branches: [master] pull_request: jobs: build: runs-on: ubuntu-latest services: redis: image: redis ports: ['6379:6379'] options: >- --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 strategy: matrix: ruby: ['2.5', '2.6', '2.7'] env: RAILS_VERSION: 6.0.0 SQLITE3_VERSION: 1.4.1 REDIS_URL: redis://localhost:6379/0 CI: true steps: - name: Setup memcached uses: KeisukeYamashita/memcached-actions@v1 - name: Start MongoDB uses: supercharge/mongodb-github-action@1.3.0 with: mongodb-version: 4.0 - name: Check out repository code uses: actions/checkout@v2 - name: Do some action caching uses: actions/cache@v1 with: path: vendor/bundle key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} restore-keys: | ${{ runner.os }}-gem- - name: Set up Ruby uses: actions/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} - name: Install libpq-dev run: sudo apt-get -yqq install libpq-dev - name: Install bundler run: gem install bundler - name: Run bundler run: bundle install --jobs 4 --retry 3 - name: Run Rake run: bundle exec rake - name: Run Examples env: FLIPPER_CLOUD_TOKEN: ${{ secrets.FLIPPER_CLOUD_TOKEN }} run: script/examples flipper-0.21.0/.gitignore000066400000000000000000000003251404600161700152220ustar00rootroot00000000000000*.gem *.rbc .bundle .config .ruby-version .yardoc Gemfile.lock InstalledFiles _yardoc coverage doc/ lib/bundler/man pkg rdoc spec/reports test/tmp test/version_tmp tmp log flipper.pstore .sass-cache bin .DS_Store flipper-0.21.0/CODE_OF_CONDUCT.md000066400000000000000000000062201404600161700160310ustar00rootroot00000000000000# Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at nunemaker@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ flipper-0.21.0/Changelog.md000066400000000000000000000467721404600161700154630ustar00rootroot00000000000000## 0.21.0 ### Additions/Changes * Default to using memory adapter (https://github.com/jnunemaker/flipper/pull/501) * Adapters now configured on require when possible (https://github.com/jnunemaker/flipper/pull/502) * Added cloud recommendation to flipper-ui. Can be disabled with `Flipper::UI.configure { |config| config.cloud_recommendation = false }`. Just want to raise awareness that more is available if people want it (https://github.com/jnunemaker/flipper/pull/504) * Added default `flipper_id` implementation via `Flipper::Identifier` and automatically included it in ActiveRecord and Sequel models (https://github.com/jnunemaker/flipper/pull/505) * Deprecate superflous sync_method setting (https://github.com/jnunemaker/flipper/pull/511) * Flipper is now pre-configured when used with Rails. By default, it will [memoize and preload all features for each request](docs/Optimization.md#memoization). (https://github.com/jnunemaker/flipper/pull/506) ### Upgrading You should be able to upgrade to 0.21 without any breaking changes. However, if you want to simplify your setup, you can remove some configuration that is now handled automatically: 1. Adapters are configured when on require, so unless you are using caching or other customizations, you can remove adapter configuration. ```diff # config/initializers/flipper.rb - Flipper.configure do |config| - config.default { Flipper.new(Flipper::Adapters::ActiveRecord.new) } - end ``` 2. `Flipper::Middleware::Memoizer` will be enabled by default. ```diff # config/initializers/flipper.rb - Rails.configuration.middleware.use Flipper::Middleware::Memoizer, - preload: [:stats, :search, :some_feature] + Rails.application.configure do + # Uncomment to configure which features to preload on all requests + # config.flipper.preload = [:stats, :search, :some_feature] + end ``` 3. `#flipper_id`, which is used to enable features for specific actors, is now defined by [Flipper::Identifier](lib/flipper/identifier.rb) on all ActiveRecord and Sequel models. You can remove your implementation if it is in the form of `ModelName;id`. 4. When using `flipper-cloud`, The `Flipper::Cloud.app` webhook receiver is now mounted at `/_flipper` by default. ```diff # config/routes.rb - mount Flipper::Cloud.app, at: "/_flipper" ``` ## 0.20.4 ### Additions/Changes * Allow actors and time gates to deal with decimal percentages (https://github.com/jnunemaker/flipper/pull/492) * Change Flipper::Cloud::Middleware to receive webhooks at / in addition to /webhooks. * Add `write_through` option to ActiveSupportCacheStore adapter to support write-through caching (https://github.com/jnunemaker/flipper/pull/512) ## 0.20.3 ### Additions/Changes * Changed the internal structure of how the memory adapter stores things. ## 0.20.2 ### Additions/Changes * Http adapter now raises error when enable/disable/add/remove/clear fail. * Cloud adapter sends some extra info like hostname, ruby version, etc. for debugging and decision making. ## 0.20.1 ### Additions/Changes * Just a minor tweak to cloud webhook middleware to provide more debugging information about why a hook wasn't successful. ## 0.20.0 ### Additions/Changes * Add support for webhooks to `Flipper::Cloud` (https://github.com/jnunemaker/flipper/pull/489). ## 0.19.1 ### Additions/Changes * Bump rack-protection version to < 2.2 (https://github.com/jnunemaker/flipper/pull/487) * Add memoizer_options to Flipper::Api.app (https://github.com/jnunemaker/flipper/commit/174ad4bb94046a25c432d3c53fe1ff9f5a76d838) ## 0.19.0 ### Additions/Changes * 100% of actors is now considered conditional. Feature#on?, Feature#conditional?, Feature#state would all be affected. See https://github.com/jnunemaker/flipper/issues/463 for more. * Several doc updates. ## 0.18.0 ### Additions/Changes * Add support for feature descriptions to flipper-ui (https://github.com/jnunemaker/flipper/pull/461). * Remove rubocop (https://github.com/jnunemaker/flipper/pull/469). * flipper-ui redesign (https://github.com/jnunemaker/flipper/pull/470). * Removed support for ruby 2.4. * Added support for ruby 2.7. * Removed support for Rails 4.x.x. * Removed support for customizing actors, groups, % of actors and % of time text in flipper-ui in favor of automatic and more descriptive text. ## 0.17.2 ### Additions/Changes * Avoid errors on import when there are no features and shared specs/tests for get all with no features (https://github.com/jnunemaker/flipper/pull/441 and https://github.com/jnunemaker/flipper/pull/442) * ::ActiveRecord::RecordNotUnique > ActiveRecord::RecordNotUnique (https://github.com/jnunemaker/flipper/pull/444) * Clear gate values on enable (https://github.com/jnunemaker/flipper/pull/454) * Remove use of multi from redis adapter (https://github.com/jnunemaker/flipper/pull/451) ## 0.17.1 * Fix require in flipper-active_record (https://github.com/jnunemaker/flipper/pull/437) ## 0.17.0 ### Additions/Changes * Allow shorthand block notation on group types (https://github.com/jnunemaker/flipper/pull/406) * Relax active record/support constraints to support Rails 6 (https://github.com/jnunemaker/flipper/pull/409) * Allow disabling fun (https://github.com/jnunemaker/flipper/pull/413) * Include thing_value in payload of Instrumented#enable and #disable (https://github.com/jnunemaker/flipper/pull/417) * Replace Erubis with Erubi (https://github.com/jnunemaker/flipper/pull/407) * Allow customizing Rack::Protection middleware list (https://github.com/jnunemaker/flipper/pull/385) * Allow setting write_timeout for ruby 2.6+ (https://github.com/jnunemaker/flipper/pull/433) * Drop support for Ruby 2.1, 2.2, and 2.3 (https://github.com/jnunemaker/flipper/commit/cf58982e70de5e6963b018ceced4f36a275f5b5d) * Add support for Ruby 2.6 (https://github.com/jnunemaker/flipper/commit/57888311449ec81184d3d47ba9ae5cb1ad4a2f45) * Remove support for Rails 3.2 (https://github.com/jnunemaker/flipper/commit/177c48c4edf51d4e411e7c673e30e06d1c66fb40) * Add write_timeout for flipper http adapter for ruby 2.6+ (https://github.com/jnunemaker/flipper/pull/433) * Relax moneta version to allow for < 1.2 (https://github.com/jnunemaker/flipper/pull/434). * Improve active record idempotency (https://github.com/jnunemaker/flipper/pull/436). * Allow customizing add actor placeholder text (https://github.com/jnunemaker/flipper/commit/5faa1e9cf66b68f8227d2f8408fb448a14676c45) ## 0.16.2 ### Additions/Changes * Bump rollout redis dependency to < 5 (https://github.com/jnunemaker/flipper/pull/403) * Bump redis dependency to < 5 (https://github.com/jnunemaker/flipper/pull/401) * Bump sequel dependency to < 6 (https://github.com/jnunemaker/flipper/pull/399 and https://github.com/jnunemaker/flipper/commit/edc767e69b4ce8daead9801f38e0e8bf6b238765) ## 0.16.1 ### Additions/Changes * Add actors API endpoint (https://github.com/jnunemaker/flipper/pull/372). * Fix rack body proxy require for those using flipper without rack (https://github.com/jnunemaker/flipper/pull/376). * Unescapes feature_name in FeatureNameFromRoute (https://github.com/jnunemaker/flipper/pull/377). * Replace delete_all with destroy_all in ActiveRecord adapter (https://github.com/jnunemaker/flipper/pull/395) * Target correct bootstrap breakpoints in flipper UI (https://github.com/jnunemaker/flipper/pull/396) ## 0.16.0 ### Bug Fixes * Support slashes in feature names (https://github.com/jnunemaker/flipper/pull/362). ### Additions/Changes * Re-order gates for improved performance in some cases (https://github.com/jnunemaker/flipper/pull/370). * Add Feature#exist?, DSL#exist? and Flipper#exist? (https://github.com/jnunemaker/flipper/pull/371). ## 0.15.0 * Move Flipper::UI configuration options to Flipper::UI::Configuration (https://github.com/jnunemaker/flipper/pull/345). * Bug fix in adapter synchronizing and switched DSL#import to use Synchronizer (https://github.com/jnunemaker/flipper/pull/347). * Fix AR adapter table name prefix/suffix bug (https://github.com/jnunemaker/flipper/pull/350). * Allow feature names to end with "features" in UI (https://github.com/jnunemaker/flipper/pull/353). ## 0.14.0 * Changed sync_interval to be seconds instead of milliseconds. ## 0.13.0 ### Additions/Changes * Update PStore adapter to allow setting thread_safe option (https://github.com/jnunemaker/flipper/pull/334). * Update Flipper::UI to Bootstrap 4 (https://github.com/jnunemaker/flipper/pull/336). * Add Flipper::UI configuration to add a banner with customizeable text and background color (https://github.com/jnunemaker/flipper/pull/337). * Add sync adapter (https://github.com/jnunemaker/flipper/pull/341). * Make cloud use sync adapter (https://github.com/jnunemaker/flipper/pull/342). This makes local flipper operations resilient to cloud failures. ## 0.12.2 ### Additions/Changes * Improvements/fixes/examples for rollout adapter (https://github.com/jnunemaker/flipper/pull/332). ## 0.12.1 ### Additions/Changes * Added rollout adapter documentation (https://github.com/jnunemaker/flipper/pull/328). ### Bug Fixes * Fixed ActiveRecord and Sequel adapters to include disabled features for `get_all` (https://github.com/jnunemaker/flipper/pull/327). ## 0.12 ### Additions/Changes * Added Flipper.instance= writer method for explicitly setting the default instance (https://github.com/jnunemaker/flipper/pull/309). * Added Flipper::UI configuration instance for changing text and things (https://github.com/jnunemaker/flipper/pull/306). * Delegate memoize= and memoizing? for Flipper and Flipper::DSL (https://github.com/jnunemaker/flipper/pull/310). * Fixed error when enabling the same group or actor more than once (https://github.com/jnunemaker/flipper/pull/313). * Fixed redis cache adapter key (and thus cache misses) (https://github.com/jnunemaker/flipper/pull/325). * Added Rollout adapter to make it easy to import rollout data into Flipper (https://github.com/jnunemaker/flipper/pull/319). * Relaxed redis gem dependency constraint to allow redis-rb 4 (https://github.com/jnunemaker/flipper/pull/317). * Added configuration option for Flipper::UI to disable feature removal (https://github.com/jnunemaker/flipper/pull/322). ## 0.11 ### Backwards Compatibility Breaks * Set flipper from env for API and UI (https://github.com/jnunemaker/flipper/pull/223 and https://github.com/jnunemaker/flipper/pull/229). It is documented, but now the memoizing middleware requires that the SetupEnv middleware is used first, unless you are configuring a Flipper default instance. * Drop support for Ruby 2.0 as it is end of lined (https://github.com/jnunemaker/flipper/commit/c2c81ed89938155ce91acb5173ac38580f630e3d). * Allow unregistered groups (https://github.com/jnunemaker/flipper/pull/244). Only break in compatibility is that previously unregistered groups could not be enabled and now they can be. * Removed support for metriks (https://github.com/jnunemaker/flipper/pull/291). ### Additions/Changes * Use primary keys with sequel adapter (https://github.com/jnunemaker/flipper/pull/210). Should be backwards compatible, but if you want it to work this way you will need to migrate your database to the new schema. * Add redis cache adapter (https://github.com/jnunemaker/flipper/pull/211). * Finish API and HTTP adapter that speaks to API. * Add flipper cloud adapter (https://github.com/jnunemaker/flipper/pull/249). Nothing to see here yet, but good stuff soon. ;) * Add importing (https://github.com/jnunemaker/flipper/pull/251). * Added Adapter#get_all to allow for more efficient preload_all (https://github.com/jnunemaker/flipper/pull/255). * Added :unless option to Flipper::Middleware::Memoizer to allow skipping memoization and preloading for certain requests. * Made it possible to instrument Flipper::Cloud (https://github.com/jnunemaker/flipper/commit/4b10e4d807772202f63881f5e2c00d11ac58481f). * Made it possible to wrap Http adapter when using Flipper::Cloud (https://github.com/jnunemaker/flipper/commit/4b10e4d807772202f63881f5e2c00d11ac58481f). * Instrument get_multi in instrumented adapter (https://github.com/jnunemaker/flipper/commit/951d25c5ce07d3b56b0b2337adf5f6bcbe4050e7). * Allow instrumenting Flipper::Cloud http adapter (https://github.com/jnunemaker/flipper/pull/253). * Add DSL#preload_all and Adapter#get_all to allow for making even more efficient loading of features (https://github.com/jnunemaker/flipper/pull/255). * Allow setting debug output of http adapter (https://github.com/jnunemaker/flipper/pull/256 and https://github.com/jnunemaker/flipper/pull/258). * Allow setting env key for middleware (https://github.com/jnunemaker/flipper/pull/259). * Added ActiveSupport cache store adapter for use with Rails.cache (https://github.com/jnunemaker/flipper/pull/265 and https://github.com/jnunemaker/flipper/pull/297). * Added support for up to 3 decimal places in percentage based rollouts (https://github.com/jnunemaker/flipper/pull/274). * Removed Flipper::GroupNotRegistered error as it is now unused (https://github.com/jnunemaker/flipper/pull/270). * Added get_all to all adapters (https://github.com/jnunemaker/flipper/pull/298). * Added support for Rails 5.1 (https://github.com/jnunemaker/flipper/pull/299). * Added Flipper default instance generation (https://github.com/jnunemaker/flipper/pull/279). ## 0.10.2 * Add Adapter#get_multi to allow for efficient loading of more than one feature at a time (https://github.com/jnunemaker/flipper/pull/198) * Add DSL#preload for efficiently loading several features at once using get_mutli (https://github.com/jnunemaker/flipper/pull/198) * Add :preload and :preload_all options to memoizer as a way of efficiently loading several features for a request in one network call instead of N where N is the number of features checked (https://github.com/jnunemaker/flipper/pull/198) * Strip whitespace out of feature/actor/group values posted by UI (https://github.com/jnunemaker/flipper/pull/205) * Fix bug with dalli adapter where deleting a feature using the UI or API was not clearing the cache in the dalli adapter which meant the feature would continue to use whatever cached enabled state was present until the TTL was hit (1cd96f6) * Change cache keys for dalli adapter. Backwards compatible in that it will just repopulate new keys on first check with this version, but old keys are not expired, so if you used the default ttl of 0, you'll have to expire them on your own. The primary reason for the change was safer namespacing of the cache keys to avoid collisions. ## 0.10.1 * Add docker compose support for contributing * Add sequel adapter * Show confirmation dialog when deleting a feature in flipper-ui ## 0.10.0 * Added feature check context (https://github.com/jnunemaker/flipper/pull/158) * Do not use mass assignment for active record adapter (https://github.com/jnunemaker/flipper/pull/171) * Several documentation improvements * Make Flipper::UI.app.inspect return a String (https://github.com/jnunemaker/flipper/pull/176) * changes boolean gate route to api/v1/features/boolean (https://github.com/jnunemaker/flipper/pull/175) * add api v1 percentage_of_actors endpoint (https://github.com/jnunemaker/flipper/pull/179) * add api v1 percentage_of_time endpoint (https://github.com/jnunemaker/flipper/pull/180) * add api v1 actors gate endpoint (https://github.com/jnunemaker/flipper/pull/181) * wait for activesupport to tell us when active record is loaded for active record adapter (https://github.com/jnunemaker/flipper/pull/192) ## 0.9.2 * GET /api/v1/features * POST /api/v1/features - add feature endpoint * rack-protection 2.0.0 support * pretty rake output ## 0.9.1 * bump flipper-active_record to officially support rails 5 ## 0.9.0 * Moves SharedAdapterTests module to Flipper::Test::SharedAdapterTests to avoid clobbering anything top level in apps that use Flipper * Memoizable, Instrumented and OperationLogger now delegate any missing methods to the original adapter. This was lost with the removal of the official decorator in 0.8, but is actually useful functionality for these "wrapping" adapters. * Instrumenting adapters is now off by default. Use Flipper::Adapters::Instrumented.new(adapter) to instrument adapters and maintain the old functionality. * Added dalli cache adapter (https://github.com/jnunemaker/flipper/pull/132) ## 0.8 * removed Flipper::Decorator and Flipper::Adapters::Decorator in favor of just calling methods on wrapped adapter * fix bug where certain versions of AR left off quotes for key column which caused issues with MySQL https://github.com/jnunemaker/flipper/issues/120 * fix bug where AR would store multiple gate values for percentage gates for each enable/disable and then nondeterministically pick one on read (https://github.com/jnunemaker/flipper/pull/122 and https://github.com/jnunemaker/flipper/pull/124) * added readonly adapter (https://github.com/jnunemaker/flipper/pull/111) * flipper groups now match for truthy values rather than explicitly only true (https://github.com/jnunemaker/flipper/issues/110) * removed gate operation instrumentation (https://github.com/jnunemaker/flipper/commit/32f14ed1fb25c64961b23c6be3dc6773143a06c8); I don't think it was useful and never found myself instrumenting it in reality * initial implementation of flipper api - very limited functionality right now (get/delete feature, boolean gate for feature) but more is on the way * made it easy to remove a feature (https://github.com/jnunemaker/flipper/pull/126) * add minitest shared tests for adapters that work the same as the shared specs for rspec (https://github.com/jnunemaker/flipper/pull/127) ## 0.7.5 * support for rails 5 beta/ rack 2 alpha * fix uninitialized constant in rails generators * fix adapter test for clear to ensure that feature is not deleted, only gates ## 0.7.4 * Add missing migration file to gemspec for flipper-active_record ## 0.7.3 * Add Flipper ActiveRecord adapter ## 0.7.2 * Add Flipper::UI.application_breadcrumb_href for setting breadcrumb back to original app from Flipper UI ## 0.7.1 * Fix bug where features with names that match static file routes were incorrectly routing to the file action (https://github.com/jnunemaker/flipper/issues/80) ## 0.7 * Added Flipper.groups and Flipper.group_names * Changed percentage_of_random to percentage_of_time * Added enable/disable convenience methods for all gates (enable_group, enable_actor, enable_percentage_of_actors, enable_percentage_of_time) * Added value convenience methods (boolean_value, groups_value, actors_value, etc.) * Added Feature#gate_values for getting typecast adapter gate values * Added Feature#enabled_gates and #disabled_gates for getting the gates that are enabled/disabled for the feature * Remove Feature#description * Added Flipper::Adapters::PStore * Moved memoizable decorator to instance variable storage from class level thread local stuff. Now not thread safe, but we can make a thread safe version later. UI * Totally new. Works like a charm. Mongo * Updated to latest driver (~> 2.0) ## 0.6.3 * Minor bug fixes ## 0.6.2 * Added Flipper.group_exists? ## 0.6.1 * Added statsd support for instrumentation. ## 0.4.0 * No longer use #id for detecting actors. You must now define #flipper_id on anything that you would like to behave as an actor. * Strings are now used instead of Integers for Actor identifiers. More flexible and the only reason I used Integers was to do modulo for percentage of actors. Since percentage of actors now uses hashing, integer is no longer needed. * Easy integration of instrumentation with AS::Notifications or anything similar. * A bunch of stuff around inspecting and getting names/descriptions out of things to more easily figure out what is going on. * Percentage of actors hash is now also seeded with feature name so the same actors don't get all features instantly. flipper-0.21.0/Dockerfile000066400000000000000000000006321404600161700152250ustar00rootroot00000000000000FROM ruby:2.5 RUN apt-get update && apt-get install -y \ # build-essential \ # for postgres # libpq-dev \ # postgresql-client-9.4 \ # for nokogiri # libxml2-dev \ # libxslt1-dev \ # for a JS runtime # imagemagick \ # ghostscript \ # debug tools vim ENV APP_HOME /srv/app ENV BUNDLE_GEMFILE=$APP_HOME/Gemfile \ BUNDLE_JOBS=8 \ BUNDLE_PATH=/bundle_cache WORKDIR $APP_HOME flipper-0.21.0/Gemfile000066400000000000000000000013421404600161700145250ustar00rootroot00000000000000source 'https://rubygems.org' gemspec name: 'flipper' Dir['flipper-*.gemspec'].each do |gemspec| plugin = gemspec.scan(/flipper-(.*)\.gemspec/).flatten.first gemspec(name: "flipper-#{plugin}", development_group: plugin) end gem 'pry' gem 'rake', '~> 12.3.3' gem 'shotgun', '~> 0.9' gem 'statsd-ruby', '~> 1.2.1' gem 'rspec', '~> 3.0' gem 'rack-test', '~> 0.6.3' gem 'sqlite3', "~> #{ENV['SQLITE3_VERSION'] || '1.4.1'}" gem 'rails', "~> #{ENV['RAILS_VERSION'] || '6.0.0'}" gem 'minitest', '~> 5.8' gem 'minitest-documentation' gem 'webmock', '~> 3.0' gem 'climate_control' gem 'redis-namespace' group(:guard) do gem 'guard', '~> 2.15' gem 'guard-rspec', '~> 4.5' gem 'guard-bundler', '~> 2.2' gem 'rb-fsevent', '~> 0.9' end flipper-0.21.0/Guardfile000066400000000000000000000007311404600161700150600ustar00rootroot00000000000000# A sample Guardfile # More info at https://github.com/guard/guard#readme guard 'bundler' do watch('Gemfile') watch(/^.+\.gemspec/) end rspec_options = { all_after_pass: false, all_on_start: false, failed_mode: :keep, cmd: 'bundle exec rspec', } guard 'rspec', rspec_options do watch(%r{^spec/.+_spec\.rb$}) watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } watch(/shared_adapter_specs\.rb$/) { 'spec' } watch('spec/helper.rb') { 'spec' } end flipper-0.21.0/LICENSE000066400000000000000000000020561404600161700142420ustar00rootroot00000000000000Copyright (c) 2012 John Nunemaker MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.flipper-0.21.0/README.md000066400000000000000000000134031404600161700145120ustar00rootroot00000000000000[![Flipper Mark](docs/images/banner.jpg)](https://www.flippercloud.io) # Flipper > Beautiful, performant feature flags for Ruby. Flipper gives you control over who has access to features in your app. * Enable or disable features for everyone, specific actors, groups of actors, a percentage of actors, or a percentage of time. * Configure your feature flags from the console or a web UI. * Regardless of what data store you are using, Flipper can performantly store your feature flags. * Use [Flipper Cloud](#flipper-cloud) to cascade features from multiple environments, share settings with your team, control permissions, keep an audit history, and rollback. Control your software — don't let it control you. ## Installation Add this line to your application's Gemfile: gem 'flipper' You'll also want to pick a storage [adapter](#adapters), for example: gem 'flipper-active_record' And then execute: $ bundle Or install it yourself with: $ gem install flipper ## Getting Started Use `Flipper#enabled?` in your app to check if a feature is enabled. ```ruby # check if search is enabled if Flipper.enabled? :search, current_user puts 'Search away!' else puts 'No search for you!' end ``` All features are disabled by default, so you'll need to explicitly enable them. #### Enable a feature for everyone ```ruby Flipper.enable :search ``` #### Enable a feature for a specific actor ```ruby Flipper.enable_actor :search, current_user ``` #### Enable a feature for a group of actors First tell Flipper about your groups: ```ruby # config/initializers/flipper.rb Flipper.register(:admin) do |actor| actor.respond_to?(:admin?) && actor.admin? end ``` Then enable the feature for that group: ```ruby Flipper.enable_group :search, :admin ``` #### Enable a feature for a percentage of actors ```ruby Flipper.enable_percentage_of_actors :search, 2 ``` Read more about enabling and disabling features with [Gates](docs/Gates.md). Check out the [examples directory](examples/) for more, and take a peek at the [DSL](lib/flipper/dsl.rb) and [Feature](lib/flipper/feature.rb) classes for code/docs. ## Adapters Flipper is built on adapters for maximum flexibility. Regardless of what data store you are using, Flipper can performantly store data in it. Pick one of our [supported adapters](docs/Adapters.md#officially-supported) and follow the installation instructions: * [Active Record](docs/active_record/README.md) * [Sequel](docs/sequel/README.md) * [Redis](docs/redis/README.md) * [Mongo](docs/mongo/README.md) * [Moneta](docs/moneta/README.md) * [Rollout](docs/rollout/README.md) Or [roll your own](docs/Adapters.md#roll-your-own). We even provide automatic (rspec and minitest) tests for you, so you know you've built your custom adapter correctly. Read more about [Adapters](docs/Adapters.md). ## Flipper UI If you prefer a web UI to an IRB console, you can setup the [Flipper UI](docs/ui/README.md). It's simple and pretty. ![Flipper UI Screenshot](docs/ui/images/feature.png) ## Flipper Cloud Or, (even better than OSS + UI) use [Flipper Cloud](https://www.flippercloud.io) which comes with: * **everything in one place** — no need to bounce around from different application UIs or IRB consoles. * **permissions** — grant access to everyone in your organization or lockdown each project to particular people. * **multiple environments** — production, staging, enterprise, by continent, whatever you need. * **personal environments** — no more rake scripts or manual enable/disable to get your laptop to look like production. Every developer gets a personal environment that inherits from production that they can override as they please ([read more](https://www.johnnunemaker.com/flipper-cloud-environments/)). * **no maintenance** — we'll keep the lights on for you. We also have handy webhooks for keeping your app in sync with Cloud, so **our availability won't affect yours**. All your feature flag reads are local to your app. * **audit history** — every feature change and who made it. * **rollbacks** — enable or disable a feature accidentally? No problem. You can roll back to any point in the audit history with a single click. [![Flipper Cloud Screenshot](docs/images/flipper_cloud.png)](https://www.flippercloud.io) Cloud is super simple to integrate with Rails ([demo app](https://github.com/fewerandfaster/flipper-rails-demo)), Sinatra or any other framework. ## Advanced A few miscellaneous docs with more info for the hungry. * [Instrumentation](docs/Instrumentation.md) - ActiveSupport::Notifications and Statsd * [Optimization](docs/Optimization.md) - Memoization middleware and Cache adapters * [API](docs/api/README.md) - HTTP API interface * [Caveats](docs/Caveats.md) - Flipper beware! (see what I did there) * [Docker-Compose](docs/DockerCompose.md) - Using docker-compose in contributing ## Contributing 1. Fork it 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Run the tests (`bundle exec rake`) 4. Commit your changes (`git commit -am 'Added some feature'`) 5. Push to the branch (`git push origin my-new-feature`) 6. Create new Pull Request ## Releasing 1. Update the version to be whatever it should be and commit. 2. `script/release` 3. Profit. ## Brought To You By | pic | @mention | area | |---|---|---| | ![@jnunemaker](https://avatars3.githubusercontent.com/u/235?s=64) | [@jnunemaker](https://github.com/jnunemaker) | most things | | ![@alexwheeler](https://avatars3.githubusercontent.com/u/3260042?s=64) | [@alexwheeler](https://github.com/alexwheeler) | api | | ![@thetimbanks](https://avatars1.githubusercontent.com/u/471801?s=64) | [@thetimbanks](https://github.com/thetimbanks) | ui | | ![@lazebny](https://avatars1.githubusercontent.com/u/6276766?s=64) | [@lazebny](https://github.com/lazebny) | docker | flipper-0.21.0/Rakefile000066400000000000000000000024451404600161700147040ustar00rootroot00000000000000#!/usr/bin/env rake $LOAD_PATH.push File.expand_path('../lib', __FILE__) require 'rake/testtask' require 'flipper/version' # gem install pkg/*.gem # gem uninstall flipper flipper-ui flipper-redis desc 'Build gem into the pkg directory' task :build do FileUtils.rm_rf('pkg') Dir['*.gemspec'].each do |gemspec| system "gem build #{gemspec}" end FileUtils.mkdir_p('pkg') FileUtils.mv(Dir['*.gem'], 'pkg') end desc 'Tags version, pushes to remote, and pushes gem' task release: :build do sh 'git', 'tag', "v#{Flipper::VERSION}" sh 'git push origin master' sh "git push origin v#{Flipper::VERSION}" sh 'ls pkg/*.gem | xargs -n 1 gem push' end require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:spec) do |t| t.rspec_opts = %w(--color --format documentation) end namespace :spec do desc 'Run specs for UI queue' RSpec::Core::RakeTask.new(:ui) do |t| t.rspec_opts = %w(--color) t.pattern = ['spec/flipper/ui/**/*_spec.rb', 'spec/flipper/ui_spec.rb'] end end Rake::TestTask.new do |t| t.libs = %w(lib test) t.pattern = 'test/**/*_test.rb' t.options = '--documentation' t.warning = false end Rake::TestTask.new(:test_rails) do |t| t.libs = %w(lib test_rails) t.pattern = 'test_rails/**/*_test.rb' t.warning = false end task default: [:spec, :test, :test_rails] flipper-0.21.0/docker-compose.yml000066400000000000000000000012421404600161700166660ustar00rootroot00000000000000# postgres: # container_name: flipper_postgres # image: postgres:9.4 redis: container_name: flipper_redis image: redis:2.8 mongo: container_name: flipper_mongo image: mongo:3.3 memcached: container_name: flipper_memcached image: memcached:1.4.33 app: container_name: flipper_app build: . dockerfile: Dockerfile volumes: - .:/srv/app volumes_from: - bundle_cache links: # - postgres - redis - mongo - memcached environment: - REDIS_URL=redis://redis:6379 - MONGODB_HOST=mongo - MEMCACHED_URL=memcached:11211 bundle_cache: container_name: flipper_bundle_cache image: busybox volumes: - /bundle_cache flipper-0.21.0/docs/000077500000000000000000000000001404600161700141625ustar00rootroot00000000000000flipper-0.21.0/docs/Adapters.md000066400000000000000000000126311404600161700162520ustar00rootroot00000000000000# Adapters I plan on supporting the adapters in the flipper repo. Other adapters are welcome, so please let me know if you create one. ## Officially Supported * [ActiveRecord adapter](https://github.com/jnunemaker/flipper/blob/master/docs/active_record) - Rails 5 and 6. * [Sequel adapter](https://github.com/jnunemaker/flipper/blob/master/docs/sequel) * [Redis adapter](https://github.com/jnunemaker/flipper/blob/master/docs/redis) * [Mongo adapter](https://github.com/jnunemaker/flipper/blob/master/docs/mongo) * [PStore adapter](https://github.com/jnunemaker/flipper/blob/master/lib/flipper/adapters/pstore.rb) – great for when a local file is enough * [Http adapter](https://github.com/jnunemaker/flipper/blob/master/docs/http) - great for using with `Flipper::Api` * [Moneta adapter](https://github.com/jnunemaker/flipper/blob/master/docs/moneta) - great for a variety of data stores * [ActiveSupportCacheStore adapter](https://github.com/jnunemaker/flipper/blob/master/docs/active_support_cache_store) - great for Rails caching * [Memory adapter](https://github.com/jnunemaker/flipper/blob/master/lib/flipper/adapters/memory.rb) – great for tests * [Read-only adapter](https://github.com/jnunemaker/flipper/blob/master/docs/read-only) - great for preventing writes from production console * [Rollout adapter](rollout/README.md) - great for switching from rollout to flipper ## Community Supported * [Active Record 3 adapter](https://github.com/blueboxjesse/flipper-activerecord) * [Consul adapter](https://github.com/gdavison/flipper-consul) ## Roll Your Own The basic API for an adapter is this: * `features` - Get the set of known features. * `add(feature)` - Add a feature to the set of known features. * `remove(feature)` - Remove a feature from the set of known features. * `clear(feature)` - Clear all gate values for a feature. * `get(feature)` - Get all gate values for a feature. * `enable(feature, gate, thing)` - Enable a gate for a thing. * `disable(feature, gate, thing)` - Disable a gate for a thing. * `get_multi(features)` - Get all gate values for several features at once. Implementation is optional. If none provided, default implementation performs N+1 `get` calls where N is the number of elements in the features parameter. * `get_all` - Get all gate values for all features at once. Implementation is optional. If none provided, default implementation performs two calls, one to `features` to get the names of all features and one to `get_multi` with the feature names from the first call. If you would like to make your own adapter, there are shared adapter specs (RSpec) and tests (MiniTest) that you can use to verify that you have everything working correctly. ### RSpec For example, here is what the in-memory adapter spec looks like: `spec/flipper/adapters/memory_spec.rb` ```ruby require 'helper' # The shared specs are included with the flipper gem so you can use them in # separate adapter specific gems. require 'flipper/spec/shared_adapter_specs' describe Flipper::Adapters::Memory do # an instance of the new adapter you are trying to create subject { described_class.new } # include the shared specs that the subject must pass it_should_behave_like 'a flipper adapter' end ``` ### MiniTest Here is what an in-memory adapter MiniTest looks like: `test/adapters/memory_test.rb` ```ruby require 'test_helper' class MemoryTest < MiniTest::Test prepend SharedAdapterTests def setup # Any code here will run before each test @adapter = Flipper::Adapters::Memory.new end def teardown # Any code here will run after each test end end ``` 1. Create a file under `test/adapters` that inherits from MiniTest::Test. 2. `prepend SharedAdapterTests`. 3. Initialize an instance variable `@adapter` referencing an instance of the adapter. 4. Add any code to run before each test in a `setup` method and any code to run after each test in a `teardown` method. A good place to start when creating your own adapter is to copy one of the adapters mentioned above and replace the client specific code with whatever client you are attempting to adapt. I would also recommend setting `fail_fast = true` in your RSpec configuration as that will just give you one failure at a time to work through. It is also handy to have the shared adapter spec file open. ## Swapping Adapters If you find yourself using one adapter and would like to swap to another, you can do that! Flipper adapters support importing another adapter's data. This will wipe the adapter you are wanting to swap to, if it isn't already clean, so please be careful. ```ruby # Say you are using redis... redis_adapter = Flipper::Adapters::Redis.new(Redis.new) redis_flipper = Flipper.new(redis_adapter) # And redis has some stuff enabled... redis_flipper.enable(:search) redis_flipper.enable_percentage_of_time(:verbose_logging, 5) redis_flipper.enable_percentage_of_actors(:new_feature, 5) redis_flipper.enable_actor(:issues, Flipper::Actor.new('1')) redis_flipper.enable_actor(:issues, Flipper::Actor.new('2')) redis_flipper.enable_group(:request_tracing, :staff) # And you would like to switch to active record... ar_adapter = Flipper::Adapters::ActiveRecord.new ar_flipper = Flipper.new(ar_adapter) # NOTE: This wipes active record clean and copies features/gates from redis into active record. ar_flipper.import(redis_flipper) # active record is now identical to redis. ar_flipper.features.each do |feature| pp feature: feature.key, values: feature.gate_values end ``` flipper-0.21.0/docs/Caveats.md000066400000000000000000000015411404600161700160730ustar00rootroot00000000000000# Caveats 1. The [individual actor gate](https://github.com/jnunemaker/flipper/blob/master/docs/Gates.md#2-individual-actor) is typically not designed for hundreds or thousands of actors to be enabled. This is an explicit choice to make it easier to batch load data from the adapters instead of performing individual checks for actors over and over. If you need to enable something for more than 100 individual actors, I would recommend using a [group](https://github.com/jnunemaker/flipper/blob/master/docs/Gates.md#5-group). 2. The `disable` method exists only to clear something that is enabled. If the thing you are disabling is not enabled, the disable is pointless. This means that if you enable one group an actor is in and disable another group, the feature will be enabled for the actor. ([related issue](https://github.com/jnunemaker/flipper/issues/71)) flipper-0.21.0/docs/DockerCompose.md000066400000000000000000000012771404600161700172500ustar00rootroot00000000000000# Docker Compose for contributors This gem includes different adapters which require specific tools instaled on local machine. With docker this could be achieved inside container and new contributor could start working on code with a minumum efforts. ## Steps: 1. Install Docker Compose https://docs.docker.com/compose/install 1. Build the app container `docker-compose build` 1. Install gems `docker-compose run --rm app bundle install` 1. Run specs `docker-compose run --rm app bundle exec rspec` 1. Run tests `docker-compose run --rm app bundle exec rake test` 1. Optional: log in to container an using a `bash` shell for running specs ```sh docker-compose run --rm app bash bundle exec rspec ``` flipper-0.21.0/docs/Gates.md000066400000000000000000000132761404600161700155600ustar00rootroot00000000000000# Gates Out of the box several types of enabling are supported. They are checked in this order: ## 1. Boolean All on or all off. Think top level things like `:stats`, `:search`, `:logging`, etc. Also, an easy way to release a new feature as once a feature is boolean enabled it is on for every situation. ```ruby Flipper.enable :stats # turn on Flipper.disable :stats # turn off Flipper.enabled? :stats # check ``` ## 2. Individual Actor Turn feature on for individual thing. Think enable feature for someone to test or for a buddy. ```ruby Flipper.enable_actor :stats, user Flipper.enabled? :stats, user # true Flipper.disable_actor :stats, user Flipper.enabled? :stats, user # false # you can enable anything, does not need to be user or person Flipper.enable_actor :search, organization Flipper.enabled? :search, organization # you can also save a reference to a specific feature feature = Flipper[:search] feature.enable_actor user feature.enabled? user # true feature.disable_actor user ``` The only requirement for an individual actor is that it must have a unique `flipper_id`. Include the `Flipper::Identifier` module for a default implementation which combines the class name and `id` (e.g. `User;6`). ```ruby class User < Struct.new(:id) include Flipper::Identifier end User.new(5).flipper_id # => "User;5" ``` You can also define your own implementation: ``` class Organization < Struct.new(:uuid) def flipper_id uuid end end Organization.new("DEB3D850-39FB-444B-A1E9-404A990FDBE0").flipper_id # => "DEB3D850-39FB-444B-A1E9-404A990FDBE0" ``` Just make sure each type of object has a unique `flipper_id`. ## 3. Percentage of Actors Turn this on for a percentage of actors (think user, member, account, group, whatever). Consistently on or off for this user as long as percentage increases. Think slow rollout of a new feature to a percentage of things. ```ruby # turn stats on for 10 percent of users in the system Flipper.enable :stats, Flipper.actors(10) # or Flipper.enable_percentage_of_actors :stats, 10 # checks if actor's flipper_id is in the enabled percentage by hashing # user.flipper_id.to_s to ensure enabled distribution is smooth Flipper.enabled? :stats, user Flipper.disable_percentage_of_actors :search # sets to 0 # or Flipper.disable :stats, Flipper.actors(0) # you can also save a reference to a specific feature feature = Flipper[:search] feature.enable_percentage_of_actors 10 feature.enabled? user feature.disable_percentage_of_actors # sets to 0 ``` ## 4. Percentage of Time Turn this on for a percentage of time. Think load testing new features behind the scenes and such. ```ruby # Register a feature called logging and turn it on for 5 percent of the time. # This could be on during one request and off the next # could even be on first time in request and off second time Flipper.enable_percentage_of_time :logging, 5 Flipper.enabled? :logging # this will return true 5% of the time. Flipper.disable_percentage_of_time :logging # sets to 0 # you can also save a reference to a specific feature feature = Flipper[:search] feature.enable_percentage_of_time, 5 feature.enabled? feature.disable_percentage_of_time ``` Timeness is not a good idea for enabling new features in the UI. Most of the time you want a feature on or off for a user, but there are definitely times when I have found percentage of time to be very useful. ## 5. Group Turn on feature based on the return value of block. Super flexible way to turn on a feature for multiple things (users, people, accounts, etc.) as long as the thing returns true when passed to the block. ```ruby # this registers a group Flipper.register(:admins) do |actor| actor.respond_to?(:admin?) && actor.admin? end Flipper.enable_group :stats, :admins # This registers a stats feature and turns it on for admins (which is anything that returns true from the registered block). Flipper.disable_group :stats, :admins # turn off the stats feature for admins person = Person.find(params[:id]) Flipper.enabled? :stats, person # check if enabled, returns true if person.admin? is true # you can also use shortcut methods. This also registers a stats feature and turns it on for admins. feature = Flipper[:search] feature.enable_group :admins feature.enabled? person feature.disable_group :admins ``` Here's a quick explanation of the above code block: ```ruby Flipper.register(:admins) do |actor| actor.respond_to?(:admin?) && actor.admin? end ``` - The above first registers a group called `admins` which essentially saves a [Proc](http://www.eriktrautman.com/posts/ruby-explained-blocks-procs-and-lambdas-aka-closures) to be called later. The `actor` is an instance of the `Flipper::Types::Actor` that wraps the thing being checked against and `actor.thing` is the original object being checked. ```ruby Flipper.enable_group :stats, :admins ``` - The above enables the stats feature to any object that returns true from the `:admins` proc. ```ruby person = Person.find(params[:id]) Flipper.enabled? :stats, person # check if person is enabled, returns true if person.admin? is true ``` When the `person` object is passed to the `enabled?` method, it is then passed into the proc. If the proc returns true, the entire statement returns true and so `Flipper[:stats].enabled? person` returns true. Whatever logic follows this conditional check is then executed. There is no requirement that the thing yielded to the block be a user model or whatever. It can be anything you want, therefore it is a good idea to check that the thing passed into the group block actually responds to what you are trying to do in the `register` proc. In your application code, you can do something like this now: ```ruby if Flipper.enabled? :stats, some_admin # do thing... else # do not do thing end ``` flipper-0.21.0/docs/Instrumentation.md000066400000000000000000000016431404600161700177130ustar00rootroot00000000000000# Instrumentation Flipper comes with automatic instrumentation. By default these work with ActiveSupport::Notifications, but only require the pieces of ActiveSupport that are needed and only do so if you actually attempt to require the instrumentation files listed below. To use the log subscriber: ```ruby # Gemfile gem "activesupport" # config/initializers/flipper.rb (or wherever you want it) require "flipper/instrumentation/log_subscriber" ``` To use the statsd instrumentation: ```ruby # Gemfile gem "activesupport" gem "statsd-ruby" # config/initializers/flipper.rb (or wherever you want it) require "flipper/instrumentation/statsd" Flipper::Instrumentation::StatsdSubscriber.client = Statsd.new # or whatever your statsd instance is ``` You can also do whatever you want with the instrumented events. Check out [this example](https://github.com/jnunemaker/flipper/blob/master/examples/instrumentation.rb) for more. flipper-0.21.0/docs/Optimization.md000066400000000000000000000106751404600161700172030ustar00rootroot00000000000000# Optimization ## Memoization By default, Flipper will preload and memoize all features to ensure one adapter call per request. This means no matter how many times you check features, Flipper will only make one network request to Postgres, MySQL, Redis, Mongo or whatever adapter you are using for the length of the request. ### Preloading Flipper will preload all features before each request by default, which is recommended if you have a limited number of features (< 100?) and they are used on most requests. If you have a lot of features, but only a few are used on most requests, you may want to customize preloading: ```ruby # config/initializers/flipper.rb Rails.application.configure do # Load specific features that are used on most requests config.flipper.preload = [:stats, :search, :some_feature] # Or completely disable preloading config.flipper.preload = false end ``` Features that are not preloaded are still memoized, ensuring one adapter call per feature during a request. ### Skip memoization Prevent preloading and memoization on specific requests by setting `memoize` to a proc that evaluates to false. ```ruby # config/initializers/flipper.rb Rails.application.configure do config.flipper.memoize = ->(request) { !request.path.start_with?("/assets") } end ``` ### Disable memoization To disable memoization entirely: ```ruby Rails.application.configure do config.flipper.memoize = false end ``` ### Advanced Memoization is implemented as a Rack middleware, which can be used manually in any Ruby app: ```ruby use Flipper::Middleware::Memoizer, preload: true, unless: ->(request) { request.path.start_with?("/assets") } ``` **Also Note**: If you need to customize the instance of Flipper used by the memoizer, you can pass the instance to `SetupEnv`: ```ruby use Flipper::Middleware::SetupEnv, -> { Flipper.new(...) } use Flipper::Middleware::Memoizer ``` ## Cache Adapters Cache adapters allow you to cache adapter calls for longer than a single request and should be used alongside the memoization middleware to add another caching layer. ### Dalli > Dalli is a high performance pure Ruby client for accessing memcached servers. https://github.com/petergoldstein/dalli Example using the Dalli cache adapter with the Memory adapter and a TTL of 600 seconds: ```ruby Flipper.configure do |config| config.adapter do dalli = Dalli::Client.new('localhost:11211') adapter = Flipper::Adapters::Memory.new Flipper::Adapters::Dalli.new(adapter, dalli, 600) end end ``` ### RedisCache Applications using [Redis](https://redis.io/) via the [redis-rb](https://github.com/redis/redis-rb) client can take advantage of the RedisCache adapter. Initialize `RedisCache` with a flipper [adapter](https://github.com/jnunemaker/flipper/blob/master/docs/Adapters.md), a Redis client instance, and an optional TTL in seconds. TTL defaults to 3600 seconds. Example using the RedisCache adapter with the Memory adapter and a TTL of 4800 seconds: ```ruby require 'flipper/adapters/redis_cache' Flipper.configure do |config| config.adapter do redis = Redis.new(url: ENV['REDIS_URL']) memory_adapter = Flipper::Adapters::Memory.new Flipper::Adapters::RedisCache.new(memory_adapter, redis, 4800) end end ``` ### ActiveSupportCacheStore Rails applications can cache Flipper calls in any [ActiveSupport::Cache::Store](http://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html) implementation. Add this line to your application's Gemfile: gem 'flipper-active_support_cache_store' And then execute: $ bundle Or install it yourself with: $ gem install flipper-active_support_cache_store Example using the ActiveSupportCacheStore adapter with ActiveSupport's [MemoryStore](http://api.rubyonrails.org/classes/ActiveSupport/Cache/MemoryStore.html), Flipper's [Memory adapter](https://github.com/jnunemaker/flipper/blob/master/lib/flipper/adapters/memory.rb), and a TTL of 5 minutes. ```ruby require 'active_support/cache' require 'flipper/adapters/active_support_cache_store' Flipper.configure do |config| config.adapter do Flipper::Adapters::ActiveSupportCacheStore.new( Flipper::Adapters::Memory.new, ActiveSupport::Cache::MemoryStore.new # Or Rails.cache, expires_in: 5.minutes ) end end ``` Setting `expires_in` is optional and will set an expiration time on Flipper cache keys. If specified, all flipper keys will use this `expires_in` over the `expires_in` passed to your ActiveSupport cache constructor. flipper-0.21.0/docs/active_record/000077500000000000000000000000001404600161700167735ustar00rootroot00000000000000flipper-0.21.0/docs/active_record/README.md000066400000000000000000000112761404600161700202610ustar00rootroot00000000000000# Flipper ActiveRecord An ActiveRecord adapter for [Flipper](https://github.com/jnunemaker/flipper). Supported Active Record versions: * 5.0.x * 6.0.x ## Installation Add this line to your application's Gemfile: gem 'flipper-active_record' And then execute: $ bundle Or install it yourself with: $ gem install flipper-active_record ## Usage For your convenience a migration generator is provided to create the necessary migrations for using the active record adapter. By default this generates a migration that will create two database tables - `flipper_features` and `flipper_gates`. $ rails g flipper:active_record Note that the active record adapter requires the database tables to be created in order to work; failure to run the migration first will cause an exception to be raised when attempting to initialize the active record adapter. Flipper will be configured to use the ActiveRecord adapter when `flipper-active_record` is loaded. But **if you need to customize the adapter**, you can add this to an initializer: ```ruby require 'flipper/adapters/active_record' Flipper.configure do |config| config.adapter { Flipper::Adapters::ActiveRecord.new } end ``` ## Internals Each feature is stored as a row in a features table. Each gate is stored as a row in a gates table, related to the feature by the feature's key. ```ruby # Register a few groups. Flipper.register(:admins) { |thing| thing.admin? } Flipper.register(:early_access) { |thing| thing.early_access? } # Create a user class that has flipper_id instance method. User = Struct.new(:flipper_id) Flipper.enable :stats Flipper.enable_group :stats, :admins Flipper.enable_group :stats, :early_access Flipper.enable_actor :stats, User.new('25') Flipper.enable_actor :stats, User.new('90') Flipper.enable_actor :stats, User.new('180') Flipper.enable_percentage_of_time :stats, 15 Flipper.enable_percentage_of_actors :stats, 45 Flipper.enable :search puts 'all rows in features table' pp Flipper::Adapters::ActiveRecord::Feature.all # [#, # #] puts puts 'all rows in gates table' pp Flipper::Adapters::ActiveRecord::Gate.all # [#, # #, # #, # #, # #, # #, # #, # #, # #] puts puts 'flipper get of feature' pp adapter.get(flipper[:stats]) # flipper get of feature ``` ## Contributing 1. Fork it 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Added some feature'`) 4. Push to the branch (`git push origin my-new-feature`) 5. Create new Pull Request flipper-0.21.0/docs/active_support_cache_store/000077500000000000000000000000001404600161700215705ustar00rootroot00000000000000flipper-0.21.0/docs/active_support_cache_store/README.md000066400000000000000000000056451404600161700230610ustar00rootroot00000000000000# Flipper ActiveSupportCacheStore An [ActiveSupportCacheStore](http://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html) adapter for [Flipper](https://github.com/jnunemaker/flipper). ## Installation Add this line to your application's Gemfile: gem 'flipper-active_support_cache_store' And then execute: $ bundle Or install it yourself with: $ gem install flipper-active_support_cache_store ## Usage ```ruby require 'active_support/cache' require 'flipper/adapters/active_support_cache_store' Flipper.configure do |config| config.adapter do Flipper::Adapters::ActiveSupportCacheStore.new( Flipper::Adapters::Memory.new, ActiveSupport::Cache::MemoryStore.new, # Or Rails.cache expires_in: 5.minutes ) end end ``` Setting `expires_in` is optional and will set an expiration time on Flipper cache keys. If specified, all flipper keys will use this `expires_in` over the `expires_in` passed to your ActiveSupport cache constructor. ## Internals Each feature is stored in the underlying cache store. This is an example using `ActiveSupport::Cache::MemoryStore` with the [Flipper memory adapter](https://github.com/jnunemaker/flipper/blob/master/lib/flipper/adapters/memory.rb). Each key is namespaced under `flipper/v1/feature/` ```ruby require 'active_support/cache' require 'flipper/adapters/active_support_cache_store' memory_adapter = Flipper::Adapters::Memory.new cache = ActiveSupport::Cache::MemoryStore.new adapter = Flipper::Adapters::ActiveSupportCacheStore.new(memory_adapter, cache) flipper = Flipper.new(adapter) # Register a few groups. Flipper.register(:admins) { |thing| thing.admin? } Flipper.register(:early_access) { |thing| thing.early_access? } # Create a user class that has flipper_id instance method. User = Struct.new(:flipper_id) flipper[:stats].enable flipper[:stats].enable_group :admins flipper[:stats].enable_group :early_access flipper[:stats].enable_actor User.new('25') flipper[:stats].enable_actor User.new('90') flipper[:stats].enable_actor User.new('180') flipper[:stats].enable_percentage_of_time 15 flipper[:stats].enable_percentage_of_actors 45 flipper[:search].enable # reading all feature keys pp cache.read("flipper/v1/features") # # reading a single feature pp cache.read("flipper/v1/feature/stats") { :boolean=>"true", :groups=>#, :actors=>#, :percentage_of_actors=>"45", :percentage_of_time=>"15" } # flipper get of feature pp adapter.get(flipper[:stats]) { :boolean=>"true", :groups=>#, :actors=>#, :percentage_of_actors=>"45", :percentage_of_time=>"15" } ``` ## Contributing 1. Fork it 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Added some feature'`) 4. Push to the branch (`git push origin my-new-feature`) 5. Create new Pull Request flipper-0.21.0/docs/api/000077500000000000000000000000001404600161700147335ustar00rootroot00000000000000flipper-0.21.0/docs/api/README.md000066400000000000000000000404451404600161700162210ustar00rootroot00000000000000# Flipper::Api API for the [Flipper](https://github.com/jnunemaker/flipper) gem. ## Installation Add this line to your application's Gemfile: gem 'flipper-api' And then execute: $ bundle Or install it yourself as: $ gem install flipper-api ## Usage `Flipper::Api` is a mountable application that can be included in your Rails/Ruby apps. In a Rails application, you can mount `Flipper::Api` to a route of your choice: ```ruby # config/routes.rb YourRailsApp::Application.routes.draw do mount Flipper::Api.app(flipper) => '/flipper/api' end ``` ### Mount Priority - important if using Flipper::UI There can be more than one router in your application. Make sure if you choose a path that begins with the same pattern as where Flipper::UI is mounted that the app with the longer pattern is mounted first. *bad:* ```ruby YourRailsApp::Application.routes.draw do mount Flipper::UI.app(flipper) => '/flipper' mount Flipper::Api.app(flipper) => '/flipper/api' end ``` In this case any requests to /flipper\* will be routed to Flipper::UI - including /flipper/api* requests. Simply swap these two to make sure that any requests that don't match /flipper/api\* will be routed to Flipper::UI. *good:* ```ruby YourRailsApp::Application.routes.draw do mount Flipper::Api.app(flipper) => '/flipper/api' mount Flipper::UI.app(flipper) => '/flipper' end ```` For more advanced mounting techniques and for suggestions on how to mount in a non-Rails application, it is recommend that you review the [`Flipper::UI` usage documentation](https://github.com/jnunemaker/flipper/blob/master/docs/ui/README.md#usage) as the same approaches apply to `Flipper::Api`. ## Endpoints **Note:** Example CURL requests below assume a mount point of `/flipper/api`. ### Get all features **URL** `GET /features` **Request** ``` curl http://example.com/flipper/api/features ``` **Response** Returns an array of feature objects: ```json { "features": [ { "key": "search", "state": "on", "gates": [ { "key": "boolean", "name": "boolean", "value": false }, { "key": "groups", "name": "group", "value": [] }, { "key": "actors", "name": "actor", "value": [] }, { "key": "percentage_of_actors", "name": "percentage_of_actors", "value": 0 }, { "key": "percentage_of_time", "name": "percentage_of_time", "value": 0 } ] }, { "key": "history", "state": "off", "gates": [ { "key": "boolean", "name": "boolean", "value": false }, { "key": "groups", "name": "group", "value": [] }, { "key": "actors", "name": "actor", "value": [] }, { "key": "percentage_of_actors", "name": "percentage_of_actors", "value": 0 }, { "key": "percentage_of_time", "name": "percentage_of_time", "value": 0 } ] } ] } ``` ### Create a new feature **URL** `POST /features` **Parameters** * `name` - The name of the feature (Recommended naming conventions: lower case, snake case, underscores over dashes. Good: foo_bar, foo. Bad: FooBar, Foo Bar, foo bar, foo-bar.) **Request** ``` curl -X POST -d "name=reports" http://example.com/flipper/api/features ``` **Response** On successful creation, the API will respond with an empty JSON response. ### Retrieve a feature **URL** `GET /features/{feature_name}` **Parameters** * `feature_name` - The name of the feature to retrieve **Request** ``` curl http://example.com/flipper/api/features/reports ``` **Response** Returns an individual feature object: ```json { "key": "search", "state": "off", "gates": [ { "key": "boolean", "name": "boolean", "value": false }, { "key": "groups", "name": "group", "value": [] }, { "key": "actors", "name": "actor", "value": [] }, { "key": "percentage_of_actors", "name": "percentage_of_actors", "value": 0 }, { "key": "percentage_of_time", "name": "percentage_of_time", "value": 0 } ] } ``` ### Delete a feature **URL** `DELETE /features/{feature_name}` **Parameters** * `feature_name` - The name of the feature to delete **Request** ``` curl -X DELETE http://example.com/flipper/api/features/reports ``` **Response** Successful deletion of a feature will return a 204 No Content response. ### Clear a feature **URL** `DELETE /features/{feature_name}/clear` **Parameters** * `feature_name` - The name of the feature to clear **Request** ``` curl -X DELETE http://example.com/flipper/api/features/reports/clear ``` **Response** Successful clearing (removing of all gate values) of a feature will return a 204 No Content response. ## Gates The API supports enabling / disabling any of the Flipper [gates](https://github.com/jnunemaker/flipper/blob/master/docs/Gates.md). Gate endpoints follow the url convention: **enable** `POST /{feature_name}/{gate_name}` **disable** `DELETE /{feature_name}/{gate_name}` and on a succesful request return a 200 HTTP status and the feature object as the response body. ### Boolean enable a feature **URL** `POST /features/{feature_name}/boolean` **Parameters** * `feature_name` - The name of the feature to enable **Request** ``` curl -X POST http://example.com/flipper/api/features/reports/boolean ``` **Response** Successful enabling of the boolean gate will return a 200 HTTP status and the feature object as the response body. ```json { "key": "reports", "state": "on", "gates": [ { "key": "boolean", "name": "boolean", "value": true }, { "key": "groups", "name": "group", "value": [] }, { "key": "actors", "name": "actor", "value": [] }, { "key": "percentage_of_actors", "name": "percentage_of_actors", "value": 0 }, { "key": "percentage_of_time", "name": "percentage_of_time", "value": 0 } ] } ``` ### Boolean disable a feature **URL** `DELETE /features/{feature_name}/boolean` **Parameters** * `feature_name` - The name of the feature to disable **Request** ``` curl -X DELETE http://example.com/flipper/api/features/reports/boolean ``` **Response** Successful disabling of the boolean gate will return a 200 HTTP status and the feature object. ```json { "key": "reports", "state": "off", "gates": [ { "key": "boolean", "name": "boolean", "value": false }, { "key": "groups", "name": "group", "value": [] }, { "key": "actors", "name": "actor", "value": [] }, { "key": "percentage_of_actors", "name": "percentage_of_actors", "value": 0 }, { "key": "percentage_of_time", "name": "percentage_of_time", "value": 0 } ] } ``` ### Enable Group **URL** `POST /features/{feature_name}/groups` **Parameters** * `feature_name` - The name of the feature * `name` - The name of a registered group to enable **Request** ``` curl -X POST -d "name=admins" http://example.com/flipper/api/features/reports/groups ``` **Response** Successful enabling of the group will return a 200 HTTP status and the feature object as the response body. ```json { "key": "reports", "state": "conditional", "gates": [ { "key": "boolean", "name": "boolean", "value": false }, { "key": "groups", "name": "group", "value": ["admins"] }, { "key": "actors", "name": "actor", "value": [] }, { "key": "percentage_of_actors", "name": "percentage_of_actors", "value": 0 }, { "key": "percentage_of_time", "name": "percentage_of_time", "value": 0 } ] } ``` ### Disable Group **URL** `DELETE /features/{feature_name}/groups` **Parameters** * `feature_name` - The name of the feature * `name` - The name of a registered group to disable **Request** ``` curl -X DELETE -d "name=admins" http://example.com/flipper/api/features/reports/groups ``` **Response** Successful disabling of the group will return a 200 HTTP status and the feature object as the response body. ```json { "key": "reports", "state": "off", "gates": [ { "key": "boolean", "name": "boolean", "value": false }, { "key": "groups", "name": "group", "value": [] }, { "key": "actors", "name": "actor", "value": [] }, { "key": "percentage_of_actors", "name": "percentage_of_actors", "value": 0 }, { "key": "percentage_of_time", "name": "percentage_of_time", "value": 0 } ] } ``` ### Enable Actor **URL** `POST /features/{feature_name}/actors` **Parameters** * `feature_name` - The name of the feature * `flipper_id` - The flipper_id of actor to enable **Request** ``` curl -X POST -d "flipper_id=User;1" http://example.com/flipper/api/features/reports/actors ``` **Response** Successful enabling of the actor will return a 200 HTTP status and the feature object as the response body. ```json { "key": "reports", "state": "conditional", "gates": [ { "key": "boolean", "name": "boolean", "value": false }, { "key": "groups", "name": "group", "value": [] }, { "key": "actors", "name": "actor", "value": ["User;1"] }, { "key": "percentage_of_actors", "name": "percentage_of_actors", "value": 0 }, { "key": "percentage_of_time", "name": "percentage_of_time", "value": 0 } ] } ``` ### Disable Actor **URL** `DELETE /features/{feature_name}/actors` **Parameters** * `feature_name` - The name of the feature * `flipper_id` - The flipper_id of actor to disable **Request** ``` curl -X DELETE -d "flipper_id=User;1" http://example.com/flipper/api/features/reports/actors ``` **Response** Successful disabling of the actor will return a 200 HTTP status and the feature object as the response body. ```json { "key": "reports", "state": "off", "gates": [ { "key": "boolean", "name": "boolean", "value": false }, { "key": "groups", "name": "group", "value": [] }, { "key": "actors", "name": "actor", "value": [] }, { "key": "percentage_of_actors", "name": "percentage_of_actors", "value": 0 }, { "key": "percentage_of_time", "name": "percentage_of_time", "value": 0 } ] } ``` ### Enable Percentage of Actors **URL** `POST /features/{feature_name}/percentage_of_actors` **Parameters** * `feature_name` - The name of the feature * `percentage` - The percentage of actors to enable **Request** ``` curl -X POST -d "percentage=20" http://example.com/flipper/api/features/reports/percentage_of_actors ``` **Response** Successful enabling of a percentage of actors will return a 200 HTTP status and the feature object as the response body. ```json { "key": "reports", "state": "conditional", "gates": [ { "key": "boolean", "name": "boolean", "value": false }, { "key": "groups", "name": "group", "value": [] }, { "key": "actors", "name": "actor", "value": [] }, { "key": "percentage_of_actors", "name": "percentage_of_actors", "value": 20 }, { "key": "percentage_of_time", "name": "percentage_of_time", "value": 0 } ] } ``` ### Disable Percentage of Actors **URL** `DELETE /features/{feature_name}/percentage_of_actors` **Parameters** * `feature_name` - The name of the feature **Request** ``` curl -X DELETE http://example.com/flipper/api/features/reports/percentage_of_actors ``` **Response** Successful disabling of a percentage of actors will set the percentage to 0 and return a 200 HTTP status and the feature object as the response body. ```json { "key": "reports", "state": "off", "gates": [ { "key": "boolean", "name": "boolean", "value": false }, { "key": "groups", "name": "group", "value": [] }, { "key": "actors", "name": "actor", "value": [] }, { "key": "percentage_of_actors", "name": "percentage_of_actors", "value": 0 }, { "key": "percentage_of_time", "name": "percentage_of_time", "value": 0 } ] } ``` ### Enable Percentage of Time **URL** `POST /features/{feature_name}/percentage_of_time` **Parameters** * `feature_name` - The name of the feature * `percentage` - The percentage of time to enable **Request** ``` curl -X POST -d "percentage=20" http://example.com/flipper/api/features/reports/percentage_of_time ``` **Response** Successful enabling of a percentage of time will return a 200 HTTP status and the feature object as the response body. ```json { "key": "reports", "state": "conditional", "gates": [ { "key": "boolean", "name": "boolean", "value": false }, { "key": "groups", "name": "group", "value": [] }, { "key": "actors", "name": "actor", "value": [] }, { "key": "percentage_of_actors", "name": "percentage_of_actors", "value": 0 }, { "key": "percentage_of_time", "name": "percentage_of_time", "value": 20 } ] } ``` ### Disable Percentage of Time **URL** `DELETE /features/{feature_name}/percentage_of_time` **Parameters** * `feature_name` - The name of the feature **Request** ``` curl -X DELETE http://example.com/flipper/api/features/reports/percentage_of_time ``` **Response** Successful disabling of a percentage of time will set the percentage to 0 and return a 200 HTTP status and the feature object as the response body. ```json { "key": "reports", "state": "off", "gates": [ { "key": "boolean", "name": "boolean", "value": false }, { "key": "groups", "name": "group", "value": [] }, { "key": "actors", "name": "actor", "value": [] }, { "key": "percentage_of_actors", "name": "percentage_of_actors", "value": 0 }, { "key": "percentage_of_time", "name": "percentage_of_time", "value": 0 } ] } ``` ### Check if features are enabled for an actor **URL** `GET /actors/{flipper_id}` **Parameters** * `keys` - comma-separated list of features to check **Request** ``` curl -X GET http://example.com/flipper/api/actors/User;1?keys=my_feature_1,my_feature_2 ``` **Response** Returns whether the actor with the provided flipper_id is enabled for the specififed feature keys. If no keys are specified all features are returned. ```json { "flipper_id": "User;1", "features": { "my_feature_1": { "enabled": true, }, "my_feature_2": { "enabled": false, } } } ``` ## Errors In the event of an error the Flipper API will return an error object. The error object will contain a Flipper-specific error code, an error message, and a link to documentation providing more information about the error. *example error object* ```json { "code": 1, "message": "Feature not found", "more_info": "https://github.com/jnunemaker/flipper/tree/master/docs/api#error-code-reference", } ``` ### Error Code Reference #### 1: Feature Not Found The requested feature does not exist. Make sure the feature name is spelled correctly and exists in your application's database. #### 2: Group Not Registered The requested group specified by the `name` parameter is not registered. Information on registering groups can be found in the [Gates documentation](https://github.com/jnunemaker/flipper/blob/master/docs/Gates.md). #### 3: Percentage Invalid The `percentage` parameter is invalid or missing. `percentage` must be an integer between 0-100 inclusive and cannot be blank. #### 4: Flipper ID Invalid The `flipper_id` parameter is invalid or missing. `flipper_id` cannot be empty. #### 5: Name Invalid The `name` parameter is missing. Make sure your request's body contains a `name` parameter. flipper-0.21.0/docs/http/000077500000000000000000000000001404600161700151415ustar00rootroot00000000000000flipper-0.21.0/docs/http/README.md000066400000000000000000000034611404600161700164240ustar00rootroot00000000000000# Flipper Http HTTP adapter for use with the [Flipper Api](https://github.com/jnunemaker/flipper/blob/master/docs/api/README.md). Given you have [mounted](https://github.com/jnunemaker/flipper/blob/master/docs/api/README.md#user-content-usage) the Flipper Api on an application, you can use the HTTP adapter to interact with Flipper just like any other adapter, and internally it will handle all the http requests for you. This means that you can have the application exposing the API store your Flipper data, but interact with it from other Ruby apps. Initialize the HTTP adapter with a configuration Hash. ```ruby require 'flipper/adapters/http' Flipper.configure do |config| config.adapter do Flipper::Adapters::Http.new({ url: 'http://app.com/mount-point', # required headers: { 'X-Custom-Header' => 'foo' }, basic_auth_username: 'user123', basic_auth_password: 'password123' read_timeout: 5, open_timeout: 2, }) end end ``` **Required keys**: * url: String url where [Flipper Api](https://github.com/jnunemaker/flipper/blob/master/docs/api/README.md) is mounted. **Optional keys**: *These will affect every request the adapter makes. For example, send basic auth credentials with every request.* * headers: HTTP headers. * basic_auth_username: Basic Auth username. * basic_auth_password: Basic Auth password. * read_timeout: [number in seconds](https://docs.ruby-lang.org/en/2.3.0/Net/HTTP.html#attribute-i-read_timeout). * open_timeout: [number in seconds](https://docs.ruby-lang.org/en/2.3.0/Net/HTTP.html#attribute-i-open_timeout). * debug_output: Set an output stream for debugging (e.g. `debug_output: $stderr`). The output stream is passed on to [Net::HTTP#set_debug_output](https://ruby-doc.org/stdlib-2.4.1/libdoc/net/http/rdoc/Net/HTTP.html#method-i-set_debug_output). flipper-0.21.0/docs/images/000077500000000000000000000000001404600161700154275ustar00rootroot00000000000000flipper-0.21.0/docs/images/banner.jpg000066400000000000000000001014011404600161700173730ustar00rootroot00000000000000JFIFC  !"$"$C" }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ? ( (((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Ph(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((-|b׼c-},]YPH腋N+VH9 oz_7~&xGJEi쑙rT aYk)+7^!ׅ, 5M !_åm@3j?9.|5}ԫws\mq_ ~ejkv 6*B\u8?x rW55$6QE1ygď.xG66|F.d72J0PkOf|=oA[J4 0fkd40G{Fqkg<+)OPV𮓪ܤi=7,` N2}MxQ ݮjWE¨8r@=8yew]>vk і5֢)WĻo:Skh%VYQؖ3"Xc[kPxVV{ OE`xLh'{ƶ?L𕦑qm[Yą{E{|VoO:ÜY)LIʕar\ T aESW~ Ɛ\_ʙ1=z=JR"/FTs }^W8:t4G½C5zL1dᄊAݻ ޻Z> Gkgᫍ6?p$r%xÁ$ |FOQEQEQEWzA/cA3J KNNHWЕN=sߴGxbhfY.-7|Cz#~5/'iXfkW*3Ts^\Oxͩǚ"3nw{p1GZ懲QE|nf4m&xUY!T9 z1ZxDׯc;(%XAN>W~`Ko|!,>xf :d J ڗP=R Xnf{ydX2`GsQ@GbH&gX,%$@ 3ҳ?g<7y.omi& FU yF đ0 kȿhOx>>-gGc4P6!88%z7O ПDŽ? i*=8_1EfD$J[EBf 2I)k76S^tY<)T?|[_] ķ[o2lWi>< hR٢BVI説IC8_|XxO\{\~4kwdaї8$vIWmh7ufWvS㜑W=QLAY&-|?G\m-@,B8X4-|%Ftqk" darF(ô/ᵕ߇ -I) A>ԴBNLeG бH? JÞNJ>|eQLE doy|nntd{ xR)8bW)# p;n ǎ)!)((*;1["B>¤/ʀ>nOߎ4> 2 8Њ/MN!~p(KF:`'?|ww^.}|E|/WTfbd3CҾg[]+Z-!ݼv8ZHl袊b ~-B>hjڒ=?ii3 ێ{*İ*z] ʂZ6 >`ƸtIڮ#la]$ snjsҽk_4~MoJ +m  RGQ=r%߆i$|q<kt/[fnq}W.?JC>)+h:ïjqqq~ηFS^^o7MDCRվ)|sƕ⟇*P%@M+Ԋ?k~( o}̊ysO9|e+ֺ'~#xaY.-U8ؕm ^ ѴSLt2X[$pHX1qz4ٯESQEQEVoe|90*4ֶpJT,32=kB|y">`ۏŚ;k>O5 wMw(p^/k|C(ocJbW݂3X9:_;5OC~ ׭!C<W~:.mKYjz6NIF)S?#'\JHl(ޯދocqs.[vM18VSwF,no>g[=J }=T.~x87p|~`g;sw?4<{A[t2Rcڤ`ᙎy3[Yܤz'Кl7~L{Ju103s\y# 0J?܏b) (o\xj6eƱ4G?2*Kۥz|f%|\fv'oF6:P_~.[+-&K)|3H ޾D3u |u_E]*__<ү.~$矔L=~$<'k3[}KnZ]3I QE1۶ב|=KTy-6Q-gʀ[ݳ0zx'}!/o)nv;IFA:|L>,jz?F,2( Op+k 7 tK}><5w2K,Id(Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@sW|3꺶}V;ϑ͜3Q@Q@Q@^5𗇼gǥxOus ?:H2`z3qs\ḩ;^EV,6Nʵ 7ڊ8ry4Q@Q@<kiFkˆIBXvcm1o΍LW Bx?*ߢ";khās8$I<ԴQ@Q@ >0uv1|m?}ֺ((|Eh"L4mB&)pt#kJ<7߇uD YyoWgޫS+(+^x=3шv>ӵtTP1xzm鎵QEQEQEQEh ΋GfjڞEǟ#y`f*9@Q@Q@^+m^f9V/5U/(A\7(?)FWQ@]/Llb-!H qmFI$94Q@Q@ jZif/&imʼn˱,O)|q xZm!I#C#ӶqWCE6$XXvPs8~EQEᧁ|_wnԼR077H||1]PEPP^KiyoͼRH@zTPO}x>˜2cecm㷶8aBGjQG8 ( |7]6;w`OyEQcNk~W -Kz<be;Y118bHc*"@Q@s;rk'@Y6ų~s=+ܺU"h^ cIctu ;Q˼MSB-YٌSXǝb Wo< '5H?QF/G'bXۼN=Ҋk7s񝕵=T;JǭrQ*BEjI#o8.q hyqp,"i܂ޤ8Mtz6k"ʟ"c=+StɴŹᗳ_J_և8ymOku=+h!lif}ldaER(((((((((((((((((((((((((((((((((((((((((([K&_;zumcAտ=/[7r:d8AX^6"Z!1h4ϴBf na$z]?:ŮΏ\wuP^uTnW.;]̆]Fm2drE<~.l H׭@uyҵ-#ɷjyq$]WVҮuwho"=KI؋Lq;+/?צ@d~/ci J/͇ټۿv6]=o1~d#HD˕c#6R=k?4ig^Ey.Aw]cAտM+[7r:d8AJ(?b @&F0[p|ǡ ­ν>\|Ng^Ez?VK&eoF7!A*@Ө}'?i^~KHmڥgq`q Wcgi7n{X.'b/1HTP];>/.>lN9x-}?3ao;v1ݍn_Zo/E`ߙ8E&\r0.3Gn@e {?WMgI W|Ng^EeWXmoSJjVM{\Ι829{ygu2743Yۆm=Ɗ#IwtR ӨVi>"w&eoF)AE<R S?m#/i~~KHmڅwq`q WwEU?CWf={jw$W?'N<R '?+oR'оgO7w}2=k0m?F4uo̜C}".U_#ָU?+Ө0Tw?ew~?m[+TҼ蚕q+N3yԢ8}R+C.*3(#9Mˊ('?,gR'~fvcyܾo$_JPu2q?LV;a~\gԏZݢ<R߉ Tw?:(/ºmeWRn#~tq X^7"Z!1h4ϴBf na8#ֻ(?ps$w?e?NlO [vy¸wiZķ"\K'? I׭@|wy-ɷjyq$]WVҮuwhoǾiv;0rEy.A+0߉ })o?e3ݻ_wco>ֺ($_J]>Fg*ͽ# >z wuPŮλ  i^~MJɸk'G<zԢ ( ( (1itl3}"ןȓWޯI6yy}B^o$vZ9k;ŘGW!VgH;ずD9 ox<2}*@==Eb٢ERJ!"IjZ԰\9Qg~<{V#ұ5j[++Qu25Ş`&5{ՈCZ:|$NGP'js"hROz=H Ȧ:1QHxMN*W *ĝjk'U 0srqʻ #"Iaу)^ܭݔ7)D CW咨UxiNT$ޟOEW}0QEQEx?sWu7q#e];z}ZGOVOq3>}։{FrO` z-P]?(Tw?:(O?(ե fF}(q|Ǡ>cAտ=Wnp"dg J(?p.Δ`];:G?(ҥ?5˜}^6N9SҪ})oj? a'n0۝zoJ(0Tw?e)O?:(yZjeL 74[8#|H׮t@wo^ynP#px;WwEy.A*֑SGU[EL.O.0ݷ3{khR߉ P[?J5itCgy x\͌[P1)/O>s\7r?NlO<|_w¯CD/?t=CVi&={&rם3;jxGsWu7q՗ ®^Ey֑SGV\|EL.MŹQ^EOlR ;<UO3:'x[ `m,wd@}+wZseꚯM$~D3ZP~'R&N0|OJ4u {D0X<ͅS.NT}A9")kiX}۷nwq뵽+<-wO\go(yZjeL 74[8#|H׮t@w#ηrx3ڻ(G.έi?5[;W->:C!sFvID \w(oUA+w>;UO7'x[ `m-gWcEeXnuoSU6iuě/șrykW|NlN<|`[?v>'%Tys~ϫf©'+c>V)oj m?>Nݿ>a;ޕ[;>[K'tg^^h6zn>M{McATP ⯈Γ_#o^ynUovGjW|Ng^Ey֑SCV\|EL.MŹQ^EOg@z}x'ǟjD3}W alVrvqnXnuoOU6iuě/șrykR\K'߉ *]C]s]]E?Og@W|Ng^EU/?t=CVi&={&rXgv ⯉Ɠ_#o^ynP+px+<_Ug@R߉ PĞ!h>$SO5=p3k}x{5$>4 o$K23G*}n󿽚xڧI4dYO(>}^/ֱj.Oox9rdKۑҏU {5O!=h_rwP?2Oo{Uǯ$P $f}#_IG܃x{5$OO_ i"8s_e.sdgߚ_WC{5OwW_c?گI42OӦisj>G܃x{5?$>I4I/'_Q@< ڏE jG/' 'sο$iALz_Ⱦ[f'ƽOr!'=dYc9Ry^qqG诲?b{4OET?ƏI$3dG$"eɥz?Ⱦ??_.O_2O4}^/jK,{U?ƃOcCگI5NhO}_[;٪|I.I4xI5z_WC#AOcCI4xo^~'OVAG󿽚}wUGdΚVAkI#1W$AOU?Ʋ:ϷJ>G܃x{5?$_ hdkگ?'V ^ x{5?$??dG$"eQz?Ⱦ[;٭ 'ƽdG$"'5ޜ֏QE #OIG܃x{5$_ k~^xOgwI+fĮI}#r9}M$L5x8a"ɟ]MNMw+((|bIO\,w?Wah1^ᣒːiH>f¯Eڶ1 9q-տ!Jn8-AR2Ksf坠`II9 Gǰ9iaf_v"ZY-IU:z5Y9/_>3BWOYm?;kL Kֵ̚d$NU#k\_M0Ka~ZTۮdeŘ, ս/N!ȨudH?61\\t梮d߽l}qVѻW,R)]N>%[KK /7zGu/ FoZ3)ze} !'ec'vgW5:Rb;kbcNvFΫih3й彀 ڞgotnL*)=:jJQki۫==n|6+ T{ ~5F4IεU+O\\!Sq.ky./na#lŸ65a;Q_2~QEQExoYơmiž^uUHN8IoO{U?Ƶ>,AJ{2='Wºy#L]c%7nyu}٭ '_ iOG܃u_2O_.O񬮔gQE #SO'2O)qڏE!of'ο|h_2OJ88z_ʾ[f$!' 'o'U.~\cWAG󿽚xI4xU?Ʋ8)^/;٫ '_ h$=dY4⏫RE!}n󿽛~#do*]T\oМ/? U?'\G܃ud{T?ƓOU?Ʋҗ}[;٨KW܃u>$?=d@/A{U?[1+9 >}^/jG28׵_ ix<?Ƴ֞ b (}Z١ 'W$G'Vxi^/jI/'|I/j']⏫E #OIG܃x{5$_ iWĞ",?O}Qz?Ⱦ[;٩ '!W$q/گ5^}_r;=?ZSϋ8/kWGY~]ֿe[7.JI !x/xφ!YnbdTc˞^d|gƓM/kPEyޭ/:Ňcg,Z9m'bV=嶌 ǓQ^d<gOZUjx[_tr\a: 7*BNW Cq@WVJ~y,^PȐ  qϋ8UzuZ< G7gWۿՓvWMX>6ύۿՓvWMggm*+ Ŝ;};`cTˎFxy_Ŭ+TƼܭkPY~մ]ֿye[7.JI !x/xdž~!9h{_hnH{FAQ˞^`|o_ZֿU[@EZ~(\w:߰Y;#v%c[-`dqZ+ /kV|1N׭5ߊzt{ {7*Bu;wEUաҮ/~w,]yBO"B, qgŢxӏ+? E-k_*h. O'}%qY'v^}mxUҢωYý#=@%N.; -p^WkWZUw^]5yeK7,J>Uu{x9յX|3 5#cCwML܂2 tsް-P+ZTWo_ZֿUz}Wjs^\|] 9m'bV=FO'%-{_*=:<-Ꮘ~my|OӣX`o_)n:աҮ/~w,]yBO"B+<6Ӄ('%-{_*_ZUz}xEnJ|k 7+ݻvWMg_m*+o Ŝ;};`cTˎEy!kQ_ſ-kZTV_l};AuF=uROp/qxVբ?Fk 12or*6sހ;+̇>,NO"U7Zֵ袼WVO 9gwnO"2Ĭ{e Ǔ^a_ů-_kZUo |Ckwޟ:Cp7vwTU]^*O:[yBO"B, qB-kZO/ Gg%>5gcvdݕouVjLVG#w8ww{ DZ$_/+O+G |rdb$zcv+𭖯h6zǻξ*rWiH^:=R(((+9qre^=_J&_m'y$p9wcV-=ۏk ֆqk{*H~5SWDc12F<ۿZ=kѾ:l{$pA -VPh-׻[wМ%+N6~WE>޵^2bA@@?M´ |m[Ѕ6VPgue俯 `gdȈ\v֝6oVDd,.sW>k㸭8A$|p ˰b0yI.\tH6#5f8NPwC`(o֑UK1 dTjH?OI7$]w㛋M (jCU#ϥpH#<ř嘞ߩZTNsV3ؒ~3Z?r)O Y .ե%faGzzN7{7> $"ү| Goʽge;q?+sn }^ESr(?Y ( ( +W>!:?Oocoqa9`MY[@Ey֓ڵΡ{qNqkݴ^|ah + -{_*Zֵ;>ΓKso_ٴMnR$ބc͞ս-[PЮ,=kPo}Tl+Li^b|kƬܯkRk_ZUz}/ZN-Q %N].ʨBA sjź#NĮ+ϴgnmۆwtWZ׵/*ݶgm^(.. PMrp8q*MG^о(biml-< ;, sv;PwEy!kU_[KChɼ6Wpg4Q^`|o<|kZOZֵwΓsoYMRzNa6{PeEeWt= [d}n<8-¶TsڸOB-kZOX]ti:L&G$w9tv#*&$=~lv +ދ_x'GJFvmۆwr[@Ey_ż+ZUDvM{ȠD^|@i6qgj0{ew ÑעEy!kQ_żkk_*=>iZ>% @46$,>$>lYjsgkz|ﲥǕv+eA^zg=RB-kZ ~5g){^?+ :g&Kox;Ɯ]};MPbc6;U_-|ϴĮ+ϴgnmۆwtWkWZֿU/!%-k_*=:AwmYjoK(E`wq\w1Qn.z||/.<( Xqڀ;+-kZZ?'jg7m`Z=᲻FG#9EC>-m#Hϯ"UzuZGqx$mDƆdބc͞Y~*Bֿ567e[+ ~l+Lp[@Ey!kWa3ZN-Q N]. 1_zF-'J(._`d{T! 9cVދ__x'?W3o6tڀ:z+-wG!kPUt.;mB@qul.㓁+W>"j:CNocoqa@o1`[j0/? E-k_*=>((((((((((((((((((((((((((((((((((((((((((J,M`{ּKQ}?S !#¼▔Rxux|E6C'tai[h\8pòOӥQ7PkF(|Sx֥j=orL^35GG铜%)`*F񶧣"(/l}a>U5ye:/~_rT /_{ 'ո8fALV8syZcy_wjw'w$qEo/.&[.v 9b? 4rp$> ƁM;I} Id?3ׅpY?Gf ƺh/\٢+OԂ(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((jVp3Y.]=E=]N}3Q|3هb=Fxץ@Ő@,?Oט:e8e#+8I$r7+,dd# x:DF%<rؿM6 0{zZ:}ܶkʺ5tJER;>/ ] QL[]W GoՎx?S:?y}H51bz>F?,gTVh3(Ӕ: 枬 Ǯ+^c02IzFyޭ&G<=XKuH-@Rrh:< #LѬ0dv7W3.Ug(%<|[-"m;9fx$r=EfۯxVڣ?kbq]XyTGN&p_"@##~`N9)w&N֔* @㯵^tkV W.'5v%z? kcS l%$y~ZF O nHÔ2~=nWXϭVr[-E,{|( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( |eၨU[>t%]UQj& r+#!>fC/ľvw;4&ʽ~QE3Pq!lq,/G"Ş/yエwPW`Ѹ̪&~Q]X|MJ۱cڢף[@A'{<3,W^ ;{73\uû6:2kۥRF|n+t_w\~ilͻ5$(*8mab8cX{xZU yP c ?2+ũ'Zsp"O!>Ѫ]\,5`OJd.= GEcr}I1JF9Ix/ofKp12}.i; Budve6vIqs3$\5/ӍIuDKS{*o?wޥ"k\u{;:|4uwOd\>v_Y]Q^)AEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPQ][uAq KuW\R@fX$-&q1RzֹSDմ웛)hr:~54V%RDžMF_޽Fүrnyc k&[7l1t<=*{{w&p[ Oug\XyxX=]j1>̑^&Ɨ-q 6)f>q~nV5$5T\5g8!G"gq{^ɧ^[Ewk x\W]\ڵ8s Usriv_~ Y|KzÓocB~J ;M>;KXmmH@@*URW> qŠ(s((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((B5kYy*ں*7tK#_0܏XBhx*Ě>}g_Gԕ& NZ(5 ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( (EPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPA((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((flipper-0.21.0/docs/images/flipper_cloud.png000066400000000000000000007442251404600161700210020ustar00rootroot00000000000000PNG  IHDR(_f3 iCCPICC ProfileHTSY{!!@Bz(ҫ$PBLvepGPGpTD (*`AAY XP,af/ܛ?A8V C% d3pD2rR1+44y3_e@]I__J@8'f ܊\$ 1D3, Jxf#+K| 2f gX8k%Kd"?ٸ3WȖDkf}Jc>6Qr{q<8=TnOY]*TN@ O"r9Yxes%A8>-ZcXX0yĻ{ 2#Gy 3ųR P{83%6L=CO>0Ѐ:X 9|@ ,\ @`(E`*^P8hyp\AxzxF !*@9d1!7 ¡8(JD Z mB?CUzAC[3I0 ւ0fp$Nyp>.#p#|w+xP (:JebP!xT2JZ*DQT;.5ƢhGGe !t#".=!c41g I,`00'10]cXl6ۀmvb8NgsŅ8,\nn}x~= ?NP" !!BE DWb$1XN'^">&SPPSpRS*U(W8pEOIdF"%d-V;2lD Ǔ[ȵ 䏊TEKE"OqbbŐ¢,Q('((J%#%/%jJSJ=JTek ʇ*TT|Tx**5*T(>Ջʥn^а4cJ+uFTUTTUsT+UϨQt#:NJ?N55?gӜ9w|PW+TkPRPQOS/VoR0XG\\ܹs} kikЬѼ9%ڥuAkX]}V{H#)9`1匋]M]]>q=c(z zOLdR6`u LCNvFF1FՌyuƏM&&LMbMiMoffJ[氹|y<Kez& />U~G*[ژpm*mْm}m6۾3oOhfAP0hXä1CW0NNkN;}rvpr>Ka /wssuc%qv8eJeaxʫ]SW7ŷwo_??пؿk#.#+IZ.-l !!OBC {n2=4pXgGQ&QhJtBtmbi qG,ڱh > {Wh,I_rf)e)gDLbL/N5g4T4yJyC|W~ EkrI`k!L0,VߤMv0m"=&!qJ"J] Ľ˜X6" B,(ݐȾeeWf\Dr(FYyy?@h[rʾUUVCV_f`C\od 1Z]]bgƽߣ~߱vӮM y׊ʊln?LlIұammmŇJKJoo,e߱t2;;e;{˃ʛwڶKҳJjSՇݼwx߫h?緯ڨ[]|?8PtAC.:< I8rz} c˟>xD/T,lsGMq͝N׃uOWQ=,lىsyF[ŭS-m{t!½a;.^rvV+WN_uzZu7oidCG-[ͷnt.<{w/c߻޵;~OBO}<~8hc'JOʞj>^3}}7E<{/^輨<=;t増įƇ &##o$o&n~{mO2?~TxS/Ɨ})j[b3 '' @ qt=%7=8P@ nii ]lk+iʹ/;ҚVeXIfMM*iD(_ASCIIScreenshotګiTXtXML:com.adobe.xmp 1320 Screenshot 863 @IDATx eGUow' $< " D ç1"#*2PQF ̄yJBBFd=g}=ݷOUjUk9]H@z =H@z =H@z =H@z =H@z =H@z =H@z =H@z =H@z =H@z =H@z =H@z =H@z =H@z =H@z =H@z =H@z =Hy`I5{`SA-rW`G'6ҐSuxmJDO4!6u}3ڸ062&s)_?2sgr-v=qO NmJ9[׶e79&q5n2۳fa%`A&XJebU%cG)o)G d/TNRdԵM[[OrO^s;?t{o/>Nf_j.?+ 3rRm=?d'߼Lʸ|kOf]^J<ԥ3%4Rz@.M?F,Y: uih7u}f ͺyKG;g&73KhC]8<37@]YB./t@N㙉 ouy)=P&vLo|#PgЬK4qxfo|>f]^J<ԥ3%4Rz@.M}r{M,D01N2/lK K ^Z[?}lI<; s+_?2#z`^87~oGrO4{qʽz,G=_rv|=񾨠68\1!3|$xe,)KO?qc2ax?$(4(Hh///CJ<@o壽9A T|Tqxzlfi)CSoK9r x62Bkw}];iAebOyos2rH~Ț~0_U}؏`}׮%2՗S D_N/Gt?uke^&~q=o]NۻGܓ^(1~O'r9gO{Rmw?ȸfuڂך؟>gIoIfCH| ,'R;O]оا9cE}:SO D_Ώw'߼_+]s1rW?s-#330f//<쳟?Fk.s Fvm98<)bRV0ID]{* 2QWPO۠_Q}rIxI=}x}eS_V׬d;grC,sؼNW7m{4Á(߸'ąRןx!VisM^'G^\ßݥ:ǹ ~^;Յ~x)4yCa`%(I8T<88Z<13IdRvclӞfh4+@UM>Z2G9&o^&BF^?诫:);q +_NrZ$C?z:^O^h&Smdԅ !=e~}5 շCAQx UTG(waDyWc~k~lɯy/?z>^HgM0kC7I?0F"r ;YO\?k I#y}? ⅼwny~K̰Pw_P5(/m?qĺv mY.mJp4>ztNJO8teyB[zҴk@ M[F!bhCGC[F|vd-ť ,ן~>fzo?r5C8zAwćj?xR9 =BB9OY/柢9籏neS,ա}KxC>!:9QGJ/N^JlYʃsQ-=_a+M;ZJկ}lh;qZg}H7>_ 3+Ο8g/kFyu5%^K ý}F=yd}u~\5#q\S{ i ޕ)9Q}O!=PEG<[}st&'dp_:~td)qzh|U@?͓(?6o_w<ا/km}cdq0w?+Gi߼'?yqﺙk7f+_\Kf?Ok}&JGhXPSo^q@4M!c@@:4Y/4d;y}J[؀W67dtƦ>qsꢄ߶4ZB\_3r9#&k kZB|-uO^㌘\C%!Kmhןg2w[ڳ:uB!^7κD~?eC]^(vS:zPJiw_P{޳;A >pc_c xf]9e(q|t<>|ϲ!=>r+G"vB<:~?o _vD_uv%'7?yo^: xM+q*X~k\YkSMkɼ/2?r̊?gA</lZOf=2с:f8NCtʴu6uh4k[# y h{A~a32RrcsA1h><E_3rs"ן~+rO^sƔ1&;by%୳~s|LiVpk Dd\G%'׿>xph :oM.%tDŽ},>u^056 =9 qNI)et"Zܧ`g(\tqνk8]oഩ}pa^o5:Hw>J;+4莐9rQkiyo??rN^3r=|Qskkgi3 S?؃oa,R?%zmSB&fخN a:3" 8HG>A}dhu*Nq / УGe,(''/ן\3x-ѼNWi?&{ j1nr5y3ׄg/1#)~r8wF&yͅnmARY@]_QFe!;ֆЭ(qNm@hLmt(xоNOYص,j<}G/PFҬj_>4@ic%* {L'KbiSK[eMbE_wP~wf:g2_I,KDT\w˗Oy'LF^&א}q:5l%k,紽|Wmc[QzbQѶ?#5;f E AQtOOE*G)WgLjmSf,G6%:Giݓ<9UURxW7%(w+/** Hg}L9׬\rO^s~ϐ~5mS{b 5'^ѣ.YP{{p\|zZKؠ}u@[3zq>8KhG:8uDAULjgq /87 j u+б 69kS<8C8lj>p@i+,OF.>LO⚘O^5!߽͸`d_'޸obs1m,}%uxY3hu EJd5\AUU?yQߚth!Gh:q@|qgrGPF˓G>Ы}  S7>),rzן:?W?kGg&+WxG:~y#n:)igڄ]}  ΢;>tpmިv |]6iS5tniҭc)G 6:8hG,iO#|wxO&^35K^5z?r֋q-WrU#_eɺ4kyd%G% \Qqy9!a['_OCWuQڇǀҵty48(xqۦ(b\:7y^vؾ}:蠛ܹ[ZZ6olawU<WW(+ ~tCiӦZLO3ƃqFcI|_?"ן\x26X/QO^#_7rOƃLx-{y"륾.켳>gug/#aɢ/t{#Fa.Ro-HyoT"*\D19lǁic:}ȂNv rԁȧàm!9׽w;]uUǎ;jJGz =H@z =H@z`x0I8;^~ 'W\q~IJmVN1b⠙'Dsr)m=!Mxc5|{9(:AN*՚PĦ4%NYNy!kɇ4+_ywǧ ( ЇyGrFT֗/6_hMg-$}#_?2yZd"qe/L'ן\rw ן,X/ ) q\_e\+;c}78Wq1*&E;*7i\l#K]i zEkt:20K=+NiUM \Ykפ^#|%@}tye@ '/GSEK!_ \Gyzo?rź8)C7[#B?s~2{ͼ!?z_Gݝt*e6ugh7#F<@[E9Ov,=xiS#`{ C Ǝsv::2,юz c,2~/'t)sPفR%)G@1?W.rԈP\2ƈ2/_3ן~$y "2ymc&yoJy߼cx|bym^z_:Ã2Yh :mꌺb](_:X2/Ψ+屌E3uy,"|Q>ꌺb](_:X2/Ψ+屌E3uy,"|Q>ꌺb](_:X2/Ψ+屌E3uy,"|Q>ꌺb](_:X2/Ψ+屌E3uy,"|Q>ꌺb](_:X2/Ψ+屌E3uy,"|Q>ꌺb](_:X2/Ψ+屌E3uy,"|Q>ꌺb](_:X2/Ψ+屌E3uy,"|Q>ꌺb](_:X2/Ψ+屌E3uy,"|lٲ;Rg90:97eq󡿂Q_=Ƕ<<y;hZ1:Ng(@r>M]{Ff{ja ^7ᣕ Z5\o[Eǖz\?o<1iiVGݖ[!zQ_ƟΣvٖ߲o|)32Øq-MV.Ҥ+ҢVOvm=_$Gο\Øh[r-O󌿵U1bDJwvy{/uimd;=H遽sLw{ݫ۶m[~tu_*qQov"__svU|ˁoZ_-~4'}15x}1؋GG?sjA;Y}&Fi} N;؏2Dijr2GԁcPSgͭŇ"7iL&on&+K1fivGfUj*A;N:GL-0@8b4ǀd:QzAU-կ~ piPM}'AbCGW 6ƒwzQ9#]uqئE:u7Z?蠃.likE;ڹ?KZ[mݺZO8 '9y)N̚;\yJk<ώcn(,ן\\b\ZSꡌglĸg9SpCxPFؓ۶m['>qpEkih-q->̧?׿g﫣$B{#h4~ORƾ{^(#>̋Do~󛓠<ȃ(4/G<8.^$37@v(ы~yq8iW.!:uj<898Jpt>!/Rڗ`YND0&! ض=rQƺ ui>&ߎk_ϾTë Rqv z'qzM?uX'*|c<Km)^mvbO{DvEԣ>x"]XF4K=H@/1Cz#_5-H*9wX_9 Xg>;g-X&<>{r MԝWuWw}ݶ8Kދ;f9ӽ%/g\k~~~\o˼8qo?ʙ(g F3>_%ȯøqwPc 5hWb O `R;Fqt 8p|.?uxp,%:ױ}(B,azPE>Q[x^+^~yh>n=N,W;N>'xiC~cFO|Ks9k町_k#[١vn -i 9wP?B^Ey>V^%rC뻡6>kb]RneQ^%X-~"C;C!x(/xle~;Ӻ|;2S%8[:uK~=E}yg+Nݶ!~:M;'n俭\ nzӛY,޳:^t~~&gDm[>9>>mnS/c Ys,Ѭi}7o;z!EQ.'w?ݼyf7Y~>VyCcci{rGF?')߭{^Z_{v};=ݗ.=lWILݟ~ms nqVSr{^x|ygoO 6} !zȏH~Qq[T'r+ݯ#_h=$pW#8H_\7P.ȉ(>/@ y9J+G ^]/uH jC04W< ;hǪOL"ɯp D_|+KGY.mo.8GD㖷_\T}76]nok7l;/?삳 .>n CZsWX>7_}xYA~T—/fIVcww>='vwzG^!V+*GGwOQX<솇u_W38=,IuzÅDx]C'977mz//c.\?:(Y=?+şQg_[+u>Έ9{>/?ꨣnwo;[ߪ.?֝} g CS}a>w߿+ڻӝTt`[;Cah$ sƯN!ұ .`rHm۶u_җJ1xG'y|]ze=zfښ_<|ث^X y7ݤX~x?[1-7c9~;0svLo쳶}ѵߜcnzL};l}:_ ƿiiS9s}wAq^H,pSwNogN~7~žߩ7w[=a|[]omR!1_^zRyr7UHG>+-|ݝ|㏯ >ݷ˾ njӔ2-TDsNԝ}ɦ.igZH{>p,+_}o_8H#N? oxC}Y2{#cO 2㐎#m$^ yhz~=¹//M1|<}ڽC,8{P# 3pē̈́FN\,Q'Ghʐ QK8bvxЦ9 ]2ZmAǦ|-6ٟS߮cnMT[mz / "ON1|;&GvW-}<w$9yq#mYR࠽Qm?hۏ|U$'lfًk]E2ڦ~Dv]tQﴧbێEbW{+_9yiYx0~]F xCok1TFFv0Fc}i??g4?c1MkWG=}hǍ$ԧv{Xy~4,t-ݨ_ZK?S?]R}Aq['qܧnrڮgWx;.lr?Z@nhqsG5ɟ~>ı4~}d8_U~N>>OmWP7qHq|޶#~_.8-/*+uh))*䃧7{n%п6SiQوWUwB+TGY$%O8ᄎ7MȲg(2Z*A; s9'Ӵ6/$yog''o^gySup$.+ۿw&iY+/ \){^{+dJi/Ҷiz|hr/կmh޿npRn^}YM;'r+4o|gÃw?V6~HF14oO կmwQcSn^W<㇯AL|/ڀ96̣і8zІQΤgG[cJG{.`@ rv6qd )鼝/thҁ_OYs[atbkmh|䖒é(|rȡۅ>|@m^/OagYT/8})~''8nnmZ-gWzOAza0J}& ޒ 9~&'y [Z[*j>&zJ:8KicϾg9mx≕: Һ8h=ANQ>GO]Z-o[MwyӟC`AX߶2N˵%O>)>eĵi </~_ݭ Cyst Y·mDԧ?SOʣ'4qQgMa0Z_&r!fގ) &1e'N*~n$ ^tq }s .\ Fh$VKLzQ_\Pxw=&|>K^_':4TOok N JghSץg׺p,X^]ѹtӾsfwMN@죯 ][9|ؗU^75)geS<__??k}:/c牔ߨ_U6A׼&M~۾}{=yݳe]ғT_q>SƛWDq9I%_zXE4ߣ)Gc6Zqx u[/uz?3O{jrWg\]TO6K! 뫶)KbS'DxyJ`h|gx2~w=VU}5_s#N|N'I(tuI^-%RV7琧i^EXP;Q֌|^SO$8|@k\w]<7q]@IDAT@9`̼wW|=x*>|ʓrȡ|tO␹3$Olӟt?/Od0&׾:/H:KbL]G8'szgkmjKǑ&ֈ9+=19nj D'v3s_Üo|̜ I\c(x]'| *ǎUL;̕>z֠-OȺ67E|烯'7o $8Ogquo:YW ~@Tk/dN/ uO|&'A3?j ]pm/ QyXX^[_bYVcɦn.^_u\~ (8ٞW~}^,=5R[Q?ؗ5lOky`fyx?iG{E' 3J^}:8ciKWPsjS]f胺fJi]5 Z3m2xQ*G6D.M)tP5`$^.?Tnޏ3Eg9:ۃo5R>zF x6}?uEE?8T?<>4~mjoZ7,6o-lsH讃}?kG=)O8"nfD"źvgƏmʫ ʶn;.}K}O >pᆑDZMp ,)nlkh &A⁤7$#Ϻ=4*# Erй#~`UpM֛p^ܘQk|-fʯJ}Dmo{[O}K$iA?vD~s?Mn77<.OG_'D}=lF'I0bnz&}GҊ csDb:.vK$Mm۶+%1I$yKB"熘Ihb"_#i: 8BB@ bD ||#:K~2? wlcO|؟X25|" :wbʸςϲ|d|O*:K)vF1s>YXtD˴s4ȓu tp mr>qsC>!&0F|eN36$/;&+ I;u$1hG '. kIn K?YX' ׏Ղ)4[j˖c5A'=|Cc}$܆t3k;$7 N}ю8-NJ%}'J-&yq\ݝt\U^GcNlcKԕoV7O[9}\ϣ'u5⬻־ՔkD]p!٨>2JrZv?z\yStry5֣'YL]S@(is;2:f:ԑN(W/t]RuCAnѶN(W ꉺ3է~(/r^jeOܼrqC~k:;SjO}kߴ<򶺔iKtLH[D[?~-8@.RVdۗ2nJv< 7O⊛I`jtC7D$nj}ʑ@'A?ߋay6Iߖ5'H>zV n۶m^, ۿ"vy~ 7:y*O<%SA'JI%nϻ8OK5"JIrNx"ߓ{ \crrnG m$  6FusD$ D]d8$ Io$8T# .~B8dI'&O_$VH XHf2^Iғd+cG<%čI(iVav;G'Ll+KG@< &ALό -M2ѱ7O2|mi,=J$H>1' *3bu[Oh.<I̍9!Y 琧~!HűR[<;4ye->>& |&Gl<-oM0O :>~c6bݾ#$! @Lt)b!G1nWڥDFf}'>Qcs"_xuD)9@V-Gʀѷ~RJz#y7Jݾ}ޓ% Y*wu?{zK Nܝsq|O/bygXѵ2-vtA;h _G>rlHy3q@(jv}G}1xt_8uS#%S-߿Pu_uAnz⹙>6>I“z'x]XO9.JxC]>g՗Ͷ~ѿ)?%ǶmEz2~)+$vX[#1 @~>QGsn$!'AIc^n0,$Ao$wH0uٛenS=d#IrI`pÌ|LPIҧ?CD{6;I>~&(IGF/@I۷p;wDV<9/Gꨊma_c~>c'aF&+$fصI=9t'8d~ _@҅$!|pIRL|#?Ictj:2yB'&3KxrX EbWP<5m@B'ƚZA_>q/o 4|mCq$d9:#~u m>" yB?+G\Kn"ʇv7lb>_tۿv9]ݧ[盛[]׿S1\[Ƴ1w5̦5uVI_+O_EW6w9u8 ө?OWtw,O[ 7I_U{ewy~N_9{qE?E:r7+OȚי,8DAJ`$$O@s+՚cNSX*FE1<3K:(;Āqhv:@9,8lCHDyQbP'|S{m߿"WC#]{i /63 )' Wlϲ-&'Q|%ٕ?D_IޠȾƸp!Fߌ?im[w|mD6Ңi ~6?Ɗ1܆x*[ 7 㦚HZ"24=&If=m??Qb:G$a7ހ47|}=D+ǀNyLL =rcMDk"ߙɍ'@ҔVd0Oa&~$$ORhB+G?LZhq1 De$H/'@B5ĭ@#,oޚ 0T^%YǓxI)CuGe"8L":>J?czwt ^'oq_o:X ~j1$WFI-O9g=6 &9L(Wd \d}N'%Ƹ1:'^G}T]WL`;6x7 "c|%\Z2_~}t3ɶGh}O|%CG":&)/|qb͚ >qZ\WPl㉧^!:|<_m~ Kh6kDN󦻺=;JrK37;'#|o>CtEfSwW[ķrmݓ}}\.w=rg4w+ 3$Qyh-w^dNYx˂^-دZ]+؟ 9u?Y<@"f[Й&2#fFiN6tJe?hZB:Cܠҹ#訝BL:uf["J6h@4]u4qūء!yzdL#Z$(Ip.xbkH~OO>㳿/#XIOT9 ڵd v^c57~9J$y~om$T-qD'>MHc<з H$_<' S^&q0YH>6 6@?cϙs}. 19?ŎWY|Fd$TD-juod$ xU7 6F9Ü?63vr-o?ʢo=As?qri>2HW:/!?|m~mvc[ʯt/_{GW$.Ϲd͍!= t/}b_e_^F*gߞAQ}xݶHΛ~f쾦cEbC;|/K_gkj?ri˱kעߋDGxH:(/w9H#xOMBk,u*+ҋ4ڷ^P?m}MP:fG4>d4(` E}.Fs9lJWE$-;Gt-ݍrhklzIN^GUM#SuEZ7ON꘧v l?bˍ{m+>SMu$Ix<Ռqeg>Ƙlظa\ 1^#d 7je%_YFYu6l<# xz+˹-8(Wد_4UK^Fktm50Bo2ԍlHPR"B↤:ܐŒD2ƒc 5Ip܄s$-b#ǡIb\@ >ۿ*,7$'p@<<>Nҁ$kOPr}W_3|S˵g~~o5$/ǺOO5Q_H^$i0X!T&\W$k"͒XfX|M} CҖt{ITm#I<ݼ"zbO1!I;4th/$t.}K;$v I8}GI{6r$,l,dބ IW 9[MPҦ/C<~1O"_>IR~cx~*=xI=e *G{|"7`$>&s<5 c%!9Fg9fܡW9Ƴ`Xc^&ԋog<$(ksck}v='bvC=>s9J0e%oXx򝵁 X#c7>(ԕO'N'}Kw}$<ỺG-O\0[TWgeW#YTjI5K|lSO޴Ѽ3'>k_9kݿY>:z>e5XTo.COZԭy^G6Α}o6#rb йqK>gxft,/~=6]^Nނ ݈-C yd]8AR[8AWNh ~távM[ d  X' /|n>m+a-w;xn8QϴMW~|OW[o_^k"S! 9w^~;;\}ޣ{ PLgte/QOgqZ9?ڷۛZMǼ}}y_ lQ3?3O h-/6Xc2vPkD7~r*0wIN\OS>ҩK$}+6VC0Nkc9+OC=o}[$rUj|3N16>A#cLJn>2Cs7 Հ[|G9OD%9j/?Oh+im6 IX%M"qoo&hZ_M+G)O[x#Mm9;EzIs_̏N9{ח6wҖ/{󝓼cwt/xY]>W<7Y_6*?7= WJE\}uTV8u'7zQκ7[ I@;ᙇ73(3g=ky&m5c\=<7gmZ_c2&kQ&u#za><9P&tU}qME>i٥&RidU%8i{\H>k?^^ovگc>i#7}t 8XoE\k?. #ıEh߸EA;Xh]KyG|Z`_"NhAZvG6:eS>OOJH/,?g=_=;ߺϕ IdAT^NIT~W7}H~L_q O|4o+$ͩ)OcZF<|h#qzHOzx"Oxˆrdwun-crmiPhz*t_ȳfoR%O=:٨sx!7ҽi<ơgqiQXgy8~*_Ms, <򑏬G/ZBh{gUֿQ FI !<S |6%@|SǦWHU/%dbG1B#6운,e=:GǦNACt1`P2QJєGj ..եMh7͛ME׵+vxU ??zeGbqg >\Co7Rg :Kq̱M]k'g_O*ї! ".4fDZG]>4>h>UEk!v>/ #i[FXN)xfPf%Qŷk}x#_jG^tI|.ŷk}V4߶yx~.j(9jqYkrڧ_++b?^SA~\szGjOenY^{4$՞@yO6M69$Vsϸhr>;/Wc#?}? yK?8d<7֕Q|=M_3rb>#IhxslꃮZ׋yjV:C ޚ<NkGڑW>p8@g0E_tS+O) >A<֡QGj6Kw^mA?^gI<C;ֳ/!>}G؊zk[!A4M@z =WZ~oo(7k=Fg;gEF '`|ވ}|ro_q@K:!%90.$q̫k3OfnRR9;Ju'8?GZV~Iۭ'drQjq0B. I86ux?,BHHw]ň↌6:Upt6qqfm񧈈, { !dߓN:/շI'y $UթST}߽{<@ 9r/e,)lL?嚾2EbbCJ`9AHcg5Ri^^zC b֬GyR~r,N4{s9Fo[#~7HrA(ŴhS,W_LLsMג"rvM#~_åx56[_qqnQ$pL `O7z о>pm¹FF6E^A)gcx:5{I~op_ax7<ʼn,ڣNeH"[ҼN(Ql`Æ#"@yaC'g+6UƆXOVБ(HP-S$OiXܑ#&j)vHKpb Ǖy2QZ{a!]~[#?pvrq%j;/7>_?!q78~-gk,U\9}sرݳowWA'~Bvqcƌt|dkdTjѦxbe13՛k"ITSMyuUA=l)eR,5E`M(=g.z1uNhS*yV^tKο:_)&twЧmے>t^~UG*u] sK "0Nse}8ȎTH'KGls]j> F‰Tzl鄩tJe/e}8ȎTH'KGls]j>Z/ug\b#)Jq`"&Ҩ!)SW3.:٨''.OI(@M@yiG':B:숯2)1Щ_*"^1(N~Iя<묳ޯI|IU6Fv**> t(?M'qeGo8OsHFfo{q]Σq%XJi\g\g+]Λtɒ}{goSG6nt ?&ҳɎF;r!F} y|a#gK1XwM Q!,.Žי#ؠ>yz6#EO V`IOLYGbspJ&88V:JC$@#~7LI\_GB5O#OTϸHRފ/qY;ikl츔ED‘#dÆ:¿QFa/)L1y0EO=/u}Ӽ8fkHsmQ 6SVydK~>x#?ĝ} ӏnnHٰ!'jMn^b[ǟ!/t 87ߌj}Vb]o\G\~C#tןqlo^Ϡ|_ҿ,Te5n<_qia|xW;R6b#}^Ůa#Q{کe)uC`&/_6&;E[D>7*CoBTS>#_˦Xlj.s\.ix|Ǘ~b+i{E.Uy6qokWK?S8_ g\?ۿ?$xYo${3}DH9HRL4N r/Vx?(./lG[ldUWPW-$QG@GA$2RuR)Gr*bۓ9?}Zz$H{?z/y+s}nӸOG =K~rYA&|_\ߦc&?NϼylEzޮ n T)Z9}ԩg(K o#!Et=:6UI˳EAn*pN.!DQJ5X:# IQ(>KAD ۮciH lO]a-I @ @ @ Nt'Ԓo(#ʓ$D0!@ @ @ @ @_DmÀ_7?7ڈkĿўl9ŊOl/O}NU|W-JQ0D ܗ;DuPHtlՊO⫍68B@ @ @ @ "en.#XOoM[Ϋ>(ۑ*>m$ʓh]TUTQHԠiR _ :𤹍 >񭶴SLR %VgA;$@ @ @ @ 9w0qjġ?C'-V% _o+oa#l؉,PqUmQ8FF!0Ay:ڒ=VW>ɯ|<)>yy6$@ @ @ @ { JiEK4Rt\ M+nDҞ> 9-A!tTy̲m e|P:H=B^B\b_6|tx@ @ @ @ Yy/pcX&BL7qlNc7S|xAxԣe 'a"<z촩A!t^: {u!y|KKg @ @ @ @E@|#O&N M6-~ֳ>/<>z7]UyUkKJ9bڨHȓ*ي:N)vڐRGU?ُfk]@ @ @ @ )Ĺjv")TG^ E<^vF"{to @ @ @ @ ?cEKu9&>-۩o^|ps-m]ih!eGtR!NӁ B;u2~)RvaZ|)gC@ @ @ @  c.K0Rq`'.7ob%{Y7 #~c'~?Wͣ'o?)~VՎ'!?n6?~}LDKx@}kxv 7ؑGi{ðalb=3ΰ}٧[}5݌ʊzqj w4>Fp/_nVq5ݦ/.]nf{14ƌcs}qH[ssαR<+VXQEً/h{n:ku7=3O} Yf_~Ko^Z_W_zݺu鼼駟^楨7}iܹϯwi'{_~2[{JO~Ō@ @`s(O7k<yjԱclj'>TFdTܝWU)A?V[ HpPyB'sAA<$䃥6=ޖkslFڐA],X-Yn;% W=<K(}Cw4{خc#m wٖMlaGy${[pa"lzG;\qyeԩSxEElZm$N-ʣ>Z6[o T~I̙cW\quttĉkSO=eW_};6/ va ?0-Df?Μ9cYdBrE`]w:*9ٳgd]vI:C6!q~Wc|e{o1c?6dȐJO~*N"@ 2eRD85"M|582-W<[[eV߈A=+[^L~m(XDY8vG *?:VU7 {~ʖXig|vfǚ6vH;d=m3/|1锤 46R3:克+aC67l ش8 ܻwe:pwRfCH1Q|QOPɣ'"prĒ/&_(ՔfJ9'%TU)K,etȟg ۼaؠW|%S^QN,IϹ\/S> UkڬsF[zzL ɶN\>ѧgۑ/{ ;|?o=sY{meıx8~f:aٗ4۹_fjbvfߜnv둷m6׼;͆y Ĝ_||x6>>͖3/w3;wO?gFm>lmeYȱk0+~{".w3Ew$w];O>B|XFww'r-nC9$-ofuTQ֊3.cy) 7}k,O2wv7YbWe?lҤI7EtzWm a-[flE7fBXJ/X13_^4Tc=xF_lykw?9{'LIvѤlӔ}}j„ ]I:A?LSk\|p~'?#F?܎8Ԯq}N3|h(efg}pl뮻>у@Jj~2- "R۳>k[%s3Y~ g90ӕe$tsaԩS凔?pN:O{).,;_ZXnC1k8738?s}_yX`<EVw5ff2^ajs8;餓p/8~*u.Kǜ+YiЊai @#&; i8٠l#R!O GRwI[\ͧ]R7#\nN) L'u ll4PN Gɪؒt҄]}6u$Rr7Xq:)m̤3. ^<}V! _2"[%Đ_+t\q/w me?;d_M{)K^lGvlvO;Iyug'0ϡD18W*s??Z iN*ϧ̮["-Os'*W2nX=c 7)=M,,ntCnxsaƚf@i'7ĐEҹ7YmH?S_ > (!La?bCeff'7,NCL"駟ɓ'r+|tdCKAyŘ,dԨQ+n^!} 2 ­^FP&Liq񝳴|!̘ 4:NoG?2NCAiUx9$$$.C /С g\#Aji9n)[ː=?+/iTO9-<եZ;S`\Lgr3fꪫR3]/ [u߅DB?AJꍙc?yاkI7 \9fX_;% @ hmwCbҗpo7qmNZAw@H$HK]n:(͗w;+xe}ˈc q}xތM g?(MbqNF#]'MRjA9%]9!Dα}zߞlgq)%79aұ@xهv7{hN~ñ6؏OLZ26= ⅛6n &f!ԆWnnjSf9A3(O9d׿Y+ͥZnT!87!_{ȃ~6̨\̐0!F+/ %74!;'fs^ܪ."K<1+ޘMҪ_Wjx<68&1~ݪXV_@ Z a\_X%ReԣaCd+NMz7)QZ e[U6ˆF"GؒW !'t ^JǏ;LBmo%g9v~!~;~ώїw3srA6 K:LKA;!yWuZ;Vo2;WYe.RZ^1;?-Xg*oi%{]f73$˞3 O6lʍfo-/]$}se{$9N(g^w!Wg~3 ŗ;Vae wC>Rjآטv_y@w/,#S̴z̬\KqGWgi?KDX}Ehy{S?p.-| 0~k|j/W $1Y/3ka[U2>3_& fA2{ bUdlY*G[CKEK!. 9>f=0&Lٴ/dAʟȌ ~N+ɷcx2 WRxog( %x7+>i9DVōqi3yYelC4CUXB_zic&DQ.rcH@""!ta3D FA /L$n"ΆclK+3-U^ uNn}Yge>Iq~2[E @ d@8i3m#Ra~vBB' 'N4zfb)_2wL"9{b(.!zK!DK/u)it湑)7_zs=cON ݭK`/ GRd_x{3K3}_Sd$7,2eJ,Ɯw f0҈] Ofg^Vnao{Ҭ=rZfri*yZf<j|/;?/8Hd@\F *WαAqUa ‰""[ yvNu3'3ۏ̨jU78cfbCH2sG_3B̾ɾE]~͹걗BOd?6dQ>no;Ώ'ONϠ{;h*U]΍6;bޒ11I?r1EѪXd|a@ }(>/& 7IyH=I:=B ylA*{6 |S'/8k$`1)B:!=) xCjO;6ꡟOm\tP/&CcOlR/4wY~ѡ&J>ѯSlp'.;)mYױF j/,ZRk -o_LCP5d'mlڕ fٍJ:f!um$,JTKt/A9LTb?LA>1NJK=9MvBFV?/OfП޲xe5).I&Vkwr~E'c/QzT[Ǘ9HֿՃNvٿ?}i_?i#a13/UyyfY'›'cԩi췌+Y6:^5:wqZ KُIʒvfd xj#i}e|q"?fP92j+)ֳrqU,!ʁ@ @|E͊3#S7.p|"LOפgF=~(;C(#zlV=ۥ>FdzեgkCGh9ْRÇ# >[mɕhdFtAtn:Fц0v:ɫ*,If`\o~X.ߗ~ڮ=7_PGf6ۉ'f BI/fcۦ&`WMdg_A^zϝb%>c٨֒dٳC4YϏ1s%R'9Dҳ(?'} 7{KUy׼b HJf ),%g0/Ae_s,i%!=4n ܊$b 䗖g6/a`a6 x2o=WVno4>@RfHRB,5C{["f1Y!ƌA^qקpg3^" xK=j}sˎtm{o?3fH7[˜wϾŬTNHF0d"uJie# s q,%ǚ^D/hO|bTK';dꩧZ!>yV )+)Srړ!德UZܯ_!ƹ!I9s=~I+탍}M`j+9/BJr>Ȭ}~W>?c| @K"-82ne=zliCG_Ѡ+#Q;RP|ْ=g$*0Zme lhKǜVH"OJR$Os[lW\b$)6^П1ϩr2zϔf7B w}j<6E{gLv0ڽ!mD͹J|]z/|oQzf~C,y]By?g,j³![u"|<3H;3]넝}@tzCTf.ύW-^JNti>k[`x1M'91tp9X^߿h)eV?'x ֿ;yTz;|쌉e_9Cf=zr-эe{qL݊sWؾi۱ۦ̨eyZ)٣A3|{"k_[=/A}U|YȣfA9_>9q>Ԋvb7_,2.eaI+.~lE}O1V !a10j6fBlCj8r,ɳ@ <6\PkAˁ3jkq'G+ﻜ_|ToZ+E,eX|$ʧ[nI%D{+cYO@ Z~43(ijAqW m3w ?7Rs^)~K'!=Bύ#~TMMSBtO*3x:EJ[lK6ʴ#_1yz6G_דN' _xq9>{.ιϗcomeg\rAo-ٱΟI~6tH׮6}D3ؾœXO\u-eK?҃0Bg8ġIJ^dsdم3InHxf$Smn'33:)X$?:'a6%[Q829x Ib۞ByZuE.W-όOfktIժQS)VG XZ1fZ8zۇLF׊G-J_f9n}kFcW_m_f?a ɣxf/$X6_@ l?Xbp _Vf*߆2D%whGS zǂKӌIq4Gܰ:J /]n8kO{X <9}Ry8`#w|q?.}yrSPGeD9sy.'(G=?,=#Bso'13'_[$|)wy9}9ߜ^UzXo`$;!N޽bן$|!@ Кf55G^LJ2Q]Qˎ Nfcc1$c/2' 2c<1{ng'Ϡ,-鑉Ui B^,mnrpDgPއ}T9/񙘾r"ߝ^λdf]n0h\\69Xݬ6fk[ qbv)$lQ-!MlUxo1G@ @ @ @`E?ȉ#iccv$y6t#8quؑ'FڢG(c3 JG{鱑oR R+oŮq`(B"(6 vb DuXv:D-r,_ʗuwZφd+WjM'O`ӿ֯a?'  (;78 0n?j6IJʝvr"2M[KϲTf~n@$nn lTf&i @ @ @ Ȗxrh)Sx>Vfڤ}#O`R:DU'H^md꼛&_CYI?/Ƨ]ȚuzDb^Zۄ]l¸Q_> HOK'g4i96_杞Iofd 09 mCxzrͪ I @ @ @ @A%5`ɿ);;պq3H@9-A! ^y̲mazqԼ0GLGov1oH/űACl!!ck;:fdZRisҗrCpNHxnm k:l錹lU>r'$@ @ @ @e@Ek+9X/1'%i#bHe@J"t^GqlO%My|tïy 髜Eu'̘/3r皥+퉟bi mO^u nk}x(:Q@ @ @ @ D@|ZKo%v7+G6AOʆH^BC|_%4婧W[ϦXN}gB=4 Fn~>K!i6 =~%Wتm6fmݏ( iI7UlR{kKR?y?Y6\yi e䂅 ]|6'} 5ۋCgWcY3![If`:)mH[>{ʍ6v_h,Kڴ|sf~WMwY|]|vwvY?wwg/k>{Ѫg\gΘe -J\wʣym O~& _Ԇ /nUb|BG@ @ @ @ Clsqa9FBR\Z^$Q^ -'=G>e9)^Nmގr7iح"Ss" lHI buX:RJ;XoK/q7dذ!,ш> um_=cY>_ϕ$[9дr|&ާCGۋ|`wXAma$N{6gZvza;l P;YZA,[");}+_7wKf>=z]vɷYL @ @ @ @{]_/oհfv# s/ڰ#bg-SqeI}6CP H^!ҩ X(cGt9S ̳;OK1fԈbBf<_k WIl 8`;7mC?O 7kߐ>RbPeZ~uc\9[_ϦO} Nj?v`xWV#;_>{akmw~{vGY?$@ @ @ VG:X|\|85Dxh uV\Dlh<Ģ^mHёR/$_-(Ů4zI:w 8dc uvتCGϦ%L;N͖T;TC4mĻga-Yݗ-Yn9yy2'y33کv3$9mmyƖ>[::''ui>snN_:޿F{n{aE6mCB͉׿zi>z|Ϟ֋Y _L?h>]!"ESNלxv]N-pQ@ @ @ @B S צrJt<2< 0V>'.͖lXF)Wfʢ3Fx$yՋyS:GJ{OYǟJ=e⫭gSY bǦQGS{ؒI2qf>NcplIg%f_gm}N8lv 6j@w{`{z˻[c/,n3d'I%=i;|jrɷ{о^l?4ztz_7g_Ν7f vny>#٘wَ#6-Wv,'LS>`;ן.(io:Ŧx}c=͙单dvDJ,j$y$K_ΚeƎ5^:gή٩wVAgxSNvvɏK̟v7 J@ @ @ @ P@$cW'D,87D8G#M1EP )WSK.9)TTGq{UcȗIO<@|QOTg{O&ɦ=;Vy;KNoJбvsqO=3ڬ}k >KwJf3#Ok=>w]a?7ȆLͥkQ,XߎVY$V^tW^}]rOo1m_msTj9٦=3v>x6$/a|=#6;hcF?~C㋾ecwS?ޙΧMOm&S1ٙ){'R'ǻZ4ft, RoK.Kxs/YZ|OF[lE߯G&@ @ @ Zn@,}?#Fyzo%jz|jƥg-$TƟͦlvȃ) (>IO`HǟXE Ұd''WXc'Mi=@ϐ޷>=4guh[rwF;v>mz]ua-=l?f&OM,PFi{} 4H~zD;w Iȗ~{?=3!!wxF$r[ߜy4_--feQ֔ߨ^Ej_>ėn3a̯ꔿwTh'|~bUe-m?9R+V%]{~^f Q#F] @ @ @ (c^ ~ŭQP#^:D)cS{VҖMܜ|즵 L$AЋpl|.jG[ <"R1A^&?4xzWm=a"ܑgw-["=Sr-~`Sl|&My6hcXϗ}|tA>SӍ֤NK~sO?M[ӟYOxf%³iwޭÇu5R??`H>4 ?GS"+WJ]oLzq.@ @ @ ^j/&r0;65q|ǑcDT5ɪq#S:J唎 QHYSfs. ~8ԛ 1D^jX-v^6IgNq݆[o-gVNik89%Y}]l$Pe" eK6󠴡v9kTJ:SlhC=zC;߶ P=`/4a=>x>؀e:m/s^vt>{A \g2;`Z3?pvfƬY~{R'MFn㮄 o>yg}>2ډ#;Ww}泑|$= ǟuچf:*3Nkoۼ#kr`'oq#^mCg!o,[ى>E[X@IDATvbʅ'تݻ osW@ @ @ RA9E "O:&&Mm2x6Ϧz|CdXԡS?d|-m!!yu;:NW[q@BM:'uؓG}no]O27.?a/#v~[ǿKp|{TooN˺9w;&O/~_[hv׽֘IZ'䓗\ucfi˾7= @`[#qviȶ@ @  K-I\# _%=9%KP  SّGmb6:qg\:|NyT_o6*{1ŀöаN^e:Ngٴ] t%)cҞ:l^Jcu6nvm=3cZWtYa1}vY2o6_]ygT{:מ礗yû-{hfҜ۩3kr鲒qyxgGBP6pVYcӕ҆ ( 4AP/*@ @ ʕ+mԨQ(coa * "x4IEJ{ڡ?C'S䣫Rبx<ɑ/"ҥ J !QL :Aْd_iOl4'>+K=VM2x>aT?їcpٴg854fV/ϑ;h8v1 zj 鹹!Hv=I2x@ @ @ @ ,ISp]pdhl/qkbpel9[߰6~ =JՖĿyYUIruK ڌQGȣSI6uJLcl;v;6KToO>X[8 c7~/eKlSIr{c@ @ @ @ ՖfͲ%6i$koo mbu%ȫa &{ߔR' <[5Ootp{/F`:GNHIR焤byuAY%:izse|4DgL7yBÀa@ rk@ @ %ޫWGy$B]Ka5(=|Ŋ6z|7$\)))^!6EbҖ<"_8~#2M"X6g[ٶf]@ @ @ @ m={vrRyuM2e7S=!Fd!eaH9'+E6:";܆:MLmV|A@ @ @ @ Z]Oy]yXo7wE>75nhز!dHJϦ:tB0bǦ=RbmVff#@ @ @ @ z "oRODՅ4'kTfKRP0?'$sRϊt/#,Tz?K@ @ @ @ @ 0`ZZAJ漝yPOD ֳ$dI2)y 1oTOOMD @ @ @ @ gS40sQYz5*Wb M$!eAףgDtR;J'/!/ߤmSYm1XB@ @ @ @ "Uxw|\fڶ9V/^T|gkKNֲ*R;Qf#$MRT3)2g+|RFHi4?fP@ @ @ @ @kiQoG/X]hHцs"-ELXPY-eŔlEf;Nv!qA騅@ @ @ @ I6lKҲNO_YXM DT`/IHD>B`RFz6Q (@ @ @ @ >@ax5Ƣ'.aP8&F"#ot 0!@ @ @ @ @k P~|!\8߼oEOx6厰&p":ANb'[ǎ:_CYI?/Ƨ]H @ @ @ @ (VX+7+8SSH :Üةyae_B@ @ @ @ @֒# לCWWr!6 ^bNJF>Đb&D:5 q)=K1$@ @ @ @# >%75_uYH`;m :Re¿KiSO;iMa~؈J޸+a#j`gtB&2`y;Mv,krQ @ @ @ @ deMWƶ]&YuӍRQ$%`@gCHI(c'rMt|GS,V҆MwoQ @ @ @ @`;"j[f^2dP <izQ&()yO '&M2l٫x7sm_ĿaM<[,kJ3%%GS:BYQ!`/ $i+*v<6R|@ @ @ @ l^oWA.tuGQC7ڈ!d\luEyu8/6:IYx3M;يcz!EGJ="|"RWIaQ/&{VTO%ۈJ6c70@ @ @ @ 4OH~]9T-kM˔-˿9X뫊k3p EBFTΫ^̫HPRu䉏blI'%z6#Öl}OO'RC/_^]NQ-stHd m(3PL 6 ܿ'ԩ y6.4yNЖzӓn!mذaݶG}8pn 3@ @ @  K"n7(nj|6;%I$!nȃ F"s{RE:uT>H<6HRO5PP|-x_H 7ޅ߶c9ztǟ&q"H >jsK]8q>[ PvnRq @ ^ _%Kyjd#n>MeV5rO|k tGT&ǯaWU!(,Ai04(ϦA D25QqHEDzGPA!XI@ iӧ+i /[.Okɗ-1ʕ+s?^č7&6RL @ З(/gF9X!'#ԯM`Η86i<|\v_[89+6UFr)\ht:SP/u:bK;lHs=yIei q:;;^dq;ÎLϮsS5^gy#Wuwh,I'O}ڮ5|ُwoD*Sl]^tqq]|߷{>rWe?'vOOngyv܉;ع6{ΜJ=_uCSVWj<3$#/Ǘ_n.z9Bs|/;@ x:Vk8zF(,Qɼ|.jG[yDdſ ?JOջ*$^J|쿱KztÁof϶}K 'ު*K)]" ]*bw`aSc:m;6"& (ݝt˹[`/>]k;wnҷ.^X1ٱs|=Y~ 0 :߼eTPѥo!˖/ʕ+a&uVwoK!2w|7ON~=o,YRʔ.-0\O$ ,˕/,xj׮%;7~G/.wy5CG#p 0} ۸Qc)Yi3q|gݏ#dSGvHhra9t>,_ɿEx185x;$ȿh·G86۴rkG[AZatЌ($mO#=m. v:a%cA־G #/K)%򷧟t7C9rDOj*Uk.JGH\tI ǭr=wI:eڴru7ʔdҤq#9uˣ{P}[ ŧN&˗?RҮO=IƇ|os/$/oO{^'9-3xN/*2q J^#^xYnQG'\DXu>y}HР׼nl޵GtUɟ7lܾKr:C+V/'g!oɉcõF Tn7ڷ<ӡ-8=ls`!]FFFG1jBa:A# 8cgC}34vL2gq7:,o;G#DO.kЗ_𚫮p$RYsu'eYdG<… O!>~G .Hr>J Z4fX|)?͝F}LVto fRX Ⱦ P8B6I9|o:poP.`[r*HG:ԱFiĄ<:ai,'s O<8h !6qPn@0#)W.kBKx<޷,)իD.t22#R֬^A8BYvImӺ1Qx0rsK.wVYKy6G`P^Y3s{rKUgx<`ŋ%YLJYI˖g"bt={̘1Cq:۵i&Y`Hvڭ;}גeʆزyzOfΔVZI"EN;wzOsm7mT>lҽn:5kVŋG:E)5Be٠;ϛ@)ut);';tʕ+KÆi*[bsRmmD3csߨy:[VڟRBHa5kȌ3o)VtIԮ0?t!ݺuucǓ]VĤ}vRP!Kp*XnW׿gۓm^zVԩ[GjתF3{=h"}jڿ:uFk2 Ɋ+dmRVn9GXYDX=G S$OdDz(-M Ay*H:A a\P L?#DՅk3>ۤ9b<-LmdJ yà5Dqzt:M5=Giux<n]HVD鿞]~%rVݣ N;zlLcO)dŘYT#3IK;o}TEM1͗7v&kY2>oJs&623< #G3R?W3QI%\j]7([ѴS}eР/ڌ ϏI.ICݮϿK/D~YjU^֭^P1y֒_ ۷o+oF({nyJ7/C{Q񥗔-yt СC}:7bժSO$gȑ/?~@r#ɗ/_X~;,_oّӧOwe&wL Z~# &+Ǐ%hSdj? ![B9I_|2bpy}x?nMϺgs ={J[X$M@~םyFۘQ#/>#~ ei9$'nyD?v|Pi֬fiG"'W.cnjr㴯xFy]?3rb`,ccs>v(:gwࡋSOmr2>} w2,ߗ1G{e%tم`a#xXⲍݑ+rֿ=n"TM ~Պ.UxQiZ;rL'(5P!qvgeN!~Ɉ|F"R<ÞZC45:Pģ4(G46)C,=Xlowv{2x<9V>t>ءKw];\z㐯9R.]#'*5k֔|6ljyNdn3fJ%ggmۊyTtvhL8H|E>KHOH6dnݱݺ}YnP΍JV^yn=1we{<)x)BH2:ns> ̬Y33{Z/kZ Mz GnfҨQC;25@HHa} &{!}Z!?LQI.\H+jU]H{6-Gg7۔z\pHÚ˖/׍jZqXҋڿ5//N$֧"b|zo ow /eE$ +B5i%yd7Y!/3M85^\f9IáOGHqh1U lYHdl1"0F5naX rCS6 M2ԾM%>xr&||runׅdn,SJey?Βreʣѭ;}{M46Aa䢋[;wreʔ_xNJٽky_8ʆي^|[Dg/˚5k54m|\^IoK=̇]/4k?6y<9 9i#hu 8cLv$DJ4{$'-? /ku3hArt~\tb3cbsSsG)}~,]ԢaUW^d<79'}R@`g]qac q{>*22qJB 47d #3gY#E=bgy@_P/0Sr#XG !P@BRGMٲ5$gaSH/lF0Zh$"tѨ# E N]ʃgyVBk ydgرz٢yIugz7oQ"2!6X[n^-[T!oà@D&xq7%ڥL?E qԽ#/xA&w5VҥH[nQ8nݦHI#$B녺:B~e|ʡC&VT '$#xHVoLvf ҖmZ^l2uk,ʌYdI5'b Y lnW`:+TM3>'Sɪ͌Xcr0 `wW?c,B)&ΜY!x#@zx<=z|2vk+^^e-!lQT࣏u/M6ɟp}/yx}LɬV% 4hܤQ':/GHcfx歷  HGNw'xHf1fG~#?[:n>9mg+?mGI*Rp&=mr뭷ȼuܒ 6ʹ =@N@`de"R^7I)WZ֯-,yITuͶjyKd-rkvRtŜM3>ȋr ]Xql1΍ʉ[{eq('̾f<Wȋ^h ґet G:L:N>y6H0uqҔaz6xӳ2x/ZߋG#x<l@~K`͛-M.CtH)_l(kag[nlҾC;^452b]<@Mlj4jP7I$ #ʖsx#>fX3}- m;NdEX0;CJ6L[Y.m {?WL pՉlQ>-Gv ̲}m2jh ,m}иqcկ ,:#dgnznhP7 HȮM4.7uۭi}بzb抺L aA/5pdƩ)jjGbuq9P>ڧ,F  p`S|B:AGgu4CFA;Ѿǖsqәfx<G g!|9YD yݾRv y*V<⩶LIe䝈W\^unZkG_BYdMGRj  fgEs':f\FaGndnL A$"˖*!W@׳ r7l)i>p˯8۶k{R"%fUp-[YdhϙsdZ5_{.tӇ~ܮ;ysa7NX$_xrd%Oty2wˌ#|M  A8䏖ō33;A><4Yr}b"WFb)bÌY\t0S 4XOX\g ȳvvN/x<G '!0}ZQa$y6E~w ^l#G {رay6mEuK¦$o5-%r3(/] eXRd#l-yf5Z%|ͷ(ҷaþ Rqu_}5[_=Ē. ~: wHx3Lry׭[61`x,sA=eޖ|ޱݺw ak{Rt2B:]uuv۸}=fL @`u%|x<h| /Ja  B}4Qb ,fa @!nGHSfu7F,4}- $`=;H[{K[{P:XG#Tj'8]uu/};/IK%ִYSD~ҫ׍R|9&^ fÆ .׺icǍg}yy3"+FnV]+, n~VA}rʲrJ/ʊeĉCIZ5`:EOr:W_{]Λ':uE:/˚ktJl;B [.2uLd箝D>G͆ ;t㻽woWFbnTtm w9RfImsO>uqE e;OFf1f_{{owzdo^y#$rp#Lt:=w%Cu95뮗뮽ڑKtC~;v lg927opDJ:u6/bW,"mG#xD e$qx1{+!2!2ꑦ<(C.յs]?x=l:ͶowfA#GHa2OxJرC W#iÕ*U e{nG>1E:8MsZ:OP@W-|3TRi:D~hf0~#R !g6fu>oyN c'k򛍍&ML,\Hȟ\ҳgO A{>x<Hg}q!mQiġfơF<8B#5]}8GM m}hlB"A#NcAG4`tI[fԢ0fQ'M~:ˬճY).GteK*wBx@ >{'d֯_OzINuou/LZjL}K]^ftg'(J{t,)S nפIc}W~QϽe_~InlfݱoHnɞ!|S/G[SsrTriKNq]mnܴaF@WKN.orc]_`[o4#XU G#p!r˾l?^sIsLԥl" pkݺ+]TR%N?#svPi>θ:F%!u,r-{fGp8\RN#34Jt,7H@D:L>b%Y0>_v"''n^g^[C~W4T± "z(9|S'EDi]2r,H{nY_TA_"]kg}'<{nУOwZRi$j5dŪ!V7x<_Z2zJNd*HV ȟNf/!e&77h q|fFx%x,K)ʑD;ߐk _ԭWJQJizQJdZ*$(!=A6mB(VχGTDL%C~TM]%4ƒ7@H#z,DkC6q8(vo:VYfP@A/$CP!k q#`1@, YgDZP8yq+#m6c ’:ΧGPW'Ovk}*\*2Bg.ўlK9. rդ4#tdΜ^^~ۍmVϚ#֬jU*<@b,"h*vx<Gx"p[7 xtfGą!r}<ݼ|m;A?yڹSfΞYm蓷xr2E P6J˓4ˮNPA, 3|%xtq=ҦOy0/ȧagmP[C}gvJc! Qy 8ˑ<0qt#]+W'ئ=` CH]7b^}Vl/i?L{.G淛&|\l=Xj&rA_Y/r.Z`ayoOʳ/&5Iץzg b|0٬StԪzujޖG~}ngqߖ]:;0рA2D_n{A:7헞##LwPWhֺD![6Sנ*${앛_nz4e?QG~&x<Gt@ ں\3Z;vt[uJ#-Z5kʫ:ǟtK8GP+\}7eMqG#}gaw&{HYe^滺`5neeNu5vd3k0S%$M>&fCh)f\I! GȁX63;اQA簺uuL= 6πJg=ilHG%$AהD^R2V$%eޓspS&]&iZ[CkP <ő]C=/I؜%j<1&9֯cD=G#stϞ=֨B ycuС;Ϊx<R109ٮ%l!i nճĭ}k|ʱkwY}+?* V>0= %N&NXq tqC|6LwV`9]'L] ȁz^.[&W#x<G#x<@NG dp^Ƨ07E[44=yƵMk7tzu 1ŌT (;9HrQ!2B/uc&6L}tO &>]#SM 1:U׺7OIwVrNIR]"FNFک\n=wP%Rr%)k!k|t9|StD /w<ѤrtOh:VIcq-8?+Sͽx<G#x<G#8H 'qdG?#%})FTD40;aХ=OfkfSyKZ= iniȚ>rGuW<@q1?\,PS"~u֡yAw2y?/h>5#+/֦WAw%u G禕WSB{<[gmo2TPAthKSsRH&9}6i۪K G#x<G#xr(/Of!\C Qfg?̣ٴ|h#(-krX}Q #6 tq:CZ9v:GhqO:ojiik͌Oݓ&Et69toebiԡ.6u<)8ϑݯw-!iָkw 6U[r=ovkoe={NO}_X5窟ߵd~*qndG֓46ծ396O./H io#V)1?G#x<m[xˀϘ8Q"8,8֭[THŚX\FX q4?4qBg^M$n#2 >\yuJxF h [HA}B#1}AH>bmMrN9y`:&yz'E 9Wd:m{N<%IAeCD4+єC7E<0F"Eɣx!{x<G#x<G#p*#INﲸ ߸1B`,Q&Dȇ_ rxQnoJYKG1]xYȋ*tvQ:F`PFs6HH$.bue o vh`BE:Dٺ$Gؼkw@)!g:CVl&t %Oq!7XXa)wG*8$vE Jтleݫ6Rʕt VG#x<G#Hm||o1#oȠH2#cq/QgOJ#ڷza7Nt IpA]:EA`lt2S6[9 Che #mz^<lϋW 'M>3C7+u׆M6e|o =mguŖұTq`oꦲj˘Q#u#/_Pϝ`ׯ['*V /^PVs]0NgJ=z€= [zNo2sDƂ/ó^ !87SfmC=~-tml"־ˈψ4FM[c4L'hQt >y`F~ЎRf,nF}xr&~ d2ʹ[:Vu=/ Uɯ{TA#d [ Nw2kzɓLڸ\Ѧ͢^z˓[:_L*^Բ\֪!`4L隓Ԯ"wZǒȖ]?ʢuN2mc9pfPܼ]ʗ("7uj!wmmڹG^:^"xSΣOxN5rʥ;6Y:@ayqLGbJm߾M ."YINbPҾCP;91{ש5EeG9{Ej֪% VuaoX^'TI#(ds״w>G8D-=}@`/#1~o7B`GoQ.`[r*slDPetЈH[s O<8h !6qPA>1=nj^x<e=N c N~%7+IRp)[LoLHݺS,Y㦑kpy~n$2`,)/\,ٱw]QJ-GHҏFMJP2f2iW/E [to5c˹gUW´.?L/@Q>>{)}uCr㳟;.q6~nMwoVG<,#f/ ];e' n Ϙ6Uꚅ=/DXC  9.;eO?JRd3s)k'gիl@*niۮT^= \#F ]5k.6{9ϰ2wGV}6ɟ\tɥahsuWޡS'yݾeSxq~~)S@|8Pz|S6(a18^q5ZdL0^Z ;h#~aSD }`-xΘ>J_zYA poų=Iw]='3C6m8!}_`8xyj ۶mrqnUVˆxx93Gq ?rM7K$hG,*[X]~Ւ͵XF_9]=g/>}fniب0ez…K]\{Kceǔź\@SNjG#M0sSÚTحqmr#/9 #2mYqb:c8s#(M?t;q׭TF^f[7vlg#?}Oeu$(_^ QrB~.$JJWN %.O'?<7>xN/dB*ꒇQիԥ~$puß'Mr_dsRÑ|"UhH)ȇl.vQ#a ݅7m:wϓFxժҮ}G)fLwzYMrBPJ a ^ܪc6ⱾFwÆJ[w;GYZ F.J._nE+sի1@+3'[<=H<p3t\7r#l L!Z5nȹԽn|3}xW_w# *4P[oمh(!c6>|;AO\o>mLu0B?uop-WN8[Y7X.'Ea$iI&k2=' rm@k@]HڄW#׭[+W]{# ,u~ܰ~\r]ש 4yha͚d߼w@vE*wc}J\BH_y u0oX/슄$>kr_c{#$ΣU BAvsh5|#0}0?_)?Km%=գhm-[#Bxԭ{wgg[b(xAV]w{h$}6Sskuي[n-Ǒo|u /x&wrOb~@7ftm xi13Kar6_p^Ʃ10^x$07^T|l!A>)#n\\$- }lHjF4D+<:FCo&ea%/I>pCamپ'(NjG #Pl aڴUJ?NS)yQbl,D _iK_RRαdVYd B^+L[;mNF]XYӮQ@=>w/_Hg5ʕRw(З3#oǻ~<3`~lPU[v8zUʅyx<y8d(c{=\#DiH-=S俴oq7>5L$1D/ +J>i# 1]-n4Ot(C.:ذ>.}s0 HdGV*֕ʾ9yӬ%n w/]1RTKMZ^ ;tJ^^H_b92R(}f.j~䃥:uBuM_}#8=sѦBHޛKc[y}\}iL)gjhf c/V.]O1Fh&ӟd3M # R9'<6*8[dIj8e"x!zVe/^tL oSFڋL#+~o!w9AZl+3%zr/R2w.X0 ڲ8$xPȍJTp}BvEMv&.y?rJr%tҎ_xݦԨ (1$k<63zd8dgpC"t\3mNc3]mڶu$6e$}V~}edB=3C,mQQ='эlMfes,>O;GO?8Lp%M^8$D(cJ;C+[huIۧkmUqiyG4؀#!:M' N@  lQyzؤ |8obiMOeƋG#s3ǑN熠|TeGG$`DmyWb"麕y}sejd2%SB_Q$kuO< B/銀[_Znm2zb]qU"EhZ)Z$li|IVOL^a!˜sWSKg~6l:x'@jc>-\@ "pch:>\t:wu?z%'ݧ؈% X85/a$Y?3}|8g!eơ?*sDx9|b>}<=^ >ZGӧMqSSFe-=B~\v܌SMwddhi|d)SG̈́LElu bl]k\D'm(|a0ZlxeV ᣇc b+RO;|v xřǺr}GwSb n:oi%t%Lx%伖W_% v?MfCtVY>8D˧?r-)5BƳG?Ƈ?L?HQ^cwdrW^rD44}{W_TYDňa ĘZŋd¸qrKC&_y gm2\WR8#F#%Ջr~u"WԵ4]zW_qGѺs4\yz!r=0ޒZ2.Pr_vddvٽ'y85b;r{R'(l.AY+e"L+xɞϐ:vW;)8dD׹es۵ 5LrDBi͚tqt3%3D_d5]V=t+vdXmɮ!Zy5(lsP]qG!sZ#;[Y8ȋ\)ӉDwx‹ :" W^|d(FVd,$ldC݀6kY c#GKY" ߄ Cvh"(bmC(bdŒ-h1VpV9B"2s]r^h }e4t$#f@ki-,ous!ǺƳ3k<3x?S5#>[Ke!OYiqvK٨#p";BV(ސͬ" WugV|KN,+m,0WM>FH< ުu%;-8xmD#&nzF,xYآ!ژK2۷[jڔ)o"It'ُ,u$jD#x ԏv^q+{e'SjfQ !uO%2xe뾋oW ̴seYkM<9i!> "B4qgƩYEGRƫG'4.ȃ#{u!N0)A9c FƭNF4r[91{Y}s \SqlRxe% ]BrA&+B5l0Pqǎu:I-gB ԝc߿еDz%U􏒗SAYDYqTiYJI̶y<>ŃTGo)D49^nq/+'\VVM }}$~K.Տꉜ LI!Ҧik(} ]EںE""9=5=zP! Z CG$DLjC84VfuqiV}ѣaꢇ`>:)z= z1XÔF(G6?f6}Blk=5^]|>Sڮ}E˳G.] /YD_k8R1`pq8p^ԦM̙3ec~(]tfJ9퓅##S1|7G#HdKZƴM=2Vhe''U!4c3AMJ()%ZV<8ό 3 4qc>FdA!bhlӱzֆozB>+FUJ/ vĭ!呦OFF*!lgik8bi 6;LYjOH޽۹)s0eruԉNuvu{]Lw vUҥKGvf=o#x<G#x<G@xA ‘F̸8#*3( v&COIhy[?kq%XPBu0٥͚5ˑk׮uiɊ+:2ώ8us]e:9=3ח{<G#x<G#8Q?/K͌|tx4x5ʱh,D!a|bzf87'Ja>ϼ2r-1S>칉̍Yǻ^:uڽ;x~/_WfYߴox&Ai 4 h%ԱtU?KFU\D6{Rڊw-Ef}9hmGtgn6},/ȧagmR'u˳zVZi1XC6<:F$NYqHC:E 8ȋǞ]Z@e/J<!n )3@L"G qO˯hO uj׮R3r3֋ȑ#gd޽&<'NtSùinGzi6 5#b4=G#ȁ\r!)'ˆK/ $;LH!D YgϚ)=Kl2C|5kZ*VrʲɓfZRY֩,44}4`pCݒSYhZf,ӁɼfOf0̍p7D%c`'VZ9PK&J[ɴu<G#.\$|:[\g菢g*d"49 { ͸9Ql4uOܮVP1Ǔ !=l;)̍@ߎ-8nD_`dh!i7v$ndoa7vO}k4po&f{Ch)f\I! GȁX63;اQFSnCjձY_y34u!K*=6Ή Q)uV71EC.X@V6x@Nva:xB.\ ٲeKׯ( p6t0d=J@kԨlv3uTګW ᚕ2԰Wx<@D5̞%SRd)nEuKwe:vkNG5eY6jF;w0g:SmNΪWߕ߄d7| ҹKףBF5Jgh:;wqYQFglSdҠa#iߡnCbǬg>5]vO>zیiS{=/č;}K.1G ku]q5niDeAGƂ7w85m-y΢ d ^ D >%%5ҭxwZ\Zl0~lթЩz!1Is|N Ldn} s=tu]C=\݁wW^}yˤ7l1=Bǻ>b]SǵuHw`x͒_ a~ryRܧvZnhڬdg\d;O|?kUs]-ڵ`<9c:|_*Vz&۴m+'MԵXKWZq>=# וylF{T̄KNoЭ zJlƺ^=sԭ9xjzAu/[n̒|\|eo 7=e6t(r!b\!a3rP]9ơ ƿY= Oڷ'&&i(=#f`FI30[[Գ?{/eq*HU) *b{&oobM$7jXDEJ&Ezw9{}y3d׿@.&?$9]Ynf̾<tP3ppޯ_l/9QrIn&eCڷo_Dž?C0 C|e [9_ًo>lbxkɘ=a oUEGuzs~~Ͻ/ |:\鎁$ygVx /}Pҵ[ӥ[77sC¶4){B~*p|yYje3bF z>rၨ5kfAԘ|0N95{nKwA~ 8`r'?|IM{Μ~lA6{,wG*ƾuջU؛|9n+k\_@:{n Y ?ؿ'?CANt&_pÇ>.򰔿ڹAVX^w򐹟z2ws{ܓ—_qU?䧟w598Ѓ~oFHLbYX ǸY)u9mx=cxW}^xnD ?C"'iwk>}\ JܓN>@C%|[{B b>mZ`C~*{ ~3=[;CG?+˞^|e9#LH}V<=>{;g[qpE>C>/6s mxJrO$/{;xI` E}7>'>?0<=s f&(A) $ąJGFBx3jFzx54x;9"ׁY;+2{ó nK{M?_8Ć٨,1b^cInLÞ\r@3g@矕~iHq;\WBL^ ȱEYi{J ս¬a#0L_!QYpS=ڛ=k׍qYL(G,o {ϞnF [cCǯ9}{f0sbi]<ƌ|{Ysd8}㳪`t/w$ca0w/(3O]fўm`,sY؀z;w\3,YYн$Iӌkz'~Fwر]n>3DP=i{%37IK¯P-|1 3%ﴟ)jffryKdE@?Ly_.692Nd?/DyL 2QGCUG{D@b/bKdGYU'VZ D֚NtPbLR&} C0 ~Df$u{aA1[&I$_ꙅD/ _gH3᠆dyL]5bvV,rNȄ ėэ)$O,g)ddw YI$?Ivi|_lsO}:tUzҘY,1gY/_lK1{O~ DT%wrYm۵7K/i|89iFz- lڭcMk5OsObXr/boP?s8BhiƟw>g}VfLy\^ۥ4mc}Ui>Cb<8iؾ*RbG_@IDATz{v~l?BcӟNebYe 385<Đ7MvŎhGڐ#_H>)(.I:w68c uvتC=WOqij8ؒ*>q(+"Iuh] I_5a!`lEyyfR͊ϴ` 3>TlV!OY;G{l5! 6 B~zVk$Pdc﹍!MyrZY=?j(XYRfGugu2C?)r엱]SHs[7/9G:.IJ˲h1 ي5"^3,|~2~XJ'ʘIO Ҙ ݢe/+ڠk$N>B8$1˓XdPDWgdWմ*{YK{W)1 RVϢ ri-۪%n]DHŋwS 'FYZ pj7X'$o7wYbGX52}n'!!G3:ڳk׀K}0Ӓ*.4K}9_웗5('{ V6uޡZv~v &8+ Ȗ /E^>\SR5c(%FטT8kcF%3E}e&4Ug/^jKA2&č{mًBT$EĆa,/"% )m!@;#&!`!`l@qi'nzKF8W_q0[88W8xf!alf,_ 33O?p ]f%rr1} _`/?@Bsqa2@gpAMDހ_(TW؛[({5ۯ˖CS{|b?fC-=f+r]\o 5D)tY|_Y#BOpÆ vw_¾z:~P}?HI';K{?S=IOyv4¸8}z l[ǎ~pbQeW^IT5$[:0AO D"~M!n ?P^PӤܧLA9aƶCqIýJT^mٸ>7_qrc:y(%_˓{}W>w --_gf7وwBbՙ] љ\tC;&jl794ߘM=OlTO͸`D|S>eS)U'*U$(d;x#U^zclKԛ/8)@Ldy鍬!`[WL43ʬf^qD>af39X_&$3JжLBrմT7.D_R}UU|mn$dOIt$qO#b[|u]&>nKXb105KzO03}y1aHfBd|_]R9=I|qƦXw/'+v/oڔq^^gU^]{SJ:.'MM|*o|^M1`&Fu^|qm${~,Zmp3fLNi}U ?cXy"GqQbFz]@F:^|JTFQCqJSBEs@uTNuĨXM)IM .q`_"%ā Գ)8@68ec#Ox9 )ڡ÷+DQ+3JilG#SG4:!=+{W -XWP;R|RC0 C0 Ce8rK/ 9=GO?l`#5 C`@ #ܗ/18/5&oC?#^x5NJ}U#㓧-9ɍ7/iJ:N2`NfǢv%GD* QL_}to[KLf¥9kk0 mfg$>Nŋ7bDžs5q|Ǒcײr Q0..7;U`t!%QXMHsbt a+PёM)񽹉!`!`!`!`!E"l L:qk6'F|HLRx;RcQ<"BQD=>k%viz_lO6&!`!`!`!`d6o|).(" mbRQDHDfQyS^v>lGN`|NO31 C0 C0 C0 C0D2eAˏKģ1ο/n$Jaʩtq=D$R2y.YCՑ q?03BR1*C"B<"%)tlɳq'dJӞ6P/6PЋ y ^H_UV+W42tݨQ#׸qkW_5kr!`!PYAH!t 4p};>˗nan;Ovy|7 C0 CH@ kK\W<B#M)k7 M,$O4sD!Nf ) 2QءC'uq^|W|om# &ΝI:YL/^L *;/ip~;\ǎMw}/^{W/-u:3x`7a„J //E㏻3g/뮥4ׯw sm#k-h1C0 ܹsG&U (K /C=oR!`!`@&iy%2WLſ)N: y$o]->M:&ʊʒ¡D<QH50R|j@>h(e)#k@"4U|Nm=k Ib E@ڴiᘏbf%ҥK2iӦΝ;v=nȑ.5ʭY{ݻJ׼yseg_0iӦUQB>cnHMD׶1X C0>琪 2K|ڵk_U0 C0 m % < )щ{^P/N =ޫ?칰!ʍU>lK:PLDWC "J`C6q[AzRtjT&f͞=;brڵnٲe(L"!.sAAnK.n] 5y̶nʔ)nnww̞?~kݺѣGrÍ%nV!`&p51 C0rciS첋hv+v)^{ugymvܘoK&M8۽4q8a;B(?An{tSLv?ƍ}=أo{wGc^zy1խSz?}fp}~Dž|}xK/6;-Z&owwđG>sWn=?p/|L:ō}=wmW(u&!`!`}![x;.Q7!bGY<\ÆV*DyE\G%t2d|::I^Pmd^S:/_˿}u_|K\Æ C o>lɓ'/<wПVZBc\Ժ{ Ռ3ԩSݎ;ڷoT衇~'W^gϞnȐ!2_O=@ ImѢE Q;u/{\.P C0mƍ}͛;]v[3C3E,[4ث~)᮵:=>l;3\;7 eOA4|nEEns側Aĉޣg W\_O[͛?;⨣8Osk#׻A}q;yұ߇P \.ҭpYn&; gׯ)aГO&n=c}JD}m] k}c!`!` J817a_Õq!bGJ{Dh#΍K|y_ȣ߫.QDRRpBb: zf"ʗ6t~H+򐓐,[|?>y}LH7n\=~{QG D{;"o@K8$'&dH9.@ Kމoٞ oU _p  4/l l-Rhg)c|DTҖdN]{z(C m/R Ajb!`vi5?j{3dwCҵ[ #⯬`03 dz~P={۳KO<1ٴi3TҹcN=<;C{RoݲUKwGq f͜ 3:;gNweaeJcO ?C0P|0 C0 ڂ\ެVoH>6 HE \zR:3 I]\O;.Wfې'6ԫ^z/2T }E0t D$}aOI/,VB,<q|Iַo2"pw :9Ϛ5++M +—vzv QYY!=6YNXβp?fIgKڠueDU89.nG/dICP y]ӑԩq$ ˞bW|5{\1eV+)D8*Cfbdg!`! uؤiw#/(,V}R aX!ٴYr{g^}MhRX.]=2=}~cI%m̅<xf7F>ty7%2!`!` ̏đARS7F{"2ŧY ģGdv%͵|A)jcs)0yQgUK$:N>h=)mgC;]g~ nW,K#+ԎH ؗeZ0MCkdYYYjⓠ=/#`2554d×Hbv*TM C0 "hᢰd?q( GÁDf̔{84gghʁHǶYfAǵ˗g[aKfَ1 lIfM aNA F9q,=!`!`3U7ߧ#zE3 咊08;+M$^e-6brqQ);cCLڒFφ2f@Gf1{JH[HRH|ir.I=0Y;Rā8|I])6ƒ0&)hƨA,!`UEnxXW|b4kNf$ao#> ~)vիw8[]t cϠbP')!57 fSN0qۭyU~a $:G0# eb[RD>(t2x"E(9vAEhtĂᙥ؅;B3! )TroHQarx {ZbĚ2eJdg6/7zqX͞ń7`7'wtzoW)RlaYB,w4iRX⭓9 ^KM C0 ^xޟ=iV <Ѓa/G 왋[+qrIbsW}=01X-˿U[ol*,f-1U')Vvao};^܊m!`!`Â$_T_W&4͡'Qʒj+Q;٩EGy)"T:-iH<S)0 yt v"&0e  _#?=Y zϡ3 éSN U(EsÇw#G 89N>p uNN.U2;"(i2?XcMsi<9׵,5X!9ɜ}c=6eǮ]vIM3d2vսsn 8膿ewOK9aQMV'?VEҶ%>!`!P]Ջx21&^zF84JbP'bBvs#=_y|j㳕E_嚊p*PG0tbl:xUei’Ȟ8'OA\|6w/` E HKf]Bִ0IdoqkzcvU32w7G~']׶M6Gs/ywXܟ?7K,!`lY9ٱumE_>c%3!'k66}!`!`@2K-+&y3fL%C= `pOW!ȅupr%ԣS;RD)\[a%Alц ^NiSv嫃'.JqsR81z J6tA`y|a'ڒGWe$O{K໡b[_ǞH9R瘁=3IuV2F-Bc&|u2ybeem!'tӪ C06!!`!`@<^ix7lE ToʲOb> 𧘴cN툡`_R " 1t b'{ !IYR[t}u$61 ڃ #_sw[x;ᘣ*ulM۹Mw٧3\ƍn_~~uE繾GhfΚv۵ҋq0 C0 C0 C(\   ! wFd4BO{%[=6jO=> 2qH+peJQ,I'UBR%e]I?"532`}0 <Ԑpnev}O=;k࿞pt~zq{s.v?/w'4~Pۧ[Ўm%X-Z;ܥgC0 C0 C0 CT"R<.ąJGFYěyUH! \&G,= :ڈ q)W5T)˲taJN颬IR=B]4}.o|0m>9DrS:}F8ݾ{뻽uq_.YHȩENbĞmZr;=3ntÞ{m'!`!`!`"y1o?yEk+4(9p깰B(#tH(i.&#E86ؔ=}6O[C0* jjװ!e͚5A$Mv!d׬]~# ?6E_o K C0 C0 C0 dV67y3vbiq*H[#ԋĞ:]ؓ^m1*{| ߖx! 0kd)rs;gdpGG]M 3 |Ŋu|$}#vh 1g̜Uo%3-/di߮/~~~׹nnUe!`!`!`!&doX*VV,D"5q22!oqE!"I6تL^~UU^ucobuֺ&ۛǹs欘V_.ۚӽ?~ܞ9#^> 7N-Ɔs_˗ǻ˖{\vTmC0 C0 C0 C %-%΍TMɿ)f|A69) y1HJ}6ȗK"%I|/T';#>kb_}xTn~CÓܯ{կ% 6[y' O^x unp?5>ݫ/&y~G?kڤܳܪܬ9swxS8s?^n-5 C0 C0 C0R#P(,'L<#bbC=N9{D|9|b[$T e}*DelsAjPR;Rg=ylHBYSJ[6k7L0JG`']׶MJoXb fEB<"'w]74kjl~٬X5n6bei&v| 7|!#!u!d֍Ah C0 C06 K-p͛7+n9f̘JqqWmZhq/"GuQbFz]E:tU:.=T_6 UY@8Sit+.cwz m$N@`T%~yK0j%+9l%65'ewI:iC5@NbÌKڙ!`!`!`UE ;j#V% A3C  HE|3E<j+J}U AW'/I_@-D7yNI0 C0 C0 Cؚ,[!˓) (}npPυLj[g RUmZCC0 C0 C0 C`"7q@ ,dK ҆`"+8~Nzt I+{/RM C6"yUVuV954 C0 C0 C06=L:qk6'F|HTqz\pyAH"&!PؿQnΊUmM CkrV}~c!`!`@M!РviznvEiZ A)^S\XّG#[/tR:|2uo=vȫE%-AI :E9@`E:ǺwLY8vGGFKelX_u]̹ǿyۦm7̝;5k,\k׮u;u#jʂ 3wiĎ,^ح^ڵm6/tVRB~0uem&0 C0 CZ,[~_vg˹wq~cg>_ȑ#ݤI?x 9M2%yʕI&o߾n]w V_|{B?ZhinEC0 C0 C0 @4K˹ŷ)SFh`epC˧WxooDyE r$b6t2d|::I^Pmdک 1uٝ~$}ꩧRC0 C0 C0 @f 0/Cʸl׆Nh#΍K|y_ȣ߫Qkli\~PcX`zAkhȯ%Ƿr!`@?tZbbup_}׻w n?p6's… ìiӦe #\OTlfwr@4!>LL m!K> /c߲oNq9}tLͣ:*DM C0 C0 C06 Y<H>6:|D)ғ QHz3QDxUaGE EG=)E 1ZmM C']\ 1cƸ3fٙ|uڵ7fcrD+$a6#D + lKK`Ef d_fr-[ ?Ug!`!`!`C u_ő1!n > Ca838t\#BQ{c#NNۑK~|6Y8CpF^:t$@!ux܆z :|?`d/'%6w Cv,î@LY٫WJ?4o<+Y*Z<:o<~Udd5krV6 C0 C0 CDd&GIHNM|yы< ģGdv%͵|A)jcs)0yQgUK$:N>h=)mgC;]g0 Yj[vcRCiȞڋ2n,EW_}5%!̙c2oN gdt{1k%̤dI-ƞqeo0 q 8{S̝r1=3e&h~܋/.l ib!`!`!`u/. |56tiI7S?qCǑҞO^_ԉ ]@XUNlK@T>|6IiO|aC^l↓1#abTɟ,w]6B˭ K!J{뮻i؅/!QY@(BJ6i2Vlso,U}RJتscFu⍊97 C0 CM\d[T\[+֧OP8?14_/87e nz.qvhNfV:EB((C*0e\DuDttv&Raŗ?ՋZ!`@!+9i46G9Z>ʷ!`!`!`#E</G؈SF=v(N*c#> Q|AGI;|}ŊT IĝƆˆŃa)e g(oMŧl901 C`S =zlP0 C0 C0 C`+E 3N)x5\DBRFr(k".|G#U=]!(( D⃼:FO"@Sg[0&!`!`!`!`@V+7%&G_+(iJlĘĹ|Nu$B,ЉxkUYR>IKoK0 C0 C0 C0 C` F@|Z<1+BFrۙt zR.D:zHH ".iA(O=l{|ngxL C0 C0 C0 C0HCryŜzDM|&M)TA/26(=D'x%6\U`R P O0yPFOl&;R'7~f711 C0 C0 C0 C0,"2/G?ƵYYb (0 s!$I^&:D>O~#)Rþ--!`!`!`!`lqdJ %>26oʰIW;n'ڰ#bl"y |J#%SυRFHQ/cڈG\ckb!`!`!`!`lQdVÉ!c\'R2{^ICPT HQtԋĞ:]ؓ^mIY0 m45U:Hߖx 6K C0 C=_-_|vɓݨ7F3gejjݺuz4V%fJOtRʕV0Et5jXa F0o|wWHkҤKmH>ַ]ӦMݸw,tÞᮼ2~\6Me}!` ̏%5W'?k%<o/85 IcWKCP9s)d:z_yD62uSh`$%h+`h+_ԓ'&!`!`I|jJU_us ~M)^~ӟ eիW}gzm;#'QŋTwo~ӍN}> FV0 C0 Cԭ+,p^pgpep_N!oshſjtTOlV"NePˮB Ct4,{D1PA*Cʖz*;[Q21 C0 C 5c/z/ UbEŋݿnٲeaÆaZjMfU~MSzxfK.B 'U(^!`!P]2yR^#!TG* >MelįGs~먗 [88LDOA(iJ:s]r*0H4HE-SCqHEDl6. ,l+eC0 C0 ̜ɺzu,6,fO_X>c[Te&%Kp!'?:Х˞,YvܱYV2{uZiʕ+'|7n\[ILQt٤Y!`6@f6..83o@Jym|Dc#?r>|0Rv_[_qIx-"@ @?eR!UV Ƨ!`!`@d=m}ۮapGG-E8lG 8 d'ܰaݙ;W7ycw܉'%⧝yۻg8Dzb 7|ؑnIo~|uC vu!fwEk䫯]=4};:0uo,<.r_C{z"?6 > M_}uwI~^&:_owigfg'?;)>=݉'T85kl{rquT?;;}z1CpnܹY0 CdxvW1b(,Qɸ|,jG[yDdſ ?DhO{!`!`@.#W*VJ۰x|v%mphΌ3'b~)S\ɻ_z9TNP w-7$pΟo-344bq 3s…A1aDλAxڣLwΛ&N 8\a;b% tw@7qG%n3COc3_5a|O |}ogy& tg9sA<:`(xfϞ8@6h5_M:]wx;CZO'+WzM;6kyUW_N2oٲkӺ 1oÙx=l!`lJ_pqx7Sm̿;.65q|ǑcxUWJŨ`X*O0:(hGW_V[H=~!,\jG6_ϟA;駟:S> C0 C iݒ )Փ6q&_~ʴi٪ee|a9ɉك>F>ҍy@6BoNϒKݏoΛsvf1>f!dg^~~{nZ?_yxO^pZbRȳF n gest8?g aL-Z ~ gkK@w\_8q1?.cWDϼ\G~X` ~k;#=Y=߿0S"gyC0 C`!l7Nɿގ .5ȴHQ"y T$iGQ蹰GO%^y؛ew]td*TϚ=+CRfpv%wegWn!_wb_xyٝwnKgϙ?:D8`C~!M߹gg"s3gronh6d0˴I'ܳ̒N<>; M~kweY D)'>KӦM=Cר+B51Yr$'MrSLuo G)~0 CH@46-yjH@%BqPڈh$\_tRޫBYz;o^PnB-5 C0@E "O: <)"bzjϋ'e.l>bQN~$r4 AC:O0*:vtNF :⊴D%:G}loӧ6)m۶ {2&GWj5a('Ljɟ,9g0 CV ]]+煾lX6i^>K1_簑жuw6׬LU9]!: g{? ocKM~^ dUKU %$]df)33nowڵGYIWL:oxQY-j~UWV2h֬imR'La!`@%N /<G0o|zqgްoqL%{T-i JR bj Lv\0E:/QQ/[_\*cC|Ƣ~_?3jԨ:d_C{oiz_vR#64t&MJ'XԳg02_ Vz)a{0p@׻wowAÆ ˚fk&{;c4{{챎ٝssY>{.J)*mlU!Pl?m}!(|5ܯ0AC?P)~ǡ7нlrO@Geeܧ~N쒋2RvYfҤٺBf\7}cKYY>]s/gE^F_"98,)gO=\tAI:w.s3;{ TNۂrKɒmNH/&s25yо}yYYY3/ K 7}t!`@Z//pfpbeubzpl#EɣOlXq|BGy |68UP:Af-Ƥ%1ت^<wNCI1۲XB#ϡ< ˈ<@ҡ)$'34;~szoFlR!~쉩K>?qg}\W+믿}||1سy?9YQrʠ3 D!0zB_OK C0 -zv}ܺYيL:l$q[銥̃5gF_\7%׵7w\+__ro˾rOn1Y;{' 1cd") rٓ2'!dspyyaGώd>?rȑ>JuUX fĥ]ts,㮻+6}y+OUHm:C0 C`c !(j-#`K#bC$!:Þ*)bJ)PdF%e.|;v;x~k JH?ȹ\:,?)X}g>0ӑ.q7Y蓄rbя~fyGfʄ;vlY(3~0N8!sd/z\}~_A3\Zjbr-=㦖7 C08k}5ox _N :M ˦ ˟P҆^ /~~?rʫ ? "?"[|[+/=ē`{;M?. _z 7a$wʀK~={;dK.wk۾3KrᄏЀGnRgyo +qy>ЛGxȾu 4t>*<{3@>~OSkv'IuxpuIAq~<=8y?Aw}Va!`0Xĩ!:3SCOGJ84$bNN34R|aO9ED`~r> Ha10DmVB/))>Dٶ*ӦM ;v ($y|aY3_ ' +W>k3˲p#S,E  /JXRĖ~СCCɽTbq0 C-4]W7ABDnըE>&'MU'lO==ag;ۓh˖/s"=K,Mtuھ@v˟-Μ+/w]faIcPKc;5w|mߛSOwl=˻_?^&Mvp{ݮvpDN߮チpǯb]nzR]A 9]Aésw>T.@;SΤܹh_g0}ʆ!`B 3>LZ|_>Oa^hFRQlЫ@>JG]LJR^v"#I)̠dG o1C@RۊA6NC |Nvlݨ*MS`F,^;L|bdu$sK! 'v7oc9X`aųE,ww;K;-mNOc٣n%bkXSYh(Sɉ߬6b&k,LfqIR"ɟ C0<6sݒE sHp@1c*1(ɏ&K'\G%̀"i@vφv^~i*kjW= sgR pX K= })!Ka_rO,V/Ǽq8} j ͠T8=k8T%N.lTwayC0 C`B}%%~UqZ7PWe@jJ)`1[*f/ T|yi۶B׿9ѻTiӦ|KR/WT檊.*$չ/ :C0 C&ȐćIQ'M\:Ջc87R.m{b#zt :cWPD4TN9IEDôq4H_ yvT'ErXCUV.\Xzdi9s܈#‰~֎LzClBWjQa?Rx@?x d7i߾}AeޛġońSYΌP  C0 C`kBAܚ0ؖ^z NL%q5 T(SNh#zڨ/@gC4űHmJ X+ݻ~Yˬ{Nʆ`0oq1V)B)~XZLONO\YYYs, 5g,,f}9S1<!:dȐ@T{Oe1Vo!`@mDNЍz%xᙅ'C0 C`[C 51qn $}CD\ bqH.KG["Eԓ'E[uRb_eC*m>W\c'=(!MS^qcf&_)UU,2oҒS_R}lY C`S!)*{Pn*,!`!`M\W=(WX YC4qP)&b_Nm)Nz6}6+Gm:l(RYol/@PRX ܥU!'3'M C0 C0 C0 Cmy%`Z-.~")ë{ ULRh~Vz(>gK2* aRvvѫFL;RM|/r׵k׎0 C0 C0 C0 CdV}qIĭiv#ex1Jh#?q̧Hcj_hG ͅР``#:E籡 Xt[ʊ)W66M a}sC0 C0 C0 C0 C`B2"o/pt1GUB!$@:=tХR((GaGT)ґ^H bH i^}_NN'{Zɽy.ZZU,ҝ8Pd"~TF,jC|^MMP&,F#`0F#`0k$e[Ź1)W+V#>\jeD*.ez S!%bLi`DhĔزQ"ߴopqd1F#`0F#`0k•3k:-Vi" w#ezn#!ꐋ* Jt;j V95-F#`0F#`0F`ApI8//x"go?D ša/ſsSxťTlGЋ;OW4HJlxuK$Ʉa*08Mvl79P#`0F#`0FʪW\j*K#Q:=x60F 4!'CdKtQ,ʢh%gP&$,FPO+Gz-&N~slE1孩ڤ0ira 0F#`0F)h%(ɥ**#guԫj:4)9/~y% "A'BǢ cR{Ʃ?UFck1Fϟg~^BrqߝϿr-Rwߍ熿ァ8sd0F#`0FnZ!EESklbEHE_I>=S_c)ٲ'uhi*mD>--0k&lEب{lܳGCv̝7d߯oXKr%+F#`0F#f Pi$|]5UAR;!KH/J%xWZ1II?%56Us=";lOɃgC*A׫5BPyUO]Ĩ#/E'uXX:T#`F`9 /'y6^:Ę]g;6eW_? #^7Ï>Ocn{?qݮ]>+iӧ_:봸şn+geǎ㷿I }~x'O?94ycǍ9]|lr#`0F#`E`Ee K<#b ͊zo8=4| ÎG"NmW,(SD+)O`vB+>Ս-@f1FʸG#C>y@<5Xhq~&O- {gƌ@W88{xWSN/ϼ_=O[O?鄘ΌHKIr=wSO<.xzh:8ϔm_H`xf\;tJ_X8iK)_r};~?qR|3/5W^{Ct߰[\o:??~4K~Ϯy /ņ4e~Gvp~ %[W0F#`0F` кKO7[Jq->űQ_\f:8tV| ?0PN$E M?}mRrI[aCYS4_.0K +#?wv3g._s29&Oq27b5\nK;أk2r|@U|қov/ޜWyVWBN"C9\'fq7ٸgqw@[0F#`0F` кŻZ_]`Z>6X(RdzQ4HMF1e ?|?,F| gzHbe=kU~ τqlD R/r²R3)9'Ydܔt!Ig,H[ ]Ư."˓} /wzCor #`0FVfe]8NR"֢iѰo8>cRyTK>T% 9%QSA\d~iOşg ~C%1E+OKEb@#0nȸwҵ1l#1uĜmώ}bP'ǀ.-W4gzr\K1/ռ3g*)=YR*c^[}{'x8/1'c]v}^S\,Su7Yjh|dǖӹQw lD?W2{e#,6d9F{0F#`@=fMSdugS91383x6l!Ĺ1[z_: N/\"nFT^Fno~$olgғryn yudt^n)ȥ2\Oǧ=(/g{Dޛϼt]l.Wg o;$uOK|KxhٖG~H{k\srqY4X`A\xⰃ>nf1F#`0F@a Ⱦ"F`sÿ񁔒tD{+1FK~/dR]DUhIEQPHБ#1G)"RJ, ^4cC?z#?>/˖b*BrOyfD;'\qW-7I9e괸kҭKpnMo|// 'yͷ-v__vUp#~ʷh3cf|[6?:p83?/Wp+#_]w+ T?\lΊ}#9O̷nok)W{N^EyoM3$7#wKDR Yf['ė|+  #`0F#`D% F>dqoSX6y#-Us7XCy0G]W ٪ĆJRIhRJZ4ыHďHR166^9IjL :; {erQJlrd-{F;mRіfϙ6T?3wn۰e1Dt*g̜chpm%([۴綘?AtLGV:w.\/y׵[j`RtfZg>Lsr@N+uJ_%?W$#`0Mx9}Zt-sܓO>*|}'Ϟ=;6hScrzXY$! 08=tMuƈۃc %zlyԆ.a,:ŔbY$+$UmDD hN#4YMb@!ln0+bDNՏ]_fV3/)9 ^[ɢo:uHNqFW.^ֱ6F#`0F|W7-?59,8D`?+8ĢXVcGMGdRe#{l-2U (0͋┟9HaGM?i{F#`0F&žVi[G]}ZYX#"0DRc<Qȃ mlŦ#;v?<ŭj7O= JEۺUg\vu՝#`0F#` @_~1s豢ӏ>ɻ慓Sjr^ag JġPU\zOnVb^ZD.E;WRI^GGNJ>&h,6jB%m_H]#DL]0qY0F#`0F:u;ct=1JI蓴.kj-ZG)-UBDD!m/QHDlIj"#c1P ꌥqiJ $0F#`0F#`@!6۔N₻BK;ŷipgb7+ڨ_x;l-ÆGU,WU Rlm$CPIz5VKQ/8{-S|Aq#дgs[0F#`0FBZIKRn?r+\0`S$ !ERjUۊ-{Ƣ_fƗ2/t?0M.sF3vz0F#`0FU@ rެGq!b%&bE! *!,nh/E²x c cU6/,Mn-F4>9-zۄ1]2a„5kVNaѢE1f̘hUӦMz*d#߲w9+{ڻt#`]ƧwNM1#_|qEw^tСbw}w1hРLTp }mÌeˡCŋ裏_֓N:b-+cǎSN90F#`0F` к . L© AG& :"T ŗ-e'G5E`MbPHJG>EOLS)dRIvzh+uDm۱$e_@"-/xC9ǂ1+J=ݻꑓKa0F#`0F`%!P[d %4$}DT3+/ v!O1)C<+]ST-#!Jq1U3SƋ90'+[P9ɌF}u]#~)Ӈ YQҹsXxVo1F#`0F#<vĕᮩ8=xCS;5FVPEN Q Gy"1Ch3^cO=DXʞ9LHXG9=tm HL]01gm\Ù˻-P\;qn^guVm#Gjދ~8F,s=S ^9yE.]bѯ_l1uԸ{snatڵ0U#`0F#`XٮU*_t_Wo&:؊WF[[V=%Bb5^#ҪUaVҲQ" iSb$!@ AIM&[/N _uM\ϥ J`5s`&qGdg νr-cǎ ɾ}~ӫ:hov9&DuQg32ާOܯ/~{p ѿ|΍7g}vAN[lE|ӟ$7߬.0F#`0F`%#PśhEM؈SF?vTbN~ЉD/T}`>ѿQ!4u!: ^$S5D 7]L1#`>b裏Z_7 +wm_&Æ =c=l͂[~jq%;7<ڷo}Bb zX ɊGVbJ YJ>,AL|Y% qk+5{arN6PW_|Rg<+0o-ڦ#4vXi;ҽ˦8|'_ϫ3!3VcrD_<ՌJՖl& -d_ƍݻ՟si0F#`0Vȳo'=" ,ĎťIWC|)&cHq[+±Bt$IRҐiy- F?ǯJ^E#f ؆,6mVQr^^:RuFΕlJZCz5M81vuץlȃ,Ο\pam#`0F#`XE~^BA%|6D›C8ڈQF?>蕋Gѩ?UxtTFJ%#=*&*=:+q@A/rQ&S5OH$mMHc)~_$YX&c8G3 "\Js饗\S.R2dH_:d.)Sl?>_ 䬂dk:l{.o';>]hQK,qli’b/׍0F#`@,sߍ<˚>5?׎n];Ob[岬L±6_tMqE0kT"ݫWxVq4\|8B?~%؈C_Ġ(?]CȆzQV,äѫ5ʆ?ĮW&ޑJ0ˀsb`V-8_!@IDATCU8K.ɷa:\~/VTE !%kM~+Gc[!U-F|?'b%M+W4F#P S=;jCR>u^lz+4U^7s-U[L'}+)θ0Ԙp`poM?TmRdC=~/ܛ!xYYK#4I$ zRġ$%Vdj|ʏ&*&q* ʄBW1[7۬岙Zv-+؈6W~I.k#`0F @yd~=(I+CW^GĻ"׆Kx4.U-qli}mA8a4D#UmNL|hbʎSGWE[3W.5k{wm^#`0F#`05h]U)>NmqnʹP>d+.L6Q{bѯ1(GR_~TbWUVODb&N<$PV"S5/Geed1qhƩ$OڔnZa$O9GΠٳgXivbFE`mkc#`0F#`0(,dE)^LJ681Њ:&+_E[qy)KgL1 q ΃~1$(9JcG|J%R%>%sG|)Թ&^JrF#`0F#`X=%yXF0XߢTGF7(ó!? `_I~ҏtJ!/#S1_"lUK=bRp?яAr.N:W6ogώy&lҬ):/#`0F#`0F#@%9p^T"66ר_+r~ [88MDO($s=r*0Hr"묳NpmRsΩkL6-#`0˂mf͊ 6ؠ18 .믿~tܹ!]h3t`hj̘s.7^"X /ںDu7ܗ/18/5oC?P#_x5O*SW,ƧXqsS_dZ]!(IRI6 0I'1UKԋqPGD* QL_9:M|?GXȪInJ^`7x㥈J^pe,- ,Xx1jԨSTt=.|̓o1N7y.g_{,>=of|_/'Nz(ٵkmbw.oLO<2{c}YK.1tիW{3f'cSSO>0 =`<9Gy$OwQG4o7e][mUO|ׯ_r!R@W0F{OStA{,1g}68SKtxg۷on\IV[ps'8a„}.ѣG7b0͉g?]z[&1K7R3CGOk~J_zdI׿mM3/f[m}E+@xr4ċɶȿ;65q|Q/Ƥ(NK냯*G1>*)2͢S F"uXFY>a_Gb TtӖɼ9g[ou& )y7n\& -cꭶ4C^_yd 뮀fm2iw8l/bѢKZNN|p@;gΜ\^Nĺcy[:$߽ 3!8!"K㪫[wad;3n/|0o?w}GN?R-/~^ʅEla/;#`0k8?ps{0F,C^?B\<^qC6}'##Nzt#>$&%%O]A@l3NR:AG46vHT Ȗ>S]yc0N:M>cZ mݬQY\) ')_1 vsT <86h#5c6N:) 䲘w1ƎJ_lR.mf">s_"s=Knms2!-8㌼JX5-"!&O>=o_TdE.?g?b2՘gxO+;_0F|wmgyj]1+#`0 @ %tX݀ S8fFuET4r zIT2sQHJV+F`?az9TF)%@b }$L)"<'C?z#?Fɼ4v $@BBAC.b19_ (۰%pE)TDv#GJV[5aX GV]RwyK9$rQ|VFE[XՈpn6L&V9^y啹2aDe'&c +)n)e~l3#` u?؁?UGs }R'{µ?mOot>WvȷKJpLv$SuD_>J|/G2Jk9ʪDVAZ= dɻ0YY/a#gj.+zq(/R\qyg#` dB%~n.q(8G=.iCHK]qp&5gDG2fxOa! iQ )뮻Kcd8fQAռ !>h&Uyd`enE2dHݎ)X!@-&؀'yB`뮙lU|Yg0́+&}韉<ꏾҢ~?FNuڰkfQlc#1=OƆ]:;l}ۆ!_1wAu4}V\xcdZ~ d}_|-ޚ1'~۹CئOn I8-t Ju9q_^Hgnnվ<8i%(ŏ5r"?yDR) DJe>p6 8b0FÀ;>|9pJQ !?:n.+]H! x`$O>FaÎO|k4.;!ъ@BNBjcq>!,y#>$&{^"?tgyf΅;# *<0cE$8K8R\:&̕Ŀ ?R8)+7-F#М,~xb߳[5'i" Nvٴ[?ώq- ޙ;?g]ߍW{ 2sy@xϞ9RϤ3*ɣ>͡[VCKug{mbI++Hxh_xx-;#gV}z45»U!_p_xِ!}!2E!gGC1hSG'?b:(~.-rN)VA5Q%N[BA$Ȗ:cńg 6a_9>/}odR/es=ER[DLb˖$.y }ٰR(nį'ȇ ^jĖ/٬फ़ՌP#,n%wάlD TR|`<۾Xъh[y#lc0F`M@m*yl'|NdvD~ϖ !yWagu wZ2ŇIbk׮y9$e!`-"7dĈՕ;ZT;՞ᏮG+HM*o8`/=ʹ_~*aWRb0MMy ǎ7Pgb-fdv6%_ .%erwfՓsp`%KueWk` vDWϸZLe;&coSi,ŗݷcrR5P?uHĭA^4,rkشYÎqPW 艫!oIFR)R%AJ14I J$y&Hb<;%>CǎO|ЖotO=- eQ$%%/z/E>bK1cqSuQxq//`~wǩXygKg+ a4۲ `WmD-V`^X9}i#`0k8de!gOJO#YtD zRiEc=ĦHɏIVʾѸkVhY{Hn{οOeWdWMU̬4FxG) ~c:(%,-F#f f䮴(t JR8=Ml?MRϢ:봫oyb s>k5碴Tߏj@"WK]?=m^DSI8K uJVh*>>c$E&( jc~Wʷ%ԃ·)o~%$`RGO:ܼk,ERy{^y-也uY/_je1'C ʲК;ZEY,s#`V\$3l̤(\xf`u[VOIg8y=:ExdXm82o婗<~kLd[cKi>*][Z%4-&Z _CG?(laC85lImRqU<~RrXWQHȉWR$U'*¤)E1駔?I*H%HF^@x52)Ԋ"1I}MrrreCu5Vʉeض7F#:{6,qwN`5fmWHǮp-ݬ?ZÁUlAY:B©8ḊAɪNn$ggD2aYn,$vcwn%la]U^~C7VpBiڥe=D\n|Z#`V/x/-v9p-knMSVQ7?btJDr7lKˎrF:t!>>>bwZ~تolйcɗe3,0mG`@NmԆ͏vQ]ܷɴґ- XW=[iwE5^hQ^Z)l(Ƀ|Pg&5߈ɍ_#_]#0FAqQXIkiÜ|dY?lQ_'Eai>zn5'+a7se͜>tN+X2ץ/̗%d0!=gZ7ِ8V+B:xSGN6i ~;UK|S"rJT !JV㡟Gt/WC@ž~|Kv|CM^FYqI|qS&>2_ =Q#`>|S7(T#/WQ22,U]iv][V#Χ<0Fyj$Y̸Qk"'WS-_VmKI9a #^!L#ܘЇCD¯.Ό~8tCh#~bS|㨓yOVrs=`CR-K | Dt~hLNNyP%8\1F#`0F#`G Kߤ"oL͚G4jQHB؏ѓ,6i vUO]B])-NmtX0F#`0F#`@vo.dK_}V9V_r)X-#1"emV8XIb/0Sd'mI+([F#`0F#`0FqKۉӃWR0`EZ0( !-R,b"38P9~P4b0F#`0F#`ԯUK+r}W/DVh!E6b'Q?E_SH D|:56UK}_!}ybѾ}ܹ3-:H#`0F#`XnZ8&L_ C?X׎n];~Vōb@Lhï񏗺cTy64zgRVT"ӏϚA@J$ :JD:D0N& Ʀj)|4c428qb@PJ (:uӧ]F޽cuוK#`0F#`0F`%!Vb6>?+)6!P$GYOC^"SHD|PƘxťTl䀠ǯw4^KKu*!Sjb@溒Pd0LvquF/&;J{kU7#9?qergϞ1`DOn3k֬xbElU]em3gƛoY?eʔL7cƌz7F#`0F#`@pe<k0~2,$]O !ER!z6v.B6"'?K%}a-/cxd?| mimVKnF)י 6ȫ$YUI?ecǎ{wJy@_ 8|0aB̞=;իWtA9F=7>hկ~5tW^yelqi.,gkڮ{/<۷o}W<#`0F#`h JP2Y8/ixĵWM%{&O~\vW,oآѸT-qv@]U䴪AA RL$5K uyWP% " 䂾h(fma{7ҽ{%rR[tnfܹK6Ҙ4iR#Fdt-"ƏwqG#.8cXp6F#`0F#`@91f,^UDV5H8-1lIhc![Y)"2U88)ӌ[-\#ln{o^yǞ{Yjb0F#`0F Hx1x280pmjC+SuO-?U3+r'$!dp uk#I!J($Ʀj.;lOɃ<ܱGsd+6B?v7x#׹,-–ɓ' v$>6p%\=31lذ3gNl&1x`x#dz>ov2~GV}2-=PKy amYvJx ƌ/2bu'[־`'cԩs徝v)|dzίL~9V9z;=ܓOСCj~!Wfb0F#`0FUPq^_:qh-JuiS |'lH%=6Hy|z59:%W#!Qg m&jmG_?4:WL:cO!7M#GPVݫICIR%Ozsd~b$D= ""R&v^fnf$ۭ! XȃߴW<62|"Mb@A}K_$nI'|2?XU/R؝:uc{zj>@=#1jԨ? *ŹJKA>sNѣK;se%94:GVs2f}qz\^䕾+F#`0F#`X^%Kuj$ Q?OSkï9AIѣI>ФR5'G$:ԕƲ4Q8""SGW6B]پ :w[mUᶈIj2cƌTlkfҳՓ>D͛W"(!%u(#D 9%ZVzDPAĶZnݺUltX$!{GK]1F#`0F#`r"кK"o%r:B?7 ,e@#:|xT>V.;tq-P*v*q(E;BLh$!=+{W `,^Π'vãM%0]:=&hѢ51L#jW4<ĒjrbVr~#YjE_jrRl>h5|7~#DqCCk.?L49Ν޲OfAY/~\#`0F#`Eep_X8( =B~kT2>ҫL]:cyiLy|iui$I%'$$ BT-S/1:Bu|!)[+ASO'Us 'MTTjlf%$$_׼ }Z)J%9?rKlĉy5" 9Iv3z>m\SN*ʏVeW$&E)ZsyșÖs jEtl%O2qi'w}ͷ-wSΪ"vlPkm'8ŧ?uX\w-s2cӗnq/F,;УӲ;h`i??M0F#JXu3O˻=@x*|F I cC(e$D84GR5W &:hcCIO\z|ȇɎ865+K#̝+(%X䔼U$MD}'m01ƶ Y}_V ?He_IZe5PcˮXo-r[ljfdՋY-^5}qN:[}+wyӻW:ླྀ??`Lf& B1~Bfk7}84~qčޡߋ.v)6H/Jpu7^]QsU1~~ocAC7^12t޷r>/IcO~yItD>BE}NHi:B!#`0F#`@"k:-D~WS%(E4PdՆlD\새R+ڦfa_fk(~\m%*0mG`߽ǽWFF^qsgg^x1I;_z&Fz-⼿?'?g{8ƏJY%oWxv?.+!o-7};uo_ *]֋7#GMN>8$s0׍0F#`0FI(IM˿%xI]]  J1jC"A/AO[#"6l-"(ex`C"C99>+ ԽzY)7^r f%][|dK^ |o=z!Ib[Y 1m1y 'ӭ\%H>N1)J?Ja˖Ud9fn:tA;G֟C^}} #32tm HL]01'۳c81Kj1N:Ʈ˜/ }mZYmruگ.9n*9s i+o~9;)5zL,N87^P_Hk͍;?;{>Op% yLǺn0F#`0͇@+%`.щ{__Iaσ Wy*報@=%HB! `Q!L XA~JctRb{wb@(.qׄ+ti4~}񩾧hn:놀ח{1(39e:3zKgny7#%șK.2Xy͍7gy!q3]sÎ;Vn~E9-ꯥm=t|Ѓi00F#`0F (\ )I)~L-Nz7+ڨ_>7m?tP2V"*(@UQH"cH6d|:$aפ*a٢4uD_ID~,gP@N^i;W6hB\9DV vjpz;H3*%뮻n?-lЯoX\xvs>Q~{N&G}葸btzif _ʗi)|}7svA'~!#<3tzF#`0+ pbo$" +AŎű1F%6ꊇ=QGG?:Re]Eap= eKr vԕJN'zKXO8}|{0mF{\mpd CȾgĹЖ!+vѢEhLBVrΉ 7~,-_ܾ؞1sVʹs/*p%TָiW#`0F`B`U͜>-u떏"vU1{hINHI8XOmr>x|#O$A>J>N6i ~;U yrJhS iLl8E$\+Иj8#\p$ۺ*aVS/)ρe!'/#,H1q:K#`0F#`qĹɿο%EN&)V5pJTW;|#VM8JULRFe65e3bڴi=zg䆿9SL\.eF#`0F#`V!k ͕// |56te"o:~Ad<%+ޭR|t=&Lo}w7 Gǎ?Baٯ_p <ǟb2tX-UQQ (DGb$u&Lq*oS|&+`R_ҡCLA )O/Y=JJVX6*ey 7ds}cĈ C ɫ6O9%B^}1x4hPw=UYbF O==pE*0vy..;k-{/}k_N:J8+{3zI'U쯥&cǎƸ#`0F#` V dDJW×aN!84M]\uo#ꃓC+'#%:jAq@D ř[&Ȧ ( +.{&c2d,>߹_y`8hl1Nsr9ǀ%lCr+nkh&/m )H&(Nژ,θ+Ȫ\zub~9lȇz(m1gv[ʸ|gNx饗o:41cСCGy$'ONO8p'aرa)C֭[̙2A6 شm.w቏oj(mdNBN2f[Iǎf3o+c0F#`0F8$qm_9g'r0$"$šDuoT2^\b+F:WVe-S-@Cfauqɧ@.<ԵpJYa(''\I9H9QM9 ٷoϙ8809 eS;->pQGMH &$bqʔ)b򪫮 lfd̉s/+ q:3+!;?D<8ǜf͚0bĈǛo2&|: ) QI'[->7Oat߫ ӖMKսg q8snn w_2dH"ǹsO?{{/L4)\}pw.z_CϚ;.=ipޫ̙'g̎5*]ْ̅%KO??G#`0F#`v A)(# !I[Yy5#=\BO$x98W4,ihr" 5<HIc d8&)?tq'>IߪAd rwmdNB#NN 3I.?~ݻ!B. ?x"U hK^~D\q) c=3L%D%Yٳg;x%J9kҼ/ "d{Q3rrvUr$2 ;ğ'/ ު 9o߻<#׾Rx=zt" _t)ՠAd3bi d^<' /Lȼ9#gH2_}ο$}?w}_O?G#`0F#`v ߠ]_|"MrG?" R_smW`.EnHNXir@`/ `J&$BTr@ ܆1"6fݩAVě Co%Od ~Æ \T˄> s_ɴ}K H! X%,e>}ˎAL^}?GN?D9|-F#`0F#c(HpbOsdjF8͉D93J06aK?vOQs MH&NeKjJhOQd`WUȎ8 Ȧ cIB}gP)IK~ [o5Aả,\'.͓l3Fie; R'9dpP u3aQǎ5yi&jj Jxz&?sGSd}MGH#[&!j3*!'ч1ٵkתgiqde*ejAi77smiLKB w3>p<+7{R~yZ@1gArΪy|ߗɦZVk}\RUM?JypY± d\[0F#`0F` ߔQyA2FH?phpimuiS |>'RC "OUd\W&D1Y`B&}_?4:O:cď;W !"RF%Y Hu,%".fEزroG6"%C@NBsҜ) :/*nvmreNpf%#g{NfmF$@XBgϞ'291¥4d ]!9@?x}G5md[ҭdmy{W_M;yHK21N:5?lG ,`1F#`0F3\7樹QWXBB j?hE2<"UhC(G $>4Q ""eK?sBCnRV=S@Bdއ-1Wp ʻロ.`k($ۨ2 dlIa<p7iJ%)@d! L49 7?a"X^|Ŕ|v[mjP2O>9<ᦛnJ$ Ɛs|rl {TȖbz*xG/Y>%kIyxɲd7/)2y ާd_ )ry祬f2%.#`0F#`?8/]+IR|؈_~-'KDmy|P<e; J&K=})&H[mT%JX1~#"2VS>JJ">&Es(9&n†8PAYd̙3ᛘe^BdB*SS$J.̏Rܦ\BG ]w]r2=ǏJZⱝ|n ޢU>I?3mf'nnl'ˑ+?GxT6efg07$9QUOcl+b%|fTͯZY9ʷK#`0F#R+bư|ưn萖;m[; ]:4rؙxb_php^kÏ!gmD% ż#nN>KM+K-%$D H'1Vs8jqBSO]>UOȢZtQI 9@l Ќc gKL?W秭-y][Zz#`0F#`ZKWm|uZuB& M` a ^l=ox1859&[xp&8yLʺT'}Щ 9eLPNB`/=m[ďOg|w1yX.~47/BT AɹXDGgW#+b^MNrbr5AY]<^fCTx9x5=\87'Ώmણr ̓R KmJF)$K=ѯQGԯO2 )RK(b0F#`0F|s$y͙+(wUQ&c5#HW^BA#2gms_L=u->1~G$g9J}Vvx,F#`0F#`0F@J_^nCO&F?ukO4"7 ,(q%]ޯaxQHTd&Q%y˂gF0F#`0F#`h!Jcpc͎sb~ۋאPD#%8iy%pާ}x(%~rL:6b^ 㛠0F#`0F#`@KD \;i[BQC"G'z-bQ .:{()"(R[%5g#`0F#`0FD %<\rOWZJ, dHEĄT>dEB`Х0F#`0F#`@KC`Ӧ8٬)?Bq{ž~ A򠣏&@)=""[Jtıg҈b&K# n:xF#`0F#`0FE PH 87qu8Šx_%#H@8HPDJÞ:cr 81E1N1W%ԗӏ*0F#`1|O?/HM=˗/F#2Xn}@YN/7ƺ@|'Ke+ܩ[4d#b0":/r=S~j,mT_7_k1F#`.Y~km_=-X`>+q^qĉS5k*Tؼys3T2#`筏+~|gxշ _vLx=夡rck2(ǚ-&c 7h ICD;tDa,ģmC4^%:!u@0F#`J?~|Xxqm#`0F`"07fC~tyhᅷnCv PȠ,͚_<W JlI fTudNXra1 cUF`˥0F#`@D,G}4^7&F#`v@w&#/<=!]if˿AJ漝4yPMj1$Dt! -%2+>|h>d#߲WRW;;a1F#`@z,Y{p9由S7V O5jTݻVNxÊ+l80vΖ?<<ӡgϞK/ ֭ >l;wnsÙgst>C=;멧 ^{m"VopiO>$iyLoذ!ᬳ ]t7w+[n2~rJ]ѣG/H.Z(m6|cql>:1^xWÌ3-s8CΝ̙3իW;,0F4S|e4snOpCs3>HY٭8[ ?7%μо]0x>* dk5~jhiuIC9 MRř½/XwԻGc-c'w# ~Я^22y!=pQdžN{ ׇ' {h&? ?6l1N=*sB=ۆ=)[Mx7W/ +GA85=:=#ģQGdq,̑zUi -8Q`ɪ=HDMX |0{Jo:vzj J#`0F`kN8D"N6-|[D/č7>8,\0u][]βt㏇N:%{HM>P_ȹٳgGy$!{ox뭷ٽ{oNNB :>:%塇@j?L+~Od-[{9<n<кuD^9;v 'On>b>ᤓNJXd2c=6Ł#`@Eֆ韈>D{Liᩩ#k d<|Xru8#fk~=Dʆ[37g oۦUXz]:gA#Ú"{6L۽kx.we,Y*^!bͺcH/ٟ|N9t@XqS W"Pxē5;07Usdδ208;& LTD6vF#؋ECq 1KK>A $#`0Fl]tQlkgA xu%nȐ!dYh|x5jT"=&8$Qg]692@"d{II&$"_-B7͔١C0iҤD\2 ',}+#Gi?i:lذd:`pW:k·k8p`"7w}aoMn/5s!/O]dNg}adB?M:1F拀Μ=C6ca!i+׮9,|8.ų;Aĵ||T~gaI3e@ 2]Dp_<x/2qkmh!I3tT:D|%)_/diNQUYD$VM`DE`rR"Bu%: 5x"E4(s_0F#`r̶'x"3&yWP$ 뗺K/ׁXC̙8FHJKDZItAv:%}"J%D)]s%CIcO!YϏ˗/W3eUA6&tJzJo֖rh׮]?#SwygRC"mH#`h}[ \o؃\X۰) O%h4/;;wLYsy?Oj2s!6ga٥Sx;{m0 $yL8Oa1%eSZvܡ;}붕/X#)х^|\HBʜ7N<%mqlZ$'5V>qS[eV"+t'&-MZ!m7I-ߛLRIcCPe#r2}hMV /-ݠӘXM/e#ޢUMF#bbi',D2.%I(=xꩧ L-lF+P6$)HL _4enrn$sO`Mlؒ^MzNΚ[nS1*cs+guuQRKḊY&E#`h^L,s,y탏SfXy@1MI:j~Qfoċh.;@f$!=';?ٱzC֢b2Oq9a̖֝vuLᩳ/Ƴ0{7&|"x50}dȹRlH@7\Cqk70?NGqDqpF&*gpS&QsΥ;.!L0vHܘι\qyyAp:q#`/WMq;VoȠy/r'^7e13qZѾkUBL/VLJr aUHk(> #yɋC;'*c3qo(|aQ[DoSj>=6 J!/&.["a!j=>XD^qӧ^'zq1#`0F4 HJB6!ۤ_by/4)gRrB9Ikj6]ɧ6#`@DjIC˖j2|P_OMGd\~Fa>2&+ ߔl 9Y߮+9E;xa#$ša/ſsS:FxD~Zu6/[+k!(qhGh뻕łO:_i10¼{''4<>(X0F#k`s$$7QsVbcLBI[0F5tN$AZ>8pABhmQ[AH9٫kݥ?{jqaÑγ'ؕD:WKIOO$x98W4,ihr" 5<HIc d8&)?tq'>IgPF4,F#`p+n6gw[0F!зGl4 l7|%яwTykÎEqYe&*j*0 JdB"D/m#byl[0F#`0F#`hQvÉ!c<͑'R2{Eĩ@HE1)=}zNR"""#a e{ti0F#`0F#.k[Ĵs}e8lHp"BۼIG1ˉKGlզ.ꏪԯco1F#`0F#`0-͛SN`GWU4Vd$c*k6!>/OcOd&6hcGs0F#`0F#`h9n DZsߘAVP+FDl#" A`zF>JŃXOn yȞkw/m#`0F#`0MDu=--ö .ɩE< Sz1ҠA Y(;-:}yΓA f,Y0"R%:4&~4#`0F#`0Fit6,Y$ef 'yx=v')lȿU{UkH XLC[b6z|WX^N$%>1N)-F#`0F#`h2=; _>^6(:<o*.v/K!Q8F'ʨJ}y|71ՎIjxX$A~hc#BSO]>U#`0F#`0F|5Y{[WCͣ[D`&8yL 8QUOS1 M0וs y$ AoS.Xc[-VΘď#`0F#`0F#"y6G&5t;#>$&%%Oc "2PuD |$~|PW%v5iؕl%>c,F#`0F#`0F!P$y7gϣdUEdb5#MN*"s2[#>eF~_[߲30F#`0F#`hyʪW\x5ىGc۝k =RVj!(YvZJC*ND"hSў=T%y#kӏC-F#`0F#`0F P (ŏ5;-Ήpxb@5$(Bq3^d8d :&J.ͤ3&aPIXaK)CqUO}>#`0F#`0F@|'Ke٩[4d#b0":/r=S~j,mT_ ʈ#`0F#`0FE"PȠk[R97(ܠa4'' !ERj>sx6ڶxkR|gPF#`0F#`0-Be)o֬jX |NV2DDeKP0?'$s’ s$\+FX5:~X.hlT?X*>]2tYkUmUmRcoeaѲ/쇟**s.}}*q0F#`0F`GMk %sNe< &"@$TL: mːOJ QI܏4%g?l]6|'aݺuePAA[ a-9 T߅o_<~̫aض˹_vLIGxGA6m"AHǿ<\;4/Խ&opn4ƿ^qWsT #`0F#`n@fͿŗ!xU9Jb<$!HI tXĄxJǮO>C[#jފ,@R~駁gݺuK_~ ν+>aaРA_zpeoOqqJĦC?$B#1F`R A/\d 0S8߈b#fe(&cc2VSxgK"';vm6ߊ+œ9s};pYgm8vdH=0ͰW=١?o}Iwyܞmۄ^sQ=צqӳUF#`0F#`['G_&n m@T8o&6v:))ߚSVҐ0aM$_Q `K yS6 |a\DygP*srrMWms FQjժpw)S!C~\1;ޏۨKbdNN2#]o'^M\=zHgGc• . S>8eCpIa~=+Vx)ff. Y_r_;__ӱKq:/+ۣC׎êuϾ[3C8z~; lWӜ3?5̊sj߮McH.PCe+?E=C%#À}@2[f9g=(\zґUw憇_~+\5rX'n)_ϸ׍0F#`0-H9.(y2qsQErRc8';5^kU*BwRG P'5 T$5^}#2ڔy|t_HÚlor܊;w8ꫯriM7~򓟄G}4lPwYgL>#g?YÄ mg wygOx\p~5k,XooWUxgٽ7Mj/^\wsb.3g /BÛo[ϧ~˕ǟ-Oܫ&D"s 7ijO|@pi{leKHfI$ o~rrEtGR9O0oT_σĎx0 ޞf]o#_x>,Y:q6G^I$U5Oj>i漢Փq;<[sr21H؞z}q;=[p'q{^Qx)yؗCiL1 !,N?C˞ui~V>Ņb0F#`( &L(NS*X-/u+ղP $P4FLl`h5lI1 x%KML[|z1X_jJaG3ItTB lޥK0rDH;N`N:Iɓ Ν|饗(FAyP3Ǝ \rIB I|wܹB33~G'|2٧O4~8\{)d˖- :u . p'>͛W++nW&#AٵSk/mZ 'r@T@IDATo?:4vh>T|<#rN$#ҘǤ#<pa+SvΠm^}p9'3) K+Ҝ8򟯮l7MjrZ$ٜޞvH"?Dwcf.SgozT䡩 YR4Sf w~oxOSߡ 7_I<lN~)BuXw.}zy ”Hr8*dj)Kg]8k㾄X5 #`0F#`.@B_c^S mԎ6"pipm|SGC0^6ꏪ3S lR A)G .GLN`&3FHB4YΖ/dX-a|snC j7ئM|ĸfg5`Ю]ɇ92rJC@- a[ѳMr9sQG2.駟Mtzkܸqc"6!#oҕ=L4)~v13f9g1rDXYO{ "PKFrj٪5q[q]:c : 9 ot K:wGIp gC7|i݊MyyH!(/HPV&Ss D + vgK}N2 <q0)n&ϗޝ_$c!Wː ͼ[aerHԒ\ EPv:W*W0؛|ɷoק#`0F#`@ G7Ȋ'~29Dؠ'q;ڲOQ' ɘ<c4Nz+|P1AбHrDSt?$7 0C47HES<ݻeiq#˗/O䥶bsi_aСcM&ޡCT$qں-'SһwtS9۾ٮ309{bBr?dza^"([$}K,I}_C_.)c3̺r!Hxd=:d]$7@o+Q|N:qCbؔwҼ{3q:%[_zw^8Q*?ϓ(ٳF wo|ŔM{wX,'.IfIRxc7r$O]z՝G{̘1)GxyH]*0HB\4lPs1>5 Bf__/!OP_.˧8d6 ^^S*bq+=aZڮ\.%z8^עe毡, YdYrQ.?m[>׍!}: 0F#lh. HLY;cr:%" ;|=6i׃KI=zl(5~|VZKKAC&?g1Eu8<i\dX)Š-[G@RB cHJ?i|Cl}{)n+^fSO=5<^KMLH^ 2#Ht"J$~Ϊ8kXi^K.?DBvf\5eFQG16?4pI~b΅|~&nzy{! t؁m{.o&on&""A9yٿy<P^եSu dvB.h=48 "^!KCbr9 xdCR7/ّ_6'͜ՐyڶN+ÆL῞.9ᢚ *uOcw$F|xiMݱ 3Ǎq[ɬdW\Wʥ6\o7XMy}JsϺWKk-k0F\b@Ib/ .G] ßprq!E6R1,|?h˟~Gf[AЇo,;JoiN$6M]c`XD-~gMyg,"E[~d>/7w 5VoG$΁c\Jy_-u4JC\Dzij(YK,lX y~ecmWAP֟[8HFT2 kr;vo0F\TQy_8>}pe""ШËm83tϓԱ#lc|h9x8|D'_*ܐ0$Tjrabj"VxmƠXM(-6ez%HHR|Eܪ#S:! &C` cǤM=vħO _`klTIIcǣ(#){h_Ab; 6F#`0F#Ђ(\_:qh?2,çQG#M1&ó!? *G⠒09&D ꌡyM`Z ȟ&ISg,`2~Әb0F#`0F#`Zv֚3 RW@fEQ#6:Q*"~r AdO]!,Pa[/F#`0F#`0F#P0'۶ō$~)|Jq4dHHBQ!8OƆ&HJAH2"R%:4&~4#`0F#`0F@awη5GܫW3"vr.b2:"mE\˿bczգ:'v8ŧ#`0F#`0F@a7ܗ/18/5CÑ/~ q)PzS2GܜƔW;V5$DAsS~'f^NC?-}Sa#`0F#`0F#rch$-Wh!R}.%C0qK'=>G+1U$uh\S#`0F#`0F#"צ7G9AR*P]EDFb+:ACRI8ꐏDσ-zEZRGԯO#`0F#`0F4 0miԛV1?]IM!؊G?oقb0F#`0F#`Z*W%^MvXvϜJ-% N S~AىHm<{*{2V<5ǡ#`0F#`0F#r(ƚJ?8;՟2(#`0F#`0F@oufv8V_X,9XJ HEG GHMJTE3V|F(u0F#`0F#`@CuD5k- o'Nގ:_fya@CSi)PdĆJli+|`+2]>qڌc egW#`0F#`0F#rؼ:,I\_|/" @ ,1:%IWGLx#y|to2"f1F#`0F#`hlZTܩ[pypUE jTdz |$HCJsvm.ǡ$6Fhu| j՘%b0F#`0F#` ę5;-έS"sWZػzncB!$([dчKqh+6g1F#`O?4,_bŋe˖Uֱnݺ䟲]6_Rw61ska֫ W <#`ERgΧ§Wʰ%4 A%)w& V U " щd5ٷ:&8tXMoS|LJP26͍؝EѰ#`0FYƒ>X??۷/WrÆ orH;C޽÷Քg?x GQ"|Iw}wzMm+W_ k֬ {w8묳 AiӦ{.sL8쳋afΜzWV}k%믿>Ůul%; ~3r%7FzLQCԸϮ f*7ocA\vFO>$ə%ܻSЭszc(@B؝X/84M]\uo#χ^sQ|1RS(8(d zXdiA"$ikAN|B#`0FAӦM- i6ɓ''r.j]MoƏ)Sx |; ]v 쓈!C?|aرaذaM"'?DNySNI|UܧF#{ 0wṕ}-] t:o-{W9zp=nTEJx2x46ΨS"߰×cx=c+džRgUi%Q ҡB 3":Zz4Fi.܃-7[0F#`@>}N;mcҥ)fkСCKB֭z뭉<>|x`D2OٺNv1c^{N?&eɒ%iI#`@S8OSj#no-buXA E+U.sv"$~ {Iqh"A]87W\JF`#ƫRpȩGh08:Nws?<ɺ0F#`7vm)so޼y-d =:~loBϞ=矟÷~;k.s9Wrq:th8?Vwy'Qzb .L[bNF#s7Ν 80ya=LTNHDS>m_jU"!R}饗lRƱQw%#`ؽxywW :ߚIS^]:;ƿVxvݸ~F}2R<(/ !I'$^ . ?I[/'[>J ,i$/d:C[8{1zh+e|\|#`0Fl8'Qƍu |~ZbEx'u' a1ۗ;w )yPl>dǶh;sek~!4iR" q /:Ă,'y @A$"{ox뭷R{c3uᥗ^JdbN\Yw?̅6"WUʤ$rĉSOG㩿ˉ8s\뮻Bd`2W[ni="Hс3-ABZ"̅-漖dtBv""{[ٟƼ:uJgo2O<1]Lв#`v]}E4s^Y'Y6o2\p%/[/|[bUX~c6tXwun྽Y!}ƪ9oX?EY b%Pd"c (ztLXE]}G$.gjADv_}blvi0F#!@"~B.d!dA"BAp ŭ|h&[" R v@A421/5pG2 4'x"wzh"2!tQ 0?{n:N{^ s)"S}!7$3@bpΜ9idGXHA뮻.]?d_   yLodn80+I(k"{|oGծ"l dKz-s!.73NH`q>)#`vIf[x$uåa϶mHddOp ~vzP6gA8eȀpϠOFxο׉S F) >MvŎ:l:>GP_H\|Q]E!XC"0cp&İ8~l/|vmSIl)8)ӣcm1F#`EI=.[}rdⱕItJHI}FmTG'"D#%(ğBVYxq2e:%}"(m˥"~m%Ns!'Yh:בL=l{R\}&.N"CN"MY\[ H!%*!8B;'`7o^8 .HB i+_$ [saY4~ݭ:mBXX:X#`0Fl+8ߐwّlW0aBE:٥"RB zd')|@Wj٣G&ͥ2H $$c[4q!]"!lvN"zH;.!| YDH]HQdACx$9l' [nIJ>}SٙSLI/=4Ƿιc5۽͛0 r3f1cƄlH9!GAob ,FNlD̂7u.j\(-d#`u2{Mݹ\vґ؃/J8ø7M%8Uڶ .8/]x'Qv6%sO|ÿa 'cȖz)BP2Y"}AE":ƒOC)">u-uŢm1F#`\Ʌ wܑE$2'>?? u/<j|'qȑK3<.~bc: 箧^aÚ(rԻSzDzzIn\K`Q/7<بK\f ç55֌JRxB ur: xJXrjc~ɽ,m&`A੗Ğϕ嘩c y)4;w~4;tU5kN=|Dǿ+AG&0~9>c?ߪڒ!uL ɇ/ 5iGZҢk ݒy0#ֺ'Xrո՚_sVc8MLV>UW,\$ӵ L`Q7*ֻex+ou3Mpkݷ%߃S{BPN'ڛvQJ0ԇvJ2#݌(T>J(cS5Ĕ~X*GSOWE`-qPR)^m3gNDE~.ssj̟ ,/ߟ~V|ЎFxqڴiceqy#f&r s=qSMrv5֊寕OZB^elv[@|#][j}^諕~YYFr&`&`<=uFIg5Ew#)KGߤJzA cL"#uMN;))+UåqӖp 5 '|#nXI= @G" MkF7o U%Z^]k54Zb M ԙvNʩE~QNfxXUs10Dt yӒVZb&`&`&`&`&`@qU~y D` 'eFJH|X$x)i c?#3tI?vL˯*OF~ Q9W^xgbĂW ?uN<3tڏ|8sI?E~N;lgǒN8GŃ?8 W\''6xx'sqڡOll]vG?ƨoj8+~b|8\UO6czl|"]o-:{Ϙ>}F?.(>~t^~3~[%,.슘V|sx<՚,ךqBNSNV[gӷOzͷ9?^I#bOx~O_Æ c:2vn֦t J/t/zt7J>t27k,Zqhs_)j9^sѧIǣNb_39+$BZnү6u.DMW?ȇOmpXCo&`&P")} <(sxq?eko|'{8=ZC3I ^}uj^=ߟ<3=7z[_.8nijUVS flѯ_xvb̜5;<߷o|;Rwso|?9I[eUڗyЂ &׿}RONiNSOI?y>G㒀w]w8kܘ+~47m[=qV OBQަ͘0X+G'~'׊>NO4UN]K:7OQL6#1bM>00000X)J1v5JO)}u$(AQڈLE~.;I)1j7x|+Z&ͯ( @UXc' krǽ0>8$^}cGiӧ]ܗï~ le諾|ĺ1_8c?{'Oε箻A'ǟ_V'lXz{}::KGPOޟۭ}YuՁq~=A 炿#ƌ?7AW%o veCi֦(bgϞ9ne|7_E9iʝ{e~_ܧs?U\{Í !]73oNC~ogݡG~ufq \'ѿ0//hfhbЍ^G!f9O[:%FA:>dn\ pմFJ%BIuZ8m,c [\p1!F7$}$Wߏx'60''%!'Ĭ9sr̙_ңȲ!J[ChXֈYTP8bklOߒI+RV)_OӢ]7MpBt#E?q1I&?q\5?;(Aa3FH=?C]Kѣ{w_.{X38utIe2w(vK]>zrJ[~V1|v9wnF:ZeoX^wMqM7.ݺVV #XM޷OSǹ5;A⽔__^ݜIq+~G;H460000xGҹSz IǴΓjJ./ZiKSI44|2\?`n͏_>=ښ8Gr4f$H+(uc jgO\,\ÆiXٰXiu*fޡ9u-N{9sfo7`1lkZ[?q͸ ͳ3W>Kɧ.yɧCo8nߣ=v9L,~1#;-P K0000w$Ŕًb~;H8wt-_:Q~|ވW<1KSO45OE $WU^*'٩$Z"4SYⴠTB>"8?ccI?1N>U}wd3A%iY_iϛKiҕy~OY6mz-t.olΌ/$=+H'ysi٫S^25%8+&=g\rvS{8M?oCB*9[ҥKx_Qxe ո{x;qm~uiˎW]lG^tqz76믷^z' G,^"Zmzg5#ح.;zn\zUѫG+.X}~GSNJk~c1;M?C{ct4URZOkLLLL:*}Ǭc`q]zv8ٿ׻K,÷ QRXfS ]LqKcܴSYGUl4MP3 uT&-cQ PX$ xTr>X0Nc''F\q~שt;(`3vN ItupI~+ЏjvD">/s!i]g>?qFQ4 ^[:"K(c;m]cs4!3|'=RjwwC?#M6 Nʗ[oy1L)WǞy g9x,@[;=ߓ]ӻ#YMޞw^I[?ܐ`vCypZ*=S~wjrF/~9{ҡ<[0ײlz'_81Sך~0000xG>|@߼J417a_2 qaGxL#͍KzuG)re57֌$$W,C`@#E'A}đJբKXO8Đ#Ò06x9}{no~ssgQY &8$:k~&xY,{Dk^ ߿A^Jg;f#=X\?ӻDjwXV2wֽ[ڪ+cWZtOk;aY{Z=(8y=amʧ~㣿sQD}.'L,%<_i>hѢ̗d0000'߷Ӧ'.>z"3A11]g' 5NJz}jԕȡȃѦN>ǏFG%ч4<ʝ-Ma˞7<$VrJLIY_ Xu-V㢟Oǂ%*#~GbxA.; 77m&`nY1nW3]V4pq,cLgD|'% .Z$#'5c_-FÏlN?zT ]Y"_]ZsC,nMְk+w* #?clxWbY~Ks @;#е]ˤ!>ah\ty@IDAT|hb:C5եOI_iS'8:c]ʓխdɨGpc‹ctd~CQnF1¯x͍xͻda3L wNsR,.ۨ#b̧N<&CM#`|̷|=W\rA9AfNr 4Hpw>W^mۥv~x(gcZi t x/4140idԤQ'LicĐK:_,8J͡KYG.Y#&AnPH&Bk"-V>i,~8O!1r棎wPf,bux5bflѭX9[l2[h2S.o}'A}kD:f&`&`&`&`&`@qoig)IK!H'-NB4J яn'c5'|:+AiQHQjьI zLլRN*V9icm;(3;1vKSk;'ˎ*bLqlPLLLLLLL`G2t2ieەփn'MO"'k,Y(D%ZJ0 B$JbikN V%i31mR4f&`&`&`&`&`&`&`&`oi}vhzZ_i #FIDTdADI|D:\Kצ-P&b6h:X:Nn͊1űs tDxKWVT\[nnPՅHG&hHIQGBg)RmLZGk3h:yyqK^\UY\)o tdI3kw[Z[͊׵FԻb"uɯG (IAOX'>\榔_4r~L+5N_:0H,~o5W 1iiѪdK%xiiw7XϜ9)1Z{ֶ`100000Jek>8lMKCCXk\d$PO #u-@ݒ1N#uxƪ?U?4?yCy(|L4l&N kxt_,}΋e3 Nց8743.ihGS8@SS> ֢S5G_MkDI p-xrK]7KS5HX|`R,p^);)jYU7 Ƽěn>zɪϟ}9vݿ˒>{6:x[n#>tds]wF3tyI| Î;fob]'N'x@ƛ4G[ LLLLLL#Կ0Jo}hg)1ođKAOc.׼+S~'R'g]$HEb2h5q/.>ݨJyAE~nRiFLm&`+@soE&`̛`쎜/i@7nofnǜs[4qb/V[퍓y{5׌ /V_=OB$h*@b=zlSv_X$j2[noiKswt-H;zXdq\riZλ'O{Pxcz(g׮]cwD_r㓿AhL7~0|pxo1&PJ;b~}cz78/{[\uetȡ1tgc我c g6{(eӦMyƞ{ݺ0bQ:gCr,]=L3&>ṟY|䢋b|iw#vGoN)qGDru^֥ J"Px:Kz26oՈqc$4*OQk#5u.bqi\9r`ESڈ@Ief|Bc.-V}Ǩc+RKXk~/&`&vvI4ޢk}}W~l7^$b̂#=cٍ(ϧݎ=ТuOw=8-lٵQ*cr|?Q򵩯eݖœ7w^x{uy\700000xk ŇRijC6 Gbš)1\k %>J1ԫͯ<*ik)ı.8>>7ӧKS DQ|rGh3XsZ>=w?`@wG}߳ AϾ08嗛"\h츜5%1[ֽiVJ.v靗39N-&?sz CM2%k[Z_=^Ye&`x)aAJ_(RjSg~bKI?%kl6~Lq}1\{0h!C 00Ypas/??x{80w&^kA9jtf|qΜ9͖+DގF>}묻^yG1000000h@ >._lԿICĴtIG rҏΆUK_^]N%Q-cq,Hb chs\d|&㊋Xm-6`2 @qF_wm̟7/'ou1`3frk}ul׷oߘ1cFswnKy'Q=??HvvҫW$p;9TgE]nkƌHZ500000x\ɤW5jmԥnzJFJ$T2\U2&f}%}"PrPrHT,]79R;wa3IGM<=8z|%~l/TΝ:Gu=kN;({;Ǭr9rԨu=K6v: k4;6oE=ڻFdc'o]6m`G JWz(GCOS;UG_+j~X48ȏML+<|UM`ΒŒ\ 0>`KikMC 2đ8.0q0J!8y3]C7_4l&`m'sbb|yi_d#jSψЛ[mKGm&`?u~tUc#60000[{iSc`z"t63٬Ybt}M }G4:1\hk ӅFxL:~]PC}VeS5#ndDA&Bt,BF?~ri+Pƪ?Ui3rG1\f&`+LRrJk$W\ѱn"d54J'0vH?y&`&`&`&#j2h_ҿмGè/}MڛJWSX4?%c)gjF֬r!Y4yQT|e^X'NC?u#y3f300000000C@*ӊ;&5 *A0DFꚜ<'?>ǦDpRT-+E:1\1m? @$ MG7o U%Z^]k54Zb M ԙCJiB{*?ևGɺn^DbLQT)QJh,J|$ӏ_qcGk|u` tD%K⤣qoXKUkD䆈 *|~DEIHm\lTͦ>Jrs&7494f&`&`&`&`&`&`&`&`@I>6&WIKچך1EmGdGlĘ؇|\,_165xrG +5s_C[LLLLLLLL#,[jKP(aU"BDA & 0ÈMW?u. Z%P*M6<LLLLLLLL#( pVKk+P}57"P2Xba1` %*2DLDE8ɛpCqZ4/#{40000000h,'۵JC/'.W+ q\裮Pʏ)bpY4]eQV/t.MLLLLLLLLC(mC 6iu4š_ D@.$ȤFGbe!yy4}40000000hJ/l[bZ@,QBe] T$%TDBʃ؏ Y,1i qUO]F])-%NmwJLLLLLLLL:"*xп}Ik{QJKVkEIHhÑɸd@b&%RxLr,y1Jc MLLLLLLLL%aZKTQG+YК%ZJ0(XЖXI,mͩJW\8m1{ef30000000.En:t;4=E//b A"&X̥:% ƫO#&}\R4Df&`&`&`&`&`&`&`&! T<-]{QrouGCCkO 5AQ4$I";$b9DT͆O;$q(dbں4?'k~}3s޹W i6dfM7LLLLLLLBJDFK6 *hKkcֆ)7u{k+FĒ9.'dLq.bij䍚&"B|, 7IO7Lq!7~i~nV`R/(vLDl&`&`&`&`&`&`&`S,K_a.I:-]zL(-YcۻJ%ȈTG/#Ct3ihGS8']֢G?Ux|4-f@ )rUVHM ƍXͩb4(~Ŕ8 " yc1$;V{{Z]+X[rZoQëmkd!NV%\RïGT[/R"#18}9Ni-ċDKJ360000000x`;ed=qo&+# E ?&}x6JC#^9K榒Rs7?yeHçoQ,9bRML.ZML S ..%h3koq2ѱ tHn }ko ΟªnP9%R?FOF8pQIS)uL>ͥ>\=cK>rDf&`&`&`&`&`&`&`&! J֎%=66oʈqcݤ)OQk#57bqi\5;r`iJZ3 uI,"^(YQ+a-1Ibj6!Vm͑\~mx tK=ڻ&͏X˛ (#6*%fJ\,gӧ1Թ'Q2UCbÏxg b&`&`&`o (| jO/P^nj3s~Z6}ƌ={vz-\VW9 ԩEuGbvaԍs 4B`ٱ,X,X^#Pwf7boWyF#*N7of\61\9@ڌe؇ 8JHb1|i \bzV 7o^|?E|8߬_㦛noaSLxlqGmѿ"=?YiN;O;l_t-嘧~:cmNRλljnזI?<96lv]b„͆.^8fb^{?i  |~{qѕw<5>yq u>?s]9& 2KmXڣVOcuMc &$8# K"9iSS?5VU8b(~FWK0000M68>O7zQX?vzrѷO3zt{rnDƳs2p`Dnb68s?=9|bM7)?#v_w^圍VW}Ç k1gma{lLL#֌s3cHJZ&bhjvXQk4hĢI-=zqNzZ$W3#W1Y`}bRՙ`Թd-]7 ET5riuƴen3000hYc=K—#2;@]pqo~ɧԕ˥KǼ[ߴXλˏ&Hwroj|⡇9s;#n]ZtW\uu׿s>}Z[% YS\N:5 ;S'qvK><3tx;"N!}n\~sT4s`/M&;8/Q8o}~_{mZ9~YGwҸλW|ov_&6r8ce^<:Μw\y5f}RK00wGOϜϸM/]z?wDZ8>G'qݏ8/9ϙ"Ǝ¸dfɏoeȍ)MUՀ:"$r"D?93VOj+#3f&`&`&`&PAǿ|͟ G,]4&N_Ʒ,Sc}!{X}_Rdᦸ=/OxZ >v(㐃w,P r~`i>(lwkhr};-"=Ƽn?z^뭻n>~z= nwgj&O~%ǮƬگ:%''.rPyG/%1v5e-)Ï<'<0J;3j˼G {w>4ٚk vmn>}E^71qdC:0fΜ_7bM,|rB10xXtY?nRopɒ2sN[8Ͽ偸b!b Gǣ/6;18b́7_\~]!K%FoCϣdu U5C,(KTP8)c$4Rjwde.)OmZ~5ϙfŮ]}E<䓱V[eqܸS93nmؽYN?3~qcѢEi.iiq1#J=*=>5[o=Oˏ7 (4=_={ N?!v9xá;^j30xghq| ޵>C{)q^Y^{+/M#]o핷ǤfZ4<׻1$PJBֆIwT~t21imFE])j9^sѧđGj((IB\Ehc >բauɧ|䤏<1͏Onͷ>cl&`&`&`&`%{{|#Gy jN&F3fΈ5\#Lz|yViM6,PvWma0qb._1k~ד^z)8|~ڥ__fcnuyJIc,NpUx͞ȸ&]`83N޷GDH[nJEYz~I{-v?"`AyrT,+$6tΐFKAվO9_LLQ E/%R`1QLj'v Ob#mQG[ur)?m.jjG#Uk7<D*I6`>-gMf%#n@藿8?b{_fSLLLL@#NOcU/|yGu̯k´Ӳn]DG{hʝ Vx+p׷i…M߹ZjiWieʮQ xnGe|g?QޥʻO;{rKk_vU}ޮ5x00v3kOjzEHU >bݻJy)w:k6J@w&UIB/X"K }%M|8[S͑9I1Wq~L~/S[k~9ڋf&`&`&`&_o8RdvW[w<^ک;.#<̳geX>lX=c6̇?KrM6[c_ ;~/޽{KK.y!U~b0mɁ;)g&ĉQr)&6qIRWO M:%}QB+jrhuX~ƒ\x]75kZ3-1-R7FINP8'n61uC48l2Q @E1f:{}Î䐔y#;}?wr( yE_,z=fL|-v~sb/}1*Tm#JRCzbn~|Q'4ӱA:`ᝓ,\"yRb_ytw6X^{f>oa>}p_6νu7Wrѵ]SNͽܽ^x}}ZM00w%`/t1DJ F &>b藦F 4>r,\Pj^䪜?kXWlĈ+&Ux)Qg SLj_qaG]U" r( I ,^c]b&`9[zo?)]Sk1Wpw`-\0V]-Cs[szy#7,ӱ1m=lx,u6}Y{oY{S#c&`&XY.\ӎHDj3g~g)S-ﭸӦ}׺w握 :(x)]%$08Nmip~qѦO~46GNjY_6:juc֬2m憔ch,}1F\xe,6+/]ySwt;(`30007'LZy_R5V<׈VF6ӚX9rfm[̷"cyzߧb&`&`N=ӯWߝZh)Mz L%uX(Iin\ۨk>%]:>)N+ȯUV ҢC" % ToZ7q 'b&`&`&`&`&`&`&`&`@i7en֮Ri-.X+|JXh Zrsɢ`YsqQM%mO Ħ|60L`ђ1uQqL tYt.-h@P~eb&`&`&`&`&`&`i}oi}lJ,v,⹁zH"!H(P>JDHE힤6̯>rh=)FOIJkޥzv?l&`=Զ8mb5;oSoGO_l0l-5w%vL'U_8vm‭֯^tY+cۣn;MLLLLLL0ZZ42mDS8Ӆ:&M::[,8J͡KYG.Y#&AnPH&Bk"-V>i,~8O!1r棎zgvPZl5-ƬKLөnw>=>bQCGjL|mf ׻]ߋg&`&`&`&`&`&`oG%RIK!H'-NB4J яn'cF~4'|ʯuЮkMiI`JqM1%FJ.>.:K<1*S5/8   L;&e>Rk_PtK1|t&`&`&`&`&`&`&N'оd^lfBCfhǕTJd<%ɫE<%9J&!vD cZH&G`qRb}S1 >ZMI"wP6WX6=4v`Tc<~1N1wϸ't[86F~,Gy7sru'>zv׏Hﲜ?KSb)y6f̙\3{Xo'_ o}0f];LLLLLLL$t)rXjI$,oIǓ(-uIUN8ũŏ)GSW 5D\@IP O1@RGr>.sѦ,ΏMa60F`C_2#5SfΉe^G_j8tc1)=]p˃IcZ-ܧ/@$7f[VWw>b́7΂)ߒcyyj[ciZρ[oMoZgn J N3{{7Y#he2)WVg~#xu(NԢG$Eѐ)a7B,[Li _qrI&Ky(z^i&`v)Nrrlm6B!mTv">&Y\vQ$Bžri拱p1?04 #VX>5۳z}O@_9t*񾴆y \300000D`@n1e֢thwg4{{7Yҿ(ǤM[G[Z046L_0^1O9h+'u_(c#]Դ t$#X$oݡsw;2rXgȠѭkt%ʌ9ѫ{9oxΘ5 NfkqYi:_#mrꛐvLq?db&`&`&`&`& =f-X-%u zvt=ߕ%t6|c%.ćY :՛⥥W'|1Źɧq̡PK#)%cay'.G|a\VG^j6r'fFy(QEhLxdy`Z=ع;[҉߼k#\{xt5wwF5](oխ?1۲ Mјr98#8 m&`&`&`&`&`&9g@njokzF C("#VֿR|+ MhcjSLJIӇ]֢G?Usn|?pR '.&*.T~|JA,u,qO#AnHci>0@${!o{Xc`&rS8Q~c/.%/~~{wnqŸlo9NWH齓슜6g^:rU&My y,Տr+&`&`&`&`&`&`&fԿ0J|0EC;NI#\O zug楤X?1O?9Z#&+K&GɅɇ_7xcC~A㸉b~~.MfoO;,/뱼qG$mDCiۗm>{?cti=L,>?xl1,Z{X6btxGˏ=Menݒx96$Z?ͻn"MLLLLLV@)H  6$mfZըkjhivuƒG9i_9)Vy/ffΊTԢHO (QY,e1 ʧ0Fu=.ya>qtDfA੗!}c7dj8aClj%KOID֌L OcX}u##R78ꚟ:1M-5xG׫7kWyNn"s5:LLLLLLLH$Z@IDAT740:ij(A(6Xiqfa`.5%BW45RX c(O. ƧS Dʉ1Y%mNc*W;6fE!R&/ +?sITz"nD7yOl&`&`&`&`&`&`&`&`&qt #o _t#J]) 2$2R#^>u+!8V^H%vORg>.[p tHڴ&DTJm0uW-1J@$r 3:%s~1.#b&`&`&`&`&`&`&`&`@-=z{*?ևGɺn^DbLQTK"&XH>R,jR8ͭR˖ᶙ @#P(_鮤)N:7oϚZ#%7DnPI+#*NB"hSbvOj6Q|6y 駡6000000008J1v5J O^6D֌ $.j;"gHb}ē8b-^%l&`&`&`&`&`&`&`&`@HdڭVWKXEwg!" b5^Ft/DIJcMmvi\1F)7mpE1)J'i kŐiKTP*,XbSKLLLLLLLL(Dk[ZJ5jR@[ LqR)"҇Ȩ~J|Gx12_Q$Nsj#RqSeAL`&`&`&`&`&`&`&J;(}I·f# 45d8N,}))NDƠi~'S\m굌 HLX,+_LCr#w1}/1~̯zg2Ѱ ; P0xgq_ߝ ]ˤ!>JcLH]M!'4TcKRO492rbXVLR+d\J6%cH0ԍC?&W"S|GcZK00000000Fv%E%nŮANbuI$<C)G]%eԕXVAILLLLLLLL:GY~ +ͤ[(jm+ JT$qd\2 }C)%Ni3^0SK*'mX{e050000000xtrXUt;izv_jV7f"9u %& "$1%VJ՜AL|Ņfc(<(4f&`&`&`&`&`&`&`&`ҥaڭVn'"^ "! HDS̥:% ƫO#&}\R4Df&`&`&`&`&`&`&`&! T<-]{QrouGCCkANnT"!;D!%EqZmǡdnJ]2ɍ8ܹ-4600000000hJ/D+fJOYt@Q%fb|,:"Z%>Kb580riښR~g300000000G$PM;;պFpF2D@%e,U䴻љ aQ%iae/ŞLLLLLLLLLc( ڥ"bkEV 1ܼĢ(BJ, Qca,V8|k2¥rRi~? F @& =]okQCzE/u^vV Rm"K&Tq3^cS5υxrvE%95m&`&`&`&`&`&`&`&`&qɑVcLjiC44╃7in*y)57~c&Fm^Պ$'1\L)uc U-B&& iQi m-hqo`-N&J600000000I PԿҍqSXucѭ" DJ` (h'qM|rOyci.8Is@"@ s4 g0`ߝǝ>mlc9|d1`rd@A $sJwޡwvfvFZIڪvv#V%[;շ JPqqGpGpGpGhqdJFNK\7qe䳗x7rm1-:.j#B삢 BADB3KR { J$!B.S|Dl#͒pGpGpGpGhQdvÉ!c\ȿ)^PJ!( *$RQ(:EJbO./_JD#ul#LFJ%[#8#8#8#8- dgpob_)!El:dtQ?ccG:#uHJ[5FPe3>.#8#8#8#8@A`ݺ&PXo(zQ1lJVd >bl#B6ĢLi||O>P"HP K񃹋#8#8#8#8#r*"nMxJȑ ¢ [$EdDXj` i#$P 0E!{%N)CDlotz6qqGpGpGp ڊ5kǶ<.!б][ѥuXx|S -YΖ:蜖-ҳs;֩&P<$'TgD%م~ï:ׂAD鈏M=_w~vydD2}IjL5It*y%,_-MU3)EDjv@_Z'mƊ 8@utUWY.c{:\GpGpGRX6/?ogЮm) 9+lx[xí[\ku[YjARZTMiT@~׆y\*lZM %Ό6,L]PE&\SL"qıQ'xPc١CGl|_`WpjJI$ .`4!K'JlÆ2S:t Fo. VǦnCCS&&f6mycX2/Y4o+GpGpG1WA=;Zmm ']^[V>n,w&IpüZdxW:Vk*Dmꐐz.*g̴NHC?Ų2|7f8㇋ؒ@B6%`$sc__U{o͉bcO=ߕ#8#8#a+>d+7EVN4.iPO/l nZqx@ۀbATuH]Gǚdt%qeM?q!,UdžK~)g8eVE4%8#8#8@"XXP3-6 B#գۚCRQr& 2"i@ ̂bTE?~Ե;HI\cG?vuDK_1pD3'KY9=>8#8#8@"$=3GHJr?x?<&?KC " e#RQD!tP|D4Rjudn,BdO]O$rC?z#3[~m!B#T<gc=U4;?d}Qo}z7[L_{6;봓3>5ڮ_w3ZMM֯o6t]3s#8#8#8#P A) ދ>L:B5d1M|1isg Olt5}蔇ۙQ;K_aGr\ĨsA$QxiL)i|,T*<{ceS|1oVգ{pk߿;[>s}eoOy׎s\~?9{b's]t13/h=w#8@Qؑc/sqGpG5 !(ŏUr"?Թja)e%E4PyZ=)E&јN@l<ť66\49APqq*u8t+s\{v8[SS*quڵks.[n=6wlW]'Lz>;N=F\8#PH?߼2mٴ} `?m4-GpGp*dgVOm8381: _Gr0B3kˆ1dN1[3V:bc"ǂjjP A>sa#4a=6f M8@1H[.4V\!w/GpJDW󓓹غ8#8#-#!(+ !q/J=:HCBb@(=6+$!1dYBM`lWP\ F}U^^[hmfr&С[׮ZS[k^tgN^߰.z|2z8#P|vapG`DUHY 㲵`'_B~/'/ M  %}$ĄPBǟRzƞ6"=qij|'hϛT&];ںψ7wzf [jΞc/=)^K>cuN?xe۪]fuԩA-ěcߴ+V[@cGo{vkSjygx-_   7le )F85U=6XU6ʆRʻ(y%E@DTN E!L =>ƤRN*5V}s 8@8?>PxMSO8YF O;_g} [?sq@N!pӻW$EP I5}#['艉`wOŦ3(Dѹ8@!б}ޖIRJM|?=y=hv׾j'ֹs';봓?;t`J#b˗os8-oLW:#5X|eőZXA);9#-"߉}zmh+/ֵs~P9Co5O:>kJ-)wG;Cg'|N=$ gi1$2uV﫧azw/-YA '&> K‡c82)l׆NFɅ 5mb8utU"RQSH?B;O)"R #!q4IPPu?M^vSNN\ G`@az[UEҥCᳩrZG ‡uk deVSO<θVZe]:wn [$.>.(|#8[=evm966-Gpwbׯ~_7"(_{sb fLwkMf2jϝaqifw ~>-Os Y2%']dkFpgˎwgŃKh]:֯!cǽeNW4W QI"IMI<WPvG+\D?Tce8@"?Ct+)JfAjʼ~V?Mk.]/ ?s7KXrn8#8#vmªy`3$07T[ۉ,R 4RFÇ~aC?QXLL6B/{JbK^kn#vq̀{v $e;[Vu<pdsl ǐ?wm{L/8L;(l9a#8@ʪpXvlV,/;|]GpNT,YԶ `*ݺv%K-Rs_`>H8~p±wm,hirNaK7s/k];u^rRlɪڶ}wb?hs;OδvK-?s^~?۾/GEHXyCcKmHߞ- -/'eqdZ8(;>b=z7tQG!x4x8X(e#?ʔoPG\;baOԋJ9I2t/z);&٘b`ƣz[VP:I@@r@O?'9\.#T2ݻw5UN)KP8#,={G.9X26{--< ?8,N^E,\Ϭ}x 9gҩmxg0z4y[ޜv+>ݺ5qyKW~#f[:{iwx +ruZ[ƮG~uX|Pg7D"4GJr94SNB|4&t+E` NJj@Q”K?w^m /6brIW|c2Tc;9 .#8#$dցe+Z+fЯMy˪a72XnN6t-cx8O^bfOZG5džU{l?Ȯ~!#zM{p֡]WPBR5|lp6'< .Ա@8hfʥ}%>x61 ;JW!(Ţğ7aO BH,lp2Jie 9(`:bW3wK/JWP#8#8)|КPvOVO{7^Gpm{i&m_{#>ś/{ztn;VWoy5{ w6} <І_5v腋Ygen$Kt >q5ѫwq+/NsmgU󖮴lawNI/ˎ= N옽v{4օBRKmepe" )SM:"%mql䫘W$8-zD1[~|SeEҦ%GIB$HOĤ&F[CO'F,Îj J`pqGpGe 6Ga1 _qqGض\VLɱ/7TϞ٧bGuۥoOhf̴K/<_6'b ,~wK\1ٱcG۷͛ jjkmUau~^P{y[rV:^xgz s;؂e[QPg%e*! ӻڶCw㹓_l{w]c՚.u "zʿ_zlĩ/~O%vm.q"4#.=uDG HJ!(H M_6"'*M=d5hRlM|B57 #8#8@A6ط睦VRrɖz#/o6i{vׇU}~COIJsѹg. k%+;_xXۻaj_?\x>RVO>x {r{~=59; klIv w1wVSӑ\ί)OO|ߞ +,!%e氵̳UQW%M$$m$׎6qy=5Q?L4QS@J@$&`i"YAeRCSOB[>ێ=OXpq2gܭ|GpG ~7lg,Xm#4\.ls@<\L=1_]rj_EpqGpG!9ma{#8[.]6:N<Ɩ*<+t[7V l6cm~ EdDW×?Q'M`Ӌ=xAE3%S)(JA)0wN:P&BS1H&*34b/_ll(89)$tGpGpGpGhœ*3>M9ЗR ']".#ҡWB!!V,⋸Ɉ'PvN Kb<J Kv8hpGpGpGpG ĵQQzDD šaſsS6J=q%؈C'7*K!(5&$ɓz;u5~)f3>u.嬜]I8i,k%J$',Ybs̱k"7dZlLpGpGpGpG`@ !(ţ1+qaÑц-,TculR=q/'[>RaeNCɉ$TJjc hI*Ncď8" Sg,tҷ3(׬Yc3gδvYK:KZM 睺8#8#8#8#ݷ]_|"M|x)׆j,\KB3I zM%IN>$Bc)Y`RXfk|ر5,_<jnJ{:ʭwkVZe=zhM:#8#8#8@",> NmqnʹP>d+.L6=c/Jt# _qTbWP)a&&N\$Pa~l22MJR3mxUoVt"4cu `9rs[>#:thI+SI0=cӧO.Ȇ :#8#8# ʯJ\`h=$E)^LJ681R _OPJ7T'V\cs'ДD6M@Q+yT1}muҾ}{;C5|xwmO<6mVV|>[!t]GpGpGp6>;9q5WU6nh抺u0՚UAčU:&/RBRNbC1Q,"%)bRI/(|R'6VXygb m.Ն^Y`A3sG#8#8#8@mj[v|\zZGsq;k&̇y&Eā:gL_ĔC ؏)G-#V\jŎKB.ߗ˯AY+J4aWIpJ>D&1d)`eK?kLA?mM#ν<ꨣ{W^y%KPBX £>u7nM0V\imV[[kwvFۇ~8nR~7!b'O7^{ۃ>ۃ N;-t?О~i7o;1bNb3ƦMfN>d2d;^{,+G/~7W_ r)q{!'|ҦLbK.zjCivc9:ud^Y8#8#8@+A{*xi !.!dZ뱉D\.mŚ:pҸK@z tT*g\!9)ߥG ŅQ v6%~-/=`c/tGԦ "[|R+JץI}Au4It*yljijq}i]`aK]cv VoD_z%{g";om+/h>l$3GɻI&Yuuup8/Gyvu,);ı Nj?s91hCL|d;:yt1ƂCI+2١C=2k֬8>gn'.>;N 9A.Z. #8#8#"uWZƺvlk׈h Xz' #;jٸYmVnZ- _,o_UnpW$86 ᱒Rv4[k 8%3$v$08`JDHGO,ƕk zW=#(u|4>Ky\" (vjzd0[yP &Y)I ixƇϰesDx M)N;gW\^wuqE"!p D$@ }ַo߸2?[>@wyTcS]yc'&G@Ϟ= d%#$+YeHVfBE+#Y¶n9<'ƍgٚ]vY6r|P4,9!;fT3*#8#8#8#8dȾcTsÿA6Rֆ~.2o6po8<|q/~&E^a8 t$H>(EDjBA{.DI(3bNۉC_Ȳej+V1bU~=\Ts"OP<O,7|ij_~h?zhk׮6qD{j 9!)y ‰Prx<'wO/a.SA7<(|>i?u϶qGpGpGpGh.2/npmx7J'{|6xP&Ƣ_D<uŦWJ!( F@ 4zt+^tGLCS{bk#ސe劈r Ǝ]8Q8cB;=ziHy qk{ ;c<`C x<ӍU<b-d&灈I޷rK$!A9[SC>4oHs=}ј}N)Z^wGpGpGpF aPpg)gGLOw Nd#mĿQGG[u>˿elhmc)Dd'ItIdM? >W<1(E_:&BЍ/U!?nJCV)vxt\ocx X ԩS.~Q1}PLh՛\Jȏ-CJy;4=-4f >LD%\YC4y8DRJ'?tgDz| '=6G{J@(>t#G)쏂S*I J !hKf iD!$(W©=`(.1cK8~mjTV"'I|/OsK+Ur@~Ikc|q\8#8#8#8@d.#G[+J5l8߰~ =*K[^B[;1kT2h)BpDPGIQ2$ɥ4D"F#&}q/Q[k|1g & ]GpGpGpGpZ %N K$e.-iSI84t\?=ݛ%ck|xAXJ!( (2 (JMPF;^kB"4&e񉃟|=Kb+deoos̉g!|KRV GpGpGpGp* %| )щ{qH6SÆ~qjh=6W%rl }yQJ$L'FC6/z|#I?'|UjV(8[Tx4O>l %wGpGpGpG`B 9K۹ŏijM~ſ`mp)J|%Rz (h:rXćdhC*@#I&T;|d^+.uDߊMUAIR8OGpGpGpGpdVP‰0 ;JcG%6{b~tS:kltyI [.%MW I:" zTf'ɣQ\3HpGpGpGpG02+(syd"L EQM B))bQ"-+%$SR+Iv;uS+{dl8#8#8#8#ڵZl-ǢĔagŤRDHB)2NF'E4~ЏheAJJCD%qe#8#8#8#8#b ͿRގ|9DWPd\ QHBڊ.GIJuSPWlJlS_V[_A ,.#8#8#8#8-<[_a^fڶ83x9JyZXRPG[۹PH,ߤD`J?mffm#[ŤP4R08#8#8#8#t<2&QQ[NurL/84%"8u" ĆNR@IDATJS1%Y]8m,{|_APsqGpGpGpGh[bQA" '% N Lt"!)IRPL[%:l5~:1b)v7T?%fO3xlGpGpGpGp։@-b,ś'^~x4@n*ChssCd84.%z-Ǐin7RJ9 M M_6"'*p\B>%(&@[bChj|Am.*… mҥzuzi}u2pGpGpGpG Yx'~ +r_RHUp׆?".bSGF~li iJ) L$!1+``B퐩ポl𥮉PW_fǡN,K|1\mhV,^̙WMbW^ֱ#)؊+? wޕg8#8[ڢ`#8#8@Ly6mѡC=npeؠg ve/.MO#OcⓎ/"4V}o/U4E#1tL:}ZN΅m|=4>0C(J[E9o޼H>BL6 (s֬YvZ0`@Zy}PLpGhܭuNg8#8B`ʜ[h_$#B t"ġɏ66uqqſяO zGѩ?T?:l J)'.J8([b`K[N'$B&$_t89T).]í}{_X5٭[71cFsεG}4fׯq;˗/UV+ OpGpGpGp@ 'GC(iÿQDĿaG,٣> ]׸c+Q|džRhbF"0I^>M>MT&B\b`$5NYWS9smݬLI~;M!%MŠ{'n? ,==䓕8#8#8#8C yH\ZEo!xr ^afRձG4~)fCHP+G&u%c}fln}8lInӧGSNF"2bs(MRzϞ=X8f;bHJ"lcR9?8sStypGpGpGpV@BPGqaÑb?fA94JD>ԱWKC'bҖ/rU`\ڍDaDD*\eʔ.Lƞ6D4Q4Jl#2'I߶ΠiݬLiF2O>6tH N6->ٛ`-@\2:l'FM[o5?h:uT{'cQFٟ=~v2V5\FT?O%g%|ablH5k7lGuT\-k:9/[E$#8#8#8#8 d|4P?&M6G?"MD\vĿaK~戁e&Q i" { J$!B.S|Dl#͒v [!8S2=rH2dHTC$ʆmک'F)ۼ d;w{nF7n\Ue˖jCB~R"o=S6bĈxf/hy6qD߿&L/cA|;֪!i!їh([/ jǏo|#8#8#8#fY2"R<*!X G? 0᫘3.;-4~FO M A4!l:ߋI? (9Jՙ-}Q6ulRx]^)$YRP0裎&ND"ulji*6&8""8.houٳg<3!}zvm(W|_hΛ| B3gΌ+\>?!6/^{5+χ`HM|Mc3@IҜuItv+mv}''gr*(>"_`pۼꪫ.sV]we]vYҒ/GDPnk5Ľqn3?3<lNA ZjI|' ov#8#8#dtg& Kxv^q)_&8بK\F+eN5a|M^Qry;3tI! 0$HBB?z1_+SAAQLå)+FXY`3gN$rO CV'KutP\!9yd>}z$ !':hvG\^x\ZɓYy/XW<3ќ'tRC1 *ƧėKܜ|rW;RJa`.6 0IA/1TSuDb:u6+jA8l/XHIQF5J2[VO8SO6AJEVSTjq+!I~}%ļM.󅳽s6s6+kY.]Gшtg*y䑸]w;,vq8+%[yelH*j1K/lO?3#1ϱ <Ԫ՝ n#bˋpb%5l*oOd<8x F/׈orwމEGmo ?x\-Vq[͕SFy!7twqnvy [Y;3P|q…c`ȑo|ùBVl]W?qN?t{7zhv5JgËf"b±=<6,:_F!v[[c/85wL穧sqGpGpZ:*ߤOKbpj"SMq~Mmq1Cx"Ob1Xܔ((\KO}ƀ8xJ ‚yIA7~0m. ?|A4Z-9s 7q.9]!r!@> g>hX 52 3,}`(#&[]w3&9Б AM4ɾ//ώgCBA$cU+ R 'NJ'~DT1k֬SDR)78A@38#"?x${LAvuc34hp|WlAJغ SkDL+e;_:Ao &+{m땾F aXD7:<묳b?)]t~!O9/H=sWG"Hs{<Ë Vr){R)ߴ .p@\iE7}ʗk^{[1 !7,Lɓyt9ypGpGhgěrbgpmO`UGƗN6Gl.;?T 4T f`D$ǏV^b(蹰ǎ~M:~ե/e~@@h$@.Fm)EsΑ;FH7՟zh x( +]'@@`he>[t/ZXy"b !X ĪRDa!ROS$~F2:qrc mo$BT ΐb#_C)D"b͊<ڔד8nauuu=zt$8"#0$V11yo A}@ʱڑ{&pƑG_#xOHeƇ,f!DƗ;r胐#*!E!X!Gܧt‹@Mv|'O9y⒰5kC0+?"Dz8#8#0Jr?x?)I&}2%g ٰ " R\/7F xoBc<6 .}9ѐI Sa)(*a[aJŜ'CTJR2+m"ؐ"y yXƼιN) 'Z awcGpGpG`[B /x/pmuJj(qoS/\Ŀk,)ى]F@J!( @ F@ v$c\SYŇVV~ |҇Jr*H'βLyG9g ödGVeVb!AHAxHAT&.twjWNUcd1by|BR,oņ,J BR=+a[cB1rL:SPLVQ>s>u/UwF#k.a` +v1XY .#FH$νlЙќ_ /GpGpZRD85wrvmqgD6Ҧ1š:evmDV Gļ2pؠ"qJtmhFIcmzf1hs~c> s99\zֈ[oycV‚Ӱ ґ?H\a{.8HxX 4Jb.[/9pKH X=ُ|93 Ӽ F/bt!DĂ=$t άŗ~0kƹۡ9 Tab/8RaE-λV6@Af1XjK;1[Sa%*Gx*8 _:k9+NW=fų9۳>GpGpGBVߊ/>Ps[6L(P!TIYQE:"[ )Y)R }bC [ro 8-/HV{Ap;K)rD9.A-TJUWW1cw_$jKC}#qjz+mMu'o]w]\ RTI!}yO?g;8-^4!xBq@^kud_ʸĭaSdO~ ?J.ӏD-SҧCҘ‹>x18pg)C-:e)MR*ƤM]i#SP )0e!&=KN{ڈ$i{zqqrqmHBDa7EtpG}TtEf19|TޅbZ{sSMYwSJ릎!M+} wUluN*'#fS#P~=3ڻJ߾8kG5!S䡗ؑϲvm Y&gn#~'+׍G"vݡ'Y LQ)ֶUŤR~tްAd/n.&Օ(cчh|/j* &DDe:P4 ¤I}k I?'|Uj&8|qYg9)>G"p۴ؽ~]Vk8e#5h.?uX{}(뎀#$|Q;]0o?&d5%$1MSmvÿ/Q7Ŧ-.aCDUJ/>}AIX tȆ@ N[ڐJ*eoIX*lCOm^}5>[=]GTx:#65;#P<>Os< Gh1Ok=d{qD hҊȬX-&.#&EMlR2Q$e>XUزG=HX4kGy8#8@%"va%99@ A9C6XBfi:@%"C2+(sy}.DR!p!*E j$m3:SB2%,CKQ.?Yº8#8#8#8#8-Ypb˿ %SN%`"R!$!HB)'6DcR++[bd4|? VqtGpGpGpGpZ  + `؊sQYo5tb K$!mAD$ tJK+6%/vjGpGpGpGp@-ލ0/x3qm[_P-,)XJ HE G7)R( PGI)y|_AYtZ#+Wu(˖-+i3zL6V^,#˭5}qZbzGpGpG!PU鰊o.Bx;p)].RD m֘LtiÇ[р#doў|mw}weʔ)f̘Y)|޼yȴ{Ν[ȥdo_u]o͚56k֬ޚ>1wGpGpGh$ o* QgfqYX K D"XSĢ'Qѡ:\ 8>mv&f{߿Y2d;muBǍg}QM?va4h&Ś9sꫛÝGpGpG( %LSnU XT4FN& "ҐDq(RmLbCG۶-g 8 C&b ӭ[7;ꨣSNmb7Vdp[dA6l<#78{Z}tUmK6mc>:o_0nCP;G`@M&|>oimMo@g 9LYo!\OU<_\H bPg`E񡍐 :#ч!\N6RD/T1>\cRtqJD`ԩ6vX[jA}6lذM7dwKqkn߾}SNvr| Hȇzgtq^{n?x\pB0aBCA\ |ƹ;qֱ#o!fӧq>o1O?x㍨g硇jOŲ&OAnܺt钍;YFN8̙cO?tqm=\Gy>ck߾q`4`޽z2"d[2c%!81gEٝwi]vY$) a3<3D;/lAuݻo{w̤P,:6OS|}K;F?y#9T+O NO̷/ysy_^{֮]k~{7wmKwK!̊@Z+vp+ĉ=P^Ϟ={@Xɼ:묬כߏ=3Zw{-+#P6_WvĠ3MX<֮}FT[;{b{pv_>h[T3/5*U]>b/{~89MztC_va*0g{o?Ot@CN\eu=h)k_NMkٺ7_[~׵Wz\tZ*2ķʼnC#2bі4R>x\ONQifzj$S_xᅈUW]ea#F6 6f̘HpMy?K4#<5^sϳSs`8DnSl](c'kE?6,66 ;؂YP@V[@#W;JJ2ĆS0%%C3K"N}$Bb$1,~D< ,qe[\X-Gb}ѣG$5!X=:"3Y |LjKH'V.2tbE!+/Y #)4>H>S  jLVodcN;, O}*ބJ,掐7BlV=pVjiL~@\b"elr'$3A$ ` N;ϑ#GՐ)~rc>0_ʽC V@"`HlkʗU`-'"׉q&~bT W>'Hafr5.W Tݟk#'e}䜻xp E`+OGn?V#vcޛh=V3)|Yf1#wKtzHH".26ӏ&G|C5;B6|rqDB(}B5Z޽& A$B-ېX+$%+.!v/2 ) r%? a>*C@R"3]FޙOxkWg}6n%f8D+U1!&|L~cg*R4r4~Ṡ "Y4N9\̚Aٶa-ɳsc5#lVՎ<G>vmۍ?go.k|Ev^WwΫY6 gq7N6nڮ\gn;殙ǟc{إ;^k;֬[ewM=UmbζVoslZ޾]m>Y{lv_ XZ{p*[>cݘS/ZUἯڿaulӥu8Jkfս}4D G";w0o?ʬf{'IM˭jԾa?fս}<|/wqZό()ŏC^1'B3˵GW ml"'Rs{>ikMRAI\g z ?3_:B^:Id 1;9Ppq*VƱk#.{j6ȫ@R2"C3sɤ0\IV60_F{q xAJ1)N1_czIVr?NnTVFru)tCr%NBO2xby>בMݟܻrmG`[BOǁ>LfYֽ]/iX$&5Wڽ:S`9~]~h,KPBnץ^YP?;x?>O7s!r1OlqGmk_o_|qLX 6 1A+G,}欞80&/{^P AϷk}lU]ÿm¿#$rA?ʺͨ#PԬ3ߵ@@0$|rhm 0lnV5p[§u@{^k3|/3,_YU9W~:%ESY|R*uҿlgտ?|۰ze< 7 G !(ţ0JhCHJC3t4WKKIOQLSSlO J97P4"t%t0NRhkrcB)9)[GE[cQqO ϊ%G\>;iҤx!YB]8]eX,{L<a;8>A.yqee9ÊGmIg0Km%fey@ 8ACX% L>DV]Fp'>V%-oI_wui$o4u֥]s5s4uŻ9B0/⳨&T˳?y0ԭY[?}?s"TK?=}~I\=@%yl~ɪ*j'*28ymFdžVsvڋW^VM5o}wMqǪyץcNV: whguS澇o9v nƅv܂Sl]Slm~/\ouir=vҗmkC@`n!PpIJx|v[|JckG`m=vekp,X`]TL~uZ'=8 v]\@` _W28184Ŀa'[ÛɆ1qk %: }/tUҕƗU$" $0 %8lEt"n;΢[n}WlSꪫmu秹bb_\KK/MK@6ސΛ[$֭wݩ <ұSl TKmP7^>/髍Mꬆd˷mSgWϕ[LIm;(3d69Ajkih[ח4{w}ù~sJ\~컍gX'?oM ur=_=}wm}؉ߐ@ CLɱ훭ӊI7tlo7'=_נ/# GML{n6Mv N֣kw_8e%P צ6:$?éIscSzl_O?c4vY,uB@#$C`ARW? cGRDi+yi&z5%vlWݫOiC@E@Γ8D(4R)lX~T+¯RYW%vX)Wš&Cm_6 DoZĭ"Z?cc/_}pѩd62>bK[W i0ͽbOPʐ@ 84hǟn?9c[IK.[lt#q|خָ3_fq{vԯ?Br7wmF~sk]۬ܗ;3hcmH/1Iϯ 5!|Cēy5)e:YΡщ="N>MرIש-r{M)k!(<0DO”y`ӇPjBL~ÿ|P y-䮘j30$@ @ kܲ0٧,~~O{{c?o}ު ;夥)m;mvWi^/۹v;bU}?K/*ݫb946h+oݻ7s'MT@C̗xG #珌'25EˬeuXC|XɊuO֫ޟE9yZ,jɃWꖮHc?!3k4񕛃+k~y78P﷛o>54#PwGI?\|^MmJ=Z_z7l"JDm ^++II8&K`=c!AJTJ6L, _ر8l"" y 0VU!Ȏ ܷN\Ux0uE@)ɵ*?⋾wA9xZc~6$qOM=in!ŗq=Ο0/=\Śk/탋mگ}WM}~9s$/op"Ó^Ϙ,vNT6r=>WSluۗC #<uwϯJޙ" N ~/|B65 e<"6Ɖ`c[ڲ#7?~~$8s[!" D4 Џ_J{W `:Nuư)>eH @ @ 0`%lU?[gq47BT0yIOjJ@ 8LNN2ձ+܉˚Ijr¬ :C [yA2oC?P#_7/'R"9e7'vR AI"fCh$z^-SEQGD*1&O]>敖@ @ @੍O`_T?@ slKr/&r04;665q|9G=I].US_:caL"$ʄD(~iq;6şgz!KCcrw)y @ @ RܼOd@ @IDATlxRNLCIrjz6qn9?q\|d8=|T|NNDM`D.$guPгikb.}-'{-[mڴ)=O XMs$d.k]9f C=Tu|h@ @ @ p(#=PJ?x? KfW8%)Q%+Qg/<>ؔ6g. (@ [f{_\4v@P@ @ @ K}ц3)C7z(|(mSǗ/ta(rNI0(&iKH61HRdr g 6)/J|} C ;3ݪ|饗h'|r)]we{g?n;e/{Y޽{m۶Xwig|sK4sKt{{/L`%}1!T{=OS{`mmmهW_}uʝe /'yf[x#8"w~{z^VL\N:${ ^`Fo}[6?ύg/~O+0W.7;Bˋ:*<@ʕϷ38#a("|^WۢE?{yv' _+Do馄vwVڵ~_~/| >w뭷s[Ά?̛ϔ477qW³,/8 ZЮ9>H?,8u,u@)ǐLZp]pdhl/qke" sn *߰c6C<Sj,m㉏ISi%AkJ14 I))M2E?vJ;|҇H%j7zŧ J ] J_ ח'晄\7/vg<S&Y/NDٗ%c!뮻.f}|zK%=BG?Q"!'>IUĻ+c!!O8K\ x|3i|XMMa0?|%Kn͞ &믿>GuT">f,H٥Kyر# .L k^B7%D. 6׾6პ3yIG[."Ljp)M N?:JtU11DcVB/٧kA+_lks]ٷ}?;o}p=i=mxqA+X*@ʣ>:,-oo~]@ x !1cǐL,nvoQ@$C "Uφ-IբtH1X_m hm6ҹ/e-R?nmNZr5/ˏE]{m^k-]a%p@*ɪB;= rj>!.YqWI/3vqHo9F5>/zU"H~Y-֐rBxnl\~\CW9ݻw)ՔWSI NQ\Mȉ q!CI-yoE;47KW.ms}. Q񬗦m @ C@< ?u;6塶x;4.Q7!bG[<\ÆV)=GiLhK@P IЇcd4I/uD}M?^3(GzGͶ SvYkSu[[C` 챝{vZW.;Sveيe3@pEn9Db4qK7/bND8Y2T=<8[YYz "ŊX@ @ \JyCDVBdf`~y\zJ.gxL{1^M:lX}ʃ~bfS|VRdphĺ{G>RϰazѽТ#}kg"{m`F(Ք~#lٚV7`NRnU󬥩dVǠ@Dyg?C=p6ɲ:CPCnf#+[l->_/+!X<ϷO~=/Xa//g.(dt6k|L ʺhDdݺuM\sM:~۴!%ؐ˪U<+[?U`v9hجK^3-y:D,(+5%/k~t}K_VyqܲΜC@ @ @ x 82V!!؉0_S]|:6hA'$GK~Z^0Ic/ 6$G`|6L1'[eC)W}ү#4"V;IȠ=l_XP5-XdG\e M6/76.|եY޶v/[g3Z7nEL!}[zUOH" vrl,Li >T_%UViʭϬ8Ums |idA*+)|M1`+{5J?Æ6|c[V0UcJ.?dC=|z%H)`1˯WS])b{|x80fc>:;͉`ˉzioj6_1ޘ>۹u gN< gs}|c9ҁ"'baErd_%})ڕ9T#Jv|~իq,VhnCE6yhrx3aWM^A+@ @ @ <Lp[p]ɦ_}ڞp$x9LYUjYW:)T B)î jcoDc6(y`b2:6*K>ԏsm۶VX> v'-yގY efK Ms[s-j'{ =pX/xԆV~_|6W< oZ:qw XC.[bcc6Y|~+wxc~{Ֆcڒw|B~poKş9^ڑ?oM)p'sڬj_vd@ @ @ jGDžMH9.(sM:xűyHNj|Gd="V"+t'iH:S% A utLq"&0mM O _uj@%ȗ&@[|Ch*ȁg cvkgL4[sK56֭wvoh/m[H߀XN~ ]mۚKmVrߓ>@#nvmV< KyLD;oٔ$OƼ#f @ @ @  (sM|8qtдxO\6Y8mًK.F|)&cXE8*V`RT$d N[q NlW>Ň#$3^3o^n[ԓf'pv_[V-^f,=V?mw6ˎ~+waNkf[` |Uetiv:o{߷^pQp 5W2?y#Z W}{+;;;﷮-eH @ @ @ >Lb!ɿy^BȉCWUj!(a"sR;m!,ЉIk"q)g&CԲI|[x9 9o[bv [mVkmnS;~؈dMRNI\'!v+㤓Gτ&N<:~un2XNlU6:6d;w=Zln۹} 9gb lƍޞvZ[xck,K{mxl.Zd[ۚl|k^ң蠓Cvw{a[dk >ӨǷlwߛ=?ؼz>6غGVmwܮ3ϰ/\U{.{ϵd n쵗\d~_/?}&;'j_zwR{ǟ/W^R{?At>#[nM/zodϪÏ{?F%E'{v[+g\w-[3 7us|%|yEԿWۯ=b^c  j-cjժ[Y0 @ @ @ ' ̉"qaÑц-̫Nh ulD('z'm !1Flǻĥ=M4xZGHDeKaJ4 la6ڊO?*gP[GՓ;kNk;oNo Y_nٻFl~kbMkoWDV+:lz[4nqǺvWX W5(ŁA'E޿L>D >ȣWSvǝS?'R7쵏}tb`x5 ޝV?,En ]=/|)_'ҋ_iq}wcc~SNfeL}b1~i _]q>Q۱sW_]yx .'Bz{{` Yo=A[va@ @ @ AɰU<7JDefd[ Cc"aφF(IH(NxTn3N^Mknغ;ws&{mx3IϜ.'%lyva:mWgk[weKf:=&^]~趺^%wk%~xϩa|ЉY\Ӡ[n8xczKN<؏~r緻^mWL:Yx_VA i'o/g~rvW+ؗss=٧[쵯oG6>߯J{?t yg2/+1Z|MP`8͉D" )ma+S`e˧W68)Ӷx/]2vzmt|f#N[[m;wp[ pN[`-ۂG}[mknxtDmo`;W=S7|]V-\uN}gmveSn~'`[m˖,[EMt<'gEGc?_<LzF>b"^o[tXrex7mHڍ@D6g42An8^$soGF<!g!c#y_@ @ @ @`!Zx1x280jCukqWS]m _.L^Wb.+"vN(KhB0=Oؑ(cG|J^($yHJRz%u}sĊs8Z뜄w˸ mk:p~pj.@I@ΟQ^@ @ xP^Y$ rqcݒ~4v8ٻ:ozlAW`&!9#u&D@l MN?ISg,`2~/|#chYoۼl]v=rr 'wˎkVK}[~*k[vw=VnO}+~o졇v=i[m>dvq[l'\2[yv"|/i'YK۽7<ǏyN7f{|uW-=U~Gϧs11cbv_DN䶟%3*yV.ؾ!9NI9?mWv[յ"7ͼ  @ 8<gJ$1E @ \kquJ2bqk2F o$uHB6* $ЇP҇@,ҏ%zD>d-ufD~p~Ț#Os}wl|eۘ vc 陒cNP[gLrwsO]C600}`?oWP q~i53|K~'?ŗvߐ:xo?fxd>w~Ǭ?+9g>.xɋ]hT?ן9Y#WAe-oEgvH슷٢ 윳0щ<Ϩ=ϱm˅^K;!eͦ}l޼ٶW"rիk1@ 9Fkl @ @ 8p47nhCS&/wq1Ȧ:$P C--Ŀa#=ȧt6 xc[QjaDDM 0>@ϳ"eCPJTK c5;Oɦ=dC%d+C-zΟ:iM+bƭW9ɸG޻ҋI:r~Qkܻ6<ɺ}d wZ6*VKh[ +ކzک'+/<)Opq*O1~4|X}ɛwtWN8B|\ˬ,KVl>> 8s͟?n#)eG?1?Q a'jyaL"vDI:"iSQ?6J>W-c#!>R-~b?}~QWcB:OnIkl7 rWN[?N[XC2 >k'|ܘϟlU6$h3Nߏ-ەI,yvc~ZKtn_r8 eG$zHK Dw Ip@:ӊZGVSNuY]o؟i`}ψ??pomllfްFlVC>ck׮lI=_bB3'K@ @ @ i8I?sSC÷!dK[ƪO1ѫі+6DNIrJ"&UhMzp(1.{_=x{pCs<%ShVfnԟ=9+"vnu^_o1^%on >I%$w*Q;fJ{ H;.0pj]xqqa3H @ @ @ <zf%ĭ&b ^ ’9ǭFŗN6Ă7>JbT &:DҤ`& hR>O;&0^.>h#H8E\C>Uޟ Ǜon&#v7qos2ߖ-v,prOek_Ė-O϶ymb lE8/t&@ @ @ (E.ƒvspfu85z~$}pid-u6qmG\y|5_>ܤ0h&QbҧM` ڲC'!>vQJ4i![W)%+VWlBO58IE}ed;i++ Z:&\"vڙnn[{WK/?p߰b@7z@H699DF@ @ @ F`yLjRWqĿ'ir*]/rM~7g'{㏶b{lo@8Ǯ;wG/PKRZ\gPvs(y{{l`dZoplo^t[ner5ko>S @ @ @ @  LU/kVʿm88oj96JQj ЧޥwU1YM4nG~>s`G|2~x Jm\ev~[%9~ );lph.wowBs==u6ۿy#e܆@ @ @ @ Sr%Ό6n'& g,"E0R")&m)[CGBP9V(*("$^0:=[|k^iovܱ\B)@ @ @ @B`ׂ )I7(Q"oaC=m5VDq5^CJ}%k!( )4I d,d"Br6">6l5>x~d`,.]bw/ _2oOr_}o]{mhx̖/_nǮ;Nie/}ۗs?Q@ @ @ @ JxYGx2/HBi*AϨzt˃_u-=:6{ڌU\VZJRS$+p$1 (&i^MB<' +`iooKVm@ @ @ @ @@ 0k%HJ+ OO&}Ӽ-ce'DM>dC_YLHϠ @ @ @ @ "+(orM|jjS(x\-B"j2CBarؠ1c8hcW.>~SBH @ @ @ D`b0@ :}l%`/CQ_JD<",?j业 kB* (8mMɩMVS?e:[NI`&QJRmJ%@(Iq,&,RE''1R"ixAN!t @ @ @ @ ,/%Pܙ7jGoA#r?fsQYXᯌ+tOQc)I.GƄC b#"`G]CDuj\>6Ӯ%@ @ @ @ @[/qifD8F1߼D[%MϘlÔґ @ @ @ @ 44$:&yo3ؐR dmFv?ƕ$h#uB R "+-"i31R'D>P|N2[!@ @ @ @ B`tT4X% L9l GGJɡ0h&a0N@fĽ*311>PRW>Wy|7Mc1ʇ2n@ @ @ @ 9@-1'tHοN[ȯV~U/)8#M"FF@MZSҟ`hmV7RRm|8W'a)>YFI;$@ @ @ @ 3‰…i/h똹l7bK)՗G'P6,"*[A1|kc z-!%0AI-'*l[hJAVq@\Rv{!@ @ @ @ Cs'-/&-ߘ/SSjr7JG]; y`'ie`ZggLBNKPI*%/ ;"y􌣍\(e#Dm)>m]wM#$@ @ @ @ Kك>Ϲ7RK obEC?B]8K"N[%/=ƳUD"Ͳ2Y%i> C_om:*Ossc|)~>/#c(7Ŕ)eC+>}fK8V|(}-)/???,1Oq_q#~x}pq3<>UE^v)>~t|Sj H@FƳ {}Ö6qKQ|'F>#Ks aB?"GͿ`]WNKӚrJ0 Dc jLa Җ'md ASɖ6"%qG˿|!`~ڲXa,(%t\vty|KˇG| /q/gg L(uԹ5οq__}'}Q;?'k!8SG\8󏎵Bw)G]6UIDԱ ܿ|^tzl=uA'[}ԱActAO=u6 zDctatɠL[blEeIE6$0Q{J%bH'(lL J)lu|6xcCW:5^$~E(ueKIwŧԏjEdž(bVќW?|# ߇GT K qo!? +kWL1tW*Gu|/GCNħIWCJ_W/\AOR>a |^-N`OEJ??_# }~MKrDUI([ȷ r$O JB[Sǖ{5˧ OvM ;jc>W%>W[CRqٰŎ.[W\i ml/[&?C?B|#r㻺h+A `߿87?:Wrvik:(liCQbCmO_R2_ʏ0WguQ|Tb#6}Z($TD(IDb""C=h9tlQry|cC IB:IDAT~i#A; <>zWNHG[ڔF<%;¢4/c㰗O(>:_~#~ߐx| xW q~8N87?+?;~t]W*\#q3g_[u_66JW)>ΫgB[K|q|3"_zῄr?CpP_n4L,QȖ :H.'U)L_ӧMS2./_^{ܰsTݫE,Cd/h):D6kg?a]P ˉ/e+_kt}>諭3O gt^Q)} Ǐ8pci\W\q]!;.\q_#uJUb*=hulJK=͛EYS~3~:ztJᛶ|H R*1(߫M tɎ}K>^M>(~>|2&!i䗱c-;NyTq9HGO1c[q/ LJ,M\7 |Jϯk߿ -C/~>C>vޟǧ8ԯ+wJ谫?a#hS"Sy0^~9-'IL0}_sV%"6+>laMzJ|`c#=%(svugvtWo~FEԦDcT/XMΏh!b(|0S?0B??/qo:l󉎝:E"?+?8OsG\Oϧ#ʿ3#8dOR|}&n2eu:Qj꣭<:~Ƒ6x~ƱOCaYY0IpHy"KPzAuIL㼚D %uPθ|aN咏rW|EK|2ΥFؔbŇv܏: O|1k%6N9DG_>"~w\}oǟ8'?_j' %h\EP\W\D73?O?tL-ث{Bdžz 1\/a,6xo6ӏBǡÆ2cT_繕t*C^ɱ`/sMR hҩǓjJ"RqC?mqcT-ұym6;(8!W~g+{="?}?W3)'a8w굛#w&?&q2yLoS q)|g;]aci\D |G;FIN :_~'=!_hM/Ϗԕ%BlG[zW@])gW?>4&x5( !`yI:O$&HՃOMR~m[:c<:B\|V F! /᜷闞:߿c(X;ǟ Nj8GA#3% l oNiӯk+HG_Wp8mlO"?ˏΫ) ɘgSןCsjSgCT?>ţy{5)k&x lJHB~DcؚER136O]zGW6򜼙˞vv_lPGc$ }g\ Z x~A)/SuUQelTVU|"6}BlӆvPg,BW-/'c˖|F\gbOğ\M}.?qq, 8N+S8ײ߸5q_]}#?'-{ʹD W((->q>t293FRßxJpθ1N>Kk9kg{S>4Fx7U3o("f4 JpBMA}F&3XڲM]mW?G1uU6_oldC<=1#>u>d:Kfq?o Se'q돸^r_;~_sV:G/8Kd/_'B}b+o+g$'6: [uJd,JP*q d}+>|E>9o&+{豗/UOvJEsԇbbK]}<r)e]bѧx 6"/W|]c+|wQ"?qO G,t_LA"lM\O~woqͱ3~$|7B>sk(}5?3Ƣ}~-}pby— ߲DGt)~s'{ufٺjO$*& uMP0]@OC5Ĥ>t=lzWS>g'XlN>1TgsquC j9D?%qot8G\+I8!g.xqK|u-Hcl5Jm^-cT|HE@dž^V'6 Ke!&vG>|/4xREܦlBQ$JIIQQ|"cQMİz-_A4ҖM#C(\4|G?e.C)f:"[N1[Dv>Jy|ٹ3jB+:gr߈o\W\'8oRz"~9]1w$~?j'|fK!nj?6j(wAN]]>~S>3F4}"!4/$Մ)51M(OY>r#xEvB>);ILe@Rjg_S=/K_*a?eоb]: a/?qO?8N=7ο\WеWu(#?8^ qןq_AK\C]و[i|qRsơCu6ybkW^\ceK,^OLRsU? T HSPIG̫E@B_E)O<[}PO$+رIGб3Pb69|Eyc;C"gv h(ؓ3P'{=J $S,#J6B Qlᘨi<;"-u˖2)}O,tʛRa&|Tb#a> g#3ڎcšӰ.uێq2[kUBHpJI(pN2,xX[ċ1c&.uo:8Hڇ,r^x'=n:i}8( lþrŹѶ Գ{=xFR?C¸wMX b>9?򏍅gNGPQUcw(k~K.ƾ%. _[s/mیoܷT:8~ tȿ?i71/AP7?&w [3'3Q`"Cր8ЖL=sȧu2} esc0vC?X%8Y3/ou/~[>%c J{>ʱ8'._QXS)v#TFGWΩ 9??.).&f@b$x'Ϸe!ny\89ҌS~?kpI}SnGwg mH7:|NpXKHHJ@oU&:?S:>i:IA}KyYt#K]U=t>ѩ}GÆ:gٵ+_sg\-J܄L~[_CWgyVĉWG~W])ީ),O_wiG:?s??oş.q1q!+QЃ<{Ngq@xq)cߜEz(!d}븹>xڙd/ }D݇ALrOK[c s_%$탯iY DcuH#b6%}@mPNt>|RwֵOGڇ)}e=p;1W) [{O:6`[QUEc,Hwv}߁'|xCƘɼA^)^s9:MJeԉ,r.%Я<@9?蟤.9㔥,}}oI# %q2x Y}<`}U,ԉsڧ5:#C?6c}Ҝ+?<3q9<(q;X >>{50&O%zt|hzǯ@c&.&6A t/ x0@SҶOЫ}J>Uv,ǹ~|W3<xP?k#?8dϹ{bRQUy~+o.? dΦ/s䣋q愔>m3ӧN R2y&8S19Ktb6uik5GN}QS tL-)DБ.ȶsP^2Ѿ:)|tyLԱsj:(bi 9q3VݏPqqs$U.oOG?g.}o@??ǦX?}x\?/HWc(~Ru3%G;$ߕ<%dwi ѫ=`;i`WIiӾYñ!N@1/;u[o3I,`bdw<(^0uǯAh`ti_]xibND?}c_1RukI:s ukOYl վ>o>ۑSۯ]>c}'?W|cz?}.eǗ=oO2G8{RǾxJ:1?tޔA=z7w+Yb\\i .:Y>tЦ_/qI[uRc~>|V&Gݗq|~zԵO]̇:եS"Xb_U>~z9'>_yZ2Wοi_o??tisž_蠟:lΏ~│k .bⓘ}O=QbN/( (x,$l]0tyXE:S>,%rcUyQǗ1ӯ};6%c ק}N Y/,qkUź[;r}d_c쇿~?Ο3-(<<soo@Xgg|k&|C9w~u}w~8Ac<$39vhsO&)g.CJ fLI#Y\KLdUR}ʨ#S~RW|mЦN9f,AGt uj6X+g X!u 87(;şo;b~q_Mgbwll3κȩ9߶oW`K E0KXG 2Fr%|곍.zdhO}io>ԯM(EaRN8KXY=c]_<\Lfs po?Ȝ= ]ܿ!*nA MԡGyu"caRQg߇J&0lh 1`{>Ƣg|yX.,yzyWs|t8)o?%zq} 8iGݮy̴o+b#d-n<<_9KN?bQZc%>s߁s?//;o-e)ȵ4K}SqODeG1TR?u-/ CGr\,+eӠGQKmM^RGvM856myeiӧis{ U=)A>mi_ÓkO<lüG\<~| /8c9"p(((((8bc(lL( 2y-#7BԳ >2cb(_R9mKda?O!,NY\,𘏛@O}_ |G{վ%cmACI~u1_G&O ȟʿXC}y~g-v;!KwBS{zv>U=Ʃ,kY:OڟFU}I(*z~AI> ڒ1gdt̾ռ>_ rO\-˟$"$<[j~Q灘,@rÈ'>Ɓ_A:T8[c1 (ZYTqwm|*83[Ox~O?ar eEc$ OyeDçt̪ckT~G699A>ʫ9Ot4}G=mRGVyFGo0"{< JOS=Ο#D?_}uƾ?Ϝe6l~3,G;L <?' cEw^p??f,GYQ{2Vz %X£-!OY*kA@S7:)2}iXtm3vk{3?Οѡԟ#~sxGyo:NdG\mZ.m q)9/5: ٦.)_ǥ+us?^Ճ,hAW0zЅ,|C!iO9}OY?KYo_'kθ\?';:?ʿ?wPG_Tߟ>?}=:}Wv }8y~e5qz}W<PC/$ks2 HhNݶuYi꾌iCE;v.o?]o?B=o{1@oA#goooGWq{矼#3w135G 9%`ϻ1b߅t @>ȋDcĆ6Q̾K|ӹM\{}u}eDNr~9m'9#G߿p{quY؉Kw3|W)vtqc_ߧ7 O3s̑ ?uǵ:4h?}=>UpߍKuJ_8+2 A$z#kQoC|}W0 D}GX/l(#Yw}`e\l[z)B[I 3uV'k8q J ;/K?{y;6qΟ3N3+s3>;}[oxdzǨ\w5!˷i[W7ѫ}C8H=_9fIۑMlLPy$Amھ^4e6gԩj"?_:m;u~fR/Dڣ|V췎.@\i[{iמm# ڤm~?k=A|(y?MD<3_xZUUuJvE׎ p{H։736gs)?aO]<ڦv<9jI9% FYg- OYDy8628 㑝2}G)=H{탇>i';/omEsncoccWgw}}} s|s?=:^o~U7}W+&dK *x:|QB8Q{ކGj_D:GN9?簚7N?kv4.;:܂$V<S=z_|2KpHk+1:zt䫞ƉuAc7~lU\cIgLjoO s3&;m^eߔ 4j ?' zRI8LȮq6#ʛ%R44TR~d_},HqOY?S|COL,:8Aߍ߽::{bďΟE-n:~3cow ,?:yYϖ&0r1Yq @t~/__U1Z=SKϹo}(8}{_ܭON3o|&z@@|[!GUg|d ZSD~xxl8fOruKߩ̽+y>3S#F+nJ'%㫼Οu>3}fn<~]!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!8<7TIENDB`flipper-0.21.0/docs/moneta/000077500000000000000000000000001404600161700154455ustar00rootroot00000000000000flipper-0.21.0/docs/moneta/README.md000066400000000000000000000033171404600161700167300ustar00rootroot00000000000000# Flipper Moneta A [Moneta](https://github.com/minad/moneta) adapter for [Flipper](https://github.com/jnunemaker/flipper). ## Installation Add this line to your application's Gemfile: gem 'flipper-moneta' And then execute: $ bundle Or install it yourself with: $ gem install flipper-moneta ## Usage ```ruby require 'flipper/adapters/moneta' moneta = Moneta.new(:Memory) Flipper.configure do |config| config.adapter { Flipper::Adapters::Moneta.new(moneta) } end ``` ## Internals Each feature is stored as a key namespaced by `flipper_features`. ```ruby require 'flipper/adapters/moneta' moneta = Moneta.new(:Memory) Flipper.configure do |config| config.adapter { Flipper::Adapters::Moneta.new(moneta) } end # Register a few groups. Flipper.register(:admins) { |thing| thing.admin? } Flipper.register(:early_access) { |thing| thing.early_access? } # Create a user class that has flipper_id instance method. User = Struct.new(:flipper_id) Flipper[:stats].enable Flipper[:stats].enable_group :admins Flipper[:stats].enable_group :early_access Flipper[:stats].enable_actor User.new('25') Flipper[:stats].enable_actor User.new('90') Flipper[:stats].enable_actor User.new('180') Flipper[:stats].enable_percentage_of_time 15 Flipper[:stats].enable_percentage_of_actors 45 pp moneta["flipper_features/stats"] {:boolean=>"true", :groups=>#, :actors=>#, :percentage_of_actors=>"45", :percentage_of_time=>"15"} ``` ## Contributing 1. Fork it 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Added some feature'`) 4. Push to the branch (`git push origin my-new-feature`) 5. Create new Pull Request flipper-0.21.0/docs/mongo/000077500000000000000000000000001404600161700153015ustar00rootroot00000000000000flipper-0.21.0/docs/mongo/README.md000066400000000000000000000050311404600161700165570ustar00rootroot00000000000000# Flipper Mongo A [MongoDB](https://github.com/mongodb/mongo-ruby-driver) adapter for [Flipper](https://github.com/jnunemaker/flipper). ## Installation Add this line to your application's Gemfile: gem 'flipper-mongo' And then execute: $ bundle Or install it yourself with: $ gem install flipper-mongo ## Usage In most cases, all you need to do is require the adapter. You must set the `MONGO_URL` or `FLIPPER_MONGO_URL` environment vairable to specify which Mongo database to connect to. ```ruby require 'flipper-mongo` ``` **If you need to customize the adapter**, you can add this to an initializer: ```ruby Flipper.configure do |config| config.adapter do collection = Mongo::Client.new(ENV["MONGO_URL"])["flipper"] Flipper::Adapters::Mongo.new(collection) end end ``` ## Internals Each feature is stored in a document, which means getting a feature is single query. ```ruby require 'flipper/adapters/mongo' collection = Mongo::Client.new(["127.0.0.1:27017"], database: 'testing')['flipper'] adapter = Flipper::Adapters::Mongo.new(collection) flipper = Flipper.new(adapter) # Register a few groups. Flipper.register(:admins) { |thing| thing.admin? } Flipper.register(:early_access) { |thing| thing.early_access? } # Create a user class that has flipper_id instance method. User = Struct.new(:flipper_id) flipper[:stats].enable flipper[:stats].enable_group :admins flipper[:stats].enable_group :early_access flipper[:stats].enable_actor User.new('25') flipper[:stats].enable_actor User.new('90') flipper[:stats].enable_actor User.new('180') flipper[:stats].enable_percentage_of_time 15 flipper[:stats].enable_percentage_of_actors 45 flipper[:search].enable puts 'all docs in collection' pp collection.find.to_a # all docs in collection # [{"_id"=>"stats", # "actors"=>["25", "90", "180"], # "boolean"=>"true", # "groups"=>["admins", "early_access"], # "percentage_of_actors"=>"45", # "percentage_of_time"=>"15"}, # {"_id"=>"flipper_features", "features"=>["stats", "search"]}, # {"_id"=>"search", "boolean"=>"true"}] puts puts 'flipper get of feature' pp adapter.get(flipper[:stats]) # flipper get of feature # {:boolean=>"true", # :groups=>#, # :actors=>#, # :percentage_of_actors=>"45", # :percentage_of_time=>"15"} ``` ## Contributing 1. Fork it 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Added some feature'`) 4. Push to the branch (`git push origin my-new-feature`) 5. Create new Pull Request flipper-0.21.0/docs/read-only/000077500000000000000000000000001404600161700160545ustar00rootroot00000000000000flipper-0.21.0/docs/read-only/README.md000066400000000000000000000013731404600161700173370ustar00rootroot00000000000000# Flipper read-only A [read-only](https://github.com/jnunemaker/flipper/blob/master/lib/flipper/adapters/read_only.rb) adapter for [Flipper](https://github.com/jnunemaker/flipper). Use this adapter to wrap another adapter and raise an exception for any writes. Any attempted write raises `Flipper::Adapters::ReadOnly::WriteAttempted` with message `'write attempted while in read only mode'` ## Usage ```ruby # example wrapping memory adapter require 'flipper/adapters/read_only' Flipper.configure do |config| config.adapter do Flipper::Adapters::ReadOnly.new(Flipper::Adapters::Memory.new) end end # Enabling a feature > Flipper[:dashboard_panel].enable => Flipper::Adapters::ReadOnly::WriteAttempted: write attempted while in read only mode ``` flipper-0.21.0/docs/redis/000077500000000000000000000000001404600161700152705ustar00rootroot00000000000000flipper-0.21.0/docs/redis/README.md000066400000000000000000000051371404600161700165550ustar00rootroot00000000000000# Flipper Redis A [Redis](https://github.com/redis/redis-rb) adapter for [Flipper](https://github.com/jnunemaker/flipper). ## Installation Add this line to your application's Gemfile: gem 'flipper-redis' And then execute: $ bundle Or install it yourself with: $ gem install flipper-redis ## Usage In most cases, all you need to do is require the adapter. It will connect to the Redis instance specified in the `REDIS_URL` or `FLIPPER_REDIS_URL` environment vairable, or localhost by default. ```ruby require 'flipper/adapters/redis' ``` **If you need to customize the adapter**, you can add this to an initializer: ```ruby Flipper.configure do |config| config.adapter { Flipper::Adapters::Redis.new(Redis.new) } end ``` ## Internals Each feature is stored in a redis hash, which means getting a feature is single query. ```ruby require 'flipper/adapters/redis' client = Redis.new adapter = Flipper::Adapters::Redis.new(client) flipper = Flipper.new(adapter) # Register a few groups. Flipper.register(:admins) { |thing| thing.admin? } Flipper.register(:early_access) { |thing| thing.early_access? } # Create a user class that has flipper_id instance method. User = Struct.new(:flipper_id) flipper[:stats].enable flipper[:stats].enable_group :admins flipper[:stats].enable_group :early_access flipper[:stats].enable_actor User.new('25') flipper[:stats].enable_actor User.new('90') flipper[:stats].enable_actor User.new('180') flipper[:stats].enable_percentage_of_time 15 flipper[:stats].enable_percentage_of_actors 45 flipper[:search].enable print 'all keys: ' pp client.keys # all keys: ["stats", "flipper_features", "search"] puts print "known flipper features: " pp client.smembers("flipper_features") # known flipper features: ["stats", "search"] puts puts 'stats keys' pp client.hgetall('stats') # stats keys # {"boolean"=>"true", # "groups/admins"=>"1", # "actors/25"=>"1", # "percentage_of_time"=>"15", # "percentage_of_actors"=>"45", # "groups/early_access"=>"1", # "actors/90"=>"1", # "actors/180"=>"1"} puts puts 'search keys' pp client.hgetall('search') # search keys # {"boolean"=>"true"} puts puts 'flipper get of feature' pp adapter.get(flipper[:stats]) # flipper get of feature # {:boolean=>"true", # :groups=>#, # :actors=>#, # :percentage_of_actors=>"45", # :percentage_of_time=>"15"} ``` ## Contributing 1. Fork it 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Added some feature'`) 4. Push to the branch (`git push origin my-new-feature`) 5. Create new Pull Request flipper-0.21.0/docs/rollout/000077500000000000000000000000001404600161700156625ustar00rootroot00000000000000flipper-0.21.0/docs/rollout/README.md000066400000000000000000000041171404600161700171440ustar00rootroot00000000000000# Flipper Rollout A [Rollout](https://github.com/fetlife/rollout) adapter for [importing](https://github.com/jnunemaker/flipper/blob/master/docs/Adapters.md#user-content-swapping-adapters ) Rollout data into [Flipper](https://github.com/jnunemaker/flipper). requires: * Rollout ~> 2.0 * Flipper >= 11.0 ## Installation Add this line to your application's Gemfile: gem 'flipper-rollout' And then execute: $ bundle Or install it yourself with: $ gem install flipper-redis ## Usage ```ruby require 'redis' require 'rollout' require 'flipper' require 'flipper/adapters/redis' require 'flipper/adapters/rollout' # setup redis, rollout and rollout flipper redis = Redis.new rollout = Rollout.new(redis) rollout_adapter = Flipper::Adapters::Rollout.new(rollout) rollout_flipper = Flipper.new(rollout_adapter) # setup flipper default instance Flipper.configure do |config| config.adapter { Flipper::Adapters::Redis.new(redis) } end # import rollout into redis flipper Flipper.import(rollout_flipper) ``` That was easy. ### Groups If you're using [Rollout groups](https://github.com/fetlife/rollout#user-content-groups) you'll need to register them as [Flipper groups](https://github.com/jnunemaker/flipper/blob/master/docs/Gates.md#user-content-2-group): *Rollout* ```ruby $rollout.define_group(:caretakers) do |user| user.caretaker? end ``` *Flipper* ```ruby Flipper.register(:caretakers) do |user| user.caretaker? end ``` ### flipper_id Rollout expects users to respond to *id* (or method specified in [Rollout#initialize](https://github.com/fetlife/rollout/blob/master/lib/rollout.rb#L135) opts) and stores this value in Redis when a feature is activated for a user. You'll want to make sure that your Flipper actor's [flipper_id](https://github.com/jnunemaker/flipper/blob/master/docs/Gates.md#user-content-3-individual-actor) matches this logic. ## Contributing 1. Fork it 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Added some feature'`) 4. Push to the branch (`git push origin my-new-feature`) 5. Create new Pull Request flipper-0.21.0/docs/sequel/000077500000000000000000000000001404600161700154605ustar00rootroot00000000000000flipper-0.21.0/docs/sequel/README.md000066400000000000000000000104641404600161700167440ustar00rootroot00000000000000# Flipper Sequel A [Sequel](https://github.com/jeremyevans/sequel) adapter for [Flipper](https://github.com/jnunemaker/flipper). ## Installation Add this line to your application's Gemfile: gem 'flipper-sequel' And then execute: $ bundle Or install it yourself with: $ gem install flipper-sequel ## Usage For your convenience, a sequel migration is provided to create the necessary tables. This migration will create two database tables - flipper_features and flipper_gates. ```ruby require 'generators/flipper/templates/sequel_migration' CreateFlipperTablesSequel.new(Sequel::Model.db).up ``` Once you have created and executed the migration, you can use the sequel adapter by simply requiring it: ```ruby require 'flipper-sequel` ``` **If you need to customize the adapter**, you can add this to an initializer: ```ruby Flipper.configure do |config| config.adapter { Flipper::Adapters::Sequel.new } end ``` ## Internals Each feature is stored as a row in a features table. Each gate is stored as a row in a gates table, related to the feature by the feature's key. ```ruby require 'flipper/adapters/sequel' adapter = Flipper::Adapters::Sequel.new flipper = Flipper.new(adapter) # Register a few groups. Flipper.register(:admins) { |thing| thing.admin? } Flipper.register(:early_access) { |thing| thing.early_access? } # Create a user class that has flipper_id instance method. User = Struct.new(:flipper_id) flipper[:stats].enable flipper[:stats].enable_group :admins flipper[:stats].enable_group :early_access flipper[:stats].enable_actor User.new('25') flipper[:stats].enable_actor User.new('90') flipper[:stats].enable_actor User.new('180') flipper[:stats].enable_percentage_of_time 15 flipper[:stats].enable_percentage_of_actors 45 flipper[:search].enable puts 'all rows in features table' pp Flipper::Adapters::Sequel::Feature.all #[#"stats", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"search", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>] puts puts 'all rows in gates table' pp Flipper::Adapters::Sequel::Gate.all # [#"stats", :key=>"boolean", :value=>"true", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"stats", :key=>"groups", :value=>"admins", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"stats", :key=>"groups", :value=>"early_access", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"stats", :key=>"actors", :value=>"25", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"stats", :key=>"actors", :value=>"90", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"stats", :key=>"actors", :value=>"180", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"stats", :key=>"percentage_of_time", :value=>"15", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"stats", :key=>"percentage_of_actors", :value=>"45", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"search", :key=>"boolean", :value=>"true", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>] puts puts 'flipper get of feature' pp adapter.get(flipper[:stats]) # {:boolean=>"true", # :groups=>#, # :actors=>#, # :percentage_of_actors=>"45", # :percentage_of_time=>"15"} ``` ## Contributing 1. Fork it 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Added some feature'`) 4. Push to the branch (`git push origin my-new-feature`) 5. Create new Pull Request flipper-0.21.0/docs/ui/000077500000000000000000000000001404600161700145775ustar00rootroot00000000000000flipper-0.21.0/docs/ui/README.md000066400000000000000000000122071404600161700160600ustar00rootroot00000000000000# Flipper::UI UI for the [Flipper](https://github.com/jnunemaker/flipper) gem. ## Screenshots Viewing list of features: ![features](images/features.png) Viewing an individual feature: ![feature](images/feature.png) ## Installation Add this line to your application's Gemfile: gem 'flipper-ui' And then execute: $ bundle Or install it yourself as: $ gem install flipper-ui ## Usage ### Rails Given that you've already initialized `Flipper` as per the [flipper](https://github.com/jnunemaker/flipper) readme, you can mount `Flipper::UI` to a route of your choice: ```ruby # config/routes.rb YourRailsApp::Application.routes.draw do mount Flipper::UI.app(Flipper) => '/flipper' end ``` If you'd like to lazy load flipper, you can instead pass a block to initialize it: ```ruby # config/routes.rb YourRailsApp::Application.routes.draw do flipper_block = lambda { # some flipper initialization here, for example: adapter = Flipper::Adapters::Memory.new Flipper.new(adapter) } mount Flipper::UI.app(flipper_block) => '/flipper' end ``` #### Security You almost certainly want to limit access when using Flipper::UI in production. ##### Basic Authentication via Rack The `Flipper::UI.app` method yields a builder instance prior to any predefined middleware. You can insert the `Rack::Auth::Basic` middleware, that'll prompt for a username and password when visiting the defined (i.e., `/flipper`) route. ```ruby # config/routes.rb flipper_app = Flipper::UI.app(Flipper.instance) do |builder| builder.use Rack::Auth::Basic do |username, password| # Verify credentials end end mount flipper_app, at: '/flipper' ``` ##### Route Constraints It is possible to use [routes constraints](http://guides.rubyonrails.org/routing.html#request-based-constraints) to limit access to routes: ```ruby # config/routes.rb flipper_constraint = lambda { |request| request.remote_ip == '127.0.0.1' } constraints flipper_constraint do mount Flipper::UI.app(flipper) => '/flipper' end ``` Another example is to use the `current_user` when using a gem-based authentication system (i.e., [warden](https://github.com/hassox/warden) or [devise](https://github.com/plataformatec/devise)): ```ruby # initializers/admin_access.rb class CanAccessFlipperUI def self.matches?(request) current_user = request.env['warden'].user current_user.present? && current_user.respond_to?(:admin?) && current_user.admin? end end # config/routes.rb constraints CanAccessFlipperUI do mount Flipper::UI.app(Flipper) => '/flipper' end ``` ### Standalone Minimal example for Rack: ```ruby # config.ru require 'flipper/ui' adapter = Flipper::Adapters::Memory.new flipper = Flipper.new(adapter) run Flipper::UI.app(flipper) { |builder| builder.use Rack::Session::Cookie, secret: "something long and random" } ``` The key is that you need to have sessions setup. Rails does this for you, so this step isn't necessary, but for standalone rack, you'll need it. Without sessions setup, you will receive a Runtime error like: ``` RuntimeError: you need to set up a session middleware *before* Rack::Protection::RemoteToken. ``` See [examples/ui/basic.ru](https://github.com/jnunemaker/flipper/blob/master/examples/ui/basic.ru) for a more full example ### Configuration Flipper UI can be customized via `configure`, which yields a configuration instance. #### Description We can associate a `description` for each `feature` by providing a descriptions source: ```ruby Flipper::UI.configure do |config| config.descriptions_source = ->(keys) do # descriptions loaded from YAML file or database (postgres, mysql, etc) # return has to be hash of {String key => String description} end # Defaults to false. Set to true to show feature descriptions on the list # page as well as the view page. # config.show_feature_description_in_list = true end ``` Descriptions show up in the UI like so: ![description](images/description.png) #### Banner Flipper UI can display a banner across the top of the page. The `banner_text` and `banner_class` can be configured by using the `Flipper::UI.configure` block as seen below. ```ruby Flipper::UI.configure do |config| config.banner_text = 'Production Environment' config.banner_class = 'danger' end ``` By default the `environment` is set to an empty string so no banner will show. If you wish to customize the look of the banner, you can set `banner_class` to one of the bootstrap color classes: `primary`, `secondary`, `success`, `danger`, `warning`, `info`, `light`, or `dark`. The default `banner_class` is `danger`. The above configuration results in: ![banner](images/banner.png) #### Fun mode By default, Flipper UI displays a videoclip when there are no flags. The `fun` mode can be configured by using the `Flipper::UI.configure` block as seen below. ```ruby Flipper::UI.configure do |config| config.fun = false end ``` ## Contributing 1. Fork it 2. Create your feature branch (`git checkout -b my-new-feature`) 3. **Fire up the app** (`script/server`) 4. Run the tests `bundle exec rake` 5. Commit your changes (`git commit -am 'Added some feature'`) 6. Push to the branch (`git push origin my-new-feature`) 7. Create new Pull Request flipper-0.21.0/docs/ui/images/000077500000000000000000000000001404600161700160445ustar00rootroot00000000000000flipper-0.21.0/docs/ui/images/banner.png000066400000000000000000000446311404600161700200270ustar00rootroot00000000000000PNG  IHDRTBsBITOtEXtSoftwaregnome-screenshot> IDATxw\e1ިLS@T{UQG[mVkj[V{" nEqr#QQy羹'8DP7xA XK^+IN}@j5+eE,bb}K~yI)= IJ[@1qD=KvS"H F$b% Y8QF$<{ y>qZޓnxA w~;?x]Aׅ9w2#iۺY^w_,; +ٺ8hŘ)mZArsrZR}F-D}&YaXbB~ipSS{k7H v\q Ô]6m5:~,|6Nu۵)xilaQtQ+.Xt >» @!S=VPܻҰk睿.qt)<܏^jRv0GOw"J|E Q E>\6vb9|,-cƞDt3['XVf`H#rp߾%\zj8Of$bma:leVjw=Lz3mmZc_܎fѰ>mClXsyAO(/4~ѱܑK%guHHSȯL9{:}662J}~R?8|R<:ݤ1pdŪ MMӢYڋ5%W}朑yo&xDx5O*D$mvt9ac u5mrfJgdю ^W͛ڸ%~KBaMP+(#)LmX[QfJrzzBpaDohHDFDo`@DyjuvZY GVj4FÈtO ڪrj"2&̤I,143%$P,""\ADFƐL,-(+5:y|dXkUo49ֆT#s3<7,fgOmy+akKM#_cfcCDI)ڗIDdfc-,^cbiED-N%Hb`WT|lޝƖ"C0>}wFRS3'ǧ>]m ;[~Hhd㆏d4CD~-_;zM{8_%zɱCB/h=z]/> ֲ ;vniԣ:[U\Z+sZ{dzuL,-B8:33vt{V }c"9sWCֿ/p |>zM\MsvZEeaO;=k*-Vm+"S ]ƌb̿~345VC{j-SLN0Դ~3kؠK'"'x?J`UZߧ'Y?6]s)Y}=tUuݽS6kw&٠9? %  wajѿ;[q9\hnZv| Mza:xA w~;?xDZ,q#lmbDzRcF$>(©'JvS\ɎU"@q] w~;?xA wJ~)SN+d,˪4%]D"11I ߹DC92D_ORʄRMDͿ̒L|$Y2{{ 8cY:K_O²{ y>q< 0: w~;?xA w~;%~_1uHu*ρ/[4qms.:;XރجQ̬n/*BY_JTTN>vA۷nY =x$*:q{o OK3ψ!򶥿;N{)]{Ai Zӝ_G$PR\&d h`-ք(<ݪ̬'&~A3 m кe{r٥CW1'3, cKWh93(roYU*rujtlࣨ Eg$h\0KbzB7R +zm;FJ64}9Ё/)CI!wkQ v9;VzH墿WXx's6s,tV͵a99>=:HH|sHTBoL'k79ࣁMթRnݵ?5=ܬԕ@1a-H+|C݉Eм$}@MVwVDD5lqYsQ> w?Fi=Sᑓg<[wN~>^F Ї թu]/C0 $89{paX GD2aLnܾkmeXҐS3|2 4˗ݷ/$ZleH 5s{DP@Fz-3RβF|蓨{;}}~՛˵/YloGD?=}>$77ILNfFRC"ʖɊNy^Lָ~[ @9(3˗ک%HBRޘ6z(MDDdjlLD?LrKrbՆm#~Q/ aT aVDd߹]Z%zcJ5=K]<Sί '8]o9uWp/48|Ptψ<ܪ6CCU]NDD,8rȜ%qU*Qii*U. BELMYMIM#檋3lF`Ɋ$"2735/t>L\29}sQ*cؓFSER3gjRD,B=XjwOh4l.\6 ejkv6^5.l?NбMLM:po}sXPԼɚM w1c3b]ˮTw~;?xA w~;%?D (@D޹0P(UV@YQrF-aO#iF :H61gɞ@D2yNLβ:OS>2X$`c{;88{ܔ(J~.xA w~;?xA w~;?x#*QoFP(4MP:BL('q\MNNh4RT,XZB=Kv3''I,KRRޞ% ?e%Ii([8'J|KbA w~;?tJ'邧LEsn>3?OUOetcaer[*2\&qsWX}FNy\Ұi"ߚ!C jOss_x/sr9:r ?#Q~}nkޞ[777]9痖>w}7 4y'O wry؈QMթ߾S?/Dt=O胇x/^7 2^æW?|䘧#Lj;>{7a[H' LiԬ@P 3OLm5i4fwn(m)g2/9A+#)*J_){z01aݮQˑN=[&rԴQ_b媾_ڱu3^q57M*5 v}՚u11[jUoAM40n{7'r|ߺ8{ n;[ۻ"V*,,|zzz2#M'MgkkaFܿ}R h`-zB|+d%|ת[נGȐBa/iڤſ_.ݗX`g~lm4mNT(nn5ݽ8 EDtǏ)m"P~Jkk+"[մ~8|WSRSǏݹSmSy寋eҜ4E~ ZӍ[Rl& tјz4=HTEu5!2֣jH QvnӢdGqYt3&6~mM) Zx~uҐ囹m c)MA#Qvn7aO OHx_ZMD/\J Q-$4Tz7[]xLD/K]R O&_~߿qA 6!CΖl['%Z[[1եuhu:*P-{"55YEv9 櫩c5?]gF꼉Db7oS˵aۊT2|Q_DȑQZjS9^lM +A{5\Xns_WTqy l66ˉǝpVw|B"99::w8̴^ݺ͛5m׶5hJM¦fi V$_;KQK磈ܭIeN?%w _GvQ#G_k>,]GUL(:5!#Mo3[љaDDb >xSY[[7۫Uu%"a/ZfPHDr9tЀ-[ Mw!%-"jִ_ vsm۴r9rgr kWYYZ~Hݰ:ЙDDzB5)G "ߛ-yΏ#꺙 JwЅh򷧭_\hz"*42h}p$)oѬޭTH/O'$\ l۩ sU:6,X9|rJ; ;I$ƍ6nԐ;6m:i¸wP RL~aݓ&IҕܢfIvŻ>R4Wʐf`"~$rƈ!jH{"^b4mIӏ\MA2yvA1O4nVN$"VY11'N&"V!"aH{ TT.Wh uw`hhXۿVϞE4x#? ǎO55jHDemuJy|D^Eҳt:vk/HPf #T7zxљath ]"h:2IG8jFwk/(&]aЊtfNэ_Q:q/<<{/^[~f͈R%{'GǣG׭SۡJwm޺o/֮pQ}g'G33k9feePN8Æ R;w-EāC6믆8;ŭZNPL7=سG7+KˌvQmaᇯ`]yh;B=؃E4>MV^i{_>8[^A /‹EoDoٴn\vU+!"a fϙ0?؄oݾCV7mʔo&.\9ҹc@:=wMIMݻ}u ==)q>8 A w~;?xA w~;?xA,_5_YaYK*]g;5V,68es"ڳ/xێ]X[7uC"ZaCzwS]]cb|5o[s,@欹uk:z`Y3g<'%%Ξ8eҸxoܼ?0 ^gjj{ظNb鳭Z%:<{5GD0}VDq֝:[C{wQ+Wgv- =#soԈ UؚBeK6n6bCQ#l~5څK}$bqJj+VIDreÄ(-=aTm433.iʝN_Xg4ڗw">7ca;8(:9:̜>BMv-_++:L;p??_ YhI[ 9q؋,8t 9wIi[6=[|]С,D>z= IDATl9D4,Wͭ"S)왑0㙗E2F>5ND)͆ 0vu^ިÄBA>f7~Dt9GX;ټ'1\|t(, ?@B쏆3om po0tjO%w6?g10sݥM]ËNYv7 l֨KEv1g_^xY|yܛԭ჊_^kn7b{EK[|2wxA킚oݲ7bpI @A?5KgG"ڰmufY~M#"u^~8rX|ӿ{LMRe*j`U*vqc/讧'!"⸭߻)ul[ӳ`u^C#"1 89^_OeٝݍJ ծ H.x<ԫSJ rffI>" 7nEct~E":uRfvONۉi.j;?~>wm޹777`?O~SƳбDt֝0eܷF0+ڑg<[wN-Ίu|OLvnckA{wCD^5 Je1DԺE B!Uꜜ]Í|<{P0ڍ;[DԬqUᣧ5=a@а%_J}@۴aE\:AEӆ1 ò%<~:==aLUL 240+r ^fgv?QK:eYBaiimǔ|+#(6VT[:WSJf v{Weoa+oV8#a3MM;k Ms~%`nfDDOͽFU" >rnmܢL.wRa D;Y4ݽ0LJv<5E؍[N#w"(Yt%aW9p$- s_/-6ًjza_ ?TdNmv?|C;ްًznBolٹh@3|ZA-8|űYӨ 5k~6˲OGe蘸AzVt!Sw/#3Թ-5Bh8:~|ȕvA*WZh8 (xA w~;?xA w~;y^sq/N҄_1M8 A w~;?xA w~;?xAF_XyNY%">s[֯266*"Dua|B"DZ:z"JMK11q6~>5==<&ytLCoE/?N'/?Z[[}9dĸQ#N>ܠ~hwj4X,1lpPWy ]9bhbbR~ZrIrH$^]jD"g5Fö=#յD"LT/Yѩ{^ݻZYY_gmcb:E3a* @D6NqD~m@"J[tPW___ 8Tܠ~P(HvvIɯ ۡ]k"277NHH355ٽ@ls'Gsg!ʇd7)WʺBT2!dѦvƖ;9Jb(33DԤvA ,,ڰFRi -ٸyۈ1 FÞC/q\{GEE9;;I!Ty -'DDT;EtukI+NT̏S"y٢|JHTh&LX" mȻkφnG'IqG7ue%չAW),#F*πn_^z4lzДNB`X2mF*>՞Vݨvѳ_iwKt[EJ7$vYvxB?[J >?:~Dq~c|ʷ yin/}ztֶ,'h؍[y{Y,߉ rlΎU=\.y5Wl(-=cܙS藴ΧQ1zlKDz=f[,龃RҌmׯS^g4t@nJIMWHjHDrk'*54,Nw""7Pد^~=̊{ᖜ6{%g} ] ?q \ 7Y̩$b;ݵcO;"3;{Cc"a׭,-|DYDӷKW+n~Ǖer[WiTֹ:9X[(SӽgFfփGO&q14,JxMh#m3ν=:/ѓXo7K 3~O_*M5S99ʂk7o5o jv۷RTzb(1)eC3asssw})Sn:Q^xد >JeLMbgkCDg/tYԯW\u7ѓĤ[r)48A.*'.\Eχ̙1%/,' ۷nQ畻݉ >rBRD.[z{яU&ݗFREnݹ_v\YXetWӓ\MMSSm-9JWI)VDdea}<|P)rFxwjtܥ v|toQ32{v頝]y746\ӿW ÒSRȡRu^C#"1 89^_7l!Pݛ)v6ek̬lUn Dsߡ>~:>!F5WTg3O' >satnJ #isfNo$xw1=|̅)cGH>rW=hѤAm:wi&7o޸o'D"J% eRwO/'$%8z¯WtPV߲cĐn\oݽqۮBᓧ `V߼ݨ^hҠIĤK)-ZFu}IrEC"9s!dӿ{S˧}B@òDa5B W{h^]WoڮTZ4nQ1z@*k,n419n\֫ӣsrJOEBGO4r}k6;}Xc#®jѤ'|?iH$ڴ}ϡc{tnWqKW{\H]A"ZgfjYc*?d9V(*rcG |Tx[w7WG$Qu=xmUuq7'{UKNN%w"|=;k451&ھ5*DaX GDXjhx14,9%r>93Usn1Wʿ@?@0Le{̬̌Ժ|m"%5}ՆwN}<|<~6aWnܾ{e{; y͓gю;հ^k {ׁ,GYѱq#"==IZ<|Blml-ɡl3Ɲ ba5w KO"Q**rږ;wH$vla\!6M2Tl$"T{]f9d*SRdgdFj` ɵY@0qg/.^F"tjaϐn޹W+%}}aY 4ܓgީ'E`eiѼQk6kԺEk6YZ{{]յC+7_@__jhmnfZ144oj\|zz;M Zf-۹ރGju˲|~egk="!TOOPhC351fYV(6*^}VQVL-K$73Lȉ3I)Gw""w;HD&FFYsJᵋ0jz\ h4]|mےv6XȹV9~NDzF&˲;:GN{uZ6m~9 ~m2rȗ-^&WvuvkG"ౘԼֱgblB\DbQjx߸=kz]ڷNJI]n #߬ao}ęU*Y݉Lkm59=G:n` vu捻]}wڗoݵ_wO/IWkk\oG > j/e-zw\Sp{<'pxA w~;?xA w~;?xGTu:>}q&94naOxA w~;?xA w~;?¯~ǻI).].bC E|;?O E{oݾSabшaj}^43+k 9ԡÇ\ e,%$&.m?\rlwSTW ^'W(/3xϿ6V1YآMY]6w3rĤ5PǿklӾz]6D'Ϝ9wk~355ٽ@mϝZ?DdkkU𮕵URrʑc'4j:EŃ{P(\lF`h`0jİ wvc%1LTL ru^mw⿪Ws:iBU˳~x/?M:|i&Y)m:9:̜>BMv-߂23~?υ%/xsuU6o7-Dtjt1xr);veGG,ɉɉbD"\h42˲nnDtĩ" EYD,hXXP@/e=\G=׻=DV;ժ?x8I$L277p!5?ѵߠFF :\]5kʾ}lX",z}D"ٌ練QPj q(gg2)$钿lFH߅tEt!#2)>qʼn*9{7T%"RD"yѝKэÞhSG"-Τ%Ґ؆˿FlF1z{8z4>1qY_YR}ϒm?b*R FGɦI M)$ %fdڬ"ϊnFÈJNB^:E-sGD;r,;RR^帥.3GD(a[ʷ vrޞn}{tw""C.cmϥ+׷ j^չ8JKXr*7w%iT^%;ےg~?O]k7/\suv*xbg3NE7n?tt׎m>>y]>.](fѓgzON,]{Uoq0L6ѥ&zةeA㵛76Q۠k7۹}+J'R([&?}>1Ë677wwGO1l)>1ic󗯄kXԴ.v6DtbKUj.Nzu=[w#=ILJnߺ !BYɡrrJ¥+[4ip|ȜSn>}r"}|p)_*W{119Zlcm\òBv;ԾUO'$ըںe#9y6==cq"p;6Ovߘ0rhតF>wiFRw3|O\2vTjnˎ#'{upރMѩsijb~~vH$RTB_+U2}IRrꁣ'jza:K\H]A"ZgfjYc*?d9V(*rcG |Tx[w7WG$Qu=xmUuq7FR)yUKNN%w"|=;k451&ھ5*DaX GDXjhx14,9%r>H>-KO"Q**rږ;wH$vla\!6M2Tl$\. IDAT"T{]f9d*SRkbeUeFj` ɵY=T+&v+H$Nmqx W\vNUEBġr}5gjb̲P "aWdld-.g :rLRrC%bȝQ,R.ϱ|'cr'Wi_lJD>^~k5WWpسΜʖ(>_Nbb&"{[네d"zE~5=Ck4.\md ;[XP\ Sry'"=#e{:wB"'=o݉&뷤Rl=EҕsJ"ҞQO-51q%+7jTs5l@uS.\q\-_6ϗh4;ҿvqe7ZYZjlwѹ/8rrܫkвivYبG6ngC lh5j];Q-}>EM8c Ǭlo0eD$zw=C W8QÞ;?xA w~;?xA w~;?Q)yp.V+ =w~;?xA w~;?xSG'U[w"4z]-F 0lPEMg~II#Z?q >m:~SczDyyږFZt(] ?射c'L D*od3T{ЕK]zDCe"RG>-+WMqeZL.n޺̜5wM>ۻ&8㿧O vȘ#0% ? 0r8 r 0CB e%q(W8ShCM1vZ2'ڍ>_oc/Ɔ9[f\*ݪ}ˌ:*UsC4eSx~}6{}Y "n'6kV!Z}dú;8GO,t?O(۸jwி]+]޺/3MBtڤq 2%)Q1(n`LE%)$I2/JcdY%IRLLJ$(O!MK2# ?-[ƶ@tj$),4{fzFiQ"eeTT/UI%w,nv+ُhx<6{h&`hllt:ɪjb}NdU?!(QuY~GBDD؟@p8Ȟ=#"GB\*{YqcNQg:sΟQA~1gry𓜡rWRgΚ !~xJYV沭;vNKsptHI2]ʬ^N>\;JBNjnӧm{$B 1$ v`r*Jw(t '.C(P8@q?~!C(P8m ?INg'#r:,\ֶt:p6+:j}.k[L&ͦΦ&\dYV4Fʶ}x(P8@q?~!C(:4Jo>IENDB`flipper-0.21.0/docs/ui/images/description.png000066400000000000000000000602471404600161700211060ustar00rootroot00000000000000PNG  IHDRL!WsBITOtEXtSoftwaregnome-screenshot> IDATxw\%$  K{m:mvh뮵nV8AQQT({&@!~,^?\{o_Fe -]@sAo!r[9-Bx !ؐoҘ`2jEB5Z]STTf*3M]T(++TVcqSXեr"+8\YV47CSHb,0+PՠcYV#%1r <<4Z0.Or[9-Bx !Bo!r[9& Q{ Hk6U xیDw-;pdG/\zKMKoٹ~x4ԧ_\QV۵iʁ~t]MhgIPBBB ..Kyvxb<O"մ0NCՖC%O-b }L'ke)\HFGKRɃZJ.9{)SNzfF[rb8|GUm25?[HZJt{6:JLN9tP ?}>*V[ZXL8ֶ EF ?-y{qw..)GtZBQqs nf*+>yB'1^rmޜman_PTV^VgLy]ZzjZgG {o OГ8'$e|J^@m4H@aiV/KiVzžF"i;ݚP+28aBR\9RbC[LhrW }~$FT&J%nҢ>эb(uҸEorY"}Hp+|E%ť-)CnYnݙ7XHDJܙaCm\PLgQ"2hI_""K Oۯ>Nq3sc*$1::8@Dނl|RJH#UVMY*Tj5`Dbhol,&"1waxoccz=KDS'?~̟TTT2tP]'x4eș⻻mM|QL$߿}̈'뒐(=P&EgDDݾ-j&CY?܇][HӡPJ-ΐM*uN^c?|dAa/Ek ;$"k ӏD"3bȘC ݸR'qNn'K,}E^ tugzzX'$wȵ:t,u'"z2TQMD$Ri%VBD7TFu[)YHäԴNRQmk#T 1eNssnqhu5_@DV&<+@ CbY lGF5jMkSGTKLR9q(qܘ^N# G+Ҫ0W.`YP?G!nBZ֗&n%ƹv͠~~ߺ5ߘ>3&M[}Y&Ba^@qH(>7QBb⨱pӏO Mپ] gO5yͪjtBx5qFڍ5̼uwTVfga|WT&3OȜ0gϜT\fzPh4aWf0LQQ%3ls+maae33o:]?xuqIa#$޲u?m4;C+ݱCQYd#yyy];D`-_^Uӣ~rͺJJ,-]hDu~I" F̝}8SoL2$ػ=223gijޱkc'9$&&9qcFr/]t~>_|L&}k[[ÇY𭿶zrrXWJloP's:9Jj1[ncYv&??R-XtG\%;'?̽O?_˲B2|Ա'X}wxs!˲e#F5oxeYN˯Ngd8wdY,I,~]<_urq?X-*.vMKYkz~АᛷŲlJj7|#g#ϱ,{qoF᝔²;v:w<!PK^}'w+qW,ky}W⮲,~*g_ovʴj Geb@e/DEw ꕕͲ_[3RQOT= O&>;A*#fL*H ӧ:|f{at:c4:&v !ƎIDfBFp+ڭŋ#N^A EĺuqsBD3gLhEƍ%֭ZZFNnnzF1ȥsn~qDr޽zQ+++ b\:wBDN/ LDvvbxDɱBP*kvǻ+yzi4K_}eDb{LndccMD `OD'=YkRmO"~nҳ˗-iTaC7EVu=h㯿V]n}SDweKK̺]{-$$$gdѦ[j9lLv{#k]oRkk;[[N? J-9%%11lݦgxӓaS*# =p047/SR/Xj-=~fzVZ8yc # 5]? xhɢ%%{cFqGAkv۶?ߠ!yox/o$2mְӘ2/>۴寞}l1F=2b!LسGCg}^3%%;i}K-X'CF>`U ?mfA^uƬ{k#CBa!&b0l;y㫚? ?ܺlIDnEDTzDDFF[&xl-|SB&~ɇWVW[ZZbDžÕÕ_Bo!r[9-Bx !Bo!r[9-V!0Z~2<,Z- Z@IRBQ]]U<.ZP(LLLju:\.WT:xtBʪB o!r[9-Bx !Bo!r[9-Bx !Bo!_rJtO F RR(ڀaY u:L&DS!#h4JR(Z[[ڠÕH8x:D"LVUUUWBN׋⦮ izN5|I3ZP!+r[9-Bx !mRu(+AQ:ڼ{yr̓ 9M$aUpMM'T4JFn|QΗ-w@X kYB:8>L6Z-(NWVX~ 9QW:6Ste┩2kdYGVPP ӭa#XJz y떗o7PREDK^Os]{^= ;lp άUvv3š>a_¢w^jq"rFIhՙ7Brⷍ?^87_}ǯéG鯿 V=c9< MeI 1&"SjhvNz̷kD"5tqf'«5H?w]ĻN;'%%+G~WD2l`άc&\ƛ srs|i%Bw4w˷ViVV6ߚH$z{{6 U*g|VYY/_t>~_&.~uvvu_p/_=_>3gL3432BcF4aj8eW:YXdႡCj9{6dZ֜<ajQx5S}8q1" SvF5vԶ{Oo>cBW g/N9Ϳc聽ӧMysj##Ώ_^xͺ('7Ͼn˯9ĺ/>%Ϳҿ_%o9|pmEEl߹DFN|{[O^~csaQ{ݲuۃb'OE "*.)tk6X]''{lu'O=p/]8p@DرeꃚGt巤;ܻKc_8o׮JMߨC򹪓eBd20-NcNqlxkSCXQa**)9;zw""w.EEE&&bgggWD4~[PPx矛LD2tȐ3g#E "ww<"Ҭ6|%bq@@[vܩ[Cѱ'^Ddnn>n#G?pGD1)8ǎ=?eD"SL>r]=_Sag_j Ltv@$twf-1E"eee[O;}2yII)d;DKr sLss-.)fN %%w75t,KDnrs ??GZ;fԞN|ȱNѤ]\]v6mIMss3BQ|ٚ""ejV+W(ڷo5K5Ϗ̥,72z}箹s^6<\T}˗.nݺUqq17eY\ѦM;0W(h4 65n՚eْVVVDTRZڦM/[^ׯa׬+j|៯2:&6))9x۴!}wF;W3mlvݺm;WT?xhuUXRR0LrrJę63778;4pTzOYZZi3i_o8qaKȸx28-ܺV28mj":p(IC`@Nt;v5ZsݠYVܾESgLe6ƛ zh}zZgDԧwg0k+!ڳ/uC/|uޛ,-;wrjuFFFÆy~̚k?JM2m`~ǎo5=ۦͫ}_֖ǎ3z䃋>xuBBⰐ1G/(VjNk#Fu|D"13N4dĨ LDFVkP)e$##a;ȏo@{iru?l<o8z;YM"//ѱErNNo9*Ej_0cpفCs-] Hxb9\=9-Bx !Bo!r[9-Bxdff>:q].]4i1M)##E8\ Bo!r[9-Bx !Bo!r[9-@ t-]Bk\;s>ZYYYPPҵ0[kԤo_&!2b.\7rX'-]S!-=hx5ap%@8s>zx$Sa9݄}"ZNS*pqvTB‚yZ ¦ !Bo!r[9୦?t7?f+?YW ong͔+-]ۖyKWFDF5Cusjҕ9_OK|pO֥7siVW?Rү%$7S眨ˇOj|{e̘.t! 8pOfwTQO7OL9t,qlG"bhҕ4QrEYVV{w#Mv\:i}Mm}:ޱä Z9zƭg䙚Ɔ JD}ԴgpCݽRH DD^8r@ 2߀>=jj xRps:qj=)imZYx7_KJKJNӡ=]OHɤݻGF ?-y{1 `oGOS'mks!R֭,re!C\Zٺc_ZF&˲M4Xhv8r=1e^ABJm|vlK']$bmm&mnfz.*zbVho}Aɩnj_Xy!?E(8mOW'<VA\ۖRWfI$ƛ]T[w^?fĐ!.\ڹ/t9Ѣ7f;ul:;vxysnBDFB!=36dGo̘5uҫ/N+:VstJMju^pscREY&Ov8\^^QWDTUʙXcc]C kr$,","2gz3co1" =w=wnG 徲 ?ubܵ-{՜|nf\cWfM}wVVWM7nfGD.ʇ+wla" )7}zgDŽ9x""嫖j[% Dt,KqW%̚6JK,r2lރDddd|}dQ3˲~^ڇR柏ȲQ=]Ǎfan{fW,~N{vqtE򤔴..zu߻/MjJ"1&"M$sQbhn.ώID5{:_,5*:V@ Xx=>1Wn1V4nk^/(*T۟<~{pD|(&&pv3e'D!Cܻս /^͗|'VWWQJͮ]$ 5NMKkkcoۖz=pg,g,_h*{΅m:w vv7rsgM FFF.ΎEDtZBϠnBP*5Y^5Wػ{W@ID0So5v&? 0܅faeiAwްlNgch]CSoVq[^ϭenfƵX s3Sȧ9 K۴&&U}cRCzu04SރGr2X,&" iZK1WޝQ+ZVeJSP'W(?^{Y]wgRjǚL*&*TjM3 cb"*in-"ҳlx,,*>x,T0\LDe܇!qiZ*2nT&h4\6s5c9ul 6.J +K e ׍ӑO0O]:{,͈HQv'b\B./jFFF "jeeaX*`͙VWg{莽j|߱0'atsvMb,Z*Gh"##LkhiaVU^STZVVB)2*f ~wr$T@J,+/&LLLTJnZ ?YPX4bZB2wAlҴ >pDRiYy@QV.`IL5B)&ln;!CYýԍa}Sn9xw)"Ҿ">1zbe659;uLNy!KNNM#"ȈE_+zuhv MJI۱3g톟n1'/_*HDbFw"16r5^QVvZ/ej-.g]BRϜc]O?o *zBBmG wWjf_k g/<^).b_XZBRee^{Sǜ<":s>F*۶6bHhsyRپ0ٹyI]=Ξh ?z3](|zt,˞9ْ zrKKˇ1zBN^~ȐD$(UL$}{YZogv\l@^Â׼uW%ʧO[\Rs"H$JLԱ}X|Z"#Bܻѵ~ʤDdo6ͰȨfǷnee],9;uLq3ܼn~=!o ZkLe2g0 3~p7*sv6~ޞ;5b.hnf:&dcv9W%ҩSG)eaq7mODӑ k|,ʤ{=[*W p< 0{g޹Й]0L^~eRi^3ne=t4"2THoq6K*$ptt|1-]-]S6K=Qzo!r[9-Bx !t:uKW4!gt!O2K?2@+hxrFz汰~{4ܴњP_o"xRUPX0۶2~{x5a9=kiaʹp!6Bx !Bo!r[9-Bx !Bo!r[ ?O.v0u/j85a)M+##E8\ Bo!r[9-Bx !Bo!r[9-Bӵt -{ϓs)heeeAAQK2lmR|[@ s9na;t--)-=hx5ap%@ ;s>zxxSa9݄}"ZNS*pgJ !ZP(,(U~AP(lr[9-Bx !!֝ sq'|\Q6o߶loZ]?oʈȨf(M.__3kYq|ҕ\{rige[\dT3cFزe-4m$Ʀ}zh|bʡcf2߮!CSåkC+U[ኢ>_߷WP^^F[g .NF07 {+zwPOW?iۮKq'>~s҄FBaZz枃GsE"nǎ46,&VsLMecCt%>^kfj3?x8҈!NDW%:VTRjia>jXp7߮\',nٱҕkS۹c!Za\S*Un.S'71TVUmپ'1%M++/fLqIiI{sѷr:'"^cߡ 2{7?e3khؗɲcv&MfƭmkZbl~f@gM8sIMIL?4"2*>1gQaQɇk^dj*+,*VUV> 5˞z̈!Ebkϼ[B&t8~a5Wlڶ[|~"˲B (U{!T~VVΤF [3 zB/u>҅KDѼ93'uj}*w7 ^{叭J ¢̬i'DF./(+~۲]*5,x߻+J6'Y*/c  /zB]Oݽc.=x#2*{ ?ubܵ-{՜zf(/_U*)UDD^e3pղ |W122Jϼ;<\8UZ*gѼKߔd;l@6o~n˜?Vj4F˲~^ڇR柏ȲQ=]Ǎfan{7t:]ߞA]=5L,\ڪ3cFx{'(ͷk>^ j>mm ؏SYh EVN]۵uan*ʈ̴抖fUUꅯ11IMMee:N(&gEFL&ͣaDD5N HLyyeV}e޲rss3#}Ebl|jʵ_6o˪{/S^^r=12ogkZn""2*rܵVV65WP'*v?p@ pwuh|BL7Pq<-G7W¢|N*+z}uάBl΁aܼd;aVfQzZv8X=s>ӝ[{@嫆ƅE%7nfѵda>&y6[dحa !C طf{K sh؟+Ȯc7ܽ{DF]ǍFD]\=Ǐ/U{G?qWbqNǍ9L:i{RIϠnnԿw73ءӧlܼ͌kImZOyv}<@ў:{^aXpaFT\RzBljZF=vEźvBoOsQǍv3ևk61 'U^̚<޹Ё#;o7y˜l=q@q;:v򴳓c؃kxp]|nR܇Gvvkp1SǨ˲4  GG'U QFRsxxy_;m4EMiҕXU܃eY.6nԿwG\KW1gfWyQOT=]R_f32Ow&?"h ?~$,KJotv8Go|X/,ZG(Oyunee[rU5 ?}_ W"c#aDt3VN^nҬ /;;;7@KVVWΟH$j^*Ͽ[uv57m]=On۞}w0qJBbV_]OnB ^PO_eY"*//^!ǥ޷ZvGD)7?;:-欗_ ԳWW>2텉MكDΐkr̮{(/!=/~퓒S:ӧ[PϾY,.)ykᒀz.[J:p0IS3f'#ƌӧ[ACX%/h۝\=vxټ97`Y7o7 O֭nG ?sXKq<z,I\!WXX#"ni=:uZYY>8o)K~ɈNN\%Z,:}&_~xP(Gxnҳw߁[6FDvmZL}aWyO}c>˲\{pwe= gLn3:UVV2 gDb͗9 3 ァo𕭭풷9y괿eKA?䣚_fJj/?}{Myn}Ӟzm׌Dr!?qW4[lΟ|ETt̆9 Q8&~gѫ/NOHg7c~vG :I\+g}0ݶ3igg/"?n~=eA"O[XZE"w.Vtw2wDt6܁)l윜؋Æӻ';w3jSdْE"(##O?Y0]ρ ߚ?zT]>p0FZ)kފf*X,榹ʪ*//K/(޳o)Sv{O۶011h*XܱCa!v8_NND`gИem[6D"KK V]I)]FпQǎ-tvY}%],_WfLDUJ޽z;rP܈_`=+W<%5홱!D$x%h\!|ْs0073t:.++'$h5MN mDnXTXXHD'Nzt#*%"*..neeضȜ\"{֖;w6%"ETjbEV%\x3Nyno͟ѹwׄĤ{*ɵnm["vk0̟mݱswqI wVu6\~srΖĶm۫׮yǎWP*_oogiit&OlzpRU)51e2铭vu{P!?ݳ8ww3dD_@Dmڴ6,j۶-݇;鷍DԪUR\zpE!Chgg.o23ok Hy)|֭R3׆ޮK;e5 QG~iccCDw޾koGDy\%yymZ6 ^_`oeoW.FOm۾|D+ZNsw *gxo֖߮*n:y.;;cbWnڼDXKgĤ;;@v~11S~÷ߏ+_[?_gm^_.;?~"(0ٹS={Q._OycJJKƎOyw$#3sHKi㯻K(v1lO!)uq¸g`L`\> \wOvӵQâk7y0U5 ;/pQS/8eo֮0iꌠ;Q/IWH8igk4֮^(d鬹ry^C $iS&gdDDE_4<0 $4ڱueAs}}{(7Yvn-̘}rl?9 Ŝ#srjHD_>׹]8Mn(ˣ?vmZp?.YSڷ[—K%޾=-&n];gff6u7^nIǏΛ4ߌQ(neo{0yÕ ;|7zx]wF.7{}|O7>I Gku*jggbM ZǐϫwDrX9`,0B !22BP5XYd2zC@chMNIrx ־M'%KHж=}M] >G<܈(WE9oR,F\n[[37Ljhu5:0B !Bc!rX9`,0B !Bc!\Jz8UruX zVÕX9`,0B !Bc!rX9`,0B !B[d2%ͳ.*.yZY ۴hnrZҵIo~`gU'=HDj9 Wkg/\ޥm}:{=Y}"L&{UT\- hoHrPwss8uBc!rX9`,0Cnڌ ϜN=sh:rԡwW`bUED"hMZvorOh%7vp@ =xw^w`'O:}pp@ ^^9={|||'Lѓg*4x[hGd̑D"'gZVS5ra=GG ClP^xR]]*r6Ga 100۶R hU:QO+4 1y={i*/?W{IWMm1b׮yD8lhN^VVV-6|qՍh`^6]ܰ~-+KMUBw/_ƮyuuPUiiiƞD>ݿ}=##34,[::-[7gaC>z,FGz035ѱ ? Q^ܗ/k+[Î| nn/ ?wAy2lQ{eԭ3f7~xc(@?{ʎ_,&"~tȱ蘽'pX@nn N ^|QIWZha*CCCXbڃ|ѫ}<c ڛ#|nfwUΌ=hi!`XnQrvwgsrB0##S)uppY8?=#c%Dt uuuƇ͞T{{E M>S*_i3Μ=vʹgnkh՚G߾][^n{tx쁸D[~:f[\lx Fܐ33ރGywPڪOL8ܑܘqߩ---.=vtt3j$튌sBҹӥsg|=]FN ߸zb#MD;G_̧O\ڮmk"ڸiKX^N'̚9CWW755miXxX,[Dxz<#>ur^==NN666&:uj֭teI""SS~iA\]]]ٵsFvNX,=p `Ȑ}O8ͷ6muםGEr! lmlܻ/SDTںAzuQVڜ6Яoc}NN mok{ ?))D?.FD}cDž.^X~<}LpL$"&>^T͙5֦r@5vZ L&dBB޻'KINT-"S- Dt"ᔋ{S"*))Q(^xanfI Gku*jggbM ZǐϫwDVBc!rX9(#ɬ,[@&7p8䔴*qJ! N}־M'%KH]P |yQㅯr޼)FYY m[5q'[37La*ihufinu9`,0B !Bc!rX9`,0B !BcU}?,-qX/:ZJMMl+rX9`,0B !Bc!rX9`,0B !eIRmN!Ljd~r!>5!Q|X"JDG _dh}?_N]HBxyH Kކ33QOMOKYE|R lAD9ёᄚSa+q'o[1?\(~KMc]F<*ě @j:rhA:w(5T.ph*p}YZs L^ B*/P95pI5rzy8HRY:jۡ`,0B !Bci{Pޝ܇Md|ɩm~yaxww>̼ħwaqn *:0de*g|FJ=1tu߰H_#6D$I|2`#[U΄O BNK|D@ us<'^,:w)'tYf~@rf_!. uL*9u9Ҋ~v?W?}'v̓L SWJRѝ>[ן[Ei^3-g*B+T[N 3lI0^/oY~HN"7{rISZOcIs~X-&¤ز% q=]-dM9Ba:IZ:q8LUM o9OZ[f,lV'v]:,HDV󏻻ړ|(E^"+q9M'>zօp-]YwowN&Y05efyKo&vy!bܹ{mz6K&"Ჵ\ƶqj:owL{Eپ.~T"?j_lC_w4VM7OMyޅ''!c>}?9ݽ>GB.C+;'udXrbK?B5SNL"UK>aLgL&4W'gsBڷ|?7)NW^\B,85e{ Fn ^&ܗDB*կ逸- &*y *kȳq2u8~PZSOf^zOZEbR-+-PNEf||׼]^ODs,Ǡ4;l'/e/^eaYAk jJD&./M"DF "2jm[=\6xrsh{YI3eyn +nMBn$.(O9!K%r--=TOieYb ".fIaƱtUsy&:R(dB'£$YbeC'H*%ifw6߄=ow Lakً:xU~Sυ=1gUgW+kk)ƑNYkm뗻eL[zL=spD,Y/WXy>>EIgMt7tYvmc5mк䶾DsX8ӨCKMݸl8~OqL|:wN=[ {f,}us/ߏ a>I&6q3hՍʱ ;}P?D.{v&ux [x;^_xϿ#0\t/­Ca/9B3y_a !RsIRGz9l$r&zVHءˢkomRsuz&Ww-'Optt8<ءKQ|6cmRp~F"KpEx{ȀU7X~/E"KP|m&㧯m'"Zy󷗥)tpۘZL|:Qdy4PX6>?߸:w(_KE"jh SwB*U濼ë^H8`6-Ñplz'X9`,0B !Bc!rX9`,0BcX)Cr8ʖVrbXUW\.UON&d2 8533`|'X9`,0B%.IDAT !T{JiIENDB`flipper-0.21.0/docs/ui/images/feature.png000066400000000000000000000726631404600161700202230ustar00rootroot00000000000000PNG  IHDRGRF:sBITOtEXtSoftwaregnome-screenshot> IDATxwXS; @ e:ʼn 'nuSmնZ˪].[UQDQAlQP02Gl1*\qxΓsʗ3ra`nW3H5`R T@{+U6Oo0imm474IJLxU<===#2 wI$Z<NHDZMȆ&A}sS&ֵSaRH,Rz}q8K8\plǕH5`R T@{ =jH5`C]Am-#wҶsUP}mGYELEHw4PD΃]{ oWܣC|bʃ8zqKk}{E?D'҅4>ݔ qfʽ/OLqwqֺ>lЁWcQVF!o1J3)],Z)4H5ǥd)b1NUǥE&ݑ(+7@EDRՓ~1 h1R2t>@R[#%kc9>5T׬esEF5BIx1w*;7Ҹ鏿V(}fM;~%7*d記 ) tiff_~IyW+^[RQYuHdkkRzMRmi*+IG/Ѳp?\.KK_wz CJHvhPZ# ~O4MY9y~>\։^=Lh)&/i3;ђomGzJ<{h.-îUojj%̵ URZ.4223ݻfaQW=rfΨ>^+rccYBDDÇzypM]}7sތ5jwIX|`]X}2yDD/RЏ:[QDDӀ&Z.nj)ҁgОj 굢sHO4SD!"eJV_C{gp\I }2Wȹ=s:TVn>L3r8L~~;+"rs{%)As3Q+ݻixNxyT{%Vݷ*RxkIe$i#"2!<.!*(SI oX>k2+OS@ǓDF*RjyȪvwzI^ f# 2ឈ9ws=}zmKkkS7KT;;Όwiؠ!Fk}caSXlQsFDƷ>剨JB$ovlM,'&+'õ߃{j4OXDU<.iFDC?˪\[d}2kh¯cK3=ZZ^p}C"4$0hHF5mz+>?, N8曝zzwG}c 0m"M1M4ʑ#"+w>+'ͥצuR0oGD2SSGuDDv&PRA-QXIeMAGYDDMT@Zs ::xT.G';ΉD@?2г~m&+ G&6ɻ) w w+R(?>=$Ch]^9*h_#9N[ygXDԍ%u~-21~jjȏ|g%00Xy%ώd"/O7o0wz~̱mTw*1 T@{ =jH5`R >{T7 ==;Hwq9-mRr>RH A-mFPZ-'&IsCDTG\@Gz\Gd"462A{0 uK7j\T@{ =jH5`R T@{ =jH5`R TлIPKRB322233xw0 ӮJPB>)tL&H$<εZ@677#࿉ –kRU< }}#~ȝ%#RT@{ =jZKm/uQFkDG;)<eɪYT:n5G8)\)"(%Zt3)tj/f)s$i]v\U8|8ocOlRa*3~RRrJэ>ּv>*B=ۨUII# +&8K?y{n̴"TF? >ߪz"KvTLLR$2}{ ;}ڮ]NNnUU믽,- nTUUIO?ܼi ׇ> ڽmURRT?>AÞ[@ݡÏ>^XM:r850?wN__+ƍ gnsz5B\.ӭĚ[O2U:W4Ȟk8C> pN1p|_)yC&|ȩ鬧fg_~>2|)ђK>_Ttquu9z88yjK굵uu)S_D"iSGa7jp"<6ʉ̛+\ys8AD.$d,r8YO͸xj\C7Vߗ)+QkKʴpw5P(UKDJTnL=iMm 133UYmՃsJ!79\nEEŠZǜ6u#=vY3۽"rܥ_;]ĤHd"5ǟnݶK"bM.׋ŽzTu035x[f~њ{Jt0@+/~$D"-YݭEMMaq{n wbj*od2q>Ytcܜjwvkyx/}pl^/>l<)9%''7xHUݻ\C{g{tpg_zuifVvKKKVv˯-H$&&&cG"#G\9k7/??''ۯvqheeտHѣFݭZw77wYk7H4jw0zH#4p@~'"\W;NǞ]/,FDcr\W~ꗌ޿Z[[(hO9РP(t/]X'FU¼ 7V :b[o:dgѰCK^2=Pןo ޽z]eo0ީ\P?n3 p߶K'N>wނQ#Ϙ6nxc'[N/~޻ ~7=gLRZZ6uʤ;W}Ѧ!S'LVQYoiNT r(ӷ|7 K^|9Gi3猝8@qch=5eƬD$6)0/(,,qO_}AD=LܼWq*DyyÝ:;4'Vkk/zvD@r6ܨ,^ySӓҧOǡwWPp(*&tl8 y&D99ڏ="|DtB!6#qrH:džT$<I?UEe5PH5`R T@{&~s`ٚ_owxNy"F>[xwӖvek{_GFHlGP<~.gYq\&/[o/KMrZzkk[gx_W/vԙg'<5uċ]]};vTS*P)00066:|4ՙ˽-,3󎞈21;JOy/ed33Mӟ$r*mShٚt\B|bJy|hsf\`CW*J^=̜bm]sÏ ODxȓ1 Cǎ1tJ%".3\.w# ҜB.GDE_!H]Ϟah(hniٽPv^Aw soO7]"qWS[W[W?0ť{R蕬\hs|bʉ|'ùs ) ti6VSӯdw0+.-745[%m2ٞ qs2`+ٹ 7_YB8/Fı3c|bʕ8|Oԋ9yV/[ƫƺz//f/MWƯ^u{ ?FD</;?7_}aԐCQ^/nlܰ k߬_ 1g ݕKR/ed-^0Vkcտj5oaD{ؑAiTcӡwȓ1-- ̘<íIME&)i/41n^9pqV(Ɯ>G  }=Y Dл@p>1Eϟ;s󬩓Hsq=/^bJvϟ2eEcMT&]4߇|.gfQ^.3$_9ອ5 ջSNYi,KߪUew^=gƦv`7*yܭYruHP(ʆƦ&HLE.g46I>ھCc[b$Rin>քFFTjddZp8v;uDRUDd<8SW'p8b545> H5L.WXZ6 e2*Uڙtj|9wJբʳz)0bPWΜ2lЀ#ΜK "S nTʥmm2'F67ozzzuub"07Ur\kkaG TDDnjuL`wTY&"211|IKK_R  ǻ<Ν{acyǣ ghh(JU CCCYP(jN^|~FV}'s9D MF.#QM}}}ZO=Rڴq (.U88ПY9-J2m}K+JJˉ(.!YyUMMRk+}>_*m,:rexDҫgSRVʱ비$\.mn/׮\"67OI) a$o.k733W_*-; %ņ!f^=r ΞO74 2>xE֖ݯ]Ȫ_8gfN~Afv?ںĔ|sgL|~vBG^| 22']͞BDY9ťeÇ 4ukѱ)i>0WϮwrϻz-&.k?APFD o^OkIӍBg'ǃ9Ό\ZZ[OǝлMv!95!xjX}ҲKYeC ܧ=fNV}Tj{P PTSrUe啣G -/13'oٹW loGDmm1gi|, ŽFOJF IDATy2&Zx@Ѹ$?# p8UG2FIѨDc!|~iYz,Z-8wvaaÿ:bٚ;>U^SӓҧOǡwWҕ E G @\B҄Ox#'j@@gS(i3"M^"R xʪ'\V<O'C!=jH5`R 졛Tmρek//ݹ:R/nXf/u]˙֬O|$r%hrM]5;Z~\0̣?.>1婩'^2Jeq:(Wg.;z"X?d(=MM8yQ":0FqIIik6n"hH`yyLXtIEec7y<)ME&ѱaG^^<ͥI"}g_\ɩsgN9sc)z<^CQ|^w&kSx`rccᴐ~y2ahQ#$KYGkLEW 0a2Dַr<"* D< CCAsK} [{{ںںg'(.ӎJGd F}U=6jj?\PX0C lw#2ܡWO}}p1}u1q̞ѷ}hkD;93Lc3s_{qUUn[6cl,67O03?t6u؊ )E7Ku7}~\'OEDEkvhhlڵ\x (D#=lǎں_nΙ1y˙9?pz<]}g=S/$ED^zni3Kowjmm[ڋο .*.Y0w&w0XccS] X 0Mji$1%MW  E.3&k0{jD/3xT*U56466jmO r8%wB!|>[uZcblؤZnlsn jimQԤ^JUCW2aťs8Ñ'84t`f~}ps I3sBkkií$|bs/_vrnkmID^%"8v<:6;jݵ^ܰv'546D&zY9-J2mZ55I$zp8%e9fLRӯeZw?P(KHpSFUյWQFV.igЛt,,w\{xg="qǍ of*zG`А@[kzmK_zc>i<8; 4 9-}Θr93G.!=ޞn{B:(ϛΘ2ԙiq=},BW_p(?nd(z~C]Vx"}/.NigMD~ޞ 22s,w7kZB')ϜKJ{1>xÙ4~tMmݹ )# =)WV8֯@<'^>y~eh(3bXJeR}g'ϔ c>~}sgN۞SgΩ/,r̞?g=9}M~;{P}x8aE&ƾޞ+S<0陪]2BNd֖Va?&00XzCZnm]5Ҳ5w| 00۹k㈡lIS/e,}鹎ݢnW;_/np맳mïǣc.5' ?zu20_3#O|spAƍ,OM)zza=O5"rUݎ̞1zҗ߯k\#T@{ :Bhws'xg_]' dv/immuKDyWg̚VPpm/88hٛ***HUꆍ-xv5G0:2qfcHss7o|g[Dׯo_SSS+Kˬ& nwn.BΟMt0ij0"= /ѡGVfܼ/evUcMm+ 8/pwޓJO͙GD,|N-t<ٶU{zO?y8wW hk Ʀo۝os;p"ljC#OtZ=Lտr"R<ԧOM޿Y\af":Վowyk~O=س_WVVʷrW:[3h$}E8YVv8]UwBD~d]|J xcSѯ,]0԰#nn//ߢ]]vv]޽˛9QRp8mg'>=Q]|_ؼϜyg[I|fח =:CD+V9u+\tM~2lyO!_K^yMO';W{_k׬%&%rO?ӿS£F] rnۯ7zx['iW^W/[[[%ĝQ-;99-ya1g+ TwsuI~+/HDχGDJJKS.7fD?=4ugbڷV¢>٢YߟD"_ʪo.29.$%GD^-(066&={펼YTZV-4xzz]\QYv<vT/;{ohh { =xh}8Q[~}՝ٻw>off*+\#ϝrf66D}0̻o7j#GCCfj^^2vL057H$KC >qʒd2Aԝ,-^NE'!"07W|X[ݻҲ2" WkLD;zO##C'Dddh[XzS{Pؑ7/~o_'ss3oY9URZfi]66DՊ֪2"]g -ﷴlmmTX[_θ֯H$_˯tͪOϝMO/>pgDldhZ :"-tyr`qcǨ vl0)9Ёr2\5WrQyE%uMښF vGFN"d=Lmv}JJKUSTtgȡI ݺY>hWdԪVvvw.Bo7h``foBheeEDؑ;QYYݺwx_n_.]Ll?a624HU M][ v_-pxb?ի֞Rn(>ST/_nTtLv}sn$'_W2oʤW_^2_M}=F5rļshnw߅g'wRPo?o53004i{K3Ѯ_~Z`͛%R*sz`u?ՅOw.~ K^|^s[T*P!ݭÆL}̵k7 bN6}'[e)B>XƎϽŽ|}W;^~m˗;;}ٳ/a |mG ]S&ٵzei qdS$1 3=teklUݢ G=jH5`R Tx) +ˮRPd(\/^/H5']А. ׋NDz;+[c<|ȱMiO|l-Ç՘H5/Bљ}$ɷ5!=jH5`R T@{ =jH5`R jourǹKTseJxHZqT@{ =jH5`R T@{ =jH5EP1E*mag3w攞v0 {X 33Ѽ9q׉Lp[Zv;Wӭ]U2|廛rێ?x}mmc7y<)ME&RWUKGH!42z'#;⥌a4XdU5 _Hy{kQQ*S -̃G {8ĩ3(HaZ8U罡GShٚLվ۸C66I/gfX?#^+r¹ZCR鷽Ufw"w(ʒᤦ_/R ty^MTSWW/n "==]HI>gں}"(%r^nr0 yVݻ͞>=Jޗ{sfLt "<~%{@)$J[b"{%r|(.3c3s~m0RSR/;jU5fx͢^&d!"a?oXڕ}\B('/.|LǾC9yÇ TlÇst .qQRj[?7K~ޞnYi}hwW¬|UFak3cN4V/L>|-ղeD|aOw_cdd^tSv6cGQN~KUuEޞn.NDtt\bJZו<>?-dǫ9~\*c߳ {]!nh<>ߧ?\x)J SS'{_mmD!"= F ogdfC--Bg^]#OLΜ׽_%=`HdLDv6VGnY Dлz^=.Z-%rEeݶO~{ێ@nx{(/i!D$y{f]neM<޲jW[ Jaں}"Kr{;LLH"W(#+wպD$ˉ"jj T'LE9TW/&"uWՉl"RׯI_/*R.ȼY>0Lbxe=^"݊K5SN,21V]bf&"{y7""Re  D$W}tO6(6BέHo>80;bE@DEDA EDc5Fhlh[X{޻Q@"hWprO=t;MZJW+ˎ_%Lż%IJQ,_$\"244K ԴInB%\ rsR)ǓV9y&af.J\d,-3䏐edgG{gG{"ZfGwg?lRbٹgeq|@/(VBL>mms3S5Dً̬l"z ܥzz:>135Pd]:/RR9zl+f>6nt!ھ5;شWjZ =xnM4-͵Eo۽_K HK Vz?̬oGDujĽJIȬSFvm\:xl׾.OHD>/e[P]uשi[wt*2rh2^Oow5;o۽o-; {M1G~3Z6wzp}G8ys_0}y6fdf]rxoK:={ٲc/_oҬsXPhum#k*[^.=tlPdO-6n>g-;'|<22nݵaU/gm[zS4b\lҨwCt ߴmY LZp~rN<*Md.I)>eի@~g~^(+pT@{ =jH5/T*/?R t_%TUKU/ՁTҵov* :UQ}M~b|_Yȉ'ΫoTgcd{sIהqxzz볨VN-=zz%nиOW}vD$N|WZ|XSu\[R=|oST5kpI3/]};kd6mACUQg:5ڡ7)s>ԫ FlqDOm?󽍿t=:l\!G^K-Hn=p":{D|-`l!=o?ҌlW 9m&ML{0' ExqBiѸsv*|C&FKw_nr)Ǝg·-lbG8E 8}s 4>LʡݎSౢ${neݽm(m2\k- ?Wοz"Iȉ٣rhz6mI9.( *PӘ@ksm]v 7x6/r_%`^Ϊ[>ڮk\G_GE譝a ӺUmA%u!к>g\>wJmK]so7"jllע7=~^Z\&) 5-r.]͏l y⟉˓/ժo+~Fptt0GtwȲs o߳FD\C7j!*?n.v].yu66ODa}˺pdv8r<O_502)*ZH/~䷿Bi1ϘT*0S 7k㶔)ۻ[56,~'{nYA!q8ļ1ؼV?\` ]$ ;?}W #kgբ-j8D6Pb$0X5sޭŹD.Ǐҽ#"ݰ&{U^jҧkZտݨcxf&=_E~37 0/s&uI&kl]gXΘ<Ju[:tAB8 Ô(W4&!Jbw~ ֮꾀 z̿p lp  q̗UYMyAp)l_seU R12T?\@{ =4jbi`d+I*eF"aBE`gH434j.XfڃOo p#[![q|aL(Rܾ36534so5!Kp#"G2q gmVs[>f1->(7N6ƶ&! "\^+N򌍌{FklBi2I]*g$M]>OBUHv#O{ =jH5`R T@{ =jH5`p*+(**x*H5== +**UTI|TZ<OWWT隊TLi`R T@{ =jH5`R T@{ =jH5`R _ {4OT'dbz1|>127U]tQ 7/@OWK:JX##" dnՓo'Rd1 #ɴ1%d*ﱕL5#HT[ È%RSz@{ =jH5`R T@{ =4|s9Q]:n]{accTVm< T.4a܀P&ntD$Is<|͛6oqʤݺhpCmbD222VI@L5}}W҈bX=(#GjYYVԮ능ѿ,d¸j2a_+VnԷ&Nnݳwdԕ D/ tiզG.Ye\\BfV~-<ƌտPأO:u=+;~梥}}: NᵵUP>z1#33j7mlVZs; FУ{qGgd0~7у5j iyB32fޝZf]^=WF#3؉._-Yܬٯ˗kkۦp=׫Ww82#"QZm*{lћ$U<kZZQfV6ݻ:x{)6n԰e ŏ/\skZ M\rE} {E/Xx?MkҸr˱D(x^B'O^K"++nܸ%+EDbO qED5kܵ{EL&c"b{K5STH "K ׿qurrHҭ]n˯REv[cvD14ĢԷDdmXiID)s3Qp!Shђe,+r*#ծ^ND7$mXl3---|w,Т<0Yıt/x׊x+g7cKsƔU*Dei:~=DqTD`T{ѕל5kkKD535]x@ W}?' /ml?yTG ƌ7_|X[[QrJA"YX[wiѝ*ӧFpL'/Ie(**ڼ52: :8Q҃xHD==b%䧟g;qR>3 3y7II ̫YRe/O":|䘢D,>s̬iSnU˖z?Ptwyffe"Oj"hY?8{y;;)if޿~]֣7~#(g/\jɱMԵm׶ͱ'f3mZ5qs{U^!_<{nRRR "hk̎[vVss͚={ٳ%Dwlڧw̎g;0o˶WsgTQ__ogύ4o5 M훘O(MCgme?OkR?vD*c6z'N͞7?fʂysF/Ю{Ι5ӗcoVnsk?ۆ 6[UQS>{1Nt iYvuT|twf`i?)~,..vFZ64'5K[[{-ECC mm̟Wu}~W -{ڗ 0M7TS/s_ ,H5`R T@{ =j?2!!>-=#SI&OW =jH5`R T@{ =jH5`R T@{ =jH5`fRmo7vwlnܭm#"^pĨMUii/cBr?1C.k)9??i  6bdZZ:P4/]񦗿K_vr^{Б |5T b˱s U(TQި#G/pђe%\z=?E~7[>a?&Aw٧˗q;w;9P^87  tQҕxxxv 2uZ//߀NJJ"=v¼ ;GVl5K^6oRTTԬGUN fO?շ_Cc0|T|k׭շr;w5DW^k7 lݶ=4 01wFF bFҳqv0޾GH$2s[d2o?076kxr>88x20Gppn)+$&kTWsa&''' 'oKy ̛0̓Ϛ88+ZSt73 x^}H1&Μ5Wo;Z>LcPh|T/(2jX,.**jު̓32\Z]zax?y IIImrWω^0y4aR/|ĨBYw7pE"QaaapXm1ݥ7".>ADzع{Xs^Kd&/O1ڹ{OcDGb*u=@GML|+4Gola̛ L2 +t\b27 -[ysK=v򕫄yy 8&0 eܰ#e2k]\32f&fFM<}acO8h-ʟ: U'OĊw3h}[^0̟[w.o!2Þ5vps0Nmݶ}Y;QVTU"VVVD':|hXhpZ6DԫgA_ZBa<7IJJNJN~حKg"rmҾIuttb/hז{}̬ko|=+"222ֵGhU#KD6֍6W'qB9z<?Б#H.􁈼{rܺum{|--ZlRRR.]]VDTnݶm_`oߤv-݅Gzz573Smn޼}qcF3|>zj DԷw3οwܣ{W===mmmIIݥ9Ʒo޽/";`mmuȱ,[vZ-|^) :IRrr\|BDx'"j԰a-hklݶ=>!ήޯ˗r8V-ϟ9anfFD^ 6]v-?Dr=~hȲJfii\YV,-,۸QxǰϞ?{7<|sD'*{:Էܹ9:4MOOvSRRx:0?Gry_W1jO411Vse9=C>""255),,/4kdRej1=#(fddw?r?ϝjͺfddk̨9bݲ6j8T&#lyI>dgp\Zosͺ#ƍ,,\r"166Qut@V+ME7HծVs?o޳wgϝa*{:(6#"L}zu?tPQgҢwS&MP^/rلaX\XXCD_WOS׭߸wZ66y"se5eiaADGWd$eeen{zQJqC2//O' x3/&}x5R.3+QD1cDϟCej"٧d~X|OӦKSFK-G100LfV)edfZXP3#pǏO8xCӦAjnT"`K.52d¼ي^)155Uw@V* W֮Vsݷ_];OqC3'GkkP+YYKػo($())Dc)mr)D X[ZZȯܺ}OedfYXZX[YI_Wr|MqmF"H$ .>ul0W*:9:^I@ %R4+":~d?7ՕnڼU=@D풒\FD/^zO:4h`aP`ωtՊF5&zά":>zL~qՃF:a*{CD#[vp߀¢iS{ڥDr!As8?K-uo@H=}}wN?rU)ݬ@C~L{06e]FUy¸1m<ޱog.pM[maUÔ+4o%,oΚ?s3_ xpذ#Ǐo?Rs!fe\y|^;JTU)|K ]wںNq^8S&{=d^}v*u ESFrל9Yí7C37A@E2a,;==kh`wNKmi5qv]q@5Re(yh/"Ĩ䅌Dݨu]JDl뤄~*8UBzTܼk9Ƭ閮VR"0jP+++RY݇ɣh;4jeTrC+v>_&8)" SGK$Wڨspomed]^n9cbZ.H*mYy-K<ܒwx}ֿL_ZKDF+mDPp)QlWò[`k5׫ uRVT=jH5`R CH$;Oorl{ByFw|zS}_eJ3W gmV̓ی{FTuGh0ȇ2Wf5Ҧf lyF=# /PQ4v-00ȇHj1 ?Ymv 4O{ =jH5`R T@{ =j%Sa>>27>0Lpx\NaQq Sz+Fٹy6 sF*rT rD2L,Vp% SMawUT^|^d?R T@{ =jÑb(IENDB`flipper-0.21.0/docs/ui/images/features.png000066400000000000000000000373411404600161700204000ustar00rootroot00000000000000PNG  IHDRLwSgsBITOtEXtSoftwaregnome-screenshot> IDATxgXwQv]ZPPĆ {أ&$FccIl7.bbEiJQQ{a̙3T8# (-9-Bx !Bo!r[9bbrEV\&G[UH$02LjTT"Yr@R|:;5=ȹWf&H8H,L2"rDZ,Oe"r <|49Za [[r[9-Bx !Bo!r[9wO:nW--VQǪ4I s.:m}t7m޸%-=#E\j^( 9=JMKWE'N{mZ}vۜ 'i1)5&"qd-3R߹-մ/x~htY-`OD<=u.$;;IHJb dRc"ʔ W]>i\ޤA=6pTX$d/{Q7u#";YnhQdJ &DDwǽ „?xYʇKDDf&&Do ^IT\uժT"yeU@?Tju޲.  ڷh:)9U*sYニw*y0h+ԥSkYQKWQKSс {بrES.˲{aV8 (/l" ,,n߻)ΰ9Z킥аCP~ɴ!\JG*вjCLP3g(Jݑ6wA3-j]+4k nߺšcm=V9K~2c[k6Vrsq:"ЯEgk4 Wj5##0oYLCYV^*< x !Bo!r[9-Bx !Uhq'->H$*0BRgNUK "YJS4-COJij"-OQo! ""+C`YV->X,0TflTDwqELX1X!9-[9-Bx !Bo!r[9-Bx !Bo!r[9-QZmZZRjeV@qBcccsssPXX%%%iZT*KBh P()OQ+piRTRѧcYV"tU%C"}Bq0@Rx !Bo!z |R#ROS}R,8w+6dLE| GDOSO]Gr);<#2~N.9୒Qii݄! T*WWZX1ڵKdRf}N|U*!']#넆]afgg _{:g<6^}4v';\ 4l-6eђerܽU;22^, ߨY>UDtA{͝n&MjܼWmv~ =Q'N_u7m7 --hGYafI~ȠZבr;355 *TqryCR^G+V?hǖM.DtQsSRkW^+Uܶi}5m2VܝX"&N(i?~LDw`ÔIvvw/wehhض 2#MLMmbR ~`r|֢GԷ&u"~WСGE2{r7l^fMZjйW,E<}V%JM?yr Rill^LMLUZ ""sc sșOPn k"W~|gnSRƍЩ{z,tF4 MorVwVNB]AD4>֥I,ְn1D&<@ha{ҐTBSЍ8ӟL (6nфFn=m(YI /˟hu2irjWrXz{)3~|Jpǿ_FCD_J}UƺZU˗ukUL..D".Kjܰa^koԮ]$& Kގ6o~-llGtssJO`]Z{1QfMF|F7###B@ڶrW~k֯Yime1eauIӁDDBѤ#DDyw>'uDNDO~;|v-^o"7*h}}XI(340lټYak˕sࡧG5qLJ\۪'5f:v+XwRrrAVʻW$Iƍ4nDDGn7mv7ElP M7ܖ3èMM!mEYRzȋQˋ_%kcKcVJyDHDrd$&"zE2""6ԅ(Ѓ#FЃ$:~"Mk4}=''Ys?AD%" QO""V%"aHwSLP19bש]+g"_qёǦF7"]eJzpD KҳT:vk/HPfJW{atp $hi:< OHCXw:ѵFiygz!Ѵ*Fgu%X=\]9f͙ŋZ5 Ŗm;߸٪Es"*WȑcqP{l5<|,CZ[[ԫۨAcOaCԪMn! 0Wؕk* ߌ%" CG'$*--}ǮDԾ]2}У7/yv%"΋BhQH=P@Tj9gi#Kr~] y-PKBdƵ,_oUk֙<=<֬\S.1 ts-5{.05,ZhgkeFÔI3g9OݻuINIٳo}6]Lv!999P֍Yffʭ,;j-r_ko\t jUYA}1W%eY hvA3ᝬiwGr||Kak-Bx !yp2CPGӦxOհ-<ִu|͂PY]߇w ga&Bo!r[9-Bx7GEEM9ww-$EFF+r[9-Bx !Bo!r[9-Bx !Bo!r[5kVw_(\`YV%D.@oٶSebaZ [w8:9V 57 >jmmm0%:&vףyC{wgRc㱣lޔTNz7<|/+Wp{YkAP4iq\dT?,{M?/9۽wA]qUN/_r`1&qPﺍ[tf-Q6IIɿ6Sޱ_,;SU_/p=cb;;9Λ3aδnގtUcDЧ^LL,hZv(55;ݻQU*찰 &/0LZ z_Opexmάj6ƀf>GP6m>fѨZHyz5t}$bqrJ_WJ%ryXTaRL`b"--NO(Bo=}}ሳ6SRݯSЉO]eNƏۢE."CCsB4o0Ddiaq\ZZ.ޢcbJr(W95le=y gv_yJOF(+ٙb|[4?vTBbE<}ӌYZ-lhh/XVq( =t<~wP`\^z;g&w>.tDͅn6SAjQ3-;Q3OʥTIJjxHDIZX[X2`ICQÇ ~{Nev>,?vMm?6v{;[R*yc:_ddKi5U3JeAmP;lUJ Uze3BD"k)/W&j9k <}H戛{ƞg;5-KLr_@\E!Ŝ߹EWSTu(2Gq>5}lk}yzcӽђk?J{[Hަ/ xF?Z~_kD ̫Ҳ37< R(Q},W@4lNDzt<,7G[Y/j4M\ȕ_U53ٕK_~o GnEg1ȪvrV_o'k6{He;) dւ%K|yٛ*~y!W]yXԴ_l#g{'_ 6Jio\m\e]h]v6|g}x7kT499?}a;sۃLM32jz`9dk4[w8ũBH8nˮ}?D];Y#oMNξ>fruveٝ (ׯS˗fTn+a7tY'H(ܸ}W]&ýً陙30 /S^Yg9ӧt hiq:<.oGO4ղ"k xV;ׯW׺^DYU(4*ڴl۬P($]StXYZTND^}7ڍ;[b"jޤ[i wC"jSr=x!G䌌 kxTzÿm{uXEcT99]0,QRrʁcRSIKOw\QDB13Ss=>j49,*J++K]Ը !W(#ȻTR֭AZ[vPB9 s=Vֿ0AGO52K&5{縙I@֞^{ߞm323 Ksr07OH$ԴȨºURW#(W(+gy\{KLJxEDw?dB9Mkx K!ƭ'Qe7;T,^U|0+TvA^eiiQӳM@Pv3.תY`e+(͝Q`Nv;|丳c^VzCCj.ؼsрtgtZ6ocYK ^nO#g-Xbdd׼I؍, 8p9*:vP.StTqVxW~N ޯcrބÕJB!r[9-Bx !Bo!r[9-Bx !>+:ޗCrӄÕ[9-Bx !Bo!r[9-Bx !Bo!r[9-ܻ<2b̄;]Hu+MLdr!(*:f 8ԡ!D2_DGz{1kEthK Ç۰8jUy*olccoF ?~tbbR>&|CDAl۩ղbh~Z;|r4wk>4!!GA xUgj3Sm Md2"D![D"{-ղ=|ؽJe݀"H.֩[ݺX[[[88huTtmٜa~u99Z`9&&BîkKDRo˼ |  6 >!1a;oCD涶6 ff쏉}8oL$@ifr;e\!M ɼy:Y-}^yU&2:e9"JO055u033lll[F- ,˾1L*[eYP6m>fѨp}J!~n13vjRgz/^8O="?d$##CD8}"|5tjתiiaQ"#@w29BS~4-zpyC)ȔYNDG"ڵj:z|>~2zD\AD[hIDATDW.=RRx_"{_;UKU3'wHV P\] ɤڷ :Uk\]ƌsibQJjxHDIZX7B1.22ťTvVʏ?)+Ѝ\Gc(igk]$:R|抎*}85)W-5Df6.!Ғؖd˾F}c@5O>G6&l$eEuoPUJ=Cv}1CG%ȶ'IM4ɤ%d\_JT'/+{qN*E)t ĞU w;Z2f}]'O.$ů%zA!8 )mA7 9Ym|Yې&b$uFre[]9'+:C" V'>d?Ho_/\f u"mܶ۳jt-, qc};CޯS]e+ֵkQͥ8;zl:uvI㌌ ߷ΧцwÒq{V>y/eR㶾-ԭEDZ3:w GO&"B\Ϛ JSv9/7Wgľ^=)KΛYb_>s[굛/8o)9e-6MƱ|gI9ݭw "rsq235IJIRf _&&X[Zeh-"z(B̪UcOp˗DGNNx=P:bH?]O >q\jZzt3ˡo 07}PfФ@0rh?TwX CN=::eL*ݵc=zIcKk7>|g=hٴa<{qf&a7oڰuD"J R:uibR#ǽkxM4ͺ;^Z0'@  E<=l0oEۍݰ-eӆM'$.XoϢcb"Wn9.!Qrv@DYYYχl0LZ^[ - lfρ#}{vYqJn٤g*G^eg*ً֦U7WrkTNVw[-BOv{Wo>uX5CjٴShHq[g@WW~ڲ5"q_4s3Ӥ䔈gQN*ܻXPR>8㸄#ܺ{q"4{Uy$^ͽRRR ݼ^z5@ Ы[@֯ ~w 3S"SJ~0 ˲>uH"K/\MJ~ikc8wbxt{h3ro%)`RVM"usu*[$\WN鯗g5/j?O?A7n=}6YcGO6_'ƭU kC߮ۼQ}"20ԫUރG-{;[[+"rv,o"ujZݸެQ}X0L&oܾ^3H$*UKeJҫ[{DŽo5:p`k+P\cb_8捐)dRݲL*h4KV>\rerɛ7Zdfʝ*@XDs뛙,+H D&Ȕ3 DvV>~:1)yȡNÝ{L!O#o5l@og5Mdtl劮9ZnFSۧ7˲W]u9Zh"q'W(uP}afjоHk|j{>)R;uNwYNdtL\BbC:D`gDD߸ѻG0VqKWkzV+l_rV"+Y/j4jxVy'<5-e{<{B2+{ܺ;zTj\ᵽǾ[b]JEDh/&f,;uaZ]`mfW$ѱot^e>=8^q\rJ깐+5{=yb5ʺYm|bRNN\075cfjT|"Rԡn@5<Ά\Q[GO}s-guTt+a7Tr5l@R^YZ5}Vݱ~=u&/j7n{k+˼:|>2eGΎ{vPX5ZuKMMdWn#mx?57t$Z^8W]Z4]ZLMd_# YqޢH$IwOWg[lhhٿMbrʊ'vm mOsPwgΈNԴXұo# >1##Æ>u4[}mdˮ΅mWܷ_/&bԬ^?oZ|Ȧ_/5t@a}ײC~ZZP pd˯w]QÕ[9-Bx !Bo!r[9-Bx !Bo!r[wxWu/WG;;9 +r[9-Bx !Bo!r[9-Bx !Bo!r[ .bmbRʬ0߲mg\ ] ޲mVˊŢ7lq/SS{es~_qlGv}YOHs܅^r|* @/=J弅XGE*ߖaҟKk"a/k>4!!GA ԭݥsGy)] |][7Dc'N>{!Ct:;9Λ3DdggU3oubR'6nƯe򥇜P(\ 3ha~Zzm;TY*bhu\iwc*Wr*+ePO,7)wʸBytl[tvr>u ]:ik[1mS |5hosm\S@1}JWWM(y/eMat3%(}>|dr\!F$)JV),[ݝ?ED"(#3?`RNZ*/䒃Y'FLD/r[ۡTTѫgD$H~y5*Rci=ha] ɤڷ :Uk\]7k_<4zE"&R [R*M=Jw:.SdJw:R:!YR)>sEGWFH"DDʇti3HdQpc8\ɩINǼjQ=$2bB"-mIVkG1T)qx\ncF_QVTe^3dח= ?}\zYw:DD~y]q'w!'qQ?zDbOnɪk|e_nO;Ik؂rR>(ۂor$4-A*I!'!MH ^0$ʶ =+r&ENFoD$Nv}~PY(m=V=@²lP8ާ{X,0J˱Z8U\b]{\iVSggO4}|mhhP}72/\f =n✿̅ϢO/Vmv.?tl陙8.^,fQij(d>ԋW<+38cv'ثvQC=kv_&21kf*@,%$&Q\q\wc 6;;{wOr_uQUjւ%Ǝ?;zܥ+aZ573׳-pKlM%W=xG$$&iu|a,IztX>)9e-6uH"K/\MJ~ikc87@@"Qr^*T^:{P$vl~ˮсC^]6oL\&eRF.p_*:e(C.;ŔH޼53ST!SaFFrByC`a'\X|D"+ +M\Bc"z`anf` q,_nt@"ڹ`LMX D0 +LdLn9SH$ogǍ*?ܹdͳT*"իLL^8N21>lBD=YnK%7WB Õ}\HF<+KuԹuk孊KHlSly\=zj9;jMjK.WJb2PFq\ Ϫ7,cρg/P(Pfewu[w32(-Ըk{}lź,t'q@VGEjYݨRm԰MKy:g2jլ7}ZvǞCM<ݸ}U,m;Ȝ?q9;٥Cajh֝,55u_a6߷y%h\ґjyU{ht~_uktٿkY35}=P(?fy{7gy&"Xԫ['9?/ )wEGWo!r[9-Bx !Bo!r[9-Bx !Bo!D,& x_EwxwȽsOWo!r[9-Bx !Bo!r[9-Bxݯ)KORlQ צgTv>'4S]; 䗤ѨM&?vZuJo!8y.řkI۾7%-+{O)/8[<~#r^TݓMP?~V'R:AX41|*BS{s\߹[w>~3^ OlMȤRM{|pRq|EQy,*թi9bةB/jxmߴq͊k׃8nƬݺٱg.^D&ۿ{of H$ vs7[P(?M4!䤭:nYND/߮~Tfd\JNN~]ب}[.'$&>cDTӫzJu]ѿ0V-7{{[ a*WPMҦ I6=3o9mkPeI) Ø>gbcc#]c9{BzzF^rM&=(`Z-&Pv}e ^]XYXpnei)4Z5d&L<1.8&rU+ EtڔY1DdmmUcg^"R(kѬg{Osyi"zzG[ -~F₯4/*f2{z8ld&: z}?pE јQѝeGbotqq)w}-0SAEWY{_Nm/eMsZ޽zv߸}gе7k)&PJoo!1r }FX롃v}Dj[.Z[NNc'ڶ}8 |OU;}"x쨯ݫT&>_ui@>Bq=s>W|S9Pr[9-Bx !Bo!r[9-Bx !UT1 VˬVBaziqzzB-J@hddTDB2--MDj9P(-,,Sԛ>kx !Bo!r[9-JQtIENDB`flipper-0.21.0/examples/000077500000000000000000000000001404600161700150505ustar00rootroot00000000000000flipper-0.21.0/examples/active_record/000077500000000000000000000000001404600161700176615ustar00rootroot00000000000000flipper-0.21.0/examples/active_record/ar_setup.rb000066400000000000000000000013701404600161700220310ustar00rootroot00000000000000require 'bundler/setup' require 'active_record' ActiveRecord::Base.establish_connection({ adapter: 'sqlite3', database: ':memory:', }) ActiveRecord::Base.connection.execute <<-SQL CREATE TABLE flipper_features ( id integer PRIMARY KEY, key text NOT NULL UNIQUE, created_at datetime NOT NULL, updated_at datetime NOT NULL ) SQL ActiveRecord::Base.connection.execute <<-SQL CREATE TABLE flipper_gates ( id integer PRIMARY KEY, feature_key text NOT NULL, key text NOT NULL, value text DEFAULT NULL, created_at datetime NOT NULL, updated_at datetime NOT NULL ) SQL ActiveRecord::Base.connection.execute <<-SQL CREATE UNIQUE INDEX index_gates_on_keys_and_value on flipper_gates (feature_key, key, value) SQL flipper-0.21.0/examples/active_record/basic.rb000066400000000000000000000005111404600161700212640ustar00rootroot00000000000000require_relative "./ar_setup" # Requires the flipper-active_record gem to be installed. require 'flipper/adapters/active_record' Flipper[:stats].enable if Flipper[:stats].enabled? puts "Enabled!" else puts "Disabled!" end Flipper[:stats].disable if Flipper[:stats].enabled? puts "Enabled!" else puts "Disabled!" end flipper-0.21.0/examples/active_record/internals.rb000066400000000000000000000063751404600161700222200ustar00rootroot00000000000000require_relative "./ar_setup" # Requires the flipper-active_record gem to be installed. require 'flipper/adapters/active_record' # Register a few groups. Flipper.register(:admins) { |thing| thing.admin? } Flipper.register(:early_access) { |thing| thing.early_access? } # Create a user class that has flipper_id instance method. User = Struct.new(:flipper_id) Flipper[:stats].enable Flipper[:stats].enable_group :admins Flipper[:stats].enable_group :early_access Flipper[:stats].enable_actor User.new('25') Flipper[:stats].enable_actor User.new('90') Flipper[:stats].enable_actor User.new('180') Flipper[:stats].enable_percentage_of_time 15 Flipper[:stats].enable_percentage_of_actors 45 Flipper[:search].enable puts 'all rows in features table' pp Flipper::Adapters::ActiveRecord::Feature.all # [#, # #] puts puts 'all rows in gates table' pp Flipper::Adapters::ActiveRecord::Gate.all # [#, # #, # #, # #, # #, # #, # #, # #, # #] puts puts 'flipper get of feature' pp Flipper.adapter.get(Flipper[:stats]) # flipper get of feature flipper-0.21.0/examples/basic.rb000066400000000000000000000005111404600161700164530ustar00rootroot00000000000000require 'bundler/setup' require 'flipper' # check if search is enabled if Flipper.enabled?(:search) puts 'Search away!' else puts 'No search for you!' end puts 'Enabling Search...' Flipper.enable(:search) # check if search is enabled if Flipper.enabled?(:search) puts 'Search away!' else puts 'No search for you!' end flipper-0.21.0/examples/cloud/000077500000000000000000000000001404600161700161565ustar00rootroot00000000000000flipper-0.21.0/examples/cloud/app.ru000066400000000000000000000006751404600161700173160ustar00rootroot00000000000000# Usage (from the repo root): # env FLIPPER_CLOUD_TOKEN= FLIPPER_CLOUD_SYNC_SECRET= bundle exec rackup examples/cloud/app.ru -p 9999 # env FLIPPER_CLOUD_TOKEN= FLIPPER_CLOUD_SYNC_SECRET= bundle exec shotgun examples/cloud/app.ru -p 9999 # http://localhost:9999/ require 'bundler/setup' require 'flipper/cloud' Flipper.configure do |config| config.default { Flipper::Cloud.new } end run Flipper::Cloud.app flipper-0.21.0/examples/cloud/basic.rb000066400000000000000000000005401404600161700175630ustar00rootroot00000000000000# Usage (from the repo root): # env FLIPPER_CLOUD_TOKEN= bundle exec ruby examples/cloud/basic.rb require 'bundler/setup' require 'flipper/cloud' Flipper[:stats].enable if Flipper[:stats].enabled? puts 'Enabled!' else puts 'Disabled!' end Flipper[:stats].disable if Flipper[:stats].enabled? puts 'Enabled!' else puts 'Disabled!' end flipper-0.21.0/examples/cloud/import.rb000066400000000000000000000006501404600161700200160ustar00rootroot00000000000000# Usage (from the repo root): # env FLIPPER_CLOUD_TOKEN= bundle exec ruby examples/cloud/import.rb require 'bundler/setup' require 'flipper' require 'flipper/cloud' Flipper.enable(:test) Flipper.enable(:search) Flipper.enable_actor(:stats, Flipper::Actor.new("jnunemaker")) Flipper.enable_percentage_of_time(:logging, 5) cloud = Flipper::Cloud.new # makes cloud identical to memory flipper cloud.import(Flipper) flipper-0.21.0/examples/configuring_default.rb000066400000000000000000000010371404600161700214140ustar00rootroot00000000000000require 'bundler/setup' require 'flipper' # sets up default adapter so Flipper works like Flipper::DSL Flipper.configure do |config| config.adapter { Flipper::Adapters::Memory.new } end puts Flipper.enabled?(:search) # => false Flipper.enable(:search) puts Flipper.enabled?(:search) # => true Flipper.disable(:search) enabled_actor = Flipper::Actor.new("1") disabled_actor = Flipper::Actor.new("2") Flipper.enable_actor(:search, enabled_actor) puts Flipper.enabled?(:search, enabled_actor) puts Flipper.enabled?(:search, disabled_actor) flipper-0.21.0/examples/dsl.rb000066400000000000000000000035661404600161700161710ustar00rootroot00000000000000require 'bundler/setup' require 'flipper' # create a thing with an identifier class Person < Struct.new(:id) include Flipper::Identifier end person = Person.new(1) puts "Stats are disabled by default\n\n" # is a feature enabled puts "flipper.enabled? :stats: #{Flipper.enabled? :stats}" # is a feature on or off for a particular person puts "Flipper.enabled? :stats, person: #{Flipper.enabled? :stats, person}" # get at a feature puts "\nYou can also get an individual feature like this:\nstats = Flipper[:stats]\n\n" stats = Flipper[:stats] # is that feature enabled puts "stats.enabled?: #{stats.enabled?}" # is that feature enabled for a particular person puts "stats.enabled? person: #{stats.enabled? person}" # enable a feature by name puts "\nEnabling stats\n\n" Flipper.enable :stats # or, you can use the feature to enable stats.enable puts "stats.enabled?: #{stats.enabled?}" puts "stats.enabled? person: #{stats.enabled? person}" # oh, no, let's turn this baby off puts "\nDisabling stats\n\n" Flipper.disable :stats # or we can disable using feature obviously stats.disable puts "stats.enabled?: #{stats.enabled?}" puts "stats.enabled? person: #{stats.enabled? person}" puts # get an instance of the percentage of time type set to 5 puts Flipper.time(5).inspect # get an instance of the percentage of actors type set to 15 puts Flipper.actors(15).inspect # get an instance of an actor using an object that responds to flipper_id responds_to_flipper_id = Struct.new(:flipper_id).new(10) puts Flipper.actor(responds_to_flipper_id).inspect # get an instance of an actor using an object thing = Struct.new(:flipper_id).new(22) puts Flipper.actor(thing).inspect # register a top level group admins = Flipper.register(:admins) { |actor| actor.respond_to?(:admin?) && actor.admin? } puts admins.inspect # get instance of registered group by name puts Flipper.group(:admins).inspect flipper-0.21.0/examples/enabled_for_actor.rb000066400000000000000000000013571404600161700210330ustar00rootroot00000000000000require 'bundler/setup' require 'flipper' # Some class that represents what will be trying to do something class User attr_reader :id def initialize(id, admin) @id = id @admin = admin end def admin? @admin end # Must respond to flipper_id alias_method :flipper_id, :id end user1 = User.new(1, true) user2 = User.new(2, false) Flipper.register :admins do |actor| actor.admin? end Flipper.enable :search Flipper.enable_actor :stats, user1 Flipper.enable_percentage_of_actors :pro_stats, 50 Flipper.enable_group :tweets, :admins Flipper.enable_actor :posts, user2 pp Flipper.features.select { |feature| feature.enabled?(user1) }.map(&:name) pp Flipper.features.select { |feature| feature.enabled?(user2) }.map(&:name) flipper-0.21.0/examples/group.rb000066400000000000000000000014241404600161700165320ustar00rootroot00000000000000require 'bundler/setup' require 'flipper' stats = Flipper[:stats] # Register group Flipper.register(:admins) do |actor| actor.respond_to?(:admin?) && actor.admin? end # Some class that represents actor that will be trying to do something class User attr_reader :id def initialize(id, admin) @id = id @admin = admin end # Must respond to flipper_id alias_method :flipper_id, :id def admin? @admin == true end end admin = User.new(1, true) non_admin = User.new(2, false) puts "Stats for admin: #{stats.enabled?(admin)}" puts "Stats for non_admin: #{stats.enabled?(non_admin)}" puts "\nEnabling Stats for admins...\n\n" stats.enable_group :admins puts "Stats for admin: #{stats.enabled?(admin)}" puts "Stats for non_admin: #{stats.enabled?(non_admin)}" flipper-0.21.0/examples/group_dynamic_lookup.rb000066400000000000000000000031541404600161700216310ustar00rootroot00000000000000require 'bundler/setup' require 'flipper' stats = Flipper[:stats] # Register group Flipper.register(:enabled_team_member) do |actor, context| combos = context.actors_value.map { |flipper_id| flipper_id.split(";", 2) } team_names = combos.select { |class_name, id| class_name == "Team" }.map { |class_name, id| id } teams = team_names.map { |name| Team.find(name) } teams.any? { |team| team.member?(actor) } end # Some class that represents actor that will be trying to do something class User < Struct.new(:id) include Flipper::Identifier end class Team include Flipper::Identifier attr_reader :name def self.all @all ||= {} end def self.find(name) all.fetch(name.to_s) end def initialize(name, members) @name = name.to_s @members = members self.class.all[@name] = self end def id @name end def member?(actor) @members.map(&:id).include?(actor.id) end end jnunemaker = User.new("jnunemaker") jbarnette = User.new("jbarnette") aroben = User.new("aroben") core_app = Team.new(:core_app, [jbarnette, jnunemaker]) feature_flags = Team.new(:feature_flags, [aroben, jnunemaker]) stats.enable_actor jbarnette actors = [jbarnette, jnunemaker, aroben] actors.each do |actor| if stats.enabled?(actor) puts "stats are enabled for #{actor.id}" else puts "stats are NOT enabled for #{actor.id}" end end puts "enabling team_actor group" stats.enable_actor core_app stats.enable_group :enabled_team_member actors.each do |actor| if stats.enabled?(actor) puts "stats are enabled for #{actor.id}" else puts "stats are NOT enabled for #{actor.id}" end end flipper-0.21.0/examples/group_with_members.rb000066400000000000000000000030271404600161700213000ustar00rootroot00000000000000require 'bundler/setup' require 'flipper' stats = Flipper[:stats] # Register group Flipper.register(:team_actor) do |actor| actor.is_a?(TeamActor) && actor.allowed? end # Some class that represents actor that will be trying to do something class User < Struct.new(:id) include Flipper::Identifier end class Team attr_reader :name def initialize(name, members) @name = name @members = members end def id @name end def member?(actor) @members.include?(actor) end end class TeamActor def initialize(team, actor) @team = team @actor = actor end def allowed? @team.member?(@actor) end def flipper_id "TeamActor:#{@team.id}:#{@actor.id}" end end jnunemaker = User.new(1) jbarnette = User.new(2) aroben = User.new(3) core_app = Team.new(:core_app, [jbarnette, jnunemaker]) feature_flags = Team.new(:feature_flags, [aroben, jnunemaker]) core_nunes = TeamActor.new(core_app, jnunemaker) core_roben = TeamActor.new(core_app, aroben) if stats.enabled?(core_nunes) puts "stats are enabled for jnunemaker" else puts "stats are NOT enabled for jnunemaker" end if stats.enabled?(core_roben) puts "stats are enabled for aroben" else puts "stats are NOT enabled for aroben" end puts "enabling team_actor group" stats.enable_group :team_actor if stats.enabled?(core_nunes) puts "stats are enabled for jnunemaker" else puts "stats are NOT enabled for jnunemaker" end if stats.enabled?(core_roben) puts "stats are enabled for aroben" else puts "stats are NOT enabled for aroben" end flipper-0.21.0/examples/importing.rb000066400000000000000000000020441404600161700174050ustar00rootroot00000000000000require 'bundler/setup' require_relative 'active_record/ar_setup' require 'flipper' require 'flipper/adapters/redis' require 'flipper/adapters/active_record' # Say you are using redis... redis_adapter = Flipper::Adapters::Redis.new(Redis.new) redis_flipper = Flipper.new(redis_adapter) # And redis has some stuff enabled... redis_flipper.enable(:search) redis_flipper.enable_percentage_of_time(:verbose_logging, 5) redis_flipper.enable_percentage_of_actors(:new_feature, 5) redis_flipper.enable_actor(:issues, Flipper::Actor.new('1')) redis_flipper.enable_actor(:issues, Flipper::Actor.new('2')) redis_flipper.enable_group(:request_tracing, :staff) # And you would like to switch to active record... ar_adapter = Flipper::Adapters::ActiveRecord.new ar_flipper = Flipper.new(ar_adapter) # NOTE: This wipes active record clean and copies features/gates from redis into active record. ar_flipper.import(redis_flipper) # active record is now identical to redis. ar_flipper.features.each do |feature| pp feature: feature.key, values: feature.gate_values end flipper-0.21.0/examples/individual_actor.rb000066400000000000000000000010741404600161700207170ustar00rootroot00000000000000require 'bundler/setup' require 'flipper' stats = Flipper[:stats] # Some class that represents what will be trying to do something class User attr_reader :id def initialize(id) @id = id end # Must respond to flipper_id alias_method :flipper_id, :id end user1 = User.new(1) user2 = User.new(2) puts "Stats for user1: #{stats.enabled?(user1)}" puts "Stats for user2: #{stats.enabled?(user2)}" puts "\nEnabling stats for user1...\n\n" stats.enable(user1) puts "Stats for user1: #{stats.enabled?(user1)}" puts "Stats for user2: #{stats.enabled?(user2)}" flipper-0.21.0/examples/instrumentation.rb000066400000000000000000000016731404600161700206470ustar00rootroot00000000000000require 'bundler/setup' require 'securerandom' require 'active_support/notifications' class FlipperSubscriber def call(*args) event = ActiveSupport::Notifications::Event.new(*args) puts event.inspect end ActiveSupport::Notifications.subscribe(/flipper/, new) end require 'flipper' require 'flipper/adapters/instrumented' # pick an adapter adapter = Flipper::Adapters::Memory.new # instrument it if you want, if not you still get the feature instrumentation instrumented = Flipper::Adapters::Instrumented.new(adapter, :instrumenter => ActiveSupport::Notifications) # get a handy dsl instance flipper = Flipper.new(instrumented, :instrumenter => ActiveSupport::Notifications) # grab a feature search = flipper[:search] perform = lambda do # check if that feature is enabled if search.enabled? puts 'Search away!' else puts 'No search for you!' end end perform.call puts 'Enabling Search...' search.enable perform.call flipper-0.21.0/examples/memoizing.rb000066400000000000000000000017061404600161700173770ustar00rootroot00000000000000require 'bundler/setup' require 'flipper' require 'flipper/adapters/operation_logger' require 'flipper/instrumentation/log_subscriber' Flipper.configure do |config| config.adapter do # pick an adapter, this uses memory, any will do Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new) end end Flipper.enable(:foo) Flipper.enable(:bar) Flipper.disable(:baz) Flipper.disable(:wick) # reset the operation logging adapter to empty for clarity Flipper.adapter.reset # Turn on memoization (the memoizing middleware does this per request). Flipper.memoize = true # Preload all the features. Flipper.preload_all # Do as many feature checks as your heart desires. %w[foo bar baz wick].each do |name| Flipper.enabled?(name) end # See that only one operation exists, a get_all (which is the preload_all). pp Flipper.adapter.operations # [#] flipper-0.21.0/examples/mongo/000077500000000000000000000000001404600161700161675ustar00rootroot00000000000000flipper-0.21.0/examples/mongo/basic.rb000066400000000000000000000005451404600161700176010ustar00rootroot00000000000000require 'bundler/setup' require 'logger' ENV["FLIPPER_MONGO_URL"] ||= "mongodb://127.0.0.1:#{ENV["MONGODB_PORT"] || 27017}" require 'flipper/adapters/mongo' Flipper[:stats].enable if Flipper[:stats].enabled? puts "Enabled!" else puts "Disabled!" end Flipper[:stats].disable if Flipper[:stats].enabled? puts "Enabled!" else puts "Disabled!" end flipper-0.21.0/examples/mongo/internals.rb000066400000000000000000000026231404600161700205160ustar00rootroot00000000000000require 'bundler/setup' require 'pp' require 'logger' ENV["FLIPPER_MONGO_URL"] ||= "mongodb://127.0.0.1:#{ENV["MONGODB_PORT"] || 27017}" require 'flipper/adapters/mongo' # Register a few groups. Flipper.register(:admins) { |thing| thing.admin? } Flipper.register(:early_access) { |thing| thing.early_access? } # Create a user class that has flipper_id instance method. User = Struct.new(:flipper_id) Flipper[:stats].enable Flipper[:stats].enable_group :admins Flipper[:stats].enable_group :early_access Flipper[:stats].enable_actor User.new('25') Flipper[:stats].enable_actor User.new('90') Flipper[:stats].enable_actor User.new('180') Flipper[:stats].enable_percentage_of_time 15 Flipper[:stats].enable_percentage_of_actors 45 Flipper[:search].enable puts 'all docs in collection' pp Flipper.adapter.adapter.collection.find.to_a # all docs in collection # [{"_id"=>"stats", # "actors"=>["25", "90", "180"], # "boolean"=>"true", # "groups"=>["admins", "early_access"], # "percentage_of_actors"=>"45", # "percentage_of_time"=>"15"}, # {"_id"=>"flipper_features", "features"=>["stats", "search"]}, # {"_id"=>"search", "boolean"=>"true"}] puts puts 'flipper get of feature' pp Flipper.adapter.get(Flipper[:stats]) # flipper get of feature # {:boolean=>"true", # :groups=>#, # :actors=>#, # :percentage_of_actors=>"45", # :percentage_of_time=>"15"} flipper-0.21.0/examples/percentage_of_actors.rb000066400000000000000000000013771404600161700215610ustar00rootroot00000000000000require 'bundler/setup' require 'flipper' stats = Flipper[:stats] # Some class that represents what will be trying to do something class User < Struct.new(:id) include Flipper::Identifier end total = 100_000 # create array of fake users users = (1..total).map { |n| User.new(n) } perform_test = lambda { |number| Flipper.enable_percentage_of_actors :stats, number enabled = users.map { |user| Flipper.enabled?(:stats, user) ? true : nil }.compact actual = (enabled.size / total.to_f * 100).round(3) puts "percentage: #{actual.to_s.rjust(6, ' ')} vs #{number.to_s.rjust(3, ' ')}" } puts "percentage: Actual vs Hoped For" [0.001, 0.01, 0.1, 1, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 99, 100].each do |number| perform_test.call number end flipper-0.21.0/examples/percentage_of_actors_enabled_check.rb000066400000000000000000000012041404600161700243550ustar00rootroot00000000000000require 'bundler/setup' require 'flipper' # Some class that represents what will be trying to do something class User attr_reader :id def initialize(id) @id = id end # Must respond to flipper_id alias_method :flipper_id, :id end # checking a bunch total = 20_000 enabled = [] percentage_enabled = 10 feature = Flipper[:data_migration] feature.enable_percentage_of_actors 10 (1..total).each do |id| user = User.new(id) if feature.enabled? user enabled << user end end p actual: enabled.size, expected: total * (percentage_enabled * 0.01) # checking one user = User.new(1) p user_1_enabled: feature.enabled?(user) flipper-0.21.0/examples/percentage_of_actors_group.rb000066400000000000000000000021301404600161700227610ustar00rootroot00000000000000# This example shows how to setup a group that enables a feature for a # percentage of actors. It could be combined with other logic to enable a # feature for actors in a particular location or on a particular plan, but only # for a percentage of them. The percentage is a constant, but could easily be # plucked from memcached, redis, mysql or whatever. require 'bundler/setup' require 'flipper' # Some class that represents what will be trying to do something class User < Struct.new(:id) include Flipper::Identifier end PERCENTAGE = 50 Flipper.register(:experimental) do |actor| if actor.respond_to?(:flipper_id) Zlib.crc32(actor.flipper_id.to_s) % 100 < PERCENTAGE else false end end # enable the experimental group Flipper.enable_group :stats, :experimental # create a bunch of fake users and see how many are enabled total = 10_000 users = (1..total).map { |n| User.new(n) } enabled = users.map { |user| Flipper.enabled?(:stats, user) ? true : nil }.compact # show the results actual = (enabled.size / total.to_f * 100).round puts "percentage: #{actual} vs hoped for: #{PERCENTAGE}" flipper-0.21.0/examples/percentage_of_time.rb000066400000000000000000000011561404600161700212170ustar00rootroot00000000000000require 'bundler/setup' require 'flipper' logging = Flipper[:logging] perform_test = lambda do |number| logging.enable_percentage_of_time number total = 100_000 enabled = [] disabled = [] enabled = (1..total).map { |n| logging.enabled? ? true : nil }.compact actual = (enabled.size / total.to_f * 100).round(3) # puts "#{enabled.size} / #{total}" puts "percentage: #{actual.to_s.rjust(6, ' ')} vs #{number.to_s.rjust(3, ' ')}" end puts "percentage: Actual vs Hoped For" [0.001, 0.01, 0.1, 1, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 99, 100].each do |number| perform_test.call number end flipper-0.21.0/examples/redis/000077500000000000000000000000001404600161700161565ustar00rootroot00000000000000flipper-0.21.0/examples/redis/basic.rb000066400000000000000000000004221404600161700175620ustar00rootroot00000000000000require 'bundler/setup' require 'logger' require 'flipper/adapters/redis' Flipper[:stats].enable if Flipper[:stats].enabled? puts "Enabled!" else puts "Disabled!" end Flipper[:stats].disable if Flipper[:stats].enabled? puts "Enabled!" else puts "Disabled!" end flipper-0.21.0/examples/redis/internals.rb000066400000000000000000000030001404600161700204730ustar00rootroot00000000000000require 'bundler/setup' require 'pp' require 'logger' require 'flipper/adapters/redis' client = Redis.new # Register a few groups. Flipper.register(:admins) { |thing| thing.admin? } Flipper.register(:early_access) { |thing| thing.early_access? } # Create a user class that has flipper_id instance method. User = Struct.new(:flipper_id) Flipper[:stats].enable Flipper[:stats].enable_group :admins Flipper[:stats].enable_group :early_access Flipper[:stats].enable_actor User.new('25') Flipper[:stats].enable_actor User.new('90') Flipper[:stats].enable_actor User.new('180') Flipper[:stats].enable_percentage_of_time 15 Flipper[:stats].enable_percentage_of_actors 45 Flipper[:search].enable print 'all keys: ' pp client.keys # all keys: ["stats", "flipper_features", "search"] puts print "known flipper features: " pp client.smembers("flipper_features") # known flipper features: ["stats", "search"] puts puts 'stats keys' pp client.hgetall('stats') # stats keys # {"boolean"=>"true", # "groups/admins"=>"1", # "actors/25"=>"1", # "percentage_of_time"=>"15", # "percentage_of_actors"=>"45", # "groups/early_access"=>"1", # "actors/90"=>"1", # "actors/180"=>"1"} puts puts 'search keys' pp client.hgetall('search') # search keys # {"boolean"=>"true"} puts puts 'flipper get of feature' pp Flipper.adapter.get(Flipper[:stats]) # flipper get of feature # {:boolean=>"true", # :groups=>#, # :actors=>#, # :percentage_of_actors=>"45", # :percentage_of_time=>"15"} flipper-0.21.0/examples/redis/namespaced.rb000066400000000000000000000021171404600161700206040ustar00rootroot00000000000000require 'bundler/setup' require 'redis-namespace' require 'flipper/adapters/redis' options = {url: 'redis://127.0.0.1:6379'} if ENV['REDIS_URL'] options[:url] = ENV['REDIS_URL'] end client = Redis.new(options) namespaced_client = Redis::Namespace.new(:flipper_namespace, redis: client) adapter = Flipper::Adapters::Redis.new(namespaced_client) flipper = Flipper.new(adapter) # Register a few groups. Flipper.register(:admins) { |thing| thing.admin? } Flipper.register(:early_access) { |thing| thing.early_access? } # Create a user class that has flipper_id instance method. User = Struct.new(:flipper_id) flipper[:stats].enable flipper[:stats].enable_group :admins flipper[:stats].enable_group :early_access flipper[:stats].enable_actor User.new('25') flipper[:stats].enable_actor User.new('90') flipper[:stats].enable_actor User.new('180') flipper[:stats].enable_percentage_of_time 15 flipper[:stats].enable_percentage_of_actors 45 flipper[:search].enable print 'all keys: ' pp client.keys # all keys: ["stats", "flipper_features", "search"] puts puts 'notice how all the keys are namespaced' flipper-0.21.0/examples/rollout/000077500000000000000000000000001404600161700165505ustar00rootroot00000000000000flipper-0.21.0/examples/rollout/basic.rb000066400000000000000000000006741404600161700201650ustar00rootroot00000000000000require 'bundler/setup' require 'redis' require 'rollout' require 'flipper' require 'flipper/adapters/rollout' redis = Redis.new rollout = Rollout.new(redis) rollout.activate(:stats) adapter = Flipper::Adapters::Rollout.new(rollout) flipper = Flipper.new(adapter) if flipper[:stats].enabled? puts "Enabled!" else puts "Disabled!" end rollout.deactivate(:stats) if flipper[:stats].enabled? puts "Enabled!" else puts "Disabled!" end flipper-0.21.0/examples/rollout/import.rb000066400000000000000000000017251404600161700204140ustar00rootroot00000000000000require 'bundler/setup' require 'redis' require 'rollout' require 'flipper' require 'flipper/adapters/redis' require 'flipper/adapters/rollout' # setup redis, rollout and rollout flipper redis = Redis.new rollout = Rollout.new(redis) rollout_adapter = Flipper::Adapters::Rollout.new(rollout) rollout_flipper = Flipper.new(rollout_adapter) # setup flipper default instance Flipper.configure do |config| config.adapter { Flipper::Adapters::Redis.new(redis) } end # flush redis so we have clean state for script redis.flushdb # activate some rollout stuff to show that importing works rollout.activate(:stats) rollout.activate_user(:search, Struct.new(:id).new(1)) rollout.activate_group(:admin, :admins) # import rollout into redis flipper Flipper.import(rollout_flipper) # demonstrate that the rollout enablements made it into flipper p Flipper[:stats].boolean_value # true p Flipper[:search].actors_value # # p Flipper[:admin].groups_value # # flipper-0.21.0/examples/sequel/000077500000000000000000000000001404600161700163465ustar00rootroot00000000000000flipper-0.21.0/examples/sequel/basic.rb000066400000000000000000000007331404600161700177570ustar00rootroot00000000000000require 'bundler/setup' require 'sequel' Sequel::Model.db = Sequel.sqlite(':memory:') Sequel.extension :migration, :core_extensions require 'generators/flipper/templates/sequel_migration' CreateFlipperTablesSequel.new(Sequel::Model.db).up require 'flipper/adapters/sequel' Flipper[:stats].enable if Flipper[:stats].enabled? puts "Enabled!" else puts "Disabled!" end Flipper[:stats].disable if Flipper[:stats].enabled? puts "Enabled!" else puts "Disabled!" end flipper-0.21.0/examples/sequel/internals.rb000066400000000000000000000062371404600161700207020ustar00rootroot00000000000000require 'bundler/setup' require 'sequel' Sequel::Model.db = Sequel.sqlite(':memory:') Sequel.extension :migration, :core_extensions require 'generators/flipper/templates/sequel_migration' CreateFlipperTablesSequel.new(Sequel::Model.db).up require 'flipper/adapters/sequel' # Register a few groups. Flipper.register(:admins) { |thing| thing.admin? } Flipper.register(:early_access) { |thing| thing.early_access? } # Create a user class that has flipper_id instance method. User = Struct.new(:flipper_id) Flipper[:stats].enable Flipper[:stats].enable_group :admins Flipper[:stats].enable_group :early_access Flipper[:stats].enable_actor User.new('25') Flipper[:stats].enable_actor User.new('90') Flipper[:stats].enable_actor User.new('180') Flipper[:stats].enable_percentage_of_time 15 Flipper[:stats].enable_percentage_of_actors 45 Flipper[:search].enable puts 'all rows in features table' pp Flipper::Adapters::Sequel::Feature.all #[#"stats", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"search", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>] puts puts 'all rows in gates table' pp Flipper::Adapters::Sequel::Gate.all # [#"stats", :key=>"boolean", :value=>"true", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"stats", :key=>"groups", :value=>"admins", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"stats", :key=>"groups", :value=>"early_access", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"stats", :key=>"actors", :value=>"25", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"stats", :key=>"actors", :value=>"90", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"stats", :key=>"actors", :value=>"180", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"stats", :key=>"percentage_of_time", :value=>"15", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"stats", :key=>"percentage_of_actors", :value=>"45", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>, # #"search", :key=>"boolean", :value=>"true", :created_at=>2016-11-19 13:57:48 -0500, :updated_at=>2016-11-19 13:57:48 -0500}>] puts puts 'flipper get of feature' pp Flipper.adapter.get(Flipper[:stats]) # {:boolean=>"true", # :groups=>#, # :actors=>#, # :percentage_of_actors=>"45", # :percentage_of_time=>"15"} flipper-0.21.0/examples/ui/000077500000000000000000000000001404600161700154655ustar00rootroot00000000000000flipper-0.21.0/examples/ui/authorization.ru000066400000000000000000000050631404600161700207410ustar00rootroot00000000000000# # Usage: # bundle exec rackup examples/ui/authorization.ru -p 9999 # bundle exec shotgun examples/ui/authorization.ru -p 9999 # http://localhost:9999/ # require 'bundler/setup' require "logger" require "flipper/ui" require "flipper/adapters/pstore" require "active_support/notifications" Flipper.register(:admins) { |actor| actor.respond_to?(:admin?) && actor.admin? } Flipper.register(:early_access) { |actor| actor.respond_to?(:early?) && actor.early? } # Setup logging of flipper calls. if ENV["LOG"] == "1" $logger = Logger.new(STDOUT) require "flipper/instrumentation/log_subscriber" Flipper::Instrumentation::LogSubscriber.logger = $logger end adapter = Flipper::Adapters::PStore.new flipper = Flipper.new(adapter, instrumenter: ActiveSupport::Notifications) Flipper::UI.configure do |config| # config.banner_text = 'Production Environment' # config.banner_class = 'danger' config.feature_creation_enabled = true config.feature_removal_enabled = true # config.show_feature_description_in_list = true config.descriptions_source = lambda do |_keys| { "search_performance_another_long_thing" => "Just to test feature name length.", "gauges_tracking" => "Should we track page views with gaug.es.", "unused" => "Not used.", "suits" => "Are suits necessary in business?", "secrets" => "Secrets are lies.", "logging" => "Log all the things.", "new_cache" => "Like the old cache but newer.", "a/b" => "Why would someone use a slash? I don't know but someone did. Let's make this really long so they regret using slashes. Please don't use slashes.", } end end # Example middleware to allow reading the Flipper UI but nothing else. class FlipperReadOnlyMiddleware def initialize(app) @app = app end def call(env) request = Rack::Request.new(env) if request.get? @app.call(env) else [401, {}, ["You can only look"]] end end end # You can uncomment these to get some default data: # flipper[:search_performance_another_long_thing].enable # flipper[:gauges_tracking].enable # flipper[:unused].disable # flipper[:suits].enable_actor Flipper::Actor.new('1') # flipper[:suits].enable_actor Flipper::Actor.new('6') # flipper[:secrets].enable_group :admins # flipper[:secrets].enable_group :early_access # flipper[:logging].enable_percentage_of_time 5 # flipper[:new_cache].enable_percentage_of_actors 15 # flipper["something/slashed"].add run Flipper::UI.app(flipper) { |builder| builder.use Rack::Session::Cookie, secret: "_super_secret" builder.use FlipperReadOnlyMiddleware } flipper-0.21.0/examples/ui/basic.ru000066400000000000000000000036441404600161700171250ustar00rootroot00000000000000# # Usage: # # if you want it to not reload and be really fast # bin/rackup examples/ui/basic.ru -p 9999 # # # if you want reloading # bin/shotgun examples/ui/basic.ru -p 9999 # # http://localhost:9999/ # require 'bundler/setup' require "flipper/ui" require "flipper/adapters/pstore" Flipper.register(:admins) { |actor| actor.respond_to?(:admin?) && actor.admin? } Flipper.register(:early_access) { |actor| actor.respond_to?(:early?) && actor.early? } Flipper::UI.configure do |config| # config.banner_text = 'Production Environment' # config.banner_class = 'danger' config.feature_creation_enabled = true config.feature_removal_enabled = true config.cloud_recommendation = true # config.show_feature_description_in_list = true config.descriptions_source = lambda do |_keys| { "search_performance_another_long_thing" => "Just to test feature name length.", "gauges_tracking" => "Should we track page views with gaug.es.", "unused" => "Not used.", "suits" => "Are suits necessary in business?", "secrets" => "Secrets are lies.", "logging" => "Log all the things.", "new_cache" => "Like the old cache but newer.", "a/b" => "Why would someone use a slash? I don't know but someone did. Let's make this really long so they regret using slashes. Please don't use slashes.", } end end # You can uncomment these to get some default data: # flipper[:search_performance_another_long_thing].enable # flipper[:gauges_tracking].enable # flipper[:unused].disable # flipper[:suits].enable_actor Flipper::Actor.new('1') # flipper[:suits].enable_actor Flipper::Actor.new('6') # flipper[:secrets].enable_group :admins # flipper[:secrets].enable_group :early_access # flipper[:logging].enable_percentage_of_time 5 # flipper[:new_cache].enable_percentage_of_actors 15 # flipper["a/b"].add run Flipper::UI.app { |builder| builder.use Rack::Session::Cookie, secret: "_super_secret" } flipper-0.21.0/flipper-active_record.gemspec000066400000000000000000000020731404600161700210510ustar00rootroot00000000000000# -*- encoding: utf-8 -*- require File.expand_path('../lib/flipper/version', __FILE__) require File.expand_path('../lib/flipper/metadata', __FILE__) flipper_active_record_files = lambda do |file| file =~ /active_record/ end Gem::Specification.new do |gem| gem.authors = ['John Nunemaker'] gem.email = ['nunemaker@gmail.com'] gem.summary = 'ActiveRecord adapter for Flipper' gem.license = 'MIT' gem.homepage = 'https://github.com/jnunemaker/flipper' extra_files = [ 'lib/generators/flipper/templates/migration.erb', 'lib/flipper/version.rb', ] gem.files = `git ls-files`.split("\n").select(&flipper_active_record_files) + extra_files gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n").select(&flipper_active_record_files) gem.name = 'flipper-active_record' gem.require_paths = ['lib'] gem.version = Flipper::VERSION gem.metadata = Flipper::METADATA gem.add_dependency 'flipper', "~> #{Flipper::VERSION}" gem.add_dependency 'activerecord', '>= 5.0', '< 7' end flipper-0.21.0/flipper-active_support_cache_store.gemspec000066400000000000000000000020601404600161700236420ustar00rootroot00000000000000# -*- encoding: utf-8 -*- require File.expand_path('../lib/flipper/version', __FILE__) require File.expand_path('../lib/flipper/metadata', __FILE__) flipper_active_support_cache_store_files = lambda do |file| file =~ /active_support_cache_store/ end Gem::Specification.new do |gem| gem.authors = ['John Nunemaker'] gem.email = ['nunemaker@gmail.com'] gem.summary = 'ActiveSupport::Cache store adapter for Flipper' gem.license = 'MIT' gem.homepage = 'https://github.com/jnunemaker/flipper' gem.files = `git ls-files`.split("\n").select(&flipper_active_support_cache_store_files) + ['lib/flipper/version.rb'] gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n").select(&flipper_active_support_cache_store_files) gem.name = 'flipper-active_support_cache_store' gem.require_paths = ['lib'] gem.version = Flipper::VERSION gem.metadata = Flipper::METADATA gem.add_dependency 'flipper', "~> #{Flipper::VERSION}" gem.add_dependency 'activesupport', '>= 5.0', '< 7' end flipper-0.21.0/flipper-api.gemspec000066400000000000000000000016541404600161700170150ustar00rootroot00000000000000# -*- encoding: utf-8 -*- require File.expand_path('../lib/flipper/version', __FILE__) require File.expand_path('../lib/flipper/metadata', __FILE__) flipper_api_files = lambda do |file| file =~ %r{(flipper)[\/-]api} end Gem::Specification.new do |gem| gem.authors = ['John Nunemaker'] gem.email = ['nunemaker@gmail.com'] gem.summary = 'API for the Flipper gem' gem.license = 'MIT' gem.homepage = 'https://github.com/jnunemaker/flipper' gem.files = `git ls-files`.split("\n").select(&flipper_api_files) + ['lib/flipper/version.rb'] gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n").select(&flipper_api_files) gem.name = 'flipper-api' gem.require_paths = ['lib'] gem.version = Flipper::VERSION gem.metadata = Flipper::METADATA gem.add_dependency 'rack', '>= 1.4', '< 3' gem.add_dependency 'flipper', "~> #{Flipper::VERSION}" end flipper-0.21.0/flipper-cloud.gemspec000066400000000000000000000016561404600161700173540ustar00rootroot00000000000000# -*- encoding: utf-8 -*- require File.expand_path('../lib/flipper/version', __FILE__) require File.expand_path('../lib/flipper/metadata', __FILE__) flipper_cloud_files = lambda do |file| file =~ /cloud/ end Gem::Specification.new do |gem| gem.authors = ['John Nunemaker'] gem.email = ['nunemaker@gmail.com'] gem.summary = 'FeatureFlipper.com adapter for Flipper' gem.license = 'MIT' gem.homepage = 'https://github.com/jnunemaker/flipper' extra_files = [ 'lib/flipper/version.rb', ] gem.files = `git ls-files`.split("\n").select(&flipper_cloud_files) + extra_files gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n").select(&flipper_cloud_files) gem.name = 'flipper-cloud' gem.require_paths = ['lib'] gem.version = Flipper::VERSION gem.metadata = Flipper::METADATA gem.add_dependency 'flipper', "~> #{Flipper::VERSION}" end flipper-0.21.0/flipper-dalli.gemspec000066400000000000000000000016521404600161700173270ustar00rootroot00000000000000# -*- encoding: utf-8 -*- require File.expand_path('../lib/flipper/version', __FILE__) require File.expand_path('../lib/flipper/metadata', __FILE__) flipper_dalli_files = lambda do |file| file =~ /dalli/ end Gem::Specification.new do |gem| gem.authors = ['John Nunemaker'] gem.email = ['nunemaker@gmail.com'] gem.summary = 'Dalli adapter for Flipper' gem.license = 'MIT' gem.homepage = 'https://github.com/jnunemaker/flipper' gem.files = `git ls-files`.split("\n").select(&flipper_dalli_files) + ['lib/flipper/version.rb'] gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n").select(&flipper_dalli_files) gem.name = 'flipper-dalli' gem.require_paths = ['lib'] gem.version = Flipper::VERSION gem.metadata = Flipper::METADATA gem.add_dependency 'flipper', "~> #{Flipper::VERSION}" gem.add_dependency 'dalli', '>= 2.0', '< 3' end flipper-0.21.0/flipper-moneta.gemspec000066400000000000000000000015171404600161700175250ustar00rootroot00000000000000# -*- encoding: utf-8 -*- require File.expand_path('../lib/flipper/version', __FILE__) flipper_moneta_files = lambda do |file| file =~ /moneta/ end Gem::Specification.new do |gem| gem.authors = ['John Nunemaker'] gem.email = ['nunemaker@gmail.com'] gem.summary = 'Moneta adapter for Flipper' gem.license = 'MIT' gem.homepage = 'https://github.com/jnunemaker/flipper' gem.files = `git ls-files`.split("\n").select(&flipper_moneta_files) + ['lib/flipper/version.rb'] gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n").select(&flipper_moneta_files) gem.name = 'flipper-moneta' gem.require_paths = ['lib'] gem.version = Flipper::VERSION gem.add_dependency 'flipper', "~> #{Flipper::VERSION}" gem.add_dependency 'moneta', '>= 0.7.0', '< 1.2' end flipper-0.21.0/flipper-mongo.gemspec000066400000000000000000000016431404600161700173610ustar00rootroot00000000000000# -*- encoding: utf-8 -*- require File.expand_path('../lib/flipper/version', __FILE__) require File.expand_path('../lib/flipper/metadata', __FILE__) flipper_mongo_files = lambda do |file| file =~ /mongo/ end Gem::Specification.new do |gem| gem.authors = ['John Nunemaker'] gem.email = ['nunemaker@gmail.com'] gem.summary = 'Mongo adapter for Flipper' gem.license = 'MIT' gem.homepage = 'https://github.com/jnunemaker/flipper' gem.files = `git ls-files`.split("\n").select(&flipper_mongo_files) + ['lib/flipper/version.rb'] gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n").select(&flipper_mongo_files) gem.name = 'flipper-mongo' gem.require_paths = ['lib'] gem.version = Flipper::VERSION gem.metadata = Flipper::METADATA gem.add_dependency 'flipper', "~> #{Flipper::VERSION}" gem.add_dependency 'mongo', '~> 2.0' end flipper-0.21.0/flipper-redis.gemspec000066400000000000000000000016521404600161700173500ustar00rootroot00000000000000# -*- encoding: utf-8 -*- require File.expand_path('../lib/flipper/version', __FILE__) require File.expand_path('../lib/flipper/metadata', __FILE__) flipper_redis_files = lambda do |file| file =~ /redis/ end Gem::Specification.new do |gem| gem.authors = ['John Nunemaker'] gem.email = ['nunemaker@gmail.com'] gem.summary = 'Redis adapter for Flipper' gem.license = 'MIT' gem.homepage = 'https://github.com/jnunemaker/flipper' gem.files = `git ls-files`.split("\n").select(&flipper_redis_files) + ['lib/flipper/version.rb'] gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n").select(&flipper_redis_files) gem.name = 'flipper-redis' gem.require_paths = ['lib'] gem.version = Flipper::VERSION gem.metadata = Flipper::METADATA gem.add_dependency 'flipper', "~> #{Flipper::VERSION}" gem.add_dependency 'redis', '>= 2.2', '< 5' end flipper-0.21.0/flipper-rollout.gemspec000066400000000000000000000017371404600161700177460ustar00rootroot00000000000000# -*- encoding: utf-8 -*- require File.expand_path('../lib/flipper/version', __FILE__) require File.expand_path('../lib/flipper/metadata', __FILE__) flipper_rollout_files = lambda do |file| file =~ /rollout/ end Gem::Specification.new do |gem| gem.authors = ['John Nunemaker'] gem.email = ['nunemaker@gmail.com'] gem.summary = 'Rollout adapter for Flipper' gem.license = 'MIT' gem.homepage = 'https://github.com/jnunemaker/flipper' gem.files = `git ls-files`.split("\n").select(&flipper_rollout_files) + ['lib/flipper/version.rb'] gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n").select(&flipper_rollout_files) gem.name = 'flipper-rollout' gem.require_paths = ['lib'] gem.version = Flipper::VERSION gem.metadata = Flipper::METADATA gem.add_dependency 'flipper', "~> #{Flipper::VERSION}" gem.add_dependency 'redis', '>= 2.2', '< 5' gem.add_dependency 'rollout', "~> 2.0" end flipper-0.21.0/flipper-sequel.gemspec000066400000000000000000000017161404600161700175410ustar00rootroot00000000000000# -*- encoding: utf-8 -*- require File.expand_path('../lib/flipper/version', __FILE__) require File.expand_path('../lib/flipper/metadata', __FILE__) flipper_sequel_files = ->(file) { file =~ /sequel/ } Gem::Specification.new do |gem| gem.authors = ['John Nunemaker'] gem.email = ['nunemaker@gmail.com'] gem.summary = 'Sequel adapter for Flipper' gem.license = 'MIT' gem.homepage = 'https://github.com/jnunemaker/flipper' extra_files = [ 'lib/flipper/version.rb', ] gem.files = `git ls-files`.split("\n").select(&flipper_sequel_files) + extra_files gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n").select(&flipper_sequel_files) gem.name = 'flipper-sequel' gem.require_paths = ['lib'] gem.version = Flipper::VERSION gem.metadata = Flipper::METADATA gem.add_dependency 'flipper', "~> #{Flipper::VERSION}" gem.add_dependency 'sequel', '>= 4.0.0', '< 6' end flipper-0.21.0/flipper-ui.gemspec000066400000000000000000000020471404600161700166560ustar00rootroot00000000000000# -*- encoding: utf-8 -*- require File.expand_path('../lib/flipper/version', __FILE__) require File.expand_path('../lib/flipper/metadata', __FILE__) flipper_ui_files = lambda do |file| file =~ %r{(docs|examples|flipper)[\/-]ui} end Gem::Specification.new do |gem| gem.authors = ['John Nunemaker'] gem.email = ['nunemaker@gmail.com'] gem.summary = 'UI for the Flipper gem' gem.license = 'MIT' gem.homepage = 'https://github.com/jnunemaker/flipper' gem.files = `git ls-files`.split("\n").select(&flipper_ui_files) + ['lib/flipper/version.rb'] gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n").select(&flipper_ui_files) gem.name = 'flipper-ui' gem.require_paths = ['lib'] gem.version = Flipper::VERSION gem.metadata = Flipper::METADATA gem.add_dependency 'rack', '>= 1.4', '< 3' gem.add_dependency 'rack-protection', '>= 1.5.3', '< 2.2.0' gem.add_dependency 'flipper', "~> #{Flipper::VERSION}" gem.add_dependency 'erubi', '>= 1.0.0', '< 2.0.0' end flipper-0.21.0/flipper.gemspec000066400000000000000000000023751404600161700162470ustar00rootroot00000000000000# -*- encoding: utf-8 -*- require File.expand_path('../lib/flipper/version', __FILE__) require File.expand_path('../lib/flipper/metadata', __FILE__) plugin_files = [] plugin_test_files = [] Dir['flipper-*.gemspec'].map do |gemspec| spec = eval(File.read(gemspec)) plugin_files << spec.files plugin_test_files << spec.files end ignored_files = plugin_files ignored_files << Dir['script/*'] ignored_files << '.travis.yml' ignored_files << '.gitignore' ignored_files << 'Guardfile' ignored_files.flatten!.uniq! ignored_test_files = plugin_test_files ignored_test_files.flatten!.uniq! Gem::Specification.new do |gem| gem.authors = ['John Nunemaker'] gem.email = ['nunemaker@gmail.com'] gem.summary = 'Feature flipper for ANYTHING' gem.homepage = 'https://github.com/jnunemaker/flipper' gem.license = 'MIT' gem.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } gem.files = `git ls-files`.split("\n") - ignored_files + ['lib/flipper/version.rb'] gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") - ignored_test_files gem.name = 'flipper' gem.require_paths = ['lib'] gem.version = Flipper::VERSION gem.metadata = Flipper::METADATA end flipper-0.21.0/lib/000077500000000000000000000000001404600161700140005ustar00rootroot00000000000000flipper-0.21.0/lib/flipper-active_record.rb000066400000000000000000000002021404600161700205670ustar00rootroot00000000000000require 'active_support/lazy_load_hooks' ActiveSupport.on_load(:active_record) do require 'flipper/adapters/active_record' end flipper-0.21.0/lib/flipper-active_support_cache_store.rb000066400000000000000000000000661404600161700233740ustar00rootroot00000000000000require 'flipper/adapters/active_support_cache_store' flipper-0.21.0/lib/flipper-api.rb000066400000000000000000000000261404600161700165330ustar00rootroot00000000000000require 'flipper/api' flipper-0.21.0/lib/flipper-cloud.rb000066400000000000000000000000301404600161700170630ustar00rootroot00000000000000require "flipper/cloud" flipper-0.21.0/lib/flipper-dalli.rb000066400000000000000000000000411404600161700170440ustar00rootroot00000000000000require 'flipper/adapters/dalli' flipper-0.21.0/lib/flipper-mongo.rb000066400000000000000000000000411404600161700170760ustar00rootroot00000000000000require 'flipper/adapters/mongo' flipper-0.21.0/lib/flipper-redis.rb000066400000000000000000000000411404600161700170650ustar00rootroot00000000000000require 'flipper/adapters/redis' flipper-0.21.0/lib/flipper-sequel.rb000066400000000000000000000000421404600161700172560ustar00rootroot00000000000000require 'flipper/adapters/sequel' flipper-0.21.0/lib/flipper-ui.rb000066400000000000000000000000251404600161700163760ustar00rootroot00000000000000require 'flipper/ui' flipper-0.21.0/lib/flipper.rb000066400000000000000000000116311404600161700157700ustar00rootroot00000000000000require "forwardable" module Flipper extend self extend Forwardable # Private: The namespace for all instrumented events. InstrumentationNamespace = :flipper # Public: Start here. Given an adapter returns a handy DSL to all the flipper # goodness. To see supported options, check out dsl.rb. def new(adapter, options = {}) DSL.new(adapter, options) end # Public: Configure flipper. # # Flipper.configure do |config| # config.adapter { ... } # end # # Yields Flipper::Configuration instance. def configure yield configuration if block_given? end # Public: Returns Flipper::Configuration instance. def configuration @configuration ||= Configuration.new end # Public: Sets Flipper::Configuration instance. def configuration=(configuration) # need to reset flipper instance if configuration changes self.instance = nil @configuration = configuration end # Public: Default per thread flipper instance if configured. You should not # need to use this directly as most of the Flipper::DSL methods are delegated # from Flipper module itself. Instead of doing Flipper.instance.enabled?(:search), # you can use Flipper.enabled?(:search) for the same result. # # Returns Flipper::DSL instance. def instance Thread.current[:flipper_instance] ||= configuration.default end # Public: Set the flipper instance. It is most common to use the # Configuration#default to set this instance, but for things like the test # environment, this writer is actually useful. def instance=(flipper) Thread.current[:flipper_instance] = flipper end # Public: All the methods delegated to instance. These should match the # interface of Flipper::DSL. def_delegators :instance, :enabled?, :enable, :disable, :bool, :boolean, :enable_actor, :disable_actor, :actor, :enable_group, :disable_group, :enable_percentage_of_actors, :disable_percentage_of_actors, :actors, :percentage_of_actors, :enable_percentage_of_time, :disable_percentage_of_time, :time, :percentage_of_time, :features, :feature, :[], :preload, :preload_all, :adapter, :add, :exist?, :remove, :import, :memoize=, :memoizing?, :sync, :sync_secret # For Flipper::Cloud. Will error for OSS Flipper. # Public: Use this to register a group by name. # # name - The Symbol name of the group. # block - The block that should be used to determine if the group matches a # given thing. # # Examples # # Flipper.register(:admins) { |thing| # thing.respond_to?(:admin?) && thing.admin? # } # # Returns a Flipper::Group. # Raises Flipper::DuplicateGroup if the group is already registered. def register(name, &block) group = Types::Group.new(name, &block) groups_registry.add(group.name, group) group rescue Registry::DuplicateKey raise DuplicateGroup, "Group #{name.inspect} has already been registered" end # Public: Returns a Set of registered Types::Group instances. def groups groups_registry.values.to_set end # Public: Returns a Set of symbols where each symbol is a registered # group name. If you just want the names, this is more efficient than doing # `Flipper.groups.map(&:name)`. def group_names groups_registry.keys.to_set end # Public: Clears the group registry. # # Returns nothing. def unregister_groups groups_registry.clear end # Public: Check if a group exists # # Returns boolean def group_exists?(name) groups_registry.key?(name) end # Public: Fetches a group by name. # # name - The Symbol name of the group. # # Examples # # Flipper.group(:admins) # # Returns Flipper::Group. def group(name) groups_registry.get(name) || Types::Group.new(name) end # Internal: Registry of all groups_registry. def groups_registry @groups_registry ||= Registry.new end # Internal: Change the groups_registry registry. def groups_registry=(registry) @groups_registry = registry end end require 'flipper/actor' require 'flipper/adapter' require 'flipper/adapters/memoizable' require 'flipper/adapters/memory' require 'flipper/adapters/instrumented' require 'flipper/configuration' require 'flipper/dsl' require 'flipper/errors' require 'flipper/feature' require 'flipper/gate' require 'flipper/instrumenters/memory' require 'flipper/instrumenters/noop' require 'flipper/identifier' require 'flipper/middleware/memoizer' require 'flipper/middleware/setup_env' require 'flipper/registry' require 'flipper/type' require 'flipper/types/actor' require 'flipper/types/boolean' require 'flipper/types/group' require 'flipper/types/percentage' require 'flipper/types/percentage_of_actors' require 'flipper/types/percentage_of_time' require 'flipper/typecast' require "flipper/railtie" if defined?(Rails::Railtie) flipper-0.21.0/lib/flipper/000077500000000000000000000000001404600161700154415ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/actor.rb000066400000000000000000000005611404600161700171000ustar00rootroot00000000000000# Simple class for turning a flipper_id into an actor that can be based # to Flipper::Feature#enabled?. module Flipper class Actor attr_reader :flipper_id def initialize(flipper_id) @flipper_id = flipper_id end def eql?(other) self.class.eql?(other.class) && @flipper_id == other.flipper_id end alias_method :==, :eql? end end flipper-0.21.0/lib/flipper/adapter.rb000066400000000000000000000030641404600161700174110ustar00rootroot00000000000000require "set" require "flipper/feature" require "flipper/adapters/sync/synchronizer" module Flipper # Adding a module include so we have some hooks for stuff down the road module Adapter def self.included(base) base.extend(ClassMethods) end module ClassMethods # Public: Default config for a feature's gate values. def default_config { boolean: nil, groups: Set.new, actors: Set.new, percentage_of_actors: nil, percentage_of_time: nil, } end end # Public: Get all features and gate values in one call. Defaults to one call # to features and another to get_multi. Feel free to override per adapter to # make this more efficient. def get_all instances = features.map { |key| Flipper::Feature.new(key, self) } get_multi(instances) end # Public: Get multiple features in one call. Defaults to one get per # feature. Feel free to override per adapter to make this more efficient and # reduce network calls. def get_multi(features) result = {} features.each do |feature| result[feature.key] = get(feature) end result end # Public: Ensure that adapter is in sync with source adapter provided. # # Returns result of Synchronizer#call. def import(source_adapter) Adapters::Sync::Synchronizer.new(self, source_adapter, raise: true).call end # Public: Default config for a feature's gate values. def default_config self.class.default_config end end end flipper-0.21.0/lib/flipper/adapters/000077500000000000000000000000001404600161700172445ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/adapters/active_record.rb000066400000000000000000000156451404600161700224150ustar00rootroot00000000000000require 'set' require 'flipper' require 'active_record' module Flipper module Adapters class ActiveRecord include ::Flipper::Adapter # Private: Do not use outside of this adapter. class Feature < ::ActiveRecord::Base self.table_name = [ ::ActiveRecord::Base.table_name_prefix, "flipper_features", ::ActiveRecord::Base.table_name_suffix, ].join end # Private: Do not use outside of this adapter. class Gate < ::ActiveRecord::Base self.table_name = [ ::ActiveRecord::Base.table_name_prefix, "flipper_gates", ::ActiveRecord::Base.table_name_suffix, ].join end # Public: The name of the adapter. attr_reader :name # Public: Initialize a new ActiveRecord adapter instance. # # name - The Symbol name for this adapter. Optional (default :active_record) # feature_class - The AR class responsible for the features table. # gate_class - The AR class responsible for the gates table. # # Allowing the overriding of name is so you can differentiate multiple # instances of this adapter from each other, if, for some reason, that is # a thing you do. # # Allowing the overriding of the default feature/gate classes means you # can roll your own tables and what not, if you so desire. def initialize(options = {}) @name = options.fetch(:name, :active_record) @feature_class = options.fetch(:feature_class) { Feature } @gate_class = options.fetch(:gate_class) { Gate } end # Public: The set of known features. def features @feature_class.all.map(&:key).to_set end # Public: Adds a feature to the set of known features. def add(feature) # race condition, but add is only used by enable/disable which happen # super rarely, so it shouldn't matter in practice @feature_class.transaction do unless @feature_class.where(key: feature.key).first begin @feature_class.create! { |f| f.key = feature.key } rescue ::ActiveRecord::RecordNotUnique end end end true end # Public: Removes a feature from the set of known features. def remove(feature) @feature_class.transaction do @feature_class.where(key: feature.key).destroy_all clear(feature) end true end # Public: Clears the gate values for a feature. def clear(feature) @gate_class.where(feature_key: feature.key).destroy_all true end # Public: Gets the values for all gates for a given feature. # # Returns a Hash of Flipper::Gate#key => value. def get(feature) db_gates = @gate_class.where(feature_key: feature.key) result_for_feature(feature, db_gates) end def get_multi(features) db_gates = @gate_class.where(feature_key: features.map(&:key)) grouped_db_gates = db_gates.group_by(&:feature_key) result = {} features.each do |feature| result[feature.key] = result_for_feature(feature, grouped_db_gates[feature.key]) end result end def get_all rows = ::ActiveRecord::Base.connection.select_all <<-SQL.tr("\n", ' ') SELECT ff.key AS feature_key, fg.key, fg.value FROM #{@feature_class.table_name} ff LEFT JOIN #{@gate_class.table_name} fg ON ff.key = fg.feature_key SQL db_gates = rows.map { |row| Gate.new(row) } grouped_db_gates = db_gates.group_by(&:feature_key) result = Hash.new { |hash, key| hash[key] = default_config } features = grouped_db_gates.keys.map { |key| Flipper::Feature.new(key, self) } features.each do |feature| result[feature.key] = result_for_feature(feature, grouped_db_gates[feature.key]) end result end # Public: Enables a gate for a given thing. # # feature - The Flipper::Feature for the gate. # gate - The Flipper::Gate to disable. # thing - The Flipper::Type being enabled for the gate. # # Returns true. def enable(feature, gate, thing) case gate.data_type when :boolean set(feature, gate, thing, clear: true) when :integer set(feature, gate, thing) when :set enable_multi(feature, gate, thing) else unsupported_data_type gate.data_type end true end # Public: Disables a gate for a given thing. # # feature - The Flipper::Feature for the gate. # gate - The Flipper::Gate to disable. # thing - The Flipper::Type being disabled for the gate. # # Returns true. def disable(feature, gate, thing) case gate.data_type when :boolean clear(feature) when :integer set(feature, gate, thing) when :set @gate_class.where(feature_key: feature.key, key: gate.key, value: thing.value).destroy_all else unsupported_data_type gate.data_type end true end # Private def unsupported_data_type(data_type) raise "#{data_type} is not supported by this adapter" end private def set(feature, gate, thing, options = {}) clear_feature = options.fetch(:clear, false) @gate_class.transaction do clear(feature) if clear_feature @gate_class.where(feature_key: feature.key, key: gate.key).destroy_all @gate_class.create! do |g| g.feature_key = feature.key g.key = gate.key g.value = thing.value.to_s end end nil end def enable_multi(feature, gate, thing) @gate_class.create! do |g| g.feature_key = feature.key g.key = gate.key g.value = thing.value.to_s end nil rescue ::ActiveRecord::RecordNotUnique # already added so no need move on with life end def result_for_feature(feature, db_gates) db_gates ||= [] result = {} feature.gates.each do |gate| result[gate.key] = case gate.data_type when :boolean if detected_db_gate = db_gates.detect { |db_gate| db_gate.key == gate.key.to_s } detected_db_gate.value end when :integer if detected_db_gate = db_gates.detect { |db_gate| db_gate.key == gate.key.to_s } detected_db_gate.value end when :set db_gates.select { |db_gate| db_gate.key == gate.key.to_s }.map(&:value).to_set else unsupported_data_type gate.data_type end end result end end end end Flipper.configure do |config| config.adapter { Flipper::Adapters::ActiveRecord.new } end flipper-0.21.0/lib/flipper/adapters/active_support_cache_store.rb000066400000000000000000000100141404600161700251730ustar00rootroot00000000000000require 'flipper' module Flipper module Adapters # Public: Adapter that wraps another adapter with the ability to cache # adapter calls in ActiveSupport::ActiveSupportCacheStore caches. # class ActiveSupportCacheStore include ::Flipper::Adapter Version = 'v1'.freeze Namespace = "flipper/#{Version}".freeze FeaturesKey = "#{Namespace}/features".freeze GetAllKey = "#{Namespace}/get_all".freeze # Private def self.key_for(key) "#{Namespace}/feature/#{key}" end # Internal attr_reader :cache # Public: The name of the adapter. attr_reader :name # Public def initialize(adapter, cache, expires_in: nil, write_through: false) @adapter = adapter @name = :active_support_cache_store @cache = cache @write_options = {} @write_options[:expires_in] = expires_in if expires_in @write_through = write_through end # Public def features read_feature_keys end # Public def add(feature) result = @adapter.add(feature) @cache.delete(FeaturesKey) result end ## Public def remove(feature) result = @adapter.remove(feature) @cache.delete(FeaturesKey) if @write_through @cache.write(key_for(feature.key), default_config, @write_options) else @cache.delete(key_for(feature.key)) end result end ## Public def clear(feature) result = @adapter.clear(feature) @cache.delete(key_for(feature.key)) result end ## Public def get(feature) @cache.fetch(key_for(feature.key), @write_options) do @adapter.get(feature) end end def get_multi(features) read_many_features(features) end def get_all if @cache.write(GetAllKey, Time.now.to_i, @write_options.merge(unless_exist: true)) response = @adapter.get_all response.each do |key, value| @cache.write(key_for(key), value, @write_options) end @cache.write(FeaturesKey, response.keys.to_set, @write_options) response else features = read_feature_keys.map { |key| Flipper::Feature.new(key, self) } read_many_features(features) end end ## Public def enable(feature, gate, thing) result = @adapter.enable(feature, gate, thing) if @write_through @cache.write(key_for(feature.key), @adapter.get(feature), @write_options) else @cache.delete(key_for(feature.key)) end result end ## Public def disable(feature, gate, thing) result = @adapter.disable(feature, gate, thing) if @write_through @cache.write(key_for(feature.key), @adapter.get(feature), @write_options) else @cache.delete(key_for(feature.key)) end result end private def key_for(key) self.class.key_for(key) end # Internal: Returns an array of the known feature keys. def read_feature_keys @cache.fetch(FeaturesKey, @write_options) { @adapter.features } end # Internal: Given an array of features, attempts to read through cache in # as few network calls as possible. def read_many_features(features) keys = features.map { |feature| key_for(feature.key) } cache_result = @cache.read_multi(*keys) uncached_features = features.reject { |feature| cache_result[key_for(feature)] } if uncached_features.any? response = @adapter.get_multi(uncached_features) response.each do |key, value| @cache.write(key_for(key), value, @write_options) cache_result[key_for(key)] = value end end result = {} features.each do |feature| result[feature.key] = cache_result[key_for(feature.key)] end result end end end end flipper-0.21.0/lib/flipper/adapters/dalli.rb000066400000000000000000000065361404600161700206700ustar00rootroot00000000000000require 'dalli' require 'flipper' module Flipper module Adapters # Public: Adapter that wraps another adapter with the ability to cache # adapter calls in Memcached using the Dalli gem. class Dalli include ::Flipper::Adapter Version = 'v1'.freeze Namespace = "flipper/#{Version}".freeze FeaturesKey = "#{Namespace}/features".freeze GetAllKey = "#{Namespace}/get_all".freeze # Private def self.key_for(key) "#{Namespace}/feature/#{key}" end # Internal attr_reader :cache # Public: The name of the adapter. attr_reader :name # Public: The ttl for all cached data. attr_reader :ttl # Public def initialize(adapter, cache, ttl = 0) @adapter = adapter @name = :dalli @cache = cache @ttl = ttl end # Public def features read_feature_keys end # Public def add(feature) result = @adapter.add(feature) @cache.delete(FeaturesKey) result end # Public def remove(feature) result = @adapter.remove(feature) @cache.delete(FeaturesKey) @cache.delete(key_for(feature.key)) result end # Public def clear(feature) result = @adapter.clear(feature) @cache.delete(key_for(feature.key)) result end # Public def get(feature) @cache.fetch(key_for(feature.key), @ttl) do @adapter.get(feature) end end def get_multi(features) read_many_features(features) end def get_all if @cache.add(GetAllKey, Time.now.to_i, @ttl) response = @adapter.get_all response.each do |key, value| @cache.set(key_for(key), value, @ttl) end @cache.set(FeaturesKey, response.keys.to_set, @ttl) response else features = read_feature_keys.map { |key| Flipper::Feature.new(key, self) } read_many_features(features) end end # Public def enable(feature, gate, thing) result = @adapter.enable(feature, gate, thing) @cache.delete(key_for(feature.key)) result end # Public def disable(feature, gate, thing) result = @adapter.disable(feature, gate, thing) @cache.delete(key_for(feature.key)) result end private def key_for(key) self.class.key_for(key) end def read_feature_keys @cache.fetch(FeaturesKey, @ttl) { @adapter.features } end # Internal: Given an array of features, attempts to read through cache in # as few network calls as possible. def read_many_features(features) keys = features.map { |feature| key_for(feature.key) } cache_result = @cache.get_multi(keys) uncached_features = features.reject { |feature| cache_result[key_for(feature.key)] } if uncached_features.any? response = @adapter.get_multi(uncached_features) response.each do |key, value| @cache.set(key_for(key), value, @ttl) cache_result[key_for(key)] = value end end result = {} features.each do |feature| result[feature.key] = cache_result[key_for(feature.key)] end result end end end end flipper-0.21.0/lib/flipper/adapters/dual_write.rb000066400000000000000000000027161404600161700217360ustar00rootroot00000000000000module Flipper module Adapters class DualWrite include ::Flipper::Adapter # Public: The name of the adapter. attr_reader :name # Public: Build a new sync instance. # # local - The local flipper adapter that should serve reads. # remote - The remote flipper adapter that writes should go to first (in # addition to the local adapter). def initialize(local, remote, options = {}) @name = :dual_write @local = local @remote = remote end def features @local.features end def get(feature) @local.get(feature) end def get_multi(features) @local.get_multi(features) end def get_all @local.get_all end def add(feature) result = @remote.add(feature) @local.add(feature) result end def remove(feature) result = @remote.remove(feature) @local.remove(feature) result end def clear(feature) result = @remote.clear(feature) @local.clear(feature) result end def enable(feature, gate, thing) result = @remote.enable(feature, gate, thing) @local.enable(feature, gate, thing) result end def disable(feature, gate, thing) result = @remote.disable(feature, gate, thing) @local.disable(feature, gate, thing) result end end end end flipper-0.21.0/lib/flipper/adapters/http.rb000066400000000000000000000125631404600161700205570ustar00rootroot00000000000000require 'net/http' require 'json' require 'set' require 'flipper' require 'flipper/adapters/http/error' require 'flipper/adapters/http/client' module Flipper module Adapters class Http include Flipper::Adapter attr_reader :name def initialize(options = {}) @client = Client.new(url: options.fetch(:url), headers: options[:headers], basic_auth_username: options[:basic_auth_username], basic_auth_password: options[:basic_auth_password], read_timeout: options[:read_timeout], open_timeout: options[:open_timeout], debug_output: options[:debug_output]) @name = :http end def get(feature) response = @client.get("/features/#{feature.key}") if response.is_a?(Net::HTTPOK) parsed_response = JSON.parse(response.body) result_for_feature(feature, parsed_response.fetch('gates')) elsif response.is_a?(Net::HTTPNotFound) default_config else raise Error, response end end def get_multi(features) csv_keys = features.map(&:key).join(',') response = @client.get("/features?keys=#{csv_keys}") raise Error, response unless response.is_a?(Net::HTTPOK) parsed_response = JSON.parse(response.body) parsed_features = parsed_response.fetch('features') gates_by_key = parsed_features.each_with_object({}) do |parsed_feature, hash| hash[parsed_feature['key']] = parsed_feature['gates'] hash end result = {} features.each do |feature| result[feature.key] = result_for_feature(feature, gates_by_key[feature.key]) end result end def get_all response = @client.get("/features") raise Error, response unless response.is_a?(Net::HTTPOK) parsed_response = JSON.parse(response.body) parsed_features = parsed_response.fetch('features') gates_by_key = parsed_features.each_with_object({}) do |parsed_feature, hash| hash[parsed_feature['key']] = parsed_feature['gates'] hash end result = {} gates_by_key.keys.each do |key| feature = Feature.new(key, self) result[feature.key] = result_for_feature(feature, gates_by_key[feature.key]) end result end def features response = @client.get('/features') raise Error, response unless response.is_a?(Net::HTTPOK) parsed_response = JSON.parse(response.body) parsed_response['features'].map { |feature| feature['key'] }.to_set end def add(feature) body = JSON.generate(name: feature.key) response = @client.post('/features', body) raise Error, response unless response.is_a?(Net::HTTPOK) true end def remove(feature) response = @client.delete("/features/#{feature.key}") raise Error, response unless response.is_a?(Net::HTTPNoContent) true end def enable(feature, gate, thing) body = request_body_for_gate(gate, thing.value.to_s) query_string = gate.key == :groups ? "?allow_unregistered_groups=true" : "" response = @client.post("/features/#{feature.key}/#{gate.key}#{query_string}", body) raise Error, response unless response.is_a?(Net::HTTPOK) true end def disable(feature, gate, thing) body = request_body_for_gate(gate, thing.value.to_s) query_string = gate.key == :groups ? "?allow_unregistered_groups=true" : "" response = case gate.key when :percentage_of_actors, :percentage_of_time @client.post("/features/#{feature.key}/#{gate.key}#{query_string}", body) else @client.delete("/features/#{feature.key}/#{gate.key}#{query_string}", body) end raise Error, response unless response.is_a?(Net::HTTPOK) true end def clear(feature) response = @client.delete("/features/#{feature.key}/clear") raise Error, response unless response.is_a?(Net::HTTPNoContent) true end private def request_body_for_gate(gate, value) data = case gate.key when :boolean {} when :groups { name: value } when :actors { flipper_id: value } when :percentage_of_actors, :percentage_of_time { percentage: value } else raise "#{gate.key} is not a valid flipper gate key" end JSON.generate(data) end def result_for_feature(feature, api_gates) api_gates ||= [] result = default_config feature.gates.each do |gate| api_gate = api_gates.detect { |ag| ag['key'] == gate.key.to_s } result[gate.key] = value_for_gate(gate, api_gate) if api_gate end result end def value_for_gate(gate, api_gate) value = api_gate['value'] case gate.data_type when :boolean, :integer value ? value.to_s : value when :set value ? value.to_set : Set.new else unsupported_data_type(gate.data_type) end end def unsupported_data_type(data_type) raise "#{data_type} is not supported by this adapter" end end end end flipper-0.21.0/lib/flipper/adapters/http/000077500000000000000000000000001404600161700202235ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/adapters/http/client.rb000066400000000000000000000055041404600161700220320ustar00rootroot00000000000000require 'uri' require 'openssl' require 'flipper/version' module Flipper module Adapters class Http class Client DEFAULT_HEADERS = { 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'User-Agent' => "Flipper HTTP Adapter v#{VERSION}", }.freeze HTTPS_SCHEME = "https".freeze def initialize(options = {}) @uri = URI(options.fetch(:url)) @headers = DEFAULT_HEADERS.merge(options[:headers] || {}) @basic_auth_username = options[:basic_auth_username] @basic_auth_password = options[:basic_auth_password] @read_timeout = options[:read_timeout] @open_timeout = options[:open_timeout] @write_timeout = options[:write_timeout] @debug_output = options[:debug_output] end def get(path) perform Net::HTTP::Get, path, @headers end def post(path, body = nil) perform Net::HTTP::Post, path, @headers, body: body end def delete(path, body = nil) perform Net::HTTP::Delete, path, @headers, body: body end private def perform(http_method, path, headers = {}, options = {}) uri = uri_for_path(path) http = build_http(uri) request = build_request(http_method, uri, headers, options) http.request(request) end def uri_for_path(path) uri = @uri.dup path_uri = URI(path) uri.path += path_uri.path uri.query = "#{uri.query}&#{path_uri.query}" if path_uri.query uri end def build_http(uri) http = Net::HTTP.new(uri.host, uri.port) http.read_timeout = @read_timeout if @read_timeout http.open_timeout = @open_timeout if @open_timeout apply_write_timeout(http) http.set_debug_output(@debug_output) if @debug_output if uri.scheme == HTTPS_SCHEME http.use_ssl = true http.verify_mode = OpenSSL::SSL::VERIFY_PEER end http end def build_request(http_method, uri, headers, options) body = options[:body] request = http_method.new(uri.request_uri) request.initialize_http_header(headers) if headers request.body = body if body if @basic_auth_username && @basic_auth_password request.basic_auth(@basic_auth_username, @basic_auth_password) end request end def apply_write_timeout(http) if @write_timeout if RUBY_VERSION >= '2.6.0' http.write_timeout = @write_timeout else Kernel.warn("Warning: option :write_timeout requires Ruby version 2.6.0 or later") end end end end end end end flipper-0.21.0/lib/flipper/adapters/http/error.rb000066400000000000000000000004211404600161700216760ustar00rootroot00000000000000module Flipper module Adapters class Http class Error < StandardError attr_reader :response def initialize(response) @response = response super("Failed with status: #{response.code}") end end end end end flipper-0.21.0/lib/flipper/adapters/instrumented.rb000066400000000000000000000101251404600161700223110ustar00rootroot00000000000000require 'delegate' module Flipper module Adapters # Internal: Adapter that wraps another adapter and instruments all adapter # operations. class Instrumented < SimpleDelegator include ::Flipper::Adapter # Private: The name of instrumentation events. InstrumentationName = "adapter_operation.#{InstrumentationNamespace}".freeze # Private: What is used to instrument all the things. attr_reader :instrumenter # Public: The name of the adapter. attr_reader :name # Internal: Initializes a new adapter instance. # # adapter - Vanilla adapter instance to wrap. # # options - The Hash of options. # :instrumenter - What to use to instrument all the things. # def initialize(adapter, options = {}) super(adapter) @adapter = adapter @name = :instrumented @instrumenter = options.fetch(:instrumenter, Instrumenters::Noop) end # Public def features default_payload = { operation: :features, adapter_name: @adapter.name, } @instrumenter.instrument(InstrumentationName, default_payload) do |payload| payload[:result] = @adapter.features end end # Public def add(feature) default_payload = { operation: :add, adapter_name: @adapter.name, feature_name: feature.name, } @instrumenter.instrument(InstrumentationName, default_payload) do |payload| payload[:result] = @adapter.add(feature) end end # Public def remove(feature) default_payload = { operation: :remove, adapter_name: @adapter.name, feature_name: feature.name, } @instrumenter.instrument(InstrumentationName, default_payload) do |payload| payload[:result] = @adapter.remove(feature) end end # Public def clear(feature) default_payload = { operation: :clear, adapter_name: @adapter.name, feature_name: feature.name, } @instrumenter.instrument(InstrumentationName, default_payload) do |payload| payload[:result] = @adapter.clear(feature) end end # Public def get(feature) default_payload = { operation: :get, adapter_name: @adapter.name, feature_name: feature.name, } @instrumenter.instrument(InstrumentationName, default_payload) do |payload| payload[:result] = @adapter.get(feature) end end def get_multi(features) default_payload = { operation: :get_multi, adapter_name: @adapter.name, feature_names: features.map(&:name), } @instrumenter.instrument(InstrumentationName, default_payload) do |payload| payload[:result] = @adapter.get_multi(features) end end def get_all default_payload = { operation: :get_all, adapter_name: @adapter.name, } @instrumenter.instrument(InstrumentationName, default_payload) do |payload| payload[:result] = @adapter.get_all end end # Public def enable(feature, gate, thing) default_payload = { operation: :enable, adapter_name: @adapter.name, feature_name: feature.name, gate_name: gate.name, thing_value: thing.value, } @instrumenter.instrument(InstrumentationName, default_payload) do |payload| payload[:result] = @adapter.enable(feature, gate, thing) end end # Public def disable(feature, gate, thing) default_payload = { operation: :disable, adapter_name: @adapter.name, feature_name: feature.name, gate_name: gate.name, thing_value: thing.value, } @instrumenter.instrument(InstrumentationName, default_payload) do |payload| payload[:result] = @adapter.disable(feature, gate, thing) end end end end end flipper-0.21.0/lib/flipper/adapters/memoizable.rb000066400000000000000000000077661404600161700217350ustar00rootroot00000000000000require 'delegate' module Flipper module Adapters # Internal: Adapter that wraps another adapter with the ability to memoize # adapter calls in memory. Used by flipper dsl and the memoizer middleware # to make it possible to memoize adapter calls for the duration of a request. class Memoizable < SimpleDelegator include ::Flipper::Adapter FeaturesKey = :flipper_features GetAllKey = :all_memoized # Internal attr_reader :cache # Public: The name of the adapter. attr_reader :name # Internal: The adapter this adapter is wrapping. attr_reader :adapter # Private def self.key_for(key) "feature/#{key}" end # Public def initialize(adapter, cache = nil) super(adapter) @adapter = adapter @name = :memoizable @cache = cache || {} @memoize = false end # Public def features if memoizing? cache.fetch(FeaturesKey) { cache[FeaturesKey] = @adapter.features } else @adapter.features end end # Public def add(feature) result = @adapter.add(feature) expire_features_set result end # Public def remove(feature) result = @adapter.remove(feature) expire_features_set expire_feature(feature) result end # Public def clear(feature) result = @adapter.clear(feature) expire_feature(feature) result end # Public def get(feature) if memoizing? cache.fetch(key_for(feature.key)) { cache[key_for(feature.key)] = @adapter.get(feature) } else @adapter.get(feature) end end # Public def get_multi(features) if memoizing? uncached_features = features.reject { |feature| cache[key_for(feature.key)] } if uncached_features.any? response = @adapter.get_multi(uncached_features) response.each do |key, hash| cache[key_for(key)] = hash end end result = {} features.each do |feature| result[feature.key] = cache[key_for(feature.key)] end result else @adapter.get_multi(features) end end def get_all if memoizing? response = nil if cache[GetAllKey] response = {} cache[FeaturesKey].each do |key| response[key] = cache[key_for(key)] end else response = @adapter.get_all response.each do |key, value| cache[key_for(key)] = value end cache[FeaturesKey] = response.keys.to_set cache[GetAllKey] = true end # Ensures that looking up other features that do not exist doesn't # result in N+1 adapter calls. response.default_proc = ->(memo, key) { memo[key] = default_config } response else @adapter.get_all end end # Public def enable(feature, gate, thing) result = @adapter.enable(feature, gate, thing) expire_feature(feature) result end # Public def disable(feature, gate, thing) result = @adapter.disable(feature, gate, thing) expire_feature(feature) result end # Internal: Turns local caching on/off. # # value - The Boolean that decides if local caching is on. def memoize=(value) cache.clear @memoize = value end # Internal: Returns true for using local cache, false for not. def memoizing? !!@memoize end private def key_for(key) self.class.key_for(key) end def expire_feature(feature) cache.delete(key_for(feature.key)) if memoizing? end def expire_features_set cache.delete(FeaturesKey) if memoizing? end end end end flipper-0.21.0/lib/flipper/adapters/memory.rb000066400000000000000000000047571404600161700211160ustar00rootroot00000000000000require 'set' module Flipper module Adapters # Public: Adapter for storing everything in memory. # Useful for tests/specs. class Memory include ::Flipper::Adapter FeaturesKey = :features # Public: The name of the adapter. attr_reader :name # Public def initialize(source = nil) @source = source || {} @name = :memory end # Public: The set of known features. def features @source.keys.to_set end # Public: Adds a feature to the set of known features. def add(feature) @source[feature.key] ||= default_config true end # Public: Removes a feature from the set of known features and clears # all the values for the feature. def remove(feature) @source.delete(feature.key) true end # Public: Clears all the gate values for a feature. def clear(feature) @source[feature.key] = default_config true end # Public def get(feature) @source[feature.key] || default_config end def get_multi(features) result = {} features.each do |feature| result[feature.key] = @source[feature.key] || default_config end result end def get_all @source end # Public def enable(feature, gate, thing) @source[feature.key] ||= default_config case gate.data_type when :boolean clear(feature) @source[feature.key][gate.key] = thing.value.to_s when :integer @source[feature.key][gate.key] = thing.value.to_s when :set @source[feature.key][gate.key] << thing.value.to_s else raise "#{gate} is not supported by this adapter yet" end true end # Public def disable(feature, gate, thing) @source[feature.key] ||= default_config case gate.data_type when :boolean clear(feature) when :integer @source[feature.key][gate.key] = thing.value.to_s when :set @source[feature.key][gate.key].delete thing.value.to_s else raise "#{gate} is not supported by this adapter yet" end true end # Public def inspect attributes = [ 'name=:memory', "source=#{@source.inspect}", ] "#<#{self.class.name}:#{object_id} #{attributes.join(', ')}>" end end end end flipper-0.21.0/lib/flipper/adapters/moneta.rb000066400000000000000000000055211404600161700210570ustar00rootroot00000000000000require 'moneta' module Flipper module Adapters class Moneta include ::Flipper::Adapter FEATURES_KEY = :flipper_features # Public: The name of the adapter. attr_reader :name # Public def initialize(moneta) @moneta = moneta @name = :moneta end # Public: The set of known features def features moneta[FEATURES_KEY] || Set.new end # Public: Adds a feature to the set of known features. def add(feature) moneta[FEATURES_KEY] = features << feature.key.to_s true end # Public: Removes a feature from the set of known features and clears # all the values for the feature. def remove(feature) moneta[FEATURES_KEY] = features.delete(feature.key.to_s) moneta.delete(key(feature.key)) true end # Public: Clears all the gate values for a feature. def clear(feature) moneta[key(feature.key)] = default_config true end # Public: Gets the values for all gates for a given feature. # # Returns a Hash of Flipper::Gate#key => value. def get(feature) default_config.merge(moneta[key(feature.key)].to_h) end # Public: Enables a gate for a given thing. # # feature - The Flipper::Feature for the gate. # gate - The Flipper::Gate to disable. # thing - The Flipper::Type being enabled for the gate. # # Returns true. def enable(feature, gate, thing) case gate.data_type when :boolean clear(feature) result = get(feature) result[gate.key] = thing.value.to_s moneta[key(feature.key)] = result when :integer result = get(feature) result[gate.key] = thing.value.to_s moneta[key(feature.key)] = result when :set result = get(feature) result[gate.key] << thing.value.to_s moneta[key(feature.key)] = result end true end # Public: Disables a gate for a given thing. # # feature - The Flipper::Feature for the gate. # gate - The Flipper::Gate to disable. # thing - The Flipper::Type being disabled for the gate. # # Returns true. def disable(feature, gate, thing) case gate.data_type when :boolean clear(feature) when :integer result = get(feature) result[gate.key] = thing.value.to_s moneta[key(feature.key)] = result when :set result = get(feature) result[gate.key] = result[gate.key].delete(thing.value.to_s) moneta[key(feature.key)] = result end true end private def key(feature_key) "#{FEATURES_KEY}/#{feature_key}" end attr_reader :moneta end end end flipper-0.21.0/lib/flipper/adapters/mongo.rb000066400000000000000000000116351404600161700207160ustar00rootroot00000000000000require 'set' require 'flipper' require 'mongo' module Flipper module Adapters class Mongo include ::Flipper::Adapter # Private: The key that stores the set of known features. FeaturesKey = :flipper_features # Public: The name of the adapter. attr_reader :name # Public: The name of the collection storing the feature data. attr_reader :collection def initialize(collection) @collection = collection @name = :mongo end # Public: The set of known features. def features read_feature_keys end # Public: Adds a feature to the set of known features. def add(feature) update FeaturesKey, '$addToSet' => { 'features' => feature.key } true end # Public: Removes a feature from the set of known features. def remove(feature) update FeaturesKey, '$pull' => { 'features' => feature.key } clear feature true end # Public: Clears all the gate values for a feature. def clear(feature) delete feature.key true end # Public: Gets the values for all gates for a given feature. # # Returns a Hash of Flipper::Gate#key => value. def get(feature) doc = find(feature.key) result_for_feature(feature, doc) end def get_multi(features) read_many_features(features) end def get_all features = read_feature_keys.map { |key| Flipper::Feature.new(key, self) } read_many_features(features) end # Public: Enables a gate for a given thing. # # feature - The Flipper::Feature for the gate. # gate - The Flipper::Gate to disable. # thing - The Flipper::Type being disabled for the gate. # # Returns true. def enable(feature, gate, thing) case gate.data_type when :boolean clear(feature) update feature.key, '$set' => { gate.key.to_s => thing.value.to_s, } when :integer update feature.key, '$set' => { gate.key.to_s => thing.value.to_s, } when :set update feature.key, '$addToSet' => { gate.key.to_s => thing.value.to_s, } else unsupported_data_type gate.data_type end true end # Public: Disables a gate for a given thing. # # feature - The Flipper::Feature for the gate. # gate - The Flipper::Gate to disable. # thing - The Flipper::Type being disabled for the gate. # # Returns true. def disable(feature, gate, thing) case gate.data_type when :boolean delete feature.key when :integer update feature.key, '$set' => { gate.key.to_s => thing.value.to_s } when :set update feature.key, '$pull' => { gate.key.to_s => thing.value.to_s } else unsupported_data_type gate.data_type end true end private def read_feature_keys find(FeaturesKey).fetch('features') { Set.new }.to_set end def read_many_features(features) docs = find_many(features.map(&:key)) result = {} features.each do |feature| result[feature.key] = result_for_feature(feature, docs[feature.key]) end result end # Private def unsupported_data_type(data_type) raise "#{data_type} is not supported by this adapter" end # Private def find(key) @collection.find(_id: key.to_s).limit(1).first || {} end def find_many(keys) docs = @collection.find(_id: { '$in' => keys }).to_a result = Hash.new { |hash, key| hash[key] = {} } docs.each do |doc| result[doc['_id']] = doc end result end # Private def update(key, updates) options = { upsert: true } @collection.find(_id: key.to_s).update_one(updates, options) end # Private def delete(key) @collection.find(_id: key.to_s).delete_one end def result_for_feature(feature, doc) result = {} feature.gates.each do |gate| result[gate.key] = case gate.data_type when :boolean, :integer doc[gate.key.to_s] when :set doc.fetch(gate.key.to_s) { Set.new }.to_set else unsupported_data_type gate.data_type end end result end end end end Flipper.configure do |config| config.adapter do url = ENV["FLIPPER_MONGO_URL"] || ENV["MONGO_URL"] collection = ENV["FLIPPER_MONGO_COLLECTION"] || "flipper" unless url raise ArgumentError, "The MONGO_URL environment variable must be set. For example: mongodb://127.0.0.1:27017/flipper" end Flipper::Adapters::Mongo.new(Mongo::Client.new(url)[collection]) end end flipper-0.21.0/lib/flipper/adapters/operation_logger.rb000066400000000000000000000063651404600161700231420ustar00rootroot00000000000000require 'delegate' module Flipper module Adapters # Public: Adapter that wraps another adapter and stores the operations. # # Useful in tests to verify calls and such. Never use outside of testing. class OperationLogger < SimpleDelegator include ::Flipper::Adapter class Operation attr_reader :type, :args def initialize(type, args) @type = type @args = args end end OperationTypes = [ :features, :add, :remove, :clear, :get, :get_multi, :get_all, :enable, :disable, ].freeze # Internal: An array of the operations that have happened. attr_reader :operations # Internal: The name of the adapter. attr_reader :name # Public def initialize(adapter, operations = nil) super(adapter) @adapter = adapter @name = :operation_logger @operations = operations || [] end # Public: The set of known features. def features @operations << Operation.new(:features, []) @adapter.features end # Public: Adds a feature to the set of known features. def add(feature) @operations << Operation.new(:add, [feature]) @adapter.add(feature) end # Public: Removes a feature from the set of known features and clears # all the values for the feature. def remove(feature) @operations << Operation.new(:remove, [feature]) @adapter.remove(feature) end # Public: Clears all the gate values for a feature. def clear(feature) @operations << Operation.new(:clear, [feature]) @adapter.clear(feature) end # Public def get(feature) @operations << Operation.new(:get, [feature]) @adapter.get(feature) end # Public def get_multi(features) @operations << Operation.new(:get_multi, [features]) @adapter.get_multi(features) end # Public def get_all @operations << Operation.new(:get_all, []) @adapter.get_all end # Public def enable(feature, gate, thing) @operations << Operation.new(:enable, [feature, gate, thing]) @adapter.enable(feature, gate, thing) end # Public def disable(feature, gate, thing) @operations << Operation.new(:disable, [feature, gate, thing]) @adapter.disable(feature, gate, thing) end # Public: Count the number of times a certain operation happened. def count(type) type(type).size end # Public: Get all operations of a certain type. def type(type) @operations.select { |operation| operation.type == type } end # Public: Get the last operation of a certain type. def last(type) @operations.reverse.find { |operation| operation.type == type } end # Public: Resets the operation log to empty def reset @operations.clear end def inspect inspect_id = ::Kernel::format "%x", (object_id * 2) %(#<#{self.class}:0x#{inspect_id} @name=#{name.inspect}, @operations=#{@operations.inspect}, @adapter=#{@adapter.inspect}>) end end end end flipper-0.21.0/lib/flipper/adapters/pstore.rb000066400000000000000000000115741404600161700211150ustar00rootroot00000000000000require 'pstore' require 'set' require 'flipper' module Flipper module Adapters # Public: Adapter based on Ruby's pstore database. Perfect for when a local # file is good enough for storing features. class PStore include ::Flipper::Adapter FeaturesKey = :flipper_features # Public: The name of the adapter. attr_reader :name # Public: The path to where the file is stored. attr_reader :path # Public: PStore's thread_safe option. attr_reader :thread_safe # Public def initialize(path = 'flipper.pstore', thread_safe = false) @path = path @store = ::PStore.new(path, thread_safe) @name = :pstore end # Public: The set of known features. def features @store.transaction do read_feature_keys end end # Public: Adds a feature to the set of known features. def add(feature) @store.transaction do set_add FeaturesKey, feature.key end true end # Public: Removes a feature from the set of known features and clears # all the values for the feature. def remove(feature) @store.transaction do set_delete FeaturesKey, feature.key clear_gates(feature) end true end # Public: Clears all the gate values for a feature. def clear(feature) @store.transaction do clear_gates(feature) end true end # Public def get(feature) @store.transaction do result_for_feature(feature) end end def get_multi(features) @store.transaction do read_many_features(features) end end def get_all @store.transaction do features = read_feature_keys.map { |key| Flipper::Feature.new(key, self) } read_many_features(features) end end # Public def enable(feature, gate, thing) @store.transaction do case gate.data_type when :boolean clear_gates(feature) write key(feature, gate), thing.value.to_s when :integer write key(feature, gate), thing.value.to_s when :set set_add key(feature, gate), thing.value.to_s else raise "#{gate} is not supported by this adapter yet" end end true end # Public def disable(feature, gate, thing) case gate.data_type when :boolean clear(feature) when :integer @store.transaction do write key(feature, gate), thing.value.to_s end when :set @store.transaction do set_delete key(feature, gate), thing.value.to_s end else raise "#{gate} is not supported by this adapter yet" end true end # Public def inspect attributes = [ "name=#{@name.inspect}", "path=#{@path.inspect}", "store=#{@store}", ] "#<#{self.class.name}:#{object_id} #{attributes.join(', ')}>" end private def clear_gates(feature) feature.gates.each do |gate| delete key(feature, gate) end end def read_feature_keys set_members FeaturesKey end def read_many_features(features) result = {} features.each do |feature| result[feature.key] = result_for_feature(feature) end result end def result_for_feature(feature) result = {} feature.gates.each do |gate| result[gate.key] = case gate.data_type when :boolean, :integer read key(feature, gate) when :set set_members key(feature, gate) else raise "#{gate} is not supported by this adapter yet" end end result end # Private def key(feature, gate) "#{feature.key}/#{gate.key}" end # Private def read(key) @store[key.to_s] end # Private def write(key, value) @store[key.to_s] = value.to_s end # Private def delete(key) @store.delete(key.to_s) end # Private def set_add(key, value) set_members(key) do |members| members.add(value.to_s) end end # Private def set_delete(key, value) set_members(key) do |members| members.delete(value.to_s) end end # Private def set_members(key) key = key.to_s @store[key] ||= Set.new if block_given? yield @store[key] else @store[key] end end end end end Flipper.configure do |config| config.adapter { Flipper::Adapters::PStore.new } end flipper-0.21.0/lib/flipper/adapters/read_only.rb000066400000000000000000000022041404600161700215430ustar00rootroot00000000000000require 'flipper' module Flipper module Adapters # Public: Adapter that wraps another adapter and raises for any writes. class ReadOnly include ::Flipper::Adapter class WriteAttempted < Error def initialize(message = nil) super(message || 'write attempted while in read only mode') end end # Internal: The name of the adapter. attr_reader :name # Public def initialize(adapter) @adapter = adapter @name = :read_only end def features @adapter.features end def get(feature) @adapter.get(feature) end def get_multi(features) @adapter.get_multi(features) end def get_all @adapter.get_all end def add(_feature) raise WriteAttempted end def remove(_feature) raise WriteAttempted end def clear(_feature) raise WriteAttempted end def enable(_feature, _gate, _thing) raise WriteAttempted end def disable(_feature, _gate, _thing) raise WriteAttempted end end end end flipper-0.21.0/lib/flipper/adapters/redis.rb000066400000000000000000000113731404600161700207040ustar00rootroot00000000000000require 'set' require 'redis' require 'flipper' module Flipper module Adapters class Redis include ::Flipper::Adapter # Private: The key that stores the set of known features. FeaturesKey = :flipper_features # Public: The name of the adapter. attr_reader :name # Public: Initializes a Redis flipper adapter. # # client - The Redis client to use. Feel free to namespace it. def initialize(client) @client = client @name = :redis end # Public: The set of known features. def features read_feature_keys end # Public: Adds a feature to the set of known features. def add(feature) @client.sadd FeaturesKey, feature.key true end # Public: Removes a feature from the set of known features. def remove(feature) @client.srem FeaturesKey, feature.key @client.del feature.key true end # Public: Clears the gate values for a feature. def clear(feature) @client.del feature.key true end # Public: Gets the values for all gates for a given feature. # # Returns a Hash of Flipper::Gate#key => value. def get(feature) doc = doc_for(feature) result_for_feature(feature, doc) end def get_multi(features) read_many_features(features) end def get_all features = read_feature_keys.map { |key| Flipper::Feature.new(key, self) } read_many_features(features) end # Public: Enables a gate for a given thing. # # feature - The Flipper::Feature for the gate. # gate - The Flipper::Gate to disable. # thing - The Flipper::Type being enabled for the gate. # # Returns true. def enable(feature, gate, thing) case gate.data_type when :boolean clear(feature) @client.hset feature.key, gate.key, thing.value.to_s when :integer @client.hset feature.key, gate.key, thing.value.to_s when :set @client.hset feature.key, to_field(gate, thing), 1 else unsupported_data_type gate.data_type end true end # Public: Disables a gate for a given thing. # # feature - The Flipper::Feature for the gate. # gate - The Flipper::Gate to disable. # thing - The Flipper::Type being disabled for the gate. # # Returns true. def disable(feature, gate, thing) case gate.data_type when :boolean @client.del feature.key when :integer @client.hset feature.key, gate.key, thing.value.to_s when :set @client.hdel feature.key, to_field(gate, thing) else unsupported_data_type gate.data_type end true end private def read_many_features(features) docs = docs_for(features) result = {} features.zip(docs) do |feature, doc| result[feature.key] = result_for_feature(feature, doc) end result end def read_feature_keys @client.smembers(FeaturesKey).to_set end # Private: Gets a hash of fields => values for the given feature. # # Returns a Hash of fields => values. def doc_for(feature) @client.hgetall(feature.key) end def docs_for(features) @client.pipelined do features.each do |feature| doc_for(feature) end end end def result_for_feature(feature, doc) result = {} fields = doc.keys feature.gates.each do |gate| result[gate.key] = case gate.data_type when :boolean, :integer doc[gate.key.to_s] when :set fields_to_gate_value fields, gate else unsupported_data_type gate.data_type end end result end # Private: Converts gate and thing to hash key. def to_field(gate, thing) "#{gate.key}/#{thing.value}" end # Private: Returns a set of values given an array of fields and a gate. # # Returns a Set of the values enabled for the gate. def fields_to_gate_value(fields, gate) regex = %r{^#{Regexp.escape(gate.key.to_s)}/} keys = fields.grep(regex) values = keys.map { |key| key.split('/', 2).last } values.to_set end # Private def unsupported_data_type(data_type) raise "#{data_type} is not supported by this adapter" end end end end Flipper.configure do |config| config.adapter do client = Redis.new(url: ENV["FLIPPER_REDIS_URL"] || ENV["REDIS_URL"]) Flipper::Adapters::Redis.new(client) end end flipper-0.21.0/lib/flipper/adapters/redis_cache.rb000066400000000000000000000072251404600161700220300ustar00rootroot00000000000000require 'redis' require 'flipper' module Flipper module Adapters # Public: Adapter that wraps another adapter with the ability to cache # adapter calls in Redis class RedisCache include ::Flipper::Adapter Version = 'v1'.freeze Namespace = "flipper/#{Version}".freeze FeaturesKey = "#{Namespace}/features".freeze GetAllKey = "#{Namespace}/get_all".freeze # Private def self.key_for(key) "#{Namespace}/feature/#{key}" end # Internal attr_reader :cache # Public: The name of the adapter. attr_reader :name # Public def initialize(adapter, cache, ttl = 3600) @adapter = adapter @name = :redis_cache @cache = cache @ttl = ttl end # Public def features read_feature_keys end # Public def add(feature) result = @adapter.add(feature) @cache.del(FeaturesKey) result end # Public def remove(feature) result = @adapter.remove(feature) @cache.del(FeaturesKey) @cache.del(key_for(feature.key)) result end # Public def clear(feature) result = @adapter.clear(feature) @cache.del(key_for(feature.key)) result end # Public def get(feature) fetch(key_for(feature.key)) do @adapter.get(feature) end end def get_multi(features) read_many_features(features) end def get_all if @cache.setnx(GetAllKey, Time.now.to_i) @cache.expire(GetAllKey, @ttl) response = @adapter.get_all response.each do |key, value| set_with_ttl key_for(key), value end set_with_ttl FeaturesKey, response.keys.to_set response else features = read_feature_keys.map { |key| Flipper::Feature.new(key, self) } read_many_features(features) end end # Public def enable(feature, gate, thing) result = @adapter.enable(feature, gate, thing) @cache.del(key_for(feature.key)) result end # Public def disable(feature, gate, thing) result = @adapter.disable(feature, gate, thing) @cache.del(key_for(feature.key)) result end private def key_for(key) self.class.key_for(key) end def read_feature_keys fetch(FeaturesKey) { @adapter.features } end def read_many_features(features) keys = features.map(&:key) cache_result = Hash[keys.zip(multi_cache_get(keys))] uncached_features = features.reject { |feature| cache_result[feature.key] } if uncached_features.any? response = @adapter.get_multi(uncached_features) response.each do |key, value| set_with_ttl(key_for(key), value) cache_result[key] = value end end result = {} features.each do |feature| result[feature.key] = cache_result[feature.key] end result end def fetch(cache_key) cached = @cache.get(cache_key) if cached Marshal.load(cached) else to_cache = yield set_with_ttl(cache_key, to_cache) to_cache end end def set_with_ttl(key, value) @cache.setex(key, @ttl, Marshal.dump(value)) end def multi_cache_get(keys) return [] if keys.empty? cache_keys = keys.map { |key| key_for(key) } @cache.mget(*cache_keys).map do |value| value ? Marshal.load(value) : nil end end end end end flipper-0.21.0/lib/flipper/adapters/rollout.rb000066400000000000000000000041521404600161700212730ustar00rootroot00000000000000require 'flipper' module Flipper module Adapters class Rollout include Adapter class AdapterMethodNotSupportedError < Error def initialize(message = 'unsupported method called for import adapter') super(message) end end # Public: The name of the adapter. attr_reader :name def initialize(rollout) @rollout = rollout @name = :rollout end # Public: The set of known features. def features @rollout.features end # Public: Gets the values for all gates for a given feature. # # Returns a Hash of Flipper::Gate#key => value. def get(feature) rollout_feature = @rollout.get(feature.key) return default_config if rollout_feature.nil? boolean = nil groups = Set.new(rollout_feature.groups) actors = Set.new(rollout_feature.users) percentage_of_actors = case rollout_feature.percentage when 100 boolean = true groups = Set.new actors = Set.new nil when 0 nil else rollout_feature.percentage end { boolean: boolean, groups: groups, actors: actors, percentage_of_actors: percentage_of_actors, percentage_of_time: nil, } end def add(_feature) raise AdapterMethodNotSupportedError end def remove(_feature) raise AdapterMethodNotSupportedError end def clear(_feature) raise AdapterMethodNotSupportedError end def enable(_feature, _gate, _thing) raise AdapterMethodNotSupportedError end def disable(_feature, _gate, _thing) raise AdapterMethodNotSupportedError end def import(_source_adapter) raise AdapterMethodNotSupportedError end end end end flipper-0.21.0/lib/flipper/adapters/sequel.rb000066400000000000000000000151301404600161700210670ustar00rootroot00000000000000require 'set' require 'flipper' require 'sequel' module Flipper module Adapters class Sequel include ::Flipper::Adapter begin old = ::Sequel::Model.require_valid_table ::Sequel::Model.require_valid_table = false # Private: Do not use outside of this adapter. class Feature < ::Sequel::Model(:flipper_features) unrestrict_primary_key plugin :timestamps, update_on_create: true end # Private: Do not use outside of this adapter. class Gate < ::Sequel::Model(:flipper_gates) unrestrict_primary_key plugin :timestamps, update_on_create: true end ensure ::Sequel::Model.require_valid_table = old end # Public: The name of the adapter. attr_reader :name # Public: Initialize a new Sequel adapter instance. # # name - The Symbol name for this adapter. Optional (default :active_record) # feature_class - The AR class responsible for the features table. # gate_class - The AR class responsible for the gates table. # # Allowing the overriding of name is so you can differentiate multiple # instances of this adapter from each other, if, for some reason, that is # a thing you do. # # Allowing the overriding of the default feature/gate classes means you # can roll your own tables and what not, if you so desire. def initialize(options = {}) @name = options.fetch(:name, :sequel) @feature_class = options.fetch(:feature_class) { Feature } @gate_class = options.fetch(:gate_class) { Gate } end # Public: The set of known features. def features @feature_class.all.map(&:key).to_set end # Public: Adds a feature to the set of known features. def add(feature) # race condition, but add is only used by enable/disable which happen # super rarely, so it shouldn't matter in practice @feature_class.find_or_create(key: feature.key.to_s) true end # Public: Removes a feature from the set of known features. def remove(feature) @feature_class.db.transaction do @feature_class.where(key: feature.key.to_s).delete clear(feature) end true end # Public: Clears the gate values for a feature. def clear(feature) @gate_class.where(feature_key: feature.key.to_s).delete true end # Public: Gets the values for all gates for a given feature. # # Returns a Hash of Flipper::Gate#key => value. def get(feature) db_gates = @gate_class.where(feature_key: feature.key.to_s).all result_for_feature(feature, db_gates) end def get_multi(features) db_gates = @gate_class.where(feature_key: features.map(&:key)).to_a grouped_db_gates = db_gates.group_by(&:feature_key) result = {} features.each do |feature| result[feature.key] = result_for_feature(feature, grouped_db_gates[feature.key]) end result end def get_all db_gates = @gate_class.fetch(<<-SQL).to_a SELECT ff.key AS feature_key, fg.key, fg.value FROM #{@feature_class.table_name} ff LEFT JOIN #{@gate_class.table_name} fg ON ff.key = fg.feature_key SQL grouped_db_gates = db_gates.group_by(&:feature_key) result = Hash.new { |hash, key| hash[key] = default_config } features = grouped_db_gates.keys.map { |key| Flipper::Feature.new(key, self) } features.each do |feature| result[feature.key] = result_for_feature(feature, grouped_db_gates[feature.key]) end result end # Public: Enables a gate for a given thing. # # feature - The Flipper::Feature for the gate. # gate - The Flipper::Gate to disable. # thing - The Flipper::Type being disabled for the gate. # # Returns true. def enable(feature, gate, thing) case gate.data_type when :boolean set(feature, gate, thing, clear: true) when :integer set(feature, gate, thing) when :set begin @gate_class.create(gate_attrs(feature, gate, thing)) rescue ::Sequel::UniqueConstraintViolation end else unsupported_data_type gate.data_type end true end # Public: Disables a gate for a given thing. # # feature - The Flipper::Feature for the gate. # gate - The Flipper::Gate to disable. # thing - The Flipper::Type being disabled for the gate. # # Returns true. def disable(feature, gate, thing) case gate.data_type when :boolean clear(feature) when :integer set(feature, gate, thing) when :set @gate_class.where(gate_attrs(feature, gate, thing)) .delete else unsupported_data_type gate.data_type end true end private def unsupported_data_type(data_type) raise "#{data_type} is not supported by this adapter" end def set(feature, gate, thing, options = {}) clear_feature = options.fetch(:clear, false) args = { feature_key: feature.key, key: gate.key.to_s, } @gate_class.db.transaction do clear(feature) if clear_feature @gate_class.where(args).delete @gate_class.create(gate_attrs(feature, gate, thing)) end end def gate_attrs(feature, gate, thing) { feature_key: feature.key.to_s, key: gate.key.to_s, value: thing.value.to_s, } end def result_for_feature(feature, db_gates) db_gates ||= [] feature.gates.each_with_object({}) do |gate, result| result[gate.key] = case gate.data_type when :boolean if detected_db_gate = db_gates.detect { |db_gate| db_gate.key == gate.key.to_s } detected_db_gate.value end when :integer if detected_db_gate = db_gates.detect { |db_gate| db_gate.key == gate.key.to_s } detected_db_gate.value end when :set db_gates.select { |db_gate| db_gate.key == gate.key.to_s }.map(&:value).to_set else unsupported_data_type gate.data_type end end end end end end Flipper.configure do |config| config.adapter { Flipper::Adapters::Sequel.new } end Sequel::Model.include Flipper::Identifier flipper-0.21.0/lib/flipper/adapters/sync.rb000066400000000000000000000047621404600161700205560ustar00rootroot00000000000000require "flipper/adapters/sync/synchronizer" require "flipper/adapters/sync/interval_synchronizer" module Flipper module Adapters # TODO: Syncing should happen in a background thread on a regular interval # rather than in the main thread only when reads happen. class Sync include ::Flipper::Adapter # Public: The name of the adapter. attr_reader :name # Public: The synchronizer that will keep the local and remote in sync. attr_reader :synchronizer # Public: Build a new sync instance. # # local - The local flipper adapter that should serve reads. # remote - The remote flipper adapter that should serve writes and update # the local on an interval. # interval - The Float or Integer number of seconds between syncs from # remote to local. Default value is set in IntervalSynchronizer. def initialize(local, remote, options = {}) @name = :sync @local = local @remote = remote @synchronizer = options.fetch(:synchronizer) do sync_options = { raise: false, } instrumenter = options[:instrumenter] sync_options[:instrumenter] = instrumenter if instrumenter synchronizer = Synchronizer.new(@local, @remote, sync_options) IntervalSynchronizer.new(synchronizer, interval: options[:interval]) end synchronize end def features synchronize @local.features end def get(feature) synchronize @local.get(feature) end def get_multi(features) synchronize @local.get_multi(features) end def get_all synchronize @local.get_all end def add(feature) result = @remote.add(feature) @local.add(feature) result end def remove(feature) result = @remote.remove(feature) @local.remove(feature) result end def clear(feature) result = @remote.clear(feature) @local.clear(feature) result end def enable(feature, gate, thing) result = @remote.enable(feature, gate, thing) @local.enable(feature, gate, thing) result end def disable(feature, gate, thing) result = @remote.disable(feature, gate, thing) @local.disable(feature, gate, thing) result end private def synchronize @synchronizer.call end end end end flipper-0.21.0/lib/flipper/adapters/sync/000077500000000000000000000000001404600161700202205ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/adapters/sync/feature_synchronizer.rb000066400000000000000000000071571404600161700250270ustar00rootroot00000000000000require "flipper/actor" require "flipper/gate_values" module Flipper module Adapters class Sync # Internal: Given a feature, local gate values and remote gate values, # makes the local equal to the remote. class FeatureSynchronizer extend Forwardable def_delegator :@local_gate_values, :boolean, :local_boolean def_delegator :@local_gate_values, :actors, :local_actors def_delegator :@local_gate_values, :groups, :local_groups def_delegator :@local_gate_values, :percentage_of_actors, :local_percentage_of_actors def_delegator :@local_gate_values, :percentage_of_time, :local_percentage_of_time def_delegator :@remote_gate_values, :boolean, :remote_boolean def_delegator :@remote_gate_values, :actors, :remote_actors def_delegator :@remote_gate_values, :groups, :remote_groups def_delegator :@remote_gate_values, :percentage_of_actors, :remote_percentage_of_actors def_delegator :@remote_gate_values, :percentage_of_time, :remote_percentage_of_time def initialize(feature, local_gate_values, remote_gate_values) @feature = feature @local_gate_values = local_gate_values @remote_gate_values = remote_gate_values end def call if remote_disabled? return if local_disabled? @feature.disable elsif remote_boolean_enabled? return if local_boolean_enabled? @feature.enable else @feature.disable if local_boolean_enabled? sync_actors sync_groups sync_percentage_of_actors sync_percentage_of_time end end private def sync_actors remote_actors_added = remote_actors - local_actors remote_actors_added.each do |flipper_id| @feature.enable_actor Actor.new(flipper_id) end remote_actors_removed = local_actors - remote_actors remote_actors_removed.each do |flipper_id| @feature.disable_actor Actor.new(flipper_id) end end def sync_groups remote_groups_added = remote_groups - local_groups remote_groups_added.each do |group_name| @feature.enable_group group_name end remote_groups_removed = local_groups - remote_groups remote_groups_removed.each do |group_name| @feature.disable_group group_name end end def sync_percentage_of_actors return if local_percentage_of_actors == remote_percentage_of_actors @feature.enable_percentage_of_actors remote_percentage_of_actors end def sync_percentage_of_time return if local_percentage_of_time == remote_percentage_of_time @feature.enable_percentage_of_time remote_percentage_of_time end def default_config @default_config ||= @feature.adapter.default_config end def default_gate_values @default_gate_values ||= GateValues.new(default_config) end def default_gate_values?(gate_values) gate_values == default_gate_values end def local_disabled? default_gate_values? @local_gate_values end def remote_disabled? default_gate_values? @remote_gate_values end def local_boolean_enabled? local_boolean end def remote_boolean_enabled? remote_boolean end end end end end flipper-0.21.0/lib/flipper/adapters/sync/interval_synchronizer.rb000066400000000000000000000027331404600161700252130ustar00rootroot00000000000000module Flipper module Adapters class Sync # Internal: Wraps a Synchronizer instance and only invokes it every # N seconds. class IntervalSynchronizer # Private: Number of seconds between syncs (default: 10). DEFAULT_INTERVAL = 10 # Private def self.now Process.clock_gettime(Process::CLOCK_MONOTONIC, :second) end # Public: The Float or Integer number of seconds between invocations of # the wrapped synchronizer. attr_reader :interval # Public: Initializes a new interval synchronizer. # # synchronizer - The Synchronizer to call when the interval has passed. # interval - The Integer number of seconds between invocations of # the wrapped synchronizer. def initialize(synchronizer, interval: nil) @synchronizer = synchronizer @interval = interval || DEFAULT_INTERVAL # TODO: add jitter to this so all processes booting at the same time # don't phone home at the same time. @last_sync_at = 0 end def call return unless time_to_sync? @last_sync_at = now @synchronizer.call nil end private def time_to_sync? seconds_since_last_sync = now - @last_sync_at seconds_since_last_sync >= @interval end def now self.class.now end end end end end flipper-0.21.0/lib/flipper/adapters/sync/synchronizer.rb000066400000000000000000000045401404600161700233050ustar00rootroot00000000000000require "flipper/feature" require "flipper/gate_values" require "flipper/adapters/sync/feature_synchronizer" module Flipper module Adapters class Sync # Public: Given a local and remote adapter, it can update the local to # match the remote doing only the necessary enable/disable operations. class Synchronizer # Public: Initializes a new synchronizer. # # local - The Flipper adapter to get in sync with the remote. # remote - The Flipper adapter that is source of truth that the local # adapter should be brought in line with. # options - The Hash of options. # :instrumenter - The instrumenter used to instrument. # :raise - Should errors be raised (default: true). def initialize(local, remote, options = {}) @local = local @remote = remote @instrumenter = options.fetch(:instrumenter, Instrumenters::Noop) @raise = options.fetch(:raise, true) end # Public: Forces a sync. def call @instrumenter.instrument("synchronizer_call.flipper") { sync } end private def sync local_get_all = @local.get_all remote_get_all = @remote.get_all # Sync all the gate values. remote_get_all.each do |feature_key, remote_gates_hash| feature = Feature.new(feature_key, @local) local_gates_hash = local_get_all[feature_key] || @local.default_config local_gate_values = GateValues.new(local_gates_hash) remote_gate_values = GateValues.new(remote_gates_hash) FeatureSynchronizer.new(feature, local_gate_values, remote_gate_values).call end # Add features that are missing in local and present in remote. features_to_add = remote_get_all.keys - local_get_all.keys features_to_add.each { |key| Feature.new(key, @local).add } # Remove features that are present in local and missing in remote. features_to_remove = local_get_all.keys - remote_get_all.keys features_to_remove.each { |key| Feature.new(key, @local).remove } nil rescue => exception @instrumenter.instrument("synchronizer_exception.flipper", exception: exception) raise if @raise end end end end end flipper-0.21.0/lib/flipper/api.rb000066400000000000000000000016371404600161700165460ustar00rootroot00000000000000require 'rack' require 'flipper' require 'flipper/api/middleware' require 'flipper/api/json_params' module Flipper module Api CONTENT_TYPE = 'application/json'.freeze def self.app(flipper = nil, options = {}) env_key = options.fetch(:env_key, 'flipper') memoizer_options = options.fetch(:memoizer_options, {}) app = ->(_) { [404, { 'Content-Type'.freeze => CONTENT_TYPE }, ['{}'.freeze]] } builder = Rack::Builder.new yield builder if block_given? builder.use Flipper::Api::JsonParams builder.use Flipper::Middleware::SetupEnv, flipper, env_key: env_key builder.use Flipper::Middleware::Memoizer, memoizer_options.merge(env_key: env_key) builder.use Flipper::Api::Middleware, env_key: env_key builder.run app klass = self builder.define_singleton_method(:inspect) { klass.inspect } # pretty rake routes output builder end end end flipper-0.21.0/lib/flipper/api/000077500000000000000000000000001404600161700162125ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/api/action.rb000066400000000000000000000115011404600161700200120ustar00rootroot00000000000000require 'forwardable' require 'flipper/api/error' require 'flipper/api/error_response' require 'json' module Flipper module Api class Action module FeatureNameFromRoute def feature_name @feature_name ||= begin match = request.path_info.match(self.class.route_regex) match ? Rack::Utils.unescape(match[:feature_name]) : nil end end private :feature_name end extend Forwardable VALID_REQUEST_METHOD_NAMES = Set.new([ 'get'.freeze, 'post'.freeze, 'put'.freeze, 'delete'.freeze, ]).freeze # Public: Call this in subclasses so the action knows its route. # # regex - The Regexp that this action should run for. # # Returns nothing. def self.route(regex) @route_regex = regex end # Internal: Does this action's route match the path. def self.route_match?(path) path.match(route_regex) end # Internal: The regex that matches which routes this action will work for. def self.route_regex @route_regex || raise("#{name}.route is not set") end # Internal: Initializes and runs an action for a given request. # # flipper - The Flipper::DSL instance. # request - The Rack::Request that was sent. # # Returns result of Action#run. def self.run(flipper, request) new(flipper, request).run end # Public: The instance of the Flipper::DSL the middleware was # initialized with. attr_reader :flipper # Public: The Rack::Request to provide a response for. attr_reader :request # Public: The params for the request. def_delegator :@request, :params def initialize(flipper, request) @flipper = flipper @request = request @code = 200 @headers = { 'Content-Type' => Api::CONTENT_TYPE } end # Public: Runs the request method for the provided request. # # Returns whatever the request method returns in the action. def run if valid_request_method? && respond_to?(request_method_name) catch(:halt) { send(request_method_name) } else raise Api::RequestMethodNotSupported, "#{self.class} does not support request method #{request_method_name.inspect}" end end # Public: Runs another action from within the request method of a # different action. # # action_class - The class of the other action to run. # # Examples # # run_other_action Home # # => result of running Home action # # Returns result of other action. def run_other_action(action_class) action_class.new(flipper, request).run end # Public: Call this with a response to immediately stop the current action # and respond however you want. # # response - The response you would like to return. def halt(response) throw :halt, response end # Public: Call this with a json serializable object (i.e. Hash) # to serialize object and respond to request # # object - json serializable object # status - http status code def json_response(object, status = 200) header 'Content-Type', Api::CONTENT_TYPE status(status) body = JSON.dump(object) halt [@code, @headers, [body]] end # Public: Call this with an ErrorResponse::ERRORS key to respond # with the serialized error object as response body # # error_key - key to lookup error object def json_error_response(error_key) error = ErrorResponse::ERRORS.fetch(error_key.to_sym) json_response(error.as_json, error.http_status) end # Public: Set the status code for the response. # # code - The Integer code you would like the response to return. def status(code) @code = code.to_i end # Public: Set a header. # # name - The String name of the header. # value - The value of the header. def header(name, value) @headers[name] = value end private # Private: Returns the request method converted to an action method. def request_method_name @request_method_name ||= @request.request_method.downcase end # Private: split request path by "/" # Example: "features/feature_name" => ['features', 'feature_name'] def path_parts @request.path.split('/') end def valid_request_method? VALID_REQUEST_METHOD_NAMES.include?(request_method_name) end end end end flipper-0.21.0/lib/flipper/api/action_collection.rb000066400000000000000000000007121404600161700222270ustar00rootroot00000000000000module Flipper module Api # Internal: Used to detect the action that should be used in the middleware. class ActionCollection def initialize @action_classes = [] end def add(action_class) @action_classes << action_class end def action_for_request(request) @action_classes.detect do |action_class| action_class.route_match?(request.path_info) end end end end end flipper-0.21.0/lib/flipper/api/error.rb000066400000000000000000000004451404600161700176730ustar00rootroot00000000000000module Flipper module Api # All flipper api errors inherit from this. Error = Class.new(StandardError) # Raised when a request method (get, post, etc.) is called for an action # that does not know how to handle it. RequestMethodNotSupported = Class.new(Error) end end flipper-0.21.0/lib/flipper/api/error_response.rb000066400000000000000000000017751404600161700216200ustar00rootroot00000000000000module Flipper module Api module ErrorResponse class Error attr_reader :http_status def initialize(code, message, http_status) @code = code @message = message @more_info = 'https://github.com/jnunemaker/flipper/tree/master/docs/api#error-code-reference' @http_status = http_status end def as_json { code: @code, message: @message, more_info: @more_info, } end end ERRORS = { feature_not_found: Error.new(1, 'Feature not found.', 404), group_not_registered: Error.new(2, 'Group not registered.', 404), percentage_invalid: Error.new(3, 'Percentage must be a positive number less than or equal to 100.', 422), flipper_id_invalid: Error.new(4, 'Required parameter flipper_id is missing.', 422), name_invalid: Error.new(5, 'Required parameter name is missing.', 422), }.freeze end end end flipper-0.21.0/lib/flipper/api/json_params.rb000066400000000000000000000025171404600161700210600ustar00rootroot00000000000000require 'rack/utils' module Flipper module Api class JsonParams include Rack::Utils def initialize(app) @app = app end CONTENT_TYPE = 'CONTENT_TYPE'.freeze QUERY_STRING = 'QUERY_STRING'.freeze REQUEST_BODY = 'rack.input'.freeze # Public: Merge request body params with query string params # This way can access all params with Rack::Request#params # Rack does not add application/json params to Rack::Request#params # Allows app to handle x-www-url-form-encoded / application/json request # parameters the same way def call(env) if env[CONTENT_TYPE] == 'application/json' body = env[REQUEST_BODY].read env[REQUEST_BODY].rewind update_params(env, body) end @app.call(env) end private # Rails 3.2.2.1 Rack version does not have Rack::Request#update_param # Rack 1.5.0 adds update_param # This method accomplishes similar functionality def update_params(env, data) return if data.empty? parsed_request_body = JSON.parse(data) parsed_query_string = parse_query(env[QUERY_STRING]) parsed_query_string.merge!(parsed_request_body) parameters = build_query(parsed_query_string) env[QUERY_STRING] = parameters end end end end flipper-0.21.0/lib/flipper/api/middleware.rb000066400000000000000000000025401404600161700206550ustar00rootroot00000000000000require 'rack' require 'flipper/api/action_collection' # Require all V1 actions automatically. Pathname(__FILE__).dirname.join('v1/actions').each_child(false) do |name| require "flipper/api/v1/actions/#{name}" end module Flipper module Api class Middleware def initialize(app, options = {}) @app = app @env_key = options.fetch(:env_key, 'flipper') @action_collection = ActionCollection.new @action_collection.add Api::V1::Actions::PercentageOfTimeGate @action_collection.add Api::V1::Actions::PercentageOfActorsGate @action_collection.add Api::V1::Actions::ActorsGate @action_collection.add Api::V1::Actions::GroupsGate @action_collection.add Api::V1::Actions::BooleanGate @action_collection.add Api::V1::Actions::ClearFeature @action_collection.add Api::V1::Actions::Actors @action_collection.add Api::V1::Actions::Feature @action_collection.add Api::V1::Actions::Features end def call(env) dup.call!(env) end def call!(env) request = Rack::Request.new(env) action_class = @action_collection.action_for_request(request) if action_class.nil? @app.call(env) else flipper = env.fetch(@env_key) action_class.run(flipper, request) end end end end end flipper-0.21.0/lib/flipper/api/v1/000077500000000000000000000000001404600161700165405ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/api/v1/actions/000077500000000000000000000000001404600161700202005ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/api/v1/actions/actors.rb000066400000000000000000000020061404600161700220160ustar00rootroot00000000000000require 'flipper/api/action' require 'flipper/api/v1/decorators/actor' module Flipper module Api module V1 module Actions class Actors < Api::Action route %r{\A/actors/(?.*)/?\Z} def get keys = params['keys'] features = if keys names = keys.split(',') if names.empty? [] else flipper.preload(names) end else flipper.features end actor = Flipper::Actor.new(flipper_id) decorated_actor = Decorators::Actor.new(actor, features) json_response(decorated_actor.as_json) end private def flipper_id match = request.path_info.match(self.class.route_regex) match ? match[:flipper_id] : nil end end end end end end flipper-0.21.0/lib/flipper/api/v1/actions/actors_gate.rb000066400000000000000000000022351404600161700230220ustar00rootroot00000000000000require 'flipper/api/action' require 'flipper/api/v1/decorators/feature' module Flipper module Api module V1 module Actions class ActorsGate < Api::Action include FeatureNameFromRoute route %r{\A/features/(?.*)/actors/?\Z} def post ensure_valid_params feature = flipper[feature_name] actor = Actor.new(flipper_id) feature.enable_actor(actor) decorated_feature = Decorators::Feature.new(feature) json_response(decorated_feature.as_json, 200) end def delete ensure_valid_params feature = flipper[feature_name] actor = Actor.new(flipper_id) feature.disable_actor(actor) decorated_feature = Decorators::Feature.new(feature) json_response(decorated_feature.as_json, 200) end private def ensure_valid_params json_error_response(:flipper_id_invalid) if flipper_id.nil? end def flipper_id @flipper_id ||= params['flipper_id'] end end end end end end flipper-0.21.0/lib/flipper/api/v1/actions/boolean_gate.rb000066400000000000000000000014231404600161700231440ustar00rootroot00000000000000require 'flipper/api/action' require 'flipper/api/v1/decorators/feature' module Flipper module Api module V1 module Actions class BooleanGate < Api::Action include FeatureNameFromRoute route %r{\A/features/(?.*)/boolean/?\Z} def post feature = flipper[feature_name] feature.enable decorated_feature = Decorators::Feature.new(feature) json_response(decorated_feature.as_json, 200) end def delete feature = flipper[feature_name.to_sym] feature.disable decorated_feature = Decorators::Feature.new(feature) json_response(decorated_feature.as_json, 200) end end end end end end flipper-0.21.0/lib/flipper/api/v1/actions/clear_feature.rb000066400000000000000000000007151404600161700233310ustar00rootroot00000000000000require 'flipper/api/action' require 'flipper/api/v1/decorators/feature' module Flipper module Api module V1 module Actions class ClearFeature < Api::Action include FeatureNameFromRoute route %r{\A/features/(?.*)/clear/?\Z} def delete feature = flipper[feature_name] feature.clear json_response({}, 204) end end end end end end flipper-0.21.0/lib/flipper/api/v1/actions/feature.rb000066400000000000000000000014431404600161700221620ustar00rootroot00000000000000require 'flipper/api/action' require 'flipper/api/v1/decorators/feature' module Flipper module Api module V1 module Actions class Feature < Api::Action include FeatureNameFromRoute route %r{\A/features/(?.*)/?\Z} def get return json_error_response(:feature_not_found) unless feature_exists?(feature_name) feature = Decorators::Feature.new(flipper[feature_name]) json_response(feature.as_json) end def delete flipper.remove(feature_name) json_response({}, 204) end private def feature_exists?(feature_name) flipper.features.map(&:key).include?(feature_name) end end end end end end flipper-0.21.0/lib/flipper/api/v1/actions/features.rb000066400000000000000000000025371404600161700223520ustar00rootroot00000000000000require 'flipper/api/action' require 'flipper/api/v1/decorators/feature' module Flipper module Api module V1 module Actions class Features < Api::Action route %r{\A/features/?\Z} def get keys = params['keys'] features = if keys names = keys.split(',') if names.empty? [] else existing_feature_names = names.keep_if do |feature_name| feature_exists?(feature_name) end flipper.preload(existing_feature_names) end else flipper.features end decorated_features = features.map do |feature| Decorators::Feature.new(feature).as_json end json_response(features: decorated_features) end def post feature_name = params.fetch('name') { json_error_response(:name_invalid) } feature = flipper[feature_name] feature.add decorated_feature = Decorators::Feature.new(feature) json_response(decorated_feature.as_json, 200) end private def feature_exists?(feature_name) flipper.features.map(&:key).include?(feature_name) end end end end end end flipper-0.21.0/lib/flipper/api/v1/actions/groups_gate.rb000066400000000000000000000031201404600161700230400ustar00rootroot00000000000000require 'flipper/api/action' require 'flipper/api/v1/decorators/feature' module Flipper module Api module V1 module Actions class GroupsGate < Api::Action include FeatureNameFromRoute route %r{\A/features/(?.*)/groups/?\Z} def post ensure_valid_params feature = flipper[feature_name] feature.enable_group(group_name) decorated_feature = Decorators::Feature.new(feature) json_response(decorated_feature.as_json, 200) end def delete ensure_valid_params feature = flipper[feature_name] feature.disable_group(group_name) decorated_feature = Decorators::Feature.new(feature) json_response(decorated_feature.as_json, 200) end private def ensure_valid_params if group_name.nil? || group_name.empty? json_error_response(:name_invalid) end return if allow_unregistered_groups? return if Flipper.group_exists?(group_name) json_error_response(:group_not_registered) end def allow_unregistered_groups? allow_unregistered_groups = params['allow_unregistered_groups'] allow_unregistered_groups && allow_unregistered_groups == 'true' end def disallow_unregistered_groups? !allow_unregistered_groups? end def group_name @group_name ||= params['name'] end end end end end end flipper-0.21.0/lib/flipper/api/v1/actions/percentage_of_actors_gate.rb000066400000000000000000000027041404600161700257040ustar00rootroot00000000000000require 'flipper/api/action' require 'flipper/api/v1/decorators/feature' module Flipper module Api module V1 module Actions class PercentageOfActorsGate < Api::Action include FeatureNameFromRoute route %r{\A/features/(?.*)/percentage_of_actors/?\Z} def post if percentage < 0 || percentage > 100 json_error_response(:percentage_invalid) end feature = flipper[feature_name] feature.enable_percentage_of_actors(percentage) decorated_feature = Decorators::Feature.new(feature) json_response(decorated_feature.as_json, 200) end def delete feature = flipper[feature_name] feature.disable_percentage_of_actors decorated_feature = Decorators::Feature.new(feature) json_response(decorated_feature.as_json, 200) end private def percentage_param @percentage_param ||= params['percentage'].to_s end def percentage @percentage ||= begin unless percentage_param.match(/\d/) raise ArgumentError, "invalid numeric value: #{percentage_param}" end Flipper::Types::Percentage.new(percentage_param).value rescue ArgumentError, TypeError -1 end end end end end end end flipper-0.21.0/lib/flipper/api/v1/actions/percentage_of_time_gate.rb000066400000000000000000000026751404600161700253560ustar00rootroot00000000000000require 'flipper/api/action' require 'flipper/api/v1/decorators/feature' module Flipper module Api module V1 module Actions class PercentageOfTimeGate < Api::Action include FeatureNameFromRoute route %r{\A/features/(?.*)/percentage_of_time/?\Z} def post if percentage < 0 || percentage > 100 json_error_response(:percentage_invalid) end feature = flipper[feature_name] feature.enable_percentage_of_time(percentage) decorated_feature = Decorators::Feature.new(feature) json_response(decorated_feature.as_json, 200) end def delete feature = flipper[feature_name] feature.disable_percentage_of_time decorated_feature = Decorators::Feature.new(feature) json_response(decorated_feature.as_json, 200) end private def percentage_param @percentage_param ||= params['percentage'].to_s end def percentage @percentage ||= begin unless percentage_param.match(/\d/) raise ArgumentError, "invalid numeric value: #{percentage_param}" end Flipper::Types::Percentage.new(percentage_param).value rescue ArgumentError, TypeError -1 end end end end end end end flipper-0.21.0/lib/flipper/api/v1/decorators/000077500000000000000000000000001404600161700207055ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/api/v1/decorators/actor.rb000066400000000000000000000014311404600161700223410ustar00rootroot00000000000000module Flipper module Api module V1 module Decorators class Actor < SimpleDelegator # Public: the actor and features. attr_reader :actor, :features def initialize(actor, features) @actor = actor @features = features end def as_json { 'flipper_id' => actor.flipper_id, 'features' => features_data, } end private def features_data features.each_with_object({}) do |feature, features_hash| features_hash[feature.name] = { 'enabled' => feature.enabled?(actor), } features_hash end end end end end end end flipper-0.21.0/lib/flipper/api/v1/decorators/feature.rb000066400000000000000000000013341404600161700226660ustar00rootroot00000000000000require 'delegate' require 'flipper/api/v1/decorators/gate' module Flipper module Api module V1 module Decorators class Feature < SimpleDelegator # Public: The feature being decorated. alias_method :feature, :__getobj__ # Public: Returns instance as hash that is ready to be json dumped. def as_json gate_values = feature.adapter.get(self) gates_json = gates.map do |gate| Decorators::Gate.new(gate, gate_values[gate.key]).as_json end { 'key' => key, 'state' => state.to_s, 'gates' => gates_json, } end end end end end end flipper-0.21.0/lib/flipper/api/v1/decorators/gate.rb000066400000000000000000000013701404600161700221530ustar00rootroot00000000000000module Flipper module Api module V1 module Decorators class Gate < SimpleDelegator # Public the gate being decorated alias_method :gate, :__getobj__ # Public: the value for the gate from the adapter. attr_reader :value def initialize(gate, value = nil) super gate @value = value end def as_json { 'key' => gate.key.to_s, 'name' => gate.name.to_s, 'value' => value_as_json, } end private # json doesn't like sets def value_as_json data_type == :set ? value.to_a : value end end end end end end flipper-0.21.0/lib/flipper/cloud.rb000066400000000000000000000044471404600161700171050ustar00rootroot00000000000000require "flipper" require "flipper/middleware/setup_env" require "flipper/middleware/memoizer" require "flipper/cloud/configuration" require "flipper/cloud/dsl" require "flipper/cloud/middleware" require "flipper/cloud/engine" if defined?(Rails::Engine) module Flipper module Cloud # Public: Returns a new Flipper instance with an http adapter correctly # configured for flipper cloud. # # token - The String token for the environment from the website. # options - The Hash of options. See Flipper::Cloud::Configuration. # block - The block that configuration will be yielded to allowing you to # customize this cloud instance and its adapter. def self.new(options = {}, deprecated_options = {}) if options.is_a?(String) warn "`Flipper::Cloud.new(token)` is deprecated. Use `Flipper::Cloud.new(token: token)` " + "or set the `FLIPPER_CLOUD_TOKEN` environment variable.\n" + caller[0] options = deprecated_options.merge(token: options) end configuration = Configuration.new(options) yield configuration if block_given? DSL.new(configuration) end def self.app(flipper = nil, options = {}) env_key = options.fetch(:env_key, 'flipper') memoizer_options = options.fetch(:memoizer_options, {}) app = ->(_) { [404, { 'Content-Type'.freeze => 'application/json'.freeze }, ['{}'.freeze]] } builder = Rack::Builder.new yield builder if block_given? builder.use Flipper::Middleware::SetupEnv, flipper, env_key: env_key builder.use Flipper::Middleware::Memoizer, memoizer_options.merge(env_key: env_key) builder.use Flipper::Cloud::Middleware, env_key: env_key builder.run app klass = self builder.define_singleton_method(:inspect) { klass.inspect } # pretty rake routes output builder end # Private: Configure Flipper to use Cloud by default def self.set_default Flipper.configure do |config| config.default do if ENV["FLIPPER_CLOUD_TOKEN"] self.new(local_adapter: config.adapter) else warn "Missing FLIPPER_CLOUD_TOKEN environment variable. Disabling Flipper::Cloud." Flipper.new(config.adapter) end end end end end end Flipper::Cloud.set_default flipper-0.21.0/lib/flipper/cloud/000077500000000000000000000000001404600161700165475ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/cloud/configuration.rb000066400000000000000000000140101404600161700217370ustar00rootroot00000000000000require "socket" require "flipper/adapters/http" require "flipper/adapters/memory" require "flipper/adapters/dual_write" require "flipper/adapters/sync" module Flipper module Cloud class Configuration # The set of valid ways that syncing can happpen. VALID_SYNC_METHODS = Set[ :poll, :webhook, ].freeze DEFAULT_URL = "https://www.flippercloud.io/adapter".freeze # Public: The token corresponding to an environment on flippercloud.io. attr_accessor :token # Public: The url for http adapter. Really should only be customized for # development work. Feel free to forget you ever saw this. attr_reader :url # Public: net/http read timeout for all http requests (default: 5). attr_accessor :read_timeout # Public: net/http open timeout for all http requests (default: 5). attr_accessor :open_timeout # Public: net/http write timeout for all http requests (default: 5). attr_accessor :write_timeout # Public: IO stream to send debug output too. Off by default. # # # for example, this would send all http request information to STDOUT # configuration = Flipper::Cloud::Configuration.new # configuration.debug_output = STDOUT attr_accessor :debug_output # Public: Instrumenter to use for the Flipper instance returned by # Flipper::Cloud.new (default: Flipper::Instrumenters::Noop). # # # for example, to use active support notifications you could do: # configuration = Flipper::Cloud::Configuration.new # configuration.instrumenter = ActiveSupport::Notifications attr_accessor :instrumenter # Public: Local adapter that all reads should go to in order to ensure # latency is low and resiliency is high. This adapter is automatically # kept in sync with cloud. # # # for example, to use active record you could do: # configuration = Flipper::Cloud::Configuration.new # configuration.local_adapter = Flipper::Adapters::ActiveRecord.new attr_accessor :local_adapter # Public: The Integer or Float number of seconds between attempts to bring # the local in sync with cloud (default: 10). attr_accessor :sync_interval # Public: The secret used to verify if syncs in the middleware should # occur or not. attr_accessor :sync_secret def initialize(options = {}) @token = options.fetch(:token) { ENV["FLIPPER_CLOUD_TOKEN"] } if @token.nil? raise ArgumentError, "Flipper::Cloud token is missing. Please set FLIPPER_CLOUD_TOKEN or provide the token (e.g. Flipper::Cloud.new(token: 'token'))." end if ENV["FLIPPER_CLOUD_SYNC_METHOD"] warn "FLIPPER_CLOUD_SYNC_METHOD is deprecated and has no effect." end self.sync_method = options[:sync_method] if options[:sync_method] @instrumenter = options.fetch(:instrumenter, Instrumenters::Noop) @read_timeout = options.fetch(:read_timeout) { ENV.fetch("FLIPPER_CLOUD_READ_TIMEOUT", 5).to_f } @open_timeout = options.fetch(:open_timeout) { ENV.fetch("FLIPPER_CLOUD_OPEN_TIMEOUT", 5).to_f } @write_timeout = options.fetch(:write_timeout) { ENV.fetch("FLIPPER_CLOUD_WRITE_TIMEOUT", 5).to_f } @sync_interval = options.fetch(:sync_interval) { ENV.fetch("FLIPPER_CLOUD_SYNC_INTERVAL", 10).to_f } @sync_secret = options.fetch(:sync_secret) { ENV["FLIPPER_CLOUD_SYNC_SECRET"] } @local_adapter = options.fetch(:local_adapter) { Adapters::Memory.new } @debug_output = options[:debug_output] @adapter_block = ->(adapter) { adapter } self.url = options.fetch(:url) { ENV.fetch("FLIPPER_CLOUD_URL", DEFAULT_URL) } end # Public: Read or customize the http adapter. Calling without a block will # perform a read. Calling with a block yields the cloud adapter # for customization. # # # for example, to instrument the http calls, you can wrap the http # # adapter with the intsrumented adapter # configuration = Flipper::Cloud::Configuration.new # configuration.adapter do |adapter| # Flipper::Adapters::Instrumented.new(adapter) # end # def adapter(&block) if block_given? @adapter_block = block else @adapter_block.call app_adapter end end # Public: Set url for the http adapter. attr_writer :url def sync Flipper::Adapters::Sync::Synchronizer.new(local_adapter, http_adapter, { instrumenter: instrumenter, interval: sync_interval, }).call end # Public: The method that will be used to synchronize local adapter with # cloud. (default: :poll, will be :webhook if sync_secret is set). def sync_method sync_secret ? :webhook : :poll end def sync_method=(_) warn "Flipper::Cloud: sync_method is deprecated and has no effect." end private def app_adapter sync_method == :webhook ? dual_write_adapter : sync_adapter end def dual_write_adapter Flipper::Adapters::DualWrite.new(local_adapter, http_adapter) end def sync_adapter Flipper::Adapters::Sync.new(local_adapter, http_adapter, { instrumenter: instrumenter, interval: sync_interval, }) end def http_adapter Flipper::Adapters::Http.new({ url: @url, read_timeout: @read_timeout, open_timeout: @open_timeout, debug_output: @debug_output, headers: { "Flipper-Cloud-Token" => @token, "Feature-Flipper-Token" => @token, "Client-Lang" => "ruby", "Client-Lang-Version" => "#{RUBY_VERSION} p#{RUBY_PATCHLEVEL} (#{RUBY_RELEASE_DATE})", "Client-Platform" => RUBY_PLATFORM, "Client-Engine" => defined?(RUBY_ENGINE) ? RUBY_ENGINE : "", "Client-Hostname" => Socket.gethostname, }, }) end end end end flipper-0.21.0/lib/flipper/cloud/dsl.rb000066400000000000000000000012751404600161700176630ustar00rootroot00000000000000require 'forwardable' module Flipper module Cloud class DSL < SimpleDelegator attr_reader :cloud_configuration def initialize(cloud_configuration) @cloud_configuration = cloud_configuration super Flipper.new(@cloud_configuration.adapter, instrumenter: @cloud_configuration.instrumenter) end def sync @cloud_configuration.sync end def sync_secret @cloud_configuration.sync_secret end def inspect inspect_id = ::Kernel::format "%x", (object_id * 2) %(#<#{self.class}:0x#{inspect_id} @cloud_configuration=#{cloud_configuration.inspect}, flipper=#{__getobj__.inspect}>) end end end end flipper-0.21.0/lib/flipper/cloud/engine.rb000066400000000000000000000014771404600161700203520ustar00rootroot00000000000000require "flipper/railtie" module Flipper module Cloud class Engine < Rails::Engine paths["config/routes.rb"] = ["lib/flipper/cloud/routes.rb"] config.before_configuration do config.flipper.cloud_path = "_flipper" end initializer "flipper.cloud.default", before: :load_config_initializers do |app| Flipper.configure do |config| config.default do if ENV["FLIPPER_CLOUD_TOKEN"] Flipper::Cloud.new( local_adapter: config.adapter, instrumenter: app.config.flipper.instrumenter ) else warn "Missing FLIPPER_CLOUD_TOKEN environment variable. Disabling Flipper::Cloud." Flipper.new(config.adapter) end end end end end end end flipper-0.21.0/lib/flipper/cloud/message_verifier.rb000066400000000000000000000065371404600161700224260ustar00rootroot00000000000000require "openssl" require "digest/sha2" module Flipper module Cloud class MessageVerifier class InvalidSignature < StandardError; end DEFAULT_VERSION = "v1" def self.header(signature, timestamp, version = DEFAULT_VERSION) raise ArgumentError, "timestamp should be an instance of Time" unless timestamp.is_a?(Time) raise ArgumentError, "signature should be a string" unless signature.is_a?(String) "t=#{timestamp.to_i},#{version}=#{signature}" end def initialize(secret:, version: DEFAULT_VERSION) @secret = secret @version = version || DEFAULT_VERSION raise ArgumentError, "secret should be a string" unless @secret.is_a?(String) raise ArgumentError, "version should be a string" unless @version.is_a?(String) end def generate(payload, timestamp) raise ArgumentError, "timestamp should be an instance of Time" unless timestamp.is_a?(Time) raise ArgumentError, "payload should be a string" unless payload.is_a?(String) OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), @secret, "#{timestamp.to_i}.#{payload}") end def header(signature, timestamp) self.class.header(signature, timestamp, @version) end # Public: Verifies the signature header for a given payload. # # Raises a InvalidSignature in the following cases: # - the header does not match the expected format # - no signatures found with the expected scheme # - no signatures matching the expected signature # - a tolerance is provided and the timestamp is not within the # tolerance # # Returns true otherwise. def verify(payload, header, tolerance: nil) begin timestamp, signatures = get_timestamp_and_signatures(header) rescue StandardError raise InvalidSignature, "Unable to extract timestamp and signatures from header" end if signatures.empty? raise InvalidSignature, "No signatures found with expected version #{@version}" end expected_sig = generate(payload, timestamp) unless signatures.any? { |s| secure_compare(expected_sig, s) } raise InvalidSignature, "No signatures found matching the expected signature for payload" end if tolerance && timestamp < Time.now - tolerance raise InvalidSignature, "Timestamp outside the tolerance zone (#{Time.at(timestamp)})" end true end private # Extracts the timestamp and the signature(s) with the desired version # from the header def get_timestamp_and_signatures(header) list_items = header.split(/,\s*/).map { |i| i.split("=", 2) } timestamp = Integer(list_items.select { |i| i[0] == "t" }[0][1]) signatures = list_items.select { |i| i[0] == @version }.map { |i| i[1] } [Time.at(timestamp), signatures] end # Private def fixed_length_secure_compare(a, b) raise ArgumentError, "string length mismatch." unless a.bytesize == b.bytesize l = a.unpack "C#{a.bytesize}" res = 0 b.each_byte { |byte| res |= byte ^ l.shift } res == 0 end # Private def secure_compare(a, b) fixed_length_secure_compare(::Digest::SHA256.digest(a), ::Digest::SHA256.digest(b)) && a == b end end end end flipper-0.21.0/lib/flipper/cloud/middleware.rb000066400000000000000000000037351404600161700212210ustar00rootroot00000000000000# frozen_string_literal: true require "flipper/cloud/message_verifier" module Flipper module Cloud class Middleware # Internal: The path to match for webhook requests. WEBHOOK_PATH = %r{\A/webhooks\/?\Z} # Internal: The root path to match for requests. ROOT_PATH = %r{\A/\Z} def initialize(app, options = {}) @app = app @env_key = options.fetch(:env_key, 'flipper') end def call(env) dup.call!(env) end def call!(env) request = Rack::Request.new(env) if request.post? && (request.path_info.match(ROOT_PATH) || request.path_info.match(WEBHOOK_PATH)) status = 200 headers = { "Content-Type" => "application/json", } body = "{}" payload = request.body.read signature = request.env["HTTP_FLIPPER_CLOUD_SIGNATURE"] flipper = env.fetch(@env_key) begin message_verifier = MessageVerifier.new(secret: flipper.sync_secret) if message_verifier.verify(payload, signature) begin flipper.sync body = JSON.generate({ groups: Flipper.group_names.map { |name| {name: name}} }) rescue Flipper::Adapters::Http::Error => error status = error.response.code.to_i == 402 ? 402 : 500 headers["Flipper-Cloud-Response-Error-Class"] = error.class.name headers["Flipper-Cloud-Response-Error-Message"] = error.message rescue => error status = 500 headers["Flipper-Cloud-Response-Error-Class"] = error.class.name headers["Flipper-Cloud-Response-Error-Message"] = error.message end end rescue MessageVerifier::InvalidSignature status = 400 end [status, headers, [body]] else @app.call(env) end end end end end flipper-0.21.0/lib/flipper/cloud/routes.rb000066400000000000000000000005751404600161700204240ustar00rootroot00000000000000# Default routes loaded by Flipper::Cloud::Engine Rails.application.routes.draw do if ENV["FLIPPER_CLOUD_TOKEN"] && ENV["FLIPPER_CLOUD_SYNC_SECRET"] config = Rails.application.config.flipper cloud_app = Flipper::Cloud.app(nil, env_key: config.env_key, memoizer_options: { preload: config.preload } ) mount cloud_app, at: config.cloud_path end end flipper-0.21.0/lib/flipper/configuration.rb000066400000000000000000000034451404600161700206430ustar00rootroot00000000000000module Flipper class Configuration def initialize(options = {}) @default = -> { Flipper.new(adapter) } @adapter = -> { Flipper::Adapters::Memory.new } end # The default adapter to use. # # Pass a block to assign the adapter, and invoke without a block to # return the configured adapter instance. # # Flipper.configure do |config| # config.adapter # => instance of default Memory adapter # # # Configure it to use the ActiveRecord adapter # config.adapter do # require "flipper/adapters/active_record" # Flipper::Adapters::ActiveRecord.new # end # # config.adapter # => instance of ActiveRecord adapter # end # def adapter(&block) if block_given? @adapter = block else @adapter.call end end # Controls the default instance for flipper. When used with a block it # assigns a new default block to use to generate an instance. When used # without a block, it performs a block invocation and returns the result. # # configuration = Flipper::Configuration.new # configuration.default # => Flipper::DSL instance using Memory adapter # # # sets the default block to generate a new instance using ActiveRecord adapter # configuration.default do # require "flipper/adapters/active_record" # Flipper.new(Flipper::Adapters::ActiveRecord.new) # end # # configuration.default # => Flipper::DSL instance using ActiveRecord adapter # # Returns result of default block invocation if called without block. If # called with block, assigns the default block. def default(&block) if block_given? @default = block else @default.call end end end end flipper-0.21.0/lib/flipper/dsl.rb000066400000000000000000000202531404600161700165520ustar00rootroot00000000000000require 'forwardable' module Flipper class DSL extend Forwardable # Private attr_reader :adapter # Private: What is being used to instrument all the things. attr_reader :instrumenter def_delegators :@adapter, :memoize=, :memoizing? # Public: Returns a new instance of the DSL. # # adapter - The adapter that this DSL instance should use. # options - The Hash of options. # :instrumenter - What should be used to instrument all the things. def initialize(adapter, options = {}) @instrumenter = options.fetch(:instrumenter, Instrumenters::Noop) memoized = Adapters::Memoizable.new(adapter) @adapter = memoized @memoized_features = {} end # Public: Check if a feature is enabled. # # name - The String or Symbol name of the feature. # args - The args passed through to the enabled check. # # Returns true if feature is enabled, false if not. def enabled?(name, *args) feature(name).enabled?(*args) end # Public: Enable a feature. # # name - The String or Symbol name of the feature. # args - The args passed through to the feature instance enable call. # # Returns the result of the feature instance enable call. def enable(name, *args) feature(name).enable(*args) end # Public: Enable a feature for an actor. # # name - The String or Symbol name of the feature. # actor - a Flipper::Types::Actor instance or an object that responds # to flipper_id. # # Returns result of Feature#enable. def enable_actor(name, actor) feature(name).enable_actor(actor) end # Public: Enable a feature for a group. # # name - The String or Symbol name of the feature. # group - a Flipper::Types::Group instance or a String or Symbol name of a # registered group. # # Returns result of Feature#enable. def enable_group(name, group) feature(name).enable_group(group) end # Public: Enable a feature a percentage of time. # # name - The String or Symbol name of the feature. # percentage - a Flipper::Types::PercentageOfTime instance or an object # that responds to to_i. # # Returns result of Feature#enable. def enable_percentage_of_time(name, percentage) feature(name).enable_percentage_of_time(percentage) end # Public: Enable a feature for a percentage of actors. # # name - The String or Symbol name of the feature. # percentage - a Flipper::Types::PercentageOfActors instance or an object # that responds to to_i. # # Returns result of Feature#enable. def enable_percentage_of_actors(name, percentage) feature(name).enable_percentage_of_actors(percentage) end # Public: Disable a feature. # # name - The String or Symbol name of the feature. # args - The args passed through to the feature instance enable call. # # Returns the result of the feature instance disable call. def disable(name, *args) feature(name).disable(*args) end # Public: Disable a feature for an actor. # # name - The String or Symbol name of the feature. # actor - a Flipper::Types::Actor instance or an object that responds # to flipper_id. # # Returns result of disable. def disable_actor(name, actor) feature(name).disable_actor(actor) end # Public: Disable a feature for a group. # # name - The String or Symbol name of the feature. # group - a Flipper::Types::Group instance or a String or Symbol name of a # registered group. # # Returns result of disable. def disable_group(name, group) feature(name).disable_group(group) end # Public: Disable a feature a percentage of time. # # name - The String or Symbol name of the feature. # percentage - a Flipper::Types::PercentageOfTime instance or an object # that responds to to_i. # # Returns result of disable. def disable_percentage_of_time(name) feature(name).disable_percentage_of_time end # Public: Disable a feature for a percentage of actors. # # name - The String or Symbol name of the feature. # percentage - a Flipper::Types::PercentageOfActors instance or an object # that responds to to_i. # # Returns result of disable. def disable_percentage_of_actors(name) feature(name).disable_percentage_of_actors end # Public: Add a feature. # # name - The String or Symbol name of the feature. # # Returns result of add. def add(name) feature(name).add end # Public: Has a feature been added in the adapter. # # name - The String or Symbol name of the feature. # # Returns true if added else false. def exist?(name) feature(name).exist? end # Public: Remove a feature. # # name - The String or Symbol name of the feature. # # Returns result of remove. def remove(name) feature(name).remove end # Public: Access a feature instance by name. # # name - The String or Symbol name of the feature. # # Returns an instance of Flipper::Feature. def feature(name) if !name.is_a?(String) && !name.is_a?(Symbol) raise ArgumentError, "#{name} must be a String or Symbol" end @memoized_features[name.to_sym] ||= Feature.new(name, @adapter, instrumenter: instrumenter) end # Public: Preload the features with the given names. # # names - An Array of String or Symbol names of the features. # # Returns an Array of Flipper::Feature. def preload(names) features = names.map { |name| feature(name) } @adapter.get_multi(features) features end # Public: Preload all the adapters features. # # Returns an Array of Flipper::Feature. def preload_all keys = @adapter.get_all.keys keys.map { |key| feature(key) } end # Public: Shortcut access to a feature instance by name. # # name - The String or Symbol name of the feature. # # Returns an instance of Flipper::Feature. alias_method :[], :feature # Public: Shortcut for getting a boolean type instance. # # value - The true or false value for the boolean. # # Returns a Flipper::Types::Boolean instance. def boolean(value = true) Types::Boolean.new(value) end # Public: Even shorter shortcut for getting a boolean type instance. # # value - The true or false value for the boolean. # # Returns a Flipper::Types::Boolean instance. alias_method :bool, :boolean # Public: Access a flipper group by name. # # name - The String or Symbol name of the feature. # # Returns an instance of Flipper::Group. def group(name) Flipper.group(name) end # Public: Wraps an object as a flipper actor. # # thing - The object that you would like to wrap. # # Returns an instance of Flipper::Types::Actor. # Raises ArgumentError if thing does not respond to `flipper_id`. def actor(thing) Types::Actor.new(thing) end # Public: Shortcut for getting a percentage of time instance. # # number - The percentage of time that should be enabled. # # Returns Flipper::Types::PercentageOfTime. def time(number) Types::PercentageOfTime.new(number) end alias_method :percentage_of_time, :time # Public: Shortcut for getting a percentage of actors instance. # # number - The percentage of actors that should be enabled. # # Returns Flipper::Types::PercentageOfActors. def actors(number) Types::PercentageOfActors.new(number) end alias_method :percentage_of_actors, :actors # Public: Returns a Set of the known features for this adapter. # # Returns Set of Flipper::Feature instances. def features adapter.features.map { |name| feature(name) }.to_set end def import(flipper) adapter.import(flipper.adapter) end # Cloud DSL method that does nothing for open source version. def sync end # Cloud DSL method that does nothing for open source version. def sync_secret end end end flipper-0.21.0/lib/flipper/errors.rb000066400000000000000000000022451404600161700173050ustar00rootroot00000000000000module Flipper # Top level error that all other errors inherit from. class Error < StandardError; end # Raised when gate can not be found for a thing. class GateNotFound < Error def initialize(thing) super "Could not find gate for #{thing.inspect}" end end # Raised when attempting to declare a group name that has already been used. class DuplicateGroup < Error; end # Raised when default instance not configured but there is an attempt to # use it. class DefaultNotSet < Flipper::Error def initialize(message = nil) warn "Flipper::DefaultNotSet is deprecated and will be removed in 1.0" super end end # Raised when an invalid value is set to a configuration property class InvalidConfigurationValue < Flipper::Error def initialize(message = nil) default = "Configuration value is not valid." super(message || default) end end # Raised when accessing a configuration property that has been deprecated class ConfigurationDeprecated < Flipper::Error def initialize(message = nil) default = "The configuration property has been deprecated" super(message || default) end end end flipper-0.21.0/lib/flipper/feature.rb000066400000000000000000000242271404600161700174300ustar00rootroot00000000000000require 'flipper/errors' require 'flipper/type' require 'flipper/gate' require 'flipper/feature_check_context' require 'flipper/gate_values' module Flipper class Feature # Private: The name of feature instrumentation events. InstrumentationName = "feature_operation.#{InstrumentationNamespace}".freeze # Public: The name of the feature. attr_reader :name # Public: Name converted to value safe for adapter. attr_reader :key # Private: The adapter this feature should use. attr_reader :adapter # Private: What is being used to instrument all the things. attr_reader :instrumenter # Internal: Initializes a new feature instance. # # name - The Symbol or String name of the feature. # adapter - The adapter that will be used to store details about this feature. # # options - The Hash of options. # :instrumenter - What to use to instrument all the things. # def initialize(name, adapter, options = {}) @name = name @key = name.to_s @instrumenter = options.fetch(:instrumenter, Instrumenters::Noop) @adapter = adapter end # Public: Enable this feature for something. # # Returns the result of Adapter#enable. def enable(thing = true) instrument(:enable) do |payload| adapter.add self gate = gate_for(thing) wrapped_thing = gate.wrap(thing) payload[:gate_name] = gate.name payload[:thing] = wrapped_thing adapter.enable self, gate, wrapped_thing end end # Public: Disable this feature for something. # # Returns the result of Adapter#disable. def disable(thing = false) instrument(:disable) do |payload| adapter.add self gate = gate_for(thing) wrapped_thing = gate.wrap(thing) payload[:gate_name] = gate.name payload[:thing] = wrapped_thing adapter.disable self, gate, wrapped_thing end end # Public: Adds this feature. # # Returns the result of Adapter#add. def add instrument(:add) { adapter.add(self) } end # Public: Does this feature exist in the adapter. # # Returns true if exists in adapter else false. def exist? instrument(:exist?) { adapter.features.include?(key) } end # Public: Removes this feature. # # Returns the result of Adapter#remove. def remove instrument(:remove) { adapter.remove(self) } end # Public: Clears all gate values for this feature. # # Returns the result of Adapter#clear. def clear instrument(:clear) { adapter.clear(self) } end # Public: Check if a feature is enabled for a thing. # # Returns true if enabled, false if not. def enabled?(thing = nil) instrument(:enabled?) do |payload| values = gate_values thing = gate(:actor).wrap(thing) unless thing.nil? payload[:thing] = thing context = FeatureCheckContext.new( feature_name: @name, values: values, thing: thing ) if open_gate = gates.detect { |gate| gate.open?(context) } payload[:gate_name] = open_gate.name true else false end end end # Public: Enables a feature for an actor. # # actor - a Flipper::Types::Actor instance or an object that responds # to flipper_id. # # Returns result of enable. def enable_actor(actor) enable Types::Actor.wrap(actor) end # Public: Enables a feature for a group. # # group - a Flipper::Types::Group instance or a String or Symbol name of a # registered group. # # Returns result of enable. def enable_group(group) enable Types::Group.wrap(group) end # Public: Enables a feature a percentage of time. # # percentage - a Flipper::Types::PercentageOfTime instance or an object that # responds to to_i. # # Returns result of enable. def enable_percentage_of_time(percentage) enable Types::PercentageOfTime.wrap(percentage) end # Public: Enables a feature for a percentage of actors. # # percentage - a Flipper::Types::PercentageOfTime instance or an object that # responds to to_i. # # Returns result of enable. def enable_percentage_of_actors(percentage) enable Types::PercentageOfActors.wrap(percentage) end # Public: Disables a feature for an actor. # # actor - a Flipper::Types::Actor instance or an object that responds # to flipper_id. # # Returns result of disable. def disable_actor(actor) disable Types::Actor.wrap(actor) end # Public: Disables a feature for a group. # # group - a Flipper::Types::Group instance or a String or Symbol name of a # registered group. # # Returns result of disable. def disable_group(group) disable Types::Group.wrap(group) end # Public: Disables a feature a percentage of time. # # percentage - a Flipper::Types::PercentageOfTime instance or an object that # responds to to_i. # # Returns result of disable. def disable_percentage_of_time disable Types::PercentageOfTime.new(0) end # Public: Disables a feature for a percentage of actors. # # percentage - a Flipper::Types::PercentageOfTime instance or an object that # responds to to_i. # # Returns result of disable. def disable_percentage_of_actors disable Types::PercentageOfActors.new(0) end # Public: Returns state for feature (:on, :off, or :conditional). def state values = gate_values boolean = gate(:boolean) non_boolean_gates = gates - [boolean] if values.boolean || values.percentage_of_time == 100 :on elsif non_boolean_gates.detect { |gate| gate.enabled?(values[gate.key]) } :conditional else :off end end # Public: Is the feature fully enabled. def on? state == :on end # Public: Is the feature fully disabled. def off? state == :off end # Public: Is the feature conditionally enabled for a given actor, group, # percentage of actors or percentage of the time. def conditional? state == :conditional end # Public: Returns the raw gate values stored by the adapter. def gate_values GateValues.new(adapter.get(self)) end # Public: Get groups enabled for this feature. # # Returns Set of Flipper::Types::Group instances. def enabled_groups groups_value.map { |name| Flipper.group(name) }.to_set end alias_method :groups, :enabled_groups # Public: Get groups not enabled for this feature. # # Returns Set of Flipper::Types::Group instances. def disabled_groups Flipper.groups - enabled_groups end # Public: Get the adapter value for the groups gate. # # Returns Set of String group names. def groups_value gate_values.groups end # Public: Get the adapter value for the actors gate. # # Returns Set of String flipper_id's. def actors_value gate_values.actors end # Public: Get the adapter value for the boolean gate. # # Returns true or false. def boolean_value gate_values.boolean end # Public: Get the adapter value for the percentage of actors gate. # # Returns Integer greater than or equal to 0 and less than or equal to 100. def percentage_of_actors_value gate_values.percentage_of_actors end # Public: Get the adapter value for the percentage of time gate. # # Returns Integer greater than or equal to 0 and less than or equal to 100. def percentage_of_time_value gate_values.percentage_of_time end # Public: Get the gates that have been enabled for the feature. # # Returns an Array of Flipper::Gate instances. def enabled_gates values = gate_values gates.select { |gate| gate.enabled?(values[gate.key]) } end # Public: Get the names of the enabled gates. # # Returns an Array of gate names. def enabled_gate_names enabled_gates.map(&:name) end # Public: Get the gates that have not been enabled for the feature. # # Returns an Array of Flipper::Gate instances. def disabled_gates gates - enabled_gates end # Public: Get the names of the disabled gates. # # Returns an Array of gate names. def disabled_gate_names disabled_gates.map(&:name) end # Public: Returns the string representation of the feature. def to_s name.to_s end # Public: Identifier to be used in the url (a rails-ism). def to_param to_s end # Public: Pretty string version for debugging. def inspect attributes = [ "name=#{name.inspect}", "state=#{state.inspect}", "enabled_gate_names=#{enabled_gate_names.inspect}", "adapter=#{adapter.name.inspect}", ] "#<#{self.class.name}:#{object_id} #{attributes.join(', ')}>" end # Public: Get all the gates used to determine enabled/disabled for the feature. # # Returns an array of gates def gates @gates ||= [ Gates::Boolean.new, Gates::Actor.new, Gates::PercentageOfActors.new, Gates::PercentageOfTime.new, Gates::Group.new, ] end # Public: Find a gate by name. # # Returns a Flipper::Gate if found, nil if not. def gate(name) gates.detect { |gate| gate.name == name.to_sym } end # Public: Find the gate that protects a thing. # # thing - The object for which you would like to find a gate # # Returns a Flipper::Gate. # Raises Flipper::GateNotFound if no gate found for thing def gate_for(thing) gates.detect { |gate| gate.protects?(thing) } || raise(GateNotFound, thing) end private # Private: Instrument a feature operation. def instrument(operation) @instrumenter.instrument(InstrumentationName) do |payload| payload[:feature_name] = name payload[:operation] = operation payload[:result] = yield(payload) if block_given? end end end end flipper-0.21.0/lib/flipper/feature_check_context.rb000066400000000000000000000022561404600161700223270ustar00rootroot00000000000000module Flipper class FeatureCheckContext # Public: The name of the feature. attr_reader :feature_name # Public: The GateValues instance that keeps track of the values for the # gates for the feature. attr_reader :values # Public: The thing we want to know if a feature is enabled for. attr_reader :thing def initialize(options = {}) @feature_name = options.fetch(:feature_name) @values = options.fetch(:values) @thing = options.fetch(:thing) end # Public: Convenience method for groups value like Feature has. def groups_value values.groups end # Public: Convenience method for actors value value like Feature has. def actors_value values.actors end # Public: Convenience method for boolean value value like Feature has. def boolean_value values.boolean end # Public: Convenience method for percentage of actors value like Feature has. def percentage_of_actors_value values.percentage_of_actors end # Public: Convenience method for percentage of time value like Feature has. def percentage_of_time_value values.percentage_of_time end end end flipper-0.21.0/lib/flipper/gate.rb000066400000000000000000000027331404600161700167130ustar00rootroot00000000000000module Flipper class Gate # Public def initialize(options = {}) end # Public: The name of the gate. Implemented in subclass. def name raise 'Not implemented' end # Private: Name converted to value safe for adapter. Implemented in subclass. def key raise 'Not implemented' end def data_type raise 'Not implemented' end def enabled?(_value) raise 'Not implemented' end # Internal: Check if a gate is open for a thing. Implemented in subclass. # # Returns true if gate open for thing, false if not. def open?(_thing, _value, _options = {}) false end # Internal: Check if a gate is protects a thing. Implemented in subclass. # # Returns true if gate protects thing, false if not. def protects?(_thing) false end # Internal: Allows gate to wrap thing using one of the supported flipper # types so adapters always get something that responds to value. def wrap(thing) thing end # Public: Pretty string version for debugging. def inspect attributes = [ "name=#{name.inspect}", "key=#{key.inspect}", "data_type=#{data_type.inspect}", ] "#<#{self.class.name}:#{object_id} #{attributes.join(', ')}>" end end end require 'flipper/gates/actor' require 'flipper/gates/boolean' require 'flipper/gates/group' require 'flipper/gates/percentage_of_actors' require 'flipper/gates/percentage_of_time' flipper-0.21.0/lib/flipper/gate_values.rb000066400000000000000000000025551404600161700202740ustar00rootroot00000000000000require 'set' require 'flipper/typecast' module Flipper class GateValues # Private: Array of instance variables that are readable through the [] # instance method. LegitIvars = { 'boolean' => '@boolean', 'actors' => '@actors', 'groups' => '@groups', 'percentage_of_time' => '@percentage_of_time', 'percentage_of_actors' => '@percentage_of_actors', }.freeze attr_reader :boolean attr_reader :actors attr_reader :groups attr_reader :percentage_of_actors attr_reader :percentage_of_time def initialize(adapter_values) @boolean = Typecast.to_boolean(adapter_values[:boolean]) @actors = Typecast.to_set(adapter_values[:actors]) @groups = Typecast.to_set(adapter_values[:groups]) @percentage_of_actors = Typecast.to_percentage(adapter_values[:percentage_of_actors]) @percentage_of_time = Typecast.to_percentage(adapter_values[:percentage_of_time]) end def [](key) if ivar = LegitIvars[key.to_s] instance_variable_get(ivar) end end def eql?(other) self.class.eql?(other.class) && boolean == other.boolean && actors == other.actors && groups == other.groups && percentage_of_actors == other.percentage_of_actors && percentage_of_time == other.percentage_of_time end alias_method :==, :eql? end end flipper-0.21.0/lib/flipper/gates/000077500000000000000000000000001404600161700165445ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/gates/actor.rb000066400000000000000000000017641404600161700202110ustar00rootroot00000000000000module Flipper module Gates class Actor < Gate # Internal: The name of the gate. Used for instrumentation, etc. def name :actor end # Internal: Name converted to value safe for adapter. def key :actors end def data_type :set end def enabled?(value) !value.empty? end # Internal: Checks if the gate is open for a thing. # # Returns true if gate open for thing, false if not. def open?(context) value = context.values[key] if context.thing.nil? false else if protects?(context.thing) actor = wrap(context.thing) enabled_actor_ids = value enabled_actor_ids.include?(actor.value) else false end end end def wrap(thing) Types::Actor.wrap(thing) end def protects?(thing) Types::Actor.wrappable?(thing) end end end end flipper-0.21.0/lib/flipper/gates/boolean.rb000066400000000000000000000015461404600161700205160ustar00rootroot00000000000000module Flipper module Gates class Boolean < Gate # Internal: The name of the gate. Used for instrumentation, etc. def name :boolean end # Internal: Name converted to value safe for adapter. def key :boolean end def data_type :boolean end def enabled?(value) !!value end # Internal: Checks if the gate is open for a thing. # # Returns true if explicitly set to true, false if explicitly set to false # or nil if not explicitly set. def open?(context) context.values[key] end def wrap(thing) Types::Boolean.wrap(thing) end def protects?(thing) case thing when Types::Boolean, TrueClass, FalseClass true else false end end end end end flipper-0.21.0/lib/flipper/gates/group.rb000066400000000000000000000016651404600161700202350ustar00rootroot00000000000000module Flipper module Gates class Group < Gate # Internal: The name of the gate. Used for instrumentation, etc. def name :group end # Internal: Name converted to value safe for adapter. def key :groups end def data_type :set end def enabled?(value) !value.empty? end # Internal: Checks if the gate is open for a thing. # # Returns true if gate open for thing, false if not. def open?(context) value = context.values[key] if context.thing.nil? false else value.any? do |name| group = Flipper.group(name) group.match?(context.thing, context) end end end def wrap(thing) Types::Group.wrap(thing) end def protects?(thing) thing.is_a?(Types::Group) || thing.is_a?(Symbol) end end end end flipper-0.21.0/lib/flipper/gates/percentage_of_actors.rb000066400000000000000000000021271404600161700232470ustar00rootroot00000000000000require 'zlib' module Flipper module Gates class PercentageOfActors < Gate # Internal: The name of the gate. Used for instrumentation, etc. def name :percentage_of_actors end # Internal: Name converted to value safe for adapter. def key :percentage_of_actors end def data_type :integer end def enabled?(value) value > 0 end # Internal: Checks if the gate is open for a thing. # # Returns true if gate open for thing, false if not. def open?(context) percentage = context.values[key] if Types::Actor.wrappable?(context.thing) actor = Types::Actor.wrap(context.thing) id = "#{context.feature_name}#{actor.value}" # this is to support up to 3 decimal places in percentages scaling_factor = 1_000 Zlib.crc32(id) % (100 * scaling_factor) < percentage * scaling_factor else false end end def protects?(thing) thing.is_a?(Types::PercentageOfActors) end end end end flipper-0.21.0/lib/flipper/gates/percentage_of_time.rb000066400000000000000000000013461404600161700227140ustar00rootroot00000000000000module Flipper module Gates class PercentageOfTime < Gate # Internal: The name of the gate. Used for instrumentation, etc. def name :percentage_of_time end # Internal: Name converted to value safe for adapter. def key :percentage_of_time end def data_type :integer end def enabled?(value) value > 0 end # Internal: Checks if the gate is open for a thing. # # Returns true if gate open for thing, false if not. def open?(context) value = context.values[key] rand < (value / 100.0) end def protects?(thing) thing.is_a?(Flipper::Types::PercentageOfTime) end end end end flipper-0.21.0/lib/flipper/identifier.rb000066400000000000000000000005511404600161700201110ustar00rootroot00000000000000module Flipper # A default implementation of `#flipper_id` for actors. # # class User < Struct.new(:id) # include Flipper::Identifier # end # # user = User.new(99) # Flipper.enable :thing, user # Flipper.enabled? :thing, user #=> true # module Identifier def flipper_id "#{self.class.name};#{id}" end end end flipper-0.21.0/lib/flipper/instrumentation/000077500000000000000000000000001404600161700207045ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/instrumentation/log_subscriber.rb000066400000000000000000000044541404600161700242440ustar00rootroot00000000000000require 'securerandom' require 'active_support/notifications' require 'active_support/log_subscriber' module Flipper module Instrumentation class LogSubscriber < ::ActiveSupport::LogSubscriber # Logs a feature operation. # # Example Output # # flipper[:search].enabled?(user) # # Flipper feature(search) enabled? false (1.2ms) [ thing=... ] # # Returns nothing. def feature_operation(event) return unless logger.debug? feature_name = event.payload[:feature_name] gate_name = event.payload[:gate_name] operation = event.payload[:operation] result = event.payload[:result] thing = event.payload[:thing] description = "Flipper feature(#{feature_name}) #{operation} #{result.inspect}" details = "thing=#{thing.inspect}" details += " gate_name=#{gate_name}" unless gate_name.nil? name = '%s (%.1fms)' % [description, event.duration] debug " #{color(name, CYAN, true)} [ #{details} ]" end # Logs an adapter operation. If operation is for a feature, then that # feature is included in log output. # # Example Output # # # log output for adapter operation with feature # # Flipper feature(search) adapter(memory) enable (0.0ms) [ result=...] # # # log output for adapter operation with no feature # # Flipper adapter(memory) features (0.0ms) [ result=... ] # # Returns nothing. def adapter_operation(event) return unless logger.debug? feature_name = event.payload[:feature_name] adapter_name = event.payload[:adapter_name] gate_name = event.payload[:gate_name] operation = event.payload[:operation] result = event.payload[:result] description = 'Flipper ' description << "feature(#{feature_name}) " unless feature_name.nil? description << "adapter(#{adapter_name}) " description << "#{operation} " details = "result=#{result.inspect}" name = '%s (%.1fms)' % [description, event.duration] debug " #{color(name, CYAN, true)} [ #{details} ]" end def logger self.class.logger end end end Instrumentation::LogSubscriber.attach_to InstrumentationNamespace end flipper-0.21.0/lib/flipper/instrumentation/statsd.rb000066400000000000000000000003721404600161700225350ustar00rootroot00000000000000require 'securerandom' require 'active_support/notifications' require 'flipper/instrumentation/statsd_subscriber' ActiveSupport::Notifications.subscribe /\.flipper$/, Flipper::Instrumentation::StatsdSubscriber flipper-0.21.0/lib/flipper/instrumentation/statsd_subscriber.rb000066400000000000000000000013621404600161700247600ustar00rootroot00000000000000# Note: You should never need to require this file directly if you are using # ActiveSupport::Notifications. Instead, you should require the statsd file # that lives in the same directory as this file. The benefit is that it # subscribes to the correct events and does everything for your. require 'flipper/instrumentation/subscriber' module Flipper module Instrumentation class StatsdSubscriber < Subscriber class << self attr_accessor :client end def update_timer(metric) if self.class.client self.class.client.timing metric, (@duration * 1_000).round end end def update_counter(metric) self.class.client.increment metric if self.class.client end end end end flipper-0.21.0/lib/flipper/instrumentation/subscriber.rb000066400000000000000000000044111404600161700233740ustar00rootroot00000000000000module Flipper module Instrumentation class Subscriber # Public: Use this as the subscribed block. def self.call(name, start, ending, transaction_id, payload) new(name, start, ending, transaction_id, payload).update end # Private: Initializes a new event processing instance. def initialize(name, start, ending, transaction_id, payload) @name = name @start = start @ending = ending @payload = payload @duration = ending - start @transaction_id = transaction_id end # Internal: Override in subclass. def update_timer(_metric) raise 'not implemented' end # Internal: Override in subclass. def update_counter(_metric) raise 'not implemented' end # Private def update operation_type = @name.split('.').first method_name = "update_#{operation_type}_metrics" if respond_to?(method_name) send(method_name) else puts "Could not update #{operation_type} metrics as #{self.class} " \ "did not respond to `#{method_name}`" end end # Private def update_feature_operation_metrics feature_name = @payload[:feature_name] gate_name = @payload[:gate_name] operation = strip_trailing_question_mark(@payload[:operation]) result = @payload[:result] thing = @payload[:thing] update_timer "flipper.feature_operation.#{operation}" if @payload[:operation] == :enabled? metric_name = if result "flipper.feature.#{feature_name}.enabled" else "flipper.feature.#{feature_name}.disabled" end update_counter metric_name end end # Private def update_adapter_operation_metrics adapter_name = @payload[:adapter_name] operation = @payload[:operation] result = @payload[:result] value = @payload[:value] key = @payload[:key] update_timer "flipper.adapter.#{adapter_name}.#{operation}" end QUESTION_MARK = '?'.freeze # Private def strip_trailing_question_mark(operation) operation.to_s.chomp(QUESTION_MARK) end end end end flipper-0.21.0/lib/flipper/instrumenters/000077500000000000000000000000001404600161700203635ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/instrumenters/memory.rb000066400000000000000000000016571404600161700222310ustar00rootroot00000000000000module Flipper module Instrumenters # Instrumentor that is useful for tests as it stores each of the events that # are instrumented. class Memory Event = Struct.new(:name, :payload, :result) attr_reader :events def initialize @events = [] end def instrument(name, payload = {}) # Copy the payload to guard against later modifications to it, and to # ensure that all instrumentation code uses the payload passed to the # block rather than the one passed to #instrument. payload = payload.dup result = (yield payload if block_given?) @events << Event.new(name, payload, result) result end def events_by_name(name) @events.select { |event| event.name == name } end def event_by_name(name) events_by_name(name).first end def reset @events = [] end end end end flipper-0.21.0/lib/flipper/instrumenters/noop.rb000066400000000000000000000002461404600161700216650ustar00rootroot00000000000000module Flipper module Instrumenters class Noop def self.instrument(_name, payload = {}) yield payload if block_given? end end end end flipper-0.21.0/lib/flipper/metadata.rb000066400000000000000000000002061404600161700175440ustar00rootroot00000000000000module Flipper METADATA = { 'changelog_uri' => 'https://github.com/jnunemaker/flipper/blob/master/Changelog.md', }.freeze end flipper-0.21.0/lib/flipper/middleware/000077500000000000000000000000001404600161700175565ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/middleware/memoizer.rb000066400000000000000000000051761404600161700217430ustar00rootroot00000000000000module Flipper module Middleware class Memoizer # Public: Initializes an instance of the Memoizer middleware. Flipper must # be configured with a default instance or the flipper instance must be # setup in the env of the request. You can do this by using the # Flipper::Middleware::SetupEnv middleware. # # app - The app this middleware is included in. # opts - The Hash of options. # :preload - Boolean to preload all features or Array of Symbol feature names to preload. # # Examples # # use Flipper::Middleware::Memoizer # # # using with preload_all features # use Flipper::Middleware::Memoizer, preload_all: true # # # using with preload specific features # use Flipper::Middleware::Memoizer, preload: [:stats, :search, :some_feature] # def initialize(app, opts = {}) if opts.is_a?(Flipper::DSL) || opts.is_a?(Proc) raise 'Flipper::Middleware::Memoizer no longer initializes with a flipper instance or block. Read more at: https://git.io/vSo31.' end if opts[:preload_all] warn "Flipper::Middleware::Memoizer: `preload_all` is deprecated, use `preload: true`" opts[:preload] = true end @app = app @opts = opts @env_key = opts.fetch(:env_key, 'flipper') end def call(env) request = Rack::Request.new(env) if memoize?(request) memoized_call(env) else @app.call(env) end end private def memoize?(request) if @opts[:if] @opts[:if].call(request) elsif @opts[:unless] !@opts[:unless].call(request) else true end end def memoized_call(env) reset_on_body_close = false flipper = env.fetch(@env_key) { Flipper } # Already memoizing. This instance does not need to do anything. if flipper.memoizing? warn "Flipper::Middleware::Memoizer appears to be running twice. Read how to resolve this at https://github.com/jnunemaker/flipper/pull/523" return @app.call(env) end flipper.memoize = true case @opts[:preload] when true then flipper.preload_all when Array then flipper.preload(@opts[:preload]) end response = @app.call(env) response[2] = Rack::BodyProxy.new(response[2]) do flipper.memoize = false end reset_on_body_close = true response ensure flipper.memoize = false if flipper && !reset_on_body_close end end end end flipper-0.21.0/lib/flipper/middleware/setup_env.rb000066400000000000000000000030761404600161700221210ustar00rootroot00000000000000module Flipper module Middleware class SetupEnv # Public: Initializes an instance of the SetupEnv middleware. Allows for # lazy initialization of the flipper instance being set in the env by # providing a block. # # app - The app this middleware is included in. # flipper_or_block - The Flipper::DSL instance or a block that yields a # Flipper::DSL instance to use for all operations # (optional, default: Flipper). # # Examples # # flipper = Flipper.new(...) # # # using with a normal flipper instance # use Flipper::Middleware::SetupEnv, flipper # # # using with a block that yields a flipper instance # use Flipper::Middleware::SetupEnv, lambda { Flipper.new(...) } # # # using default configured Flipper instance # Flipper.configure do |config| # config.default { Flipper.new(...) } # end # use Flipper::Middleware::SetupEnv def initialize(app, flipper_or_block = nil, options = {}) @app = app @env_key = options.fetch(:env_key, 'flipper') if flipper_or_block.respond_to?(:call) @flipper_block = flipper_or_block else @flipper = flipper_or_block || Flipper end end def call(env) dup.call!(env) end def call!(env) env[@env_key] ||= flipper @app.call(env) end private def flipper @flipper ||= @flipper_block.call end end end end flipper-0.21.0/lib/flipper/railtie.rb000066400000000000000000000020731404600161700174210ustar00rootroot00000000000000module Flipper class Railtie < Rails::Railtie config.before_configuration do config.flipper = ActiveSupport::OrderedOptions.new.update( env_key: "flipper", memoize: true, preload: true, instrumenter: ActiveSupport::Notifications ) end initializer "flipper.default", before: :load_config_initializers do |app| Flipper.configure do |config| config.default do Flipper.new(config.adapter, instrumenter: app.config.flipper.instrumenter) end end end initializer "flipper.memoizer", after: :load_config_initializers do |app| config = app.config.flipper if config.memoize app.middleware.use Flipper::Middleware::Memoizer, { env_key: config.env_key, preload: config.preload, if: config.memoize.respond_to?(:call) ? config.memoize : nil } end end initializer "flipper.identifier" do ActiveSupport.on_load(:active_record) do ActiveRecord::Base.include Flipper::Identifier end end end end flipper-0.21.0/lib/flipper/registry.rb000066400000000000000000000023411404600161700176360ustar00rootroot00000000000000require 'thread' module Flipper # Internal: Used to store registry of groups by name. class Registry include Enumerable class Error < StandardError; end class DuplicateKey < Error; end class KeyNotFound < Error # Public: The key that was not found attr_reader :key def initialize(key) @key = key super("Key #{key.inspect} not found") end end def initialize(source = {}) @mutex = Mutex.new @source = source end def keys @mutex.synchronize { @source.keys } end def values @mutex.synchronize { @source.values } end def add(key, value) key = key.to_sym @mutex.synchronize do if @source[key] raise DuplicateKey, "#{key} is already registered" else @source[key] = value end end end def get(key) key = key.to_sym @mutex.synchronize do @source[key] end end def key?(key) key = key.to_sym @mutex.synchronize do @source.key?(key) end end def each(&block) @mutex.synchronize { @source.dup }.each(&block) end def clear @mutex.synchronize { @source.clear } end end end flipper-0.21.0/lib/flipper/spec/000077500000000000000000000000001404600161700163735ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/spec/shared_adapter_specs.rb000066400000000000000000000273561404600161700231000ustar00rootroot00000000000000# Requires the following methods: # * subject - The instance of the adapter RSpec.shared_examples_for 'a flipper adapter' do let(:flipper) { Flipper.new(subject) } let(:feature) { flipper[:stats] } let(:boolean_gate) { feature.gate(:boolean) } let(:group_gate) { feature.gate(:group) } let(:actor_gate) { feature.gate(:actor) } let(:actors_gate) { feature.gate(:percentage_of_actors) } let(:time_gate) { feature.gate(:percentage_of_time) } before do Flipper.register(:admins) do |actor| actor.respond_to?(:admin?) && actor.admin? end Flipper.register(:early_access) do |actor| actor.respond_to?(:early_access?) && actor.early_access? end end after do Flipper.unregister_groups end it 'has name that is a symbol' do expect(subject.name).not_to be_nil expect(subject.name).to be_instance_of(Symbol) end it 'has included the flipper adapter module' do expect(subject.class.ancestors).to include(Flipper::Adapter) end it 'returns correct default values for the gates if none are enabled' do expect(subject.get(feature)).to eq(subject.default_config) end it 'can enable, disable and get value for boolean gate' do expect(subject.enable(feature, boolean_gate, flipper.boolean)).to eq(true) result = subject.get(feature) expect(result[:boolean]).to eq('true') expect(subject.disable(feature, boolean_gate, flipper.boolean(false))).to eq(true) result = subject.get(feature) expect(result[:boolean]).to eq(nil) end it 'fully disables all enabled things when boolean gate disabled' do actor22 = Flipper::Actor.new('22') expect(subject.enable(feature, boolean_gate, flipper.boolean)).to eq(true) expect(subject.enable(feature, group_gate, flipper.group(:admins))).to eq(true) expect(subject.enable(feature, actor_gate, flipper.actor(actor22))).to eq(true) expect(subject.enable(feature, actors_gate, flipper.actors(25))).to eq(true) expect(subject.enable(feature, time_gate, flipper.time(45))).to eq(true) expect(subject.disable(feature, boolean_gate, flipper.boolean(false))).to eq(true) expect(subject.get(feature)).to eq(subject.default_config) end it 'can enable, disable and get value for group gate' do expect(subject.enable(feature, group_gate, flipper.group(:admins))).to eq(true) expect(subject.enable(feature, group_gate, flipper.group(:early_access))).to eq(true) result = subject.get(feature) expect(result[:groups]).to eq(Set['admins', 'early_access']) expect(subject.disable(feature, group_gate, flipper.group(:early_access))).to eq(true) result = subject.get(feature) expect(result[:groups]).to eq(Set['admins']) expect(subject.disable(feature, group_gate, flipper.group(:admins))).to eq(true) result = subject.get(feature) expect(result[:groups]).to eq(Set.new) end it 'can enable, disable and get value for actor gate' do actor22 = Flipper::Actor.new('22') actor_asdf = Flipper::Actor.new('asdf') expect(subject.enable(feature, actor_gate, flipper.actor(actor22))).to eq(true) expect(subject.enable(feature, actor_gate, flipper.actor(actor_asdf))).to eq(true) result = subject.get(feature) expect(result[:actors]).to eq(Set['22', 'asdf']) expect(subject.disable(feature, actor_gate, flipper.actor(actor22))).to eq(true) result = subject.get(feature) expect(result[:actors]).to eq(Set['asdf']) expect(subject.disable(feature, actor_gate, flipper.actor(actor_asdf))).to eq(true) result = subject.get(feature) expect(result[:actors]).to eq(Set.new) end it 'can enable, disable and get value for percentage of actors gate' do expect(subject.enable(feature, actors_gate, flipper.actors(15))).to eq(true) result = subject.get(feature) expect(result[:percentage_of_actors]).to eq('15') expect(subject.disable(feature, actors_gate, flipper.actors(0))).to eq(true) result = subject.get(feature) expect(result[:percentage_of_actors]).to eq('0') end it 'can enable percentage of actors gate many times and consistently return values' do (1..100).each do |percentage| expect(subject.enable(feature, actors_gate, flipper.actors(percentage))).to eq(true) result = subject.get(feature) expect(result[:percentage_of_actors]).to eq(percentage.to_s) end end it 'can disable percentage of actors gate many times and consistently return values' do (1..100).each do |percentage| expect(subject.disable(feature, actors_gate, flipper.actors(percentage))).to eq(true) result = subject.get(feature) expect(result[:percentage_of_actors]).to eq(percentage.to_s) end end it 'can enable, disable and get value for percentage of time gate' do expect(subject.enable(feature, time_gate, flipper.time(10))).to eq(true) result = subject.get(feature) expect(result[:percentage_of_time]).to eq('10') expect(subject.disable(feature, time_gate, flipper.time(0))).to eq(true) result = subject.get(feature) expect(result[:percentage_of_time]).to eq('0') end it 'can enable percentage of time gate many times and consistently return values' do (1..100).each do |percentage| expect(subject.enable(feature, time_gate, flipper.time(percentage))).to eq(true) result = subject.get(feature) expect(result[:percentage_of_time]).to eq(percentage.to_s) end end it 'can disable percentage of time gate many times and consistently return values' do (1..100).each do |percentage| expect(subject.disable(feature, time_gate, flipper.time(percentage))).to eq(true) result = subject.get(feature) expect(result[:percentage_of_time]).to eq(percentage.to_s) end end it 'converts boolean value to a string' do expect(subject.enable(feature, boolean_gate, flipper.boolean)).to eq(true) result = subject.get(feature) expect(result[:boolean]).to eq('true') end it 'converts the actor value to a string' do expect(subject.enable(feature, actor_gate, flipper.actor(Flipper::Actor.new(22)))).to eq(true) result = subject.get(feature) expect(result[:actors]).to eq(Set['22']) end it 'converts group value to a string' do expect(subject.enable(feature, group_gate, flipper.group(:admins))).to eq(true) result = subject.get(feature) expect(result[:groups]).to eq(Set['admins']) end it 'converts percentage of time integer value to a string' do expect(subject.enable(feature, time_gate, flipper.time(10))).to eq(true) result = subject.get(feature) expect(result[:percentage_of_time]).to eq('10') end it 'converts percentage of actors integer value to a string' do expect(subject.enable(feature, actors_gate, flipper.actors(10))).to eq(true) result = subject.get(feature) expect(result[:percentage_of_actors]).to eq('10') end it 'can add, remove and list known features' do expect(subject.features).to eq(Set.new) expect(subject.add(flipper[:stats])).to eq(true) expect(subject.features).to eq(Set['stats']) expect(subject.add(flipper[:search])).to eq(true) expect(subject.features).to eq(Set['stats', 'search']) expect(subject.remove(flipper[:stats])).to eq(true) expect(subject.features).to eq(Set['search']) expect(subject.remove(flipper[:search])).to eq(true) expect(subject.features).to eq(Set.new) end it 'clears all the gate values for the feature on remove' do actor22 = Flipper::Actor.new('22') expect(subject.enable(feature, boolean_gate, flipper.boolean)).to eq(true) expect(subject.enable(feature, group_gate, flipper.group(:admins))).to eq(true) expect(subject.enable(feature, actor_gate, flipper.actor(actor22))).to eq(true) expect(subject.enable(feature, actors_gate, flipper.actors(25))).to eq(true) expect(subject.enable(feature, time_gate, flipper.time(45))).to eq(true) expect(subject.remove(feature)).to eq(true) expect(subject.get(feature)).to eq(subject.default_config) end it 'can clear all the gate values for a feature' do actor22 = Flipper::Actor.new('22') subject.add(feature) expect(subject.features).to include(feature.key) expect(subject.enable(feature, boolean_gate, flipper.boolean)).to eq(true) expect(subject.enable(feature, group_gate, flipper.group(:admins))).to eq(true) expect(subject.enable(feature, actor_gate, flipper.actor(actor22))).to eq(true) expect(subject.enable(feature, actors_gate, flipper.actors(25))).to eq(true) expect(subject.enable(feature, time_gate, flipper.time(45))).to eq(true) expect(subject.clear(feature)).to eq(true) expect(subject.features).to include(feature.key) expect(subject.get(feature)).to eq(subject.default_config) end it 'does not complain clearing a feature that does not exist in adapter' do expect(subject.clear(flipper[:stats])).to eq(true) end it 'can get multiple features' do expect(subject.add(flipper[:stats])).to eq(true) expect(subject.enable(flipper[:stats], boolean_gate, flipper.boolean)).to eq(true) expect(subject.add(flipper[:search])).to eq(true) result = subject.get_multi([flipper[:stats], flipper[:search], flipper[:other]]) expect(result).to be_instance_of(Hash) stats = result["stats"] search = result["search"] other = result["other"] expect(stats).to eq(subject.default_config.merge(boolean: 'true')) expect(search).to eq(subject.default_config) expect(other).to eq(subject.default_config) end it 'can get all features' do expect(subject.add(flipper[:stats])).to eq(true) expect(subject.enable(flipper[:stats], boolean_gate, flipper.boolean)).to eq(true) expect(subject.add(flipper[:search])).to eq(true) result = subject.get_all expect(result).to be_instance_of(Hash) stats = result["stats"] search = result["search"] expect(stats).to eq(subject.default_config.merge(boolean: 'true')) expect(search).to eq(subject.default_config) end it 'includes explicitly disabled features when getting all features' do flipper.enable(:stats) flipper.enable(:search) flipper.disable(:search) result = subject.get_all expect(result.keys.sort).to eq(%w(search stats)) end it 'can double enable an actor without error' do actor = Flipper::Actor.new('Flipper::Actor;22') expect(subject.enable(feature, actor_gate, flipper.actor(actor))).to eq(true) expect(subject.enable(feature, actor_gate, flipper.actor(actor))).to eq(true) expect(subject.get(feature).fetch(:actors)).to eq(Set['Flipper::Actor;22']) end it 'can double enable a group without error' do expect(subject.enable(feature, group_gate, flipper.group(:admins))).to eq(true) expect(subject.enable(feature, group_gate, flipper.group(:admins))).to eq(true) expect(subject.get(feature).fetch(:groups)).to eq(Set['admins']) end it 'can double enable percentage without error' do expect(subject.enable(feature, actors_gate, flipper.actors(25))).to eq(true) expect(subject.enable(feature, actors_gate, flipper.actors(25))).to eq(true) end it 'can double enable without error' do expect(subject.enable(feature, boolean_gate, flipper.boolean)).to eq(true) expect(subject.enable(feature, boolean_gate, flipper.boolean)).to eq(true) end it 'can get_all features when there are none' do expect(subject.features).to eq(Set.new) expect(subject.get_all).to eq({}) end it 'clears other gate values on enable' do actor = Flipper::Actor.new('Flipper::Actor;22') subject.enable(feature, actors_gate, flipper.actors(25)) subject.enable(feature, time_gate, flipper.time(25)) subject.enable(feature, group_gate, flipper.group(:admins)) subject.enable(feature, actor_gate, flipper.actor(actor)) subject.enable(feature, boolean_gate, flipper.boolean(true)) expect(subject.get(feature)).to eq(subject.default_config.merge(boolean: "true")) end end flipper-0.21.0/lib/flipper/test/000077500000000000000000000000001404600161700164205ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/test/shared_adapter_test.rb000066400000000000000000000315141404600161700227560ustar00rootroot00000000000000module Flipper module Test module SharedAdapterTests def setup super @flipper = Flipper.new(@adapter) @feature = @flipper[:stats] @boolean_gate = @feature.gate(:boolean) @group_gate = @feature.gate(:group) @actor_gate = @feature.gate(:actor) @actors_gate = @feature.gate(:percentage_of_actors) @time_gate = @feature.gate(:percentage_of_time) Flipper.register(:admins) do |actor| actor.respond_to?(:admin?) && actor.admin? end Flipper.register(:early_access) do |actor| actor.respond_to?(:early_access?) && actor.early_access? end end def teardown super Flipper.unregister_groups end def test_has_name_that_is_a_symbol refute_empty @adapter.name assert_kind_of Symbol, @adapter.name end def test_has_included_the_flipper_adapter_module assert_includes @adapter.class.ancestors, Flipper::Adapter end def test_returns_correct_default_values_for_gates_if_none_are_enabled assert_equal @adapter.default_config, @adapter.get(@feature) end def test_can_enable_disable_and_get_value_for_boolean_gate assert_equal true, @adapter.enable(@feature, @boolean_gate, @flipper.boolean) assert_equal 'true', @adapter.get(@feature)[:boolean] assert_equal true, @adapter.disable(@feature, @boolean_gate, @flipper.boolean(false)) assert_nil @adapter.get(@feature)[:boolean] end def test_fully_disables_all_enabled_things_when_boolean_gate_disabled actor22 = Flipper::Actor.new('22') assert_equal true, @adapter.enable(@feature, @boolean_gate, @flipper.boolean) assert_equal true, @adapter.enable(@feature, @group_gate, @flipper.group(:admins)) assert_equal true, @adapter.enable(@feature, @actor_gate, @flipper.actor(actor22)) assert_equal true, @adapter.enable(@feature, @actors_gate, @flipper.actors(25)) assert_equal true, @adapter.enable(@feature, @time_gate, @flipper.time(45)) assert_equal true, @adapter.disable(@feature, @boolean_gate, @flipper.boolean(false)) assert_equal @adapter.default_config, @adapter.get(@feature) end def test_can_enable_disable_get_value_for_group_gate assert_equal true, @adapter.enable(@feature, @group_gate, @flipper.group(:admins)) assert_equal true, @adapter.enable(@feature, @group_gate, @flipper.group(:early_access)) result = @adapter.get(@feature) assert_equal Set['admins', 'early_access'], result[:groups] assert_equal true, @adapter.disable(@feature, @group_gate, @flipper.group(:early_access)) result = @adapter.get(@feature) assert_equal Set['admins'], result[:groups] assert_equal true, @adapter.disable(@feature, @group_gate, @flipper.group(:admins)) result = @adapter.get(@feature) assert_equal Set.new, result[:groups] end def test_can_enable_disable_and_get_value_for_an_actor_gate actor22 = Flipper::Actor.new('22') actor_asdf = Flipper::Actor.new('asdf') assert_equal true, @adapter.enable(@feature, @actor_gate, @flipper.actor(actor22)) assert_equal true, @adapter.enable(@feature, @actor_gate, @flipper.actor(actor_asdf)) result = @adapter.get(@feature) assert_equal Set['22', 'asdf'], result[:actors] assert true, @adapter.disable(@feature, @actor_gate, @flipper.actor(actor22)) result = @adapter.get(@feature) assert_equal Set['asdf'], result[:actors] assert_equal true, @adapter.disable(@feature, @actor_gate, @flipper.actor(actor_asdf)) result = @adapter.get(@feature) assert_equal Set.new, result[:actors] end def test_can_enable_disable_get_value_for_percentage_of_actors_gate assert_equal true, @adapter.enable(@feature, @actors_gate, @flipper.actors(15)) result = @adapter.get(@feature) assert_equal '15', result[:percentage_of_actors] assert_equal true, @adapter.disable(@feature, @actors_gate, @flipper.actors(0)) result = @adapter.get(@feature) assert_equal '0', result[:percentage_of_actors] end def test_can_enable_percentage_of_actors_gate_many_times_and_consistently_return_values (1..100).each do |percentage| assert_equal true, @adapter.enable(@feature, @actors_gate, @flipper.actors(percentage)) result = @adapter.get(@feature) assert_equal percentage.to_s, result[:percentage_of_actors] end end def test_can_disable_percentage_of_actors_gate_many_times_and_consistently_return_values (1..100).each do |percentage| assert_equal true, @adapter.disable(@feature, @actors_gate, @flipper.actors(percentage)) result = @adapter.get(@feature) assert_equal percentage.to_s, result[:percentage_of_actors] end end def test_can_enable_disable_and_get_value_for_percentage_of_time_gate assert_equal true, @adapter.enable(@feature, @time_gate, @flipper.time(10)) result = @adapter.get(@feature) assert_equal '10', result[:percentage_of_time] assert_equal true, @adapter.disable(@feature, @time_gate, @flipper.time(0)) result = @adapter.get(@feature) assert_equal '0', result[:percentage_of_time] end def test_can_enable_percentage_of_time_gate_many_times_and_consistently_return_values (1..100).each do |percentage| assert_equal true, @adapter.enable(@feature, @time_gate, @flipper.time(percentage)) result = @adapter.get(@feature) assert_equal percentage.to_s, result[:percentage_of_time] end end def test_can_disable_percentage_of_time_gate_many_times_and_consistently_return_values (1..100).each do |percentage| assert_equal true, @adapter.disable(@feature, @time_gate, @flipper.time(percentage)) result = @adapter.get(@feature) assert_equal percentage.to_s, result[:percentage_of_time] end end def test_converts_boolean_value_to_a_string assert_equal true, @adapter.enable(@feature, @boolean_gate, @flipper.boolean) result = @adapter.get(@feature) assert_equal 'true', result[:boolean] end def test_converts_the_actor_value_to_a_string assert_equal true, @adapter.enable(@feature, @actor_gate, @flipper.actor(Flipper::Actor.new(22))) result = @adapter.get(@feature) assert_equal Set['22'], result[:actors] end def test_converts_group_value_to_a_string assert_equal true, @adapter.enable(@feature, @group_gate, @flipper.group(:admins)) result = @adapter.get(@feature) assert_equal Set['admins'], result[:groups] end def test_converts_percentage_of_time_integer_value_to_a_string assert_equal true, @adapter.enable(@feature, @time_gate, @flipper.time(10)) result = @adapter.get(@feature) assert_equal '10', result[:percentage_of_time] end def test_converts_percentage_of_actors_integer_value_to_a_string assert_equal true, @adapter.enable(@feature, @actors_gate, @flipper.actors(10)) result = @adapter.get(@feature) assert_equal '10', result[:percentage_of_actors] end def test_can_add_remove_and_list_known_features assert_equal Set.new, @adapter.features assert_equal true, @adapter.add(@flipper[:stats]) assert_equal Set['stats'], @adapter.features assert_equal true, @adapter.add(@flipper[:search]) assert_equal Set['stats', 'search'], @adapter.features assert_equal true, @adapter.remove(@flipper[:stats]) assert_equal Set['search'], @adapter.features assert_equal true, @adapter.remove(@flipper[:search]) assert_equal Set.new, @adapter.features end def test_clears_all_the_gate_values_for_the_feature_on_remove actor22 = Flipper::Actor.new('22') assert_equal true, @adapter.enable(@feature, @boolean_gate, @flipper.boolean) assert_equal true, @adapter.enable(@feature, @group_gate, @flipper.group(:admins)) assert_equal true, @adapter.enable(@feature, @actor_gate, @flipper.actor(actor22)) assert_equal true, @adapter.enable(@feature, @actors_gate, @flipper.actors(25)) assert_equal true, @adapter.enable(@feature, @time_gate, @flipper.time(45)) assert_equal true, @adapter.remove(@feature) assert_equal @adapter.default_config, @adapter.get(@feature) end def test_can_clear_all_the_gate_values_for_a_feature actor22 = Flipper::Actor.new('22') @adapter.add(@feature) assert_includes @adapter.features, @feature.key assert_equal true, @adapter.enable(@feature, @boolean_gate, @flipper.boolean) assert_equal true, @adapter.enable(@feature, @group_gate, @flipper.group(:admins)) assert_equal true, @adapter.enable(@feature, @actor_gate, @flipper.actor(actor22)) assert_equal true, @adapter.enable(@feature, @actors_gate, @flipper.actors(25)) assert_equal true, @adapter.enable(@feature, @time_gate, @flipper.time(45)) assert_equal true, @adapter.clear(@feature) assert_includes @adapter.features, @feature.key assert_equal @adapter.default_config, @adapter.get(@feature) end def test_does_not_complain_clearing_a_feature_that_does_not_exist_in_adapter assert_equal true, @adapter.clear(@flipper[:stats]) end def test_can_get_multiple_features assert @adapter.add(@flipper[:stats]) assert @adapter.enable(@flipper[:stats], @boolean_gate, @flipper.boolean) assert @adapter.add(@flipper[:search]) result = @adapter.get_multi([@flipper[:stats], @flipper[:search], @flipper[:other]]) assert_instance_of Hash, result stats = result["stats"] search = result["search"] other = result["other"] assert_equal @adapter.default_config.merge(boolean: 'true'), stats assert_equal @adapter.default_config, search assert_equal @adapter.default_config, other end def test_can_get_all_features assert @adapter.add(@flipper[:stats]) assert @adapter.enable(@flipper[:stats], @boolean_gate, @flipper.boolean) assert @adapter.add(@flipper[:search]) result = @adapter.get_all assert_instance_of Hash, result stats = result["stats"] search = result["search"] assert_equal @adapter.default_config.merge(boolean: 'true'), stats assert_equal @adapter.default_config, search end def test_includes_explicitly_disabled_features_when_getting_all_features @flipper.enable(:stats) @flipper.enable(:search) @flipper.disable(:search) result = @adapter.get_all assert_equal %w(search stats), result.keys.sort end def test_can_double_enable_an_actor_without_error actor = Flipper::Actor.new('Flipper::Actor;22') assert_equal true, @adapter.enable(@feature, @actor_gate, @flipper.actor(actor)) assert_equal true, @adapter.enable(@feature, @actor_gate, @flipper.actor(actor)) assert_equal Set['Flipper::Actor;22'], @adapter.get(@feature).fetch(:actors) end def test_can_double_enable_a_group_without_error assert_equal true, @adapter.enable(@feature, @group_gate, @flipper.group(:admins)) assert_equal true, @adapter.enable(@feature, @group_gate, @flipper.group(:admins)) assert_equal Set['admins'], @adapter.get(@feature).fetch(:groups) end def test_can_double_enable_percentage_without_error assert_equal true, @adapter.enable(@feature, @actors_gate, @flipper.actors(25)) assert_equal true, @adapter.enable(@feature, @actors_gate, @flipper.actors(25)) end def test_can_double_enable_without_error assert_equal true, @adapter.enable(@feature, @boolean_gate, @flipper.boolean) assert_equal true, @adapter.enable(@feature, @boolean_gate, @flipper.boolean) end def test_can_get_all_features_when_there_are_none expected = {} assert_equal Set.new, @adapter.features assert_equal expected, @adapter.get_all end def test_clears_other_gate_values_on_enable actor = Flipper::Actor.new('Flipper::Actor;22') assert_equal true, @adapter.enable(@feature, @actors_gate, @flipper.actors(25)) assert_equal true, @adapter.enable(@feature, @time_gate, @flipper.time(25)) assert_equal true, @adapter.enable(@feature, @group_gate, @flipper.group(:admins)) assert_equal true, @adapter.enable(@feature, @actor_gate, @flipper.actor(actor)) assert_equal true, @adapter.enable(@feature, @boolean_gate, @flipper.boolean(true)) assert_equal @adapter.default_config.merge(boolean: "true"), @adapter.get(@feature) end end end end flipper-0.21.0/lib/flipper/type.rb000066400000000000000000000006171404600161700167530ustar00rootroot00000000000000module Flipper # Internal: Root class for all flipper types. You should never need to use this. class Type def self.wrap(value_or_instance) return value_or_instance if value_or_instance.is_a?(self) new(value_or_instance) end attr_reader :value def eql?(other) self.class.eql?(other.class) && value == other.value end alias_method :==, :eql? end end flipper-0.21.0/lib/flipper/typecast.rb000066400000000000000000000034011404600161700176200ustar00rootroot00000000000000require 'set' module Flipper module Typecast TruthMap = { true => true, 1 => true, 'true' => true, '1' => true, }.freeze # Internal: Convert value to a boolean. # # Returns true or false. def self.to_boolean(value) !!TruthMap[value] end # Internal: Convert value to an integer. # # Returns an Integer representation of the value. # Raises ArgumentError if conversion is not possible. def self.to_integer(value) if value.respond_to?(:to_i) value.to_i else raise ArgumentError, "#{value.inspect} cannot be converted to an integer" end end # Internal: Convert value to a float. # # Returns a Float representation of the value. # Raises ArgumentError if conversion is not possible. def self.to_float(value) if value.respond_to?(:to_f) value.to_f else raise ArgumentError, "#{value.inspect} cannot be converted to a float" end end # Internal: Convert value to a percentage. # # Returns a Integer or Float representation of the value. # Raises ArgumentError if conversion is not possible. def self.to_percentage(value) if value.to_s.include?('.'.freeze) to_float(value) else to_integer(value) end end # Internal: Convert value to a set. # # Returns a Set representation of the value. # Raises ArgumentError if conversion is not possible. def self.to_set(value) return value if value.is_a?(Set) return Set.new if value.nil? || value.empty? if value.respond_to?(:to_set) value.to_set else raise ArgumentError, "#{value.inspect} cannot be converted to a set" end end end end flipper-0.21.0/lib/flipper/types/000077500000000000000000000000001404600161700166055ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/types/actor.rb000066400000000000000000000013071404600161700202430ustar00rootroot00000000000000module Flipper module Types class Actor < Type def self.wrappable?(thing) return false if thing.nil? thing.respond_to?(:flipper_id) end attr_reader :thing def initialize(thing) raise ArgumentError, 'thing cannot be nil' if thing.nil? unless thing.respond_to?(:flipper_id) raise ArgumentError, "#{thing.inspect} must respond to flipper_id, but does not" end @thing = thing @value = thing.flipper_id.to_s end def respond_to?(*args) super || @thing.respond_to?(*args) end def method_missing(name, *args, &block) @thing.send name, *args, &block end end end end flipper-0.21.0/lib/flipper/types/boolean.rb000066400000000000000000000003211404600161700205450ustar00rootroot00000000000000require 'flipper/typecast' module Flipper module Types class Boolean < Type def initialize(value = nil) @value = value.nil? ? true : Typecast.to_boolean(value) end end end end flipper-0.21.0/lib/flipper/types/group.rb000066400000000000000000000013041404600161700202640ustar00rootroot00000000000000module Flipper module Types class Group < Type def self.wrap(group_or_name) return group_or_name if group_or_name.is_a?(self) Flipper.group(group_or_name) end attr_reader :name def initialize(name, &block) @name = name.to_sym @value = @name if block_given? @block = block @single_argument = @block.arity.abs == 1 else @block = ->(_thing, _context) { false } @single_argument = false end end def match?(thing, context) if @single_argument @block.call(thing) else @block.call(thing, context) end end end end end flipper-0.21.0/lib/flipper/types/percentage.rb000066400000000000000000000006021404600161700212450ustar00rootroot00000000000000require 'flipper/typecast' module Flipper module Types class Percentage < Type def initialize(value) value = Typecast.to_percentage(value) if value < 0 || value > 100 raise ArgumentError, "value must be a positive number less than or equal to 100, but was #{value}" end @value = value end end end end flipper-0.21.0/lib/flipper/types/percentage_of_actors.rb000066400000000000000000000001321404600161700233020ustar00rootroot00000000000000module Flipper module Types class PercentageOfActors < Percentage end end end flipper-0.21.0/lib/flipper/types/percentage_of_time.rb000066400000000000000000000001301404600161700227430ustar00rootroot00000000000000module Flipper module Types class PercentageOfTime < Percentage end end end flipper-0.21.0/lib/flipper/ui.rb000066400000000000000000000043661404600161700164140ustar00rootroot00000000000000require 'pathname' require 'rack' begin # Rack 2 require 'rack/method_override' rescue LoadError require 'rack/methodoverride' end require 'rack/protection' require 'flipper' require 'flipper/ui/middleware' require 'flipper/ui/configuration' module Flipper module UI class << self # These three configuration options have been moved to Flipper::UI::Configuration deprecated_configuration_options = %w(application_breadcrumb_href feature_creation_enabled feature_removal_enabled) deprecated_configuration_options.each do |attribute_name| send(:define_method, "#{attribute_name}=".to_sym) do raise ConfigurationDeprecated, "The UI configuration for #{attribute_name} has " \ "deprecated. This configuration option has moved to Flipper::UI::Configuration" end send(:define_method, attribute_name.to_sym) do raise ConfigurationDeprecated, "The UI configuration for #{attribute_name} has " \ "deprecated. This configuration option has moved to Flipper::UI::Configuration" end end end def self.root @root ||= Pathname(__FILE__).dirname.expand_path.join('ui') end def self.app(flipper = nil, options = {}) env_key = options.fetch(:env_key, 'flipper') rack_protection_options = options.fetch(:rack_protection, use: :authenticity_token) app = ->(_) { [200, { 'Content-Type' => 'text/html' }, ['']] } builder = Rack::Builder.new yield builder if block_given? builder.use Rack::Protection, rack_protection_options builder.use Rack::MethodOverride builder.use Flipper::Middleware::SetupEnv, flipper, env_key: env_key builder.use Flipper::Middleware::Memoizer, env_key: env_key builder.use Flipper::UI::Middleware, env_key: env_key builder.run app klass = self builder.define_singleton_method(:inspect) { klass.inspect } # pretty rake routes output builder end # Public: yields configuration instance for customizing UI text def self.configure yield(configuration) end def self.configuration @configuration ||= ::Flipper::UI::Configuration.new end end end flipper-0.21.0/lib/flipper/ui/000077500000000000000000000000001404600161700160565ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/ui/action.rb000066400000000000000000000155011404600161700176620ustar00rootroot00000000000000require 'forwardable' require 'flipper/ui/configuration' require 'flipper/ui/error' require 'erubi' require 'json' module Flipper module UI class Action module FeatureNameFromRoute def feature_name @feature_name ||= begin match = request.path_info.match(self.class.route_regex) match ? Rack::Utils.unescape(match[:feature_name]) : nil end end private :feature_name end extend Forwardable VALID_REQUEST_METHOD_NAMES = Set.new([ 'get'.freeze, 'post'.freeze, 'put'.freeze, 'delete'.freeze, ]).freeze # Public: Call this in subclasses so the action knows its route. # # regex - The Regexp that this action should run for. # # Returns nothing. def self.route(regex) @route_regex = regex end # Internal: Does this action's route match the path. def self.route_match?(path) path.match(route_regex) end # Internal: The regex that matches which routes this action will work for. def self.route_regex @route_regex || raise("#{name}.route is not set") end # Internal: Initializes and runs an action for a given request. # # flipper - The Flipper::DSL instance. # request - The Rack::Request that was sent. # # Returns result of Action#run. def self.run(flipper, request) new(flipper, request).run end # Private: The path to the views folder. def self.views_path @views_path ||= Flipper::UI.root.join('views') end # Private: The path to the public folder. def self.public_path @public_path ||= Flipper::UI.root.join('public') end # Public: The instance of the Flipper::DSL the middleware was # initialized with. attr_reader :flipper # Public: The Rack::Request to provide a response for. attr_reader :request # Public: The params for the request. def_delegator :@request, :params def initialize(flipper, request) @flipper = flipper @request = request @code = 200 @headers = { 'Content-Type' => 'text/plain' } @breadcrumbs = if Flipper::UI.configuration.application_breadcrumb_href [Breadcrumb.new('App', Flipper::UI.configuration.application_breadcrumb_href)] else [] end end # Public: Runs the request method for the provided request. # # Returns whatever the request method returns in the action. def run if valid_request_method? && respond_to?(request_method_name) catch(:halt) { send(request_method_name) } else raise UI::RequestMethodNotSupported, "#{self.class} does not support request method #{request_method_name.inspect}" end end # Public: Runs another action from within the request method of a # different action. # # action_class - The class of the other action to run. # # Examples # # run_other_action Home # # => result of running Home action # # Returns result of other action. def run_other_action(action_class) action_class.new(flipper, request).run end # Public: Call this with a response to immediately stop the current action # and respond however you want. # # response - The response you would like to return. def halt(response) throw :halt, response end # Public: Compiles a view and returns rack response with that as the body. # # name - The Symbol name of the view. # # Returns a response. def view_response(name) header 'Content-Type', 'text/html' body = view_with_layout { view_without_layout name } halt [@code, @headers, [body]] end # Public: Dumps an object as json and returns rack response with that as # the body. Automatically sets Content-Type to "application/json". # # object - The Object that should be dumped as json. # # Returns a response. def json_response(object) header 'Content-Type', 'application/json' body = JSON.dump(object) halt [@code, @headers, [body]] end # Public: Redirect to a new location. # # location - The String location to set the Location header to. def redirect_to(location) status 302 header 'Location', "#{script_name}#{location}" halt [@code, @headers, ['']] end # Public: Set the status code for the response. # # code - The Integer code you would like the response to return. def status(code) @code = code.to_i end # Public: Set a header. # # name - The String name of the header. # value - The value of the header. def header(name, value) @headers[name] = value end class Breadcrumb attr_reader :text, :href def initialize(text, href = nil) @text = text @href = href end def active? @href.nil? end end # Public: Add a breadcrumb to the trail. # # text - The String text for the breadcrumb. # href - The String href for the anchor tag (optional). If nil, breadcrumb # is assumed to be the end of the trail. def breadcrumb(text, href = nil) breadcrumb_href = href.nil? ? href : "#{script_name}#{href}" @breadcrumbs << Breadcrumb.new(text, breadcrumb_href) end # Private def view_with_layout(&block) view :layout, &block end # Private def view_without_layout(name) view name end # Private def view(name) path = views_path.join("#{name}.erb") raise "Template does not exist: #{path}" unless path.exist? eval(Erubi::Engine.new(path.read, escape: true).src) end # Internal: The path the app is mounted at. def script_name request.env['SCRIPT_NAME'] end # Private def views_path self.class.views_path end # Private def public_path self.class.public_path end # Private: Returns the request method converted to an action method. def request_method_name @request_method_name ||= @request.request_method.downcase end def csrf_input_tag %() end def valid_request_method? VALID_REQUEST_METHOD_NAMES.include?(request_method_name) end end end end flipper-0.21.0/lib/flipper/ui/action_collection.rb000066400000000000000000000007111404600161700220720ustar00rootroot00000000000000module Flipper module UI # Internal: Used to detect the action that should be used in the middleware. class ActionCollection def initialize @action_classes = [] end def add(action_class) @action_classes << action_class end def action_for_request(request) @action_classes.detect do |action_class| action_class.route_match?(request.path_info) end end end end end flipper-0.21.0/lib/flipper/ui/actions/000077500000000000000000000000001404600161700175165ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/ui/actions/actors_gate.rb000066400000000000000000000023271404600161700223420ustar00rootroot00000000000000require 'flipper/ui/action' require 'flipper/ui/decorators/feature' require 'flipper/ui/util' module Flipper module UI module Actions class ActorsGate < UI::Action include FeatureNameFromRoute route %r{\A/features/(?.*)/actors/?\Z} def get feature = flipper[feature_name] @feature = Decorators::Feature.new(feature) breadcrumb 'Home', '/' breadcrumb 'Features', '/features' breadcrumb @feature.key, "/features/#{@feature.key}" breadcrumb 'Add Actor' view_response :add_actor end def post feature = flipper[feature_name] value = params['value'].to_s.strip if Util.blank?(value) error = Rack::Utils.escape("#{value.inspect} is not a valid actor value.") redirect_to("/features/#{feature.key}/actors?error=#{error}") end actor = Flipper::Actor.new(value) case params['operation'] when 'enable' feature.enable_actor actor when 'disable' feature.disable_actor actor end redirect_to("/features/#{feature.key}") end end end end end flipper-0.21.0/lib/flipper/ui/actions/add_feature.rb000066400000000000000000000012411404600161700223040ustar00rootroot00000000000000require 'flipper/ui/action' require 'flipper/ui/decorators/feature' module Flipper module UI module Actions class AddFeature < UI::Action route %r{\A/features/new/?\Z} def get unless Flipper::UI.configuration.feature_creation_enabled status 403 breadcrumb 'Home', '/' breadcrumb 'Features', '/features' breadcrumb 'Noooooope' halt view_response(:feature_creation_disabled) end breadcrumb 'Home', '/' breadcrumb 'Features', '/features' breadcrumb 'Add' view_response :add_feature end end end end end flipper-0.21.0/lib/flipper/ui/actions/boolean_gate.rb000066400000000000000000000011101404600161700224530ustar00rootroot00000000000000require 'flipper/ui/action' require 'flipper/ui/decorators/feature' module Flipper module UI module Actions class BooleanGate < UI::Action include FeatureNameFromRoute route %r{\A/features/(?.*)/boolean/?\Z} def post feature = flipper[feature_name] @feature = Decorators::Feature.new(feature) if params['action'] == 'Enable' feature.enable else feature.disable end redirect_to "/features/#{@feature.key}" end end end end end flipper-0.21.0/lib/flipper/ui/actions/feature.rb000066400000000000000000000022571404600161700215040ustar00rootroot00000000000000require 'flipper/ui/action' require 'flipper/ui/decorators/feature' module Flipper module UI module Actions class Feature < UI::Action include FeatureNameFromRoute route %r{\A/features/(?.*)\Z} def get flipper_feature = flipper[feature_name] @feature = Decorators::Feature.new(flipper_feature) descriptions = Flipper::UI.configuration.descriptions_source.call([flipper_feature.key]) @feature.description = descriptions[@feature.key] @page_title = "#{@feature.key} // Features" @percentages = [0, 1, 5, 10, 25, 50, 100] breadcrumb 'Home', '/' breadcrumb 'Features', '/features' breadcrumb @feature.key view_response :feature end def delete unless Flipper::UI.configuration.feature_removal_enabled status 403 breadcrumb 'Home', '/' breadcrumb 'Features', '/features' halt view_response(:feature_removal_disabled) end feature = flipper[feature_name] feature.remove redirect_to '/features' end end end end end flipper-0.21.0/lib/flipper/ui/actions/features.rb000066400000000000000000000032551404600161700216660ustar00rootroot00000000000000require 'flipper/ui/action' require 'flipper/ui/decorators/feature' require 'flipper/ui/util' module Flipper module UI module Actions class Features < UI::Action route %r{\A/features/?\Z} def get @page_title = 'Features' keys = flipper.features.map(&:key) descriptions = if Flipper::UI.configuration.show_feature_description_in_list? Flipper::UI.configuration.descriptions_source.call(keys) else {} end @features = flipper.features.map do |feature| decorated_feature = Decorators::Feature.new(feature) if Flipper::UI.configuration.show_feature_description_in_list? decorated_feature.description = descriptions[feature.key] end decorated_feature end.sort @show_blank_slate = @features.empty? breadcrumb 'Home', '/' breadcrumb 'Features' view_response :features end def post unless Flipper::UI.configuration.feature_creation_enabled status 403 breadcrumb 'Home', '/' breadcrumb 'Features', '/features' breadcrumb 'Noooooope' halt view_response(:feature_creation_disabled) end value = params['value'].to_s.strip if Util.blank?(value) error = Rack::Utils.escape("#{value.inspect} is not a valid feature name.") redirect_to("/features/new?error=#{error}") end feature = flipper[value] feature.add redirect_to "/features/#{Rack::Utils.escape_path(value)}" end end end end end flipper-0.21.0/lib/flipper/ui/actions/file.rb000066400000000000000000000004361404600161700207650ustar00rootroot00000000000000require 'rack/file' require 'flipper/ui/action' module Flipper module UI module Actions class File < UI::Action route %r{(images|css|js|octicons)/.*\Z} def get Rack::File.new(public_path).call(request.env) end end end end end flipper-0.21.0/lib/flipper/ui/actions/groups_gate.rb000066400000000000000000000023031404600161700223600ustar00rootroot00000000000000require 'flipper/ui/action' require 'flipper/ui/decorators/feature' module Flipper module UI module Actions class GroupsGate < UI::Action include FeatureNameFromRoute route %r{\A/features/(?.*)/groups/?\Z} def get feature = flipper[feature_name] @feature = Decorators::Feature.new(feature) breadcrumb 'Home', '/' breadcrumb 'Features', '/features' breadcrumb @feature.key, "/features/#{@feature.key}" breadcrumb 'Add Group' view_response :add_group end def post feature = flipper[feature_name] value = params['value'].to_s.strip if Flipper.group_exists?(value) case params['operation'] when 'enable' feature.enable_group value when 'disable' feature.disable_group value end redirect_to("/features/#{feature.key}") else error = Rack::Utils.escape("The group named #{value.inspect} has not been registered.") redirect_to("/features/#{feature.key}/groups?error=#{error}") end end end end end end flipper-0.21.0/lib/flipper/ui/actions/home.rb000066400000000000000000000004051404600161700207720ustar00rootroot00000000000000require 'flipper/ui/action' require 'flipper/ui/decorators/feature' module Flipper module UI module Actions class Home < UI::Action route %r{\A/?\Z} def get redirect_to '/features' end end end end end flipper-0.21.0/lib/flipper/ui/actions/percentage_of_actors_gate.rb000066400000000000000000000014231404600161700252170ustar00rootroot00000000000000require 'flipper/ui/action' require 'flipper/ui/decorators/feature' module Flipper module UI module Actions class PercentageOfActorsGate < UI::Action include FeatureNameFromRoute route %r{\A/features/(?.*)/percentage_of_actors/?\Z} def post feature = flipper[feature_name] @feature = Decorators::Feature.new(feature) begin feature.enable_percentage_of_actors params['value'] rescue ArgumentError => exception error = Rack::Utils.escape("Invalid percentage of actors value: #{exception.message}") redirect_to("/features/#{@feature.key}?error=#{error}") end redirect_to "/features/#{@feature.key}" end end end end end flipper-0.21.0/lib/flipper/ui/actions/percentage_of_time_gate.rb000066400000000000000000000014131404600161700246610ustar00rootroot00000000000000require 'flipper/ui/action' require 'flipper/ui/decorators/feature' module Flipper module UI module Actions class PercentageOfTimeGate < UI::Action include FeatureNameFromRoute route %r{\A/features/(?.*)/percentage_of_time/?\Z} def post feature = flipper[feature_name] @feature = Decorators::Feature.new(feature) begin feature.enable_percentage_of_time params['value'] rescue ArgumentError => exception error = Rack::Utils.escape("Invalid percentage of time value: #{exception.message}") redirect_to("/features/#{@feature.key}?error=#{error}") end redirect_to "/features/#{@feature.key}" end end end end end flipper-0.21.0/lib/flipper/ui/assets/000077500000000000000000000000001404600161700173605ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/ui/assets/javascripts/000077500000000000000000000000001404600161700217115ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/ui/assets/javascripts/application.coffee000066400000000000000000000002351404600161700253650ustar00rootroot00000000000000$ -> $(document).on('click', '.js-toggle-trigger', -> $container = $(this).closest('.js-toggle-container') $container.toggleClass('toggle-on') ) flipper-0.21.0/lib/flipper/ui/configuration.rb000066400000000000000000000063221404600161700212550ustar00rootroot00000000000000require 'flipper/ui/configuration/option' module Flipper module UI class Configuration attr_reader :delete attr_accessor :banner_text, :banner_class # Public: If you set this, the UI will always have a first breadcrumb that # says "App" which points to this href. The href can be a path (ie: "/") # or full url ("https://app.example.com/"). attr_accessor :application_breadcrumb_href # Public: Is feature creation allowed from the UI? Defaults to true. If # set to false, users of the UI cannot create features. All feature # creation will need to be done through the configured flipper instance. attr_accessor :feature_creation_enabled # Public: Is feature deletion allowed from the UI? Defaults to true. If # set to false, users won't be able to delete features from the UI. attr_accessor :feature_removal_enabled # Public: Are you feeling lucky? Defaults to true. If set to false, users # won't see a videoclip of Taylor Swift when there aren't any features attr_accessor :fun # Public: Tired of seeing the awesome message about Cloud? Set this to # false and it will go away. Defaults to true. attr_accessor :cloud_recommendation # Public: What should show up in the form to add actors. This can be # different per application since flipper_id's can be whatever an # application needs. Defaults to "a flipper id". attr_accessor :add_actor_placeholder # Public: If you set this, Flipper::UI will fetch descriptions # from your external source. Descriptions for `features` will be shown on `feature` # page, and optionally the `features` pages. Defaults to empty block. attr_accessor :descriptions_source # Public: Should feature descriptions be show on the `features` list page. # Default false. Only works when using descriptions. attr_accessor :show_feature_description_in_list VALID_BANNER_CLASS_VALUES = %w( danger dark info light primary secondary success warning ).freeze DEFAULT_DESCRIPTIONS_SOURCE = ->(_keys) { {} } def initialize @delete = Option.new("Danger Zone", "Deleting a feature removes it from the list of features and disables it for everyone.") @banner_text = nil @banner_class = 'danger' @feature_creation_enabled = true @feature_removal_enabled = true @fun = true @cloud_recommendation = true @add_actor_placeholder = "a flipper id" @descriptions_source = DEFAULT_DESCRIPTIONS_SOURCE @show_feature_description_in_list = false end def using_descriptions? @descriptions_source != DEFAULT_DESCRIPTIONS_SOURCE end def show_feature_description_in_list? using_descriptions? && @show_feature_description_in_list end def banner_class=(value) unless VALID_BANNER_CLASS_VALUES.include?(value) raise InvalidConfigurationValue, "The banner_class provided '#{value}' is " \ "not one of: #{VALID_BANNER_CLASS_VALUES.join(', ')}" end @banner_class = value end end end end flipper-0.21.0/lib/flipper/ui/configuration/000077500000000000000000000000001404600161700207255ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/ui/configuration/option.rb000066400000000000000000000003251404600161700225620ustar00rootroot00000000000000module Flipper module UI class Option attr_accessor :title, :description def initialize(title, description) @title = title @description = description end end end end flipper-0.21.0/lib/flipper/ui/decorators/000077500000000000000000000000001404600161700202235ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/ui/decorators/feature.rb000066400000000000000000000046311404600161700222070ustar00rootroot00000000000000require 'delegate' require 'flipper/ui/decorators/gate' require 'flipper/ui/util' module Flipper module UI module Decorators class Feature < SimpleDelegator include Comparable # Public: The feature being decorated. alias_method :feature, :__getobj__ # Internal: Used to preload description if descriptions_source is # configured for Flipper::UI. attr_accessor :description # Public: Returns name titleized. def pretty_name @pretty_name ||= Util.titleize(name) end def color_class case feature.state when :on 'text-success' when :off 'text-danger' when :conditional 'text-warning' end end def gates_in_words return "Fully Enabled" if feature.boolean_value statuses = [] if feature.actors_value.count > 0 statuses << %Q() + Util.pluralize(feature.actors_value.count, 'actor', 'actors') + "" end if feature.groups_value.count > 0 statuses << %Q() + Util.pluralize(feature.groups_value.count, 'group', 'groups') + "" end if feature.percentage_of_actors_value > 0 statuses << "#{feature.percentage_of_actors_value}% of actors" end if feature.percentage_of_time_value > 0 statuses << "#{feature.percentage_of_time_value}% of time" end Util.to_sentence(statuses) end def gate_state_title case feature.state when :on "Fully enabled" when :conditional "Conditionally enabled" else "Disabled" end end def pretty_enabled_gate_names enabled_gates.map { |gate| Util.titleize(gate.key) }.sort.join(', ') end StateSortMap = { on: 1, conditional: 2, off: 3, }.freeze def <=>(other) if state == other.state key <=> other.key else StateSortMap[state] <=> StateSortMap[other.state] end end end end end end flipper-0.21.0/lib/flipper/ui/decorators/gate.rb000066400000000000000000000014741404600161700214760ustar00rootroot00000000000000require 'delegate' module Flipper module UI module Decorators class Gate < SimpleDelegator # Public: The gate being decorated. alias_method :gate, :__getobj__ # Public: The value for the gate from the adapter. attr_reader :value def initialize(gate, value = nil) super gate @value = value end # Public: Returns instance as hash that is ready to be json dumped. def as_json value_as_json = case data_type when :set value.to_a # json doesn't like sets else value end { 'key' => gate.key.to_s, 'name' => gate.name.to_s, 'value' => value_as_json, } end end end end end flipper-0.21.0/lib/flipper/ui/error.rb000066400000000000000000000004431404600161700175350ustar00rootroot00000000000000module Flipper module UI # All flipper ui errors inherit from this. Error = Class.new(StandardError) # Raised when a request method (get, post, etc.) is called for an action # that does not know how to handle it. RequestMethodNotSupported = Class.new(Error) end end flipper-0.21.0/lib/flipper/ui/middleware.rb000066400000000000000000000026541404600161700205270ustar00rootroot00000000000000require 'rack' require 'flipper/ui/action_collection' # Require all actions automatically. Pathname(__FILE__).dirname.join('actions').each_child(false) do |name| require "flipper/ui/actions/#{name}" end module Flipper module UI class Middleware def initialize(app, options = {}) @app = app @env_key = options.fetch(:env_key, 'flipper') @action_collection = ActionCollection.new # UI @action_collection.add UI::Actions::AddFeature @action_collection.add UI::Actions::ActorsGate @action_collection.add UI::Actions::GroupsGate @action_collection.add UI::Actions::BooleanGate @action_collection.add UI::Actions::PercentageOfTimeGate @action_collection.add UI::Actions::PercentageOfActorsGate @action_collection.add UI::Actions::Feature @action_collection.add UI::Actions::Features # Static Assets/Files @action_collection.add UI::Actions::File # Catch all redirect to features @action_collection.add UI::Actions::Home end def call(env) dup.call!(env) end def call!(env) request = Rack::Request.new(env) action_class = @action_collection.action_for_request(request) if action_class.nil? @app.call(env) else flipper = env.fetch(@env_key) action_class.run(flipper, request) end end end end end flipper-0.21.0/lib/flipper/ui/public/000077500000000000000000000000001404600161700173345ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/ui/public/css/000077500000000000000000000000001404600161700201245ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/ui/public/css/application.css000066400000000000000000000005411404600161700231410ustar00rootroot00000000000000html { font-size: 0.8rem; } .flash { margin-bottom: 15px; } .mw-600 { max-width: 600px; } .bg-lightest { background: rgba(0, 0, 0, 0.01); } .toggle-block-when-on { display: none; } .toggle-block-when-off { display: block; } .toggle-on .toggle-block-when-on { display: block; } .toggle-on .toggle-block-when-off { display: none; } flipper-0.21.0/lib/flipper/ui/public/images/000077500000000000000000000000001404600161700206015ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/ui/public/images/logo.png000066400000000000000000005141551404600161700222620ustar00rootroot00000000000000PNG  IHDR@4IDATx%E;3.n ' pׂ;wg·=\yîmaaaa0l׏^Xy%8MU)nr fIᅈRJz(!.47ψ?֛̟ħ^1 =» Þa盅SxG X_(uI9Ki,&JE`h#Vz  c$a -(∯Im .L=ðŕzNy%V{X62 a,P ${F)1E2s %$ea4f e0RA tҳ!DH$ !{s sa~0ĴtR3DVXF& 0BR+E! Z',YDJXX`'ٓ$v=2B2! vA 1AQ0[oe'=[ î~BYJB^ )ѳD,`@wd@~@!(gLU B5#zoH܃Df ??MàG{]0,|=] \EE$9EM0L(clc@yse ) PB 2>Pc_g`P}sٖVgl۶m۶m۶m۶m{gOҫ&ӵosI$1L^26ޗa}I1/w8<qgց77&zX>g)II{ ZhXth:֖6X,< @ѐ_A`A aW%,di#\TBrB}<,ҿ%|<8`  /=ݰ{:q6r&ML'.Lnqvi8R2fP#h1j c3iE:W`6" qj"4tMώD!B1DxYc$q~ʄ!a1Ec<!sF W#E>i), Մ{b0@Vsb 65{ ZN;2 zn4^ | <_W77O^='iP4yH-xvҗ=f{1.`ԐCa\4mdI'ܭyyo8p}Mj<@!'2e !ߜKv#;}3!<}$~ wQ/G܁'$n?$BDAE:Y""Tlhc :aSbTȚVkUl" lja s{ @90TG;%u/fkJgf-R*Y C55MD H/_={S'I?q `hVp- 2/EMعY2T N ^mMSb7ğ㧺Yq{T|qj Cϥ~a@<@a MPh30{,ˌ@6h&؉jVՖO&4's)%s&luo5bjj1Coc JIBN!W[_ٓߦP b]z_E@8ɇ,,U;uk=ƃDf/H&//TްIy?kl!] 1^KoRd#^Ş^o}0?e55{A\6a7ea4$}a$e(N#H5@VҰ/gs,y>1/6Mc=of?ݘ*IU1V H NYCֶu ,EHD;!;6BWkp;+;ݬK;-5, r,EGHJEW8,LRm{qI=D`HZY__`r@~<(w):SE]ήC) $`-eXJQDﵿE4 !ư uI"|1-Pu(P"-/ķ?p؏h 1l)IP ~D y O㽾z'BD4;1C-  JhE"YţdU!'ө**YQˊdٓ\fhg(b1`FN6Pu& r+mgLv eTzyF?0CdnFD&>|udH2TAUG IWҙlp+ љaqSUdݢ$FK%Ujyσ<2 "j| wZEW}  Udw6 Y͸ϟ|ߞq^|/#pI~}ܑCZfd 2r21`{Uz Cf㋙&`nreuf}v @A<> `E(Qqstޜ~Ǣ Oi;uZq]Ai\*dzC"ΥYR SsGC2rw];zv11u T>KYڗ2np}Dq@h&FERa> T6~`&l%\􃻮S],RZٲlQϋgCwf#.`[NZK5_GJUuF> 4Oy$ y0Sdq"zWqQ(k!p֢~O?|Go}74҄fc5S1?'٫15P.mk?fe~uwww*$8wVXthik&9h.qe^,k ]5q?&LsiF`1MmKљNQc";Yof#;ʎEEq#,a1V?zJnAPP(i;\`34]sRjutјAI!?:7\:m+<|=osş^_UuXc29G1WUO4oghqE >mcKɘlԽh֤bO]bT:h1Tץ5Mt_U=1)ul^&*D1m> H~|J6%Iq<:JTeXk RxM 3>5:^ +bd$@q= \"HU:0 ̱:L K+9-':/HM%5w@GǫƁ'ZŪdP;7ۉ.:W:_žZ Lҹ@}pwg #f܅%IU gDPд+q5·u(<-${X @ѡe> h X뼷b*"`%4 Lm$Z݉#awtFT_$KsUآ]㵮t#Z%f2܉+5XHr-fg$Mn}Q6!$#'91.bo}BȪc1  } ^ (%q<\5pɑ!}9z<4<bWaHX2\d 賙ޫ iy=%9'm$-E P{5P'I|i"%߅ai_Ÿ6QU2eѓijuaq 10>tr -.JF(nMe >'4,LAh\$Q>d%% *! %uݐ1|탯˗@!,T[`P Eۥ$ct{#+=pt/xuK%|C[s#-%;c:r'M~B~Q,~Ta'I>?߸{e r~1MUJyOO`uaPV,o_d̹nnox7/ _Sx)Gogϟ3z^Uj+aswɕ#{I\_*xTB]2y:,5s7>X=o*=( :rqXadMά.lX.tY%9m:3/兯÷HXb? ~UaRKGZT]QYVUe,k_fD=jlk[5A "/,~!,W> YJr*?DKZ*Ԏi+oN7!j}vn@&unL`$G-z2FzmTQ86[iYc$8jb}>ݖ}t\ ʼADd[ɸvpq Y:JQzz;umM 5%';2 {D,kbq`yiBs s-c`R FӒe<Ϟy==Ƕc2"*R<k~XAut~$_5'qj8@ ;ZdlSz˼z+R6AeA,MeWOgah`棰 A}uhz#12X?TbJsLUަ& '@!?&-YJ?Gy~2TwPF"m.x?EWgzFhc5Oׄ";bגg*Y"$R .df;Rvd.~U:PfEtz$jJt WƴOu* O~U+ [RQbT+G5^{777C12!CG0,-~Svbܘp{:#>QG1aFtFOo&o>}zjZ*ꮼv/yIejnˁؙiWIg Ʃ>H"odձe<(q: )ݯ_PG'Ɛ:@˳(ba( K=#L88͇YMx[&^%_Ȃ#"gÇifnau` (X.\S bvi<5cH%|swRQl9y;Ο%%BRKwTe>H=c;d՘ AD,M-XWם14ًX,atoBD CRudE E v΂,9cUīZLs:ø&8#Pio>9ߑ~0#;YaT/$+!RXWwZuTz wL]+5yu32e T\R$TǺ9ݼ8G$fc8?P"Np[CGd$s4q$ 8l6FB|gw" FB-jX*6]yPB.((eDR/X^`Yp:d&c1~DDNpopis1({p &su^݃ e0'IfG[u}V/{˿s)n}?[nߵmZ~=;H=LX*i]*ڃzm Lg1H*t ۜ LXd;}T$=6uư2Imw;[X5.n)`u{KQ FgYD/UXuv-CjH}v$Tً"TaRn)p :+ kIkbE,J%\r;3&RM5->Z=T IFǥ)G2T< !9Sp;2hIi@ݠ<sa1Ze I)M,Ok O˩z )Unā>s~Ͷ?3fQE)YuXVLݾ%]) ":{QwSg+2k^1u=pCTIM9"OF,/c_;Zᷴ~$aWrIu'3"ˇy,n“) ˈ\x^,`܌xg<{v=Qy3=dg 2: +TT*j} P#Usbs]BYF_20|p7M2&YF-VDNOyg0,4c'85f'R]N}?bή//x _w,qww;̔0r#Fj[%67[@M7/QƊd??rft F+f֥gn=W~*9-C?ՙ[6~&xʥc ϤO2jrl +o ǽ5Z 4Fg{UG74V`r?4;{ْ~VTŲ]ҊV"Q\y/LKHEY ֒'G*Bu2Y= DڔEi I[Łx_ArmvIkz<:гCeEɣ[N|o8ϣy2)2LL[,j-ݸc.EƜ̚5 22:~(a`$Jcꀽ>Ф9#(ڶ爲)(eZ#XO :f켷jWkMCsךQrىAZb}'VgJ ض5Nt7]+=YC{<"r- i\*5Q9RcVC"za1kZCET:sxDY8" l}ϭ(lUu(tXp|N(s6̵!SyOrn^uʀxt/Ɏ(NM+& YT=2zt+ӝ%(ܠtGp=i8J;=y*!D>ڛu¢#L=PExƭttYV)^s]:z;.#[R3c)4D660[|D$Qn=V'yJ2={{jim)bgMcb]e͙wVAۺ˜4vp\,$I)1LyWj[OcV[4YV.t|a*q C7?˿/k[xJ6߸v^ZnJ<"T Ar3fpVRilfbȺMX"m7'7BIL4 -DbZ W#0nfGE,@Qj9׳#ম/GVڔg5N"nZ4ag#tDY]ao0B'BXb{Dahd# _?buqo^,nQpeIf ^~/o=.?yUfbR4%2_@}zͽ~.seSD`S}\MaV nnH9g3&<yCjCΰjı?;\|uGGکRB]AѬKg܅(簃qԋ>#7kz1Xl? |l"3ޥU8k#6#v<;kmv#/[`NsLyHdZuOe2Mhې#s n*p)kxTB^k/G>ocY%D{v$9\oL[$y}5i||i#r6!mEBJ;j _8}A|P¸ XAuتК\1)ھkbIn*sˁs5Yk9SJ%Jsޮ˻/NܩrLEgQ@J!֑U\'T}LƪB%hK4!~ +qz`H{Jȧ#3q7:e^;m[!.iXlܶ ݨygN3BB|fѯtZXk;K]i(,'kH.eWt]6yYۺ|샞o{?-֦x̲Y('c9ֺwNZAY3\w*g)ߠחM_M97m>] uO=e(qm6`E5')s%^a ıV=#<=KS Re{؂{Nb_(QO}gꫯqڶ#r,_T*%.=eakp_~ -mk2Zų Hh}-K{'"5v-<-7&nnf~3ƂZBk6"Npu=2Qەj|jGg`Fv|@e VJ[1޾ʟc!ߙS|dnGV]9]x~<ђL8ICeL{ h%&;:7>x)G/ ީˏLC2 eO]$씎Ue-Œ;p9Ǐe*K%~*eiޜa'[Tk8s-?[f"@n 9IڏDx[ ~wv+Z{Aӽ#W:Ay}it(SU.0Y(#{ξmZ$lerM9<֑BvPø[#2dEŽt6~🖳 lg*7_xr.㯺3}952Y|< LmAg(]uQBxm1O<&r`:҈mtmq+.G,?05f0@yi} jvXKޘNoC[ SyَtȁVOiCA7;zGz][ ,p οmq<Z̑}av;/M}0|\9OYJ tMDV(KHwFȢ w&uD 8k}VTc)bZHRY tgAlIlBrr:/ӗ?o>o-=p@n!fZ[|% \.CmwW{6R i:dv6SKC63?pdr.KگF |w^77|[-mC0|t5 j`NЃzH󭍕'i'-]73UWu}pI_Vkeqw8iƳfy rB vl9 VҚ\R=z⣰p'G K cSucCy`־, Mz^q1J uO+2KrFFX#[5A I`d?㏔ 0PV;9)a ~Xah E^ 6j~%pZؠG]߿՘H +܏rɻaywW4rfO#mv蛍 4jy-?:c/ʪEA4B~sDV b 7E"7zƾ/uY\梭I׺y*W Yw6Kt[CŊERheIz)wޫ!hdG?3^2Ң`xja%.o%3YbkmkØ~!*hZR"Ј+GBcV2۶:Y }+K]X5 xt0VK%3|Z`RS96w9~t#x7-n1nfZ aTYKb|I tGh+&PZrgeIwmɣD4AQؐ2q<~F0EP`smՓ wm ?1+]2r[X& CLM,uDw꘸~z[@,ɯ#( #By]s8c\\ gHu9aBO5FG["z^YcX8R fZ p7s1*{}ߵPs`*X)Ι87jտ~\o_~qꋿ=*wyTrX1'{'ʾ.=fëp(*Y%=剤9` Mz56*~+KlUqTeWˊ02YN[Oȃҏ"-IʆpZbn{߲U7׌HIt6A.!zooϳƏR+p?^ʑc;n[諿en?#2wEMN8@ЄzN/2?n;I1)̒`(cW"YIץEApm+[ϟS˄bG8MͫAnp!mqzxh ?^Vrm7 nƵE2M1/(E-g.,!3q5;c5 u dJJ=&~ KZV: gH&n!gG0joukU ü&ۆ0q{ _UsU-˶xWï39;5I:o'ÄZN'O$:7ֶ2 G'< 4WNwwf''#7U:p@kwe%w;p>)k)OL#]'JʲFي[k8H' ELRn G|ډHg)AXHEKUp[ֵ;NjM[Q(1fW:j `t$'J:w*oH)D[ѓ?ѭb+c0nRZ3Aj gቶ#ŔvE:>8p\ za\Evz@-^~٨ŻyUS$( |ZeUGQ|b <#_ؓ-S5e}ۛn6|WuAylAFA6ޑ*L[{vɘd׃Q픅Zq71xm:C! q*N|u1a w) {RpYB\/8&]⍅>H⪯_TL#[P.uBՌ~ K3Ss{lK؟Z7g j! j@~_Dp8:VG֛Eg\~v\d-(ˇV_*wP},p7\ub{,4MWr!Wɪ}x͆ٹXt?ZO*@̖sA#J6D'=G5ù3:m? r펠:coXO“kS>S;[_YX{!M\@K%"FD<)a-|Ɖҍ-2x7_}3'u*<~t*;5BfȖGAi ^hu]}e.L*M(XG8W&Q{`eE;tzn,2T3BG|׵Q 2]%u1>eTd6?)p6B!tr`;?M *ztn>gbi+}Ԓ$  ,g+t5y9__ė|\Jğg~g~O~V䳮fR""nE{2@Zx)G|W#/џ?Fjѽic.; `<6Dm11fܠݵR5Q.BVb-T8ݻ94#Y k/ew;yd,f Ĺ/Xj1c[~d;!LLaL#`-R!9ޮ'GcPznFjN*,ﻙ@Ʊ mIJP\Qt? ?Wy{ŤWq\T_ (3&F@(lͱƘ* eρ܏ndeYke?WӢ^>;y\gd5^6& }X`8V)섥 1%t 􊨈 xRypJ`s|4Ϥ0ۛ)3@+?R&7^w>HIDZ¾N̵m2/)}*4і6L"=_5_C2V| *l ec=O?Jw 'gMbq|F,^M?~+>Yrelӌa$nO-w1Ûm(mp;vsbY:J&̆8&M*V,=Vg@K]]>mHGlcޖ@{B/'"c@uXԁ41Y$q$zGW ~`7noKj.k˨ܪV-ccﲠ2e Ba\@Dϭ _G6+#W<}o/{}oهEv~40Y0L0C'J7lϸ/Z˴| _sĸ#+LE2QE.8GoOlICQw/<ۭ5c:\AV׊z8Z-6pe%@ .`X2\A(Hkkbv͋w'ߠ1DHIsԵɄ ?uj82sε Y ON{xƷR:짢)7gՕ'sOՓ?I;b7zJQ"sL+Ȳ/ G<( \˷xP_7z叟<9H$wu Yzc-J˞-*у=y}d[~^JVp3ǐ+!Kf^Noy%Α*@" +Q$!zTۚΞ*;z<3d.e ?"(,yt3Zc :'kN朗~YL0ۿ>ts$E;thP~K9i}0Ӟ"vEkAkl"T%U# ;¡UxywӦ$%pdiBEsM1]__ .?%Ӟy+VGF3*k&{qJicт$yUʶ}#5RN[HiqLOPE)SĚ^>I@Y6&ۺbWy}YGua Lc,TZÓ,XC I屲>~M[[ʿY؅gQZEfZjvۣ{xp\^T|7|0ϭ4]qO$)*2G@*(?¯ K/^\X^̯ɪO($n (nt;ihX#-[Ϟ9cZ`Zxh& 죾JѬnqLndg KQUe-mYnBe./L 8KC*PxmT%ޝX(?3;F>dt&#oZ>Ϟ=GyE:{ >7 EFs0Ef`G:>>7|g >{ +omɷ8'ɔı f-J9 _㹤SeZ/\;~c{. Lm쌡-| zݲ ;UGWo7 !l',scGז'^~58;̆o݂[jMW4.{ED6~2񲷰)[?5?ֿ#4w.wG~7e|-fE}@||:q.fgDJX\kÞl5re9tJفH5ZzԎW;{bq\kmln۷|^ww$` }/\$Yk1rٲ@?;֚^6EÃVΜ${ݲ;Iu\kgBcȡŭټBmc.#{|>?OO7~O 0{5\KjCwVbsPU߇>@ JDDT&Q<~efj?417?\DJ>~RƸ,GjR矰mWG'o ^5ų s C,2ex؆u+3gs Nv4-pu{ 1* ;Uf9%~ߙ~q" .xV&3 bGb4IF F g )oe>6b5Wp=NtΔ(6&p)Uҿ^+$Ib.Ֆ+|hߋ8N|^{ +B:Hg)yw1&_X׾hڭ803eM5:u.u-Je6eQ~w_67}I`Yk'[TaXeR<|pz˕P ;e6;!jGĬZвe5il:mWX{ LDyDqxb!+: ] s H\n}:"a̢3+hKw`VHP.-Xj-I^}[a#O {0 *]?ګ0~hƳg~g^Ƅ8)ub`'+7smn[N7GgٶqP*+ b͉7vTx8F5Xvffa?f[-m܏<=ݸ<= [asQi#kAetd#2V" .xV".ܣ-E8 f/Xu^X1z.Mtퟩx7 doqu&Ren0$ a+?ʷh/aW*X&95U{}gywOl /#3β Lfú")._o?/8o+ {>>?g ; *9;JMXq8HUnW4ÆdaagHQ6FrG|t|}=}Vke_^|7)^cDv~䡽cR{C%EZ1CY%_/%?Os>0wn˾ۿ&Zj.QE]Ѹ@hIFx,Ll_岬!{IYT)}`wa@+RcA϶1~+=i/W?Rn#ۺK IQ}r  (PyHsi^c`l-,Α ĥS439F!j/jKLX,F|v>([C] rϟ;bjCҤm8q'[ I8GdW}!{d叇o?0y )R 1V9V]KR}ݜilcLNs-̤_$P*w!=P[__69?s.F]k3HQupL\2]-ݬuJl!(sԾUl<65aJG\&lB.5RǕӳdlt+=>|Żcyx?~}7E(r$ՖRۛ ]t;w ( pe5l?0q 6!yw-iU].[碿k0~[lJjZ&iT7fc–A.W# =ʿJہp&Um]M,͛Yڠkf@"[R" s]h(E6H|oUCLy kPk–Yՙ n#&Y˾fg96Q3%2$x٥g j|}>w!o} ϫƧ|'IIJ y0Tg0Qrmd"Rԇ;'%/VYvOQ6֮sک ez p0Y1]Vz'cR QU0ѱmx'q>"oՑE*LN"xx{\=hbHeVCRa@ѭGQ&ڙ߁YT +G,bSX@Yu۞8+V-[`o !M*ud(m+LAՖ?fBU Iݦ?\]TǏKRaP2KR,stt7(^)“g{xߋY}E @]+- i۟m#(6j9tHXӲ˵s3|h;m)Pի(#Zd'ƻS g>מ$Uܝb'ߛH~ė}7_ROcW Q#e|DUPk87zJ~03 Y[ OG84,gwqXQڪc;AC-i, yIX;ρMj<"v&~["Y f&u" C("l^ɘQME(tV0Mk.3dMyDP:9bB#k}u,Zh7%5qx,} F:8D{*hlNZc.n9^ 2i>_enhl%ZnzSh7? O|Qւ+˓,a.}xkb TCՅ |B'kY=?G~?Lc#_bCkM6W%?ʱ=6dDչ[:_fE7Ѩ(܏TarXgeu.3lŭV!ZH]m0qλIՓoa3}&%9Xr9C#G[ڃ]i]k1-ҟmZ7 eD*+o5ٮκ OZI/Q,;gZZCC?Ɓj]k ˻Egz+49יO1o>el֓_Xx-م5U,p:rTŹs\}Q;;P^ߤDp[lCH#"&(.ĸprֶYK\ ~`w.X܎)UJaQGrQJ&Yj1u L $@dt%Uo8ս F\ڧx V{ H=6XsƒfyG66n|饼,IUT1Ǿv"B I,~f7m'ۙE-N4R.X=5w?RK<%Z u3 m.D)MO ;|y-?G|_SrT\LiܜN#tѢɃȰ.evosjf:EO|K|G^h^X+ŞGlpϰu.;pTIhR+0xB1% fOtRmHkE5Wٝ2:2HmW~ܙ1C}@(J@*kDF'mhޣAzV۵ژ]w0:u_i\')ᔪ2Lm}_UKΜv|_x^ZN_?% l@iQhY<=]afbx}oFxKE Xo7om)qV 7:A6ꅅPZ`ʏvc@[3 +5(@?M~dIGօZE 4 R+}9IXoKUK4&cOsmh1ŢV fOu?%z Ƅ8}OauIS{֊خGs:{hc䧎Wm4s1ɈA9&eǏ^\JlP]H&&& әT;nw\h3DíeV҄>V$, >OFfb>bd$,c#*NE`A5m`Y:~?T#/%IH;1™G&4Id-iMfuo/rXk,BRfO{HCmGڒ9/EAJ:ӑhnL6 9;_Үb*R?#~*.,ִHr4(j9rYA6$10eSҾ$ }H֛Zե&l,wkʤJ.;Mq`D>?y`]Z~0F973aMlxd08C<8#T?xƐeʀm8y&I/b#JM&7vRd6"5P-9Y*)D: ƹ=uޏ+$V35¢ZͰ(E-L2ux=<͵̣٘&i/{hd?'/5j}^-V"B mq뮥(Nv}b,s)3y*۔dW1wM.?g$)*L^  TPfT\*5ֱVU2ZH{;q-==!2M"FϿyzO~:6Q _TCh21(w ttS. in/^2l̯JL4bE%R#|s:k YAylKX9ޣj3>Wq畮 S\ueI:wFN;VG'Ve#I ): jY0.qs?%IhHsGV"0i{ w9V,3 1\ek]ygڇ~Gz}l 8֯AnZ++~M0*JaQZϸY$GԚDg\d\VZTن08<%GzIt Ze_]՝qq8(4TʊUOL/bj lGhfc[)lR,"AM(piTn4OtT:jWu3RyE&7ٯDNSeg5)3 yW?cF>x e"U՚Qf EIdk^@ gӰb,W- ?@Ws*'UܡE5'e-ߑғAM$=9^.E3vp;x'SSJBRڅ7ayOI#.KGE(Epω0R5ak/vu>|ԃtUxc%Czfe ]ѽ:e~Xxcҿ[PԎq{oIis?뷐 0k7iIohkV-T5}nɷ&kcq^3RW|k>G~^XOnd}6g 6g x1K&8t{GPxLfopv6 gQXfKdGJE~JV-} g#ZnFև љԖQѯ;IK]@(9Lf8b {2ݡɪ\)[:“,Pc*d[SeYnl*E^3S#m8%PնqN[D=ixB`:S1]>|҇ߦaϬ%wYQ$ͭfg1\>Bj%KLd m+yptwv,5.}бm?7s2,-C;Q>Gq<0ߖElHY]n4Ep{k[ty̪yeG %5AѯA_MJ(yuSe0,3nz ӬbBe֗;a(Qf,G簄=,Pٖ#Kg|7ՓI9{SѨ33k]9h"8\XǹMMn*Ki]1*ґ*%]1uBQ.Kffwֳer$Qx=y0:E641[c ?On/&|1;܈M,4ldLgj=\>svG@lNqg2/ס &fTPi^J1T mfSm5N4|}_T#tZ)7NSwwgj hRtgLZٴ6Bl{崼Կ1 5=m6ZBXj{LƬ}VHta 68e^IbGʘq#e`ꌩ_Eܷ/fv'C'b6!89ɞDHe5:Ժ,*Ѓ$,bi&uk5Yf"HkYyv`Xf5cEh|'%Q Km<ʈ攰/$`0ޏū,) Si Ka]m3kTLbl"HvPl'sv2_oK4q1".^y/p5pC7onau+,*mR2 2~|`+"t%qCz6=uNO}V+Y4uѭ>Ψ5\V^~'8X>6٧ϐ2\Qd`ְ4$fHtդNk1FwqM$DjO9l=p-Ws* ,f0Z=0"aYhCY> æ:ބIXL?*Ya[ReБquC3?,jp.c|iA3 $ICt!$43ږuᎳ VF'O:ϬYL^~)O9Pګ77G8;ےN; V UԆM grEp ^9O5!eo6Rz=K궞.ܴVttH<:8X>#r;3A0l0Oy>ԝS:@@q+e]s9zȐ䃢s1Ցdi"m2g`;Ͻ'BcbϡpZ `"]Eў#˾97]$8kt(si<Bx@ꪮ!<Cwѿl(MfѾJە_!|T'XN{O .$O|DGϏ/<狮\b`}WvxMmp{R4y_޿%ٶ+,rym~ݝ -ÄB@mH ]SKnmȓJ-Ys|e&`tjf$c9N: 3M[u-ߝت˘T1u^-u?hL|$_T`EϵvFk!GWUXf2GK p4Xj̤YF7}+\gͦhyȭ~YӼ2Qa1L)_s\;w-40_̋(|/ &l1HC?WUWyܡ-۴֥$3y^pmƧzG4dz҃0wZ@rPsޖ9{(qyw _9m^|oj\ o貌Q=YuIi ~kkiKBҸ"UŚݝ( 6ޗ@uݺ`BD`: ŕCV)J*4{hlD,E`Ea#YS[ސ xͷ"$UNP:/959R xF%GPrܣMqS~v./+ߊ1Xvy(<U"4$)%͛\Irfi+q55>@;%?ja Rw ڻ\JE؍/hX?y_$GmyUݶǧ!`y3yo nweJڶN膢aiwUfJJUt`NC7b?M8?m'Rυ4x>&&Aˀs|8ˡeh܍WGe1(hPCAu.4ӂ Q906H"s=Xl]pcT=bVuڹ?vB$j1u|1Mgn[O >O>rJ(0N@ꗊL\8c̢ݣ_%bp}A@8\2Xz 45r3I@ b2 D;A[! XC&R_8:IYwҧ@pO1 jٲ!mP$܈P|j%@R3 -g}>S}ޚRK.ג ݺ}\8,|j\ky0ӉQx$ޱۥHh b-[OS.pmv8ܤ4Ëu@k5@ QHGߜcS[L#dNEHZ5gk\7ͧ8jrf:4a3y~nsOr[^F2+,J|<.h"PQ}m^eØXLQ'3@l(Ia;FYBB&_|(s&nIhZB\ySK1  r`IrX8&AK25% xA1P#,AƆa.rt+rn$pNcrrlAk؅SJ]K(M_~;>ֵuu5bL;z=Z˩?wݧ,K>\;ĵװ-8p%AEj]O2[&ɂ\Jmg e#uI 9ݎY1<ũS'17brB>(v|YY<蒤 o^1\GݚJZV ZɅX nB3K@m{;K ̾ ]gbIZ6yU0..@t_82jl\@YP|q0jEpT$]:>¼2Y̒J,L%neET])l`c^\È0L3{=9hOExLӔ5%БHc*(m 78V-8[uZŦ \儖srKXT<ǠJ+lɿi9`9{[}fqucV$_w[p"i1}ro Kij-맽υhIa1͊lFN.ĞȢgr0!.̅]P _߯ErI ^;%" r@,E*VS5+L&vSEJ֛znŒE$6>b6}N ʶm0+?$NNVV,U @k16aj=K,w;^;l0U}<{l7#(YhLe[agϳ2u4m܏xGY rJ>_x}* W]{Y{'=v~_y ?SM QD z m9@y07' Qg(ERRucd6^]y"`a:T bH%ǐR5`+7!bt%~jTbW^+)bd4ALKBףǵ8gzu0tlժym{]/Fq޶.X8;ϼo;Z^y~WU~-vhǷfoٶfQu;v\Ego8?bL~30௿47lʂk!&)THOf^hM52ry{oO .i}#/0FfAE/jUr (U\4IrT0"/#ę,2|J:nDCJ_\>#@~K[?WɰgJcHgg`d2iۇ1aG|g߃OަRV_y_^u5^Lstk5Xf mjlng]_>b!-3Z͎k?ÃK# ˍ'Пÿql'!;A4WCGƉJHE (ɐ$a*l@(B6鲑b&iKٔ]&`aOFѿA)[cibm"9Gt74OĚ EeÄ<#j9=ʠ!p38" Xm r_W}3'ZM:n`|=?Vm}ͺjoc(ǡ8 ϱ7yh{zַhqݶѿ=G;ߞR$zaZqo!߄R:U $y6Ah2fȠT]C/jy0 6P5HF`GBHJk]?r\#*nbRa͘NZ$T6FaP_Z*F8bV6ɐFbhG y,bA6f .: yFTK6r'I0xbO_tNըC\͎y"T^ :uYlH+OQY0 P<7q]F{ZYnOzcLs .uBG:awq'/ ??! >'&C{`$e 5 n vi0É"_ V,! Y_zb9}y=)KpyK*N@$:/l h|%}yvh+s<0eEDT,Mby!*1Pat9N(_VV j\׳vbMU󾛬XjW7M۷`wSsMf_^{{GG֮fٹmჟxqcovXIn8< 4*џ%4E?c/lV6 z)#޻~98l5+[a@%zbR ؠW8YM)58}7"bRMv1 b#xb`Tn$MPXaxHJk V+&W3QW+KB5݊Xt'޺n>hESo}!fS壧iY kVzgX8OB\ϊHyU|މ їYbȖȴwΏ|?xo|3ʈg}?_į%ܧ֛'*bw[sFP\M ,udYPvK""Kgc`-?dHcQplR鴘R"O8d W X'}lx-+UBK)q Epq+ _p6(Zߺ&ͣiwn2vOzN{ҍ 41:{>HRa;yܸ/m_f5Yg{l<ڹn\ݹñUm;Nu\goy #P`ǑAtn8 `rxԾ!|a:ýxnLʥuI^\)N$;wZ l54H;IT!٫99#ȅ]*0"h,1"- /b(c4O׀|Fk pjAt0nfRD"2-8C"<@R⦘tl_;N08ZP8ԢM~EK.`7~g'aP7Vy+'澕vVzc"k՘u#u[ö|XbF+Env?cl"q q`5V[ '.O=0ANoJtn~ g\t^c,%1 9rsifdϤx7Q+p @dn|dL%#dܾث6C\'PB˩25 ]908~p,).&v^1T ;*ׇ6*EnI-)|1[A *JThȐ %P$q%7(JsM/wxtV_Ϟj3m\u-^E{+j)}=jYiҩЦt,=Mb|6翝K+3ӎ;ZppD ȅN7h/Vjmsjf8&Vd~g>m/2G)?lGEiv3!P(!F!y`R.3ud!HXFyPB˫p-[fHJ 杻h+b! p R(?L?xݰB!= 4}VpkK<'>00 RAFn`$rB,!Va2^[|݅zn?B.#wJwlRv|]шײ8ޞ|-빨Qs_ :c-~98fZ`}(>7'a?_ Ot;~?pYX 8qCK(vޗbUkFaV @ L0Ϭ 跧iQfù$¥4HMv2;R7D$ugй,pX&>v /o Ejaܠ,EAa "lo'ϡkY|\PLd7y$nb^Cl%w'et=H7d V~7ҵH^qJŸmR/q3ýXDy)yXC{Memٶ)k;e7{߈yW]QϯHDDAue[|Cf- @*%a%.8$snӸ}E"3!'3=G1sUK(uYA<_#Z0J@ $x-$3ލ\3)o3QЭӮg iݬ-J{_Aڅ~jjze6m;wo/]qV`^4}%8jFqgyr"vQqoE3xXӕ0^ q#+AYuq&+Kj=dJ8S 5 bUгQ-jzR;J=:kA͈IGE,X * "-&MؼfqH ~5w?Ha( Kg[/[mX?L+fZEVXBC/'s.+xjvqĔZZ(Jˉ"h\D݇P%,2:I})a3 n~u6 coӨOl6 Yb3z?ta=fϽPY~ݘD[}6X 翯^?{8n!2H^2$0+ (5MBܕ[_ "N[v6{@vZIJ3!)G1̵RjEH,,' `QzI9G&V뢞,v蜯 2\[ 3FaBd0(S-Vf/!7,w Adh?$Iej.5{Ak7}p"=Ri.I[ hWVY_xns|1|3#zx1fQ1)U 4Ő unjQ$)QxsA,ksuB͕V%AhR>;}x 'Nep ˛ԉn6{[u7]ߛ&x J[¢e ׻|Kc~7S^CDGbHp.آ!(!`B;rƜ eY1C/p_<}_˲奬0L`=.w L&*Qj."&]V^< 6/0y]"\ S`g9by^潥YCGč3ZG1hG*H}~4?1zB͎j-O`]˄cG>> l D}S >l *]]5h`֠j-{.-"5:Xˎ5-/vKEkENi~(B?k>u3*i泿yl1o>'l-Λa}~= <u6Y/&&$ĔðBl53g\p;0[._KX-\UąotԵʬAdn:8lvӋn.~; )aa{ ^~{맾Ma}ub"*-? y]P{7Pe#g: S[ bxZ/R7.#[S3\S5~{?%j&nd hg8}8LP/ ^7goN8Z$~r`y p[p~[yj=u7a7,&2q~~>4~#xEHu_5E`^cd1~YV֠Z!5T8䡋弧l"Z0.nATrG1;n:C L̳7,;dM׍B'yZAz˙Lg'e @T XCNIb>49͹Ԏ#4G0ꑍ(VJ$iZq(JV9FTSQPo)~Z-~pȟ@k>@R̨ .+ESȿ?}op9#w.mud#ڭfdGR 8S .'>lu"?)Ԭ(LIR _Yx8IT%5+&%)IJ$f>g84*)٠k141|\),w>"P6?~wߝ\m2>UYCi}Gξ<8fQGǢ22`h"q ff,1,覂bٌv6 Os2x@7[ ~'$wɮ)jBH%9lȅVRw+5]u|> A9J,I\@kOTZ[=$$/Z0Cx"G)x !ѦqGĒnXԚsM Q#gr?]K!}+_ Gӑ/.pfXMH!ojx~rbR{SP]EYM1qş-o `}+ε]fW֑O(G ?].޿q+!Ri@ aĸ0VƆ3K4Ps1 S7Ģ (|R𛂦3{Z1xW!NJ8UPc@>|Mށ)sǨr-ZiMǓ_3EIԥ2-캞u q>'@@JϢX:#q)pDk1.,fxW8'&SoˏLNOrYvw䂦^!M{rv#<AE[=#4ѣ/cF_k&,R~T"ѧS\'MeZ|YcydбH✋ߩ':}KP!)KFf=Q(Njޙ,$pީN/$Z'=k(Dv;=B׻g7ITȬ_(ku*ZS,\Jd3-DFo.i%⏟]5Xm]г@ϪJ_%8.,J~kFPOw٣W2]){S(65bj5*A0Z}! 6W2jNq_^AUA"ASz }LXYCLWk4[iGi2frIlAj-e~4pp Y`5LHC馷 T_& Tzgc~,oX5IʧJ|O>&GiԱ]5e e}V\'FT"jdd|^\TX5ϤKx.5֍5;r=fڇѴHdY7-]L'rG-cV3~I5BO!ԒFcޗEZWuLl# Zhl2I_h 71Եj޵_MH(z+*<:S!g:5ʍ/~D[3&S ˟"03>kksFTMo՚)5l05WC'2jݠ^3 U}ޜ]Our<mqNp`b A/M@1vM^v^@$SVH7iYNP% J/_["Qk(Vj`nW.KH7ӿHu XWpjʌ|Ή/WCq=xIzeT߯{U!YognRBzӒSfV4Kd/_o=xH2IcH`;3Xюz=iQ5߲,P@[Uz[:&_EF_ 2m_3YL膃(%UȎ ǔc\%6AKY}m5 ؇QJ/ʙ2- [hQFkk1 AKqJqy5nX(iп&)$bϨ4D}M~c( GB1 &mC)yNI7EBjAMU|2ƪ@j |Qf5Ŏ!9ՙDRBmYm_gQzV<76*#ރF4cz4ULl{ zU9FVMNeU-0I>ׯ oV8 I~uڹJQیeOeWWFfa6Y<&ˤPI~g"Y+zf#x06 {9SWvv~i8Ef_{>7\XK[IsX<(s-JY /_ȩJ-ԥ*݄7k1kr 3*$>*[ٌ!)U+(M3ok D@D>HQ ~~Itp 4{djlc(M+5zcXzmpKk{*8ץz{A0|2z:m\_O+OtFKu2`b[U'wAInD5eFǗsP5Mrh{bzE%vm 4lMX'F9(PJ(OeȩxjRJ&E/rS {gF7Ԝy" nʜ0lWO|GcY]:{d훺m]{k]9dg4]*,@YpqzAOq{=|Ǐ;z+zN12@iQcarT֯{E_۾z|ϳE^qA^ Z"nb 9Q=Da{, >=}69:EpBCT.idfFtt oޟ_ߖ6$V^nJwc=m5b4aj)BŘ\3T1H7D04'>>x3[Ml[4}?^/OI bHA4kl슦4!tz6WbudW^Z%ལ ,dKR(-D&&II#^\Dr06HGLQLYϟ;e yVv3aׁ:tOmpr_@)ך9VSR]8z,)nGOx :z{Zx~?^i@#d!Rջ/?~|Q>ƶ ^-~7䗁Z.Jm)f$L+J+1AEsF2m#2ICr(`V4[7K>ñ~@]@_pWl*ms]f8kIe&I;gC j O~G~w-^%hrB*G,Pǵ \*k$٪.6YpA7vYv v7uUWr{&UKjYU7Ep}ˆ8{,ES^]5HeULĿǦf&Ϟݺg>A=ڻQU`mGvENZ%%EQZj\}i]mq|% k?Y=z c꥞Ƶo~w1FʧnUq1UtmE`5zSɨ;uג}֙Ի$-3лGz bfR_{lǗ$&$O 0+6~T{QnQF0Dy(f7UH#`NS(([3ko%#X֯~W~Y怷V}?|^/5uԝ_z^ΑqU:AX ҫoOHzqC>zO8P9=|ݼ# L °Qz4)qs$4)?P^4~_{%ٙvׯ{_Ћ)=i>R|ש_c3G?Ե+;sݟ{R^ĸԺ]<49vƊɯ2͙8"U&q|>gT\]O\hݸ,H3!L^K;vǜ`+ =iIKL0ڄ;+}2#ʊ=I;vMgg!1p,ihgcsL=O-$#Ykx}'M?սXx>c?I~WRj.= 2Z@]IYi42wU_dK.p3P::޼kW*U d(B* +_9J7^({GdקL75Iucyd\nxpcCN( ~@72z[Lgu3ӾarA_¥zS/赣?G+4YQUEy Q\*}?@4ۏqȕh;O?Ī;o( =g]:Y`YtN% KGAģT}_84fY׊3pIz_`@ouZ%q:.-q.[_y{~8ţ8u -:ASX2=#c*CޢL%”٠Ǿ8o|RZ{cwq/0_O[/w:OUMf%I~]D9~^ոWU3yJzec2ϑ1rzfY}0]{f=d'3ԍxAgc^Ak7+/.!47rik9KR#]\.r~Qh^3u랽%0ƒ/6.yJUAM\r  $3+myd%=`M]V$h+G9&knm6,yXAeG\Fs;52 O.Eb.qN3 *U$ Lql/f7MH ɥi4"/4_[xvƏ~G32Y^\⹈l<ЊI\Nt}xE$`nJXU6}5<~Sqv~gv*) XN eBĿ\R`ڔ>5Dn,kNZ 00W"EeuoQnL@qp,\0uM]whJe橅FmhZױy1eIuS5%4Xսzxi}bw>%\,n\j=sWI5Frg&E oi^`ڕꜾ, *j|{kV-HCqXhvnLL7k 1| 5di(UA[H 4#PUmkbrJaLt|AOn+6ԝ3|`b '㐐1q:}Ӑh7|NT.g1bۇk^o\H*A}] 0[6ul.f]VO]I_L_xbNI4JSGÿGe}< 0T5ٛ d, WnLQUt %wFgԒWbsC~K54QfN]UH4˵9P9)˓m[ɔѵJ7EVE6*H?^SŻ{Dg S>ȟGWP)Ǩ/MNX%2blMKL\ `ANƗ٬}yrH+\1i^]:ܦ(}U eXb=e56+w|3QmÖ9uVeVɭ>&/ N'fLQ2u8Ngˡؔ~ٰH]\wï8ص0mgGFGGdȳBuDo,[rBdAPw./UiL+DRje{۫SQѤ^G?-.ߙSXo'F^Wuc h'@INJ<Պܶb/M$Eb8O닿:t ȥ4[KLNf% BrqhxrOFL*ex^Uz; :f֔qR> hzj)$73n3lc4uuͭ{&%+_Г;vv 1JY I!QТpk@>ME"XL>2[֙wD$*[uñٲ96>58e`[Rs\;+B>Yu,}Ah~ ٣}@봕!k{kuSAo||6ܘrw]\\ $Nspw2Í\^FcRZȁ3+,vڹdw8齷Qo@f?')D98=ٽGԈ^z%Jd R]f=[CAըtAZyVR蹠;e31h,rCn*b.k4A3¬&։IxKa12hz!*ǚί?>Xvq15={z_MC9Va~/QB >OʣF {?Xq$"mU/Ѱϵ'%:(еǒIT .y$bhm%>zTL(#a>Iy&R8qRaY YsT0'4%xkUJT6QB5HJ2]O$@Bf|p8XqYkTBe#2L]ZAIUb9TϲQ>>ʹ+{US6)I]OL}SXLqEƑ'U6f fª&*JT9C0X7aUqJ_U3iVG%&K 1U Ⱦ1{@[(t\BS-ޮw#kE]FDž(!O>Í.5>G֢u0>u5u*;bYt l3!J@բJ?s3ҫkrt&kJdB]6zhQZM\-8N{Ȃ޺ ~`]q2(!af"ށ -e+Ș~x4 Gƃ3Y?;su`5F: \ bƽ6PhΏ\QiX݉3}=C䐕S iĘ31 MFcAkkUfa=Jl{xzy n:j>GQZ!5 U;I )p2kRX춠dX|Fڝz-;Im^0c4{+&cs 7E'48#82< AV({'"(Qqq]*;>bݏVR#\S$5~V=1ay2.&f?/=2r9Ǯ3]?&#\>B]F`ML5vcpSӘcr!`Bpf8jPS rU}^5jM)dqnoG{O>>Z> z+ ˝J=4i M3TGoILtL"]r )Ф9_F=:2i!&ܼQ[)d3(%y< *VogϹv>7MʄDQNNQv;(Ie:N]~@1;plQ ː<<|KsWͥ5݈2F| ?8V)c jur fuLN)Ey%*`d2vaUS_zGWR#(bEw&Su\(bW}v.ɧBr(tuQOp\/r81X򬅅e{!qJmkX㲸gA@.ZD(4n9lg^<ʼc5oAƚ0Z'f12am[xs?(y0;Zk\+xx1ohiџ p'5O15^wG"gRTzɞmzgi(mẏ> Qq"}ʨ>9XQ=[.k/ _Mv;NexA>|uigU廜z"*lU~ϡqškT-*n1AxJG"}rZ6"P/zm P+ l8y:ۊMjR--zN>VWkpY#iY.⥢3Oܼ) ._z͐˃KCqXzR@4WAG1gb(Y0lRip"*뛍f;Ho>(kEo.cAppLU.[\{F*Չcdžy?w=kC=G4I)5Dtu?V qsسT 4聰"\EJrv"G'Oq @|PjbL;4n3w3ٯZ[Ͼ2թqڌɫIʪ%7~+~fe`ޱEt,U^aPpSŴz73_D9> :ylF*_PLRx&&zY_$a yO} p_}vir4abfm'`dw@-Y'fLuڑmY^rC&FDT}Ed:GsVC\ GA?XV>}'G=r}ϘSjLAҜ3ˁS@<#J )*y{)khaA;căs`q"%(ĵk?;t/ıvZ7}m# ,tp0iPKd6=4ykՁ+I>oKd(@D'07#޿NS_Nje?tI~F}zGXŊł=(XpóBYw懙b\<ęM<bH_z1L-ڐN/EN%X+î"19Ƥ!N:ƾV(iWHMm:5Е\64>yGcj=a'@jӫ+q 8ؔi]'l~2$0wAď{, Q89<N'`Ll3FdWRHv5kUy gQ6Ixt1zyw:{Яcn,:[m1&a1`34Ia1¦ʇ}VHkW8[g;C]-v/T9uhbl]Z #>(EzZYk7V%<S|CP _+8f{oQ-Y^ F0E:Ĕ9fvWdo۩u_EZ|]ٞt[g.E==7=uWѩJKLQr3Pv)pRPֱXTo۬f? " /f!biLL,t]e>4̫\O#k(dIޞaOFr)a{3,m/'_ԯwz|ħں\i W@[mM!2rc>lu/3e2n!iMG[>uǫ}ԑclSt]~/\_T$[5J4OcUgԳH}B韜쿑5Ɗ?,e"eD MU_UC}v[xqr=.d\Čcs&uX5,Qr*}CoS*-OfR }&h6&(zqȐ8$ﳃl@@ו[O x!.ǣ/L۬@j~^Ɂؔ~k.l&UȘn X1dC#^c8[| O7fKwK8蝂m. ̤4k=qݨ 7m$S*=zGϗ$nnf8! t{ʸ [Th! /2䃫C2'x?dgF4܁4K S s%xJ~;q9ߒ,% }8I H/Y\X i,zqT q#^ #l|9ʶry猪G EInj\vݎMUW$I4k!<Ƶ`E?yF7D?9Tow0`?d%]r,ɶDϫ? s80dBm=22˾$>[ Eo)idAֵ{=Vqcǹ9Si >7/ % A`}(17a?7^XE Z]Hl щ8F?Nqa#"C_pg/\@Vʹ)}6 lM]}iϟ'A%mRwE=&v3Q|nM( ͢Iì9&]Jy4U !ݰ_5!pyXvҩZJAoU/bd&Y:_Z'湀w [.:֋'x_΃'d`&4kaRƓZFK $QZWàw[ċ>ߒz=7+VðQ= !#FL=Rwc.K 9`@k)EɜR'Գb`A,IIjsc$Q82(~FR UU_`&X WrPCٞg)E ytgGO:WЂty]V$힩xޏe-XsO |R70Sڥ[<2HKXkX.s A"p8  PxB]_ϼ~2 W+n!_ x(9e%jc&[K 63Jz"$ hjrEزgBLÈg{! 'ԫV of@ (zrM8ބ(p3.0##z\B;e.AA+ ղ|9XR$"(Xgʯà)ˤw2jx?\mU~c4a?{ d4QՍEO$7!sٌArl4T_\_һl|{u9-Wx{6d&}]3U5V^- 8Mx BL^Rmb qs#,O\KB,ӗeȺkz8nJj6hlmځ bٱ=rHX^9ף+"kFVx\~BI2/9|8N'zh39!q=jE._VKOKWi/+JͱrqF:啡{/D&Pv;&ZdN'}UH`!say`YKY|އ kse'䉇g<|hSdښur;0ޣg7ҋpFIʽ*L x\dyuFȬ,K M o$U% sGZ͙FO'E T:hX~$Z&hk$N4j[It[/hAe(dSIoa*d&sl= cg^,QMT'{x1&ag ͽӫ o}_iFf wLz' KveGf"V(Fم9UQ| 3;'4q$;Y# 0b9nk9 a=lq{ jɆTdH3ﹶhXU))R5}%<|>rv?Ȟ}5I7<^C=4̂T3rPx(kї%Lwd3 ?g@Ƈ??BȖyd'x@")2ZW}$z/zӱ=V4mZa{A2*F4;P&y LVhROɅ^X-:@]e0x؟xFD ܒKS>'pll%MY5IZ(x%B=/]Mj~~Y'e#N^UMG}+;kvBs&'oZhM cw"pxrcҒaA,0@z窧i:T#f&új)Q6]ݥ#u].J"{ȥ%JV<"8A8[q4ڳ!,A4MfRbx,xpԜLDT'L*Û(At,}VFY&|r2G./}ݎ I`6H'jC4]:TA_>&BCjm3}UR*MU&:gO9iLe@Ac0hqxK64Cs.oޑmH޾m޿?O+V(^{4%$Ty T fM ![cyJQ4@]ވg,g2jVfl atF)E \C&*R_]i9{Sd n'*c1ibsC8Oˢ=OP~sT׭_'7dTE)$MZNKCwib͎O@ӈf& m'iPLu[6K# c@{9Ni02RD/ t4./_&oU wO.|]Ei UZg+]]*%rV`QhRS2BO@ҝasw !EsEj!*ȴ ,hF(= V\\! d$ 6}eZA 2}SOfBˣKbtS܆ :LkU59,65%Md(1r hJDŽQޣ\wtdT`Si8y>N= 0 'K#/2rģ#a3o6׿KG0EVf/=d@0zkv͟m\nʝoG2C*mF3ܺ}@@QLOh!R3jnGISʞ|Fn=źW8ۧO@嗯:իu|3;_h&35y\4(t&ÏFoZQ)qWZ1g'ʸiw #eOسMr& _*4#0}&f]X3JymI BXPPHVx\D?z/$4;<]3q.1(B|JI%'8Km_*NyXMn˱­%@c<'x2T'β% c1?Q$,VJ_9iZqjF4eF๱júu!^Ě҂3߼'U=(t'2z7 7^RݟrVF>Js*?*m+"UT ێG$?08l~3 5IL^珔IK?[^SDMI_ C|KDOhDc75t G₳7ڿ0qm(@ Ѱ Izus? h;)x }V}*h7zDuxIZN;nk Nb`ZO'"s/,6jAq(_%Vgbe@L#3bFKU3ɬ'&o76"kX1Fu.oF:Bynt Ӽb ␞{;Y01=UGHvq*9p,1>OyծVjKx i p)dߎ1Ld #!8>f0QV ?rS>g14(ȝ4MS_Rр>ʼ~WUW'fE~Wq-ѼTUJ*N;`q\Efz6Z[7nIi(%1FCi!) ws$*&*m Qa؎l2~̾$śIx] +vdJ-E6BC<"8 NUo0V\`б tK7`)APbQ ]Þ]#P [@sa9\w14ɕ+q_ -YK -NO.gWفcϒ,EY3=雼x* OlS﷫<~Wwu&Gվ6&ľDDBV6,f5Ajܥ3I58fURgq\<M벒"e0h^K9ݥHĀ7 H8bJgOb-N D@P:ZbXsΘ 7*@Q{Rie(5ʪ赨#q4+:OhK ҍjVN~CSnjJMn&SUg"ZRT՜9}.0!( fLJr3\S+(_<$4i}ǵK寂Xѻ]w@o\ k$Z<ɧ}Oqy-t}zo:y|$!66 oo$;5o-Qeۨa9+k-žoy4&z* <{Kp^۳_`Aw,OY[*11w#Ǻ&7?0jTalNng]7NN5D-<s.QLM%۲V/>"w;8~J>ԥW|'(8CW H@FП闤{;VԚs`xBP\j8@aad[\w(`LL%}<,{[ 3>h!hvѷ)WwG޷>{ŹZ2$̌C$ax0@MUXM"Al$c"Xe\(1 HBu,c^{8Pʸ>3z%^Ec]}] u\֋fDAmVqe}d(]ם_n׺_ [q ^d.s"L4|4d 0QBHfFVF6VOwݜ:8lL֕ߪ'|&D¹crn"zGYүiQk暄:{})zCǶ[cmr`Dp&d_$x-88֝d0g.獎ZJL q_w\qij 5]?Ƶ4x@d4Fz[(}r<[ lxO~8G?D_樏wC:57:YEoI'~’6*" w!J-$i/h|3/Lt32.!2 4 zTP*.o;ӡgI+2Ȏ=̞XfIc%ͭ-~rqx$CIW+֎v3†WDUgF]MYѨRSLԬ9N`ߛﱄ+|1[6{Osm<2h,ُہGA,CQ/?} )5D`1`)#VV7kRJ % QNrɐ3ۻË-~urٛb0e(x&Ȉm##R  ԓ 1c~~o׏ 0nσ,E,<ÛB|sݥ:9^/1?rwݲfuQ=`kѰtbjIvBUۏU_#h=vOXG~Ç#=/6ZXh`=Gu^>$.M%$c#~L`3ԇgWd$k#ࣲMFs z,Ao2tB$iX(V!V23=.2~+Ԛwd(aVmǖ xU{X5w1e )|Γs9͟/oU??׶q|HoǛ~9 Y:/zr-'F4ǔaɁq#!7 {}ӢUFrU74X[lnzy!V{=Qѐw3MY.+-a{(|֍=Nu"Dz*]L ]<{%c% iZǶf<"'79mG7m_v?CmTv[e/*׵MSfPP0JWd,ld72VWJ[8H:f)}:p3VERwxV;S&=|@4OAu13rkwtKQ^ jl?VØ@I77%R s\;$( F(0Z1|<ԣL$ǏaV0)!A ~܏i+|42Lc3(=H |Ȁ߅h6gnXig$:qb&P:#{vFȣ3r8W83iktuy#xߛEw)rvHD7 S dzwLmc"~]RDM ɀ֤7|\?zWoH`;W0Oo=ٞ1wQD 'J75rۇJqӲO gh o8 wrVuKǤ%,࢙d VRu_#AoF5.|SU@xx+""L]?PoW Lku̓OdyKŒ%oknBW Wd5]XT "3# WS&{pOjFa" \w@FwMȜ*+(fގ\NaxIreAqk(Sl7'C~!к0~+DTlKF 8#b~M+yc@.׬p phə?m! ~w:{f,u _{0}~A?dzg^pk@T%^^F6D)Wcr=J#}(̷?>R']'F"UztLkCBb6ۛ c3xmx_%!!Hj ?UCZ ӾG< |>sYOqzH7[7tyy;wޣK!^X'+ԗx-85Ims$=k,vOhyk-X+=:IGӓl ˜iĪլRiP葉?7h2*z@f*Lš mEx̖EqVիQ>z,~FW堡K7#v 8oOai= F5#s쿑<6<‹aj=ro\}+|F=KND?7e~%EF>w.W7+1LE0uˣWg?>8xHU?{^&#p˾*EoKMY1g-kUc;D)g_i2T~p7XAY༬M_Ôf;Zג0Y'd`7 - 2c,|˙@K<(I WJm)7q<=aG*+{w 7EJ_;ӵzh;.WJ!&_A "n[ifx]xVq]'~ZqT9$ g!4\.gDW}fyE;?אXEǞV edEBC몵|A3MSRֳ,]5cDb[bc+7_h2cq}iWȂ7D5|PǓ]@}u̪]x'߀Ҙwp3~ tM2%>nl3˿ ㉊o5PQ`M°Tl 1i;BaJ?}ßGuu[+.>%IY,dT}O|.KO2?@(0ݸ,Dm\Dm7ј|t6YD )&F_)S[HpRa'z&]hg]^* 8Tr=PݒG d?׮ËRV/mao.8ױLHx4shM<Efיrz!!.!&R%:C4z/ wY59+0=HϜtAFڝ*gI8z}5Ѝ #qróVjg4_)im5Pkwe~.-_({Ȼnc_^D\=SZ=>s,1Pc5nOKL!.x~w![`uD\',S5_;ow_?gg1e>=ӽ"]dt_6swY2VA\޺ZW@ڎUgF.h >F~Σ=^89P =AkK;e&MAS%/nQUE3NpNUQh;GxǕG~RƵ+{x(5Z%/$^Kz6A#qw`)sK,**Qs )#B#zW@kKZ3GiSüo_!֚4%еȮ%N ymKM AtPD_Ad^ol&rrZC'E)2o&ud#/BnF ~3z>#?V='H';={P85n"dFLegWC?FҿU)?3gz+f&fF-/`'ZKGc' }:C:g ȮRh7c##)^G/'\l"]rowtme-1/^_~v] I;3=Vx1eidq* >4&{rp8E6&UWfs7FN |Y̎' GZh,iw3lpGO`ek6LeBb5՝,˱$ikE؛H. C! "u>]iﵽsׇ98)βn!r,VUkXRƿ+H,u]җOi V`F&=mD0vvGĉͰ %FժÃG1}m%JYt֖P^$'?jPjb%_J:,*G}F ?? `: Ȃ^SIOy~H],p$hs6}C#aTV'Xe?iqP@g_^zr7;'> ƳYyeh \90+jK p5)-N&Ђ*}bJBlk׍kä$ck.̨ 4'*Pf|bZ<D6Fv>Vȝu xi;=++c=s PCy#-(MFS 6xJn m'cro2 $KpN Rb5/#vlS&UCa8&]j&%M $p(#8a<(E&}8DMd Euh#52;y? ݐ(Kps0IowA޼7e{ k [:r,"߹"g 196~?(2KQff7uggH  .d>QG~KYŢ ɯ} >#QI,ҶO$6U#d,-&VK1˯\"'gz&cgE/%JD[?>Tَ{Gz\w@a=[2/?P SuXs̷6DY0V*{ 4ncޮoJ[|+O&:˜c$j붌G|I@Р}L*ZŮqZ y~3r/eRpd;dds;rqJsݩטXs?Z+qg\ &V@eOBg:ib"ぼxNϒRBUܑ!xr=.bŊ1RV$h9\:X"{$TL)K.kD/ ǶUeX6Bbwϧw ZgV颪 M*F ,JZ7=Rb!JE@d7d7mT_)zZGbykzhH{[RjͽKdrvDE𶒲–q\_yC)PwRN>7_ nY+g. fGSX&fQJPpMy0W2ԴpӔYϰ;fK,R3EIxᕷb@ѩ>z2>3g#|$="/2*U;5&hg5]%^GZr2 ߏ>‹OC< m5~}ߥXɕᝒ>od1Tb TI *y gq>' q0\ߓL^R UrdVz.2L8Q*f f ࡜#7h,t-w<8>̽J/jRE93ozֈlX)(q w uRKbS˜7g˜ n$eң3-59Tm bz/xO~],ʣT' ęjcsU#XA"z"&Qv;{,BiJ/s>,{ %ͨHӑJ hPN' Olܱ"L&T"u^PP )f `gFn1ˣGw/ 0q(A״̥hnM!Is]?,)K^[8[r!}z/Ӵէbг [8G+& /#wcň?P}MZd7]~YÙ5cۏC_ I;s$DT|- ~Cg v&&tUOe15aЈ#_" 4&'ZXf^h+ Vb)?2,ZEaI'Aʻ|WOQ?)\ =F &H/"Qmu%ou!vmc5R^[sAZQvC ޳W4Ms ׍xzHo bLj').V 1yFwz j| dR 6JF[#rG/m[ypPJ`dI ɟMF])p2qBKh 4(L2~MMD=S$OfΦ%a!4&8GƗ[Blc[ոwoڻK%}лv˪rRg'N-\EasUH׎~ SS44yvvfU֣Gku +F+vlY5W"W c֡h릉V M䁒-={%&BjU`.F}&X&3\l!I ye3=Pwӱy45&?% `4ΌR.~XͷDO8dWX7 1S*:&_sI+U`lD͊ wx[8l fS۲عG$>N4QKYZF\۵U@>#|a'qlL_K(M.8`ӚKBo&6M׫v۪nfL[<#phJcl';w_}#U_κ՟9ރL" "n2@ DKlJ}[}Aylu;IZg6suh2aЫZ mגodinGy>3;5~VH'鰝9XK| 1.q.SG*=4}臵?\ A`Al-RW:7z j7>h%gghUE|p7yJvqs5}h~Nf/jBuj }R0ԾC1U('uGP& j(#4LbS߇A#I%%XÃ]w?cB/Ǵ`jw7*|ȧ]ll9#Pk.͍PUڢF]sFU~9 o~VnV>i>7j~MԺ9-qk1BξUb6z/բ'萭(n\Q !YRbUUk{*N$DcdvszaSX=ێ (9@3[P&<3-kgqmAu҉ ˓#UddzRȡ39R*rT~0~c@~HhĻ%=RיFd0r,Z]9w:7Zڵ(?4H9i&œ"nEs,EFP֬ACk6WZYÏ#y]tjT#w]v9 "oNύ<m*C^ڀJ<#bOZ),FjG3R(O2 L0a(%\NNdu]LΨ;Y9ںn}O8{{ILɌ\ B}d,~uxrcRe+MbBbdfR?X- \vg"1/#TEuĥ#)< aSBS+4cU\wժ?E Ϡ<ˋe^ĴE&^"ִx2}}5nJ h $k%W wK ;k|?x$1g8cpA?w&L7'+b3#P_o^uB=zQ5O]gQfg>HhO.44V( Z复x$ ISA8D,7ځIRU4'%n$#݌Lt2HTn4cUo\_ h^ѿss}Z6OU瘐AXH&<~ (bA&qSc0BzX6޺WjP H&(҂SO@P̛44@ф}熰 $;J*)ڳgYtwY^f9@ţl9Wގ;| @g"|({\\O8Oŝ#f\ƽF|߲mx}Ac[OxE5yKw?~m* IC?5d&ܦJ&V{(!-2[{FMIJҧJmCG*U,x7.GgV#xb&B=#^=mτ{RX:_g>GbVZd#̡seO|Is+/Ǎq}9VE4=T/zȔ /blKzZ/>"c&#(+>E&K],uGFģ` rѣ`F]{ P%x3x/9!Ā'pnQ›)=0Yu1;v srJ2C?zS#&4=et  HśÆ(Ձ\½BͶ{j}9)g7ec=Quh)"xԻ&YS=zBς2ffg'KTvy-.-h{}-["gWj @ֺur֤ڣUHv*3*X 0`2EHðJ-Y, ^f>I /껓|c忙TIlֿ֭c5O'7[w/^I7@ e־] |dqv&!X{k?̒H$^{1L :#!#17 @|\*CPEJ+H?B AÂB=>M/Uoe$D<%M`sG|I.ˁE5jJVy Ȃ̞75R7Yy֚J=W΢؎/%;Ž]_OV҆9o^' I_65z9(!at^ew#oH¤Y+hȃS$p$2yN>n'j>lx6).,ɺip|DĨSF]~2 DT^4rqӂ(u!wd$uy $aIJ NeH7J&j*`eݽŶrDAuW#92imQZgrw?Oi眂݄?8 g:*?^(AΊn+p>2kL:tPHt;M$EqnN8ŢaT-/TH t|)cϪ\. IgR^ۘh"[ pt}Ʊ(&\ֶ:I֩Z=)s*eIEduvov'Xm,Z?EA#5a݁xMjro!@mfԇlThw  |N/|'ybVe?d"_f$t>:5FyEI^ Φp[)ޫ:1i/OZ-3P`g>} ť/@C|ao[6QK'͛1~SQ6r@27-mF:#aI]~0r&<_f?krX$xX2zkKCCJmnG=s-$V@so<ߢxE7J K:YUC!s%emFY$6$>brʉkvߓ\=9|ziL@;%ޭp/+L&.} wnsfzpwQF5(WF~`*i|ˑDq 5t 5}u#@>˧d-$d4F1=VcQ-nR0oRf1;%J//OPЃ ko172d5)ǂ_Id׺2s,ja4t@e8.3\fff{11 h*3#^i?/2kf$F3+2#6uIc@ ƹie-h*Ls.c({+4$Xu%i:1U%#L[,y?g7?,ם?ĆV͈QV$Zf잺_J@m$ 0z[ ` 5AZM՝bׅSCqCBwG4<IE3uI{אPbZ:'~15Ǡ V~UW>,"v7=FO{3 ZޕŞh9&SD"g:NhxO868 sJdZ*qd`xAE l۝J(.>KA$Ⱦ,G\$ =pCb>yy Ky1iS3q954<&+0e7䔲ʋQ8._:}E?>{w<^Ò+.[r=o5^7VgQ)|g OV0+kK00!35TDMMgXyYй3OOæluC7"Ni@D]Sv`|!wQXsY{ARO ⃠inm#-9-ܸzdc7мn6h]eЁwRݐů@#ed[ݮS=t{Q:s< 4jeiPlUNa0U=",qkۀLJxD1GCIj01|Pt$KQOtFyc(8u0LXrk& dDIO庺G*F&L:N7sn{6ֶ+ܝ @rK&s,yzO&Cr ГT'LU$@5v7)꿇:x3k{}ߋ˺:) 9.2(Ԁe06qN+[ hة:UI6j$#IoQMߚ]̏ ,htOllb\+-DI49n 1MXZC64k2bb;3y/"b MQ}@'T/hu1j^74]ǭs;b[ǀSٟl:R KX,l&X+&ll, ?EXg%&?O<qf1>z[ 1 Wid+w5U]ggV0A s Z3QtMMչ-T*}U^Y0n懧#de]ViakFO? :G~/k{GIZa~STPToi07E _ەկE$|*\$ `ġb ?x/ԛ~5s.bb E{wω{buARVHr<0Z@9.nʽxĂ)d3w;A-eONa 9}ms[TrM1i$OdH'?Dvzc&KwNIY " p"m )ϼ 84*7\xKERT {-J"su2[ Z,ޣ[pZ]BG]p8u5u:ɩ+~ìg9US EˁM@^Xym-;,Uk-R=T3x4tNYṗ=FkFRMS=lQH u!j٣(OapM7 5lQZmBZ[1`YN`NPI _?q=5P7AgDB$(!zm,bF|:ȳ/镟r#|m_jjI08/tp{O1zLvXT5j&inADЬš}oMVqDuUH *_Q{^4n4 Ԥ? Zg'2*x)ge)"W}ctqOyາzD~;r G/%t)'C yܞZ̝{8i(;CY])`@:XVL9 FƸ &Q҈|&@3{tGTWTJ耊K/{/mc xtͨf;<<')Azk/M,(9/6Hs# Wkv͛R3.Q]SarmҷDGPOᙚ4O#D!>ͨ1O`kfɓ=2Koe=a-67z8=z{͞#ISpjM6dtXt4[m+d'jf:Hݺ!Ѣո9`;uۨ jl^2?@^j1F)U}bqXMT#srACdUlIx45sauo1` hk[FQӜD٭NԚ݉)a$5?:g$}z#ﵔ' Yl0 g_2{w﯎%:˗z/7#AazAK\wup>%4 Ѕf'uf'8\i..0s 6 '&-6g/#t `%x )5{Vi~g .9E|ag\vbs#uD;at߻+?qȞc4NZMP4#C$mLctbn%D?uqCG^_S |m=s?i=5C3o67ƩrLQa O`>DZvӔG޹:XJjgϜ8^\u{5⦍WtTm!MP'cDUM!ZT:;?a'l' e_#6l\o(8V#&rCةX R׵jO֏_-[߾Tfϸ7fz ClȔb3sD7cgK A!dSmndyr%X8i_o&C_Ӣ\Z]Uǎͦآi 6\g[DlE/Z0^z)XuL#I؏QGc /`l-11{s7ˣ5YZHY/E]r N*CIcMp`a(Ӎ5M3 /IbϵsXڤh$BQ-cCH3 Q=}kk̐eN=vv>.Q'tgp$6BmѭWԕϾ[/6nᯙyù$rݼe$.q0Mf"UaGF^Fuh *w:vb[pԩd8JJޏ!܊yr{_ XcpKޢ r"LR+#t^(#?A2;lQ$F((E}=Uҫ7-ZPvۦe&8d{![Qsz1vIi-߽\.Kډw5ڵx[<$+摦 `>[*ه>gPH P$F&BR@۝91E8PGs&V^+#ca6NPޮ[4a"IP/Z' rE*SK Lat6 aaU|U.hVo M|nvWʚRTbL /F{yt,պ8_"h|fyn𬸯˗t}Z;pv))M59՛:~놆#Jh4M[8Ho"JcCxκRX]ߓd)*5sz M%^i^;7 Vmh4S o :K,-X "h]FKmF0I5,̨JSQ"lLǒ":!ҁFzKg$Za+^mS#q_Go ^O! ^r2UT<̾i֢m5+ksPn!OE·+`RG. /Zr aGTSipCӽ&M wM]n6ɋk%xG(lƤ]9Xg< :D_l0_ba)atFC9 D{vw%*WnfW<-p6OF'cբPGMo̎-ڋł[:f!fe)㮝O gOtS{$eAՊGH5f^7&Ů٫_^&Dž":O 8pa=B|)MrK=>LjyϘ0)K7BmJ, uP;]~4Y71OER_k&dHI -4"p`kMJ-H4b"ntbya p  FS.4FgЃoQNtkTĚCX P)_c=YuG[emaHvY%ʣ8 %#6ȑE6kSt6}Mxh ePv7CKv2O@v޲9PMA kh׀p#_v iCQ5yeG ŅTԡGR@{2H:D: Fn2{>떖{Z)~Sy"ͤyL!'~r\$,O 9"Ԁd #|?X@ oUS#UC0݈+ ^4ul= "H)4Thj lXc֍~Z+Urv C6F7Xdb4qыX ĸHAq4HCn#~K KXVvD#Ȼm#k|JC"%$ιhwF- ʽtM"vشDϖ) VdGf<P4z1D<б-5| ͱi4(Fomӑڨ/l6cҌX3^d bn0457L5VkHd~)'V\tlqP_Xj=HRKQ1¼~4K#5 ubtUNbB4`s' A{0khߓ,b,wM6˲Wtڠ}u #_i٢ܓC~i0 -]TEYג-F].Ѿ"0ىcZa<y6Ny_5n.J f Lܓo*gyiTnkDN!3%Ʌ<|,GWRLeeu]S 웁6y hՉ(hpF Qȯ5?NB #8@bzj 5Emw@ю{E*zcyJ Efd扉 RUl+FHO/gyɽʊzN絀Au. XQqzV"%(%+Zj/H:yޯO5 2\0.+$J$49wB<9oG{@ZfiL.'j6dlvzK[1uё:|bS(/XDxn۷O&4ƣTv:Vitq挞֯|Pcf~hp^MsHY^ ]݊ǎ^Jfl-szdB (hρC0yQz˙oiQfCV;%DTc]L'gk`Jp~/>2P蕻 G\놁wR wF8%G-lƓN`BALp|Lzmy88f-^7} Nq|s)Hy`q"@?[$]S;e'ۘAHR)`\EUErhN5eZ`>ZAc#qu o陎_n@s0`>wXkVA:T@u5B>n@{՟ve43dOi`)S':/M2oycI; `ߘ9 IJiߛ{I='.ec$UO~Xگm9|\2K~kY2ry >J0?\[ts}L#ӳא3/Fs_\8%YB}_3ڝC3,=CX~wRPߞI_& 26az) "Cɝs 7m3qJ$b_]ZAy|M+RiihuMjKSaﰣs݈T UK6 %c: 9'' ɩHTMˌiAf/u/IW{vk!XtlQzߍZiuD™お>cpzbY)XLʼnktK30(,D٢,7[:}46LF-T<09|!U[Ndy@n޽!"y3yc*J{ž1(nJfї+ ה\SMZ ~M*(2:?"^ݡBI˸ Od08Rk#fbtlcQqsv`=cMd7壪Cd6tejQIӸqTI3ĺzh\p ˬW&IK/P /).@?Y=R+iV )fp}wX-$7$ޜv\ᏼG//i\o0Bө[ZZ(25y6gccX;Isw H0d`OAU5VO!He;CR]]E iъ^&'FO#ыp8G00CD r3SuSgj|M~bzx?8=k ֽr?S0\DHÝ18)BۭN~ӏ#1Bk=l蛣o`>xnw7j) ^0$ڵ,y`#I3ԝ4 V~?YU[\ha{c -è2?D\JXG:9DP"]BQ"1 TASx,n-oMW՗:Fҡ0ɝE8\ KNi xcIVLh~xO=&MaGQIԪ,l?bHg !7A:A&R5LN~U3!Y%6]ޗZt5IjdNҍC $EL1&J% \ MڢcS-&hV-4ol)g ܠҤ5g"Q/Mѷeb`7?|:ٱa^⻷5}>Q =2%́Bm 7bO܍OmsN*"^ D);Q(*}oq3>Y}eG$hX3伤k_i "}'<FA͠ʮ:|s³ajʊ']F(U{qZqH[[B "NMGi(a"{|Fr5 uN^]S z0h;}:"n`U'_^ii yU&LeZ%=9߸%XNq[bo.-ў Ea6X[+q,~ցaA9}eXu}l s\px.t̽!5(ы&obcea<hPNʡ5g # Tϰᆢ=3Yh\1l.+[qn*OkuVӡ[\ΛZ+6^cx80ZÝY`"3o48sau넲nAjCZVA?2caFi[״71,$1eK- R<1#Ba`Rj'E~( #!R`bc $J P-n.C3Zd2"ʿ$nQβ6ġ,GZnKeiiJH>lLl>_{v-kc{-v1*׍SGH&v7Lϣ]xDꢉ-QfF]*횒w~>~6:K7\sH};Hxۚ0!,5D'ZQp.HA"W{ORyɴ6.F,=r-YDyytKVm Z#˓RA=}—&X 4D}u0,%dmq[1܃:I9׸w֫;^P Z*GR/2bo~xE^PwllE}ػaS҆h0^͉|b6qS/^#HֺjEe+CGysF,3B )S`eChkFibږk2۹))?际Zn\#iYGx˜|h*y %_xD7ynnO`eT>Uِ$V gD&@ݻ+ޯkO}GWu܋խchkccN}ڴ 8R?$fF"J&<517owk&bњ^Xf, M^^V7K@FARa aXAtq !qR/s - 9[NH/TA"l*=vk*k7Zɍ(,Yh8ڜN[D[89u1f^/>7oTPϭsbѕk[} =c:aA]}7,(`.wމA3֤v(Ck KrÖVRB"^9Ogt=W)؜&7ϼpS 7ncEnz$D k?>V;8Ms06)gi,30Ӥ4oO*:mԔ?\)FFJ§B;-~"zHF5c.8/EgEF{w]h/EWvlOŅpp̅7jb><6_Brc+kETz~de/Vؕ͞*JŘNEKC ?Vov{|LޚVB65uZ=?+8 =A 40OŒ^Y0E}Ru}B~v=:e kKe6E7LS&3v:- F=m݃M)ܓFY@2̝7Dɢ(YמG4(d.$6f,IŊH{yX[qעC]QGcMEH*&0Pݞzwړߨ_%ϫ-1Ki]gyY5 D?igKۂ:)J'S&hOÚZ jɩއcMcZНb@JicR:Zp(75(< 1o)].t l0JnU#uxGPI-HIWunNS˨'ednͨT!|1 wi0!0΀77j%t=M|)#Qo6 ,aw9OOͥa Ȇ>|͏}^o]; kWlJu&R fňX CεGB.7tcm咷,^uo O9"106,־N?urn/Z}GQ뙻1tF9~Eo!xgۿE9iuӛhDc;jDԌ8m=]^ ٠ Khkύ5"N9{(~܏‹X5GO~G<%TC3nCX>D\q,ӊq4/ ,Opj2zgW6:T`*GKsT(ڹ""]2HZCa"TCH91>bXTgO@=_uWAg  rm Q@>Y-nT')]!\Zl XKcO Q<ɕN^h(V.t<拉͔2V}3nz_|P K)玧q/!ͰCCTgc>)%y2\ W?H?b!s)`sI2"B;nHWlNCd׊"mwVw'lz)R<}+X6lVDo c}=6KfCeeOVq"&a?:BW[hJ~'qϬiHr|ڴN^2 t+(K. ㈞a45R1xԵϾ㷮C|;NtmfrնV{xNm>wwm'UQ4]#RgCk-0#ЩBqtiZwͳN!)ӄz=[~9oqrn\cXݹm6l6fw70emy"w]EC_qC9iN-Ɠ޲XHƈus[߹'dn4xWE,CڡXuBxL̑@K(:-=F7`HTNV/zIUOfHJ*Ƚw>r(<pQ )-}zaJ>M XLѦpH9Zգ5v#v5mxx7xQqД#:BzϝRL||RQڊ򷑌 y8jn49 ȢS 6~%xalB'r,*m n"xLO C?O-NǍÌ!sV:koFկM!1w(;m46v}xOuѣ(>GSR܈"|bSFv뾧ߡ3ybܘq̣/|$0*NDN(JKSd]3' /]ό  Maתwڅ; /?tU}F?޿vuSlT PQN41kP_"*qo˶|L+4ptZ@5'귤T7<2 粜]3[mHJer|曱+$80Q6X<v=\(>'ەˆ::ϗ}i_0t>w@on{CO`G߁GmamtVx>bp;#n{.M80a nP7Ȧ;ݦӽ0N?֊` rPJI(e;0 {o6q[>./]rvŞ bcpOsxDwHPmijlA6R_di/Mޭr,:g("{ײ99[\[uF=~c:o;&4).FH't7֧UmB q?yts *Sg+JA4(E@ooEU7k;X Sc$EFgv%nu=,Ҁ5ͭNNv-ϞtboP߰?lMe_y͇ާJ1޿UQW{ 49:`HYdXz SH-Vڪbe_ҌU2b/v?'ꩽM04A&8kL jb0Ol]VXWcqeϳ(vƕp^d-?vF_{VzǒJ͑hƃZ6[ۼqsk".$~aVeZ7^ gP#e,8aZë/ދERe;3bKmS/:}CNhq6*~ÝC)gPjQo Ś /ݹY3㛞x~ٛ%# ;tWK{(p]v]? Er^J:2M(VeFZ/lK#I.)uIۙ" SB~G2cF\g'.΂5#W4FҀb;iE3M(6tމUJz]]\̪ y~Ⱥd]끽0G8)敚0{{n]q٘Qi|N**zWxc v/#m!3a0rG4-bW5jl,ˮu:=hS)_;/3XjNXwBVSP6E]EHg{֫b2nl%nˆ58@l#Fc-JY `-ywRR;(x.d8'bx-OŴ]뉝hxj?y@ ,\c=z8bT@Qˈ+N#7lNXuoDZ,TSwTEϗR>F"z\@{s#b|"xx7UC "O|߼WSRZ(% xbct%=֣z? ip=EsGysQ (ԊA6h(K>܉mv Z0͆|`;X5wzHݷLzx?O6Ӥg<<Q*J!{)Iᜈhgؘ QHA2c*<SrqĺjmF b#Mr잹+܋=.,U.K2"'S]GSyaF+&X;R {T?]jۉ${ZE QEK(nm060VMV$RsROIg/ҧVVX7 כ/y܋ !*l^О$P*#xq|P;ZD#?4:J !ǵm\C_Dol) RbIΚno Ŗ~3C8vyW2]{ӿu|n]r&h ݓ܋Oޙe2>7k[䒓Ges$. s_ ,^Ǒ쵒!DB6g.+y}^'4ST=h-NA uka 6`ɅiW"d5XkO0E׹x> w^H34kjRN0):&|&7LO[d/l2﬛ʾxymk]ј38sq ?5eŎrgzV8,0}.`c*#]ἱ.A=A1#j>Āk9y~[ m(9ꂘ5&htJ."ynWn J0Rφ9 8r\ٯSqϑK沟7 )0m# yPZ3 =uqCwv<#zuݺwm]zǹKglcTzjS`'at)A\( GwE1=lQs`l,b^{hr㟽zMUB5^恠AD+aIR|Db}.;,m)/=gKhXԨpX1~TђX՗+1ʅ'{f!p"AW{6'fh܈k\و,x-%w&,cu*:k;5ʝs٭=wys rt85i'<1˅rh]~b\z_:ZB۰t#zJi1HRD-|&hۨ7LU]hR2޷:wb*6={}Z)'Iiؕ1ghbތkۖ2EQ7]ۆ5sXH͙#>1DD t R^Zq)f0ïkkvxqΥȫD j8έrl`D>wQTR1hlNEn(NVm1fƹiŎHBa1MiHW=\ԘJtʓUŵ ]ƌf IoTuΘũ\[O ] E袓Îjbc9aÒ9:xzE]x]ި#, H1:Ek5p-D̺U4EIBsP^x>[zυ ;DI?0a5^X;H{E̴lrИ9j_jB߹smn&"@Ų&wO8nѓxMgTlC`y$S ^lФ1SA' i5d9@[Bn*"SXԬA)rLv 9W;R#U^/#ʹۏN 4އ sc0ۭ=#48[X:EE}V!<)s`:ANTR9汷YwlbεGuή-d_aFW+acmSHY:Sh#;."X饻SGWS8Cě!uSS>-3oQ7G6ij1C# dl+jdVpx_[/"݉G$KZթ}q{XS?G]˅zBɐ(4ת΢dώa_i{0##rв/vL[s0ZMDjFMɘeB "6uy,6rH܌p8p dZ}Dz(vSe/yD%|8X0C)NB*+{Ca c~\%b޵ܭs1mRZM)!ҭgkLFTHx]Ke\wy0;H;t;eN|8'*9|`3O N.R{ "=\LY5t8ѫ䤞.ߦb87Fmb5aqϸLj _+ٻ(Ά;],imn #GJӯ'cv,b%+ЃHEG:cLv4S^¿m.맳s6qVi5q>>{NQQ8BE]B]2D!M|5ǫs\Nv z|#;>uB@- BHP ۑu79FW.,zCyVnt٥l(6%ϟl7$X,!3^ lR/-=PzC! 0<Ucl10| b#MDeOY & n0EJP7RI^9j3F$@tiuqM9ZYFi㳗ub^7nd{QkHHբ Xt u "l$nHpѤB:AhONfHȘеu6{V~l:!DPv1[.]CËٰXjq.u| Lx廇vIeD6D*Ԥj{×hNuRkUrd-7DsIRSK[Tu[R+C;BEW%?ɱ1d^ɞk FM`,E^Z@O0$6lE8_^wʣkHRpN<%v~Z0mI~[#<ܐ;VQi:"1`55@!BtJGdoki+*ly_woihQL@x_C}ˑuuQݩ$PQUquwV3̸;'Z8MG=jvnh SAvE#StfdcX otpF_/`X6[펄*}{qAWƇaĕ˟;9Ѳ[m]ELE LQkB*U!EqFz%SljE>&L6pLsܰ͘gOgA|X~>HR\Ҙ@MHsF%y!"Oj49 "NU|#QS`F% ËH{:ð}nsIn ֒#Ӊqe|{D)/t5 LsCV15IZ0ش{48OoEyM=og~%o6uFWgYd}Grhg*E8(WI6x&Vz/#/zV-z&r6׽T,3Sd83("N'E!sv_=F;/ A7K094]sH厭 cØ[yĪ*mK{<Oޝ5Y?z^~d{g}[2zɣ=6 >u-xVqiJ 7-|Vm/.^tcu:e=u{L=p3bQhGsl@wk4ϗ4M{a,21U^쌜\O ,nDr*7GzOG:OQgBK adxWMʫɻ[%_l-{Mtn:yVHZs3L Ium4zo4Wxt{MXhS u;$ID{sn]㹱hGrя|nk-^ tQj2hhuX;);ldp/`/uw׫YnY=)٢J10Se0s̩1ܢxvLϝ1B:8Cz]#P(YbqdcOMV 6:1^Nb}3٩εs\(D"r cS4MIfPAڔDb8ۮ.ӭWޠRCJ/O)"U{jfZ9SC-DU'5G@~A:9;2ӿXj,F" ytXc#o>/t h{x:Ѥ*Qv풯HI ITyWZ*LjV߉I沭( ŦN=F HObq9[OEnv~Qx '\_'n,XHN4 ,.~p {guѫCD7!7kϏjuzlɒ'.JzБ("P߈ (TǪʜ6Wb Α %Nhg*}~Ϯ4%֌NqMXbCٱD9DS}J#i# EёE`?˾^^K2tcLBHSM}lW+֘t?HM44㭾~#W,ߩ؟&Q7oH]a*B0(ڨ~AǔmÑ7a,]ǔ[ 7uL9R/^09c> <ܚU3h;N.BN"HGӉD lDϩ6_atFLYTTC^3H'Uhu\8=|͐bc}=r漞^mTziaB?4:1$3NXa\?qORї;_{S4ecoWMa~]td+sD ޤ4y+as:ݷ/sbvMt#j }gRF}:ց8wlnрʬ8<tA,r=a(b/xO[ 1jCP2hc  ;/=zg?bQPVP 0iR ƏH*_na0?-X{C0:ڽw=Ֆ Sl7'yM좓!q^!`X6 Im\"X=ɷFt N`]"0H>yn tDNǕꄑ1~dԁ#'?Kl;H0ޑt08[gMz0N:_k}iز9)cE}ǯUՔR|9g:UI ^eF\,˜L3E-,' ԧћ׃P&)J LJɞgg=Y3&Ip%3s{ @14xD<9h/a9ga,-7EFo}ؘIquVkAHo|s T4Zsg׉ N#S@0g$?Q3][GkČck_G%n]rn K:";g;:NjzWiiiFzo>$5x;&Vw3(EodL5Ҿ̀蓩J:2zI5Hcq<`9Ll7ng1羿oGrbd{A}W롯}7-Qѩb6]hqdQfgf{/1N -,;EfRb]ΟurWpN$Ot/lm5<"lrm#&*km(F+t#VO6-K 1"nzxqx~Nsnx.Gc)`:7;zgz&lqcZz6=&&Z ֢OsN>:^G_QT]*X08=%(x ){P_7t&k{PךM-:뭥srr;ezEf F,Nvƍ_"\o7dsaz$RLM徫vtPޏE-15y5*Rmkǯ]R6"n QԜv9}9ތFLmQz -NIcT=߮?.f.x ׃qa9Ϲ]eCXXVu*rhPFA:|͘A3A3> 9(#6_3䧵[3o*Ν4t@`w0bDhy<҃_q=^R:8[|K:u< F{҄HS|"-FѼ:ؑh%SڏxA ](rQٱO@d:}{_~=>39+TUJEP&)^ Hej SnMkk5UH]n̛>k1(6}Q6^<_]Q@BѼ<NaMD6AYv2Zc(cpL}"! .t#E9n\p9[ưܒ CGULm&L)G ^#%J#Ŧ: ɮ+J!Q9mG5zui\g4ccz !0"}N}^~ ]iۛ>9m?u= 9 ޥZm굔bo\~#㣪?E%Qn =\cuR;{Pآ 5/"q;Uono&.4zmޏG,J*R 8 ZE_rG{/':TB,LA"8Ԙ]eGżK"e.) O\xf}𙈭EI<o;cQ&Ey&2=n]rKUδ:Mwk#փx>g~\׿mPW񔗿N)ـ9F[&\Fn@y- 5Ҏ%zzEq`83lLtg?hBS -AMv 6N#Z$SbKaصHravV\S1u hLE$t7لsEd-V6QE׻?b3pέXf:& Dwa fJ_bio 6"+r:"ͣ7RsP8,xe$ O+@1$=U  )L2`;+/Ťϼ2aLo!p 28x3 7:HHHQ AJVH؉Qy 1Ds'RzRoɾɄf@z K-Ki$sdBuoYBXip);u൬ZuR#:3JW·Aml4UhI`HYD$$M1l轫#%ng^f 3ɱURbюJ4XΝ6Q#ũ.XoD2 G>>X4QĘavR4͗:ju> GbܩjmHݑmlĠ!@eX{ͽz5uj{"fb(i&CT?MsU<}nR.&&Q^Z$і-yMYy@wТԖ9&0 Zo:`I]Oup pe{IaMƲK3nҁ3Q@ isx_=~omkoNH~cJ9kdÅ 7 뤮vB@, lxT4)!XTZ%yOЄZ*$8Ѡή"Uy4:Ā] e&+2揪24kٖD^&P7 i;Ùە^9-yØڷ)"&ۜ]摭Dy EH0fԳE4^4b9;0߮7~z/}L[sQ-Vɐ(jhzX&]L-6Oy^?ii!@W}+6a,Ik)NF̼yGmM0{4!S)XuJ! P$c["hL"n9Rx4-αEdja# j'K.^#.ֆ Ͻ|[qX6#KRH! M=6MO_Ҁ}~c6LSu'>ߪRve;Uza 4&Y'R9B^RZN bX|#qf8epp64[ޯ{GĚ j.-d#raql37Fբ #62K [6-WeB(S ~zy-8JVfxq}35du XFSBǝ U;Ft-H-"3Pðh7}Po|z>ۯYhg9,ѥ|O^Waym~ EV{dΆq]ﵳQPzƐz- {4o;IK0Ԭvjz/s:FƄZ ?+HO#fpČ@M4mofD$3lPq.`sݟ{UziiX#{Um2q5 `iy4W@غ2Ǝ?"Spisފ zܪNn!Z"cUiv䡿S-HPӜ:*6Uٵ:mTds*5kkl,5M{ݩvsb_]0Zk.5in;| ۟?eЁ2 zhh[pG>G7l{԰vJѣ/0`󥻆/|l).Ή4&vͩVb#D} R(`ܩCas+P hx^/qSZ_?h{#hH% ;_d_ò6~dQVYɢ" xrԵjz[zuAoG_4kȠHNQ^=@!dWx hۜAc w8]uA [ѣ}9xR# wVҹnә!tAZD\F[ڊ6d +~$=+9$?emRo\O5Ć"jv{Ia?D)%U%ji*6?=(Y`^  i,%=FtjNzCbPP4Lg&dcsXu%!%e=оYpD_Nÿ9鸮d$yדּF`Ю<9GEV].$ř=}æEkp"F<#FkW*A!%ӏo=z(77PF~e~ $D$d2wHG:z2}ΥE}}a}hWA:\ouk3M_se@~߽2Pj)n}a]m2Ua9-]Vw.*X:+nMh^lPL3S*2I+ U7#\,' _޼i眆H>zdtZ߫.2Pe 1w ^ߜ6)ŕr } :/}E)SkeoKwAvĘQn#CzֆBԪBs_iV˛z|쩫Dz$}?SjَjqL?upHW?]5<'vwl#c(ERb s;p4Dz٤HVߒN;ְR)`TolpԌU{bvT&ҵ{¿' owSkt3ޫPҡɔ횆]va+vIԚvaQ"/ˣ >7Rn?m]7n.`iRT\#B' cIpKZXj3FYX7[6L^Im"]UFnEV ^䊡޹ƸГYvUʨC['(ۣ7sCki *v[,Mo[)V%҅:emM~N~}=?f؊wz?>g݋8.3EG׌H ܓ⹪Yq ={Qg6`dUrrv{jzХ̰1ロsaǯn<esjH)th'|@흤[j,n{q`Pe+12m1Ͷh;1orQ7IO݈{"CK9I܄t}^a :@A*Feza)%r-%hNbe`gOg}Pm~>O=gmmn߱6ύ:\fמCm>)z,L͡X=yߺ{K}wvOwm_X\6߮nem ÞRYlv>G kT;DUXMX!y8k,":9؊|D 6.k]/wn 6h`OOqL5ķ:ԒMDkEA|y$ӣ Лv"B(iT"&a^5K=ya 'A\Yo} Ȟf*Z zlѤ|Hӟg;llu:;< K%lN=ѥx FhȋH T\XܯG?߸ׯ+o=pVJ^2^TIGj|`^[k'/hGwuSP)B<Zz#&*˖Y=Y ȵޫ\=z$`M/ն1UCyfTUi4Nx#0\Org0Itte kIdF6 RU3:=Y/&$۱1[xt(D֗e^۪(uߪuH*уYՆ`'(D‡R<=hU h'dj D=K-**N))"ϾqKzNB|X7]+xA!ȌKͽQ0Fc5<,wOR iDAJd7`Y**%d\cz\7~sCwu6n/Ҹ;0Nq~}*>|ïy`bAC߮\s8U9 cnjpρDޑq2'˒Zg|!AfG K `ְu rI"$jK_P,02"}fO~pmIu-tI=|ϳ4cg\Eb  Sy)8QxA}Wz#PV]i$F[zpmon[%U`ĵͻH D-\0ңǜhaضEnS0PtDkgog&J}҅#uw=nk{XZ㻿}얆OAPd\dpѝ~et~ig̽rᢡ *!4)I]ET{(c/yhE v[d]ѹxa{|2({ʸqyp {eo;_G99\|lTw2 fn͓"bU/2.A{~pqR5ſ?r}擋P3V6 (S#P9RAvyB~.=yφH%YHۯ9LOJ3uFd;ca˷TiIL^_gؙCZT'qV{kɥ ]^B_%4bx-Û_Nj aT i+J%cuAŢ,HH)val4=z9BC:e]?6K)/岴,_ء4#omuZc|O54R/"cG7T wih5^vb1>2,t]{nrdŘ`bCm| wP k #U^eP`0vШ|=}Gs-5Z`߯E{D&(pl|7rGYy'>Ou!n] x߾ RqAsKZN1oG]s`Fta{M;D.4DӪh7"rUN_}^kW'cyjDa2TbrMϔs zz`vW\?!ge h,q2[`u5{K/םG@fفNjntж6E¤D1>{Kq GLߴ7 d G (& CﵯF.gw/){$:ȦqrdG^tZ;ޥsڿ|Q{d]di8xk9uz Ok1:D泩 k 0b@lAmp#?^(z>wKG2ׁ8z⹻(z[c-dzE3>we4W[5Ċ6蠾<>sЗ{d~Je~a: \#.-K]tΉ>)ܛ ^!2I2M=E @֩.5EİxPH}3}=vnq6B:z7oSP߬".!] #¹KBڐk9ZY?{]N*l*viW#A$ ym9%=~7C;9ѝ5x}V"]˓{Ǡqx+IO_85b ׵ :Ii>1䵮@Yj5"m'܌U{ol =EGRkD:z6q``Xc4еs$i8!E-#:3Kϰn޼+۳!=92|?JAF#Qt5t˽Q8B<\5*!pJYԄ /qʄR Us{1aU?fuNl9b-J?򎋊Hw-tQoQ!ʽIO߽-dtI T;SVm=u oZWo>]#jf7mΠ[ٮo sqf(6,=ftg&ɍ5fc;Rҽ ];jvi~n[hv~"k E oހ m #Whщ Jt.@1;KujP/;]C)xr'=sdkwDjV./tGPN&0l{;qJ^[ƔTt8s>.S_RǏ>y'ewszk)4gSPENb8jlz*lh 3z^?(ud}ѧ{HHͽߤxm.. #h-v-Ӕ_g`s5ɇLtqgo6qF稥l8*SzATDM]ٽËS4HAsdž UQok+Gz6H`&Aj Zgk䓽䣥| F,Qiü5kFx 7Nv 9a;{op_oˠH񿡀b"˒Bd 9F%aTJŋԌ[ YEc7:탻k/Kzv ifq\HuM!XXV!m? ͦ-.J`|O>~}F{-\RJ_2G$]60K:"u dz(!@i" N\\?{s SvSnmxKs'_Ek ?kiXhڶ/ VF҃l%>nTH',O( Z=71Ӈ&;3{ l,26U/꒳EH3l>:#zjS8?: k= ' Evĵ0pq=<\,u䀥thb-m7";ӏnKtB ژ~jPpX.|j6S R^dZfS g@Q#"{fV8g-fonM9t?Q&9؋[Ki1jG3lm4D&DpYާ0J{RSBZ.EsC#WP@X=ZaR.Ϙy6\bqi=dȾh8v[a S%(otU;7N1 m10h-ǮTe ;{kGg#6aEtzcsNr!6ybьRM`\Oyb־>1F~@vX>8Bd~Sb]K hY+4 0?HY:~0EIݢwMyŵ/csGL]R'h[l!-QgF^&Q< f/p- GdHM}MkK,5tHa FR(]rdû"N;#s\Ft~&18>gI1sak%}?+o==2|<[ǶcZP_q9U 2Vb|*C=.-Xqwb->p*յI\liyd6 1$]fG?5yZM.\zLTȫʝWBp a 9d8yDZh$X8/h0*xL#=ъ K'<гw{![(Mm^7,:Z7֞ڞHùF{  3v s#UEpBDlVax G3$]M,B7 ܍65xreroC}R 1[q vEDy"jZ)ټq;8jw~ύ5wGn fv:ur\rp1+~%Bzr-yIF7Xahz_c9jt;Z9\doTT0W/ss^ͅV5cz@Т`t}\ UBηCCZRؔvky6]C`:",Ͱ Ox̖FW_jc.6~Q7 9"cj%Mfs/v$j!ުzoL76JJ<+Fթ#2,3'/}BLޱm)oF /y ?!8Qd7讣}֞{bء'`y<juo|/IzCBh|,n:croD/\8F1{'1>ѨY.ccuE0`mi?=92VuOF+Cސ &vbR 6,I: zHK|AYHîTEnvӊ^?iF hv,ǵ~~{Aqa[aARA)b7tЊSU?Nޭ%VLż>M8%z~/?Ǣ~<:$2i-;n9 v' ^HglVSfV*bьi7>Q14k6=gĦSYJ-n(h.1brV"awV5 g_nJtY]z1"a1ye&mۮ9<ꭼS9kƴ)9HtўKfN/Z]g;evS>$/FӞQ$,z$G_W')Km y~M@h3wBe7obQgɽ5  h"b?覣>A hО-iLN#{^>~{hyaÅ[Ok: (%нgeVjĵyz]dW}YCpdև{ [{o.w4l=S_w;wKW6g mt' F+ )6vrP ~`*!C&! 럦Bݭ՟ގ͓@~S/ϩqЕe䏿v|@y _< k2`]\*qa /bEG xML/hsv0lUlB,QCżFX8yfQ3sxQ&)HB*rz:tx]8::\Cn֌:?_, r_[-uH|fDn^.T6c/>;==2V!MC-:8܃7:gʠui;x".IHLuyePZE2l~ }%+X5rhFLEȽOFݯ"BkWf Æ):)};upX0/\y3k^kK8 '00xJ``Nݸt{+ žI"׌\TRcq /c= 덥GY{8 pT1 ЛїMDsX1cfZ.uqo iGr2]D8b'R䶟oݞd]cDPwWYw~9]3[_.hZ8R2sڹE}*(~F"]D(ܲt tEvaHM+K{kC#o ?xQ9ѓ}X9oUoEVdj$h*|c0G|56rXUJX  8ҳv+)[,!{^'}Oa1^} !4<Y+RQXbMľKߛa]XP@>pl#>ۧ/] *w %PEM4 2%LD|5\]^C{:D)bw,7u>@h"X"WAJ\1f U~7 @GS]a)ޓܰZ9oW`W'{Wr+H% N a$Ճ/t^U@SDT_DyURf'8;2FkRWxiTK-ܯT3ƣ4X: ]⅙ak!x< A)Fܼxk#5- ]خQEמKјk"UTB"D0>5N@9H{ al #]qNz-a{ZqEjQG*I/C7~ ɂ9iՠ>}93&}$c*ڹ~iHHȹ,C=g;#`@SN?,ѹG?[RWqf3"9cSqi5?{+qtէU;?{+RZ&GFY{kmAd-c*iOhD:x(v94KJHgU@bsG Ou>:{vw=zn!B ݙ'SdPIs]JaLmڄox)R' q:RuD[yGF$g"ay0 y)9mGﷷUKx6Z k 'rM_:D&`whW%\P-ʱk>KR5tDyKq9葥>SO]9[^ #QH9deOZv {k0Kwlno_C Ͱπus&9hoMzDž +zw k{' ~Ɠ8F٣+R]:G;^dHBF')ҺTTt)ŷ?h?Y!NC_?jv7>$XoӼ`Ln*ZnJj-6z'L-VH ׌Ni2B4fjfEqNj̞ Hu7W?_b `Eg&Z,b9dSRжMoջIn ! Ο&y=nz|CFb%Zhxw>6cgTIK}'&ϗbຒg.kN.~bIyyhvX7^+l=TKˆnG%x=Bں#~bHUgӭ>+w;bSW??*#B LsZsA> Z-I'ugszRWg$ ʣnFyS h=¾\6M87j ?W<kХ'sNǮ09yau2Zx{q2[""Rr;жn0a|&u44cAj'a$k 1ƶOϔqoʫI LxaG̬8]|Bc.goC^my04='I˾Z"rMyؤ>r yHVá~w(؄SWݎ?ySM-JqcC yDP(6{rV`>*pu>zuu%/kϘ&yGC%ޢ}K{]+!;$Tl! i:V|<>W8V'ۦcgK@WKVEe;? }O}M1BQb!k[jiyF &pUh"hf1$mOɺ i)Uǣhؚ{gf0t 86㻀߿ iy<-K?,*}#RLEyUM3-˩crcւ{=-%rhŸ*#|FQͯ'oodAo?rR[@65uRx$UO=Fih7γTB>ŶlP%Pͱm1vXN~oZd09sx Mʧj7@CS`Ţ4Ef(kp\sl.~;vVY< +A,^s쮍g^xDa6Ca:Im0‚N1s2?`6p13`k_;8N'K%Ovb=ݔ;McIVmy6𼸇UTGN 9Zk m:0Wnt=}zAm0O$h6 t^J=#(bͽs(y#vX УC_ɣߔΞm9z _e%sXD0zNzqnAM'!웃\CO1Z.' JVUU$()So{|bv&k'o%qͳiHр@ ~|`gREZ~0܍Tcl҅Fι-ZWKdeckN[_ҥFC_I:(uШ͠S1Qd 7H^g=;4OϦcr.-F̻-^a~$/holFBcJ?9܊Ӭy''%2F #"c4}2βlbpVtzٖb1>s!={{ UOZmxVe|-1 `8ҝQg&-T}r CTedJ;Ծ{gyc.<=rUa!CfD1*^vށrνweNTYZMVN đ2x1더P)W9Z `saD5^:oM^ O֊il^t]N4w#;]P|fsic 9xϝ4V'#FG0^ijO v 9}a7#jǖȣWGs)w֯޲`Qaa9N7I)5r/6X#AL5Νev=}VncU|᱇{ƀ&_vÿ:+mW^9o=6Lɇ&BMwl*MK!-LճS7ZCr@?.久ػ+a~|U9B8,py!eG3Mk8+z9I}&iGcA9aE qZ}fNJI~G~FRMGt1HXl)6& ҧv4] qؑUզPm ?b鞲CSZ.i% =>CI(Azj+'u/R-1&RD*޾9^q@3]2H7^;ἮO /@?u EvCHwIWox)9o`NGGZȎdNzՋumfjNDbڈc'ie72dž'dGdE6m7TAIb=]ھSHJ] k)a+0D85?`4~Vc3acFƁN!MԘE")e3|~'|GFy~鞣h=T۵>Bk3zcF0#igQHc]'x_Ώ<_=[ro3_1%kI򈳮- Ue‡놡EJ[DV+ Z-da. t<$07>^}C/\}7:Kxm!{"1Ͼ^7oJ5L::7 [$MDd 7\~lL퍻cA#t[AbLm5DSnm?ߺOi@!<`[֢=OҩWRW}~{oőS)J{=0@N3b-=9$  лέc$T4DVt-1mHNӿI"J}usTD[0b`%,؂|r܀tvb2k,4㰱 ^ ȇ 0[EE#ǨN?1؛k[;u9Hl_b",v=-Z$ n 5K/щGN-QPیW:Eĸ |/A47p1B1e;| m@A9ï䜻QS~oߣ==?Ь/s aVf@q4skЭMсXdt{v:|||sk] THk,ZFٙӣ]gSj.#jI DW9[й:IW~ZCUGW;^oqg~R'UPjS C۩-ʶ ڒjE%-b[Q>D6q_J(_Zfnbr@V 1nDuA3\ks!Xfi|X-Nd9UuĿ2 ==yQ( j _;lXG-xQ8-vG3o+#tf4fv9uI>)a C*g찍Fs1 }R oQM f04؁t3{}zIe@Mﻠ3w䑅Oxt3ml"15eE9J}F"*cƍ00NsõY$i:: }Sgб/?w_ '}"՛z[ ].-[՜>-ɒ""?|-^vd-Yk"wn;ykjY`e+ӈB*vN۹͘"+w3Fg{Тkbq ]t=ASE dptx -/'MՑ V ytH_lw҃B_1,4E= wH_V \x$Eѓ[Z:w8)d˧oݯ?|cO>|# 4IzRV=|)m妉mсϵ ѿ<!q`ĸ';SJnO{HN_8Pr w rzJbZoՑPA멲:*vg,"č7[9~涾:sTVDR霅SE/5H,ڛS\N>w %1 "ւDo>Yt_9cH {E V{igOSr ȴ,9{!q@*8/R:S֎|~[\hB9 )jyrzݰêQ0-bDe?>M%ts4"%Bw?)U}6;74sk,%{bj&tS{C} IE3޻$mExcUWK2n#݀J5g_7=}Y1_9N-=;H5) 0t@:Eu_%6~t 80Q :㋅hiGQ {w.5VK"Yu62 yw1ۮ[Z6;6R.<_gVL;8qxVS%irAxZd[12EgjUx8Mz)܆(tOkYsÇ:{8(KptQ?am7FH)GbD4:^3YtK6|c]uht{z8,Z((@~h#q@=RS2wsO8zFnf&oGU?~oo=0G O!w =R(ئoG)N.k5$Bq(xtEUk}3q# dc2 9n}$@KPIs9EpHsoO~ؼ^cYyA\Tq!zkrK+oZTrw%JWHߩ?_)kP#uUbBR}bC12N\M-Bw.2(Uy$pVXw԰DVǛhʩEMځyVnƦucy {`К@-S M !2< K: aN$v Ie:vy*F㭑Ez+*&C}#u&or -NjHmnTX>V.,wMߺz$ovO,t):(r\Fh;ћ \zxUxlsgWXIR7 Qoq|Q@-|X}IrLCc fgLsg%6EFKG*7^oB00qJhAk=9)4L";r-}Fw3J=tRԵb|c-Z" s3ȖgoW7惧Z{\!T,)yl"zUyM+/w8>eQhj3ޛhCMYsֻ6 'q4., sy+i3a azUru]iZ(:<9OJIB4v.]GP,LZ&m&w 4Zk=/Tyt\8ra.mcWiĞ ؠ bYf|L@`1F1;/,(&nk!/t.GGD&H۶toD0flZ|7>w3\cÀeAǮ>7g=bOտ{v(Zd^zǙ\'%VMzqژrnñpPU"LHDb`)'sn"i3`Xq?"/EWH٪w_ybxHBZK*saMYS%מ Hgiݹsb~]>%|BŽq@[HXx; )|B3; Lkâf/\<7ΟC:Wf@G "G<{x]@1AkGrF6bW. 7˷Mklߘ؀eu{bw1iu}穣HV'\,{JI\6^| ~=p|K{àO"]Q̀b[:0{ uJ?;[3S:u#4y[%u?"MP} zs]U}."W64V4lj6]2sbmF#(^;#閾#UN~~*;p*Tf@h Y *HuO֤OIa xu1H4B?sFIʒ o޼ף;H\ ]ߦ<]Z9-MARt~Cs#hlgj);d=AoG$:Bw_5@BI!v,8"ϼp(&_+fwur&@|H}/CU9+6Rb' O뜂5:%?8of))@"Ȧ[Zb)Es>?hPmhxkR1lU7]ץיU#c|Q>݊q$?[>kKG%̪2 F|vS>WeȁND(Iy09S*@CC1y ^Ej}T=]K-Ȇ B> t^&JJ޴:hE 6cX ;Vo\NA\20LA1_h9|-DDû< y`qv<;/ݩwIa]Xb_Xߟ9K[+lR9gJ:%Xwn9:w[ay/Yd5(}fgPw׾NE -+ 8 fF6^h-,=d* D%Jj_.^8{mk׏'yۮ'pdfHKN}ܫ_(2qԟITLtJu1 Q^ 3\  Ip.C S\cvK쳼E'iM7q7TÖ2>41L;Yx/LKf5ʠY|dJg˅ǦN‰0LH $8 nS+4ˎIU`-}qjlw ^G"ʢX ZꇯߡH'~% VыS!g c_}:*JNRD$#ɋc!,VtQN-%m!Lᡯ~}QE!VyД(1Uܰ$[X#3kDo=eUshowgD-sڥjèqq{),fgj ݫ7/S\ +8xU z6Y_صwrO<\KK-i_e2rĿP.6 yX$l8̓~mmZxZ=x %?RG9G]GOگ_ xۺ^*(GC&G,)ZCWwM2&^B Zx`cPZmPk& wQ>n_~H^[扑mف%ŖU pYox(AָK *]V}4 ;,C44<,: EI_8@›nRv},xM."qԲV`ӗe7SS+-"c`X7:>=(-nlY"tpzH_ЉǼ L[.R3ACra5]NYve0v晌al0T;:tZ3 /#LGJgmʱ7 i LAfa`kʩ:X F.QMN::䦶]/!:D -ɷ%ت/Y5^Ƭx 4o^[.{No/m'Wk V r&߅H&7`_Gn͞X@DEks|g: -c(Yh vaw_$=<;g(%]Llp٩)ˬ!v\]k(&66!>$2~RLFA}t+/D)Cjb6vAԹ;M_m]E|[+wt|=p7V ZjI^fbCizp PE,VF9bV6mwgx}X>0>ؒ‰b>PB! 3rD)ZoG[)t/8<+l fYU ydԣMYn#Xbzmk+<`÷:N~Vl(c}%ov=>һNp4h)6>蜾dF&#8EFܑI֏':Kֹssv/q,z FRjTD8V]zX'C}1O|wjl # H]Dաvr5>,x\OvvfȊ~|:=1Y.YI^=d8!/{+zǵ]+Gjɷ{V?t0"Zo'Φd*=~ߢRcZ}du[H{ H.΢̷E’@ЄG`e{}pYXZ41] }X=]S s$s)A_LȎ5LZ@ }a{[E7#Y◑21vj Uz~v}#ߦGwtZ|!D&"xg >[ >-qJNd,f^e #sHjww"[de漨SuEOiS|:wBQOP148 ZG_a.NT8lu6]j bUD!e )xV[ aUw+R8\_'[Dn {uav7^ZbHJ=='Eΐ69ӄH*فR4:xq׼hP<ܛQNtZb 6xSC”-m\1$6z: Lo]x󗎻jUx -/Qc:ݜm5 *.0KN0$]fb] ҵq."+@*lމ~>N_#Qz5+M҆('1|nd1ͷK}gj]2G)&74ZZ)1:ۦm440+ܮuum+ X'0u.S%/hAu )…`s<$_Ʉ c "Uxʳ\"9l ~2Z϶fEg:.ɽٿNl,8qəzEeyoh >d_*o9u{Q(dS A4 ] W?J|O?r _v_EYR|Mk/gmRޕGtf&l˺~f6uS0ʔe68Rܲ: @I3bC֋uD;Cc˵륺j0zgD%=*3wy;~}ᫍ~=^]V~=e,{OYq% /1M'KK5q_m:W;~rsAHh K~,y;NpwC|G@`$:i|lI g}_svcqw ngi13Ya<g4Lun6w.0 +=sjGI-{PΛJT'Yz z5,Iyqy۸WefWʱRlE޵A߭xb_.uKKuv6wzݟUcel|^pY @ŜI$-3LyzV:IvFM s|^#J tkS5,'11H>Z6%s?~^4APQ~)E d,:<(H3ȏCW7MԑW† ߇+wوs`!=#i,[;Xl%; q '+& Ás9EoXcNv~{Ugc NrQ< $8/Q#G_x]Z,i-ǰTW5l-Š܃HqydFGK1JXd'(eR!Z*I+-,2 0{.Pnުߞ2h6H:H|1hHvE6\#J{`4VxD>u3~cyPP@#D^`0`& Au}BN Άwd|셿tc< _L׏Otj2UҽEZ+%ѝ/B 6(4SVmǦE A/;pK13! ςWjVP3&́ݫ>s# P|y rK@uz:JSfZpNIa&(~s<6=Yd񷉬gg6"Wq7, 8j &ٝr^ąk9 0љ/@4-0J2=L̓'cja&m{@~qpdt\NEg̹'0%q`KxxjH Δ)0Ξ oK/<SOY6?fu~~Wjx6[7ɚT)o3uEEUQ0R.Z|aZ⇒?bk7uNZtHMCXP,Z_MzcRt#t#Aӏ7swː?8;ҩv+?Sng}g(ɯzor|*J5G [,MM,gwސuI[)[U48np{K@%?>u)&QO>~~yg_p,4tԴQp"t;&;38Pֹ4u&>6|祂:1E"z0){燤˜XAKEӯ<ﯧ]AF!Pi.Tkۮk.Ze*e|! R/}hMIHpTz##5iE1 >ƝJz4^Lbdκxr7_ADB.A4nKTR?W'5ۏ weObmQr>U詙%N B$`b> ][.sj@"ʕK0E blm:8]ix/}{5hT6SKua-%'^!A3C7:WP5;C45x'~Q 줽ԖG~NGK15'U+e_,) Y((d%V=.?ƭ@qkN&;ur Ĕe<lk0IY5m- pRMJ_cYl` пqv07L׬'kŀ~p?~c4 rv>::8LN53ͥ7^ڍgҴ}Msi7a4ϢR s=8Ff&⋄ujIY9UX"ON@[+=:ĹοMVbDvzMl@;]# ?k*&HbjxGyhvG(Z( ͇ox>-8sg^ P YxQßEjj )\=d99Q]MxloRKqj3$=σ818ADU/z@A^ &LY:]/by<1]TvQXHV\s#2kre;3ַو-cʬ遥KzHw4>Wo?G+O+ F/Vuêg80l+ Rq@u|OM?^TxېN֧8~сb}m@[fb꒷Dpa}e]+G3 9ܽS}fnQ@&@!m`1-aM-Cv vV |cN(HxL-I$c%uQIT:_qQ0Z Xgۃ*[mE۱KeqGf$,A5*d,1 _(dpijXZ~^ ֕3ۉ`_ox٩A:J>subȐ9Ԛᆲ;6#x<Gwj{GV\\ȹC}_F*xGuu[~J@Fddv_XS_X5D.H;o^pF$5ď=|~ɨ_ǢNF[^ 2mEi %Ӱmc2zBWLrNʨYLb1 #"p<6OI"֑A,6pQ%J^jAϕv|g Y f N,?l|Q&b1ʂeIV ?A-IHDk\F[a[d6)';cW\ /Ӡ ܩ6k&=g+?/i8>WQ^r-H-tAab؛J0PGUvbm& vyCt4#(H{gֵkDZȖL}_tKUco@aⲏ 6D&.^Ai=|ϫ+,r0/SY]OA>QՈŹd>K18Z:v掟C:dP^w٩^VN48P:Cu9TP !Tkd1/bjz.^#25 6M0GJ t$vQ§&\gjy;/WwiU1dٴ ^=y|S-|fKj&,YH0e-9>>c_\-XpT3a=ڌ)t ~31K8YC\-|u+v-c9tARv]oOӧ2#5VT[61ՔoO痳H Rcm!s;dR@RzpP\YRNoҀOfj_ ddq JCk3džwtacΪ0Y|'̃'45d]'vf9)3_yC@k"Bs"߀7 3߼6Xgϊr45Y`P:H$JQ3]30 `y]0linaBKSҳdҬ}>?{Ww]3 @\8(|N\ 9]0ţϟݤ('AU1o+؈:rXO-׮;;}>?:<ԥ Mz43dK|*Ru]ןz,caQn `Xwn@w\$p.HMo|CT7>)v+%)OF0睨T+?q,ҩs?5Ԗ II̤5cgE.P'ނWx9oR B#%.>"A52)"g?:hV9&6ـI&*jHB>%k(3Tkn?`yS"#˺ו6w2SϦY WᾏXe(ˢ٢C5Fk`Ƹ\ld m.J2E˗.xV. mZ*xm3L6uooNu(F]N$RD vېkSiBB#nO7d]B t.hEd3ޅI \1q~) qhz5RirPk.b t 5-S\Mr"Kb0nP'r*>f5 E(gQ䧤8ϳ8LL_&X9x[K-ChegF0Z}yX)xR$<`gD_SϦY)E⣻no|Sf:#&ZyS0i6%;OC"9Ir#{vC''k;]xA}Qv4qQ Gj~IH:9 cNүKkm?,Ta122T'5ŠJz2OR A_]-7M @*8?V+ZZ+e&,{"b|GX ],¶NUrysڪYYr^(QLf^t۵&C 3̙v7!NyаiR`q9Q~abi܌MW|ӣ,$_HbE8^Sp4ܘzr/_t:YQnnmSfpl-cw\7Zw]z ET%xqyԨv׏U ѡV>i},k$=}wSn{5րԉmޤ.@բKj $>ϳ^#I4j(azc$ZBυPY vJ"*ג{WsGgz@/#E"ao-uT LՁ2M*EB;Fd,0m9 %jUC MX#A ^zy6J9 %+-U3~Pi*nqE.n#̰ 5;\Ofc3&R( )xsIid`'7;l܀d(d9؉sdG{ʏ3̓Řl~2{Y?>ERճxt\~;9jxNsJJ"<>`~KbS%t $M.fNYH$|ײ9U-<Wsv2^9HvX62꾃k'(Bxx nL($>i;2k/=S-mM [S@ Dɗ]e#a>M*h[V&c^c E^f0q>ީ=neo J1!1SOՐLP" ӘwXrZ TEN&@XyN'6 ^< 6qfhz1Ny냕_w^'SYy_\~͊qfA)r!I$Z|YJ aQ,cE'ؤOt|1 Gz1Η$ZYZX6T}=?ꃻcpo)E ;lliu$qj4STs?vϵEex}/YVxWrjxih|H1+U,PHR4eY S8L<lj邂(I7Ie\~_'*|_6J^HxVLOZ,ttPHKB!X2#VAxm`R]Y>> r,m[,≠Cf&,f;YR S(QpQ}江IѤϖGrM?7lwjQUި?E#}PmO6UZ,e`ѲHhmujNdz&]V::XBZXη1v"%8k][څ .n.e6*خc Ц^әi[hFIp@<߹Փ bwws"˲hV:}:CS1c7ꁃϛRi;uy2>+|m`,a_߳F8l ï̷d9~Z(e+ ;3p|!8JKA0MwHRtw`㉙~(B?y>᫯?x<@MiVe4Qn۹|!1[M@$!c ͤFA'eBf<1덮]c&\\ٷ™hs@SX(\}G!8G& zt4vS=KH,**(q 4cUݕxSTsIr`DI{6Rc3dy7N:ggzxJ:l"h1g u8x趢fd%Ϩ a/v"3,`4덮^?u5Dž\vf[IkE<#(]^z, sKRH:pn?b*%N`?:4dXU+Ld8Jpjgy5Q qJOE [d4]4|cŮ`VT:H^q=_t LJ^`Y9?crȳ9`A4f}$y1OZzI 𞑻,u‚W&.+Km/s}AګRHu0YD;13I- \2 $69<ɩ"ۮ\rK] i~(wMnK#S'߭GtWâ\i#!anmTrF 8洃~#LzN 0e*,ukQw7I{Y!quՊ˚,4lZK͙y[pR%%$+bd~X!:JXL 1|EG_\@N9g~^d c,E[k:S/h:?}.oNO> ,tSe ˸} Q^`fBك gb#Rܯl",+~lF55EZrTdq1ydz[=ҿv3`8~,`5HMJd&$8/LwL$xr۪닚j/`g,5=_`U J2zʔ56HyDGBiEoC_jR24kv}[SbW3*%F|%cy7n$O C _"Iq*L0f5aޑ֕¯|L雟}'Q}%Yx-Ij$FMЭa ʠ$Jd&72HÏ{]xQGGsvQof֟ivBnE|M/nW/U綇9;]p (y$6ʧ1mvu'5p<ёj[ßC&3!R6$`d<m6%w:e?n}5n.X:JYQ OtGb4?2E(g^N%Q:"bd\&?aK}Ueu/6/1u>䴗SWA֟|nܛwa߂TV#OiхS([Q ԬT;2n "yVZ.hqeM֛- {4JyY `لpIKwF>눲ß+SV0j'8xȺc_~>c}ԡ1a•52<ǑJIX>CqT5_>q|s;^2oq☵- BL˽CJHTϚmdNnˣ3=.Fh"27lkYgF05>Za1,ZK)de_?M҉n:·/t;l^j֚hR:jbVD|Grd;9vd9R y-¥/97`4Jw-wGdGOB:NQHJ];~7nmp>.'#SrDY,>0PBRg3C#?pcztn YKÄX>Oj (p@rہ)Z {>Ө9VH%Q cjwHd'Yvm9)܉N'yT!\e%`0G"?̸˗5dkkH1yLϮ|ﹺ=m,:MےwJ"o2 ~}d Fh嬐y,oOvNH` Umm+ÉVs8,G$)iz{Ox_ޱ;|њevﳐY9z5,~EE.68ߡiXe,kE6=$,)1Pjd?Œq4F_?np}(y0@K?\vI?yWU[ oJ#9ّʏIG#H`p;xr+:OkR# ➲ 0o孵9~tr-a)jFTγ'8luԏEb%~~ME`HP`_:=}顾`EO-MJ4gktZo^z3k~#z;*(hѼ^`K"J4#2~GGO!uXt6ߣg[L<"䝿!b oPTYs@K:l) 'נ mmVr~<|ޠ|/PTU`c&VB id#d䷐)5 Y`PlMXժq\1'RDMmsFT9Ͼ$(9H-~h8җ]Ai5l9]T闾{%, ,bne:x~5xMUf (+dIʪCw뻆 Q1#5:\ds)CR][SfTva " 3# Y_C԰,W];T0]U'3Al=1>|_۝5}u|eUtHY,po2\CeM:62׃$64*woy@5gl 5b孒:Zow:9=% )AdJxɒ8EI(B,Xke둾t7mxg:~yC4zSiEK`=hR 2'0A}3Q,^L;+^/c$Xdϑd{]_>2s}?21Q\JGۧ!!AOI ~?+F2̕@yi5_˒F_ݚ{n/2vh([bS̅ ꠿%*:Pi@!=0BjGjZE̱n-6jk5.+v[W7(5c7:Xh3B 5{2.Yd)@BRK}po9^Xc>hϽaRMBh' ^ &IDXe^{xC~#&^6R TV6NK .̑ $a4;{ψbQ%#d7I(.-Zj!8[vC]`6;d&S^_:/=pì1i $X紋6 D#R N 8h-K10pը@g,BGO=)0 :>hXgݨ̱ g̪xdb[@r˼2σ-t,(nkǒ"p$Hc0W6I""0bGq~gWd` (/OΛ(x7:~3u`l|߅>W:R^9Qbն 8ke9Ԥ.akBK; IpBiS, F˱Fk WDuZԾNqĮ>-bx]Rnb1ohl5dMKumK>=UΕ* >8M\}",puQ~cZqnsX.]pDYF̚Tx{_k?Fe<Sd v2c!o,SZ"C N'@c>u!ϒOϰs 4=߹x _QkqF/x^ף+Xh͈g5dD o X4-EE6a; ~-1yPf'B=e"k%ֿ%8q2S)^k-y1RaUVFv@ 2hN 8 a(l'7nR̞wJ:?lˁ>|~#?o>f_25qh-ȳ o~g-.5nC`qkђi<ղb¡?Oxz}Wk]Jg-fV=+n^@d<*?6-8ђ /rԼ fyH$alnb2%x0N}6j |!캸#d BM*}pS=J_v,Ȫ XP#ސ]qf {,x:^ꢹ>2=|D6KOGVe.ԃ`Opyf8O~|ć!G8HVJX!.y&:ZܖhclaeM_Y/VIߢ3:xI?3~`O[+3;F,)@!A.'7I@‥4XZ6{TNyCK΃"Q LO>Ks,XR+XcWR<̠zv2iM-i_CRC\h(k&!OF0>zG=У& $=%Ta;J E>/w؆8/R?q@+[ͳy&GV:H 슡nnHnu>-<|Kxf3v{(2w(b<,ujnR8m9Q̕зJ1M=hb!\7vĿ:Ep(]|@m;$)T?1` LԽ,TKG »Iǫ52<%LH{ZKK[ݭ{@?nq/t07a`׬b/(f%R9CNHLXcgL4~lEwgN֕6VvysXtn2Zf(ۗe`.LQk83"͙ ]Y-])/sǧfZfCVkݮl , uT>qSy>\<7wZ+,΀٭q9]0{4G \_i}[m2-.R9RŰa*;zI4knBi!KΘUv[,~0)T\= "6ENd-sxGGFzJѴ/j=U4,: By?w懠h~Z `n.{AVEnW>E.,܇kMD"4vy}p[0ͶF-$1HO":m#a!T8A0tQ6/hlVs( lJ#^dS˵<*6G?8:3 G&S {-<ɋ@ Ln|KLJOY]({%҆-k;p%PI Ralخ#K5T bC ԇɇ b 4D L BԵqAr`WD$$, ~B@BuOL10 ka#J§=:#מ+`M}Ԇ#հ= 8Ms͏k>NHVȄ'g,-WL^d~\>L̃޽fK/8ܬBJ֖"09m=F*C!P`XvnZ+CSAY0v4 m6&J !5g`ۺ(Ou(h3Wbhg®# kѷsƭ) 3 ױB2{3|o?fҚxd!4]$zD@=\Ms!:\cŇKyZ=w6 L^N/˒OYWsIY-3ܬY!0ʸ2B:+fnزλî刷FHP \oaIStBgPk z6kveW0 'X `-#]0;".iܒ~bY wWR[st<[{fjb\OeAgϏ7c}:yYބy#[L9 ߴs7//>ໝzJ 0m0W)!mB݊c6/.zH dTqVN1X6{ZcNa//4ƀrmQ`BBT' -B8ZW`,Xn$=D7l˩ Qxoz62vOl&'+Gc=s+ sSE4Iږ0z\Jk_~[3v2n#?zXogA@W3.ZVE& v63 UQ=$F 8`W!غGP t@fsp5+a5P+ m8o2ʀlvep%56KF{]123V. V[J' F(ƋONT$'iNhB"r=b3"3@VD/~ &os5?+{(\b\Wl5h3VE׌!  Ŧ7awmjڐY=ICn"mc-$]їuEe'B 0\ pR 8J`Hx ՇB Yg";A֖x\>c JJA, f& -7]pnSr+gna%他?q\Zo$& ($L )ͦ6.mj PP#9p@&cc".-/+T fhPD`$?P~4ED`AH "BMY*u}G܁'gO`()-+,A{3)~? %L;Tap߿hA;A"pp*9!=9)!a{4F8E:[gD,8ܮ]1|鉨dMh4N_] Ĉ%lpreQs%,0IYu =H@搦[Ku,*l$6?.$I,OY>៯i=M(g# ذӝù7+:F\_˒Og2yf+wSN5/IDl,܄cBĄq}=F)ĺV`"6 4$a,!TDm)ÅxXQ! AI@䔇MOlN4Y5lg @Ϩ5!!$2[:[nL>^,Xo;`"hok#1sO@ Hĩ#M@e~r+OoRu+<5"HuJJQK@`PpV$?bӮȖol_;\9x69(H0܈Μ#'RɵOgz8dr%&o9<5]azk] ҪD 7tѡE7qT>Ay!)$XB(X"O*e 'VwSHt[j6-tmԷ"v 0 F/&sOxpVGx=XWQ̹~&d63^B@Ajge\m@@CwCj L8Dvw%jBck\KAwoNFڰmHD6!2L&Etnf2yA𔋇ÇrZD]B"@ny"F5+Õ1? 1ð?id,vǖT6*f0f@!B \B,"~5-(Kdk$8:0< L&/ .p^7^/Wk0}XGc-H-ddtqcpY}PĢ)@sS&}AY&TZڣ ?Ă(h n6iDqQ+SHDRdS_tk&߼䅑+ʹ?->~z7ph߶$+fطq*7Yiv-Zu+ 8+FM4Xzo۠?!cQI'AM՞#W(#ٰVidp&.]z}/Cwkޱ6\6b SAny$RnGYPh{yO슉(TWHEwMHJفhnb3zIƸ_m $(6w/@y<}L&!2yѦT{_8/j]yy]XkAQ$P@dX+A -"mB?$iTl Գ2AISe' eV[c ԾȮ+nJM5V bu)o )vvOtYjV`Zox[1kU=`֬YٳLKTY斋[m0" uK H2Ɠ 6]#O5%f|'#[DWf.j,c2<_ZpyNM6 @& aCf$mbAzuf"A!F(m< L/sdrp Ģ15#$B MSF#36' e ve#7D%C:1teL&d2yc_,}:Q6*a2sD%pC 8i8S=)"@tGDPA kV)+@h䠗Rp2M) 0 z~ ђ^Wkm6z6WkzEPfX j&J ZimJu '2?`^aL ^e7H"tgDsN®T'A,-5,,hwp&^Ֆ__v!]|u=#(!P)pV[R)ˣ'ar53d2zZow.\;=2<֬Yf͚5ȭf͚5k6Yf͚5ȬYf͚ d֬YfWܙYYJIENDB`flipper-0.21.0/lib/flipper/ui/public/js/000077500000000000000000000000001404600161700177505ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/ui/public/js/application.js000066400000000000000000000003041404600161700226060ustar00rootroot00000000000000$(function() { $(document).on('click', '.js-toggle-trigger', function() { var $container = $(this).closest('.js-toggle-container'); return $container.toggleClass('toggle-on'); }); }); flipper-0.21.0/lib/flipper/ui/public/octicons/000077500000000000000000000000001404600161700211555ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/ui/public/octicons/LICENSE.txt000066400000000000000000000004451404600161700230030ustar00rootroot00000000000000(c) 2012-2015 GitHub When using the GitHub logos, be sure to follow the GitHub logo guidelines (https://github.com/logos) Font License: SIL OFL 1.1 (http://scripts.sil.org/OFL) Applies to all font files Code License: MIT (http://choosealicense.com/licenses/mit/) Applies to all other files flipper-0.21.0/lib/flipper/ui/public/octicons/README.md000066400000000000000000000003101404600161700224260ustar00rootroot00000000000000If you intend to install Octicons locally, install `octicons-local.ttf`. It should appear as “github-octicons” in your font list. It is specially designed not to conflict with GitHub's web fonts. flipper-0.21.0/lib/flipper/ui/public/octicons/octicons-local.ttf000066400000000000000000001505441404600161700246160ustar00rootroot00000000000000  OS/2VS|(Vcmap _Bglyf͝T 48head 6hhea ]$hmtx/Hloca9]  &maxp0 nameSlpost` @@\ ( )_< @#@#= )G 2PfEd@ |@@\G@@ (@< D@}&e& $(JOSnx| &e&#&*LQVp{|ٿa_^YWVUTSQPNLKJIHGFEC@=:9765  !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^nnN.~^>nN .  ~ ^ >  nN.~^>nN.~^>nN.~ ^ !>!"""#n#$N$%.%&&~&'^'(>())B)V)*(**+X,,r,-`-.L..//L//0D001(122423"3Z33404L4r44525|6(6|667<77889,9@9h99999:::::;H;;<,*>B>\>j>??@ @@&@@@@AA.AtAAABBHBC*C|CDDRDDDDEEE>EFFFDFG&GpGGGHH$HdHI:IbIIIJJDJL&LnLMnMMN&NO"O2ODOVOhO|OOOOOPPPPPQ6QRS:SLSbSvSSUURUUVV6VrV~VVWRWXXfYYYZ|Z[P[W@C65'&7.&67>7.'>7.7462>767&hG%&(%X ZDI>>IDY@ڬ6 _F+  ,7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!$6'.'.'o d[[dd[[d o 7>75>7.+"&=!7#575'''#5.'.5>7'577D;$$$$;D@%@@@@@@@@%"mm"ހ@@@@O,$$$$,O# ,:@@@@@@@@:,"V1mm1V9@@@@@@@@,048#35#3%")375!>7.!5##.=!5!!#35#3@@@@@:&&``@&& ` @@@@@@@&&``&& @@ `@@@@@?@-6?H.''5>5.'>74&'55>%"&46.462"&46H77H##H77H##H77H####4####4####4##7HH7$:jj:$7HH7$:m:$7HH7$:m:c#4##4##4##4#z#4##4#@@ :33335#7#3%")375##"&=!5#!#3+3>7.@@@@@:&&`  `&&@@@&&@`@@@` @&&@@#159=A'!!!!35.'!375!>7#!5##.=!#35#3#335#@&&&``@&@ ` @@@@@@@@@@&&&``& @@ `@@@@ *6BJRZbj#!..'>76023>5>4&."462."462#!.'#!.#!.#!.#!.%&Pz``z}zuIZZHvzzej``je``%&%&'&%&%&%&%$$2 $  $ J2  !!E  !!&%&%%&%&%&.8B>'6.".7$74&&'4676>>4&%>4& kr0efe0rk ',,," 4dd4 "!++!!++!!++! ,+*qSJ  JSq*kAAkI%C+  +C%IG@aAAa@@aAAa@@&@IR%4=.'#"3553>74&.462>74&'>5..462.462rK8 @&#H77H#]$$6$$7H##H77H##H7$$6$$$$6$$nKr&n9$7HH7$9$6$$6$?H7$9]9$7HH7$99$7H$6$$6$$6$$6$W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!@DK".'&#675.'.'>7>767"'#"'32673>7.##7#@ &ee& RllRD(&:!.6II6&aa'6II6C).PRllXjjXlRRl %A."I66I\vv]I66I%A"lRRlK333".'&#675.'.'>7>767"'#"'32673>7. &ee& RllRD(&:!.6II6&aa'6II6C).PRll@XjjXlRRl %A-#I66I\vv]I66I%A"lRRl !%)-159=AE35#7#3'#335#!5!%3##35#35##3#3#3!!!35#'#37#335#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ 7'!!!'7'``@ @``@``@@@``@ 7'737'!!!! @``@``@`@@````@@ !!!!!!!5!5!5!5!5!@@@@ @@@@@ !!!!!%3>7.'>73'@@H77HH77HH77H 7HH77HH77HH7  !%.25#75#75#35#!!!3353%35#35#!5'#5##5@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@->!!%!76!367>7>72635.#".'>'6@@  ` >!+?>l>'&yUF+-_/+]  -$+R+ ! &o3!2@) &&&'   &&@@&  6@"+.'#!.#546732"+.'5.'!!>7!35.%!5>3!@!KT&&& @ ) &&@&&&'  @&&&?    &&&&&  @@ *@.'>.'>7+!>75.5###.'5>7!lRRllRRl6II66IIJ6II66II $@@$$$RllRRll.I66II66IAI66II66I$$$$@?@*26>B#"&5#!>7&7"&5>'3>'337'#3%37'#31`/)[&&[) V-@D^^D@& @@ @@@&&l &&@ l@0KqsK-@ @ @ @ @.'#3>735.'>7[[[[@6II66IITjjTTjjTI66II66I2;DM>5.'>7&'>3>75>5.'%"&46.462.4627H#L2O1#H77H##H77H!0$d#H7$$6$$$$6$$e$$6$$H7$92L/9$7HH7$9]9$7HH74#d9$7H?$6$$6$$6$$6$$6$$6$.7@I"&#.'>5.'>74&'573>7..462.462.462$: e$I66I##I66I#@[:$6II$$6$$$$6$$$$6$$$~_26II6$9\9$6II6$9DP#I66IA$6$$6$$6$$6$$6$$6$@@ 5!7'! %5#3@@@@@ >7..'>7'35#53#Ȁ=@@5%#.'67#52675#35##3%.'"'3'>33>(a6["Q:&Q:&?(a6["A"E;c2r?7/&@@7/&?"E;c2r#''.'>727.'>7#335#``Cs,[>]|B``?2,[=En7 % %LJ;;))ѕ/@!73!>7.1Mp@@1MLL2\(O/1M@@".%35#3>23>7.67&.'>7@Kr&2&p2LrK!!ڣ٣@rK&&,(,JGIV!!٣٣?%."3!2>4%#535#3J " JlJ ""/@r@#!'>5.'2672?64%.'>7٣٣7d,$@ mmm,d7٣ @$mmmm +>7.'#/'7/5?'7?37E[[EE[[;HvG(f,FtH8|{:HvG(f,FtH8|[EE[[EE[FtH8|{:HvF)f,FtH8|{:HvF)f,'3GSVZo64'&4764&"2&"264'.46764>7.'&"27>4&1"'37!3#7327>4&'&"3    ! ---, ! ! ! `,;;,,;; ! ! ! ! ,--/(%_76_$(@@@@L     ! !V" " FJE r /sys/ " "TYT" ";,,;;,,;E " "TYS# " /szs@@@@ ! '+! " FJF ?A".7'7'&6.'26764''?"$8l Wp8| z{+.w֕&'&S29?Y 8o!$%:s :tZ}!9t-/i U!#;"\B:@@%!%!3!5!#-!!@@@@@@@?A&767?67>7.7>'&7o5C+һ!;XI!>C5S#=2>KD1'S5D=!IV;"ҳ+B5p62=HDKg' /7>7.&7>'.&7>'6II66II688m..٣88!..I66II66I--m88..88i@26=AEI!!3.'#.'#!>75#2;>23!>35#%5 5!535#!!#3@$H77H$$$@ "$$6$&@'#$@@@@@@$7HH7$@$$@$6$$6$$"@@@@@@@@5!5!55%!3!5!#!@@@@@P@@%;Jkt#!!!5!5>=.##5>73%5###.'5>7!7##5>=&'3%2673>7.'.'&'26%"&46'.'>.462@6I##Iʀ@$R#@@@$$$$@#R$JXJ:%6II6'eFFe'6II6%:$$6$$6II66II$$6$$I6@$9Ҁ9$@6IA@$#$9.$$$$R9$#$$$$I66IASSAI66I$$6$$6$I66II66I$6$$6$@@'+!!>7.32+"&46#"&46;27!!@$$$$@@N@@@$$$$?@ '353'33!'35!!5#7!#!5'75##7#@@@@@@@@@@@@ '  ! 777'7@@BB@@@@22@@"!!'#3%5#'#!%!%777'7@@@@BB@@@[ee[ @22@ 3!3@ !!@@!# @@5 5!@'7".'5467674&">7.@@B# YY #(yy'@.2@ )55) @2/:XX:/@ )26:!!!!6?>76&.'&376&>.6!5!5!@@'A_ L@2QQ2@L _AW@k#)4F)3#3)F3)v@@`@AL?..?LA@` )F3)F43F)4F)L@@##3#3535#535#535#535#535#!5#3@@@@@@@@@@@@@@@@@@@ %=735#35#7!!'37#35#")!>7.!.737#5&67!!@@@@J@&&&&  @ @ @@@@@@&&&&  !7'#5'7>7..'>7@@@@@@@@@ ! !7!#@@@@@+7Q>7.''.5>3>75.'7.'>'.'>7>7.m'!ERRE"&-#%%#@H77HH77H g|٣|g m4[#]+WW+]#[4m$##$7HH77HH/H/w٣w/H/ᗾ%3753535373>7. 5%.462my@@@@Gm@@$$6$$my@@@@@@GmmC@@$6$$6$@@,7.'!54375##"&=!5#!#3#3>73 3333&&&`  `&&& &@ `@@@` @&@ @@)-1HQX_c#3!5!375!>75!#!5##.'5#335#"+3753>7.0+"&=3#535!36#3@@@&&``@%` @@@@@&z&&@ &&  @ @@@@&&``&@&6 @@ `@@&&@ @&&A @@  @@@ !##33535#!5!!!3%!'!!@@@ -"6$7&$.'>7"&'>7.MJ  JM╓ғғ6)- lRRlldddd -)6RllRRl+4=#373>75#."+353>7.&&@%2L>.&&@&&@6BH&&&@L&&&@& 56.'\2z@ >7.mmmm@mmm@%!!@)-?KN27.'>765.%#'!#!>7.%3!"&535#546;7.'>77'36II6 $-6*%4IJ$$$$@@@ KKKjRllRRllI66I4&)6-$ 6I$@$$$A@ @  OOK@lRRllRRlޠ/BF`lp264&54&'>5.'.##3!>753%.'>7#.#53&/&+"#"&5463!2'.'>7'57@$$6$$%lRNjB%RllR@@$$@@$$6II66I@$% Ks@6II66II @@$6$$6$@"B&RlcLlRRl$$@@$6$@I66II6$  J@`I66II66I@@77'#533@@@ 2>4&">7..'>7.'#33>7#$$6$$H&@&@&@&@$6$$6$?=&&&&  @;#3#.'67#3>7.#.'>73365.'#3.%; 3KM11Mdd3KM11Mddd%;@HL22LL2#!dddL22LL2#!dddH #!!3!5 !5!5!!5!  7'7```  ```@@`@@ 67..'>7@}}7HH77HH@cCH77HH77H 35#535#35#%!5!5!5!!5! )!5!!5!!53#76&'"63235>7@@@ON$U+n%=+$4;[&02y0B 5(2C8#H !#&675&5&!#&6(\p(@pP8Pp 535#35#%!!!35#35#@@@@@@@@@!!@@ 7!!!!35!3#!5#33#!#@@@@@@@@@@@@ #35#335#5#353535#%!!!!@@@@@@@@@@ #'+/;?CGKOSW#3'#3#3#3#3#3%#3#3%#3#3#37#3#5!#5#!!!#3#3#3#3#3#3@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@!%04"+5.'>753>7.#3#3#3'.'>2#3 LTǕǕ&$%@@@@@@_@7HH77HH7%%@$$$$?@@*3"+5.'#!>7.!!!!!!!!5!5>7dd@&&&&@L22Ldd&@&&&@@@@@@@2LL2 -##33535#")!>7.!"&5>3!2@B&&&&Y  @ &&&&! @  #!!>7.!"&5>3!2!5!@&&&&Y  @ &&&&! @  +!!>7.!"&5>3!2>7.@&&&&Y  @ 7HH77HH&&&&! @  H77HH77H&!!>7.!"&5>3!2#3-@&&&&Y  @ &&&&! @   !33##5#3!5#75##535#3535!5!@@@@@@@@@@@@@%#3@!!51! !5 13#5 5!@@@@ !!7#53#33#@@"&*.26:'&"'&"27647627!!'&4!!!!#535#535#533#2 ] 2 2"["r@\ @@@@@@@@@@@@<1]1 <2 l@@\#Z@@@@@@@@@` $4?7'>7.&'&5>727&5467>7.&'&7@Kd6II6@@@-ARll3;E$:3Es2 6I qmm `J٣A#Fv#c;##!%7&'>76&&74&"26264&"%"264&4&"26264&"%4&"26.'>727.#>7&/??//?@6C] M`E 2Z&O5I뱱p?//??/ KC7N s $Z(-=*/뱱_"-"'!'>3.'67#5>7.7537'#'D}5JX"L*["A@@@@'#JXE;c2r??@@@@ %!75!!#7@@`#33#''77'@@@``?_`@``@`@``?a`@``@` ''77' >7.2&5>&'7@*#lR*%l}#*Rl$*Rl ' #3735୍FȘ::f@fsF`!'7%7$7>'%>&&6 &/JSK&Tvk<7JSK&Tvk<7q &g?x0F4I'QTe7&'0?.671.'&677#37353 .7>7>.>L#\fC<K1' S  7HI5 8iT n. ,A+4@@46  Si;  X$4(4(@@)2>74&'65.'757'#3?!3#!/>2"&iBT%D;@@@@@@@`*5Rl!$6$$6$@iG,KO,@@@@@@@`R5lR@ `$$6$$@ >7.7!77RllRRll@ lRRllRRl@7'77@@@@@@@ 7!%!!%#!'!!'!A@@,@@@@@f&@@@@333#'3 3@@@@@@77!7.'>7 @ RllRRll@lRRllRRl@@!!!!3#35#@@@@@@t>56&'>'.&"&'&&'54'7>.&'.7.'&'&776767672676&'&'67>&%QOPP$2,2   2 U<  1 1 $%!1 1> "" )G*FO^~#3#&'>6?4'.'&7>56'&##&'#3673367&264&&'#";265#&7&'#7>7367&%5&'#326?67..'5>76%"#"&=36=4'#54+&37>=4&)  ^%+7juJ ` %)-!'!!#3#33#535#53%3#535#53#3#3@@ @@@@@@@@@@@@@@@@@@@@@@'5@.'567 67'.'547167 671'.'5>7267. "" ٣""٣٣٣mڐI6*55*6II6 *55* 6II66II66I?$$$$/37;?OSW[_c!!>75.#53#53#53#53!!>75.#53#53#53#53!!>75.#53#53#53#53#53$&&&@@@@@@@@$&&&@@@@@@@@$&&&@@@@@@@@@@&&&&&&&&&&&&@@%!!>7.!"&5>3!235#@&&&&Y  @ bb&&&&! @  bb"#!!>75.#53#53#53D2LL22LLNL22LL22L '#553675%>7.>72&"'78___I6  I. ____6I d 6I1CR!>7&#"&46;2%#.'!#.'>7'"#!!>75.#'#'537373L22Lz&&&&<||<2&&&&@@@@@@@@@@2LL2!&&&&hbuub&&&&@@@@@@@@@@@"-=H>7.'>7."&'>>7>5.'267.".';Zj٣kYZj٣kmڐ-s M379i5J5¡5J5OO973M ~t@Bo7HH7oBBo7HH7o$$$$?, Ux./a*m@@m* /.yT-33535##5 535@ 77@@' @@@' 7'@ 3#@ !3@@@ 53!3 !5@@@@ #!# !5!@@ 3!! @@@@@@ !@@@&DM%4=.'#"3553>74&.462%"+.'>5.'32#7'.462@rK8 @&#H77H#]$$6$$%,&#H77H#rK8 $$6$$nKr&n9$7HH7$9$6$$6$&9$7HH7$9nKr$6$$6$@&#3!5'35'!53>4&' 373353@&@@@  5v @@&@@@@@@@@@ !!!!">-@@r_e\%!.'5.'!!.%>3!2!5&>35@ &&&  _r &&@@&!  \e:C.4&".&7&'#!67.'&667."&462m D g a1( (Q/00*'.=&?6=."./.4/.&'&6&676'&6'&6?66&664'&6#&.676>>7..67226&?>7>"&76.67&'.'&76٣'%     F   (* #      & $ &6 0 6 %I($ ?    K%  ! ٣'?        $ D+ %          !  -  ]  ̌A .   @'7#33#&"27647&"27647&"276@@    %5  88  K  ^^  p  4  (f  ;;  O̪  bb  w213#!3@@  @@5 @O:C&671>.1'.7>#.=4&'"&'&67676.>Ӱ̯6l4$PT ʇ   '8Qy ^Q+S3Ce ̃76767>'&'&/&?6?6766767>767>'&'&'&'&'&'&76?6?6676767>7>'&'&'&'&  % -      ( +       % -      ( +      %       + (       - %       + (       - %77757'1%7 `@gPW+o+~f#""o"o #3#'3#'3#!!5!5!3.'!!>7@@@@@@@@$$$$@@@@@@@$$$$AA >?!6?!7 9BI84Ņ@@;gDFkN2Ņ)267.'>74&3>?67.:M  2*+2-=69=-3+*2  M_37.7'7)5!?$$$$%@$$$$A@@"!!>7.#5'#373'3533**l**k``````****{{`!5@ '#3!'3'#37#7!#35'7%#5##3353'@@@ࠠ@@ࠠ@@@@@@@@ !!#!'#!@@@q11@AQ!54&"!!>7>75.#!"&532653265326537#!"&=463!2$$$$$$[@@@@@      $@$$$@$@$!   P   @ALY>7>75."&=4&""&=4&""&'5.'5267'"&'>2'1>7.$$$6$$,,$R~~~~R@٣@$6II6@$@   $$@$ss$####I66Id]&.776>'.7'&476&$+0{4K;gkMDAK!$ o.E6K)@c|*.[EiA7.%46;2#!#5!3!53##$$$$% ` @@B##B$$$$1 0@@<Ii'&"'764/&"&"/.2?64/&462764'&4?6?>'764"/&47>2?2?3.Z-300o*R?/.  Y11//2  Z12./ `U) 0L;*2-[.2* s-Z.30 )U`11  Z//11 3  Y/011  ?Q+o003M )2.[-3*:CLU"&46!733>7.'"#.'>726733>7.'"#&'5!>2"&>2"&@$$6$$lR@Q:$6II6$:*1lR#I6$::$6II6$:Rl$6$$6$$6$$6$@$6$$6$Rl#I66I#U5Rl:$6I##I66I#lR@$$6$$$$6$$ -6%'2675'&2754'>&'57%64'"&462ڐ o p 9$$6$$L0000L#q$$]b4 \_b>74&##'67>/.#.'".#"2676&+'>7#!4&'#.'#"2676&!#77#%#& #hh#   ii '.'33>'&'67>5&'67>54&'67676=A&o c +! " D01,:#\ Dq5  }& ^!T'38G+(2  ,   #  }AC#&&>&'.'.367>7>36A&o c +! " D01,:#\ Dq5  & ^!T'38G+(2a  +   #  #*")!!>7.'!>7.!!# !J@&&@ && @&&@&&L2&&2L&@&@@:I@&&5<K Ze +t  L   * H f | V & (c) 2012-2015 GitHub When using the GitHub logos, be sure to follow the GitHub logo guidelines (https://github.com/logos) Font License: SIL OFL 1.1 (http://scripts.sil.org/OFL) Applies to all font files Code License: MIT (http://choosealicense.com/licenses/mit/) Applies to all other files github-octiconsRegulargithub-octiconsgithub-octiconsVersion 1.0github-octiconsGenerated by svg2ttf from Fontello project.http://fontello.com (c) 2012-2015 GitHub When using the GitHub logos, be sure to follow the GitHub logo guidelines (https://github.com/logos) Font License: SIL OFL 1.1 (http://scripts.sil.org/OFL) Applies to all font files Code License: MIT (http://choosealicense.com/licenses/mit/) Applies to all other files github-octiconsRegulargithub-octiconsgithub-octiconsVersion 1.0github-octiconsGenerated by svg2ttf from Fontello project.http://fontello.com       !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~      !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}heartzap light-bulbrepo repo-forked repo-push repo-pullbookoctofacegit-pull-request mark-githubcloud-download cloud-uploadkeyboardgist file-code file-text file-mediafile-zipfile-pdftagfile-directoryfile-submodulepersonjersey git-commit git-branch git-mergemirror issue-openedissue-reopened issue-closedstarcommentquestionalertsearchgear radio-towertoolssign-outrocketrssclippysign-in organization device-mobileunfoldcheckmail mail-readarrow-up arrow-right arrow-down arrow-leftpingiftgraph triangle-left credit-cardclockruby broadcastkeyrepo-force-push repo-clonediffeyecomment-discussion mail-reply primitive-dotprimitive-square device-cameradevice-camera-videopencilinfotriangle-right triangle-downlinkplus three-barscodelocationlist-unordered list-orderedquoteversions color-mode screen-full screen-normalcalendarbeerlock diff-added diff-removed diff-modified diff-renamedhorizontal-rulearrow-small-right jump-downjump-up move-left milestone checklist megaphone chevron-rightbookmarksettings dashboardhistory link-externalmutex circle-slashpulsesync telescope microscopealignment-alignalignment-unalign gist-secrethomealignment-aligned-tostopbug logo-github file-binarydatabaseserver diff-ignoredellipsis no-newlinehubot hourglassarrow-small-uparrow-small-downarrow-small-left chevron-up chevron-down chevron-left jump-left jump-rightmove-up move-down move-right triangle-up git-comparepodiumfile-symlink-filefile-symlink-directorysquirrelglobeunmuteplayback-pauseplayback-rewindplayback-fast-forwardmention playback-playpuzzlepackagebrowsersplitstepsterminalmarkdowndashfoldinboxtrashcanpaintcanflame briefcaseplug circuit-board mortar-boardlawthumbsup thumbsdowndevice-desktopflipper-0.21.0/lib/flipper/ui/public/octicons/octicons.css000066400000000000000000000265001404600161700235130ustar00rootroot00000000000000@font-face { font-family: 'octicons'; src: url('octicons.eot?#iefix') format('embedded-opentype'), url('octicons.woff') format('woff'), url('octicons.ttf') format('truetype'), url('octicons.svg#octicons') format('svg'); font-weight: normal; font-style: normal; } /* .octicon is optimized for 16px. .mega-octicon is optimized for 32px but can be used larger. */ .octicon, .mega-octicon { font: normal normal normal 16px/1 octicons; display: inline-block; text-decoration: none; text-rendering: auto; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } .mega-octicon { font-size: 32px; } .octicon-alert:before { content: '\f02d'} /*  */ .octicon-alignment-align:before { content: '\f08a'} /*  */ .octicon-alignment-aligned-to:before { content: '\f08e'} /*  */ .octicon-alignment-unalign:before { content: '\f08b'} /*  */ .octicon-arrow-down:before { content: '\f03f'} /*  */ .octicon-arrow-left:before { content: '\f040'} /*  */ .octicon-arrow-right:before { content: '\f03e'} /*  */ .octicon-arrow-small-down:before { content: '\f0a0'} /*  */ .octicon-arrow-small-left:before { content: '\f0a1'} /*  */ .octicon-arrow-small-right:before { content: '\f071'} /*  */ .octicon-arrow-small-up:before { content: '\f09f'} /*  */ .octicon-arrow-up:before { content: '\f03d'} /*  */ .octicon-beer:before { content: '\f069'} /*  */ .octicon-book:before { content: '\f007'} /*  */ .octicon-bookmark:before { content: '\f07b'} /*  */ .octicon-briefcase:before { content: '\f0d3'} /*  */ .octicon-broadcast:before { content: '\f048'} /*  */ .octicon-browser:before { content: '\f0c5'} /*  */ .octicon-bug:before { content: '\f091'} /*  */ .octicon-calendar:before { content: '\f068'} /*  */ .octicon-check:before { content: '\f03a'} /*  */ .octicon-checklist:before { content: '\f076'} /*  */ .octicon-chevron-down:before { content: '\f0a3'} /*  */ .octicon-chevron-left:before { content: '\f0a4'} /*  */ .octicon-chevron-right:before { content: '\f078'} /*  */ .octicon-chevron-up:before { content: '\f0a2'} /*  */ .octicon-circle-slash:before { content: '\f084'} /*  */ .octicon-circuit-board:before { content: '\f0d6'} /*  */ .octicon-clippy:before { content: '\f035'} /*  */ .octicon-clock:before { content: '\f046'} /*  */ .octicon-cloud-download:before { content: '\f00b'} /*  */ .octicon-cloud-upload:before { content: '\f00c'} /*  */ .octicon-code:before { content: '\f05f'} /*  */ .octicon-color-mode:before { content: '\f065'} /*  */ .octicon-comment-add:before, .octicon-comment:before { content: '\f02b'} /*  */ .octicon-comment-discussion:before { content: '\f04f'} /*  */ .octicon-credit-card:before { content: '\f045'} /*  */ .octicon-dash:before { content: '\f0ca'} /*  */ .octicon-dashboard:before { content: '\f07d'} /*  */ .octicon-database:before { content: '\f096'} /*  */ .octicon-device-camera:before { content: '\f056'} /*  */ .octicon-device-camera-video:before { content: '\f057'} /*  */ .octicon-device-desktop:before { content: '\f27c'} /*  */ .octicon-device-mobile:before { content: '\f038'} /*  */ .octicon-diff:before { content: '\f04d'} /*  */ .octicon-diff-added:before { content: '\f06b'} /*  */ .octicon-diff-ignored:before { content: '\f099'} /*  */ .octicon-diff-modified:before { content: '\f06d'} /*  */ .octicon-diff-removed:before { content: '\f06c'} /*  */ .octicon-diff-renamed:before { content: '\f06e'} /*  */ .octicon-ellipsis:before { content: '\f09a'} /*  */ .octicon-eye-unwatch:before, .octicon-eye-watch:before, .octicon-eye:before { content: '\f04e'} /*  */ .octicon-file-binary:before { content: '\f094'} /*  */ .octicon-file-code:before { content: '\f010'} /*  */ .octicon-file-directory:before { content: '\f016'} /*  */ .octicon-file-media:before { content: '\f012'} /*  */ .octicon-file-pdf:before { content: '\f014'} /*  */ .octicon-file-submodule:before { content: '\f017'} /*  */ .octicon-file-symlink-directory:before { content: '\f0b1'} /*  */ .octicon-file-symlink-file:before { content: '\f0b0'} /*  */ .octicon-file-text:before { content: '\f011'} /*  */ .octicon-file-zip:before { content: '\f013'} /*  */ .octicon-flame:before { content: '\f0d2'} /*  */ .octicon-fold:before { content: '\f0cc'} /*  */ .octicon-gear:before { content: '\f02f'} /*  */ .octicon-gift:before { content: '\f042'} /*  */ .octicon-gist:before { content: '\f00e'} /*  */ .octicon-gist-secret:before { content: '\f08c'} /*  */ .octicon-git-branch-create:before, .octicon-git-branch-delete:before, .octicon-git-branch:before { content: '\f020'} /*  */ .octicon-git-commit:before { content: '\f01f'} /*  */ .octicon-git-compare:before { content: '\f0ac'} /*  */ .octicon-git-merge:before { content: '\f023'} /*  */ .octicon-git-pull-request-abandoned:before, .octicon-git-pull-request:before { content: '\f009'} /*  */ .octicon-globe:before { content: '\f0b6'} /*  */ .octicon-graph:before { content: '\f043'} /*  */ .octicon-heart:before { content: '\2665'} /* ♥ */ .octicon-history:before { content: '\f07e'} /*  */ .octicon-home:before { content: '\f08d'} /*  */ .octicon-horizontal-rule:before { content: '\f070'} /*  */ .octicon-hourglass:before { content: '\f09e'} /*  */ .octicon-hubot:before { content: '\f09d'} /*  */ .octicon-inbox:before { content: '\f0cf'} /*  */ .octicon-info:before { content: '\f059'} /*  */ .octicon-issue-closed:before { content: '\f028'} /*  */ .octicon-issue-opened:before { content: '\f026'} /*  */ .octicon-issue-reopened:before { content: '\f027'} /*  */ .octicon-jersey:before { content: '\f019'} /*  */ .octicon-jump-down:before { content: '\f072'} /*  */ .octicon-jump-left:before { content: '\f0a5'} /*  */ .octicon-jump-right:before { content: '\f0a6'} /*  */ .octicon-jump-up:before { content: '\f073'} /*  */ .octicon-key:before { content: '\f049'} /*  */ .octicon-keyboard:before { content: '\f00d'} /*  */ .octicon-law:before { content: '\f0d8'} /*  */ .octicon-light-bulb:before { content: '\f000'} /*  */ .octicon-link:before { content: '\f05c'} /*  */ .octicon-link-external:before { content: '\f07f'} /*  */ .octicon-list-ordered:before { content: '\f062'} /*  */ .octicon-list-unordered:before { content: '\f061'} /*  */ .octicon-location:before { content: '\f060'} /*  */ .octicon-gist-private:before, .octicon-mirror-private:before, .octicon-git-fork-private:before, .octicon-lock:before { content: '\f06a'} /*  */ .octicon-logo-github:before { content: '\f092'} /*  */ .octicon-mail:before { content: '\f03b'} /*  */ .octicon-mail-read:before { content: '\f03c'} /*  */ .octicon-mail-reply:before { content: '\f051'} /*  */ .octicon-mark-github:before { content: '\f00a'} /*  */ .octicon-markdown:before { content: '\f0c9'} /*  */ .octicon-megaphone:before { content: '\f077'} /*  */ .octicon-mention:before { content: '\f0be'} /*  */ .octicon-microscope:before { content: '\f089'} /*  */ .octicon-milestone:before { content: '\f075'} /*  */ .octicon-mirror-public:before, .octicon-mirror:before { content: '\f024'} /*  */ .octicon-mortar-board:before { content: '\f0d7'} /*  */ .octicon-move-down:before { content: '\f0a8'} /*  */ .octicon-move-left:before { content: '\f074'} /*  */ .octicon-move-right:before { content: '\f0a9'} /*  */ .octicon-move-up:before { content: '\f0a7'} /*  */ .octicon-mute:before { content: '\f080'} /*  */ .octicon-no-newline:before { content: '\f09c'} /*  */ .octicon-octoface:before { content: '\f008'} /*  */ .octicon-organization:before { content: '\f037'} /*  */ .octicon-package:before { content: '\f0c4'} /*  */ .octicon-paintcan:before { content: '\f0d1'} /*  */ .octicon-pencil:before { content: '\f058'} /*  */ .octicon-person-add:before, .octicon-person-follow:before, .octicon-person:before { content: '\f018'} /*  */ .octicon-pin:before { content: '\f041'} /*  */ .octicon-playback-fast-forward:before { content: '\f0bd'} /*  */ .octicon-playback-pause:before { content: '\f0bb'} /*  */ .octicon-playback-play:before { content: '\f0bf'} /*  */ .octicon-playback-rewind:before { content: '\f0bc'} /*  */ .octicon-plug:before { content: '\f0d4'} /*  */ .octicon-repo-create:before, .octicon-gist-new:before, .octicon-file-directory-create:before, .octicon-file-add:before, .octicon-plus:before { content: '\f05d'} /*  */ .octicon-podium:before { content: '\f0af'} /*  */ .octicon-primitive-dot:before { content: '\f052'} /*  */ .octicon-primitive-square:before { content: '\f053'} /*  */ .octicon-pulse:before { content: '\f085'} /*  */ .octicon-puzzle:before { content: '\f0c0'} /*  */ .octicon-question:before { content: '\f02c'} /*  */ .octicon-quote:before { content: '\f063'} /*  */ .octicon-radio-tower:before { content: '\f030'} /*  */ .octicon-repo-delete:before, .octicon-repo:before { content: '\f001'} /*  */ .octicon-repo-clone:before { content: '\f04c'} /*  */ .octicon-repo-force-push:before { content: '\f04a'} /*  */ .octicon-gist-fork:before, .octicon-repo-forked:before { content: '\f002'} /*  */ .octicon-repo-pull:before { content: '\f006'} /*  */ .octicon-repo-push:before { content: '\f005'} /*  */ .octicon-rocket:before { content: '\f033'} /*  */ .octicon-rss:before { content: '\f034'} /*  */ .octicon-ruby:before { content: '\f047'} /*  */ .octicon-screen-full:before { content: '\f066'} /*  */ .octicon-screen-normal:before { content: '\f067'} /*  */ .octicon-search-save:before, .octicon-search:before { content: '\f02e'} /*  */ .octicon-server:before { content: '\f097'} /*  */ .octicon-settings:before { content: '\f07c'} /*  */ .octicon-log-in:before, .octicon-sign-in:before { content: '\f036'} /*  */ .octicon-log-out:before, .octicon-sign-out:before { content: '\f032'} /*  */ .octicon-split:before { content: '\f0c6'} /*  */ .octicon-squirrel:before { content: '\f0b2'} /*  */ .octicon-star-add:before, .octicon-star-delete:before, .octicon-star:before { content: '\f02a'} /*  */ .octicon-steps:before { content: '\f0c7'} /*  */ .octicon-stop:before { content: '\f08f'} /*  */ .octicon-repo-sync:before, .octicon-sync:before { content: '\f087'} /*  */ .octicon-tag-remove:before, .octicon-tag-add:before, .octicon-tag:before { content: '\f015'} /*  */ .octicon-telescope:before { content: '\f088'} /*  */ .octicon-terminal:before { content: '\f0c8'} /*  */ .octicon-three-bars:before { content: '\f05e'} /*  */ .octicon-thumbsdown:before { content: '\f0db'} /*  */ .octicon-thumbsup:before { content: '\f0da'} /*  */ .octicon-tools:before { content: '\f031'} /*  */ .octicon-trashcan:before { content: '\f0d0'} /*  */ .octicon-triangle-down:before { content: '\f05b'} /*  */ .octicon-triangle-left:before { content: '\f044'} /*  */ .octicon-triangle-right:before { content: '\f05a'} /*  */ .octicon-triangle-up:before { content: '\f0aa'} /*  */ .octicon-unfold:before { content: '\f039'} /*  */ .octicon-unmute:before { content: '\f0ba'} /*  */ .octicon-versions:before { content: '\f064'} /*  */ .octicon-remove-close:before, .octicon-x:before { content: '\f081'} /*  */ .octicon-zap:before { content: '\26A1'} /* ⚡ */ flipper-0.21.0/lib/flipper/ui/public/octicons/octicons.eot000066400000000000000000000762441404600161700235240ustar00rootroot00000000000000|{LPtdocticonsRegularVersion 1.0octicons  OS/2|SA(Vcmap7eP:glyf``ehead 6hhea $hmtxQ/loca%Y Njmaxp nameg3mpost8Jsp@@\ ( )dt_< @$@$= )G L2PfEd@&e|@@\G@@ (@4B@&e& $(JOSnx|&e&#&*LQVp{|ٜa7nxDR@&:h*x P r 8  D x 6(>|  4H&4|4z8Zhv$T~*b"zV,@TlVhv"Vt : !!6!^!"J#v#$$$%%2%v%&r&&&&&&&' '"'<'X'f'((D((*4****+4+B,h,,--T---. .,./(/j/0V01412622W@C65'&7.&67>7.'>7.7462>767&hG%&(%X ZDI>>IDY@ڬ6 _F+  ,7 " g*D H6 // 6H D*g /$ 6!$6'.'.'o d[[dd[[d o 7>75>7.+"&=!7#575'''#5.'.5>7'577D;$$$$;D@%@@@@@@@@%"mm"ހ@@@@O,$$$$,O# ,:@@@@@@@@:,"V1mm1V9@@@@@@@@,048#35#3%")375!>7.!5##.=!5!!#35#3@@@@@:&&``@&& ` @@@@@@@&&``&& @@ `@@@@@?@-6?H.''5>5.'>74&'55>%"&46.462"&46H77H##H77H##H77H####4####4####4##7HH7$:jj:$7HH7$:m:$7HH7$:m:c#4##4##4##4#z#4##4#@@ :33335#7#3%")375##"&=!5#!#3+3>7.@@@@@:&&`  `&&@@@&&@`@@@` @&&@@#159=A'!!!!35.'!375!>7#!5##.=!#35#3#335#@&&&``@&@ ` @@@@@@@@@@&&&``& @@ `@@@@ *6BJRZbj#!..'>76023>5>4&."462."462#!.'#!.#!.#!.#!.%&Pz``z}zuIZZHvzzej``je``%&%&'&%&%&%&%$$2 $  $ J2  !!E  !!&%&%%&%&%&.8B>'6.".7$74&&'4676>>4&%>4& kr0efe0rk ',,," 4dd4 "!++!!++!!++! ,+*qSJ  JSq*kAAkI%C+  +C%IG@aAAa@@aAAa@@&@IR%4=.'#"3553>74&.462>74&'>5..462.462rK8 @&#H77H#]$$6$$7H##H77H##H7$$6$$$$6$$nKr&n9$7HH7$9$6$$6$?H7$9]9$7HH7$99$7H$6$$6$$6$$6$W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!@DK".'&#675.'.'>7>767"'#"'32673>7.##7#@ &ee& RllRD(&:!.6II6&aa'6II6C).PRllXjjXlRRl %A."I66I\vv]I66I%A"lRRlK333".'&#675.'.'>7>767"'#"'32673>7. &ee& RllRD(&:!.6II6&aa'6II6C).PRll@XjjXlRRl %A-#I66I\vv]I66I%A"lRRl !%)-159=AE35#7#3'#335#!5!%3##35#35##3#3#3!!!35#'#37#335#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ 7'!!!'7'``@ @``@``@@@``@ 7'737'!!!! @``@``@`@@````@@ !!!!!!!5!5!5!5!5!@@@@ @@@@@ !!!!!%3>7.'>73'@@H77HH77HH77H 7HH77HH77HH7  !%.25#75#75#35#!!!3353%35#35#!5'#5##5@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@->!!%!76!367>7>72635.#".'>'6@@  ` >!+?>l>'&yUF+-_/+]  -$+R+ ! &o3!2@) &&&'   &&@@&  6@"+.'#!.#546732"+.'5.'!!>7!35.%!5>3!@!KT&&& @ ) &&@&&&'  @&&&?    &&&&&  @@ *@.'>.'>7+!>75.5###.'5>7!lRRllRRl6II66IIJ6II66II $@@$$$RllRRll.I66II66IAI66II66I$$$$@?@*26>B#"&5#!>7&7"&5>'3>'337'#3%37'#31`/)[&&[) V-@D^^D@& @@ @@@&&l &&@ l@0KqsK-@ @ @ @ @.'#3>735.'>7[[[[@6II66IITjjTTjjTI66II66I2;DM>5.'>7&'>3>75>5.'%"&46.462.4627H#L2O1#H77H##H77H!0$d#H7$$6$$$$6$$e$$6$$H7$92L/9$7HH7$9]9$7HH74#d9$7H?$6$$6$$6$$6$$6$$6$.7@I"&#.'>5.'>74&'573>7..462.462.462$: e$I66I##I66I#@[:$6II$$6$$$$6$$$$6$$$~_26II6$9\9$6II6$9DP#I66IA$6$$6$$6$$6$$6$$6$@@ 5!7'! %5#3@@@@@ >7..'>7'35#53#Ȁ=@@5%#.'67#52675#35##3%.'"'3'>33>(a6["Q:&Q:&?(a6["A"E;c2r?7/&@@7/&?"E;c2r#''.'>727.'>7#335#``Cs,[>]|B``?2,[=En7 % %LJ;;))ѕ/@!73!>7.1Mp@@1MLL2\(O/1M@@".%35#3>23>7.67&.'>7@Kr&2&p2LrK!!ڣ٣@rK&&,(,JGIV!!٣٣?%."3!2>4%#535#3J " JlJ ""/@r@#!'>5.'2672?64%.'>7٣٣7d,$@ mmm,d7٣ @$mmmm +>7.'#/'7/5?'7?37E[[EE[[;HvG(f,FtH8|{:HvG(f,FtH8|[EE[[EE[FtH8|{:HvF)f,FtH8|{:HvF)f,'3GSVZo64'&4764&"2&"264'.46764>7.'&"27>4&1"'37!3#7327>4&'&"3    ! ---, ! ! ! `,;;,,;; ! ! ! ! ,--/(%_76_$(@@@@L     ! !V" " FJE r /sys/ " "TYT" ";,,;;,,;E " "TYS# " /szs@@@@ ! '+! " FJF ?A".7'7'&6.'26764''?"$8l Wp8| z{+.w֕&'&S29?Y 8o!$%:s :tZ}!9t-/i U!#;"\B:@@%!%!3!5!#-!!@@@@@@@?A&767?67>7.7>'&7o5C+һ!;XI!>C5S#=2>KD1'S5D=!IV;"ҳ+B5p62=HDKg' /7>7.&7>'.&7>'6II66II688m..٣88!..I66II66I--m88..88i@26=AEI!!3.'#.'#!>75#2;>23!>35#%5 5!535#!!#3@$H77H$$$@ "$$6$&@'#$@@@@@@$7HH7$@$$@$6$$6$$"@@@@@@@@5!5!55%!3!5!#!@@@@@P@@%;Jkt#!!!5!5>=.##5>73%5###.'5>7!7##5>=&'3%2673>7.'.'&'26%"&46'.'>.462@6I##Iʀ@$R#@@@$$$$@#R$JXJ:%6II6'eFFe'6II6%:$$6$$6II66II$$6$$I6@$9Ҁ9$@6IA@$#$9.$$$$R9$#$$$$I66IASSAI66I$$6$$6$I66II66I$6$$6$@@'+!!>7.32+"&46#"&46;27!!@$$$$@@N@@@$$$$?@ '353'33!'35!!5#7!#!5'75##7#@@@@@@@@@@@@ '  ! 777'7@@BB@@@@22@@"!!'#3%5#'#!%!%777'7@@@@BB@@@[ee[ @22@ 3!3@ !!@@!# @@5 5!@'7".'5467674&">7.@@B# YY #(yy'@.2@ )55) @2/:XX:/@ )26:!!!!6?>76&.'&376&>.6!5!5!@@'A_ L@2QQ2@L _AW@k#)4F)3#3)F3)v@@`@AL?..?LA@` )F3)F43F)4F)L@@##3#3535#535#535#535#535#!5#3@@@@@@@@@@@@@@@@@@@ %=735#35#7!!'37#35#")!>7.!.737#5&67!!@@@@J@&&&&  @ @ @@@@@@&&&&  !7'#5'7>7..'>7@@@@@@@@@ ! !7!#@@@@@+7Q>7.''.5>3>75.'7.'>'.'>7>7.m'!ERRE"&-#%%#@H77HH77H g|٣|g m4[#]+WW+]#[4m$##$7HH77HH/H/w٣w/H/ᗾ%3753535373>7. 5%.462my@@@@Gm@@$$6$$my@@@@@@GmmC@@$6$$6$@@,7.'!54375##"&=!5#!#3#3>73 3333&&&`  `&&& &@ `@@@` @&@ @@)-1HQX_c#3!5!375!>75!#!5##.'5#335#"+3753>7.0+"&=3#535!36#3@@@&&``@%` @@@@@&z&&@ &&  @ @@@@&&``&@&6 @@ `@@&&@ @&&A @@  @@@ !##33535#!5!!!3%!'!!@@@ -"6$7&$.'>7"&'>7.MJ  JM╓ғғ6)- lRRlldddd -)6RllRRl+4=#373>75#."+353>7.&&@%2L>.&&@&&@6BH&&&@L&&&@& 56.'\2z@ >7.mmmm@mmm@%!!@)-?KN27.'>765.%#'!#!>7.%3!"&535#546;7.'>77'36II6 $-6*%4IJ$$$$@@@ KKKjRllRRllI66I4&)6-$ 6I$@$$$A@ @  OOK@lRRllRRlޠ/BF`lp264&54&'>5.'.##3!>753%.'>7#.#53&/&+"#"&5463!2'.'>7'57@$$6$$%lRNjB%RllR@@$$@@$$6II66I@$% Ks@6II66II @@$6$$6$@"B&RlcLlRRl$$@@$6$@I66II6$  J@`I66II66I@@77'#533@@@ 2>4&">7..'>7.'#33>7#$$6$$H&@&@&@&@$6$$6$?=&&&&  @;#3#.'67#3>7.#.'>73365.'#3.%; 3KM11Mdd3KM11Mddd%;@HL22LL2#!dddL22LL2#!dddH #!!3!5 !5!5!!5!  7'7```  ```@@`@@ 67..'>7@}}7HH77HH@cCH77HH77H 35#535#35#%!5!5!5!!5! )!5!!5!!53#76&'"63235>7@@@ON$U+n%=+$4;[&02y0B 5(2C8#H !#&675&5&!#&6(\p(@pP8Pp 535#35#%!!!35#35#@@@@@@@@@!!@@ 7!!!!35!3#!5#33#!#@@@@@@@@@@@@ #35#335#5#353535#%!!!!@@@@@@@@@@ #'+/;?CGKOSW#3'#3#3#3#3#3%#3#3%#3#3#37#3#5!#5#!!!#3#3#3#3#3#3@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@!%04"+5.'>753>7.#3#3#3'.'>2#3 LTǕǕ&$%@@@@@@_@7HH77HH7%%@$$$$?@@*3"+5.'#!>7.!!!!!!!!5!5>7dd@&&&&@L22Ldd&@&&&@@@@@@@2LL2 -##33535#")!>7.!"&5>3!2@B&&&&Y  @ &&&&! @  #!!>7.!"&5>3!2!5!@&&&&Y  @ &&&&! @  +!!>7.!"&5>3!2>7.@&&&&Y  @ 7HH77HH&&&&! @  H77HH77H&!!>7.!"&5>3!2#3-@&&&&Y  @ &&&&! @   !33##5#3!5#75##535#3535!5!@@@@@@@@@@@@@%#3@!!51! !5 13#5 5!@@@@ !!7#53#33#@@"&*.26:'&"'&"27647627!!'&4!!!!#535#535#533#2 ] 2 2"["r@\ @@@@@@@@@@@@<1]1 <2 l@@\#Z@@@@@@@@@` $4?7'>7.&'&5>727&5467>7.&'&7@Kd6II6@@@-ARll3;E$:3Es2 6I qmm `J٣A#Fv#c;##!%7&'>76&&74&"26264&"%"264&4&"26264&"%4&"26.'>727.#>7&/??//?@6C] M`E 2Z&O5I뱱p?//??/ KC7N s $Z(-=*/뱱_"-"'!'>3.'67#5>7.7537'#'D}5JX"L*["A@@@@'#JXE;c2r??@@@@ %!75!!#7@@`#33#''77'@@@``?_`@``@`@``?a`@``@` ''77' >7.2&5>&'7@*#lR*%l}#*Rl$*Rl ' #3735୍FȘ::f@fsF`!'7%7$7>'%>&&6 &/JSK&Tvk<7JSK&Tvk<7q &g?x0F4I'QTe7&'0?.671.'&677#37353 .7>7>.>L#\fC<K1' S  7HI5 8iT n. ,A+4@@46  Si;  X$4(4(@@)2>74&'65.'757'#3?!3#!/>2"&iBT%D;@@@@@@@`*5Rl!$6$$6$@iG,KO,@@@@@@@`R5lR@ `$$6$$@ >7.7!77RllRRll@ lRRllRRl@7'77@@@@@@@ 7!%!!%#!'!!'!A@@,@@@@@f&@@@@333#'3 3@@@@@@77!7.'>7 @ RllRRll@lRRllRRl@@!!!!3#35#@@@@@@t>56&'>'.&"&'&&'54'7>.&'.7.'&'&776767672676&'&'67>&%QOPP$2,2   2 U<  1 1 $%!1 1> "" )G*FO^~#3#&'>6?4'.'&7>56'&##&'#3673367&264&&'#";265#&7&'#7>7367&%5&'#326?67..'5>76%"#"&=36=4'#54+&37>=4&)  ^%+7juJ ` %)-!'!!#3#33#535#53%3#535#53#3#3@@ @@@@@@@@@@@@@@@@@@@@@@'5@.'567 67'.'547167 671'.'5>7267. "" ٣""٣٣٣mڐI6*55*6II6 *55* 6II66II66I?$$$$/37;?OSW[_c!!>75.#53#53#53#53!!>75.#53#53#53#53!!>75.#53#53#53#53#53$&&&@@@@@@@@$&&&@@@@@@@@$&&&@@@@@@@@@@&&&&&&&&&&&&@@%!!>7.!"&5>3!235#@&&&&Y  @ bb&&&&! @  bb"#!!>75.#53#53#53D2LL22LLNL22LL22L '#553675%>7.>72&"'78___I6  I. ____6I d 6I1CR!>7&#"&46;2%#.'!#.'>7'"#!!>75.#'#'537373L22Lz&&&&<||<2&&&&@@@@@@@@@@2LL2!&&&&hbuub&&&&@@@@@@@@@@@"-=H>7.'>7."&'>>7>5.'267.".';Zj٣kYZj٣kmڐ-s M379i5J5¡5J5OO973M ~t@Bo7HH7oBBo7HH7o$$$$?, Ux./a*m@@m* /.yT-33535##5 535@ 77@@' @@@' 7'@ 3#@ !3@@@ 53!3 !5@@@@ #!# !5!@@ 3!! @@@@@@ !@@@&DM%4=.'#"3553>74&.462%"+.'>5.'32#7'.462@rK8 @&#H77H#]$$6$$%,&#H77H#rK8 $$6$$nKr&n9$7HH7$9$6$$6$&9$7HH7$9nKr$6$$6$@&#3!5'35'!53>4&' 373353@&@@@  5v @@&@@@@@@@@@ !!!!">-@@r_e\%!.'5.'!!.%>3!2!5&>35@ &&&  _r &&@@&!  \e:C.4&".&7&'#!67.'&667."&462m D g a1( (Q/00*'.=&?6=."./.4/.&'&6&676'&6'&6?66&664'&6#&.676>>7..67226&?>7>"&76.67&'.'&76٣'%     F   (* #      & $ &6 0 6 %I($ ?    K%  ! ٣'?        $ D+ %          !  -  ]  ̌A .   @'7#33#&"27647&"27647&"276@@    %5  88  K  ^^  p  4  (f  ;;  O̪  bb  w213#!3@@  @@5 @O:C&671>.1'.7>#.=4&'"&'&67676.>Ӱ̯6l4$PT ʇ   '8Qy ^Q+S3Ce ̃76767>'&'&/&?6?6766767>767>'&'&'&'&'&'&76?6?6676767>7>'&'&'&'&  % -      ( +       % -      ( +      %       + (       - %       + (       - %77757'1%7 `@gPW+o+~f#""o"o #3#'3#'3#!!5!5!3.'!!>7@@@@@@@@$$$$@@@@@@@$$$$AA >?!6?!7 9BI84Ņ@@;gDFkN2Ņ)267.'>74&3>?67.:M  2*+2-=69=-3+*2  M_37.7'7)5!?$$$$%@$$$$A@@"!!>7.#5'#373'3533**l**k``````****{{`!5@ '#3!'3'#37#7!#35'7%#5##3353'@@@ࠠ@@ࠠ@@@@@@@@ !!#!'#!@@@q11@AQ!54&"!!>7>75.#!"&532653265326537#!"&=463!2$$$$$$[@@@@@      $@$$$@$@$!   P   @ALY>7>75."&=4&""&=4&""&'5.'5267'"&'>2'1>7.$$$6$$,,$R~~~~R@٣@$6II6@$@   $$@$ss$####I66Id]&.776>'.7'&476&$+0{4K;gkMDAK!$ o.E6K)@c|*.[EiA7.%46;2#!#5!3!53##$$$$% ` @@B##B$$$$1 0@@<Ii'&"'764/&"&"/.2?64/&462764'&4?6?>'764"/&47>2?2?3.Z-300o*R?/.  Y11//2  Z12./ `U) 0L;*2-[.2* s-Z.30 )U`11  Z//11 3  Y/011  ?Q+o003M )2.[-3*:CLU"&46!733>7.'"#.'>726733>7.'"#&'5!>2"&>2"&@$$6$$lR@Q:$6II6$:*1lR#I6$::$6II6$:Rl$6$$6$$6$$6$@$6$$6$Rl#I66I#U5Rl:$6I##I66I#lR@$$6$$$$6$$ -6%'2675'&2754'>&'57%64'"&462ڐ o p 9$$6$$L0000L#q$$]b4 \_b>74&##'67>/.#.'".#"2676&+'>7#!4&'#.'#"2676&!#77#%#& #hh#   ii '.'33>'&'67>5&'67>54&'67676=A&o c +! " D01,:#\ Dq5  }& ^!T'38G+(2  ,   #  }AC#&&>&'.'.367>7>36A&o c +! " D01,:#\ Dq5  & ^!T'38G+(2a  +   #  #*")!!>7.'!>7.!!# !J@&&@ && @&&@&&L2&&2L&@&@@:I@&&.5= EP +X  L       6 VF & (c) 2012-2015 GitHub When using the GitHub logos, be sure to follow the GitHub logo guidelines (https://github.com/logos) Font License: SIL OFL 1.1 (http://scripts.sil.org/OFL) Applies to all font files Code License: MIT (http://choosealicense.com/licenses/mit/) Applies to all other files octiconsRegularocticonsocticonsVersion 1.0octiconsGenerated by svg2ttf from Fontello project.http://fontello.com (c) 2012-2015 GitHub When using the GitHub logos, be sure to follow the GitHub logo guidelines (https://github.com/logos) Font License: SIL OFL 1.1 (http://scripts.sil.org/OFL) Applies to all font files Code License: MIT (http://choosealicense.com/licenses/mit/) Applies to all other files octiconsRegularocticonsocticonsVersion 1.0octiconsGenerated by svg2ttf from Fontello project.http://fontello.com       !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~heartzap light-bulbrepo repo-forked repo-push repo-pullbookoctofacegit-pull-request mark-githubcloud-download cloud-uploadkeyboardgist file-code file-text file-mediafile-zipfile-pdftagfile-directoryfile-submodulepersonjersey git-commit git-branch git-mergemirror issue-openedissue-reopened issue-closedstarcommentquestionalertsearchgear radio-towertoolssign-outrocketrssclippysign-in organization device-mobileunfoldcheckmail mail-readarrow-up arrow-right arrow-down arrow-leftpingiftgraph triangle-left credit-cardclockruby broadcastkeyrepo-force-push repo-clonediffeyecomment-discussion mail-reply primitive-dotprimitive-square device-cameradevice-camera-videopencilinfotriangle-right triangle-downlinkplus three-barscodelocationlist-unordered list-orderedquoteversions color-mode screen-full screen-normalcalendarbeerlock diff-added diff-removed diff-modified diff-renamedhorizontal-rulearrow-small-right jump-downjump-up move-left milestone checklist megaphone chevron-rightbookmarksettings dashboardhistory link-externalmutex circle-slashpulsesync telescope microscopealignment-alignalignment-unalign gist-secrethomealignment-aligned-tostopbug logo-github file-binarydatabaseserver diff-ignoredellipsis no-newlinehubot hourglassarrow-small-uparrow-small-downarrow-small-left chevron-up chevron-down chevron-left jump-left jump-rightmove-up move-down move-right triangle-up git-comparepodiumfile-symlink-filefile-symlink-directorysquirrelglobeunmuteplayback-pauseplayback-rewindplayback-fast-forwardmention playback-playpuzzlepackagebrowsersplitstepsterminalmarkdowndashfoldinboxtrashcanpaintcanflame briefcaseplug circuit-board mortar-boardlawthumbsup thumbsdowndevice-desktopflipper-0.21.0/lib/flipper/ui/public/octicons/octicons.svg000066400000000000000000002524761404600161700235370ustar00rootroot00000000000000 (c) 2012-2015 GitHub When using the GitHub logos, be sure to follow the GitHub logo guidelines (https://github.com/logos) Font License: SIL OFL 1.1 (http://scripts.sil.org/OFL) Applies to all font files Code License: MIT (http://choosealicense.com/licenses/mit/) Applies to all other files flipper-0.21.0/lib/flipper/ui/public/octicons/octicons.ttf000066400000000000000000000757741404600161700235410ustar00rootroot00000000000000  OS/2|SA(Vcmap7eP:glyf``ehead 6hhea $hmtxQ/loca%Y Njmaxp nameg3mpost8Jsp@@\ ( )dt_< @$@$= )G L2PfEd@&e|@@\G@@ (@4B@&e& $(JOSnx|&e&#&*LQVp{|ٜa7nxDR@&:h*x P r 8  D x 6(>|  4H&4|4z8Zhv$T~*b"zV,@TlVhv"Vt : !!6!^!"J#v#$$$%%2%v%&r&&&&&&&' '"'<'X'f'((D((*4****+4+B,h,,--T---. .,./(/j/0V01412622W@C65'&7.&67>7.'>7.7462>767&hG%&(%X ZDI>>IDY@ڬ6 _F+  ,7 " g*D H6 // 6H D*g /$ 6!$6'.'.'o d[[dd[[d o 7>75>7.+"&=!7#575'''#5.'.5>7'577D;$$$$;D@%@@@@@@@@%"mm"ހ@@@@O,$$$$,O# ,:@@@@@@@@:,"V1mm1V9@@@@@@@@,048#35#3%")375!>7.!5##.=!5!!#35#3@@@@@:&&``@&& ` @@@@@@@&&``&& @@ `@@@@@?@-6?H.''5>5.'>74&'55>%"&46.462"&46H77H##H77H##H77H####4####4####4##7HH7$:jj:$7HH7$:m:$7HH7$:m:c#4##4##4##4#z#4##4#@@ :33335#7#3%")375##"&=!5#!#3+3>7.@@@@@:&&`  `&&@@@&&@`@@@` @&&@@#159=A'!!!!35.'!375!>7#!5##.=!#35#3#335#@&&&``@&@ ` @@@@@@@@@@&&&``& @@ `@@@@ *6BJRZbj#!..'>76023>5>4&."462."462#!.'#!.#!.#!.#!.%&Pz``z}zuIZZHvzzej``je``%&%&'&%&%&%&%$$2 $  $ J2  !!E  !!&%&%%&%&%&.8B>'6.".7$74&&'4676>>4&%>4& kr0efe0rk ',,," 4dd4 "!++!!++!!++! ,+*qSJ  JSq*kAAkI%C+  +C%IG@aAAa@@aAAa@@&@IR%4=.'#"3553>74&.462>74&'>5..462.462rK8 @&#H77H#]$$6$$7H##H77H##H7$$6$$$$6$$nKr&n9$7HH7$9$6$$6$?H7$9]9$7HH7$99$7H$6$$6$$6$$6$W@C65'&'.'.77>7.'>7.7462>767&^H)%X ZDI>>IDY@ڬ6 _2 0 & 7 " g*D H6 // 6H D*g /$ 6!@DK".'&#675.'.'>7>767"'#"'32673>7.##7#@ &ee& RllRD(&:!.6II6&aa'6II6C).PRllXjjXlRRl %A."I66I\vv]I66I%A"lRRlK333".'&#675.'.'>7>767"'#"'32673>7. &ee& RllRD(&:!.6II6&aa'6II6C).PRll@XjjXlRRl %A-#I66I\vv]I66I%A"lRRl !%)-159=AE35#7#3'#335#!5!%3##35#35##3#3#3!!!35#'#37#335#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ 7'!!!'7'``@ @``@``@@@``@ 7'737'!!!! @``@``@`@@````@@ !!!!!!!5!5!5!5!5!@@@@ @@@@@ !!!!!%3>7.'>73'@@H77HH77HH77H 7HH77HH77HH7  !%.25#75#75#35#!!!3353%35#35#!5'#5##5@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@->!!%!76!367>7>72635.#".'>'6@@  ` >!+?>l>'&yUF+-_/+]  -$+R+ ! &o3!2@) &&&'   &&@@&  6@"+.'#!.#546732"+.'5.'!!>7!35.%!5>3!@!KT&&& @ ) &&@&&&'  @&&&?    &&&&&  @@ *@.'>.'>7+!>75.5###.'5>7!lRRllRRl6II66IIJ6II66II $@@$$$RllRRll.I66II66IAI66II66I$$$$@?@*26>B#"&5#!>7&7"&5>'3>'337'#3%37'#31`/)[&&[) V-@D^^D@& @@ @@@&&l &&@ l@0KqsK-@ @ @ @ @.'#3>735.'>7[[[[@6II66IITjjTTjjTI66II66I2;DM>5.'>7&'>3>75>5.'%"&46.462.4627H#L2O1#H77H##H77H!0$d#H7$$6$$$$6$$e$$6$$H7$92L/9$7HH7$9]9$7HH74#d9$7H?$6$$6$$6$$6$$6$$6$.7@I"&#.'>5.'>74&'573>7..462.462.462$: e$I66I##I66I#@[:$6II$$6$$$$6$$$$6$$$~_26II6$9\9$6II6$9DP#I66IA$6$$6$$6$$6$$6$$6$@@ 5!7'! %5#3@@@@@ >7..'>7'35#53#Ȁ=@@5%#.'67#52675#35##3%.'"'3'>33>(a6["Q:&Q:&?(a6["A"E;c2r?7/&@@7/&?"E;c2r#''.'>727.'>7#335#``Cs,[>]|B``?2,[=En7 % %LJ;;))ѕ/@!73!>7.1Mp@@1MLL2\(O/1M@@".%35#3>23>7.67&.'>7@Kr&2&p2LrK!!ڣ٣@rK&&,(,JGIV!!٣٣?%."3!2>4%#535#3J " JlJ ""/@r@#!'>5.'2672?64%.'>7٣٣7d,$@ mmm,d7٣ @$mmmm +>7.'#/'7/5?'7?37E[[EE[[;HvG(f,FtH8|{:HvG(f,FtH8|[EE[[EE[FtH8|{:HvF)f,FtH8|{:HvF)f,'3GSVZo64'&4764&"2&"264'.46764>7.'&"27>4&1"'37!3#7327>4&'&"3    ! ---, ! ! ! `,;;,,;; ! ! ! ! ,--/(%_76_$(@@@@L     ! !V" " FJE r /sys/ " "TYT" ";,,;;,,;E " "TYS# " /szs@@@@ ! '+! " FJF ?A".7'7'&6.'26764''?"$8l Wp8| z{+.w֕&'&S29?Y 8o!$%:s :tZ}!9t-/i U!#;"\B:@@%!%!3!5!#-!!@@@@@@@?A&767?67>7.7>'&7o5C+һ!;XI!>C5S#=2>KD1'S5D=!IV;"ҳ+B5p62=HDKg' /7>7.&7>'.&7>'6II66II688m..٣88!..I66II66I--m88..88i@26=AEI!!3.'#.'#!>75#2;>23!>35#%5 5!535#!!#3@$H77H$$$@ "$$6$&@'#$@@@@@@$7HH7$@$$@$6$$6$$"@@@@@@@@5!5!55%!3!5!#!@@@@@P@@%;Jkt#!!!5!5>=.##5>73%5###.'5>7!7##5>=&'3%2673>7.'.'&'26%"&46'.'>.462@6I##Iʀ@$R#@@@$$$$@#R$JXJ:%6II6'eFFe'6II6%:$$6$$6II66II$$6$$I6@$9Ҁ9$@6IA@$#$9.$$$$R9$#$$$$I66IASSAI66I$$6$$6$I66II66I$6$$6$@@'+!!>7.32+"&46#"&46;27!!@$$$$@@N@@@$$$$?@ '353'33!'35!!5#7!#!5'75##7#@@@@@@@@@@@@ '  ! 777'7@@BB@@@@22@@"!!'#3%5#'#!%!%777'7@@@@BB@@@[ee[ @22@ 3!3@ !!@@!# @@5 5!@'7".'5467674&">7.@@B# YY #(yy'@.2@ )55) @2/:XX:/@ )26:!!!!6?>76&.'&376&>.6!5!5!@@'A_ L@2QQ2@L _AW@k#)4F)3#3)F3)v@@`@AL?..?LA@` )F3)F43F)4F)L@@##3#3535#535#535#535#535#!5#3@@@@@@@@@@@@@@@@@@@ %=735#35#7!!'37#35#")!>7.!.737#5&67!!@@@@J@&&&&  @ @ @@@@@@&&&&  !7'#5'7>7..'>7@@@@@@@@@ ! !7!#@@@@@+7Q>7.''.5>3>75.'7.'>'.'>7>7.m'!ERRE"&-#%%#@H77HH77H g|٣|g m4[#]+WW+]#[4m$##$7HH77HH/H/w٣w/H/ᗾ%3753535373>7. 5%.462my@@@@Gm@@$$6$$my@@@@@@GmmC@@$6$$6$@@,7.'!54375##"&=!5#!#3#3>73 3333&&&`  `&&& &@ `@@@` @&@ @@)-1HQX_c#3!5!375!>75!#!5##.'5#335#"+3753>7.0+"&=3#535!36#3@@@&&``@%` @@@@@&z&&@ &&  @ @@@@&&``&@&6 @@ `@@&&@ @&&A @@  @@@ !##33535#!5!!!3%!'!!@@@ -"6$7&$.'>7"&'>7.MJ  JM╓ғғ6)- lRRlldddd -)6RllRRl+4=#373>75#."+353>7.&&@%2L>.&&@&&@6BH&&&@L&&&@& 56.'\2z@ >7.mmmm@mmm@%!!@)-?KN27.'>765.%#'!#!>7.%3!"&535#546;7.'>77'36II6 $-6*%4IJ$$$$@@@ KKKjRllRRllI66I4&)6-$ 6I$@$$$A@ @  OOK@lRRllRRlޠ/BF`lp264&54&'>5.'.##3!>753%.'>7#.#53&/&+"#"&5463!2'.'>7'57@$$6$$%lRNjB%RllR@@$$@@$$6II66I@$% Ks@6II66II @@$6$$6$@"B&RlcLlRRl$$@@$6$@I66II6$  J@`I66II66I@@77'#533@@@ 2>4&">7..'>7.'#33>7#$$6$$H&@&@&@&@$6$$6$?=&&&&  @;#3#.'67#3>7.#.'>73365.'#3.%; 3KM11Mdd3KM11Mddd%;@HL22LL2#!dddL22LL2#!dddH #!!3!5 !5!5!!5!  7'7```  ```@@`@@ 67..'>7@}}7HH77HH@cCH77HH77H 35#535#35#%!5!5!5!!5! )!5!!5!!53#76&'"63235>7@@@ON$U+n%=+$4;[&02y0B 5(2C8#H !#&675&5&!#&6(\p(@pP8Pp 535#35#%!!!35#35#@@@@@@@@@!!@@ 7!!!!35!3#!5#33#!#@@@@@@@@@@@@ #35#335#5#353535#%!!!!@@@@@@@@@@ #'+/;?CGKOSW#3'#3#3#3#3#3%#3#3%#3#3#37#3#5!#5#!!!#3#3#3#3#3#3@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@!%04"+5.'>753>7.#3#3#3'.'>2#3 LTǕǕ&$%@@@@@@_@7HH77HH7%%@$$$$?@@*3"+5.'#!>7.!!!!!!!!5!5>7dd@&&&&@L22Ldd&@&&&@@@@@@@2LL2 -##33535#")!>7.!"&5>3!2@B&&&&Y  @ &&&&! @  #!!>7.!"&5>3!2!5!@&&&&Y  @ &&&&! @  +!!>7.!"&5>3!2>7.@&&&&Y  @ 7HH77HH&&&&! @  H77HH77H&!!>7.!"&5>3!2#3-@&&&&Y  @ &&&&! @   !33##5#3!5#75##535#3535!5!@@@@@@@@@@@@@%#3@!!51! !5 13#5 5!@@@@ !!7#53#33#@@"&*.26:'&"'&"27647627!!'&4!!!!#535#535#533#2 ] 2 2"["r@\ @@@@@@@@@@@@<1]1 <2 l@@\#Z@@@@@@@@@` $4?7'>7.&'&5>727&5467>7.&'&7@Kd6II6@@@-ARll3;E$:3Es2 6I qmm `J٣A#Fv#c;##!%7&'>76&&74&"26264&"%"264&4&"26264&"%4&"26.'>727.#>7&/??//?@6C] M`E 2Z&O5I뱱p?//??/ KC7N s $Z(-=*/뱱_"-"'!'>3.'67#5>7.7537'#'D}5JX"L*["A@@@@'#JXE;c2r??@@@@ %!75!!#7@@`#33#''77'@@@``?_`@``@`@``?a`@``@` ''77' >7.2&5>&'7@*#lR*%l}#*Rl$*Rl ' #3735୍FȘ::f@fsF`!'7%7$7>'%>&&6 &/JSK&Tvk<7JSK&Tvk<7q &g?x0F4I'QTe7&'0?.671.'&677#37353 .7>7>.>L#\fC<K1' S  7HI5 8iT n. ,A+4@@46  Si;  X$4(4(@@)2>74&'65.'757'#3?!3#!/>2"&iBT%D;@@@@@@@`*5Rl!$6$$6$@iG,KO,@@@@@@@`R5lR@ `$$6$$@ >7.7!77RllRRll@ lRRllRRl@7'77@@@@@@@ 7!%!!%#!'!!'!A@@,@@@@@f&@@@@333#'3 3@@@@@@77!7.'>7 @ RllRRll@lRRllRRl@@!!!!3#35#@@@@@@t>56&'>'.&"&'&&'54'7>.&'.7.'&'&776767672676&'&'67>&%QOPP$2,2   2 U<  1 1 $%!1 1> "" )G*FO^~#3#&'>6?4'.'&7>56'&##&'#3673367&264&&'#";265#&7&'#7>7367&%5&'#326?67..'5>76%"#"&=36=4'#54+&37>=4&)  ^%+7juJ ` %)-!'!!#3#33#535#53%3#535#53#3#3@@ @@@@@@@@@@@@@@@@@@@@@@'5@.'567 67'.'547167 671'.'5>7267. "" ٣""٣٣٣mڐI6*55*6II6 *55* 6II66II66I?$$$$/37;?OSW[_c!!>75.#53#53#53#53!!>75.#53#53#53#53!!>75.#53#53#53#53#53$&&&@@@@@@@@$&&&@@@@@@@@$&&&@@@@@@@@@@&&&&&&&&&&&&@@%!!>7.!"&5>3!235#@&&&&Y  @ bb&&&&! @  bb"#!!>75.#53#53#53D2LL22LLNL22LL22L '#553675%>7.>72&"'78___I6  I. ____6I d 6I1CR!>7&#"&46;2%#.'!#.'>7'"#!!>75.#'#'537373L22Lz&&&&<||<2&&&&@@@@@@@@@@2LL2!&&&&hbuub&&&&@@@@@@@@@@@"-=H>7.'>7."&'>>7>5.'267.".';Zj٣kYZj٣kmڐ-s M379i5J5¡5J5OO973M ~t@Bo7HH7oBBo7HH7o$$$$?, Ux./a*m@@m* /.yT-33535##5 535@ 77@@' @@@' 7'@ 3#@ !3@@@ 53!3 !5@@@@ #!# !5!@@ 3!! @@@@@@ !@@@&DM%4=.'#"3553>74&.462%"+.'>5.'32#7'.462@rK8 @&#H77H#]$$6$$%,&#H77H#rK8 $$6$$nKr&n9$7HH7$9$6$$6$&9$7HH7$9nKr$6$$6$@&#3!5'35'!53>4&' 373353@&@@@  5v @@&@@@@@@@@@ !!!!">-@@r_e\%!.'5.'!!.%>3!2!5&>35@ &&&  _r &&@@&!  \e:C.4&".&7&'#!67.'&667."&462m D g a1( (Q/00*'.=&?6=."./.4/.&'&6&676'&6'&6?66&664'&6#&.676>>7..67226&?>7>"&76.67&'.'&76٣'%     F   (* #      & $ &6 0 6 %I($ ?    K%  ! ٣'?        $ D+ %          !  -  ]  ̌A .   @'7#33#&"27647&"27647&"276@@    %5  88  K  ^^  p  4  (f  ;;  O̪  bb  w213#!3@@  @@5 @O:C&671>.1'.7>#.=4&'"&'&67676.>Ӱ̯6l4$PT ʇ   '8Qy ^Q+S3Ce ̃76767>'&'&/&?6?6766767>767>'&'&'&'&'&'&76?6?6676767>7>'&'&'&'&  % -      ( +       % -      ( +      %       + (       - %       + (       - %77757'1%7 `@gPW+o+~f#""o"o #3#'3#'3#!!5!5!3.'!!>7@@@@@@@@$$$$@@@@@@@$$$$AA >?!6?!7 9BI84Ņ@@;gDFkN2Ņ)267.'>74&3>?67.:M  2*+2-=69=-3+*2  M_37.7'7)5!?$$$$%@$$$$A@@"!!>7.#5'#373'3533**l**k``````****{{`!5@ '#3!'3'#37#7!#35'7%#5##3353'@@@ࠠ@@ࠠ@@@@@@@@ !!#!'#!@@@q11@AQ!54&"!!>7>75.#!"&532653265326537#!"&=463!2$$$$$$[@@@@@      $@$$$@$@$!   P   @ALY>7>75."&=4&""&=4&""&'5.'5267'"&'>2'1>7.$$$6$$,,$R~~~~R@٣@$6II6@$@   $$@$ss$####I66Id]&.776>'.7'&476&$+0{4K;gkMDAK!$ o.E6K)@c|*.[EiA7.%46;2#!#5!3!53##$$$$% ` @@B##B$$$$1 0@@<Ii'&"'764/&"&"/.2?64/&462764'&4?6?>'764"/&47>2?2?3.Z-300o*R?/.  Y11//2  Z12./ `U) 0L;*2-[.2* s-Z.30 )U`11  Z//11 3  Y/011  ?Q+o003M )2.[-3*:CLU"&46!733>7.'"#.'>726733>7.'"#&'5!>2"&>2"&@$$6$$lR@Q:$6II6$:*1lR#I6$::$6II6$:Rl$6$$6$$6$$6$@$6$$6$Rl#I66I#U5Rl:$6I##I66I#lR@$$6$$$$6$$ -6%'2675'&2754'>&'57%64'"&462ڐ o p 9$$6$$L0000L#q$$]b4 \_b>74&##'67>/.#.'".#"2676&+'>7#!4&'#.'#"2676&!#77#%#& #hh#   ii '.'33>'&'67>5&'67>54&'67676=A&o c +! " D01,:#\ Dq5  }& ^!T'38G+(2  ,   #  }AC#&&>&'.'.367>7>36A&o c +! " D01,:#\ Dq5  & ^!T'38G+(2a  +   #  #*")!!>7.'!>7.!!# !J@&&@ && @&&@&&L2&&2L&@&@@:I@&&.5= EP +X  L       6 VF & (c) 2012-2015 GitHub When using the GitHub logos, be sure to follow the GitHub logo guidelines (https://github.com/logos) Font License: SIL OFL 1.1 (http://scripts.sil.org/OFL) Applies to all font files Code License: MIT (http://choosealicense.com/licenses/mit/) Applies to all other files octiconsRegularocticonsocticonsVersion 1.0octiconsGenerated by svg2ttf from Fontello project.http://fontello.com (c) 2012-2015 GitHub When using the GitHub logos, be sure to follow the GitHub logo guidelines (https://github.com/logos) Font License: SIL OFL 1.1 (http://scripts.sil.org/OFL) Applies to all font files Code License: MIT (http://choosealicense.com/licenses/mit/) Applies to all other files octiconsRegularocticonsocticonsVersion 1.0octiconsGenerated by svg2ttf from Fontello project.http://fontello.com       !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~heartzap light-bulbrepo repo-forked repo-push repo-pullbookoctofacegit-pull-request mark-githubcloud-download cloud-uploadkeyboardgist file-code file-text file-mediafile-zipfile-pdftagfile-directoryfile-submodulepersonjersey git-commit git-branch git-mergemirror issue-openedissue-reopened issue-closedstarcommentquestionalertsearchgear radio-towertoolssign-outrocketrssclippysign-in organization device-mobileunfoldcheckmail mail-readarrow-up arrow-right arrow-down arrow-leftpingiftgraph triangle-left credit-cardclockruby broadcastkeyrepo-force-push repo-clonediffeyecomment-discussion mail-reply primitive-dotprimitive-square device-cameradevice-camera-videopencilinfotriangle-right triangle-downlinkplus three-barscodelocationlist-unordered list-orderedquoteversions color-mode screen-full screen-normalcalendarbeerlock diff-added diff-removed diff-modified diff-renamedhorizontal-rulearrow-small-right jump-downjump-up move-left milestone checklist megaphone chevron-rightbookmarksettings dashboardhistory link-externalmutex circle-slashpulsesync telescope microscopealignment-alignalignment-unalign gist-secrethomealignment-aligned-tostopbug logo-github file-binarydatabaseserver diff-ignoredellipsis no-newlinehubot hourglassarrow-small-uparrow-small-downarrow-small-left chevron-up chevron-down chevron-left jump-left jump-rightmove-up move-down move-right triangle-up git-comparepodiumfile-symlink-filefile-symlink-directorysquirrelglobeunmuteplayback-pauseplayback-rewindplayback-fast-forwardmention playback-playpuzzlepackagebrowsersplitstepsterminalmarkdowndashfoldinboxtrashcanpaintcanflame briefcaseplug circuit-board mortar-boardlawthumbsup thumbsdowndevice-desktopflipper-0.21.0/lib/flipper/ui/public/octicons/octicons.woff000066400000000000000000000425541404600161700236730ustar00rootroot00000000000000wOFFEl {OS/2DV|SAcmap8:7eglyf$8e``head:16 hhea;$ $ hmtx;DQ/loca;jj%Y Nmaxp=\ name=|g3post@,@8Jxc`datB3f0b```b`ef \SR?0;w`avg8 fJ x+qg)Eѷ\(%)wW9*&GvqRNNR.ݔR26z6|'p٫wˣfxmn7= %…pd,L3"YeMa93. V;"`!BFb9Xf7qʹ7(Qo}Szכ^zѳjIAӭnTSʙǿƥCzux| dUyݪk[ꪮfzf=3,L3 â((EF ğh 3hg%B6ĸL~wν_|鮻{Y}o9GIZHdK.IIz:nۭVL^kyH3xYll79H5=fh'ӝvSUvM «wˉ*sjm]!_v-رw޹;-KIģߑ-=1L[6 `@;ɔBڵȆaLPv؃j Ǟ+?^YlI2$IX,ki!2Ǐ=b9Yx[v^ [-a`Y6R񢘀: 9 S0%iXH0hlBlH9[QNjPmbʭN ,PB|-1;)& ;6C%?;[mxԉA`-K7\6+K<=wEF?-Ghofbg7MK<0W=$E&ڑ^)ڷFx`oru25Z^\K&"I0ە(-,raWDsϼN`v/b"I^2OJmi^ڃL)l9h^C*Jޙ[!<$RmͶ餖t@98=\.ϖ0,/s0l2776Uzs_&~jfg~;NzѤ6a=妁-yYPwpblSD1p۶]ǶG@m'"6ϤɵRj Yţ],$Q޲4#s*⿎T4]S|E5S 7qm6D_k)꣫[L* HO``K=HI\Y$i94M'.qe/P&چ5 cUt(,{YԽl A;c;su{F #=i_ lŌyHM^vb@%k w8Rѵd[']S}Í_caLX̸;ñ0|Ηx)9S{~hO6diDB [rmM 3F1J+t*hlڕ7'a$$]֪3pnuÉkNlȇ ]V&܉ǿ >~óM"n@bab rX}Q-c7, M{ 7tWn*Jp|d{{a7\Xb3 T,i/RTJ\jt$E|@pgu0O;a|!8D=tyRiW*qʬx >Oom]ks|tc@ y_} x_yKNy'/u.;\r'{45o DȆ#?&s~aɃ(w*Hl*H`.t4y elvʲ=qZ$rFn<4ޱ(Nm^y|]\b26&䋯bs F/'./OzhUYKy)qsyQeFI:`+WxkUjJg@I )(U-V%Nl O=$#6 XDōd,(2"OotM J$lxrYglF!kQPDS: .2fw]%PA)5p:%w KҒ[%yq8Ŗr-~"iPJ`&=Q7D/,cʜ.DezyRl`Bb_?pzW3:L"mr/(6E{hU?5:Տ؂m̰+ꫣGySRKmD7~ɚfTU/ʤm@DE$ARXRx*ϨmRGÜ>"׼Mu:m(6P^M@\ޮh.~8BA\ E抐8WHz wǵc->9\O3/ֵDvrˮ/\7oPv.cq55-QUu+^Zѕ,lon4 ЩvL]&TL7uR <3΢ld /ۇ;H< 3\r]]fM ҵl$$*Se8!F."pR6)[(a2܄e~" g6< D""}y] RB$R`izAL&[Gcwa`&@z;}tN7|_IDWdnUG^o H&"tq [s*Q ,?+OQ#B>W cum"-{DHt\T< ]7Icm;sI%mL\M=J̭sT)uBaⴣ?JȨ3WjR؎+s P@.g2vN-sᮧ&2+ Xyp- m[~Y)a/hYsB(nѣ/;V_o}zƮ?tqԐ6#V;Wn@,K7hn ==|@Yx{1r_ 'đLt?۳帩~1#EDwWD6qtMx9!p^Tc֝kg5* Ǹ1.[/rUe}_zi3 `k~}0qB&AmRמ9n spevp0/aGMK%fìo2)ȒٖP'#H3ZQy* P|QtǟS{#LAzbDl4}9ƽyUWe d@V _ K/`)aĺ+ #_F t[bHRqynC{WiE6+`/~JX;J糿DhS:?^׎UU7z Ӡ;КGǜ( EpveΝ($߼뮙=sozcnS%ݾ$֯y/%]8@KMitXzttמf;ZI֬tC^=ŇĂ1-jIMN:A`Tٔ5,~%xTr5QVf8<:"Y)''''"LX(ð8`z3;c@Ǿ\Y'4`/Kt).e¹]vpk8<}M3H%Gp '|k;osNvPb% f:XO1Add׾]#aI^<iƾD=fnƠ(O5c=QGv<*oL#noOǰb9/1(@j#,VnKC^{i.fUر-F~.Li磇[;Eֺ̳[4?<\/kdͣ lڻNa݇F8Yrj*p;-}O?`q/R>("%=q:GOPDH*wDxXGGeFMqOF~XmEUlWSE<3d}##=#doR2TVZ `+VW%q~$iz:fUD =2)$I <~JR Y=*"' VNBb2EE )ҤV}~#mWZ|l(Tؓ= ^6.6zFs2p8&98d]kY6&2E_]oTUصĠosn>r0.AZ]gZJ\Sgxx8"´6h2:ZM.8[)+>~&\粮G7}/:LtG~"1=KY<@㼰ZIgCϯ`լM3ԋ)TeQe2VuZPNHβZpdž d=8jޑLՎoT,gIA??#z\11J  |A's%;2XgXm.@jU<Q #. %Y{ tn-Kվ0>Yx-rHw7 >q `_>7hToMb$djgKُ!,مA &E6vb򞕮DVcsG6y^O"Et ҠYR$s Ls2D&\K 3[;x@ݦa*HYádf}> ZF46=b.ޔy%뼏 z.6 OvLhjtZj `NGY8 k癆r35쇆 ǂqA:}A:-\dP^ZhPnv2R#-0Y ,Z 4m~jj^,XP'2OkvIm|ns 0v9nj+k7Ŕ0E8gVFQu8] H'^Dm$KZS* GRUGM7; /FZ#v$Z*l!Zb}\:({&@g3D`Oe O]saH,WUx\qPeg~E5Jp;ة[uK4|Y}=Oc CLr}M 7\PGM Igl\6Icp2bX~L tËb)>zKګ 8@hph`&?89R&>*(X}SXo}kĄtm'EaMG˗?pP/z`ٛt@+DZZq=9y๻ "HBRqED*#*ߑ< *(wu k$ZI :Ɂ+96w2ŵ]m%r8){I*zZ1ەNϋdRȞ]յbG? }/>gg%lf }=4-+sK{ְsaהP]@!U~~3%Ovd$\m.iQQמ|5UoVS(lwzu&Aa֦DC3 ej69@S iO%#i3Ewl{8 Rm`mj? !?l,im5h,BIMz[c'`,;1  {)&ْ9 rFx;9x*჈.2I[xB C7ҷO02QmFy]ڍ2tXH`dPyL/ΏΫ99:b Unq+=74~\Z$J/^}mXz.mB,s''ݖ#ʗ~Ks_VlYN1v)=lMtp9uL@%4x(yл1^LlM9DRgk (7an.8:aQBXS^Ҵ`k +..}lzyߊtq"իj/gQN a{[FLfZpuG{M`j8!,gZ|Rci6T$|qE]AUIiuzaZbEH/NF(uێ@uT=Gnq(PEo~=:xzbPl}@t[خ9{DZP@3[r%(Y5vv_-:H90[ F,$lm |$IcjԋjDQ/8|Ok ܏}s+dSY<<,r8~yn! b>tDvrHuQ+/ƣf;:YjC7\%]捼mݷ!4hvy/!eE(WUc4ayĭ6 ]TbuMtDK--Qfiׁ4Iӝnjb ;i~8Dq; aF@5#h!n-Gh870@h`]C9g](M̆L4X< ^'h(n`O]8eq*ZX$UH(b{ ZcE* "EL-<3ӻ7اGn":RDc4~9"g`RD<&*k}ig43#6j oFסM {B|ϮFի˪f4-5 KQ/f腖E` fߖ FZY3L)=+foo74T/[_âv,jo4;>+Z;>" ;%G1:6P~b lGusC[Auۖgg؍Eh۳N(DowbWbi1gK߫zZCuzf$/FElH W@GDr@05Z:m\(=IBm=7\w*@fZb|ؾq<<72pk'_o;gqD7LM?+nr!릱H2 ;Njg+!S(xֶϲg}4Kn;e6OoČ?[U1Ť6{i85%n"ژgzPYMfNpk]+4Ӧ@~8=j"/֎Z$G} omZҐMI_un};L2zK\,=؉@8/DB0ϗeIT%W-58}IAݟao/oP$1X =NjD"p{)S?GIG݃OIe\ BY"O?#'Gh:_^9]0aؖK|2aM%}Aydzun_]<7OP|` @]tSҴ&ԳK>vp{+E=g/K\mF)Hj JS6M35ZR f&vZ*jH2ӣ5bhE͓R< Yd]yXLύvZk<~ZuW/k:^& [ 0N,P͍J hA-+ D5 *(¢u%Qu c͑YuDɧ~p'@soYsm|̈́xP1l+[]35W?G!u/&,uq{.ɀFXyw zN[$FKJd{^r=Q ]`?M8jXe2hDWC5𠪩`( i90^cym`<z-#1YQ0B,T2SՑ`0<1S=,+!c NUu-roHe s EcdsCf^f@(x/rVM} RHt2_}6,K!F@5Ԫj@G"GL²[eX>v` shd^ku=]4~rܽD}'K'ώwaiuo:Tl-`Z1eAt R_6n}f`뮿ayE^/8!a"lBƃ M=u{7ҝ_<""D@:]@%8"vfdz[/ Hb=a%KjJ(I/Ccm t.@ 5&cVhUd̄X<ԱweY+n][*XCw;3^k"TˤB2x/D,swJڹdi!/H3hU2~(&#10iƟ*f*JR0*-f\kWaCi`i)noW:[h<Ѹ{Իz0dNvM4۟~o"=e"&x>S]B\HxW:&R !Ϲᗛ]x\$)3xK<@_,=qwOO@ݝ!.[2P+s{EЎ96$ 1oV>G9FC˞ظ|z'1XFAOF8D1'+ oݿM0|P<>9GVBN\;s]k':MWTr=]d@4=/z?uֈ [_ha2%kD{ivڙ2ՠPdGmo0}3g<6fzv{eW"q;" .[L4Y[gz_}#/G?=n=rD-UVS>MOҲNNv!Z_3SރnSw///xc`d``b[)%6_Y@a[nMfw $ ,l xc`d``ví&PlicxmR!#l%@JH)(v @P q@_5bl73^$8Dž\9EjwIl%ޓ+3ўśѮ4jƙ>h^׾KA|P1RnxDR@&:h*x P r 8  D x 6(>|  4H&4|4z8Zhv$T~*b"zV,@TlVhv"Vt : !!6!^!"J#v#$$$%%2%v%&r&&&&&&&' '"'<'X'f'((D((*4****+4+B,h,,--T---. .,./(/j/0V01412622xc`d``pA  2CxRMk1@ -<d76C!N!% IZղ&S[ }*_vAfHD~R@l٥ Aq]OFu_<C z|B} I6;<~Ao:=f<ozF:tyA[v^eDž9jfax]\xʔ`ԂTIns8kE)4oTzYaf%j;^TixR$b>M*.*#]HYx/<*Y@O: \<*_<(%RZLekmXNj+ M^W%HTDi}^Z{j,JQOM60&V M LZ]D`yk M i@{Ǻ>1d!3+IPHC2`L y;\Fv ”b.:AdcϑmnH1]}->^F%&O\_9e:EvV{F] FWoyȩuuΡ:c=z<NjxJWO=S^YV*(x{gJty{av{+YcfN LǾ;np"uj+?ܴoћ]ӥ|??zT$xmT#E/d:$ **ȭ\/TWTUO&} Jff}[;cmɵ5t:R1aMa#8c8!8\2|Bi\+|U['3 lb~M{H$7f›(P%}\,mvDž2mfƲlP3ZF2T%TJF+%hINjjRZ⏱eڼ6eאuFN-; SG[i?lEZZkt4/ KrupT& $ع4+Ysm1M*ZQJy3'(:Y̴gM1#߱ %foIal%QI۲`"LZ=1S*fI- $S*9;\,q`M|neE3y+psXX&bu.ȶ[֭wX= bͻ5%L:3WFhnmbXrmCj #Uٶ,ɰº*LxACDI=KպZ,%m)wY+vajcK7b*[nxFz4tLl0Q l] D6 $-(K>>BK٦r >NN-͍Sc^0gK@-h&6QáQ~͆qG5U,8xm<' My/upӘ)?3N&Y-TRQH[)I(q ]= Π5n 6:Z"u#i9b.}255#Tr8}m5T2i3ϥv‹\8,Zn*?nXϔI7&4綨ߟV܉t: l6{0l/"Q=HzlOԈᾑ9֫p:z /gޢ҄i+1P#խ %(fY#ZGz©a;1`:xAiww7 *Za1gQwƥlQi0Z fXԹIBR{݉ 4C!(x S length str[0..length] else str end end def self.pluralize(count, singular, plural) if count == 1 "#{count} #{singular}" else "#{count} #{plural}" end end def self.to_sentence(array, options = {}) default_connectors = { words_connector: ", ", two_words_connector: " and ", last_word_connector: ", and " } options = default_connectors.merge!(options) case array.length when 0 "" when 1 "#{array[0]}" when 2 "#{array[0]}#{options[:two_words_connector]}#{array[1]}" else "#{array[0...-1].join(options[:words_connector])}#{options[:last_word_connector]}#{array[-1]}" end end end end end flipper-0.21.0/lib/flipper/ui/views/000077500000000000000000000000001404600161700172135ustar00rootroot00000000000000flipper-0.21.0/lib/flipper/ui/views/add_actor.erb000066400000000000000000000013241404600161700216250ustar00rootroot00000000000000<% if params.key?("error") %>
<%= params["error"] %>
<% end %>

Enable Actor for <%= @feature.key %>

Turn on this feature for an individual actor.

<%== csrf_input_tag %>
flipper-0.21.0/lib/flipper/ui/views/add_feature.erb000066400000000000000000000015061404600161700221520ustar00rootroot00000000000000<% if params.key?("error") %>
<%= params["error"] %>
<% end %>

Add Feature

<%== csrf_input_tag %>

Recommended naming conventions: lower case, snake case, underscores over dashes. Good: foo_bar, foo. Bad: FooBar, Foo Bar, foo bar, foo-bar.

flipper-0.21.0/lib/flipper/ui/views/add_group.erb000066400000000000000000000020361404600161700216520ustar00rootroot00000000000000<% if params.key?("error") %>
<%= params["error"] %>
<% end %>

Enable Group for <%= @feature.key %>

<% if @feature.disabled_groups.empty? %>

All groups are enabled for this feature which means there is nothing to add.

<% else %>

Turn on this feature for an entire group of actors.

<%== csrf_input_tag %>
<% end %>
flipper-0.21.0/lib/flipper/ui/views/feature.erb000066400000000000000000000307621404600161700213500ustar00rootroot00000000000000<% if params.key?("error") %>
<%= params["error"] %>
<% end %> <% if @feature.description %>
<%= @feature.description %>
<% end %>
<%# Gate State Header %>
<%= @feature.gate_state_title %>
<% unless @feature.boolean_value %> <%# Actors Info and Form %>
"> <% if @feature.actors_value.count > 0 %> Enabled for <%= Util.pluralize @feature.actors_value.count, 'actor', 'actors' %> <% else %> No actors enabled <% end %>
<%== csrf_input_tag %>
<%# Actors list %> <% @feature.actors_value.each do |item| %>
<%= item %>
<%== csrf_input_tag %>
<% end %> <%# Groups Info and Form %>
"> <% if @feature.groups_value.count > 0 %> Enabled for <%= Util.pluralize @feature.groups_value.count, 'group', 'groups' %> <% else %> No groups enabled <% end %>
<% if @feature.disabled_groups.empty? %> All groups enabled. <% else %>
<%== csrf_input_tag %>
<% end %>
<%# Groups list %> <% @feature.groups_value.each do |item| %>
<%= item %>
<%== csrf_input_tag %>
<% end %> <%# % of Actors %>
0 %>">Enabled for <%= @feature.percentage_of_actors_value %>% of actors
<%== csrf_input_tag %>
<% @percentages.each do |number| %> disabled<% end %>> <% end %>
<%== csrf_input_tag %> 0 %>value="<%= @feature.percentage_of_actors_value %>"<% end %> placeholder="custom (26, 32, etc.)" class="form-control form-control-sm mr-sm-2 mb-2 mb-md-0">
<%# % of Time %>
0 %>">Enabled for <%= @feature.percentage_of_time_value %>% of time
<%== csrf_input_tag %>
<% @percentages.each do |number| %> disabled<% end %>> <% end %>
<%== csrf_input_tag %> 0 %>value="<%= @feature.percentage_of_time_value %>"<% end %> placeholder="custom (26, 32, etc.)" class="form-control form-control-sm mr-sm-2 mb-2 mb-md-0">
<% end %>
<%== csrf_input_tag %>
<% unless @feature.boolean_value %>
<% end %> <% unless @feature.off? %>
<% end %>
<% if Flipper::UI.configuration.feature_removal_enabled %>

<%= Flipper::UI.configuration.delete.title %>

<%= Flipper::UI.configuration.delete.description %>

<%== csrf_input_tag %>
<% end %> flipper-0.21.0/lib/flipper/ui/views/feature_creation_disabled.erb000066400000000000000000000003171404600161700250540ustar00rootroot00000000000000
Feature creation is disabled. To enable, you'll need to set Flipper::UI.configuration.feature_creation_enabled = true wherever flipper is running from.
flipper-0.21.0/lib/flipper/ui/views/feature_removal_disabled.erb000066400000000000000000000003311404600161700247110ustar00rootroot00000000000000
Feature removal from the UI is disabled. To enable, you'll need to set Flipper::UI.configuration.feature_removal_enabled = true wherever flipper is running from.
flipper-0.21.0/lib/flipper/ui/views/features.erb000066400000000000000000000052051404600161700215250ustar00rootroot00000000000000<% if @show_blank_slate %>
<% if Flipper::UI.configuration.fun %>

But I've got a blank space baby...

And I'll flip your features.

<%- if Flipper::UI.configuration.feature_creation_enabled -%>

Add Feature

<%- end -%>
<% else %>

Getting Started

You have not added any features to configure yet.

<%- if Flipper::UI.configuration.feature_creation_enabled -%>

Add Feature

<% else %>

Check the examples to learn how to add one.

<%- end -%> <% end %>
<% else %> <% end %> flipper-0.21.0/lib/flipper/ui/views/layout.erb000066400000000000000000000057741404600161700212370ustar00rootroot00000000000000 <%= @page_title ? "#{@page_title} // " : "" %>Flipper
<%- unless Flipper::UI.configuration.banner_text.nil? -%>
<%= Flipper::UI.configuration.banner_text %>
<%- end -%>
<%== yield %>
<% if Flipper::UI.configuration.cloud_recommendation %>
For support, audit history, finer-grained permissions, multi-environment sync, and all your projects in one place check out Flipper Cloud.
<% end %>
flipper-0.21.0/lib/flipper/version.rb000066400000000000000000000000571404600161700174550ustar00rootroot00000000000000module Flipper VERSION = '0.21.0'.freeze end flipper-0.21.0/lib/generators/000077500000000000000000000000001404600161700161515ustar00rootroot00000000000000flipper-0.21.0/lib/generators/flipper/000077500000000000000000000000001404600161700176125ustar00rootroot00000000000000flipper-0.21.0/lib/generators/flipper/active_record_generator.rb000066400000000000000000000017101404600161700250150ustar00rootroot00000000000000require 'rails/generators/active_record' module Flipper module Generators class ActiveRecordGenerator < ::Rails::Generators::Base include ::Rails::Generators::Migration desc 'Generates migration for flipper tables' source_paths << File.join(File.dirname(__FILE__), 'templates') def self.next_migration_number(dirname) ::ActiveRecord::Generators::Base.next_migration_number(dirname) end def self.migration_version "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" if requires_migration_number? end def self.requires_migration_number? Rails::VERSION::MAJOR.to_i >= 5 end def create_migration_file options = { migration_version: migration_version, } migration_template 'migration.erb', 'db/migrate/create_flipper_tables.rb', options end def migration_version self.class.migration_version end end end end flipper-0.21.0/lib/generators/flipper/templates/000077500000000000000000000000001404600161700216105ustar00rootroot00000000000000flipper-0.21.0/lib/generators/flipper/templates/migration.erb000066400000000000000000000011301404600161700242660ustar00rootroot00000000000000class CreateFlipperTables < ActiveRecord::Migration<%= migration_version %> def self.up create_table :flipper_features do |t| t.string :key, null: false t.timestamps null: false end add_index :flipper_features, :key, unique: true create_table :flipper_gates do |t| t.string :feature_key, null: false t.string :key, null: false t.string :value t.timestamps null: false end add_index :flipper_gates, [:feature_key, :key, :value], unique: true end def self.down drop_table :flipper_gates drop_table :flipper_features end end flipper-0.21.0/lib/generators/flipper/templates/sequel_migration.rb000066400000000000000000000011271404600161700255050ustar00rootroot00000000000000class CreateFlipperTablesSequel < Sequel::Migration def up create_table :flipper_features do |_t| String :key, primary_key: true, null: false DateTime :created_at, null: false DateTime :updated_at, null: false end create_table :flipper_gates do |_t| String :feature_key, null: false String :key, null: false String :value DateTime :created_at, null: false DateTime :updated_at, null: false primary_key [:feature_key, :key, :value] end end def down drop_table :flipper_gates drop_table :flipper_features end end flipper-0.21.0/script/000077500000000000000000000000001404600161700145365ustar00rootroot00000000000000flipper-0.21.0/script/bootstrap000077500000000000000000000005371404600161700165060ustar00rootroot00000000000000#!/bin/sh #/ Usage: bootstrap [bundle options] #/ #/ Bundle install the dependencies. #/ #/ Examples: #/ #/ bootstrap #/ bootstrap --local #/ set -e cd $(dirname "$0")/.. [ "$1" = "--help" -o "$1" = "-h" -o "$1" = "help" ] && { grep '^#/' <"$0"| cut -c4- exit 0 } rm -rf .bundle/{binstubs,config} bundle install --binstubs --quiet "$@" flipper-0.21.0/script/console000077500000000000000000000001301404600161700161200ustar00rootroot00000000000000#!/usr/bin/env ruby require "bundler/setup" require "flipper" require "pry" Pry.start flipper-0.21.0/script/examples000077500000000000000000000003711404600161700163030ustar00rootroot00000000000000#!/usr/bin/env sh # Exit if any example fails set -e # Run all examples individually for example in examples/**/*.rb; do # Skip examples with a loop for now if ! grep -q "loop do" "$example"; then echo $example ruby $example fi done flipper-0.21.0/script/guard000077500000000000000000000003431404600161700155660ustar00rootroot00000000000000#!/bin/sh #/ Usage: guard #/ #/ Start running guard. #/ set -e cd $(dirname "$0")/.. [ "$1" = "--help" -o "$1" = "-h" -o "$1" = "help" ] && { grep '^#/' <"$0"| cut -c4- exit 0 } script/bootstrap && bundle exec guard flipper-0.21.0/script/release000077500000000000000000000004011404600161700160770ustar00rootroot00000000000000#!/bin/sh #/ Usage: release #/ #/ Package and release the gem to rubyforge. #/ set -e cd $(dirname "$0")/.. [ "$1" = "--help" -o "$1" = "-h" -o "$1" = "help" ] && { grep '^#/' <"$0"| cut -c4- exit 0 } script/bootstrap && bundle exec rake release flipper-0.21.0/script/server000077500000000000000000000004721404600161700157750ustar00rootroot00000000000000#!/bin/sh #/ Usage: server #/ #/ Starts a server for perusing the UI locally. #/ #/ Examples: #/ #/ server #/ set -e cd $(dirname "$0")/.. [ "$1" = "--help" -o "$1" = "-h" -o "$1" = "help" ] && { grep '^#/' <"$0"| cut -c4- exit 0 } script/bootstrap && bundle exec shotgun examples/ui/basic.ru -p 9999 flipper-0.21.0/script/test000077500000000000000000000011661404600161700154470ustar00rootroot00000000000000#!/bin/sh #/ Usage: test #/ #/ Bootstrap and run all tests. #/ set -e cd $(dirname "$0")/.. [ "$1" = "--help" -o "$1" = "-h" -o "$1" = "help" ] && { grep '^#/' <"$0"| cut -c4- exit 0 } export RAILS_VERSION=5.0.0 export SQLITE3_VERSION=1.3.11 script/bootstrap || bundle update bundle exec rake export RAILS_VERSION=5.1.4 export SQLITE3_VERSION=1.3.11 script/bootstrap || bundle update bundle exec rake export RAILS_VERSION=5.2.3 export SQLITE3_VERSION=1.3.11 script/bootstrap || bundle update bundle exec rake export RAILS_VERSION=6.0.0 export SQLITE3_VERSION=1.4.1 script/bootstrap || bundle update bundle exec rake flipper-0.21.0/spec/000077500000000000000000000000001404600161700141645ustar00rootroot00000000000000flipper-0.21.0/spec/fixtures/000077500000000000000000000000001404600161700160355ustar00rootroot00000000000000flipper-0.21.0/spec/fixtures/feature.json000066400000000000000000000007761404600161700203750ustar00rootroot00000000000000{ "key": "my_feature", "state": "on", "gates": [ { "key": "boolean", "name": "boolean", "value": "true" }, { "key": "groups", "name": "group", "value": [] }, { "key": "actors", "name": "actor", "value": [] }, { "key": "percentage_of_actors", "name": "percentage_of_actors", "value": null }, { "key": "percentage_of_time", "name": "percentage_of_time", "value": null } ] } flipper-0.21.0/spec/flipper/000077500000000000000000000000001404600161700156255ustar00rootroot00000000000000flipper-0.21.0/spec/flipper/actor_spec.rb000066400000000000000000000027201404600161700202750ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Actor do it 'initializes with and knows flipper_id' do actor = described_class.new("User;235") expect(actor.flipper_id).to eq("User;235") end describe '#eql?' do it 'returns true if same class and flipper_id' do actor1 = described_class.new("User;235") actor2 = described_class.new("User;235") expect(actor1.eql?(actor2)).to be(true) end it 'returns false if same class but different flipper_id' do actor1 = described_class.new("User;235") actor2 = described_class.new("User;1") expect(actor1.eql?(actor2)).to be(false) end it 'returns false for different class' do actor1 = described_class.new("User;235") actor2 = Struct.new(:flipper_id).new("User;235") expect(actor1.eql?(actor2)).to be(false) end end describe '#==' do it 'returns true if same class and flipper_id' do actor1 = described_class.new("User;235") actor2 = described_class.new("User;235") expect(actor1.==(actor2)).to be(true) end it 'returns false if same class but different flipper_id' do actor1 = described_class.new("User;235") actor2 = described_class.new("User;1") expect(actor1.==(actor2)).to be(false) end it 'returns false for different class' do actor1 = described_class.new("User;235") actor2 = Struct.new(:flipper_id).new("User;235") expect(actor1.==(actor2)).to be(false) end end end flipper-0.21.0/spec/flipper/adapter_spec.rb000066400000000000000000000105551404600161700206120ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Adapter do let(:source_flipper) { build_flipper } let(:destination_flipper) { build_flipper } let(:default_config) do { boolean: nil, groups: Set.new, actors: Set.new, percentage_of_actors: nil, percentage_of_time: nil, } end describe '.default_config' do it 'returns default config' do adapter_class = Class.new do include Flipper::Adapter end expect(adapter_class.default_config).to eq(default_config) end end describe '#default_config' do it 'returns default config' do adapter_class = Class.new do include Flipper::Adapter end expect(adapter_class.new.default_config).to eq(default_config) end end describe '#import' do it 'returns nothing' do result = destination_flipper.import(source_flipper) expect(result).to be(nil) end it 'can import from one adapter to another' do source_flipper.enable(:search) destination_flipper.import(source_flipper) expect(destination_flipper[:search].boolean_value).to eq(true) expect(destination_flipper.features.map(&:key).sort).to eq(%w(search)) end it 'can import features that have been added but their state is off' do source_flipper.add(:search) destination_flipper.import(source_flipper) expect(destination_flipper.features.map(&:key)).to eq(["search"]) end it 'can import multiple features' do source_flipper.enable(:yep) source_flipper.enable_group(:preview_features, :developers) source_flipper.enable_group(:preview_features, :marketers) source_flipper.enable_group(:preview_features, :company) source_flipper.enable_group(:preview_features, :early_access) source_flipper.enable_actor(:preview_features, Flipper::Actor.new('1')) source_flipper.enable_actor(:preview_features, Flipper::Actor.new('2')) source_flipper.enable_actor(:preview_features, Flipper::Actor.new('3')) source_flipper.enable_percentage_of_actors(:issues_next, 25) source_flipper.enable_percentage_of_time(:verbose_logging, 5) destination_flipper.import(source_flipper) feature = destination_flipper[:yep] expect(feature.boolean_value).to be(true) expect(feature.groups_value).to eq(Set[]) expect(feature.actors_value).to eq(Set[]) expect(feature.percentage_of_actors_value).to be(0) expect(feature.percentage_of_time_value).to be(0) feature = destination_flipper[:preview_features] expect(feature.boolean_value).to be(false) expect(feature.actors_value).to eq(Set['1', '2', '3']) expected_groups = Set['developers', 'marketers', 'company', 'early_access'] expect(feature.groups_value).to eq(expected_groups) expect(feature.percentage_of_actors_value).to be(0) expect(feature.percentage_of_time_value).to be(0) feature = destination_flipper[:issues_next] expect(feature.boolean_value).to eq(false) expect(feature.actors_value).to eq(Set.new) expect(feature.groups_value).to eq(Set.new) expect(feature.percentage_of_actors_value).to be(25) expect(feature.percentage_of_time_value).to be(0) feature = destination_flipper[:verbose_logging] expect(feature.boolean_value).to eq(false) expect(feature.actors_value).to eq(Set.new) expect(feature.groups_value).to eq(Set.new) expect(feature.percentage_of_actors_value).to be(0) expect(feature.percentage_of_time_value).to be(5) end it 'wipes existing enablements for adapter' do destination_flipper.enable(:stats) destination_flipper.enable_percentage_of_time(:verbose_logging, 5) source_flipper.enable_percentage_of_time(:stats, 5) source_flipper.enable_percentage_of_actors(:verbose_logging, 25) destination_flipper.import(source_flipper) feature = destination_flipper[:stats] expect(feature.boolean_value).to be(false) expect(feature.percentage_of_time_value).to be(5) feature = destination_flipper[:verbose_logging] expect(feature.percentage_of_time_value).to be(0) expect(feature.percentage_of_actors_value).to be(25) end it 'wipes existing features for adapter' do destination_flipper.add(:stats) destination_flipper.import(source_flipper) expect(destination_flipper.features.map(&:key)).to eq([]) end end end flipper-0.21.0/spec/flipper/adapters/000077500000000000000000000000001404600161700174305ustar00rootroot00000000000000flipper-0.21.0/spec/flipper/adapters/active_record_spec.rb000066400000000000000000000033531404600161700236040ustar00rootroot00000000000000require 'helper' require 'flipper/adapters/active_record' require 'flipper/spec/shared_adapter_specs' # Turn off migration logging for specs ActiveRecord::Migration.verbose = false RSpec.describe Flipper::Adapters::ActiveRecord do subject { described_class.new } before(:all) do ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') end before(:each) do ActiveRecord::Base.connection.execute <<-SQL CREATE TABLE flipper_features ( id integer PRIMARY KEY, key text NOT NULL UNIQUE, created_at datetime NOT NULL, updated_at datetime NOT NULL ) SQL ActiveRecord::Base.connection.execute <<-SQL CREATE TABLE flipper_gates ( id integer PRIMARY KEY, feature_key text NOT NULL, key text NOT NULL, value text DEFAULT NULL, created_at datetime NOT NULL, updated_at datetime NOT NULL ) SQL ActiveRecord::Base.connection.execute <<-SQL CREATE UNIQUE INDEX index_gates_on_keys_and_value on flipper_gates (feature_key, key, value) SQL end after(:each) do ActiveRecord::Base.connection.execute("DROP table IF EXISTS `flipper_features`") ActiveRecord::Base.connection.execute("DROP table IF EXISTS `flipper_gates`") end it_should_behave_like 'a flipper adapter' context 'requiring "flipper-active_record"' do before do Flipper.configuration = nil Flipper.instance = nil load 'flipper/adapters/active_record.rb' ActiveSupport.run_load_hooks(:active_record, ActiveRecord::Base) end it 'configures itself' do expect(Flipper.adapter.adapter).to be_a(Flipper::Adapters::ActiveRecord) end end end flipper-0.21.0/spec/flipper/adapters/active_support_cache_store_spec.rb000066400000000000000000000111461404600161700264000ustar00rootroot00000000000000require 'helper' require 'active_support/cache' require 'active_support/cache/dalli_store' require 'flipper/adapters/operation_logger' require 'flipper/adapters/active_support_cache_store' require 'flipper/spec/shared_adapter_specs' RSpec.describe Flipper::Adapters::ActiveSupportCacheStore do let(:memory_adapter) do Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new) end let(:cache) { ActiveSupport::Cache::MemoryStore.new } let(:write_through) { false } let(:adapter) { described_class.new(memory_adapter, cache, expires_in: 10.seconds, write_through: write_through) } let(:flipper) { Flipper.new(adapter) } subject { adapter } before do cache.clear end it_should_behave_like 'a flipper adapter' describe '#remove' do let(:feature) { flipper[:stats] } before do adapter.get(feature) adapter.remove(feature) end it 'expires feature and deletes the cache' do expect(cache.read(described_class.key_for(feature))).to be_nil expect(cache.exist?(described_class.key_for(feature))).to be(false) expect(feature).not_to be_enabled end context 'with write-through caching' do let(:write_through) { true } it 'expires feature and writes an empty value to the cache' do expect(cache.read(described_class.key_for(feature))).to eq(adapter.default_config) expect(cache.exist?(described_class.key_for(feature))).to be(true) expect(feature).not_to be_enabled end end end describe '#enable' do let(:feature) { flipper[:stats] } before do adapter.enable(feature, feature.gate(:boolean), flipper.boolean) end it 'enables feature and deletes the cache' do expect(cache.read(described_class.key_for(feature))).to be_nil expect(cache.exist?(described_class.key_for(feature))).to be(false) expect(feature).to be_enabled end context 'with write-through caching' do let(:write_through) { true } it 'expires feature and writes to the cache' do expect(cache.exist?(described_class.key_for(feature))).to be(true) expect(cache.read(described_class.key_for(feature))).to include(boolean: 'true') expect(feature).to be_enabled end end end describe '#disable' do let(:feature) { flipper[:stats] } before do adapter.disable(feature, feature.gate(:boolean), flipper.boolean) end it 'disables feature and deletes the cache' do expect(cache.read(described_class.key_for(feature))).to be_nil expect(cache.exist?(described_class.key_for(feature))).to be(false) expect(feature).not_to be_enabled end context 'with write-through caching' do let(:write_through) { true } it 'expires feature and writes to the cache' do expect(cache.exist?(described_class.key_for(feature))).to be(true) expect(cache.read(described_class.key_for(feature))).to include(boolean: nil) expect(feature).not_to be_enabled end end end describe '#get_multi' do it 'warms uncached features' do stats = flipper[:stats] search = flipper[:search] other = flipper[:other] stats.enable search.enable memory_adapter.reset adapter.get(stats) expect(cache.read(described_class.key_for(search))).to be(nil) expect(cache.read(described_class.key_for(other))).to be(nil) adapter.get_multi([stats, search, other]) expect(cache.read(described_class.key_for(search))[:boolean]).to eq('true') expect(cache.read(described_class.key_for(other))[:boolean]).to be(nil) adapter.get_multi([stats, search, other]) adapter.get_multi([stats, search, other]) expect(memory_adapter.count(:get_multi)).to eq(1) end end describe '#get_all' do let(:stats) { flipper[:stats] } let(:search) { flipper[:search] } before do stats.enable search.add end it 'warms all features' do adapter.get_all expect(cache.read(described_class.key_for(stats))[:boolean]).to eq('true') expect(cache.read(described_class.key_for(search))[:boolean]).to be(nil) expect(cache.read(described_class::GetAllKey)).to be_within(2).of(Time.now.to_i) end it 'returns same result when already cached' do expect(adapter.get_all).to eq(adapter.get_all) end it 'only invokes one call to wrapped adapter' do memory_adapter.reset 5.times { adapter.get_all } expect(memory_adapter.count(:get_all)).to eq(1) end end describe '#name' do it 'is active_support_cache_store' do expect(subject.name).to be(:active_support_cache_store) end end end flipper-0.21.0/spec/flipper/adapters/dalli_spec.rb000066400000000000000000000050151404600161700220550ustar00rootroot00000000000000require 'helper' require 'flipper/adapters/operation_logger' require 'flipper/adapters/dalli' require 'flipper/spec/shared_adapter_specs' require 'logger' RSpec.describe Flipper::Adapters::Dalli do let(:memory_adapter) do Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new) end let(:cache) { Dalli::Client.new(ENV['MEMCACHED_URL']) } let(:adapter) { described_class.new(memory_adapter, cache) } let(:flipper) { Flipper.new(adapter) } subject { adapter } before do Dalli.logger = Logger.new('/dev/null') begin cache.flush rescue Dalli::NetworkError ENV['CI'] ? raise : skip('Memcached not available') end end it_should_behave_like 'a flipper adapter' describe '#remove' do it 'expires feature' do feature = flipper[:stats] adapter.get(feature) adapter.remove(feature) expect(cache.get(described_class.key_for(feature))).to be(nil) end end describe '#get_multi' do it 'warms uncached features' do stats = flipper[:stats] search = flipper[:search] other = flipper[:other] stats.enable search.enable memory_adapter.reset adapter.get(stats) expect(cache.get(described_class.key_for(search))).to be(nil) expect(cache.get(described_class.key_for(other))).to be(nil) adapter.get_multi([stats, search, other]) expect(cache.get(described_class.key_for(search))[:boolean]).to eq('true') expect(cache.get(described_class.key_for(other))[:boolean]).to be(nil) adapter.get_multi([stats, search, other]) adapter.get_multi([stats, search, other]) expect(memory_adapter.count(:get_multi)).to eq(1) end end describe '#get_all' do let(:stats) { flipper[:stats] } let(:search) { flipper[:search] } before do stats.enable search.add end it 'warms all features' do adapter.get_all expect(cache.get(described_class.key_for(stats))[:boolean]).to eq('true') expect(cache.get(described_class.key_for(search))[:boolean]).to be(nil) expect(cache.get(described_class::GetAllKey)).to be_within(2).of(Time.now.to_i) end it 'returns same result when already cached' do expect(adapter.get_all).to eq(adapter.get_all) end it 'only invokes one call to wrapped adapter' do memory_adapter.reset 5.times { adapter.get_all } expect(memory_adapter.count(:get_all)).to eq(1) end end describe '#name' do it 'is dalli' do expect(subject.name).to be(:dalli) end end end flipper-0.21.0/spec/flipper/adapters/dual_write_spec.rb000066400000000000000000000037721404600161700231370ustar00rootroot00000000000000require 'helper' require 'flipper/adapters/dual_write' require 'flipper/adapters/operation_logger' require 'flipper/spec/shared_adapter_specs' require 'active_support/notifications' RSpec.describe Flipper::Adapters::DualWrite do let(:local_adapter) do Flipper::Adapters::OperationLogger.new Flipper::Adapters::Memory.new end let(:remote_adapter) do Flipper::Adapters::OperationLogger.new Flipper::Adapters::Memory.new end let(:local) { Flipper.new(local_adapter) } let(:remote) { Flipper.new(remote_adapter) } let(:sync) { Flipper.new(subject) } subject do described_class.new(local_adapter, remote_adapter) end it_should_behave_like 'a flipper adapter' it 'only uses local for #features' do subject.features end it 'only uses local for #get' do subject.get sync[:search] end it 'only uses local for #get_multi' do subject.get_multi [sync[:search]] end it 'only uses local for #get_all' do subject.get_all end it 'updates remote and local for #add' do subject.add sync[:search] expect(remote_adapter.count(:add)).to be(1) expect(local_adapter.count(:add)).to be(1) end it 'updates remote and local for #remove' do subject.remove sync[:search] expect(remote_adapter.count(:remove)).to be(1) expect(local_adapter.count(:remove)).to be(1) end it 'updates remote and local for #clear' do subject.clear sync[:search] expect(remote_adapter.count(:clear)).to be(1) expect(local_adapter.count(:clear)).to be(1) end it 'updates remote and local for #enable' do feature = sync[:search] subject.enable feature, feature.gate(:boolean), local.boolean expect(remote_adapter.count(:enable)).to be(1) expect(local_adapter.count(:enable)).to be(1) end it 'updates remote and local for #disable' do feature = sync[:search] subject.disable feature, feature.gate(:boolean), local.boolean(false) expect(remote_adapter.count(:disable)).to be(1) expect(local_adapter.count(:disable)).to be(1) end end flipper-0.21.0/spec/flipper/adapters/http_spec.rb000066400000000000000000000165711404600161700217600ustar00rootroot00000000000000require 'helper' require 'flipper/adapters/http' require 'flipper/adapters/pstore' require 'flipper/spec/shared_adapter_specs' require 'rack/handler/webrick' FLIPPER_SPEC_API_PORT = ENV.fetch('FLIPPER_SPEC_API_PORT', 9001).to_i RSpec.describe Flipper::Adapters::Http do context 'adapter' do subject do described_class.new(url: "http://localhost:#{FLIPPER_SPEC_API_PORT}") end before :all do dir = FlipperRoot.join('tmp').tap(&:mkpath) log_path = dir.join('flipper_adapters_http_spec.log') @pstore_file = dir.join('flipper.pstore') @pstore_file.unlink if @pstore_file.exist? api_adapter = Flipper::Adapters::PStore.new(@pstore_file) flipper_api = Flipper.new(api_adapter) app = Flipper::Api.app(flipper_api) server_options = { Port: FLIPPER_SPEC_API_PORT, StartCallback: -> { @started = true }, Logger: WEBrick::Log.new(log_path.to_s, WEBrick::Log::INFO), AccessLog: [ [log_path.open('w'), WEBrick::AccessLog::COMBINED_LOG_FORMAT], ], } @server = WEBrick::HTTPServer.new(server_options) @server.mount '/', Rack::Handler::WEBrick, app Thread.new { @server.start } Timeout.timeout(1) { :wait until @started } end after :all do @server.shutdown if @server end before(:each) do @pstore_file.unlink if @pstore_file.exist? end it_should_behave_like 'a flipper adapter' it "can enable and disable unregistered group" do flipper = Flipper.new(subject) expect(flipper[:search].enable_group(:some_made_up_group)).to be(true) expect(flipper[:search].groups_value).to eq(Set["some_made_up_group"]) expect(flipper[:search].disable_group(:some_made_up_group)).to be(true) expect(flipper[:search].groups_value).to eq(Set.new) end end it "sends default headers" do headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json', 'User-Agent' => "Flipper HTTP Adapter v#{Flipper::VERSION}", } stub_request(:get, "http://app.com/flipper/features/feature_panel") .with(headers: headers) .to_return(status: 404, body: "", headers: {}) adapter = described_class.new(url: 'http://app.com/flipper') adapter.get(flipper[:feature_panel]) end describe "#get" do it "raises error when not successful response" do stub_request(:get, "http://app.com/flipper/features/feature_panel") .to_return(status: 503, body: "", headers: {}) adapter = described_class.new(url: 'http://app.com/flipper') expect { adapter.get(flipper[:feature_panel]) }.to raise_error(Flipper::Adapters::Http::Error) end end describe "#get_multi" do it "raises error when not successful response" do stub_request(:get, "http://app.com/flipper/features?keys=feature_panel") .to_return(status: 503, body: "", headers: {}) adapter = described_class.new(url: 'http://app.com/flipper') expect { adapter.get_multi([flipper[:feature_panel]]) }.to raise_error(Flipper::Adapters::Http::Error) end end describe "#get_all" do it "raises error when not successful response" do stub_request(:get, "http://app.com/flipper/features") .to_return(status: 503, body: "", headers: {}) adapter = described_class.new(url: 'http://app.com/flipper') expect { adapter.get_all }.to raise_error(Flipper::Adapters::Http::Error) end end describe "#features" do it "raises error when not successful response" do stub_request(:get, "http://app.com/flipper/features") .to_return(status: 503, body: "", headers: {}) adapter = described_class.new(url: 'http://app.com/flipper') expect { adapter.features }.to raise_error(Flipper::Adapters::Http::Error) end end describe "#add" do it "raises error when not successful" do stub_request(:post, /app.com/) .to_return(status: 503, body: "{}", headers: {}) adapter = described_class.new(url: 'http://app.com/flipper') expect { adapter.add(Flipper::Feature.new(:search, adapter)) }.to raise_error(Flipper::Adapters::Http::Error) end end describe "#remove" do it "raises error when not successful" do stub_request(:delete, /app.com/) .to_return(status: 503, body: "{}", headers: {}) adapter = described_class.new(url: 'http://app.com/flipper') expect { adapter.remove(Flipper::Feature.new(:search, adapter)) }.to raise_error(Flipper::Adapters::Http::Error) end end describe "#clear" do it "raises error when not successful" do stub_request(:delete, /app.com/) .to_return(status: 503, body: "{}", headers: {}) adapter = described_class.new(url: 'http://app.com/flipper') expect { adapter.clear(Flipper::Feature.new(:search, adapter)) }.to raise_error(Flipper::Adapters::Http::Error) end end describe "#enable" do it "raises error when not successful" do stub_request(:post, /app.com/) .to_return(status: 503, body: "{}", headers: {}) adapter = described_class.new(url: 'http://app.com/flipper') feature = Flipper::Feature.new(:search, adapter) gate = feature.gate(:boolean) thing = gate.wrap(true) expect { adapter.enable(feature, gate, thing) }.to raise_error(Flipper::Adapters::Http::Error) end end describe "#disable" do it "raises error when not successful" do stub_request(:delete, /app.com/) .to_return(status: 503, body: "{}", headers: {}) adapter = described_class.new(url: 'http://app.com/flipper') feature = Flipper::Feature.new(:search, adapter) gate = feature.gate(:boolean) thing = gate.wrap(false) expect { adapter.disable(feature, gate, thing) }.to raise_error(Flipper::Adapters::Http::Error) end end describe 'configuration' do let(:debug_output) { object_double($stderr) } let(:options) do { url: 'http://app.com/mount-point', headers: { 'X-Custom-Header' => 'foo' }, basic_auth_username: 'username', basic_auth_password: 'password', read_timeout: 100, open_timeout: 40, write_timeout: 40, debug_output: debug_output, } end subject { described_class.new(options) } let(:feature) { flipper[:feature_panel] } before do stub_request(:get, %r{\Ahttp://app.com*}).to_return(body: fixture_file('feature.json')) end it 'allows client to set request headers' do subject.get(feature) expect( a_request(:get, 'http://app.com/mount-point/features/feature_panel') .with(headers: { 'X-Custom-Header' => 'foo' }) ).to have_been_made.once end it 'allows client to set basic auth' do subject.get(feature) expect( a_request(:get, 'http://app.com/mount-point/features/feature_panel') .with(basic_auth: %w(username password)) ).to have_been_made.once end it 'allows client to set debug output' do user_agent = Net::HTTP.new("app.com") allow(Net::HTTP).to receive(:new).and_return(user_agent) expect(user_agent).to receive(:set_debug_output).with(debug_output) subject.get(feature) end end def fixture_file(name) fixtures_path = File.expand_path('../../../fixtures', __FILE__) File.new(fixtures_path + '/' + name) end end flipper-0.21.0/spec/flipper/adapters/instrumented_spec.rb000066400000000000000000000114411404600161700235110ustar00rootroot00000000000000require 'helper' require 'flipper/adapters/instrumented' require 'flipper/instrumenters/memory' require 'flipper/spec/shared_adapter_specs' RSpec.describe Flipper::Adapters::Instrumented do let(:instrumenter) { Flipper::Instrumenters::Memory.new } let(:adapter) { Flipper::Adapters::Memory.new } let(:flipper) { Flipper.new(adapter) } let(:feature) { flipper[:stats] } let(:gate) { feature.gate(:percentage_of_actors) } let(:thing) { flipper.actors(22) } subject do described_class.new(adapter, instrumenter: instrumenter) end it_should_behave_like 'a flipper adapter' it 'forwards missing methods to underlying adapter' do adapter = Class.new do def foo :foo end end.new instrumented = described_class.new(adapter) expect(instrumented.foo).to eq(:foo) end describe '#name' do it 'is instrumented' do expect(subject.name).to be(:instrumented) end end describe '#get' do it 'records instrumentation' do result = subject.get(feature) event = instrumenter.events.last expect(event).not_to be_nil expect(event.name).to eq('adapter_operation.flipper') expect(event.payload[:operation]).to eq(:get) expect(event.payload[:adapter_name]).to eq(:memory) expect(event.payload[:feature_name]).to eq(:stats) expect(event.payload[:result]).to be(result) end end describe '#get_multi' do it 'records instrumentation' do result = subject.get_multi([feature]) event = instrumenter.events.last expect(event).not_to be_nil expect(event.name).to eq('adapter_operation.flipper') expect(event.payload[:operation]).to eq(:get_multi) expect(event.payload[:adapter_name]).to eq(:memory) expect(event.payload[:feature_names]).to eq([:stats]) expect(event.payload[:result]).to be(result) end end describe '#enable' do it 'records instrumentation' do result = subject.enable(feature, gate, thing) event = instrumenter.events.last expect(event).not_to be_nil expect(event.name).to eq('adapter_operation.flipper') expect(event.payload[:operation]).to eq(:enable) expect(event.payload[:adapter_name]).to eq(:memory) expect(event.payload[:feature_name]).to eq(:stats) expect(event.payload[:gate_name]).to eq(:percentage_of_actors) expect(event.payload[:thing_value]).to eq(22) expect(event.payload[:result]).to be(result) end end describe '#disable' do it 'records instrumentation' do result = subject.disable(feature, gate, thing) event = instrumenter.events.last expect(event).not_to be_nil expect(event.name).to eq('adapter_operation.flipper') expect(event.payload[:operation]).to eq(:disable) expect(event.payload[:adapter_name]).to eq(:memory) expect(event.payload[:feature_name]).to eq(:stats) expect(event.payload[:gate_name]).to eq(:percentage_of_actors) expect(event.payload[:thing_value]).to eq(22) expect(event.payload[:result]).to be(result) end end describe '#add' do it 'records instrumentation' do result = subject.add(feature) event = instrumenter.events.last expect(event).not_to be_nil expect(event.name).to eq('adapter_operation.flipper') expect(event.payload[:operation]).to eq(:add) expect(event.payload[:adapter_name]).to eq(:memory) expect(event.payload[:feature_name]).to eq(:stats) expect(event.payload[:result]).to be(result) end end describe '#remove' do it 'records instrumentation' do result = subject.remove(feature) event = instrumenter.events.last expect(event).not_to be_nil expect(event.name).to eq('adapter_operation.flipper') expect(event.payload[:operation]).to eq(:remove) expect(event.payload[:adapter_name]).to eq(:memory) expect(event.payload[:feature_name]).to eq(:stats) expect(event.payload[:result]).to be(result) end end describe '#clear' do it 'records instrumentation' do result = subject.clear(feature) event = instrumenter.events.last expect(event).not_to be_nil expect(event.name).to eq('adapter_operation.flipper') expect(event.payload[:operation]).to eq(:clear) expect(event.payload[:adapter_name]).to eq(:memory) expect(event.payload[:feature_name]).to eq(:stats) expect(event.payload[:result]).to be(result) end end describe '#features' do it 'records instrumentation' do result = subject.features event = instrumenter.events.last expect(event).not_to be_nil expect(event.name).to eq('adapter_operation.flipper') expect(event.payload[:operation]).to eq(:features) expect(event.payload[:adapter_name]).to eq(:memory) expect(event.payload[:result]).to be(result) end end end flipper-0.21.0/spec/flipper/adapters/memoizable_spec.rb000066400000000000000000000224441404600161700231210ustar00rootroot00000000000000require 'helper' require 'flipper/adapters/memoizable' require 'flipper/adapters/operation_logger' require 'flipper/spec/shared_adapter_specs' RSpec.describe Flipper::Adapters::Memoizable do let(:features_key) { described_class::FeaturesKey } let(:adapter) { Flipper::Adapters::Memory.new } let(:flipper) { Flipper.new(adapter) } let(:cache) { {} } subject { described_class.new(adapter, cache) } it_should_behave_like 'a flipper adapter' it 'forwards missing methods to underlying adapter' do adapter = Class.new do def foo :foo end end.new memoizable = described_class.new(adapter) expect(memoizable.foo).to eq(:foo) end describe '#name' do it 'is instrumented' do expect(subject.name).to be(:memoizable) end end describe '#memoize=' do it 'sets value' do subject.memoize = true expect(subject.memoizing?).to eq(true) subject.memoize = false expect(subject.memoizing?).to eq(false) end it 'clears the local cache' do subject.cache['some'] = 'thing' subject.memoize = true expect(subject.cache).to be_empty end end describe '#memoizing?' do it 'returns true if enabled' do subject.memoize = true expect(subject.memoizing?).to eq(true) end it 'returns false if disabled' do subject.memoize = false expect(subject.memoizing?).to eq(false) end end describe '#get' do context 'with memoization enabled' do before do subject.memoize = true end it 'memoizes feature' do feature = flipper[:stats] result = subject.get(feature) expect(cache[described_class.key_for(feature.key)]).to be(result) end end context 'with memoization disabled' do before do subject.memoize = false end it 'returns result' do feature = flipper[:stats] result = subject.get(feature) adapter_result = adapter.get(feature) expect(result).to eq(adapter_result) end end end describe '#get_multi' do context 'with memoization enabled' do before do subject.memoize = true end it 'memoizes features' do names = %i(stats shiny) features = names.map { |name| flipper[name] } results = subject.get_multi(features) features.each do |feature| expect(cache[described_class.key_for(feature.key)]).not_to be(nil) expect(cache[described_class.key_for(feature.key)]).to be(results[feature.key]) end end end context 'with memoization disabled' do before do subject.memoize = false end it 'returns result' do names = %i(stats shiny) features = names.map { |name| flipper[name] } result = subject.get_multi(features) adapter_result = adapter.get_multi(features) expect(result).to eq(adapter_result) end end end describe '#get_all' do context "with memoization enabled" do before do subject.memoize = true end it 'memoizes features' do names = %i(stats shiny) features = names.map { |name| flipper[name].tap(&:enable) } results = subject.get_all features.each do |feature| expect(cache[described_class.key_for(feature.key)]).not_to be(nil) expect(cache[described_class.key_for(feature.key)]).to be(results[feature.key]) end expect(cache[subject.class::FeaturesKey]).to eq(names.map(&:to_s).to_set) end it 'only calls get_all once for memoized adapter' do adapter = Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new) cache = {} instance = described_class.new(adapter, cache) instance.memoize = true instance.get_all expect(adapter.count(:get_all)).to be(1) instance.get_all expect(adapter.count(:get_all)).to be(1) end it 'returns default_config for unknown feature keys' do first = subject.get_all expect(first['doesntexist']).to eq(subject.default_config) second = subject.get_all expect(second['doesntexist']).to eq(subject.default_config) end end context "with memoization disabled" do before do subject.memoize = false end it 'returns result' do names = %i(stats shiny) names.map { |name| flipper[name].tap(&:enable) } result = subject.get_all adapter_result = adapter.get_all expect(result).to eq(adapter_result) end it 'calls get_all every time for memoized adapter' do adapter = Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new) cache = {} instance = described_class.new(adapter, cache) instance.memoize = false instance.get_all expect(adapter.count(:get_all)).to be(1) instance.get_all expect(adapter.count(:get_all)).to be(2) end it 'returns nil for unknown feature keys' do first = subject.get_all expect(first['doesntexist']).to be(nil) second = subject.get_all expect(second['doesntexist']).to be(nil) end end end describe '#enable' do context 'with memoization enabled' do before do subject.memoize = true end it 'unmemoizes feature' do feature = flipper[:stats] gate = feature.gate(:boolean) cache[described_class.key_for(feature.key)] = { some: 'thing' } subject.enable(feature, gate, flipper.bool) expect(cache[described_class.key_for(feature.key)]).to be_nil end end context 'with memoization disabled' do before do subject.memoize = false end it 'returns result' do feature = flipper[:stats] gate = feature.gate(:boolean) result = subject.enable(feature, gate, flipper.bool) adapter_result = adapter.enable(feature, gate, flipper.bool) expect(result).to eq(adapter_result) end end end describe '#disable' do context 'with memoization enabled' do before do subject.memoize = true end it 'unmemoizes feature' do feature = flipper[:stats] gate = feature.gate(:boolean) cache[described_class.key_for(feature.key)] = { some: 'thing' } subject.disable(feature, gate, flipper.bool) expect(cache[described_class.key_for(feature.key)]).to be_nil end end context 'with memoization disabled' do before do subject.memoize = false end it 'returns result' do feature = flipper[:stats] gate = feature.gate(:boolean) result = subject.disable(feature, gate, flipper.bool) adapter_result = adapter.disable(feature, gate, flipper.bool) expect(result).to eq(adapter_result) end end end describe '#features' do context 'with memoization enabled' do before do subject.memoize = true end it 'memoizes features' do flipper[:stats].enable flipper[:search].disable result = subject.features expect(cache[:flipper_features]).to be(result) end end context 'with memoization disabled' do before do subject.memoize = false end it 'returns result' do expect(subject.features).to eq(adapter.features) end end end describe '#add' do context 'with memoization enabled' do before do subject.memoize = true end it 'unmemoizes the known features' do cache[features_key] = { some: 'thing' } subject.add(flipper[:stats]) expect(cache).to be_empty end end context 'with memoization disabled' do before do subject.memoize = false end it 'returns result' do expect(subject.add(flipper[:stats])).to eq(adapter.add(flipper[:stats])) end end end describe '#remove' do context 'with memoization enabled' do before do subject.memoize = true end it 'unmemoizes the known features' do cache[features_key] = { some: 'thing' } subject.remove(flipper[:stats]) expect(cache).to be_empty end it 'unmemoizes the feature' do feature = flipper[:stats] cache[described_class.key_for(feature.key)] = { some: 'thing' } subject.remove(feature) expect(cache[described_class.key_for(feature.key)]).to be_nil end end context 'with memoization disabled' do before do subject.memoize = false end it 'returns result' do expect(subject.remove(flipper[:stats])).to eq(adapter.remove(flipper[:stats])) end end end describe '#clear' do context 'with memoization enabled' do before do subject.memoize = true end it 'unmemoizes feature' do feature = flipper[:stats] cache[described_class.key_for(feature.key)] = { some: 'thing' } subject.clear(feature) expect(cache[described_class.key_for(feature.key)]).to be_nil end end context 'with memoization disabled' do before do subject.memoize = false end it 'returns result' do feature = flipper[:stats] expect(subject.clear(feature)).to eq(adapter.clear(feature)) end end end end flipper-0.21.0/spec/flipper/adapters/memory_spec.rb000066400000000000000000000020501404600161700222740ustar00rootroot00000000000000require 'helper' require 'flipper/spec/shared_adapter_specs' RSpec.describe Flipper::Adapters::Memory do let(:source) { {} } subject { described_class.new(source) } it_should_behave_like 'a flipper adapter' it "can initialize from big hash" do flipper = Flipper.new(subject) flipper.enable :subscriptions flipper.disable :search flipper.enable_percentage_of_actors :pro_deal, 20 flipper.enable_percentage_of_time :logging, 30 flipper.enable_actor :following, Flipper::Actor.new('1') flipper.enable_actor :following, Flipper::Actor.new('3') flipper.enable_group :following, Flipper::Types::Group.new(:staff) expect(source).to eq({ "subscriptions" => subject.default_config.merge(boolean: "true"), "search" => subject.default_config, "logging" => subject.default_config.merge(:percentage_of_time => "30"), "pro_deal" => subject.default_config.merge(:percentage_of_actors => "20"), "following" => subject.default_config.merge(actors: Set["1", "3"], groups: Set["staff"]), }) end end flipper-0.21.0/spec/flipper/adapters/moneta_spec.rb000066400000000000000000000004161404600161700222530ustar00rootroot00000000000000require 'helper' require 'flipper/adapters/moneta' require 'flipper/spec/shared_adapter_specs' RSpec.describe Flipper::Adapters::Moneta do let(:moneta) { Moneta.new(:Memory) } subject { described_class.new(moneta) } it_should_behave_like 'a flipper adapter' end flipper-0.21.0/spec/flipper/adapters/mongo_spec.rb000066400000000000000000000020721404600161700221070ustar00rootroot00000000000000require 'helper' require 'flipper/adapters/mongo' require 'flipper/spec/shared_adapter_specs' Mongo::Logger.logger.level = Logger::INFO RSpec.describe Flipper::Adapters::Mongo do subject { described_class.new(collection) } let(:host) { ENV['MONGODB_HOST'] || '127.0.0.1' } let(:port) { ENV['MONGODB_PORT'] || 27017 } let(:client) do logger = Logger.new('/dev/null') Mongo::Client.new(["#{host}:#{port}"], server_selection_timeout: 0.01, database: 'testing', logger: logger) end let(:collection) { client['testing'] } before do begin collection.drop rescue Mongo::Error::NoServerAvailable ENV['CI'] ? raise : skip('Mongo not available') rescue Mongo::Error::OperationFailure end collection.create end it_should_behave_like 'a flipper adapter' it 'configures itself on load' do Flipper.configuration = nil Flipper.instance = nil load 'flipper/adapters/mongo.rb' ENV["MONGO_URL"] ||= "mongodb://127.0.0.1:27017/testing" expect(Flipper.adapter.adapter).to be_a(Flipper::Adapters::Mongo) end end flipper-0.21.0/spec/flipper/adapters/operation_logger_spec.rb000066400000000000000000000050221404600161700243250ustar00rootroot00000000000000require 'helper' require 'flipper/adapters/operation_logger' require 'flipper/spec/shared_adapter_specs' RSpec.describe Flipper::Adapters::OperationLogger do let(:operations) { [] } let(:adapter) { Flipper::Adapters::Memory.new } let(:flipper) { Flipper.new(adapter) } subject { described_class.new(adapter, operations) } it_should_behave_like 'a flipper adapter' it 'shows itself when inspect' do subject.features output = subject.inspect expect(output).to match(/OperationLogger/) expect(output).to match(/operation_logger/) expect(output).to match(/@type=:features/) expect(output).to match(/@adapter=# { events << described_class.now } } let(:interval) { 10 } subject { described_class.new(synchronizer, interval: interval) } it 'synchronizes on first call' do expect(events.size).to be(0) subject.call expect(events.size).to be(1) end it "only invokes wrapped synchronizer every interval seconds" do now = described_class.now subject.call events.clear # move time to one millisecond less than last sync + interval 1.upto(interval) do |i| allow(described_class).to receive(:now).and_return(now + i - 1) subject.call end expect(events.size).to be(0) # move time to last sync + interval in milliseconds allow(described_class).to receive(:now).and_return(now + interval) subject.call expect(events.size).to be(1) end end flipper-0.21.0/spec/flipper/adapters/sync/synchronizer_spec.rb000066400000000000000000000053621404600161700245060ustar00rootroot00000000000000require "helper" require "flipper/adapters/memory" require "flipper/instrumenters/memory" require "flipper/adapters/sync/synchronizer" RSpec.describe Flipper::Adapters::Sync::Synchronizer do let(:local) { Flipper::Adapters::Memory.new } let(:remote) { Flipper::Adapters::Memory.new } let(:local_flipper) { Flipper.new(local) } let(:remote_flipper) { Flipper.new(remote) } let(:instrumenter) { Flipper::Instrumenters::Memory.new } subject { described_class.new(local, remote, instrumenter: instrumenter) } it "instruments call" do subject.call expect(instrumenter.events_by_name("synchronizer_exception.flipper").size).to be(0) events = instrumenter.events_by_name("synchronizer_call.flipper") expect(events.size).to be(1) end it "raises errors by default" do exception = StandardError.new expect(remote).to receive(:get_all).and_raise(exception) expect { subject.call }.to raise_error(exception) end context "when raise disabled" do subject do options = { instrumenter: instrumenter, raise: false, } described_class.new(local, remote, options) end it "does not raise, but instruments exceptions for visibility" do exception = StandardError.new expect(remote).to receive(:get_all).and_raise(exception) expect { subject.call }.not_to raise_error events = instrumenter.events_by_name("synchronizer_exception.flipper") expect(events.size).to be(1) event = events[0] expect(event.payload[:exception]).to eq(exception) end end describe '#call' do it 'returns nothing' do expect(subject.call).to be(nil) expect(instrumenter.events_by_name("synchronizer_exception.flipper").size).to be(0) end it 'syncs each remote feature to local' do remote_flipper.enable(:search) remote_flipper.enable_percentage_of_time(:logging, 10) subject.call expect(instrumenter.events_by_name("synchronizer_exception.flipper").size).to be(0) expect(local_flipper[:search].boolean_value).to eq(true) expect(local_flipper[:logging].percentage_of_time_value).to eq(10) expect(local_flipper.features.map(&:key).sort).to eq(%w(logging search)) end it 'adds features in remote that are not in local' do remote_flipper.add(:search) subject.call expect(instrumenter.events_by_name("synchronizer_exception.flipper").size).to be(0) expect(local_flipper.features.map(&:key)).to eq(["search"]) end it 'removes features in local that are not in remote' do local_flipper.add(:stats) subject.call expect(instrumenter.events_by_name("synchronizer_exception.flipper").size).to be(0) expect(local_flipper.features.map(&:key)).to eq([]) end end end flipper-0.21.0/spec/flipper/adapters/sync_spec.rb000066400000000000000000000146531404600161700217540ustar00rootroot00000000000000require 'helper' require 'flipper/adapters/sync' require 'flipper/adapters/operation_logger' require 'flipper/spec/shared_adapter_specs' require 'active_support/notifications' RSpec.describe Flipper::Adapters::Sync do let(:local_adapter) do Flipper::Adapters::OperationLogger.new Flipper::Adapters::Memory.new end let(:remote_adapter) do Flipper::Adapters::OperationLogger.new Flipper::Adapters::Memory.new end let(:local) { Flipper.new(local_adapter) } let(:remote) { Flipper.new(remote_adapter) } let(:sync) { Flipper.new(subject) } subject do described_class.new(local_adapter, remote_adapter, interval: 1) end it_should_behave_like 'a flipper adapter' context 'when local has never been synced' do it 'syncs boolean' do remote.enable(:search) expect(sync[:search].boolean_value).to be(true) expect(subject.features.sort).to eq(%w(search)) end it 'syncs actor' do actor = Flipper::Actor.new("User;1000") remote.enable_actor(:search, actor) expect(sync[:search].actors_value).to eq(Set[actor.flipper_id]) expect(subject.features.sort).to eq(%w(search)) end it 'syncs group' do remote.enable_group(:search, :staff) expect(sync[:search].groups_value).to eq(Set["staff"]) expect(subject.features.sort).to eq(%w(search)) end it 'syncs percentage of actors' do remote.enable_percentage_of_actors(:search, 25) expect(sync[:search].percentage_of_actors_value).to eq(25) expect(subject.features.sort).to eq(%w(search)) end it 'syncs percentage of time' do remote.enable_percentage_of_time(:search, 15) expect(sync[:search].percentage_of_time_value).to eq(15) expect(subject.features.sort).to eq(%w(search)) end end it 'enables boolean locally when remote feature boolean enabled' do remote.disable(:search) local.disable(:search) remote.enable(:search) subject # initialize forces sync expect(local[:search].boolean_value).to be(true) end it 'disables boolean locally when remote feature disabled' do remote.enable(:search) local.enable(:search) remote.disable(:search) subject # initialize forces sync expect(local[:search].boolean_value).to be(false) end it 'adds local actor when remote actor is added' do actor = Flipper::Actor.new("User;235") remote.enable_actor(:search, actor) subject # initialize forces sync expect(local[:search].actors_value).to eq(Set[actor.flipper_id]) end it 'removes local actor when remote actor is removed' do actor = Flipper::Actor.new("User;235") remote.enable_actor(:search, actor) local.enable_actor(:search, actor) remote.disable(:search, actor) subject # initialize forces sync expect(local[:search].actors_value).to eq(Set.new) end it 'adds local group when remote group is added' do group = Flipper::Types::Group.new(:staff) remote.enable_group(:search, group) subject # initialize forces sync expect(local[:search].groups_value).to eq(Set["staff"]) end it 'removes local group when remote group is removed' do group = Flipper::Types::Group.new(:staff) remote.enable_group(:search, group) local.enable_group(:search, group) remote.disable(:search, group) subject # initialize forces sync expect(local[:search].groups_value).to eq(Set.new) end it 'updates percentage of actors when remote is updated' do remote.enable_percentage_of_actors(:search, 10) local.enable_percentage_of_actors(:search, 10) remote.enable_percentage_of_actors(:search, 15) subject # initialize forces sync expect(local[:search].percentage_of_actors_value).to eq(15) end it 'updates percentage of time when remote is updated' do remote.enable_percentage_of_time(:search, 10) local.enable_percentage_of_time(:search, 10) remote.enable_percentage_of_time(:search, 15) subject # initialize forces sync expect(local[:search].percentage_of_time_value).to eq(15) end context 'when local and remote match' do it 'does not update boolean enabled' do local.enable(:search) remote.enable(:search) local_adapter.reset subject # initialize forces sync expect(local_adapter.count(:enable)).to be(0) end it 'does not update boolean disabled' do local.disable(:search) remote.disable(:search) local_adapter.reset subject # initialize forces sync expect(local_adapter.count(:disable)).to be(0) end it 'does not update actors' do actor = Flipper::Actor.new("User;235") local.enable_actor(:search, actor) remote.enable_actor(:search, actor) local_adapter.reset subject # initialize forces sync expect(local_adapter.count(:enable)).to be(0) expect(local_adapter.count(:disable)).to be(0) end it 'does not update groups' do group = Flipper::Types::Group.new(:staff) local.enable_group(:search, group) remote.enable_group(:search, group) local_adapter.reset subject # initialize forces sync expect(local_adapter.count(:enable)).to be(0) expect(local_adapter.count(:disable)).to be(0) end it 'does not update percentage of actors' do local.enable_percentage_of_actors(:search, 10) remote.enable_percentage_of_actors(:search, 10) local_adapter.reset subject # initialize forces sync expect(local_adapter.count(:enable)).to be(0) expect(local_adapter.count(:disable)).to be(0) end it 'does not update percentage of time' do local.enable_percentage_of_time(:search, 10) remote.enable_percentage_of_time(:search, 10) local_adapter.reset subject # initialize forces sync expect(local_adapter.count(:enable)).to be(0) expect(local_adapter.count(:disable)).to be(0) end end it 'synchronizes for #features' do expect(subject).to receive(:synchronize) subject.features end it 'synchronizes for #get' do expect(subject).to receive(:synchronize) subject.get sync[:search] end it 'synchronizes for #get_multi' do expect(subject).to receive(:synchronize) subject.get_multi [sync[:search]] end it 'synchronizes for #get_all' do expect(subject).to receive(:synchronize) subject.get_all end it 'does not raise sync exceptions' do exception = StandardError.new expect(remote_adapter).to receive(:get_all).and_raise(exception) expect { subject.get_all }.not_to raise_error end end flipper-0.21.0/spec/flipper/api/000077500000000000000000000000001404600161700163765ustar00rootroot00000000000000flipper-0.21.0/spec/flipper/api/action_spec.rb000066400000000000000000000057501404600161700212210ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Api::Action do let(:action_subclass) do Class.new(described_class) do def noooope raise 'should never run this' end def get [200, {}, 'get'] end def post [200, {}, 'post'] end def put [200, {}, 'put'] end def delete [200, {}, 'delete'] end end end describe 'https verbs' do it "won't run method that isn't whitelisted" do fake_request = Struct.new(:request_method, :env, :session).new('NOOOOPE', {}, {}) action = action_subclass.new(flipper, fake_request) expect do action.run end.to raise_error(Flipper::Api::RequestMethodNotSupported) end it 'will run get' do fake_request = Struct.new(:request_method, :env, :session).new('GET', {}, {}) action = action_subclass.new(flipper, fake_request) expect(action.run).to eq([200, {}, 'get']) end it 'will run post' do fake_request = Struct.new(:request_method, :env, :session).new('POST', {}, {}) action = action_subclass.new(flipper, fake_request) expect(action.run).to eq([200, {}, 'post']) end it 'will run put' do fake_request = Struct.new(:request_method, :env, :session).new('PUT', {}, {}) action = action_subclass.new(flipper, fake_request) expect(action.run).to eq([200, {}, 'put']) end it 'will run delete' do fake_request = Struct.new(:request_method, :env, :session).new('DELETE', {}, {}) action = action_subclass.new(flipper, fake_request) expect(action.run).to eq([200, {}, 'delete']) end end describe '#json_error_response' do describe ':feature_not_found' do it 'locates and serializes error correctly' do action = action_subclass.new({}, {}) response = catch(:halt) do action.json_error_response(:feature_not_found) end status, headers, body = response parsed_body = JSON.parse(body[0]) expect(headers['Content-Type']).to eq('application/json') expect(parsed_body).to eql(api_not_found_response) end end describe ':group_not_registered' do it 'locates and serializes error correctly' do action = action_subclass.new({}, {}) response = catch(:halt) do action.json_error_response(:group_not_registered) end status, headers, body = response parsed_body = JSON.parse(body[0]) expect(headers['Content-Type']).to eq('application/json') expect(parsed_body['code']).to eq(2) expect(parsed_body['message']).to eq('Group not registered.') expect(parsed_body['more_info']).to eq(api_error_code_reference_url) end end describe 'invalid error key' do it 'raises descriptive error' do action = action_subclass.new({}, {}) catch(:halt) do expect { action.json_error_response(:invalid_error_key) }.to raise_error(KeyError) end end end end end flipper-0.21.0/spec/flipper/api/json_params_spec.rb000066400000000000000000000054611404600161700222570ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Api::JsonParams do let(:app) do app = lambda do |env| request = Rack::Request.new(env) [200, { 'Content-Type' => 'application/json' }, [JSON.generate(request.params)]] end builder = Rack::Builder.new builder.use described_class builder.run app builder end describe 'json post request' do it 'adds request body to params' do response = post '/', JSON.generate(flipper_id: 'User;2'), 'CONTENT_TYPE' => 'application/json' params = JSON.parse(response.body) expect(params).to eq('flipper_id' => 'User;2') end it 'handles request bodies with multiple params' do response = post '/', JSON.generate(flipper_id: 'User;2', language: 'ruby'), 'CONTENT_TYPE' => 'application/json' params = JSON.parse(response.body) expect(params).to eq('flipper_id' => 'User;2', 'language' => 'ruby') end it 'handles request bodies and single query string params' do response = post '/?language=ruby', JSON.generate(flipper_id: 'User;2'), 'CONTENT_TYPE' => 'application/json' params = JSON.parse(response.body) expect(params).to eq('flipper_id' => 'User;2', 'language' => 'ruby') end it 'handles request bodies and multiple query string params' do response = post '/?language=ruby&framework=rails', JSON.generate(flipper_id: 'User;2'), 'CONTENT_TYPE' => 'application/json' params = JSON.parse(response.body) expect(params).to eq('flipper_id' => 'User;2', 'language' => 'ruby', 'framework' => 'rails') end it 'favors request body params' do response = post '/?language=javascript', JSON.generate(flipper_id: 'User;2', language: 'ruby'), 'CONTENT_TYPE' => 'application/json' params = JSON.parse(response.body) expect(params).to eq('flipper_id' => 'User;2', 'language' => 'ruby') end end describe 'url-encoded request' do it 'handles params the same as a json request' do response = post '/', flipper_id: 'User;2' params = JSON.parse(response.body) expect(params).to eq('flipper_id' => 'User;2') end it 'handles single query string params' do response = post '/?language=ruby', flipper_id: 'User;2' params = JSON.parse(response.body) expect(params).to eq('flipper_id' => 'User;2', 'language' => 'ruby') end it 'handles multiple query string params' do response = post '/?language=ruby&framework=rails', flipper_id: 'User;2' params = JSON.parse(response.body) expect(params).to eq('flipper_id' => 'User;2', 'language' => 'ruby', 'framework' => 'rails') end end end flipper-0.21.0/spec/flipper/api/v1/000077500000000000000000000000001404600161700167245ustar00rootroot00000000000000flipper-0.21.0/spec/flipper/api/v1/actions/000077500000000000000000000000001404600161700203645ustar00rootroot00000000000000flipper-0.21.0/spec/flipper/api/v1/actions/actors_gate_spec.rb000066400000000000000000000104451404600161700242220ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Api::V1::Actions::ActorsGate do let(:app) { build_api(flipper) } let(:actor) { Flipper::Actor.new('1') } describe 'enable' do before do flipper[:my_feature].disable_actor(actor) post '/features/my_feature/actors', flipper_id: actor.flipper_id end it 'enables feature for actor' do expect(last_response.status).to eq(200) expect(flipper[:my_feature].enabled?(actor)).to be_truthy expect(flipper[:my_feature].enabled_gate_names).to eq([:actor]) end it 'returns decorated feature with actor enabled' do gate = json_response['gates'].find { |gate| gate['key'] == 'actors' } expect(gate['value']).to eq(['1']) end end describe 'disable' do let(:actor) { Flipper::Actor.new('1') } before do flipper[:my_feature].enable_actor(actor) delete '/features/my_feature/actors', flipper_id: actor.flipper_id end it 'disables feature' do expect(last_response.status).to eq(200) expect(flipper[:my_feature].enabled?(actor)).to be_falsy expect(flipper[:my_feature].enabled_gate_names).to be_empty end it 'returns decorated feature with boolean gate disabled' do gate = json_response['gates'].find { |gate| gate['key'] == 'actors' } expect(gate['value']).to be_empty end end describe 'enable feature with slash in name' do before do flipper[:my_feature].disable_actor(actor) post '/features/my/feature/actors', flipper_id: actor.flipper_id end it 'enables feature for actor' do expect(last_response.status).to eq(200) expect(flipper["my/feature"].enabled?(actor)).to be_truthy expect(flipper["my/feature"].enabled_gate_names).to eq([:actor]) end it 'returns decorated feature with actor enabled' do gate = json_response['gates'].find { |gate| gate['key'] == 'actors' } expect(gate['value']).to eq(['1']) end end describe 'enable missing flipper_id parameter' do before do flipper[:my_feature].enable post '/features/my_feature/actors' end it 'returns correct error response' do expect(last_response.status).to eq(422) expect(json_response).to eq(api_flipper_id_is_missing_response) end end describe 'disable missing flipper_id parameter' do before do flipper[:my_feature].enable delete '/features/my_feature/actors' end it 'returns correct error response' do expect(last_response.status).to eq(422) expect(json_response).to eq(api_flipper_id_is_missing_response) end end describe 'enable nil flipper_id parameter' do before do flipper[:my_feature].enable post '/features/my_feature/actors', flipper_id: nil end it 'returns correct error response' do expect(last_response.status).to eq(422) expect(json_response).to eq(api_flipper_id_is_missing_response) end end describe 'disable nil flipper_id parameter' do before do flipper[:my_feature].enable delete '/features/my_feature/actors', flipper_id: nil end it 'returns correct error response' do expect(last_response.status).to eq(422) expect(json_response).to eq(api_flipper_id_is_missing_response) end end describe 'enable missing feature' do before do post '/features/my_feature/actors', flipper_id: actor.flipper_id end it 'enables feature for actor' do expect(last_response.status).to eq(200) expect(flipper[:my_feature].enabled?(actor)).to be_truthy expect(flipper[:my_feature].enabled_gate_names).to eq([:actor]) end it 'returns decorated feature with actor enabled' do gate = json_response['gates'].find { |gate| gate['key'] == 'actors' } expect(gate['value']).to eq(['1']) end end describe 'disable missing feature' do before do delete '/features/my_feature/actors', flipper_id: actor.flipper_id end it 'disables feature' do expect(last_response.status).to eq(200) expect(flipper[:my_feature].enabled?(actor)).to be_falsy expect(flipper[:my_feature].enabled_gate_names).to be_empty end it 'returns decorated feature with boolean gate disabled' do gate = json_response['gates'].find { |gate| gate['key'] == 'actors' } expect(gate['value']).to be_empty end end end flipper-0.21.0/spec/flipper/api/v1/actions/actors_spec.rb000066400000000000000000000047361404600161700232300ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Api::V1::Actions::Actors do let(:app) { build_api(flipper) } let(:actor) { Flipper::Actor.new('User123') } describe 'GET /actors/:flipper_id' do before do flipper[:my_feature_1].enable flipper[:my_feature_2].disable flipper[:my_feature_3].enable_actor(actor) end context 'when no feature is specified' do before do get "/actors/#{actor.flipper_id}" end it 'responds with success' do expect(last_response.status).to eq(200) end it 'returns all features' do expected_response = { 'flipper_id' => 'User123', 'features' => { 'my_feature_1' => { 'enabled' => true, }, 'my_feature_2' => { 'enabled' => false, }, 'my_feature_3' => { 'enabled' => true, }, }, } expect(json_response).to eq(expected_response) end end context 'when features are specified' do before do get "/actors/#{actor.flipper_id}", keys: "my_feature_2,my_feature_3" end it 'responds with success' do expect(last_response.status).to eq(200) end it 'returns all specified features' do expected_response = { 'flipper_id' => 'User123', 'features' => { 'my_feature_2' => { 'enabled' => false, }, 'my_feature_3' => { 'enabled' => true, }, }, } expect(json_response).to eq(expected_response) end end context 'when non-existent features are specified' do before do get "/actors/#{actor.flipper_id}", keys: "my_feature_3,not_a_feature" end it 'responds with success' do expect(last_response.status).to eq(200) end it 'returns false for a non-existent feature' do expected_response = { 'flipper_id' => 'User123', 'features' => { 'my_feature_3' => { 'enabled' => true, }, 'not_a_feature' => { 'enabled' => false, }, }, } expect(json_response).to eq(expected_response) end end context 'when flipper id is missing' do before do get "/actors" end it 'responds with a 404' do expect(last_response.status).to eq(404) end end end end flipper-0.21.0/spec/flipper/api/v1/actions/boolean_gate_spec.rb000066400000000000000000000030301404600161700243360ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Api::V1::Actions::BooleanGate do let(:app) { build_api(flipper) } describe 'enable' do before do flipper[:my_feature].disable post '/features/my_feature/boolean' end it 'enables feature' do expect(last_response.status).to eq(200) expect(flipper[:my_feature].on?).to be_truthy end it 'returns decorated feature with boolean gate enabled' do boolean_gate = json_response['gates'].find { |gate| gate['key'] == 'boolean' } expect(boolean_gate['value']).to be_truthy end end describe 'enable feature with slash in name' do before do flipper["my/feature"].disable post '/features/my/feature/boolean' end it 'enables feature' do expect(last_response.status).to eq(200) expect(flipper["my/feature"].on?).to be_truthy end it 'returns decorated feature with boolean gate enabled' do boolean_gate = json_response['gates'].find { |gate| gate['key'] == 'boolean' } expect(boolean_gate['value']).to be_truthy end end describe 'disable' do before do flipper[:my_feature].enable delete '/features/my_feature/boolean' end it 'disables feature' do expect(last_response.status).to eq(200) expect(flipper[:my_feature].off?).to be_truthy end it 'returns decorated feature with boolean gate disabled' do boolean_gate = json_response['gates'].find { |gate| gate['key'] == 'boolean' } expect(boolean_gate['value']).to be_falsy end end end flipper-0.21.0/spec/flipper/api/v1/actions/clear_feature_spec.rb000066400000000000000000000023241404600161700245250ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Api::V1::Actions::ClearFeature do let(:app) { build_api(flipper) } describe 'clear' do before do Flipper.register(:admins) {} actor22 = Flipper::Actor.new('22') feature = flipper[:my_feature] feature.enable flipper.boolean feature.enable flipper.group(:admins) feature.enable flipper.actor(actor22) feature.enable flipper.actors(25) feature.enable flipper.time(45) delete '/features/my_feature/clear' end it 'clears feature' do expect(last_response.status).to eq(204) expect(flipper[:my_feature].off?).to be_truthy end end describe 'clear feature with slash in name' do before do Flipper.register(:admins) {} actor22 = Flipper::Actor.new('22') feature = flipper["my/feature"] feature.enable flipper.boolean feature.enable flipper.group(:admins) feature.enable flipper.actor(actor22) feature.enable flipper.actors(25) feature.enable flipper.time(45) delete '/features/my/feature/clear' end it 'clears feature' do expect(last_response.status).to eq(204) expect(flipper["my/feature"].off?).to be_truthy end end end flipper-0.21.0/spec/flipper/api/v1/actions/feature_spec.rb000066400000000000000000000131751404600161700233650ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Api::V1::Actions::Feature do let(:app) { build_api(flipper) } let(:feature) { build_feature } let(:gate) { feature.gate(:boolean) } describe 'get' do context 'enabled feature' do before do flipper[:my_feature].enable get '/features/my_feature' end it 'responds with correct attributes' do response_body = { 'key' => 'my_feature', 'state' => 'on', 'gates' => [ { 'key' => 'boolean', 'name' => 'boolean', 'value' => 'true', }, { 'key' => 'actors', 'name' => 'actor', 'value' => [], }, { 'key' => 'percentage_of_actors', 'name' => 'percentage_of_actors', 'value' => nil, }, { 'key' => 'percentage_of_time', 'name' => 'percentage_of_time', 'value' => nil, }, { 'key' => 'groups', 'name' => 'group', 'value' => [], }, ], } expect(last_response.status).to eq(200) expect(json_response).to eq(response_body) end end context 'disabled feature' do before do flipper[:my_feature].disable get '/features/my_feature' end it 'responds with correct attributes' do response_body = { 'key' => 'my_feature', 'state' => 'off', 'gates' => [ { 'key' => 'boolean', 'name' => 'boolean', 'value' => nil, }, { 'key' => 'actors', 'name' => 'actor', 'value' => [], }, { 'key' => 'percentage_of_actors', 'name' => 'percentage_of_actors', 'value' => nil, }, { 'key' => 'percentage_of_time', 'name' => 'percentage_of_time', 'value' => nil, }, { 'key' => 'groups', 'name' => 'group', 'value' => [], }, ], } expect(last_response.status).to eq(200) expect(json_response).to eq(response_body) end end context 'feature does not exist' do before do get '/features/not_a_feature' end it '404s' do expect(last_response.status).to eq(404) expected = { 'code' => 1, 'message' => 'Feature not found.', 'more_info' => api_error_code_reference_url, } expect(json_response).to eq(expected) end end context 'feature with name that ends in "features"' do before do flipper[:search_features].enable get '/features/search_features' end it 'responds with correct attributes' do response_body = { 'key' => 'search_features', 'state' => 'on', 'gates' => [ { 'key' => 'boolean', 'name' => 'boolean', 'value' => 'true', }, { 'key' => 'actors', 'name' => 'actor', 'value' => [], }, { 'key' => 'percentage_of_actors', 'name' => 'percentage_of_actors', 'value' => nil, }, { 'key' => 'percentage_of_time', 'name' => 'percentage_of_time', 'value' => nil, }, { 'key' => 'groups', 'name' => 'group', 'value' => [], }, ], } expect(last_response.status).to eq(200) expect(json_response).to eq(response_body) end end context 'feature with name that has slash' do before do flipper["my/feature"].enable get '/features/my/feature' end it 'responds with correct attributes' do response_body = { 'key' => 'my/feature', 'state' => 'on', 'gates' => [ { 'key' => 'boolean', 'name' => 'boolean', 'value' => 'true', }, { 'key' => 'actors', 'name' => 'actor', 'value' => [], }, { 'key' => 'percentage_of_actors', 'name' => 'percentage_of_actors', 'value' => nil, }, { 'key' => 'percentage_of_time', 'name' => 'percentage_of_time', 'value' => nil, }, { 'key' => 'groups', 'name' => 'group', 'value' => [], }, ], } expect(last_response.status).to eq(200) expect(json_response).to eq(response_body) end end end describe 'delete' do context 'succesful request' do it 'deletes feature' do flipper[:my_feature].enable expect(flipper.features.map(&:key)).to include('my_feature') delete '/features/my_feature' expect(last_response.status).to eq(204) expect(flipper.features.map(&:key)).not_to include('my_feature') end end context 'feature not found' do before do delete '/features/my_feature' end it 'responds with 204' do expect(last_response.status).to eq(204) expect(flipper.features.map(&:key)).not_to include('my_feature') end end end end flipper-0.21.0/spec/flipper/api/v1/actions/features_spec.rb000066400000000000000000000111721404600161700235430ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Api::V1::Actions::Features do let(:app) { build_api(flipper) } let(:feature) { build_feature } let(:admin) { double 'Fake Fliper Thing', flipper_id: 10 } describe 'get' do context 'with flipper features' do before do flipper[:my_feature].enable flipper[:my_feature].enable(admin) get '/features' end it 'responds with correct attributes' do expected_response = { 'features' => [ { 'key' => 'my_feature', 'state' => 'on', 'gates' => [ { 'key' => 'boolean', 'name' => 'boolean', 'value' => 'true', }, { 'key' => 'actors', 'name' => 'actor', 'value' => ['10'], }, { 'key' => 'percentage_of_actors', 'name' => 'percentage_of_actors', 'value' => nil, }, { 'key' => 'percentage_of_time', 'name' => 'percentage_of_time', 'value' => nil, }, { 'key' => 'groups', 'name' => 'group', 'value' => [], }, ], }, ], } expect(last_response.status).to eq(200) expect(json_response).to eq(expected_response) end end context 'with keys specified' do before do flipper[:audit_log].enable flipper[:issues].enable flipper[:search].enable flipper[:stats].disable get '/features', 'keys' => 'search,stats' end it 'responds with correct attributes' do expect(last_response.status).to eq(200) keys = json_response.fetch('features').map { |feature| feature.fetch('key') }.sort expect(keys).to eq(%w(search stats)) end end context 'with keys that are not existing features' do before do flipper[:search].disable flipper[:stats].disable get '/features', 'keys' => 'search,stats,not_a_feature,another_feature_that_does_not_exist' end it 'only returns features that exist' do expect(last_response.status).to eq(200) keys = json_response.fetch('features').map { |feature| feature.fetch('key') }.sort expect(keys).to eq(%w(search stats)) end end context 'with no flipper features' do before do get '/features' end it 'returns empty array for features key' do expected_response = { 'features' => [], } expect(last_response.status).to eq(200) expect(json_response).to eq(expected_response) end end end describe 'post' do context 'succesful request' do before do post '/features', name: 'my_feature' end it 'responds 200 ' do expect(last_response.status).to eq(200) end it 'returns decorated feature' do expected_response = { 'key' => 'my_feature', 'state' => 'off', 'gates' => [ { 'key' => 'boolean', 'name' => 'boolean', 'value' => nil, }, { 'key' => 'actors', 'name' => 'actor', 'value' => [], }, { 'key' => 'percentage_of_actors', 'name' => 'percentage_of_actors', 'value' => nil, }, { 'key' => 'percentage_of_time', 'name' => 'percentage_of_time', 'value' => nil, }, { 'key' => 'groups', 'name' => 'group', 'value' => [], }, ], } expect(json_response).to eq(expected_response) end it 'adds feature' do expect(flipper.features.map(&:key)).to include('my_feature') end it 'does not enable feature' do expect(flipper['my_feature'].enabled?).to be_falsy end end context 'bad request' do before do post '/features' end it 'returns correct status code' do expect(last_response.status).to eq(422) end it 'returns formatted error' do expected = { 'code' => 5, 'message' => 'Required parameter name is missing.', 'more_info' => api_error_code_reference_url, } expect(json_response).to eq(expected) end end end end flipper-0.21.0/spec/flipper/api/v1/actions/groups_gate_spec.rb000066400000000000000000000127211404600161700242450ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Api::V1::Actions::GroupsGate do let(:app) { build_api(flipper) } describe 'enable' do before do flipper[:my_feature].disable Flipper.register(:admins) do |actor| actor.respond_to?(:admin?) && actor.admin? end post '/features/my_feature/groups', name: 'admins' end it 'enables feature for group' do person = double allow(person).to receive(:flipper_id).and_return(1) allow(person).to receive(:admin?).and_return(true) expect(last_response.status).to eq(200) expect(flipper[:my_feature].enabled?(person)).to be_truthy end it 'returns decorated feature with group enabled' do group_gate = json_response['gates'].find { |m| m['name'] == 'group' } expect(group_gate['value']).to eq(['admins']) end end describe 'enable feature with slash in name' do before do flipper["my/feature"].disable Flipper.register(:admins) do |actor| actor.respond_to?(:admin?) && actor.admin? end post '/features/my/feature/groups', name: 'admins' end it 'enables feature for group' do person = double allow(person).to receive(:flipper_id).and_return(1) allow(person).to receive(:admin?).and_return(true) expect(last_response.status).to eq(200) expect(flipper["my/feature"].enabled?(person)).to be_truthy end it 'returns decorated feature with group enabled' do group_gate = json_response['gates'].find { |m| m['name'] == 'group' } expect(group_gate['value']).to eq(['admins']) end end describe 'enable without name params' do before do flipper[:my_feature].disable Flipper.register(:admins) do |actor| actor.respond_to?(:admin?) && actor.admin? end post '/features/my_feature/groups' end it 'returns correct status code' do expect(last_response.status).to eq(422) end it 'returns formatted error' do expected = { 'code' => 5, 'message' => 'Required parameter name is missing.', 'more_info' => api_error_code_reference_url, } expect(json_response).to eq(expected) end end describe 'disable' do before do flipper[:my_feature].disable Flipper.register(:admins) do |actor| actor.respond_to?(:admin?) && actor.admin? end flipper[:my_feature].enable_group(:admins) delete '/features/my_feature/groups', name: 'admins' end it 'disables feature for group' do person = double allow(person).to receive(:flipper_id).and_return(1) allow(person).to receive(:admin?).and_return(true) expect(last_response.status).to eq(200) expect(flipper[:my_feature].enabled?(person)).to be_falsey end it 'returns decorated feature with group disabled' do group_gate = json_response['gates'].find { |m| m['name'] == 'group' } expect(group_gate['value']).to eq([]) end end describe 'disable for non-existent feature' do before do Flipper.register(:admins) do |actor| actor.respond_to?(:admin?) && actor.admin? end delete '/features/my_feature/groups', name: 'admins' end it 'disables feature for group' do person = double allow(person).to receive(:flipper_id).and_return(1) allow(person).to receive(:admin?).and_return(true) expect(last_response.status).to eq(200) expect(flipper[:my_feature].enabled?(person)).to be_falsey end it 'returns decorated feature with group disabled' do group_gate = json_response['gates'].find { |m| m['name'] == 'group' } expect(group_gate['value']).to eq([]) end end describe 'disable for group not registered' do before do flipper[:my_feature].disable delete '/features/my_feature/groups', name: 'admins' end it '404s with correct error response when group not registered' do expect(last_response.status).to eq(404) expected = { 'code' => 2, 'message' => 'Group not registered.', 'more_info' => api_error_code_reference_url, } expect(json_response).to eq(expected) end end describe 'enable for group not registered when allow_unregistered_groups is true' do before do Flipper.unregister_groups flipper[:my_feature].disable post '/features/my_feature/groups', name: 'admins', allow_unregistered_groups: 'true' end it 'responds successfully' do expect(last_response.status).to eq(200) end it 'returns decorated feature with group in groups set' do group_gate = json_response['gates'].find { |m| m['name'] == 'group' } expect(group_gate['value']).to eq(['admins']) end it 'enables group' do expect(flipper[:my_feature].groups_value).to eq(Set["admins"]) end end describe 'disable for group not registered when allow_unregistered_groups is true' do before do Flipper.unregister_groups flipper[:my_feature].disable flipper[:my_feature].enable_group(:admins) delete '/features/my_feature/groups', name: 'admins', allow_unregistered_groups: 'true' end it 'responds successfully' do expect(last_response.status).to eq(200) end it 'returns decorated feature with group not in groups set' do group_gate = json_response['gates'].find { |m| m['name'] == 'group' } expect(group_gate['value']).to eq([]) end it 'disables group' do expect(flipper[:my_feature].groups_value).to be_empty end end end flipper-0.21.0/spec/flipper/api/v1/actions/percentage_of_actors_gate_spec.rb000066400000000000000000000113121404600161700270750ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Api::V1::Actions::PercentageOfActorsGate do let(:app) { build_api(flipper) } describe 'enable' do shared_examples 'gates with percentage' do it 'enables gate for feature' do expect(flipper[path].enabled_gate_names).to include(:percentage_of_actors) end it 'returns decorated feature with gate enabled for a percent of actors' do gate = json_response['gates'].find { |gate| gate['name'] == 'percentage_of_actors' } expect(gate['value']).to eq(percentage) end end context 'for feature with slash in name' do let(:path) { 'my/feature' } before do flipper[path].disable post "/features/#{path}/percentage_of_actors", percentage: percentage end context 'with integer percentage' do let(:percentage) { '10' } it_behaves_like 'gates with percentage' end context 'with decimal percentage' do let(:percentage) { '0.05' } it_behaves_like 'gates with percentage' end end context 'url-encoded request' do let(:path) { :my_feature } before do flipper[:my_feature].disable post "/features/#{path}/percentage_of_actors", percentage: percentage end context 'with integer percentage' do let(:percentage) { '10' } it_behaves_like 'gates with percentage' end context 'with decimal percentage' do let(:percentage) { '0.05' } it_behaves_like 'gates with percentage' end end context 'json request' do let(:path) { :my_feature } before do flipper[:my_feature].disable post "/features/#{path}/percentage_of_actors", JSON.generate(percentage: percentage), 'CONTENT_TYPE' => 'application/json' end context 'with integer percentage' do let(:percentage) { '10' } it_behaves_like 'gates with percentage' end context 'with decimal percentage' do let(:percentage) { '0.05' } it_behaves_like 'gates with percentage' end end end describe 'disable without percentage' do before do flipper[:my_feature].enable_percentage_of_actors(10) delete '/features/my_feature/percentage_of_actors' end it 'disables gate for feature' do expect(flipper[:my_feature].enabled_gates).to be_empty end it 'returns decorated feature with gate disabled' do gate = json_response['gates'].find { |gate| gate['name'] == 'percentage_of_actors' } expect(gate['value']).to eq('0') end end describe 'disable with percentage' do before do flipper[:my_feature].enable_percentage_of_actors(10) delete '/features/my_feature/percentage_of_actors', JSON.generate(percentage: '5'), 'CONTENT_TYPE' => 'application/json' end it 'returns decorated feature with gate value set to 0 regardless of percentage requested' do gate = json_response['gates'].find { |gate| gate['name'] == 'percentage_of_actors' } expect(gate['value']).to eq('0') end end describe 'non-existent feature' do before do delete '/features/my_feature/percentage_of_actors' end it 'disables gate for feature' do expect(flipper[:my_feature].enabled_gates).to be_empty end it 'returns decorated feature with gate disabled' do expect(last_response.status).to eq(200) gate = json_response['gates'].find { |gate| gate['name'] == 'percentage_of_actors' } expect(gate['value']).to eq('0') end end describe 'out of range parameter percentage parameter' do before do flipper[:my_feature].disable post '/features/my_feature/percentage_of_actors', percentage: '300' end it '422s with correct error response when percentage parameter is invalid' do expect(last_response.status).to eq(422) expect(json_response).to eq(api_positive_percentage_error_response) end end describe 'percentage parameter not a number' do before do flipper[:my_feature].disable post '/features/my_feature/percentage_of_actors', percentage: 'foo' end it '422s with correct error response when percentage parameter is invalid' do expect(last_response.status).to eq(422) expect(json_response).to eq(api_positive_percentage_error_response) end end describe 'missing percentage parameter' do before do flipper[:my_feature].disable post '/features/my_feature/percentage_of_actors' end it '422s with correct error response when percentage parameter is missing' do expect(last_response.status).to eq(422) expect(json_response).to eq(api_positive_percentage_error_response) end end end flipper-0.21.0/spec/flipper/api/v1/actions/percentage_of_time_gate_spec.rb000066400000000000000000000112501404600161700265410ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Api::V1::Actions::PercentageOfTimeGate do let(:app) { build_api(flipper) } describe 'enable' do shared_examples 'gates with percentage' do it 'enables gate for feature' do expect(flipper[path].enabled_gate_names).to include(:percentage_of_time) end it 'returns decorated feature with gate enabled for a percent of times' do gate = json_response['gates'].find { |gate| gate['name'] == 'percentage_of_time' } expect(gate['value']).to eq(percentage) end end context 'for feature with slash in name' do let(:path) { 'my/feature' } before do flipper[path].disable post "/features/#{path}/percentage_of_time", percentage: percentage end context 'with integer percentage' do let(:percentage) { '10' } it_behaves_like 'gates with percentage' end context 'with decimal percentage' do let(:percentage) { '0.05' } it_behaves_like 'gates with percentage' end end context 'url-encoded request' do let(:path) { :my_feature } before do flipper[:my_feature].disable post "/features/#{path}/percentage_of_time", percentage: percentage end context 'with integer percentage' do let(:percentage) { '10' } it_behaves_like 'gates with percentage' end context 'with decimal percentage' do let(:percentage) { '0.05' } it_behaves_like 'gates with percentage' end end context 'json request' do let(:path) { :my_feature } before do flipper[:my_feature].disable post "/features/#{path}/percentage_of_time", JSON.generate(percentage: percentage), 'CONTENT_TYPE' => 'application/json' end context 'with integer percentage' do let(:percentage) { '10' } it_behaves_like 'gates with percentage' end context 'with decimal percentage' do let(:percentage) { '0.05' } it_behaves_like 'gates with percentage' end end end describe 'disable without percentage' do before do flipper[:my_feature].enable_percentage_of_time(10) delete '/features/my_feature/percentage_of_time' end it 'disables gate for feature' do expect(flipper[:my_feature].enabled_gates).to be_empty end it 'returns decorated feature with gate disabled' do gate = json_response['gates'].find { |gate| gate['name'] == 'percentage_of_time' } expect(gate['value']).to eq('0') end end describe 'disable with percentage' do before do flipper[:my_feature].enable_percentage_of_time(10) delete '/features/my_feature/percentage_of_time', JSON.generate(percentage: '5'), 'CONTENT_TYPE' => 'application/json' end it 'returns decorated feature with gate value set to 0 regardless of percentage requested' do expect(last_response.status).to eq(200) gate = json_response['gates'].find { |gate| gate['name'] == 'percentage_of_time' } expect(gate['value']).to eq('0') end end describe 'non-existent feature' do before do delete '/features/my_feature/percentage_of_time' end it 'disables gate for feature' do expect(flipper[:my_feature].enabled_gates).to be_empty end it 'returns decorated feature with gate disabled' do gate = json_response['gates'].find { |gate| gate['name'] == 'percentage_of_time' } expect(gate['value']).to eq('0') end end describe 'out of range parameter percentage parameter' do before do flipper[:my_feature].disable post '/features/my_feature/percentage_of_time', percentage: '300' end it '422s with correct error response when percentage parameter is invalid' do expect(last_response.status).to eq(422) expect(json_response).to eq(api_positive_percentage_error_response) end end describe 'percentage parameter not an number' do before do flipper[:my_feature].disable post '/features/my_feature/percentage_of_time', percentage: 'foo' end it '422s with correct error response when percentage parameter is invalid' do expect(last_response.status).to eq(422) expect(json_response).to eq(api_positive_percentage_error_response) end end describe 'missing percentage parameter' do before do flipper[:my_feature].disable post '/features/my_feature/percentage_of_time' end it '422s with correct error response when percentage parameter is missing' do expect(last_response.status).to eq(422) expect(json_response).to eq(api_positive_percentage_error_response) end end end flipper-0.21.0/spec/flipper/api_spec.rb000066400000000000000000000041431404600161700177370ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Api do context 'when initialized with flipper instance and flipper instance in env' do let(:app) { build_api(flipper) } it 'uses env instance over initialized instance' do flipper[:built_a].enable flipper[:built_b].disable env_flipper = build_flipper env_flipper[:env_a].enable env_flipper[:env_b].disable params = {} env = { 'flipper' => env_flipper, } get '/features', params, env expect(last_response.status).to eq(200) feature_names = json_response.fetch('features').map { |feature| feature.fetch('key') } expect(feature_names).to eq(%w(env_a env_b)) end end context 'when initialized without flipper instance but flipper instance in env' do let(:app) { described_class.app } it 'uses env instance' do flipper[:a].enable flipper[:b].disable params = {} env = { 'flipper' => flipper, } get '/features', params, env expect(last_response.status).to eq(200) feature_names = json_response.fetch('features').map { |feature| feature.fetch('key') } expect(feature_names).to eq(%w(a b)) end end context 'when initialized with env_key' do let(:app) { build_api(flipper, env_key: 'flipper_api') } it 'uses provided env key instead of default' do flipper[:a].enable flipper[:b].disable default_env_flipper = build_flipper default_env_flipper[:env_a].enable default_env_flipper[:env_b].disable params = {} env = { 'flipper' => default_env_flipper, 'flipper_api' => flipper, } get '/features', params, env expect(last_response.status).to eq(200) feature_names = json_response.fetch('features').map { |feature| feature.fetch('key') } expect(feature_names).to eq(%w(a b)) end end context "when request does not match any api routes" do let(:app) { build_api(flipper) } it "returns 404" do get '/gibberish' expect(last_response.status).to eq(404) expect(json_response).to eq({}) end end end flipper-0.21.0/spec/flipper/cloud/000077500000000000000000000000001404600161700167335ustar00rootroot00000000000000flipper-0.21.0/spec/flipper/cloud/configuration_spec.rb000066400000000000000000000175501404600161700231510ustar00rootroot00000000000000require 'helper' require 'flipper/cloud/configuration' require 'flipper/adapters/instrumented' RSpec.describe Flipper::Cloud::Configuration do let(:required_options) do { token: "asdf" } end it "can set token" do instance = described_class.new(required_options) expect(instance.token).to eq(required_options[:token]) end it "can set token from ENV var" do with_modified_env "FLIPPER_CLOUD_TOKEN" => "from_env" do instance = described_class.new(required_options.reject { |k, v| k == :token }) expect(instance.token).to eq("from_env") end end it "can set instrumenter" do instrumenter = Object.new instance = described_class.new(required_options.merge(instrumenter: instrumenter)) expect(instance.instrumenter).to be(instrumenter) end it "can set read_timeout" do instance = described_class.new(required_options.merge(read_timeout: 5)) expect(instance.read_timeout).to eq(5) end it "can set read_timeout from ENV var" do with_modified_env "FLIPPER_CLOUD_READ_TIMEOUT" => "9" do instance = described_class.new(required_options.reject { |k, v| k == :read_timeout }) expect(instance.read_timeout).to eq(9) end end it "can set open_timeout" do instance = described_class.new(required_options.merge(open_timeout: 5)) expect(instance.open_timeout).to eq(5) end it "can set open_timeout from ENV var" do with_modified_env "FLIPPER_CLOUD_OPEN_TIMEOUT" => "9" do instance = described_class.new(required_options.reject { |k, v| k == :open_timeout }) expect(instance.open_timeout).to eq(9) end end it "can set write_timeout" do instance = described_class.new(required_options.merge(write_timeout: 5)) expect(instance.write_timeout).to eq(5) end it "can set write_timeout from ENV var" do with_modified_env "FLIPPER_CLOUD_WRITE_TIMEOUT" => "9" do instance = described_class.new(required_options.reject { |k, v| k == :write_timeout }) expect(instance.write_timeout).to eq(9) end end it "can set sync_interval" do instance = described_class.new(required_options.merge(sync_interval: 1)) expect(instance.sync_interval).to eq(1) end it "can set sync_interval from ENV var" do with_modified_env "FLIPPER_CLOUD_SYNC_INTERVAL" => "5" do instance = described_class.new(required_options.reject { |k, v| k == :sync_interval }) expect(instance.sync_interval).to eq(5) end end it "passes sync_interval into sync adapter" do # The initial sync of http to local invokes this web request. stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}") instance = described_class.new(required_options.merge(sync_interval: 1)) expect(instance.adapter.synchronizer.interval).to be(1) end it "can set debug_output" do instance = described_class.new(required_options.merge(debug_output: STDOUT)) expect(instance.debug_output).to eq(STDOUT) end it "defaults adapter block" do # The initial sync of http to local invokes this web request. stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}") instance = described_class.new(required_options) expect(instance.adapter).to be_instance_of(Flipper::Adapters::Sync) end it "can override adapter block" do # The initial sync of http to local invokes this web request. stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}") instance = described_class.new(required_options) instance.adapter do |adapter| Flipper::Adapters::Instrumented.new(adapter) end expect(instance.adapter).to be_instance_of(Flipper::Adapters::Instrumented) end it "defaults url" do instance = described_class.new(required_options.reject { |k, v| k == :url }) expect(instance.url).to eq("https://www.flippercloud.io/adapter") end it "can override url using options" do options = required_options.merge(url: "http://localhost:5000/adapter") instance = described_class.new(options) expect(instance.url).to eq("http://localhost:5000/adapter") instance = described_class.new(required_options) instance.url = "http://localhost:5000/adapter" expect(instance.url).to eq("http://localhost:5000/adapter") end it "can override URL using ENV var" do with_modified_env "FLIPPER_CLOUD_URL" => "https://example.com" do instance = described_class.new(required_options.reject { |k, v| k == :url }) expect(instance.url).to eq("https://example.com") end end it "defaults sync_method to :poll" do instance = described_class.new(required_options) expect(instance.sync_method).to eq(:poll) end it "sets sync_method to :webhook if sync_secret provided" do instance = described_class.new(required_options.merge({ sync_secret: "secret", })) expect(instance.sync_method).to eq(:webhook) expect(instance.adapter).to be_instance_of(Flipper::Adapters::DualWrite) end it "sets sync_method to :webhook if FLIPPER_CLOUD_SYNC_SECRET set" do with_modified_env "FLIPPER_CLOUD_SYNC_SECRET" => "abc" do instance = described_class.new(required_options) expect(instance.sync_method).to eq(:webhook) expect(instance.adapter).to be_instance_of(Flipper::Adapters::DualWrite) end end it "can set sync_secret" do instance = described_class.new(required_options.merge(sync_secret: "from_config")) expect(instance.sync_secret).to eq("from_config") end it "can override sync_secret using ENV var" do with_modified_env "FLIPPER_CLOUD_SYNC_SECRET" => "from_env" do instance = described_class.new(required_options.reject { |k, v| k == :sync_secret }) expect(instance.sync_secret).to eq("from_env") end end it "can sync with cloud" do body = JSON.generate({ "features": [ { "key": "search", "state": "on", "gates": [ { "key": "boolean", "name": "boolean", "value": true }, { "key": "groups", "name": "group", "value": [] }, { "key": "actors", "name": "actor", "value": [] }, { "key": "percentage_of_actors", "name": "percentage_of_actors", "value": 0 }, { "key": "percentage_of_time", "name": "percentage_of_time", "value": 0 } ] }, { "key": "history", "state": "off", "gates": [ { "key": "boolean", "name": "boolean", "value": false }, { "key": "groups", "name": "group", "value": [] }, { "key": "actors", "name": "actor", "value": [] }, { "key": "percentage_of_actors", "name": "percentage_of_actors", "value": 0 }, { "key": "percentage_of_time", "name": "percentage_of_time", "value": 0 } ] } ] }) stub = stub_request(:get, "https://www.flippercloud.io/adapter/features"). with({ headers: { 'Flipper-Cloud-Token'=>'asdf', }, }).to_return(status: 200, body: body, headers: {}) instance = described_class.new(required_options) instance.sync # Check that remote was fetched. expect(stub).to have_been_requested # Check that local adapter really did sync. local_adapter = instance.adapter.instance_variable_get("@local") all = local_adapter.get_all expect(all.keys).to eq(["search", "history"]) expect(all["search"][:boolean]).to eq("true") expect(all["history"][:boolean]).to eq(nil) end end flipper-0.21.0/spec/flipper/cloud/dsl_spec.rb000066400000000000000000000051151404600161700210560ustar00rootroot00000000000000require 'helper' require 'flipper/cloud/configuration' require 'flipper/cloud/dsl' require 'flipper/adapters/operation_logger' require 'flipper/adapters/instrumented' RSpec.describe Flipper::Cloud::DSL do it 'delegates everything to flipper instance' do cloud_configuration = Flipper::Cloud::Configuration.new({ token: "asdf", sync_secret: "tasty", }) dsl = described_class.new(cloud_configuration) expect(dsl.features).to eq(Set.new) expect(dsl.enabled?(:foo)).to be(false) end it 'delegates sync to cloud configuration' do stub = stub_request(:get, "https://www.flippercloud.io/adapter/features"). with({ headers: { 'Flipper-Cloud-Token'=>'asdf', }, }).to_return(status: 200, body: '{"features": {}}', headers: {}) cloud_configuration = Flipper::Cloud::Configuration.new({ token: "asdf", sync_secret: "tasty", }) dsl = described_class.new(cloud_configuration) dsl.sync expect(stub).to have_been_requested end it 'delegates sync_secret to cloud configuration' do cloud_configuration = Flipper::Cloud::Configuration.new({ token: "asdf", sync_secret: "tasty", }) dsl = described_class.new(cloud_configuration) expect(dsl.sync_secret).to eq("tasty") end context "when sync_method is webhook" do let(:local_adapter) do Flipper::Adapters::OperationLogger.new Flipper::Adapters::Memory.new end let(:cloud_configuration) do cloud_configuration = Flipper::Cloud::Configuration.new({ token: "asdf", sync_secret: "tasty", local_adapter: local_adapter }) end subject do described_class.new(cloud_configuration) end it "sends reads to local adapter" do subject.features subject.enabled?(:foo) expect(local_adapter.count(:features)).to be(1) expect(local_adapter.count(:get)).to be(1) end it "sends writes to cloud and local" do add_stub = stub_request(:post, "https://www.flippercloud.io/adapter/features"). with({headers: {'Flipper-Cloud-Token'=>'asdf'}}). to_return(status: 200, body: '{}', headers: {}) enable_stub = stub_request(:post, "https://www.flippercloud.io/adapter/features/foo/boolean"). with(headers: {'Flipper-Cloud-Token'=>'asdf'}). to_return(status: 200, body: '{}', headers: {}) subject.enable(:foo) expect(local_adapter.count(:add)).to be(1) expect(local_adapter.count(:enable)).to be(1) expect(add_stub).to have_been_requested expect(enable_stub).to have_been_requested end end end flipper-0.21.0/spec/flipper/cloud/engine_spec.rb000066400000000000000000000052351404600161700215440ustar00rootroot00000000000000require 'helper' require 'rails' require 'flipper/cloud' RSpec.describe Flipper::Cloud::Engine do let(:env) do { "FLIPPER_CLOUD_TOKEN" => "test-token" } end let(:application) do Class.new(Rails::Application) do config.eager_load = false config.logger = ActiveSupport::Logger.new($stdout) end end # App for Rack::Test let(:app) { application.routes } before do Rails.application = nil # Force loading of flipper to configure itself load 'flipper/cloud.rb' end it "initializes cloud configuration" do stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}") with_modified_env env do application.initialize! expect(Flipper.instance).to be_a(Flipper::Cloud::DSL) expect(Flipper.instance.instrumenter).to be(ActiveSupport::Notifications) end end context "with CLOUD_SYNC_SECRET" do before do env.update "FLIPPER_CLOUD_SYNC_SECRET" => "test-secret" end let(:request_body) do JSON.generate({ "environment_id" => 1, "webhook_id" => 1, "delivery_id" => SecureRandom.uuid, "action" => "sync", }) end let(:timestamp) { Time.now } let(:signature) { Flipper::Cloud::MessageVerifier.new(secret: env["FLIPPER_CLOUD_SYNC_SECRET"]).generate(request_body, timestamp) } let(:signature_header_value) { Flipper::Cloud::MessageVerifier.new(secret: "").header(signature, timestamp) } it "configures webhook app" do with_modified_env env do application.initialize! stub = stub_request(:get, "https://www.flippercloud.io/adapter/features").with({ headers: { "Flipper-Cloud-Token" => ENV["FLIPPER_CLOUD_TOKEN"] }, }).to_return(status: 200, body: JSON.generate({ features: {} }), headers: {}) post "/_flipper", request_body, { "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value } expect(last_response.status).to eq(200) expect(stub).to have_been_requested end end end context "without CLOUD_SYNC_SECRET" do it "does not configure webhook app" do with_modified_env env do application.initialize! post "/_flipper" expect(last_response.status).to eq(404) end end end context "without FLIPPER_CLOUD_TOKEN" do it "gracefully skips configuring webhook app" do with_modified_env "FLIPPER_CLOUD_TOKEN" => nil do application.initialize! expect(silence { Flipper.instance }).to match(/Missing FLIPPER_CLOUD_TOKEN/) expect(Flipper.instance).to be_a(Flipper::DSL) post "/_flipper" expect(last_response.status).to eq(404) end end end end flipper-0.21.0/spec/flipper/cloud/message_verifier_spec.rb000066400000000000000000000107631404600161700236200ustar00rootroot00000000000000require 'helper' require 'flipper/cloud/message_verifier' RSpec.describe Flipper::Cloud::MessageVerifier do let(:payload) { "some payload" } let(:secret) { "secret" } let(:timestamp) { Time.now } describe "#generate" do it "generates signature that can be verified" do message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret) signature = message_verifier.generate(payload, timestamp) header = generate_header(timestamp: timestamp, signature: signature) expect(message_verifier.verify(payload, header)).to be(true) end end describe "#header" do it "generates a header in valid format" do version = "v1" message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret, version: version) signature = message_verifier.generate(payload, timestamp) header = message_verifier.header(signature, timestamp) expect(header).to eq("t=#{timestamp.to_i},#{version}=#{signature}") end end describe ".header" do it "generates a header in valid format" do version = "v1" message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret, version: version) signature = message_verifier.generate(payload, timestamp) header = Flipper::Cloud::MessageVerifier.header(signature, timestamp, version) expect(header).to eq("t=#{timestamp.to_i},#{version}=#{signature}") end end describe "#verify" do it "raises a InvalidSignature when the header does not have the expected format" do header = "i'm not even a real signature header" expect { message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret") message_verifier.verify(payload, header) }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, "Unable to extract timestamp and signatures from header") end it "raises a InvalidSignature when there are no signatures with the expected version" do header = generate_header(version: "v0") expect { message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret") message_verifier.verify(payload, header) }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, /No signatures found with expected version/) end it "raises a InvalidSignature when there are no valid signatures for the payload" do header = generate_header(signature: "bad_signature") expect { message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret") message_verifier.verify(payload, header) }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, "No signatures found matching the expected signature for payload") end it "raises a InvalidSignature when the timestamp is not within the tolerance" do header = generate_header(timestamp: Time.now - 15) expect { message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret) message_verifier.verify(payload, header, tolerance: 10) }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, /Timestamp outside the tolerance zone/) end it "returns true when the header contains a valid signature and the timestamp is within the tolerance" do header = generate_header message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret") expect(message_verifier.verify(payload, header, tolerance: 10)).to be(true) end it "returns true when the header contains at least one valid signature" do header = generate_header + ",v1=bad_signature" message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret) expect(message_verifier.verify(payload, header, tolerance: 10)).to be(true) end it "returns true when the header contains a valid signature and the timestamp is off but no tolerance is provided" do header = generate_header(timestamp: Time.at(12_345)) message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret) expect(message_verifier.verify(payload, header)).to be(true) end end private def generate_header(options = {}) options[:secret] ||= secret options[:version] ||= "v1" message_verifier = Flipper::Cloud::MessageVerifier.new(secret: options[:secret], version: options[:version]) options[:timestamp] ||= timestamp options[:payload] ||= payload options[:signature] ||= message_verifier.generate(options[:payload], options[:timestamp]) Flipper::Cloud::MessageVerifier.header(options[:signature], options[:timestamp], options[:version]) end end flipper-0.21.0/spec/flipper/cloud/middleware_spec.rb000066400000000000000000000220461404600161700224130ustar00rootroot00000000000000require 'securerandom' require 'helper' require 'flipper/cloud' require 'flipper/cloud/middleware' require 'flipper/adapters/operation_logger' RSpec.describe Flipper::Cloud::Middleware do let(:flipper) { Flipper::Cloud.new(token: "regular") do |config| config.local_adapter = Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new) config.sync_secret = "regular_tasty" end } let(:env_flipper) { Flipper::Cloud.new(token: "env") do |config| config.local_adapter = Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new) config.sync_secret = "env_tasty" end } let(:app) { Flipper::Cloud.app(flipper) } let(:response_body) { JSON.generate({features: {}}) } let(:request_body) { JSON.generate({ "environment_id" => 1, "webhook_id" => 1, "delivery_id" => SecureRandom.uuid, "action" => "sync", }) } let(:timestamp) { Time.now } let(:signature) { Flipper::Cloud::MessageVerifier.new(secret: flipper.sync_secret).generate(request_body, timestamp) } let(:signature_header_value) { Flipper::Cloud::MessageVerifier.new(secret: "").header(signature, timestamp) } context 'when initializing middleware with flipper instance' do let(:app) { Flipper::Cloud.app(flipper) } it 'uses instance to sync' do Flipper.register(:admins) { |*args| false } Flipper.register(:staff) { |*args| false } Flipper.register(:basic) { |*args| false } Flipper.register(:plus) { |*args| false } Flipper.register(:premium) { |*args| false } stub = stub_request_for_token('regular') env = { "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value, } post '/', request_body, env expect(last_response.status).to eq(200) expect(JSON.parse(last_response.body)).to eq({ "groups" => [ {"name" => "admins"}, {"name" => "staff"}, {"name" => "basic"}, {"name" => "plus"}, {"name" => "premium"}, ], }) expect(stub).to have_been_requested end end context 'when signature is invalid' do let(:app) { Flipper::Cloud.app(flipper) } let(:signature) { Flipper::Cloud::MessageVerifier.new(secret: "nope").generate(request_body, timestamp) } it 'uses instance to sync' do stub = stub_request_for_token('regular') env = { "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value, } post '/', request_body, env expect(last_response.status).to eq(400) expect(stub).not_to have_been_requested end end context "when flipper cloud responds with 402" do let(:app) { Flipper::Cloud.app(flipper) } it "results in 402" do Flipper.register(:admins) { |*args| false } Flipper.register(:staff) { |*args| false } Flipper.register(:basic) { |*args| false } Flipper.register(:plus) { |*args| false } Flipper.register(:premium) { |*args| false } stub = stub_request_for_token('regular', status: 402) env = { "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value, } post '/', request_body, env expect(last_response.status).to eq(402) expect(last_response.headers["Flipper-Cloud-Response-Error-Class"]).to eq("Flipper::Adapters::Http::Error") expect(last_response.headers["Flipper-Cloud-Response-Error-Message"]).to include("Failed with status: 402") expect(stub).to have_been_requested end end context "when flipper cloud responds with non-402 and non-2xx code" do let(:app) { Flipper::Cloud.app(flipper) } it "results in 500" do Flipper.register(:admins) { |*args| false } Flipper.register(:staff) { |*args| false } Flipper.register(:basic) { |*args| false } Flipper.register(:plus) { |*args| false } Flipper.register(:premium) { |*args| false } stub = stub_request_for_token('regular', status: 503) env = { "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value, } post '/', request_body, env expect(last_response.status).to eq(500) expect(last_response.headers["Flipper-Cloud-Response-Error-Class"]).to eq("Flipper::Adapters::Http::Error") expect(last_response.headers["Flipper-Cloud-Response-Error-Message"]).to include("Failed with status: 503") expect(stub).to have_been_requested end end context "when flipper cloud responds with timeout" do let(:app) { Flipper::Cloud.app(flipper) } it "results in 500" do Flipper.register(:admins) { |*args| false } Flipper.register(:staff) { |*args| false } Flipper.register(:basic) { |*args| false } Flipper.register(:plus) { |*args| false } Flipper.register(:premium) { |*args| false } stub = stub_request_for_token('regular', status: :timeout) env = { "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value, } post '/', request_body, env expect(last_response.status).to eq(500) expect(last_response.headers["Flipper-Cloud-Response-Error-Class"]).to eq("Net::OpenTimeout") expect(last_response.headers["Flipper-Cloud-Response-Error-Message"]).to eq("execution expired") expect(stub).to have_been_requested end end context 'when initialized with flipper instance and flipper instance in env' do let(:app) { Flipper::Cloud.app(flipper) } let(:signature) { Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp) } it 'uses env instance to sync' do stub = stub_request_for_token('env') env = { "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value, 'flipper' => env_flipper, } post '/', request_body, env expect(last_response.status).to eq(200) expect(stub).to have_been_requested end end context 'when initialized without flipper instance but flipper instance in env' do let(:app) { Flipper::Cloud.app } let(:signature) { Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp) } it 'uses env instance to sync' do stub = stub_request_for_token('env') env = { "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value, 'flipper' => env_flipper, } post '/', request_body, env expect(last_response.status).to eq(200) expect(stub).to have_been_requested end end context 'when initialized with env_key' do let(:app) { Flipper::Cloud.app(flipper, env_key: 'flipper_cloud') } let(:signature) { Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp) } it 'uses provided env key instead of default' do stub = stub_request_for_token('env') env = { "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value, 'flipper' => flipper, 'flipper_cloud' => env_flipper, } post '/', request_body, env expect(last_response.status).to eq(200) expect(stub).to have_been_requested end end context 'when initializing lazily with a block' do let(:app) { Flipper::Cloud.app(-> { flipper }) } it 'works' do stub = stub_request_for_token('regular') env = { "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value, } post '/', request_body, env expect(last_response.status).to eq(200) expect(stub).to have_been_requested end end context 'when using older /webhooks path' do let(:app) { Flipper::Cloud.app(flipper) } it 'uses instance to sync' do Flipper.register(:admins) { |*args| false } Flipper.register(:staff) { |*args| false } Flipper.register(:basic) { |*args| false } Flipper.register(:plus) { |*args| false } Flipper.register(:premium) { |*args| false } stub = stub_request_for_token('regular') env = { "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value, } post '/webhooks', request_body, env expect(last_response.status).to eq(200) expect(JSON.parse(last_response.body)).to eq({ "groups" => [ {"name" => "admins"}, {"name" => "staff"}, {"name" => "basic"}, {"name" => "plus"}, {"name" => "premium"}, ], }) expect(stub).to have_been_requested end end describe 'Request method unsupported' do it 'skips middleware' do get '/' expect(last_response.status).to eq(404) expect(last_response.content_type).to eq("application/json") expect(last_response.body).to eq("{}") end end describe 'Inspecting the built Rack app' do it 'returns a String' do expect(Flipper::Cloud.app(flipper).inspect).to be_a(String) end end private def stub_request_for_token(token, status: 200) stub = stub_request(:get, "https://www.flippercloud.io/adapter/features"). with({ headers: { 'Flipper-Cloud-Token' => token, }, }) if status == :timeout stub.to_timeout else stub.to_return(status: status, body: response_body, headers: {}) end end end flipper-0.21.0/spec/flipper/cloud_spec.rb000066400000000000000000000170151404600161700202760ustar00rootroot00000000000000require 'helper' require 'flipper/cloud' require 'flipper/adapters/instrumented' require 'flipper/instrumenters/memory' RSpec.describe Flipper::Cloud do before do stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}") end context "initialize with token" do let(:token) { 'asdf' } before do @instance = described_class.new(token: token) memoized_adapter = @instance.adapter sync_adapter = memoized_adapter.adapter @http_adapter = sync_adapter.instance_variable_get('@remote') @http_client = @http_adapter.instance_variable_get('@client') end it 'returns Flipper::DSL instance' do expect(@instance).to be_instance_of(Flipper::Cloud::DSL) end it 'can read the cloud configuration' do expect(@instance.cloud_configuration).to be_instance_of(Flipper::Cloud::Configuration) end it 'configures instance to use http adapter' do expect(@http_adapter).to be_instance_of(Flipper::Adapters::Http) end it 'sets up correct url' do uri = @http_client.instance_variable_get('@uri') expect(uri.scheme).to eq('https') expect(uri.host).to eq('www.flippercloud.io') expect(uri.path).to eq('/adapter') end it 'sets correct token header' do headers = @http_client.instance_variable_get('@headers') expect(headers['Flipper-Cloud-Token']).to eq(token) end it 'uses noop instrumenter' do expect(@instance.instrumenter).to be(Flipper::Instrumenters::Noop) end end context 'initialize with token and options' do before do stub_request(:get, /fakeflipper\.com/).to_return(status: 200, body: "{}") @instance = described_class.new(token: 'asdf', url: 'https://www.fakeflipper.com/sadpanda') memoized_adapter = @instance.adapter sync_adapter = memoized_adapter.adapter @http_adapter = sync_adapter.instance_variable_get('@remote') @http_client = @http_adapter.instance_variable_get('@client') end it 'sets correct url' do uri = @http_client.instance_variable_get('@uri') expect(uri.scheme).to eq('https') expect(uri.host).to eq('www.fakeflipper.com') expect(uri.path).to eq('/sadpanda') end end it 'can initialize with no token explicitly provided' do with_modified_env "FLIPPER_CLOUD_TOKEN" => "asdf" do expect(described_class.new).to be_instance_of(Flipper::Cloud::DSL) end end it 'can set instrumenter' do instrumenter = Flipper::Instrumenters::Memory.new instance = described_class.new(token: 'asdf', instrumenter: instrumenter) expect(instance.instrumenter).to be(instrumenter) end it 'allows wrapping adapter with another adapter like the instrumenter' do instance = described_class.new(token: 'asdf') do |config| config.adapter do |adapter| Flipper::Adapters::Instrumented.new(adapter) end end # instance.adapter is memoizable adapter instance expect(instance.adapter.adapter).to be_instance_of(Flipper::Adapters::Instrumented) end it 'can set debug_output' do expect(Flipper::Adapters::Http::Client).to receive(:new) .with(hash_including(debug_output: STDOUT)) described_class.new(token: 'asdf', debug_output: STDOUT) end it 'can set read_timeout' do expect(Flipper::Adapters::Http::Client).to receive(:new) .with(hash_including(read_timeout: 1)) described_class.new(token: 'asdf', read_timeout: 1) end it 'can set open_timeout' do expect(Flipper::Adapters::Http::Client).to receive(:new) .with(hash_including(open_timeout: 1)) described_class.new(token: 'asdf', open_timeout: 1) end if RUBY_VERSION >= '2.6.0' it 'can set write_timeout' do expect(Flipper::Adapters::Http::Client).to receive(:new) .with(hash_including(open_timeout: 1)) described_class.new(token: 'asdf', open_timeout: 1) end end it 'can import' do stub_request(:post, /www\.flippercloud\.io\/adapter\/features.*/). with(headers: { 'Feature-Flipper-Token'=>'asdf', 'Flipper-Cloud-Token'=>'asdf', }).to_return(status: 200, body: "{}", headers: {}) flipper = Flipper.new(Flipper::Adapters::Memory.new) flipper.enable(:test) flipper.enable(:search) flipper.enable_actor(:stats, Flipper::Actor.new("jnunemaker")) flipper.enable_percentage_of_time(:logging, 5) cloud_flipper = Flipper::Cloud.new(token: "asdf") get_all = { "logging" => {actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: "5"}, "search" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, "stats" => {actors: Set["jnunemaker"], boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, "test" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, } expect(flipper.adapter.get_all).to eq(get_all) cloud_flipper.import(flipper) expect(flipper.adapter.get_all).to eq(get_all) expect(cloud_flipper.adapter.get_all).to eq(get_all) end it 'raises error for failure while importing' do stub_request(:post, /www\.flippercloud\.io\/adapter\/features.*/). with(headers: { 'Feature-Flipper-Token'=>'asdf', 'Flipper-Cloud-Token'=>'asdf', }).to_return(status: 500, body: "{}") flipper = Flipper.new(Flipper::Adapters::Memory.new) flipper.enable(:test) flipper.enable(:search) flipper.enable_actor(:stats, Flipper::Actor.new("jnunemaker")) flipper.enable_percentage_of_time(:logging, 5) cloud_flipper = Flipper::Cloud.new(token: "asdf") get_all = { "logging" => {actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: "5"}, "search" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, "stats" => {actors: Set["jnunemaker"], boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, "test" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, } expect(flipper.adapter.get_all).to eq(get_all) expect { cloud_flipper.import(flipper) }.to raise_error(Flipper::Adapters::Http::Error) expect(flipper.adapter.get_all).to eq(get_all) end it 'raises error for timeout while importing' do stub_request(:post, /www\.flippercloud\.io\/adapter\/features.*/). with(headers: { 'Feature-Flipper-Token'=>'asdf', 'Flipper-Cloud-Token'=>'asdf', }).to_timeout flipper = Flipper.new(Flipper::Adapters::Memory.new) flipper.enable(:test) flipper.enable(:search) flipper.enable_actor(:stats, Flipper::Actor.new("jnunemaker")) flipper.enable_percentage_of_time(:logging, 5) cloud_flipper = Flipper::Cloud.new(token: "asdf") get_all = { "logging" => {actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: "5"}, "search" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, "stats" => {actors: Set["jnunemaker"], boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, "test" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, } expect(flipper.adapter.get_all).to eq(get_all) expect { cloud_flipper.import(flipper) }.to raise_error(Net::OpenTimeout) expect(flipper.adapter.get_all).to eq(get_all) end end flipper-0.21.0/spec/flipper/configuration_spec.rb000066400000000000000000000020551404600161700220350ustar00rootroot00000000000000require 'helper' require 'flipper/configuration' RSpec.describe Flipper::Configuration do describe '#adapter' do it 'returns instance using Memory adapter' do expect(subject.adapter).to be_a(Flipper::Adapters::Memory) end it 'can be set' do instance = Flipper::Adapters::Memory.new expect(subject.adapter).not_to be(instance) subject.adapter { instance } expect(subject.adapter).to be(instance) # All adapters are wrapped in Memoizable expect(subject.default.adapter.adapter).to be(instance) end end describe '#default' do it 'returns instance using Memory adapter' do expect(subject.default).to be_a(Flipper::DSL) # All adapters are wrapped in Memoizable expect(subject.default.adapter.adapter).to be_a(Flipper::Adapters::Memory) end it 'can be set default' do instance = Flipper.new(Flipper::Adapters::Memory.new) expect(subject.default).not_to be(instance) subject.default { instance } expect(subject.default).to be(instance) end end end flipper-0.21.0/spec/flipper/dsl_spec.rb000066400000000000000000000240261404600161700177520ustar00rootroot00000000000000require 'helper' require 'flipper/dsl' RSpec.describe Flipper::DSL do subject { described_class.new(adapter) } let(:adapter) { Flipper::Adapters::Memory.new } describe '#initialize' do it 'sets adapter' do dsl = described_class.new(adapter) expect(dsl.adapter).not_to be_nil end it 'defaults instrumenter to noop' do dsl = described_class.new(adapter) expect(dsl.instrumenter).to be(Flipper::Instrumenters::Noop) end context 'with overriden instrumenter' do let(:instrumenter) { double('Instrumentor', instrument: nil) } it 'overrides default instrumenter' do dsl = described_class.new(adapter, instrumenter: instrumenter) expect(dsl.instrumenter).to be(instrumenter) end end end describe '#feature' do it_should_behave_like 'a DSL feature' do let(:method_name) { :feature } let(:instrumenter) { double('Instrumentor', instrument: nil) } let(:feature) { dsl.send(method_name, :stats) } let(:dsl) { described_class.new(adapter, instrumenter: instrumenter) } end end describe '#preload' do let(:instrumenter) { double('Instrumentor', instrument: nil) } let(:dsl) { described_class.new(adapter, instrumenter: instrumenter) } let(:names) { %i(stats shiny) } let(:features) { dsl.preload(names) } it 'returns array of features' do expect(features).to all be_instance_of(Flipper::Feature) end it 'sets names' do expect(features.map(&:name)).to eq(names) end it 'sets adapter' do features.each do |feature| expect(feature.adapter.name).to eq(dsl.adapter.name) end end it 'sets instrumenter' do features.each do |feature| expect(feature.instrumenter).to eq(dsl.instrumenter) end end it 'memoizes the feature' do features.each do |feature| expect(dsl.feature(feature.name)).to equal(feature) end end end describe '#preload_all' do let(:instrumenter) { double('Instrumentor', instrument: nil) } let(:dsl) do names.each { |name| adapter.add subject[name] } described_class.new(adapter, instrumenter: instrumenter) end let(:names) { %i(stats shiny) } let(:features) { dsl.preload_all } it 'returns array of features' do expect(features).to all be_instance_of(Flipper::Feature) end it 'sets names' do expect(features.map(&:key)).to eq(names.map(&:to_s)) end it 'sets adapter' do features.each do |feature| expect(feature.adapter.name).to eq(dsl.adapter.name) end end it 'sets instrumenter' do features.each do |feature| expect(feature.instrumenter).to eq(dsl.instrumenter) end end it 'memoizes the feature' do features.each do |feature| expect(dsl.feature(feature.name)).to equal(feature) end end end describe '#[]' do it_should_behave_like 'a DSL feature' do let(:method_name) { :[] } let(:instrumenter) { double('Instrumentor', instrument: nil) } let(:feature) { dsl.send(method_name, :stats) } let(:dsl) { described_class.new(adapter, instrumenter: instrumenter) } end end describe '#boolean' do it_should_behave_like 'a DSL boolean method' do let(:method_name) { :boolean } end end describe '#bool' do it_should_behave_like 'a DSL boolean method' do let(:method_name) { :bool } end end describe '#group' do context 'for registered group' do before do @group = Flipper.register(:admins) {} end it 'delegates to Flipper' do expect(Flipper).to receive(:group).with(:admins).and_return(@group) expect(subject.group(:admins)).to be(@group) end end end describe '#actor' do context 'for a thing' do it 'returns actor instance' do thing = Flipper::Actor.new(33) actor = subject.actor(thing) expect(actor).to be_instance_of(Flipper::Types::Actor) expect(actor.value).to eq('33') end end context 'for nil' do it 'raises argument error' do expect do subject.actor(nil) end.to raise_error(ArgumentError) end end context 'for something that is not actor wrappable' do it 'raises argument error' do expect do subject.actor(Object.new) end.to raise_error(ArgumentError) end end end describe '#time' do before do @result = subject.time(5) end it 'returns percentage of time' do expect(@result).to be_instance_of(Flipper::Types::PercentageOfTime) end it 'sets value' do expect(@result.value).to eq(5) end it 'is aliased to percentage_of_time' do expect(@result).to eq(subject.percentage_of_time(@result.value)) end end describe '#actors' do before do @result = subject.actors(17) end it 'returns percentage of actors' do expect(@result).to be_instance_of(Flipper::Types::PercentageOfActors) end it 'sets value' do expect(@result.value).to eq(17) end it 'is aliased to percentage_of_actors' do expect(@result).to eq(subject.percentage_of_actors(@result.value)) end end describe '#features' do context 'with no features enabled/disabled' do it 'defaults to empty set' do expect(subject.features).to eq(Set.new) end end context 'with features enabled and disabled' do before do subject[:stats].enable subject[:cache].enable subject[:search].disable end it 'returns set of feature instances' do expect(subject.features).to be_instance_of(Set) subject.features.each do |feature| expect(feature).to be_instance_of(Flipper::Feature) end expect(subject.features.map(&:name).map(&:to_s).sort).to eq(%w(cache search stats)) end end end describe '#enable/disable' do it 'enables and disables the feature' do expect(subject[:stats].boolean_value).to eq(false) subject.enable(:stats) expect(subject[:stats].boolean_value).to eq(true) subject.disable(:stats) expect(subject[:stats].boolean_value).to eq(false) end end describe '#enable_actor/disable_actor' do it 'enables and disables the feature for actor' do actor = Flipper::Actor.new(5) expect(subject[:stats].actors_value).to be_empty subject.enable_actor(:stats, actor) expect(subject[:stats].actors_value).to eq(Set['5']) subject.disable_actor(:stats, actor) expect(subject[:stats].actors_value).to be_empty end end describe '#enable_group/disable_group' do it 'enables and disables the feature for group' do actor = Flipper::Actor.new(5) group = Flipper.register(:fives) { |actor| actor.flipper_id == 5 } expect(subject[:stats].groups_value).to be_empty subject.enable_group(:stats, :fives) expect(subject[:stats].groups_value).to eq(Set['fives']) subject.disable_group(:stats, :fives) expect(subject[:stats].groups_value).to be_empty end end describe '#enable_percentage_of_time/disable_percentage_of_time' do it 'enables and disables the feature for percentage of time' do expect(subject[:stats].percentage_of_time_value).to be(0) subject.enable_percentage_of_time(:stats, 6) expect(subject[:stats].percentage_of_time_value).to be(6) subject.disable_percentage_of_time(:stats) expect(subject[:stats].percentage_of_time_value).to be(0) end it 'can enable/disable float values' do expect(subject[:stats].percentage_of_time_value).to be(0) subject.enable_percentage_of_time(:stats, 0.01) expect(subject[:stats].percentage_of_time_value).to be(0.01) subject.disable_percentage_of_time(:stats) expect(subject[:stats].percentage_of_time_value).to be(0) end end describe '#enable_percentage_of_actors/disable_percentage_of_actors' do it 'enables and disables the feature for percentage of time' do expect(subject[:stats].percentage_of_actors_value).to be(0) subject.enable_percentage_of_actors(:stats, 6) expect(subject[:stats].percentage_of_actors_value).to be(6) subject.disable_percentage_of_actors(:stats) expect(subject[:stats].percentage_of_actors_value).to be(0) end it 'can enable/disable float values' do expect(subject[:stats].percentage_of_actors_value).to be(0) subject.enable_percentage_of_actors(:stats, 0.01) expect(subject[:stats].percentage_of_actors_value).to be(0.01) subject.disable_percentage_of_actors(:stats) expect(subject[:stats].percentage_of_actors_value).to be(0) end end describe '#add' do it 'adds the feature' do expect(subject.features).to eq(Set.new) subject.add(:stats) expect(subject.features).to eq(Set[subject[:stats]]) end end describe '#exist?' do it 'returns true if the feature is added in adapter' do subject.add(:stats) expect(subject.exist?(:stats)).to be(true) end it 'returns false if the feature is NOT added in adapter' do expect(subject.exist?(:stats)).to be(false) end end describe '#remove' do it 'removes the feature' do subject.adapter.add(subject[:stats]) expect(subject.features).to eq(Set[subject[:stats]]) subject.remove(:stats) expect(subject.features).to eq(Set.new) end end describe '#import' do it 'delegates to adapter' do destination_flipper = build_flipper expect(subject.adapter).to receive(:import).with(destination_flipper.adapter) subject.import(destination_flipper) end end describe '#memoize=' do it 'delegates to adapter' do expect(subject.adapter).not_to be_memoizing subject.memoize = true expect(subject.adapter).to be_memoizing end end describe '#memoizing?' do it 'delegates to adapter' do subject.memoize = false expect(subject.adapter.memoizing?).to eq(subject.memoizing?) subject.memoize = true expect(subject.adapter.memoizing?).to eq(subject.memoizing?) end end end flipper-0.21.0/spec/flipper/feature_check_context_spec.rb000066400000000000000000000036241404600161700235250ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::FeatureCheckContext do let(:feature_name) { :new_profiles } let(:values) { Flipper::GateValues.new({}) } let(:thing) { Flipper::Actor.new('5') } let(:options) do { feature_name: feature_name, values: values, thing: thing, } end it 'initializes just fine' do instance = described_class.new(options) expect(instance.feature_name).to eq(feature_name) expect(instance.values).to eq(values) expect(instance.thing).to eq(thing) end it 'requires feature_name' do options.delete(:feature_name) expect do described_class.new(options) end.to raise_error(KeyError) end it 'requires values' do options.delete(:values) expect do described_class.new(options) end.to raise_error(KeyError) end it 'requires thing' do options.delete(:thing) expect do described_class.new(options) end.to raise_error(KeyError) end it 'knows actors_value' do args = options.merge(values: Flipper::GateValues.new(actors: Set['User;1'])) expect(described_class.new(args).actors_value).to eq(Set['User;1']) end it 'knows groups_value' do args = options.merge(values: Flipper::GateValues.new(groups: Set['admins'])) expect(described_class.new(args).groups_value).to eq(Set['admins']) end it 'knows boolean_value' do instance = described_class.new(options.merge(values: Flipper::GateValues.new(boolean: true))) expect(instance.boolean_value).to eq(true) end it 'knows percentage_of_actors_value' do args = options.merge(values: Flipper::GateValues.new(percentage_of_actors: 14)) expect(described_class.new(args).percentage_of_actors_value).to eq(14) end it 'knows percentage_of_time_value' do args = options.merge(values: Flipper::GateValues.new(percentage_of_time: 41)) expect(described_class.new(args).percentage_of_time_value).to eq(41) end end flipper-0.21.0/spec/flipper/feature_spec.rb000066400000000000000000000600611404600161700206220ustar00rootroot00000000000000require 'helper' require 'flipper/feature' require 'flipper/instrumenters/memory' RSpec.describe Flipper::Feature do subject { described_class.new(:search, adapter) } let(:adapter) { Flipper::Adapters::Memory.new } describe '#initialize' do it 'sets name' do feature = described_class.new(:search, adapter) expect(feature.name).to eq(:search) end it 'sets adapter' do feature = described_class.new(:search, adapter) expect(feature.adapter).to eq(adapter) end it 'defaults instrumenter' do feature = described_class.new(:search, adapter) expect(feature.instrumenter).to be(Flipper::Instrumenters::Noop) end context 'with overriden instrumenter' do let(:instrumenter) { double('Instrumentor', instrument: nil) } it 'overrides default instrumenter' do feature = described_class.new(:search, adapter, instrumenter: instrumenter) expect(feature.instrumenter).to be(instrumenter) end end end describe '#to_s' do it 'returns name as string' do feature = described_class.new(:search, adapter) expect(feature.to_s).to eq('search') end end describe '#to_param' do it 'returns name as string' do feature = described_class.new(:search, adapter) expect(feature.to_param).to eq('search') end end describe '#gate_for' do context 'with percentage of actors' do it 'returns percentage of actors gate' do percentage = Flipper::Types::PercentageOfActors.new(10) gate = subject.gate_for(percentage) expect(gate).to be_instance_of(Flipper::Gates::PercentageOfActors) end end end describe '#gates' do it 'returns array of gates' do instance = described_class.new(:search, adapter) expect(instance.gates).to be_instance_of(Array) instance.gates.each do |gate| expect(gate).to be_a(Flipper::Gate) end expect(instance.gates.size).to be(5) end end describe '#gate' do context 'with symbol name' do it 'returns gate by name' do expect(subject.gate(:boolean)).to be_instance_of(Flipper::Gates::Boolean) end end context 'with string name' do it 'returns gate by name' do expect(subject.gate('boolean')).to be_instance_of(Flipper::Gates::Boolean) end end context 'with name that does not exist' do it 'returns nil' do expect(subject.gate(:poo)).to be_nil end end end describe '#add' do it 'adds feature to adapter' do expect(adapter.features).to eq(Set.new) subject.add expect(adapter.features).to eq(Set[subject.key]) end end describe '#exist?' do it 'returns true if feature is added in adapter' do subject.add expect(subject.exist?).to be(true) end it 'returns false if feature is NOT added in adapter' do expect(subject.exist?).to be(false) end end describe '#remove' do it 'removes feature from adapter' do adapter.add(subject) expect(adapter.features).to eq(Set[subject.key]) subject.remove expect(adapter.features).to eq(Set.new) end end describe '#clear' do it 'clears feature using adapter' do subject.enable expect(subject).to be_enabled subject.clear expect(subject).not_to be_enabled end end describe '#inspect' do it 'returns easy to read string representation' do string = subject.inspect expect(string).to include('Flipper::Feature') expect(string).to include('name=:search') expect(string).to include('state=:off') expect(string).to include('enabled_gate_names=[]') expect(string).to include("adapter=#{subject.adapter.name.inspect}") subject.enable string = subject.inspect expect(string).to include('state=:on') expect(string).to include('enabled_gate_names=[:boolean]') end end describe 'instrumentation' do let(:instrumenter) { Flipper::Instrumenters::Memory.new } subject do described_class.new(:search, adapter, instrumenter: instrumenter) end it 'is recorded for enable' do thing = Flipper::Types::Actor.new(Flipper::Actor.new('1')) gate = subject.gate_for(thing) subject.enable(thing) event = instrumenter.events.last expect(event).not_to be_nil expect(event.name).to eq('feature_operation.flipper') expect(event.payload[:feature_name]).to eq(:search) expect(event.payload[:operation]).to eq(:enable) expect(event.payload[:thing]).to eq(thing) expect(event.payload[:result]).not_to be_nil end it 'always instruments flipper type instance for enable' do thing = Flipper::Actor.new('1') gate = subject.gate_for(thing) subject.enable(thing) event = instrumenter.events.last expect(event).not_to be_nil expect(event.payload[:thing]).to eq(Flipper::Types::Actor.new(thing)) end it 'is recorded for disable' do thing = Flipper::Types::Boolean.new gate = subject.gate_for(thing) subject.disable(thing) event = instrumenter.events.last expect(event).not_to be_nil expect(event.name).to eq('feature_operation.flipper') expect(event.payload[:feature_name]).to eq(:search) expect(event.payload[:operation]).to eq(:disable) expect(event.payload[:thing]).to eq(thing) expect(event.payload[:result]).not_to be_nil end user = Flipper::Actor.new('1') actor = Flipper::Types::Actor.new(user) boolean_true = Flipper::Types::Boolean.new(true) boolean_false = Flipper::Types::Boolean.new(false) group = Flipper::Types::Group.new(:admins) percentage_of_time = Flipper::Types::PercentageOfTime.new(10) percentage_of_actors = Flipper::Types::PercentageOfActors.new(10) { user => actor, actor => actor, true => boolean_true, false => boolean_false, boolean_true => boolean_true, boolean_false => boolean_false, :admins => group, group => group, percentage_of_time => percentage_of_time, percentage_of_actors => percentage_of_actors, }.each do |thing, wrapped_thing| it "always instruments #{thing.inspect} as #{wrapped_thing.class} for enable" do Flipper.register(:admins) {} subject.enable(thing) event = instrumenter.events.last expect(event).not_to be_nil expect(event.payload[:operation]).to eq(:enable) expect(event.payload[:thing]).to eq(wrapped_thing) end end it 'always instruments flipper type instance for disable' do thing = Flipper::Actor.new('1') gate = subject.gate_for(thing) subject.disable(thing) event = instrumenter.events.last expect(event).not_to be_nil expect(event.payload[:operation]).to eq(:disable) expect(event.payload[:thing]).to eq(Flipper::Types::Actor.new(thing)) end it 'is recorded for add' do subject.add event = instrumenter.events.last expect(event).not_to be_nil expect(event.name).to eq('feature_operation.flipper') expect(event.payload[:feature_name]).to eq(:search) expect(event.payload[:operation]).to eq(:add) expect(event.payload[:result]).not_to be_nil end it 'is recorded for exist?' do subject.exist? event = instrumenter.events.last expect(event).not_to be_nil expect(event.name).to eq('feature_operation.flipper') expect(event.payload[:feature_name]).to eq(:search) expect(event.payload[:operation]).to eq(:exist?) expect(event.payload[:result]).not_to be_nil end it 'is recorded for remove' do subject.remove event = instrumenter.events.last expect(event).not_to be_nil expect(event.name).to eq('feature_operation.flipper') expect(event.payload[:feature_name]).to eq(:search) expect(event.payload[:operation]).to eq(:remove) expect(event.payload[:result]).not_to be_nil end it 'is recorded for clear' do subject.clear event = instrumenter.events.last expect(event).not_to be_nil expect(event.name).to eq('feature_operation.flipper') expect(event.payload[:feature_name]).to eq(:search) expect(event.payload[:operation]).to eq(:clear) expect(event.payload[:result]).not_to be_nil end it 'is recorded for enabled?' do thing = Flipper::Types::Actor.new(Flipper::Actor.new('1')) gate = subject.gate_for(thing) subject.enabled?(thing) event = instrumenter.events.last expect(event).not_to be_nil expect(event.name).to eq('feature_operation.flipper') expect(event.payload[:feature_name]).to eq(:search) expect(event.payload[:operation]).to eq(:enabled?) expect(event.payload[:thing]).to eq(thing) expect(event.payload[:result]).to eq(false) end user = Flipper::Actor.new('1') actor = Flipper::Types::Actor.new(user) { nil => nil, user => actor, actor => actor, }.each do |thing, wrapped_thing| it "always instruments #{thing.inspect} as #{wrapped_thing.class} for enabled?" do subject.enabled?(thing) event = instrumenter.events.last expect(event).not_to be_nil expect(event.payload[:operation]).to eq(:enabled?) expect(event.payload[:thing]).to eq(wrapped_thing) end end end describe '#state' do context 'fully on' do before do subject.enable end it 'returns :on' do expect(subject.state).to be(:on) end it 'returns true for on?' do expect(subject.on?).to be(true) end it 'returns false for off?' do expect(subject.off?).to be(false) end it 'returns false for conditional?' do expect(subject.conditional?).to be(false) end end context 'percentage of time set to 100' do before do subject.enable_percentage_of_time 100 end it 'returns :on' do expect(subject.state).to be(:on) end it 'returns true for on?' do expect(subject.on?).to be(true) end it 'returns false for off?' do expect(subject.off?).to be(false) end it 'returns false for conditional?' do expect(subject.conditional?).to be(false) end end context 'percentage of actors set to 100' do before do subject.enable_percentage_of_actors 100 end it 'returns :on' do expect(subject.state).to be(:conditional) end it 'returns false for on?' do expect(subject.on?).to be(false) end it 'returns false for off?' do expect(subject.off?).to be(false) end it 'returns true for conditional?' do expect(subject.conditional?).to be(true) end end context 'fully off' do before do subject.disable end it 'returns :off' do expect(subject.state).to be(:off) end it 'returns false for on?' do expect(subject.on?).to be(false) end it 'returns true for off?' do expect(subject.off?).to be(true) end it 'returns false for conditional?' do expect(subject.conditional?).to be(false) end end context 'partially on' do before do subject.enable Flipper::Types::PercentageOfTime.new(5) end it 'returns :conditional' do expect(subject.state).to be(:conditional) end it 'returns false for on?' do expect(subject.on?).to be(false) end it 'returns false for off?' do expect(subject.off?).to be(false) end it 'returns true for conditional?' do expect(subject.conditional?).to be(true) end end end describe '#enabled_groups' do context 'when no groups enabled' do it 'returns empty set' do expect(subject.enabled_groups).to eq(Set.new) end end context 'when one or more groups enabled' do before do @staff = Flipper.register(:staff) { |_thing| true } @preview_features = Flipper.register(:preview_features) { |_thing| true } @not_enabled = Flipper.register(:not_enabled) { |_thing| true } @disabled = Flipper.register(:disabled) { |_thing| true } subject.enable @staff subject.enable @preview_features subject.disable @disabled end it 'returns set of enabled groups' do expect(subject.enabled_groups).to eq(Set.new([ @staff, @preview_features, ])) end it 'does not include groups that have not been enabled' do expect(subject.enabled_groups).not_to include(@not_enabled) end it 'does not include disabled groups' do expect(subject.enabled_groups).not_to include(@disabled) end it 'is aliased to groups' do expect(subject.enabled_groups).to eq(subject.groups) end end end describe '#disabled_groups' do context 'when no groups enabled' do it 'returns empty set' do expect(subject.disabled_groups).to eq(Set.new) end end context 'when one or more groups enabled' do before do @staff = Flipper.register(:staff) { |_thing| true } @preview_features = Flipper.register(:preview_features) { |_thing| true } @not_enabled = Flipper.register(:not_enabled) { |_thing| true } @disabled = Flipper.register(:disabled) { |_thing| true } subject.enable @staff subject.enable @preview_features subject.disable @disabled end it 'returns set of groups that are not enabled' do expect(subject.disabled_groups).to eq(Set[ @not_enabled, @disabled, ]) end end end describe '#groups_value' do context 'when no groups enabled' do it 'returns empty set' do expect(subject.groups_value).to eq(Set.new) end end context 'when one or more groups enabled' do before do @staff = Flipper.register(:staff) { |_thing| true } @preview_features = Flipper.register(:preview_features) { |_thing| true } @not_enabled = Flipper.register(:not_enabled) { |_thing| true } @disabled = Flipper.register(:disabled) { |_thing| true } subject.enable @staff subject.enable @preview_features subject.disable @disabled end it 'returns set of enabled groups' do expect(subject.groups_value).to eq(Set.new([ @staff.name.to_s, @preview_features.name.to_s, ])) end it 'does not include groups that have not been enabled' do expect(subject.groups_value).not_to include(@not_enabled.name.to_s) end it 'does not include disabled groups' do expect(subject.groups_value).not_to include(@disabled.name.to_s) end end end describe '#actors_value' do context 'when no groups enabled' do it 'returns empty set' do expect(subject.actors_value).to eq(Set.new) end end context 'when one or more actors are enabled' do before do subject.enable Flipper::Types::Actor.new(Flipper::Actor.new('User;5')) subject.enable Flipper::Types::Actor.new(Flipper::Actor.new('User;22')) end it 'returns set of actor ids' do expect(subject.actors_value).to eq(Set.new(['User;5', 'User;22'])) end end end describe '#boolean_value' do context 'when not enabled or disabled' do it 'returns false' do expect(subject.boolean_value).to be(false) end end context 'when enabled' do before do subject.enable end it 'returns true' do expect(subject.boolean_value).to be(true) end end context 'when disabled' do before do subject.disable end it 'returns false' do expect(subject.boolean_value).to be(false) end end end describe '#percentage_of_actors_value' do context 'when not enabled or disabled' do it 'returns nil' do expect(subject.percentage_of_actors_value).to be(0) end end context 'when enabled' do before do subject.enable Flipper::Types::PercentageOfActors.new(5) end it 'returns true' do expect(subject.percentage_of_actors_value).to eq(5) end end context 'when disabled' do before do subject.disable end it 'returns nil' do expect(subject.percentage_of_actors_value).to be(0) end end end describe '#percentage_of_time_value' do context 'when not enabled or disabled' do it 'returns nil' do expect(subject.percentage_of_time_value).to be(0) end end context 'when enabled' do before do subject.enable Flipper::Types::PercentageOfTime.new(5) end it 'returns true' do expect(subject.percentage_of_time_value).to eq(5) end end context 'when disabled' do before do subject.disable end it 'returns nil' do expect(subject.percentage_of_time_value).to be(0) end end end describe '#gate_values' do context 'when no gates are set in adapter' do it 'returns default gate values' do expect(subject.gate_values).to eq(Flipper::GateValues.new(adapter.default_config)) end end context 'with gate values set in adapter' do before do subject.enable Flipper::Types::Boolean.new(true) subject.enable Flipper::Types::Actor.new(Flipper::Actor.new(5)) subject.enable Flipper::Types::Group.new(:admins) subject.enable Flipper::Types::PercentageOfTime.new(50) subject.enable Flipper::Types::PercentageOfActors.new(25) end it 'returns gate values' do expect(subject.gate_values).to eq(Flipper::GateValues.new(actors: Set.new(['5']), groups: Set.new(['admins']), boolean: 'true', percentage_of_time: '50', percentage_of_actors: '25')) end end end describe '#enable_actor/disable_actor' do context 'with object that responds to flipper_id' do it 'updates the gate values to include the actor' do actor = Flipper::Actor.new(5) expect(subject.gate_values.actors).to be_empty subject.enable_actor(actor) expect(subject.gate_values.actors).to eq(Set['5']) subject.disable_actor(actor) expect(subject.gate_values.actors).to be_empty end end context 'with actor instance' do it 'updates the gate values to include the actor' do actor = Flipper::Actor.new(5) instance = Flipper::Types::Actor.new(actor) expect(subject.gate_values.actors).to be_empty subject.enable_actor(instance) expect(subject.gate_values.actors).to eq(Set['5']) subject.disable_actor(instance) expect(subject.gate_values.actors).to be_empty end end end describe '#enable_group/disable_group' do context 'with symbol group name' do it 'updates the gate values to include the group' do actor = Flipper::Actor.new(5) group = Flipper.register(:five_only) { |actor| actor.flipper_id == 5 } expect(subject.gate_values.groups).to be_empty subject.enable_group(:five_only) expect(subject.gate_values.groups).to eq(Set['five_only']) subject.disable_group(:five_only) expect(subject.gate_values.groups).to be_empty end end context 'with string group name' do it 'updates the gate values to include the group' do actor = Flipper::Actor.new(5) group = Flipper.register(:five_only) { |actor| actor.flipper_id == 5 } expect(subject.gate_values.groups).to be_empty subject.enable_group('five_only') expect(subject.gate_values.groups).to eq(Set['five_only']) subject.disable_group('five_only') expect(subject.gate_values.groups).to be_empty end end context 'with group instance' do it 'updates the gate values for the group' do actor = Flipper::Actor.new(5) group = Flipper.register(:five_only) { |actor| actor.flipper_id == 5 } expect(subject.gate_values.groups).to be_empty subject.enable_group(group) expect(subject.gate_values.groups).to eq(Set['five_only']) subject.disable_group(group) expect(subject.gate_values.groups).to be_empty end end end describe '#enable_percentage_of_time/disable_percentage_of_time' do context 'with integer' do it 'updates the gate values' do expect(subject.gate_values.percentage_of_time).to be(0) subject.enable_percentage_of_time(56) expect(subject.gate_values.percentage_of_time).to be(56) subject.disable_percentage_of_time expect(subject.gate_values.percentage_of_time).to be(0) end end context 'with string' do it 'updates the gate values' do expect(subject.gate_values.percentage_of_time).to be(0) subject.enable_percentage_of_time('56') expect(subject.gate_values.percentage_of_time).to be(56) subject.disable_percentage_of_time expect(subject.gate_values.percentage_of_time).to be(0) end end context 'with percentage of time instance' do it 'updates the gate values' do percentage = Flipper::Types::PercentageOfTime.new(56) expect(subject.gate_values.percentage_of_time).to be(0) subject.enable_percentage_of_time(percentage) expect(subject.gate_values.percentage_of_time).to be(56) subject.disable_percentage_of_time expect(subject.gate_values.percentage_of_time).to be(0) end end end describe '#enable_percentage_of_actors/disable_percentage_of_actors' do context 'with integer' do it 'updates the gate values' do expect(subject.gate_values.percentage_of_actors).to be(0) subject.enable_percentage_of_actors(56) expect(subject.gate_values.percentage_of_actors).to be(56) subject.disable_percentage_of_actors expect(subject.gate_values.percentage_of_actors).to be(0) end end context 'with string' do it 'updates the gate values' do expect(subject.gate_values.percentage_of_actors).to be(0) subject.enable_percentage_of_actors('56') expect(subject.gate_values.percentage_of_actors).to be(56) subject.disable_percentage_of_actors expect(subject.gate_values.percentage_of_actors).to be(0) end end context 'with percentage of actors instance' do it 'updates the gate values' do percentage = Flipper::Types::PercentageOfActors.new(56) expect(subject.gate_values.percentage_of_actors).to be(0) subject.enable_percentage_of_actors(percentage) expect(subject.gate_values.percentage_of_actors).to be(56) subject.disable_percentage_of_actors expect(subject.gate_values.percentage_of_actors).to be(0) end end end describe '#enabled/disabled_gates' do before do subject.enable_percentage_of_time 5 subject.enable_percentage_of_actors 5 end it 'can return enabled gates' do expect(subject.enabled_gates.map(&:name).to_set).to eq(Set[ :percentage_of_actors, :percentage_of_time, ]) expect(subject.enabled_gate_names.to_set).to eq(Set[ :percentage_of_actors, :percentage_of_time, ]) end it 'can return disabled gates' do expect(subject.disabled_gates.map(&:name).to_set).to eq(Set[ :actor, :boolean, :group, ]) expect(subject.disabled_gate_names.to_set).to eq(Set[ :actor, :boolean, :group, ]) end end end flipper-0.21.0/spec/flipper/gate_spec.rb000066400000000000000000000014041404600161700201030ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Gate do let(:feature_name) { :stats } subject do described_class.new end describe '#inspect' do context 'for subclass' do let(:subclass) do Class.new(described_class) do def name :name end def key :key end def data_type :set end end end subject do subclass.new end it 'includes attributes' do string = subject.inspect expect(string).to include(subject.object_id.to_s) expect(string).to include('name=:name') expect(string).to include('key=:key') expect(string).to include('data_type=:set') end end end end flipper-0.21.0/spec/flipper/gate_values_spec.rb000066400000000000000000000100311404600161700214560ustar00rootroot00000000000000require 'helper' require 'flipper/gate_values' RSpec.describe Flipper::GateValues do { nil => false, '' => false, 0 => false, 1 => true, '0' => false, '1' => true, true => true, false => false, 'true' => true, 'false' => false, }.each do |value, expected| context "with #{value.inspect} boolean" do it "returns #{expected}" do expect(described_class.new(boolean: value).boolean).to be(expected) end end end { nil => 0, '' => 0, 0 => 0, 1 => 1, '1' => 1, '99' => 99, }.each do |value, expected| context "with #{value.inspect} percentage of time" do it "returns #{expected}" do expect(described_class.new(percentage_of_time: value).percentage_of_time).to be(expected) end end end { nil => 0, '' => 0, 0 => 0, 1 => 1, '1' => 1, '99' => 99, }.each do |value, expected| context "with #{value.inspect} percentage of actors" do it "returns #{expected}" do expect(described_class.new(percentage_of_actors: value).percentage_of_actors) .to be(expected) end end end { nil => Set.new, '' => Set.new, Set.new([1, 2]) => Set.new([1, 2]), [1, 2] => Set.new([1, 2]), }.each do |value, expected| context "with #{value.inspect} actors" do it "returns #{expected}" do expect(described_class.new(actors: value).actors).to eq(expected) end end end { nil => Set.new, '' => Set.new, Set.new([:admins, :preview_features]) => Set.new([:admins, :preview_features]), [:admins, :preview_features] => Set.new([:admins, :preview_features]), }.each do |value, expected| context "with #{value.inspect} groups" do it "returns #{expected}" do expect(described_class.new(groups: value).groups).to eq(expected) end end end it 'raises argument error for percentage of time value that cannot be converted to an integer' do expect do described_class.new(percentage_of_time: ['asdf']) end.to raise_error(ArgumentError, %(["asdf"] cannot be converted to an integer)) end it 'raises argument error for percentage of actors value that cannot be converted to an int' do expect do described_class.new(percentage_of_actors: ['asdf']) end.to raise_error(ArgumentError, %(["asdf"] cannot be converted to an integer)) end it 'raises argument error for actors value that cannot be converted to a set' do expect do described_class.new(actors: 'asdf') end.to raise_error(ArgumentError, %("asdf" cannot be converted to a set)) end it 'raises argument error for groups value that cannot be converted to a set' do expect do described_class.new(groups: 'asdf') end.to raise_error(ArgumentError, %("asdf" cannot be converted to a set)) end describe '#[]' do it 'can read the boolean value' do expect(described_class.new(boolean: true)[:boolean]).to be(true) expect(described_class.new(boolean: true)['boolean']).to be(true) end it 'can read the actors value' do expect(described_class.new(actors: Set[1, 2])[:actors]).to eq(Set[1, 2]) expect(described_class.new(actors: Set[1, 2])['actors']).to eq(Set[1, 2]) end it 'can read the groups value' do expect(described_class.new(groups: Set[:admins])[:groups]).to eq(Set[:admins]) expect(described_class.new(groups: Set[:admins])['groups']).to eq(Set[:admins]) end it 'can read the percentage of time value' do expect(described_class.new(percentage_of_time: 15)[:percentage_of_time]).to eq(15) expect(described_class.new(percentage_of_time: 15)['percentage_of_time']).to eq(15) end it 'can read the percentage of actors value' do expect(described_class.new(percentage_of_actors: 15)[:percentage_of_actors]).to eq(15) expect(described_class.new(percentage_of_actors: 15)['percentage_of_actors']).to eq(15) end it 'returns nil for value that is not present' do expect(described_class.new({})['not legit']).to be(nil) end end end flipper-0.21.0/spec/flipper/gates/000077500000000000000000000000001404600161700167305ustar00rootroot00000000000000flipper-0.21.0/spec/flipper/gates/actor_spec.rb000066400000000000000000000002131404600161700213730ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Gates::Actor do let(:feature_name) { :search } subject do described_class.new end end flipper-0.21.0/spec/flipper/gates/boolean_spec.rb000066400000000000000000000035161404600161700217130ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Gates::Boolean do let(:feature_name) { :search } subject do described_class.new end def context(bool) Flipper::FeatureCheckContext.new( feature_name: feature_name, values: Flipper::GateValues.new(boolean: bool), thing: Flipper::Types::Actor.new(Flipper::Actor.new(1)) ) end describe '#enabled?' do context 'for true value' do it 'returns true' do expect(subject.enabled?(true)).to eq(true) end end context 'for false value' do it 'returns false' do expect(subject.enabled?(false)).to eq(false) end end end describe '#open?' do context 'for true value' do it 'returns true' do expect(subject.open?(context(true))).to be(true) end end context 'for false value' do it 'returns false' do expect(subject.open?(context(false))).to be(false) end end end describe '#protects?' do it 'returns true for boolean type' do expect(subject.protects?(Flipper::Types::Boolean.new(true))).to be(true) end it 'returns true for true' do expect(subject.protects?(true)).to be(true) end it 'returns true for false' do expect(subject.protects?(false)).to be(true) end end describe '#wrap' do it 'returns boolean type for boolean type' do expect(subject.wrap(Flipper::Types::Boolean.new(true))) .to be_instance_of(Flipper::Types::Boolean) end it 'returns boolean type for true' do expect(subject.wrap(true)).to be_instance_of(Flipper::Types::Boolean) expect(subject.wrap(true).value).to be(true) end it 'returns boolean type for true' do expect(subject.wrap(false)).to be_instance_of(Flipper::Types::Boolean) expect(subject.wrap(false).value).to be(false) end end end flipper-0.21.0/spec/flipper/gates/group_spec.rb000066400000000000000000000030571404600161700214300ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Gates::Group do let(:feature_name) { :search } subject do described_class.new end def context(set) Flipper::FeatureCheckContext.new( feature_name: feature_name, values: Flipper::GateValues.new(groups: set), thing: Flipper::Types::Actor.new(Flipper::Actor.new('5')) ) end describe '#open?' do context 'with a group in adapter, but not registered' do before do Flipper.register(:staff) { |_thing| true } end it 'ignores group' do thing = Flipper::Actor.new('5') expect(subject.open?(context(Set[:newbs, :staff]))).to be(true) end end context 'thing that does not respond to method in group block' do before do Flipper.register(:stinkers, &:stinker?) end it 'raises error' do expect do subject.open?(context(Set[:stinkers])) end.to raise_error(NoMethodError) end end end describe '#wrap' do it 'returns group instance for symbol' do group = Flipper.register(:admins) {} expect(subject.wrap(:admins)).to eq(group) end it 'returns group instance for group instance' do group = Flipper.register(:admins) {} expect(subject.wrap(group)).to eq(group) end end describe '#protects?' do it 'returns true for group' do group = Flipper.register(:admins) {} expect(subject.protects?(group)).to be(true) end it 'returns true for symbol' do expect(subject.protects?(:admins)).to be(true) end end end flipper-0.21.0/spec/flipper/gates/percentage_of_actors_spec.rb000066400000000000000000000045741404600161700244550ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Gates::PercentageOfActors do let(:feature_name) { :search } subject do described_class.new end def context(percentage_of_actors_value, feature = feature_name, thing = nil) Flipper::FeatureCheckContext.new( feature_name: feature, values: Flipper::GateValues.new(percentage_of_actors: percentage_of_actors_value), thing: thing || Flipper::Types::Actor.new(Flipper::Actor.new(1)) ) end describe '#open?' do context 'when compared against two features' do let(:percentage) { 0.05 } let(:percentage_as_integer) { percentage * 100 } let(:number_of_actors) { 10_000 } let(:actors) do (1..number_of_actors).map { |n| Flipper::Actor.new(n) } end let(:feature_one_enabled_actors) do actors.select { |actor| subject.open? context(percentage_as_integer, :name_one, actor) } end let(:feature_two_enabled_actors) do actors.select { |actor| subject.open? context(percentage_as_integer, :name_two, actor) } end it 'does not enable both features for same set of actors' do expect(feature_one_enabled_actors).not_to eq(feature_two_enabled_actors) end it 'enables feature for accurate number of actors for each feature' do margin_of_error = 0.02 * number_of_actors # 2 percent margin of error expected_enabled_size = number_of_actors * percentage [ feature_one_enabled_actors.size, feature_two_enabled_actors.size, ].each do |actual_enabled_size| expect(actual_enabled_size).to be_within(margin_of_error).of(expected_enabled_size) end end end context 'for fractional percentage' do let(:decimal) { 0.001 } let(:percentage) { decimal * 100 } let(:number_of_actors) { 10_000 } let(:actors) do (1..number_of_actors).map { |n| Flipper::Actor.new(n) } end subject { described_class.new } it 'enables feature for accurate number of actors' do margin_of_error = 0.02 * number_of_actors expected_open_count = number_of_actors * decimal open_count = actors.select do |actor| context = context(percentage, :feature, actor) subject.open?(context) end.size expect(open_count).to be_within(margin_of_error).of(expected_open_count) end end end end flipper-0.21.0/spec/flipper/gates/percentage_of_time_spec.rb000066400000000000000000000022141404600161700241050ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Gates::PercentageOfTime do let(:feature_name) { :search } subject do described_class.new end def context(percentage_of_time_value, feature = feature_name, thing = nil) Flipper::FeatureCheckContext.new( feature_name: feature, values: Flipper::GateValues.new(percentage_of_time: percentage_of_time_value), thing: thing || Flipper::Types::Actor.new(Flipper::Actor.new(1)) ) end describe '#open?' do context 'for fractional percentage' do let(:decimal) { 0.001 } let(:percentage) { decimal * 100 } let(:number_of_invocations) { 10_000 } subject { described_class.new } it 'enables feature for accurate percentage of time' do margin_of_error = 0.02 * number_of_invocations expected_open_count = number_of_invocations * decimal open_count = (1..number_of_invocations).select do |_actor| context = context(percentage, :feature, Flipper::Actor.new("1")) subject.open?(context) end.size expect(open_count).to be_within(margin_of_error).of(expected_open_count) end end end end flipper-0.21.0/spec/flipper/identifier_spec.rb000066400000000000000000000004471404600161700213130ustar00rootroot00000000000000require 'helper' require 'flipper/identifier' RSpec.describe Flipper::Identifier do describe '#flipper_id' do class User < Struct.new(:id) include Flipper::Identifier end it 'uses class name and id' do expect(User.new(5).flipper_id).to eq('User;5') end end end flipper-0.21.0/spec/flipper/instrumentation/000077500000000000000000000000001404600161700210705ustar00rootroot00000000000000flipper-0.21.0/spec/flipper/instrumentation/log_subscriber_spec.rb000066400000000000000000000054231404600161700254370ustar00rootroot00000000000000require 'logger' require 'helper' require 'flipper/adapters/instrumented' require 'flipper/instrumentation/log_subscriber' RSpec.describe Flipper::Instrumentation::LogSubscriber do let(:adapter) do memory = Flipper::Adapters::Memory.new Flipper::Adapters::Instrumented.new(memory, instrumenter: ActiveSupport::Notifications) end let(:flipper) do Flipper.new(adapter, instrumenter: ActiveSupport::Notifications) end before do Flipper.register(:admins) do |thing| thing.respond_to?(:admin?) && thing.admin? end @io = StringIO.new logger = Logger.new(@io) logger.formatter = proc { |_severity, _datetime, _progname, msg| "#{msg}\n" } described_class.logger = logger end after do described_class.logger = nil end let(:log) { @io.string } context 'feature enabled checks' do before do clear_logs flipper[:search].enabled? end it 'logs feature calls with result after operation' do feature_line = find_line('Flipper feature(search) enabled? false') expect(feature_line).to include('[ thing=nil ]') end it 'logs adapter calls' do adapter_line = find_line('Flipper feature(search) adapter(memory) get') expect(adapter_line).to include('[ result={') expect(adapter_line).to include('} ]') end end context 'feature enabled checks with a thing' do let(:user) { Flipper::Types::Actor.new(Flipper::Actor.new('1')) } before do clear_logs flipper[:search].enabled?(user) end it 'logs thing for feature' do feature_line = find_line('Flipper feature(search) enabled?') expect(feature_line).to include(user.inspect) end end context 'changing feature enabled state' do let(:user) { Flipper::Types::Actor.new(Flipper::Actor.new('1')) } before do clear_logs flipper[:search].enable(user) end it 'logs feature calls with result in brackets' do feature_line = find_line('Flipper feature(search) enable true') expect(feature_line).to include("[ thing=#{user.inspect} gate_name=actor ]") end it 'logs adapter value' do adapter_line = find_line('Flipper feature(search) adapter(memory) enable') expect(adapter_line).to include('[ result=') end end context 'getting all the features from the adapter' do before do clear_logs flipper.features end it 'logs adapter calls' do adapter_line = find_line('Flipper adapter(memory) features') expect(adapter_line).to include('[ result=') end end def find_line(str) regex = /#{Regexp.escape(str)}/ lines = log.split("\n") lines.detect { |line| line =~ regex } || raise("Could not find line matching #{str.inspect} in #{lines.inspect}") end def clear_logs @io.string = '' end end flipper-0.21.0/spec/flipper/instrumentation/statsd_subscriber_spec.rb000066400000000000000000000040151404600161700261540ustar00rootroot00000000000000require 'helper' require 'flipper/adapters/instrumented' require 'flipper/instrumentation/statsd' require 'statsd' RSpec.describe Flipper::Instrumentation::StatsdSubscriber do let(:statsd_client) { Statsd.new } let(:socket) { FakeUDPSocket.new } let(:adapter) do memory = Flipper::Adapters::Memory.new Flipper::Adapters::Instrumented.new(memory, instrumenter: ActiveSupport::Notifications) end let(:flipper) do Flipper.new(adapter, instrumenter: ActiveSupport::Notifications) end let(:user) { user = Flipper::Actor.new('1') } before do described_class.client = statsd_client Thread.current[:statsd_socket] = socket end after do described_class.client = nil Thread.current[:statsd_socket] = nil end def assert_timer(metric) regex = /#{Regexp.escape metric}\:\d+\|ms/ result = socket.buffer.detect { |op| op.first =~ regex } expect(result).not_to be_nil end def assert_counter(metric) result = socket.buffer.detect { |op| op.first == "#{metric}:1|c" } expect(result).not_to be_nil end context 'for enabled feature' do it 'updates feature metrics when calls happen' do flipper[:stats].enable(user) assert_timer 'flipper.feature_operation.enable' flipper[:stats].enabled?(user) assert_timer 'flipper.feature_operation.enabled' assert_counter 'flipper.feature.stats.enabled' end end context 'for disabled feature' do it 'updates feature metrics when calls happen' do flipper[:stats].disable(user) assert_timer 'flipper.feature_operation.disable' flipper[:stats].enabled?(user) assert_timer 'flipper.feature_operation.enabled' assert_counter 'flipper.feature.stats.disabled' end end it 'updates adapter metrics when calls happen' do flipper[:stats].enable(user) assert_timer 'flipper.adapter.memory.enable' flipper[:stats].enabled?(user) assert_timer 'flipper.adapter.memory.get' flipper[:stats].disable(user) assert_timer 'flipper.adapter.memory.disable' end end flipper-0.21.0/spec/flipper/instrumenters/000077500000000000000000000000001404600161700205475ustar00rootroot00000000000000flipper-0.21.0/spec/flipper/instrumenters/memory_spec.rb000066400000000000000000000013341404600161700234170ustar00rootroot00000000000000require 'helper' require 'flipper/instrumenters/memory' RSpec.describe Flipper::Instrumenters::Memory do describe '#initialize' do it 'sets events to empty array' do instrumenter = described_class.new expect(instrumenter.events).to eq([]) end end describe '#instrument' do it 'adds to events' do instrumenter = described_class.new name = 'user.signup' payload = { email: 'john@doe.com' } block_result = :yielded result = instrumenter.instrument(name, payload) { block_result } expect(result).to eq(block_result) event = described_class::Event.new(name, payload, block_result) expect(instrumenter.events).to eq([event]) end end end flipper-0.21.0/spec/flipper/instrumenters/noop_spec.rb000066400000000000000000000010001404600161700230500ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Instrumenters::Noop do describe '.instrument' do context 'with name' do it 'yields block' do yielded = false described_class.instrument(:foo) { yielded = true } expect(yielded).to eq(true) end end context 'with name and payload' do it 'yields block' do yielded = false described_class.instrument(:foo, pay: :load) { yielded = true } expect(yielded).to eq(true) end end end end flipper-0.21.0/spec/flipper/middleware/000077500000000000000000000000001404600161700177425ustar00rootroot00000000000000flipper-0.21.0/spec/flipper/middleware/memoizer_spec.rb000066400000000000000000000270211404600161700231320ustar00rootroot00000000000000require 'helper' require 'rack/test' require 'active_support/cache' require 'active_support/cache/dalli_store' require 'flipper/adapters/active_support_cache_store' require 'flipper/adapters/operation_logger' RSpec.describe Flipper::Middleware::Memoizer do include Rack::Test::Methods let(:memory_adapter) { Flipper::Adapters::Memory.new } let(:adapter) do Flipper::Adapters::OperationLogger.new(memory_adapter) end let(:flipper) { Flipper.new(adapter) } let(:env) { { 'flipper' => flipper } } it 'raises if initialized with app and flipper instance' do expect do described_class.new(app, flipper) end.to raise_error(/no longer initializes with a flipper/) end it 'raises if initialized with app and block' do block = -> { flipper } expect do described_class.new(app, block) end.to raise_error(/no longer initializes with a flipper/) end RSpec.shared_examples_for 'flipper middleware' do it 'delegates' do called = false app = lambda do |_env| called = true [200, {}, nil] end middleware = described_class.new(app) middleware.call(env) expect(called).to eq(true) end it 'disables local cache after body close' do app = ->(_env) { [200, {}, []] } middleware = described_class.new(app) body = middleware.call(env).last expect(flipper.memoizing?).to eq(true) body.close expect(flipper.memoizing?).to eq(false) end it 'clears local cache after body close' do app = ->(_env) { [200, {}, []] } middleware = described_class.new(app) body = middleware.call(env).last flipper.adapter.cache['hello'] = 'world' body.close expect(flipper.adapter.cache).to be_empty end it 'clears the local cache with a successful request' do flipper.adapter.cache['hello'] = 'world' get '/', {}, 'flipper' => flipper expect(flipper.adapter.cache).to be_empty end it 'clears the local cache even when the request raises an error' do flipper.adapter.cache['hello'] = 'world' begin get '/fail', {}, 'flipper' => flipper rescue nil end expect(flipper.adapter.cache).to be_empty end it 'caches getting a feature for duration of request' do flipper[:stats].enable # clear the log of operations adapter.reset app = lambda do |_env| flipper[:stats].enabled? flipper[:stats].enabled? flipper[:stats].enabled? flipper[:stats].enabled? flipper[:stats].enabled? flipper[:stats].enabled? [200, {}, []] end middleware = described_class.new(app) middleware.call(env) expect(adapter.count(:get)).to be(1) end end context 'with preload: true' do let(:app) do # ensure scoped for builder block, annoying... instance = flipper middleware = described_class Rack::Builder.new do use middleware, preload: true map '/' do run ->(_env) { [200, {}, []] } end map '/fail' do run ->(_env) { raise 'FAIL!' } end end.to_app end include_examples 'flipper middleware' it 'eagerly caches known features for duration of request' do flipper[:stats].enable flipper[:shiny].enable # clear the log of operations adapter.reset app = lambda do |_env| flipper[:stats].enabled? flipper[:stats].enabled? flipper[:shiny].enabled? flipper[:shiny].enabled? [200, {}, []] end middleware = described_class.new(app, preload: true) middleware.call(env) expect(adapter.operations.size).to be(1) expect(adapter.count(:get_all)).to be(1) end it 'caches unknown features for duration of request' do # clear the log of operations adapter.reset app = lambda do |_env| flipper[:other].enabled? flipper[:other].enabled? [200, {}, []] end middleware = described_class.new(app, preload: true) middleware.call(env) expect(adapter.count(:get)).to be(1) expect(adapter.last(:get).args).to eq([flipper[:other]]) end end context 'with preload specific' do let(:app) do # ensure scoped for builder block, annoying... instance = flipper middleware = described_class Rack::Builder.new do use middleware, preload: %i(stats) map '/' do run ->(_env) { [200, {}, []] } end map '/fail' do run ->(_env) { raise 'FAIL!' } end end.to_app end include_examples 'flipper middleware' it 'eagerly caches specified features for duration of request' do # clear the log of operations adapter.reset app = lambda do |_env| flipper[:stats].enabled? flipper[:stats].enabled? flipper[:shiny].enabled? flipper[:shiny].enabled? [200, {}, []] end middleware = described_class.new app, preload: %i(stats) middleware.call(env) expect(adapter.count(:get_multi)).to be(1) expect(adapter.last(:get_multi).args).to eq([[flipper[:stats]]]) end it 'caches unknown features for duration of request' do # clear the log of operations adapter.reset app = lambda do |_env| flipper[:other].enabled? flipper[:other].enabled? [200, {}, []] end middleware = described_class.new app, preload: %i(stats) middleware.call(env) expect(adapter.count(:get)).to be(1) expect(adapter.last(:get).args).to eq([flipper[:other]]) end end context 'with multiple instances' do let(:app) do # ensure scoped for builder block, annoying... instance = flipper middleware = described_class Rack::Builder.new do use middleware, preload: %i(stats) # Second instance should be a noop use middleware, preload: true map '/' do run ->(_env) { [200, {}, []] } end map '/fail' do run ->(_env) { raise 'FAIL!' } end end.to_app end def get(uri, params = {}, env = {}, &block) silence { super(uri, params, env, &block) } end include_examples 'flipper middleware' it 'does not call preload in second instance' do expect(flipper).not_to receive(:preload_all) output = get '/', {}, 'flipper' => flipper expect(output).to match(/Flipper::Middleware::Memoizer appears to be running twice/) expect(adapter.count(:get_multi)).to be(1) expect(adapter.last(:get_multi).args).to eq([[flipper[:stats]]]) end end context 'when an app raises an exception' do it 'resets memoize' do begin app = ->(_env) { raise } middleware = described_class.new(app) middleware.call(env) rescue RuntimeError expect(flipper.memoizing?).to be(false) end end end context 'with flipper setup in env' do let(:app) do # ensure scoped for builder block, annoying... instance = flipper middleware = described_class Rack::Builder.new do use middleware map '/' do run ->(_env) { [200, {}, []] } end map '/fail' do run ->(_env) { raise 'FAIL!' } end end.to_app end include_examples 'flipper middleware' end context 'with Flipper setup in env' do it 'caches getting a feature for duration of request' do Flipper.configure do |config| config.adapter do memory = Flipper::Adapters::Memory.new Flipper::Adapters::OperationLogger.new(memory) end end Flipper.enable(:stats) Flipper.adapter.reset # clear the log of operations app = lambda do |_env| Flipper.enabled?(:stats) Flipper.enabled?(:stats) Flipper.enabled?(:stats) [200, {}, []] end middleware = described_class.new(app) middleware.call('flipper' => Flipper) expect(Flipper.adapter.count(:get)).to be(1) end end context 'defaults to Flipper' do it 'caches getting a feature for duration of request' do Flipper.configure do |config| config.default do memory_adapter = Flipper::Adapters::Memory.new logged_adapter = Flipper::Adapters::OperationLogger.new(memory_adapter) Flipper.new(logged_adapter) end end Flipper.enable(:stats) Flipper.adapter.reset # clear the log of operations app = lambda do |_env| Flipper.enabled?(:stats) Flipper.enabled?(:stats) Flipper.enabled?(:stats) [200, {}, []] end middleware = described_class.new(app) middleware.call({}) expect(Flipper.adapter.count(:get)).to be(1) end end context 'with preload:true' do let(:options) { {preload: true} } let(:app) do # ensure scoped for builder block, annoying... middleware = described_class opts = options Rack::Builder.new do use middleware, opts map '/' do run ->(_env) { [200, {}, []] } end map '/fail' do run ->(_env) { raise 'FAIL!' } end end.to_app end context 'and unless option' do before do options[:unless] = ->(request) { request.path.start_with?("/assets") } end it 'does NOT preload if request matches unless block' do expect(flipper).to receive(:preload_all).never get '/assets/foo.png', {}, 'flipper' => flipper end it 'does preload if request does NOT match unless block' do expect(flipper).to receive(:preload_all).once get '/some/other/path', {}, 'flipper' => flipper end end context 'and if option' do before do options[:if] = ->(request) { !request.path.start_with?("/assets") } end it 'does NOT preload if request does not match if block' do expect(flipper).to receive(:preload_all).never get '/assets/foo.png', {}, 'flipper' => flipper end it 'does preload if request matches if block' do expect(flipper).to receive(:preload_all).once get '/some/other/path', {}, 'flipper' => flipper end end end context 'with preload:true and caching adapter' do let(:app) do app = lambda do |_env| flipper[:stats].enabled? flipper[:stats].enabled? flipper[:shiny].enabled? flipper[:shiny].enabled? [200, {}, []] end described_class.new(app, preload: true) end it 'eagerly caches known features for duration of request' do memory = Flipper::Adapters::Memory.new logged_memory = Flipper::Adapters::OperationLogger.new(memory) cache = ActiveSupport::Cache::MemoryStore.new cache.clear cached = Flipper::Adapters::ActiveSupportCacheStore.new(logged_memory, cache, expires_in: 10) logged_cached = Flipper::Adapters::OperationLogger.new(cached) memo = {} flipper = Flipper.new(logged_cached) flipper[:stats].enable flipper[:shiny].enable # clear the log of operations logged_memory.reset logged_cached.reset get '/', {}, 'flipper' => flipper expect(logged_cached.count(:get_all)).to be(1) expect(logged_memory.count(:get_all)).to be(1) get '/', {}, 'flipper' => flipper expect(logged_cached.count(:get_all)).to be(2) expect(logged_memory.count(:get_all)).to be(1) get '/', {}, 'flipper' => flipper expect(logged_cached.count(:get_all)).to be(3) expect(logged_memory.count(:get_all)).to be(1) end end end flipper-0.21.0/spec/flipper/middleware/setup_env_spec.rb000066400000000000000000000050151404600161700233120ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::Middleware::SetupEnv do context 'with flipper instance' do let(:app) do app = lambda do |env| [200, { 'Content-Type' => 'text/html' }, [env['flipper'].object_id.to_s]] end builder = Rack::Builder.new builder.use described_class, flipper builder.run app builder end it 'sets flipper in env' do get '/' expect(last_response.body).to eq(flipper.object_id.to_s) end end context 'with block that returns flipper instance' do let(:flipper_block) do -> { flipper } end let(:app) do app = lambda do |env| [200, { 'Content-Type' => 'text/html' }, [env['flipper'].object_id.to_s]] end builder = Rack::Builder.new builder.use described_class, flipper_block builder.run app builder end it 'sets flipper in env' do get '/' expect(last_response.body).to eq(flipper.object_id.to_s) end end context 'when env already has flipper setup' do let(:app) do app = lambda do |env| [200, { 'Content-Type' => 'text/html' }, [env['flipper'].object_id.to_s]] end builder = Rack::Builder.new builder.use described_class, flipper builder.run app builder end it 'leaves env flipper alone' do env_flipper = build_flipper get '/', {}, 'flipper' => env_flipper expect(last_response.body).to eq(env_flipper.object_id.to_s) end end context 'when flipper instance or block are nil but env flipper is configured' do let(:app) do app = lambda do |env| [200, { 'Content-Type' => 'text/html' }, [env['flipper'].object_id.to_s]] end builder = Rack::Builder.new builder.use described_class builder.run app builder end it 'can use env flipper' do env_flipper = build_flipper get '/', {}, 'flipper' => env_flipper expect(last_response.body).to eq(env_flipper.object_id.to_s) end end context 'when flipper instance or block are nil and default Flipper is configured' do let(:app) do Flipper.configure do |config| config.default { flipper } end app = lambda do |env| [200, { 'Content-Type' => 'text/html' }, [env['flipper'].object_id.to_s]] end builder = Rack::Builder.new builder.use described_class builder.run app builder end it 'can use env flipper' do get '/', {}, {} expect(last_response.body).to eq(Flipper.object_id.to_s) end end end flipper-0.21.0/spec/flipper/railtie_spec.rb000066400000000000000000000034731404600161700206240ustar00rootroot00000000000000require 'helper' require 'rails' require 'flipper/railtie' RSpec.describe Flipper::Railtie do let(:application) do app = Class.new(Rails::Application).new( railties: [Flipper::Railtie], ordered_railties: [Flipper::Railtie] ) app.config.eager_load = false app.config.logger = ActiveSupport::Logger.new($stdout) app.run_load_hooks! end before do Rails.application = nil end subject do application.initialize! application end describe 'initializers' do it 'sets defaults' do expect(application.config.flipper.env_key).to eq("flipper") expect(application.config.flipper.memoize).to be(true) expect(application.config.flipper.preload).to be(true) end it "configures instrumentor on default instance" do subject expect(Flipper.instance.instrumenter).to eq(ActiveSupport::Notifications) end it 'uses Memoizer middleware if config.memoize = true' do expect(subject.middleware.last).to eq(Flipper::Middleware::Memoizer) end it 'does not use Memoizer middleware if config.memoize = false' do # load but don't initialize application.config.flipper.memoize = false expect(subject.middleware.last).not_to eq(Flipper::Middleware::Memoizer) end it 'passes config to memoizer' do # load but don't initialize application.config.flipper.update( env_key: 'my_flipper', preload: [:stats, :search] ) expect(Flipper::Middleware::Memoizer).to receive(:new).with(application.routes, env_key: 'my_flipper', preload: [:stats, :search], if: nil ) subject # initialize end it "defines #flipper_id on AR::Base" do subject require 'active_record' expect(ActiveRecord::Base.ancestors).to include(Flipper::Identifier) end end end flipper-0.21.0/spec/flipper/registry_spec.rb000066400000000000000000000055361404600161700210450ustar00rootroot00000000000000require 'helper' require 'flipper/registry' RSpec.describe Flipper::Registry do subject { described_class.new(source) } let(:source) { {} } describe '#add' do it 'adds to source' do value = 'thing' subject.add(:admins, value) expect(source[:admins]).to eq(value) end it 'converts key to symbol' do value = 'thing' subject.add('admins', value) expect(source[:admins]).to eq(value) end it 'raises exception if key already registered' do subject.add(:admins, 'thing') expect do subject.add('admins', 'again') end.to raise_error(Flipper::Registry::DuplicateKey) end end describe '#get' do context 'key registered' do before do source[:admins] = 'thing' end it 'returns value' do expect(subject.get(:admins)).to eq('thing') end it 'returns value if given string key' do expect(subject.get('admins')).to eq('thing') end end context 'key not registered' do it 'returns nil' do expect(subject.get(:admins)).to be(nil) end end end describe '#key?' do before do source[:admins] = 'admins' end it 'returns true if the key exists' do expect(subject.key?(:admins)).to eq true end it 'returns false if the key does not exists' do expect(subject.key?(:unknown_key)).to eq false end end describe '#each' do before do source[:admins] = 'admins' source[:devs] = 'devs' end it 'iterates source keys and values' do results = {} subject.each do |key, value| results[key] = value end expect(results).to eq(admins: 'admins', devs: 'devs') end end describe '#keys' do before do source[:admins] = 'admins' source[:devs] = 'devs' end it 'returns the keys' do expect(subject.keys.map(&:to_s).sort).to eq(%w(admins devs)) end it 'returns the keys as symbols' do subject.keys.each do |key| expect(key).to be_instance_of(Symbol) end end end describe '#values' do before do source[:admins] = 'admins' source[:devs] = 'devs' end it 'returns the values' do expect(subject.values.map(&:to_s).sort).to eq(%w(admins devs)) end end describe 'enumeration' do before do source[:admins] = 'admins' source[:devs] = 'devs' end it 'works' do keys = [] values = [] subject.map do |key, value| keys << key values << value end expect(keys.map(&:to_s).sort).to eq(%w(admins devs)) expect(values.sort).to eq(%w(admins devs)) end end describe '#clear' do before do source[:admins] = 'admins' end it 'clears the source' do subject.clear expect(source).to be_empty end end end flipper-0.21.0/spec/flipper/typecast_spec.rb000066400000000000000000000055231404600161700210250ustar00rootroot00000000000000require 'helper' require 'flipper/typecast' RSpec.describe Flipper::Typecast do { nil => false, '' => false, 0 => false, 1 => true, '0' => false, '1' => true, true => true, false => false, 'true' => true, 'false' => false, }.each do |value, expected| context "#to_boolean for #{value.inspect}" do it "returns #{expected}" do expect(described_class.to_boolean(value)).to be(expected) end end end { nil => 0, '' => 0, 0 => 0, 1 => 1, '1' => 1, '99' => 99, }.each do |value, expected| context "#to_integer for #{value.inspect}" do it "returns #{expected}" do expect(described_class.to_integer(value)).to be(expected) end end end { nil => 0.0, '' => 0.0, 0 => 0.0, 1 => 1.0, 1.1 => 1.1, '0.01' => 0.01, '1' => 1.0, '99' => 99.0, }.each do |value, expected| context "#to_float for #{value.inspect}" do it "returns #{expected}" do expect(described_class.to_float(value)).to be(expected) end end end { nil => 0, '' => 0, 0 => 0, 0.0 => 0.0, 1 => 1, 1.1 => 1.1, '0.01' => 0.01, '1' => 1, '1.1' => 1.1, '99' => 99, '99.9' => 99.9, }.each do |value, expected| context "#to_percentage for #{value.inspect}" do it "returns #{expected}" do expect(described_class.to_percentage(value)).to be(expected) end end end { nil => Set.new, '' => Set.new, Set.new([1, 2]) => Set.new([1, 2]), [1, 2] => Set.new([1, 2]), }.each do |value, expected| context "#to_set for #{value.inspect}" do it "returns #{expected}" do expect(described_class.to_set(value)).to eq(expected) end end end it 'raises argument error for integer value that cannot be converted to an integer' do expect do described_class.to_integer(['asdf']) end.to raise_error(ArgumentError, %(["asdf"] cannot be converted to an integer)) end it 'raises argument error for float value that cannot be converted to an float' do expect do described_class.to_float(['asdf']) end.to raise_error(ArgumentError, %(["asdf"] cannot be converted to a float)) end it 'raises argument error for bad integer percentage' do expect do described_class.to_percentage(['asdf']) end.to raise_error(ArgumentError, %(["asdf"] cannot be converted to an integer)) end it 'raises argument error for bad float percentage' do expect do described_class.to_percentage(['asdf.0']) end.to raise_error(ArgumentError, %(["asdf.0"] cannot be converted to a float)) end it 'raises argument error for set value that cannot be converted to a set' do expect do described_class.to_set('asdf') end.to raise_error(ArgumentError, %("asdf" cannot be converted to a set)) end end flipper-0.21.0/spec/flipper/types/000077500000000000000000000000001404600161700167715ustar00rootroot00000000000000flipper-0.21.0/spec/flipper/types/actor_spec.rb000066400000000000000000000056761404600161700214560ustar00rootroot00000000000000require 'helper' require 'flipper/types/actor' RSpec.describe Flipper::Types::Actor do subject do thing = thing_class.new('2') described_class.new(thing) end let(:thing_class) do Class.new do attr_reader :flipper_id def initialize(flipper_id) @flipper_id = flipper_id end def admin? true end end end describe '.wrappable?' do it 'returns true if actor' do thing = thing_class.new('1') actor = described_class.new(thing) expect(described_class.wrappable?(actor)).to eq(true) end it 'returns true if responds to flipper_id' do thing = thing_class.new(10) expect(described_class.wrappable?(thing)).to eq(true) end it 'returns false if nil' do expect(described_class.wrappable?(nil)).to be(false) end end describe '.wrap' do context 'for actor' do it 'returns actor' do actor = described_class.wrap(subject) expect(actor).to be_instance_of(described_class) expect(actor).to be(subject) end end context 'for other thing' do it 'returns actor' do thing = thing_class.new('1') actor = described_class.wrap(thing) expect(actor).to be_instance_of(described_class) end end end it 'initializes with thing that responds to id' do thing = thing_class.new('1') actor = described_class.new(thing) expect(actor.value).to eq('1') end it 'raises error when initialized with nil' do expect do described_class.new(nil) end.to raise_error(ArgumentError) end it 'raises error when initalized with non-wrappable object' do unwrappable_thing = Struct.new(:id).new(1) expect do described_class.new(unwrappable_thing) end.to raise_error(ArgumentError, "#{unwrappable_thing.inspect} must respond to flipper_id, but does not") end it 'converts id to string' do thing = thing_class.new(2) actor = described_class.new(thing) expect(actor.value).to eq('2') end it 'proxies everything to thing' do thing = thing_class.new(10) actor = described_class.new(thing) expect(actor.admin?).to eq(true) end it 'exposes thing' do thing = thing_class.new(10) actor = described_class.new(thing) expect(actor.thing).to be(thing) end describe '#respond_to?' do it 'returns true if responds to method' do thing = thing_class.new('1') actor = described_class.new(thing) expect(actor.respond_to?(:value)).to eq(true) end it 'returns true if thing responds to method' do thing = thing_class.new(10) actor = described_class.new(thing) expect(actor.respond_to?(:admin?)).to eq(true) end it 'returns false if does not respond to method and thing does not respond to method' do thing = thing_class.new(10) actor = described_class.new(thing) expect(actor.respond_to?(:frankenstein)).to eq(false) end end end flipper-0.21.0/spec/flipper/types/boolean_spec.rb000066400000000000000000000010651404600161700217510ustar00rootroot00000000000000require 'helper' require 'flipper/types/boolean' RSpec.describe Flipper::Types::Boolean do it 'defaults value to true' do boolean = described_class.new expect(boolean.value).to be(true) end it 'allows overriding default value' do boolean = described_class.new(false) expect(boolean.value).to be(false) end it 'returns true for nil value' do boolean = described_class.new(nil) expect(boolean.value).to be(true) end it 'typecasts value' do boolean = described_class.new(1) expect(boolean.value).to be(true) end end flipper-0.21.0/spec/flipper/types/group_spec.rb000066400000000000000000000063751404600161700214770ustar00rootroot00000000000000require 'helper' require 'flipper/types/group' RSpec.describe Flipper::Types::Group do let(:fake_context) { double('FeatureCheckContext') } subject do Flipper.register(:admins, &:admin?) end describe '.wrap' do context 'with group instance' do it 'returns group instance' do expect(described_class.wrap(subject)).to eq(subject) end end context 'with Symbol group name' do it 'returns group instance' do expect(described_class.wrap(subject.name)).to eq(subject) end end context 'with String group name' do it 'returns group instance' do expect(described_class.wrap(subject.name.to_s)).to eq(subject) end end end it 'initializes with name' do group = described_class.new(:admins) expect(group).to be_instance_of(described_class) end describe '#name' do it 'returns name' do expect(subject.name).to eq(:admins) end end describe '#match?' do let(:admin_actor) { double('Actor', admin?: true) } let(:non_admin_actor) { double('Actor', admin?: false) } it 'returns true if block matches' do expect(subject.match?(admin_actor, fake_context)).to eq(true) end it 'returns false if block does not match' do expect(subject.match?(non_admin_actor, fake_context)).to eq(false) end it 'returns true for truthy block results' do group = described_class.new(:examples) do |actor| actor.email =~ /@example\.com/ end expect(group.match?(double('Actor', email: 'foo@example.com'), fake_context)).to be_truthy end it 'returns false for falsey block results' do group = described_class.new(:examples) do |_actor| nil end expect(group.match?(double('Actor'), fake_context)).to be_falsey end it 'returns true for truthy shortand block results' do actor = Class.new do def admin? true end end.new group = described_class.new(:admin, &:admin?) expect(group.match?(actor, fake_context)).to be_truthy end it 'returns false for falsy shortand block results' do actor = Class.new do def admin? false end end.new group = described_class.new(:admin, &:admin?) expect(group.match?(actor, fake_context)).to be_falsey end it 'can yield without context as block argument' do context = Flipper::FeatureCheckContext.new( feature_name: :my_feature, values: Flipper::GateValues.new({}), thing: Flipper::Types::Actor.new(Flipper::Actor.new(1)) ) group = Flipper.register(:group_with_context) { |actor| actor } yielded_actor = group.match?(admin_actor, context) expect(yielded_actor).to be(admin_actor) end it 'can yield with context as block argument' do context = Flipper::FeatureCheckContext.new( feature_name: :my_feature, values: Flipper::GateValues.new({}), thing: Flipper::Types::Actor.new(Flipper::Actor.new(1)) ) group = Flipper.register(:group_with_context) { |actor, context| [actor, context] } yielded_actor, yielded_context = group.match?(admin_actor, context) expect(yielded_actor).to be(admin_actor) expect(yielded_context).to be(context) end end end flipper-0.21.0/spec/flipper/types/percentage_of_actors_spec.rb000066400000000000000000000002371404600161700245060ustar00rootroot00000000000000require 'helper' require 'flipper/types/percentage_of_actors' RSpec.describe Flipper::Types::PercentageOfActors do it_should_behave_like 'a percentage' end flipper-0.21.0/spec/flipper/types/percentage_of_time_spec.rb000066400000000000000000000002331404600161700241450ustar00rootroot00000000000000require 'helper' require 'flipper/types/percentage_of_time' RSpec.describe Flipper::Types::PercentageOfTime do it_should_behave_like 'a percentage' end flipper-0.21.0/spec/flipper/types/percentage_spec.rb000066400000000000000000000023111404600161700224420ustar00rootroot00000000000000require 'helper' require 'flipper/types/percentage_of_actors' RSpec.describe Flipper::Types::Percentage do subject do described_class.new(5) end it_should_behave_like 'a percentage' describe '.wrap' do context 'with percentage instance' do it 'returns percentage instance' do expect(described_class.wrap(subject)).to eq(subject) end end context 'with Integer' do it 'returns percentage instance' do expect(described_class.wrap(subject.value)).to eq(subject) end end context 'with String' do it 'returns percentage instance' do expect(described_class.wrap(subject.value.to_s)).to eq(subject) end end end describe '#eql?' do it 'returns true for same class and value' do expect(subject.eql?(described_class.new(subject.value))).to eq(true) end it 'returns false for different value' do expect(subject.eql?(described_class.new(subject.value + 1))).to eq(false) end it 'returns false for different class' do expect(subject.eql?(Object.new)).to eq(false) end it 'is aliased to ==' do expect((subject == described_class.new(subject.value))).to eq(true) end end end flipper-0.21.0/spec/flipper/ui/000077500000000000000000000000001404600161700162425ustar00rootroot00000000000000flipper-0.21.0/spec/flipper/ui/action_spec.rb000066400000000000000000000042501404600161700210570ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::UI::Action do describe 'request methods' do let(:action_subclass) do Class.new(described_class) do def noooope raise 'should never run this' end def get [200, {}, 'get'] end def post [200, {}, 'post'] end def put [200, {}, 'put'] end def delete [200, {}, 'delete'] end end end it "won't run method that isn't whitelisted" do fake_request = Struct.new(:request_method, :env, :session).new('NOOOOPE', {}, {}) action = action_subclass.new(flipper, fake_request) expect do action.run end.to raise_error(Flipper::UI::RequestMethodNotSupported) end it 'will run get' do fake_request = Struct.new(:request_method, :env, :session).new('GET', {}, {}) action = action_subclass.new(flipper, fake_request) expect(action.run).to eq([200, {}, 'get']) end it 'will run post' do fake_request = Struct.new(:request_method, :env, :session).new('POST', {}, {}) action = action_subclass.new(flipper, fake_request) expect(action.run).to eq([200, {}, 'post']) end it 'will run put' do fake_request = Struct.new(:request_method, :env, :session).new('PUT', {}, {}) action = action_subclass.new(flipper, fake_request) expect(action.run).to eq([200, {}, 'put']) end end describe 'FeatureNameFromRoute' do let(:action_subclass) do Class.new(described_class) do |parent| include parent::FeatureNameFromRoute route %r{\A/features/(?.*)\Z} def get [200, { feature_name: feature_name }, 'get'] end end end it 'decodes feature_name' do requested_feature_name = Rack::Utils.escape("team:side_pane") fake_request = Struct .new(:request_method, :env, :session, :path_info) .new('GET', {}, {}, "/features/#{requested_feature_name}") action = action_subclass.new(flipper, fake_request) expect(action.run).to eq([200, { feature_name: "team:side_pane" }, 'get']) end end end flipper-0.21.0/spec/flipper/ui/actions/000077500000000000000000000000001404600161700177025ustar00rootroot00000000000000flipper-0.21.0/spec/flipper/ui/actions/actors_gate_spec.rb000066400000000000000000000066651404600161700235510ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::UI::Actions::ActorsGate do let(:token) do if Rack::Protection::AuthenticityToken.respond_to?(:random_token) Rack::Protection::AuthenticityToken.random_token else 'a' end end let(:session) do { :csrf => token, 'csrf' => token, '_csrf_token' => token } end describe 'GET /features/:feature/actors' do before do get 'features/search/actors' end it 'responds with success' do expect(last_response.status).to be(200) end it 'renders add new actor form' do form = '
' expect(last_response.body).to include(form) end end describe 'GET /features/:feature/actors with slash in feature name' do before do get 'features/a/b/actors' end it 'responds with success' do expect(last_response.status).to be(200) end it 'renders add new actor form' do form = '' expect(last_response.body).to include(form) end end describe 'POST /features/:feature/actors' do context 'enabling an actor' do let(:value) { 'User;6' } before do post 'features/search/actors', { 'value' => value, 'operation' => 'enable', 'authenticity_token' => token }, 'rack.session' => session end it 'adds item to members' do expect(flipper[:search].actors_value).to include('User;6') end it 'redirects back to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/search') end context 'value contains whitespace' do let(:value) { ' User;6 ' } it 'adds item without whitespace' do expect(flipper[:search].actors_value).to include('User;6') end end context 'for an invalid actor value' do context 'empty value' do let(:value) { '' } it 'redirects back to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/search/actors?error=%22%22+is+not+a+valid+actor+value.') end end context 'nil value' do let(:value) { nil } it 'redirects back to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/search/actors?error=%22%22+is+not+a+valid+actor+value.') end end end end context 'disabling an actor' do let(:value) { 'User;6' } before do flipper[:search].enable_actor Flipper::Actor.new('User;6') post 'features/search/actors', { 'value' => value, 'operation' => 'disable', 'authenticity_token' => token }, 'rack.session' => session end it 'removes item from members' do expect(flipper[:search].actors_value).not_to include('User;6') end it 'redirects back to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/search') end context 'value contains whitespace' do let(:value) { ' User;6 ' } it 'removes item without whitespace' do expect(flipper[:search].actors_value).not_to include('User;6') end end end end end flipper-0.21.0/spec/flipper/ui/actions/add_feature_spec.rb000066400000000000000000000025211404600161700235040ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::UI::Actions::AddFeature do describe 'GET /features/new with feature_creation_enabled set to true' do before do @original_feature_creation_enabled = Flipper::UI.configuration.feature_creation_enabled Flipper::UI.configuration.feature_creation_enabled = true get '/features/new' end after do Flipper::UI.configuration.feature_creation_enabled = @original_feature_creation_enabled end it 'responds with success' do expect(last_response.status).to be(200) end it 'renders template' do form = '' expect(last_response.body).to include(form) end end describe 'GET /features/new with feature_creation_enabled set to false' do before do @original_feature_creation_enabled = Flipper::UI.configuration.feature_creation_enabled Flipper::UI.configuration.feature_creation_enabled = false get '/features/new' end after do Flipper::UI.configuration.feature_creation_enabled = @original_feature_creation_enabled end it 'returns 403' do expect(last_response.status).to be(403) end it 'renders feature creation disabled template' do expect(last_response.body).to include('Feature creation is disabled.') end end end flipper-0.21.0/spec/flipper/ui/actions/boolean_gate_spec.rb000066400000000000000000000026311404600161700236620ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::UI::Actions::BooleanGate do let(:token) do if Rack::Protection::AuthenticityToken.respond_to?(:random_token) Rack::Protection::AuthenticityToken.random_token else 'a' end end let(:session) do { :csrf => token, 'csrf' => token, '_csrf_token' => token } end describe 'POST /features/:feature/boolean' do context 'with enable' do before do flipper.disable :search post 'features/search/boolean', { 'action' => 'Enable', 'authenticity_token' => token }, 'rack.session' => session end it 'enables the feature' do expect(flipper.enabled?(:search)).to be(true) end it 'redirects back to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/search') end end context 'with disable' do before do flipper.enable :search post 'features/search/boolean', { 'action' => 'Disable', 'authenticity_token' => token }, 'rack.session' => session end it 'disables the feature' do expect(flipper.enabled?(:search)).to be(false) end it 'redirects back to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/search') end end end end flipper-0.21.0/spec/flipper/ui/actions/feature_spec.rb000066400000000000000000000070151404600161700226770ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::UI::Actions::Feature do let(:token) do if Rack::Protection::AuthenticityToken.respond_to?(:random_token) Rack::Protection::AuthenticityToken.random_token else 'a' end end let(:session) do { :csrf => token, 'csrf' => token, '_csrf_token' => token } end describe 'DELETE /features/:feature' do before do flipper.enable :search delete '/features/search', { 'authenticity_token' => token }, 'rack.session' => session end it 'removes feature' do expect(flipper.features.map(&:key)).not_to include('search') end it 'redirects to features' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features') end context 'when feature_removal_enabled is set to false' do around do |example| begin @original_feature_removal_enabled = Flipper::UI.configuration.feature_removal_enabled Flipper::UI.configuration.feature_removal_enabled = false example.run ensure Flipper::UI.configuration.feature_removal_enabled = @original_feature_removal_enabled end end it 'returns with 403 status' do expect(last_response.status).to be(403) end it 'renders feature removal disabled template' do expect(last_response.body).to include('Feature removal from the UI is disabled') end end end describe 'POST /features/:feature with _method=DELETE' do before do flipper.enable :search post '/features/search', { '_method' => 'DELETE', 'authenticity_token' => token }, 'rack.session' => session end it 'removes feature' do expect(flipper.features.map(&:key)).not_to include('search') end it 'redirects to features' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features') end end describe 'GET /features/:feature' do before do Flipper::UI.configure do |config| config.descriptions_source = lambda { |_keys| { "stats" => "Most awesome stats", "search" => "Most in-depth search", } } end get '/features/search' end it 'responds with success' do expect(last_response.status).to be(200) end it 'renders template' do expect(last_response.body).to include('search') expect(last_response.body).to include('Enable') expect(last_response.body).to include('Disable') expect(last_response.body).to include('No actors enabled') expect(last_response.body).to include('No groups enabled') expect(last_response.body).to include('Enabled for 0% of time') expect(last_response.body).to include('Enabled for 0% of actors') expect(last_response.body).to include('Most in-depth search') end end describe 'GET /features/:feature with _features in feature name' do before do get '/features/search_features' end it 'responds with success' do expect(last_response.status).to be(200) end it 'renders template' do expect(last_response.body).to include('search_features') end end describe 'GET /features/:feature with slash in feature name' do before do get '/features/a/b' end it 'responds with success' do expect(last_response.status).to be(200) end it 'renders template' do expect(last_response.body).to include('a/b') end end end flipper-0.21.0/spec/flipper/ui/actions/features_spec.rb000066400000000000000000000104401404600161700230560ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::UI::Actions::Features do let(:token) do if Rack::Protection::AuthenticityToken.respond_to?(:random_token) Rack::Protection::AuthenticityToken.random_token else 'a' end end let(:session) do { :csrf => token, 'csrf' => token, '_csrf_token' => token } end describe 'GET /features' do context "when there are some features" do before do flipper[:stats].enable flipper[:search].enable get '/features' end it 'responds with success' do expect(last_response.status).to be(200) end it 'renders template' do expect(last_response.body).to include('stats') expect(last_response.body).to include('search') end end context "when there are no features to list" do before do @original_fun_enabled = Flipper::UI.configuration.fun Flipper::UI.configuration.fun = fun_mode end after do Flipper::UI.configuration.fun = @original_fun_enabled end context "when fun mode is enabled" do let(:fun_mode) { true } before { get '/features' } it 'responds with success' do expect(last_response.status).to be(200) end it 'renders template' do expect(last_response.body).to include('And I\'ll flip your features.') end end context "when fun mode is disabled" do let(:fun_mode) { false } before { get '/features' } it 'responds with success' do expect(last_response.status).to be(200) end it 'renders template' do expect(last_response.body).to include('You have not added any features to configure yet.') end end end end describe 'POST /features' do let(:feature_name) { 'notifications_next' } before do @original_feature_creation_enabled = Flipper::UI.configuration.feature_creation_enabled Flipper::UI.configuration.feature_creation_enabled = feature_creation_enabled post '/features', { 'value' => feature_name, 'authenticity_token' => token }, 'rack.session' => session end after do Flipper::UI.configuration.feature_creation_enabled = @original_feature_creation_enabled end context 'feature_creation_enabled set to true' do let(:feature_creation_enabled) { true } it 'adds feature' do expect(flipper.features.map(&:key)).to include('notifications_next') end it 'redirects to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/notifications_next') end context 'feature name contains whitespace' do let(:feature_name) { ' notifications_next ' } it 'adds feature without whitespace' do expect(flipper.features.map(&:key)).to include('notifications_next') end end context 'for an invalid feature name' do context 'empty feature name' do let(:feature_name) { '' } it 'does not add feature' do expect(flipper.features.map(&:key)).to eq([]) end it 'redirects back to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/new?error=%22%22+is+not+a+valid+feature+name.') end end context 'nil feature name' do let(:feature_name) { nil } it 'does not add feature' do expect(flipper.features.map(&:key)).to eq([]) end it 'redirects back to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/new?error=%22%22+is+not+a+valid+feature+name.') end end end end context 'feature_creation_enabled set to false' do let(:feature_creation_enabled) { false } it 'does not add feature' do expect(flipper.features.map(&:key)).not_to include('notifications_next') end it 'returns 403' do expect(last_response.status).to be(403) end it 'renders feature creation disabled template' do expect(last_response.body).to include('Feature creation is disabled.') end end end end flipper-0.21.0/spec/flipper/ui/actions/file_spec.rb000066400000000000000000000011621404600161700221600ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::UI::Actions::File do describe 'GET /images/logo.png' do before do get '/images/logo.png' end it 'responds with 200' do expect(last_response.status).to be(200) end end describe 'GET /css/application.css' do before do get '/css/application.css' end it 'responds with 200' do expect(last_response.status).to be(200) end end describe 'GET /octicons/octicons.eot' do before do get '/octicons/octicons.eot' end it 'responds with 200' do expect(last_response.status).to be(200) end end end flipper-0.21.0/spec/flipper/ui/actions/groups_gate_spec.rb000066400000000000000000000072251404600161700235660ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::UI::Actions::GroupsGate do let(:token) do if Rack::Protection::AuthenticityToken.respond_to?(:random_token) Rack::Protection::AuthenticityToken.random_token else 'a' end end let(:session) do { :csrf => token, 'csrf' => token, '_csrf_token' => token } end describe 'GET /features/:feature/groups' do before do Flipper.register(:admins, &:admin?) get 'features/search/groups' end after do Flipper.unregister_groups end it 'responds with success' do expect(last_response.status).to be(200) end it 'renders add new group form' do form = '' expect(last_response.body).to include(form) end end describe 'POST /features/:feature/groups' do let(:group_name) { 'admins' } before do Flipper.register(:admins, &:admin?) end after do Flipper.unregister_groups end context 'enabling a group' do before do post 'features/search/groups', { 'value' => group_name, 'operation' => 'enable', 'authenticity_token' => token }, 'rack.session' => session end it 'adds item to members' do expect(flipper[:search].groups_value).to include('admins') end it 'redirects back to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/search') end context 'group name contains whitespace' do let(:group_name) { ' admins ' } it 'adds item without whitespace' do expect(flipper[:search].groups_value).to include('admins') end end context 'for an unregistered group' do context 'unknown group name' do let(:group_name) { 'not_here' } it 'redirects back to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/search/groups?error=The+group+named+%22not_here%22+has+not+been+registered.') end end context 'empty group name' do let(:group_name) { '' } it 'redirects back to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/search/groups?error=The+group+named+%22%22+has+not+been+registered.') end end context 'nil group name' do let(:group_name) { nil } it 'redirects back to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/search/groups?error=The+group+named+%22%22+has+not+been+registered.') end end end end context 'disabling a group' do let(:group_name) { 'admins' } before do flipper[:search].enable_group :admins post 'features/search/groups', { 'value' => group_name, 'operation' => 'disable', 'authenticity_token' => token }, 'rack.session' => session end it 'removes item from members' do expect(flipper[:search].groups_value).not_to include('admins') end it 'redirects back to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/search') end context 'group name contains whitespace' do let(:group_name) { ' admins ' } it 'removes item without whitespace' do expect(flipper[:search].groups_value).not_to include('admins') end end end end end flipper-0.21.0/spec/flipper/ui/actions/home_spec.rb000066400000000000000000000005331404600161700221720ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::UI::Actions::Home do describe 'GET /' do before do flipper[:stats].enable flipper[:search].enable get '/' end it 'responds with redirect' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features') end end end flipper-0.21.0/spec/flipper/ui/actions/percentage_of_actors_gate_spec.rb000066400000000000000000000030411404600161700264130ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::UI::Actions::PercentageOfActorsGate do let(:token) do if Rack::Protection::AuthenticityToken.respond_to?(:random_token) Rack::Protection::AuthenticityToken.random_token else 'a' end end let(:session) do { :csrf => token, 'csrf' => token, '_csrf_token' => token } end describe 'POST /features/:feature/percentage_of_actors' do context 'with valid value' do before do post 'features/search/percentage_of_actors', { 'value' => '24', 'authenticity_token' => token }, 'rack.session' => session end it 'enables the feature' do expect(flipper[:search].percentage_of_actors_value).to be(24) end it 'redirects back to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/search') end end context 'with invalid value' do before do post 'features/search/percentage_of_actors', { 'value' => '555', 'authenticity_token' => token }, 'rack.session' => session end it 'does not change value' do expect(flipper[:search].percentage_of_actors_value).to be(0) end it 'redirects back to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/search?error=Invalid+percentage+of+actors+value%3A+value+must+be+a+positive+number+less+than+or+equal+to+100%2C+but+was+555') end end end end flipper-0.21.0/spec/flipper/ui/actions/percentage_of_time_gate_spec.rb000066400000000000000000000030231404600161700260560ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::UI::Actions::PercentageOfTimeGate do let(:token) do if Rack::Protection::AuthenticityToken.respond_to?(:random_token) Rack::Protection::AuthenticityToken.random_token else 'a' end end let(:session) do { :csrf => token, 'csrf' => token, '_csrf_token' => token } end describe 'POST /features/:feature/percentage_of_time' do context 'with valid value' do before do post 'features/search/percentage_of_time', { 'value' => '24', 'authenticity_token' => token }, 'rack.session' => session end it 'enables the feature' do expect(flipper[:search].percentage_of_time_value).to be(24) end it 'redirects back to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/search') end end context 'with invalid value' do before do post 'features/search/percentage_of_time', { 'value' => '555', 'authenticity_token' => token }, 'rack.session' => session end it 'does not change value' do expect(flipper[:search].percentage_of_time_value).to be(0) end it 'redirects back to feature' do expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/search?error=Invalid+percentage+of+time+value%3A+value+must+be+a+positive+number+less+than+or+equal+to+100%2C+but+was+555') end end end end flipper-0.21.0/spec/flipper/ui/configuration_spec.rb000066400000000000000000000107611404600161700224550ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::UI::Configuration do let(:configuration) { described_class.new } describe "#delete" do it "has default text" do expect(configuration.delete.title).to eq("Danger Zone") expect(configuration.delete.description).to eq("Deleting a feature removes it from the list of features and disables it for everyone.") end end describe "#banner_text" do it "has no default" do expect(configuration.banner_text).to eq(nil) end it "can be updated" do configuration.banner_text = 'Production Environment' expect(configuration.banner_text).to eq('Production Environment') end end describe "#banner_class" do it "has default color" do expect(configuration.banner_class).to eq('danger') end it "can be updated" do configuration.banner_class = 'info' expect(configuration.banner_class).to eq('info') end it "raises if set to invalid value" do expect { configuration.banner_class = :invalid_class } .to raise_error(Flipper::InvalidConfigurationValue) end end describe "#application_breadcrumb_href" do it "has default value" do expect(configuration.application_breadcrumb_href).to eq(nil) end it "can be updated" do configuration.application_breadcrumb_href = 'http://www.myapp.com' expect(configuration.application_breadcrumb_href).to eq('http://www.myapp.com') end end describe "#feature_creation_enabled" do it "has default value" do expect(configuration.feature_creation_enabled).to eq(true) end it "can be updated" do configuration.feature_creation_enabled = false expect(configuration.feature_creation_enabled).to eq(false) end end describe "#cloud_recommendation" do it "has default value" do expect(configuration.cloud_recommendation).to eq(true) end it "can be updated" do configuration.cloud_recommendation = false expect(configuration.cloud_recommendation).to eq(false) end end describe "#feature_removal_enabled" do it "has default value" do expect(configuration.feature_removal_enabled).to eq(true) end it "can be updated" do configuration.feature_removal_enabled = false expect(configuration.feature_removal_enabled).to eq(false) end end describe "#fun" do it "has default value" do expect(configuration.fun).to eq(true) end it "can be updated" do configuration.fun = false expect(configuration.fun).to eq(false) end end describe "#descriptions_source" do it "has default value" do expect(configuration.descriptions_source.call(%w[foo bar])).to eq({}) end context "descriptions source is provided" do it "can be updated" do configuration.descriptions_source = lambda do |_keys| YAML.load_file(FlipperRoot.join('spec/support/descriptions.yml')) end keys = %w[some_awesome_feature foo] result = configuration.descriptions_source.call(keys) expected = { "some_awesome_feature" => "Awesome feature description", } expect(result).to eq(expected) end end end describe "#show_feature_description_in_list" do it "has default value" do expect(configuration.show_feature_description_in_list).to eq(false) end it "can be updated" do configuration.show_feature_description_in_list = true expect(configuration.show_feature_description_in_list).to eq(true) end end describe "#show_feature_description_in_list?" do subject { configuration.show_feature_description_in_list? } context 'when using_descriptions? is false and show_feature_description_in_list is false' do it { is_expected.to eq(false) } end context 'when using_descriptions? is false and show_feature_description_in_list is true' do before { configuration.show_feature_description_in_list = true } it { is_expected.to eq(false) } end context 'when using_descriptions? is true and show_feature_description_in_list is false' do before { allow(configuration).to receive(:using_descriptions?).and_return(true) } it { is_expected.to eq(false) } end context 'when using_descriptions? is true and show_feature_description_in_list is true' do before do allow(configuration).to receive(:using_descriptions?).and_return(true) configuration.show_feature_description_in_list = true end it { is_expected.to eq(true) } end end end flipper-0.21.0/spec/flipper/ui/decorators/000077500000000000000000000000001404600161700204075ustar00rootroot00000000000000flipper-0.21.0/spec/flipper/ui/decorators/feature_spec.rb000066400000000000000000000025361404600161700234070ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::UI::Decorators::Feature do let(:source) { {} } let(:adapter) { Flipper::Adapters::Memory.new(source) } let(:flipper) { build_flipper } let(:feature) { flipper[:some_awesome_feature] } subject do described_class.new(feature) end describe '#initialize' do it 'sets the feature' do expect(subject.feature).to be(feature) end end describe '#pretty_name' do it 'capitalizes each word separated by underscores' do expect(subject.pretty_name).to eq('Some Awesome Feature') end end describe '#<=>' do let(:on) do flipper.enable(:on_a) described_class.new(flipper[:on_a]) end let(:on_b) do flipper.enable(:on_b) described_class.new(flipper[:on_b]) end let(:conditional) do flipper.enable_percentage_of_time :conditional_a, 5 described_class.new(flipper[:conditional_a]) end let(:off) do described_class.new(flipper[:off_a]) end it 'sorts :on before :conditional' do expect((on <=> conditional)).to be(-1) end it 'sorts :on before :off' do expect((on <=> off)).to be(-1) end it 'sorts :conditional before :off' do expect((conditional <=> off)).to be(-1) end it 'sorts on key for identical states' do expect((on <=> on_b)).to be(-1) end end end flipper-0.21.0/spec/flipper/ui/decorators/gate_spec.rb000066400000000000000000000017021404600161700226660ustar00rootroot00000000000000require 'helper' require 'flipper/ui/decorators/gate' RSpec.describe Flipper::UI::Decorators::Gate do let(:source) { {} } let(:adapter) { Flipper::Adapters::Memory.new(source) } let(:flipper) { build_flipper } let(:feature) { flipper[:some_awesome_feature] } let(:gate) { feature.gate(:boolean) } subject do described_class.new(gate, false) end describe '#initialize' do it 'sets gate' do expect(subject.gate).to be(gate) end it 'sets value' do expect(subject.value).to eq(false) end end describe '#as_json' do before do @result = subject.as_json end it 'returns Hash' do expect(@result).to be_instance_of(Hash) end it 'includes key' do expect(@result['key']).to eq('boolean') end it 'includes pretty name' do expect(@result['name']).to eq('boolean') end it 'includes value' do expect(@result['value']).to be(false) end end end flipper-0.21.0/spec/flipper/ui/util_spec.rb000066400000000000000000000007401404600161700205570ustar00rootroot00000000000000require 'helper' require 'flipper/ui/util' RSpec.describe Flipper::UI::Util do describe '#blank?' do context 'with a string' do it 'returns true if blank' do expect(described_class.blank?(nil)).to be(true) expect(described_class.blank?('')).to be(true) expect(described_class.blank?(' ')).to be(true) end it 'returns false if not blank' do expect(described_class.blank?('nope')).to be(false) end end end end flipper-0.21.0/spec/flipper/ui_spec.rb000066400000000000000000000167661404600161700176210ustar00rootroot00000000000000require 'helper' RSpec.describe Flipper::UI do let(:token) do if Rack::Protection::AuthenticityToken.respond_to?(:random_token) Rack::Protection::AuthenticityToken.random_token else 'a' end end let(:session) do { :csrf => token, 'csrf' => token, '_csrf_token' => token } end let(:configuration) { described_class.configuration } describe 'Initializing middleware with flipper instance' do let(:app) { build_app(flipper) } it 'works' do flipper.enable :some_great_feature get '/features' expect(last_response.status).to be(200) expect(last_response.body).to include('some_great_feature') end end describe 'Initializing middleware lazily with a block' do let(:app) do build_app(-> { flipper }) end it 'works' do flipper.enable :some_great_feature get '/features' expect(last_response.status).to be(200) expect(last_response.body).to include('some_great_feature') end end describe 'Request method unsupported by action' do it 'raises error' do expect do head '/features' end.to raise_error(Flipper::UI::RequestMethodNotSupported) end end describe 'Inspecting the built Rack app' do it 'returns a String' do expect(build_app(flipper).inspect).to be_a(String) end end # See https://github.com/jnunemaker/flipper/issues/80 it 'can route features with names that match static directories' do post 'features/refactor-images/actors', { 'value' => 'User;6', 'operation' => 'enable', 'authenticity_token' => token }, 'rack.session' => session expect(last_response.status).to be(302) expect(last_response.headers['Location']).to eq('/features/refactor-images') end describe "application_breadcrumb_href" do it "raises an exception since it is deprecated" do expect { described_class.application_breadcrumb_href } .to raise_error(Flipper::ConfigurationDeprecated) end end describe "feature_creation_enabled" do it "raises an exception since it is deprecated" do expect { described_class.feature_creation_enabled } .to raise_error(Flipper::ConfigurationDeprecated) end end describe "feature_removal_enabled" do it "raises an exception since it is deprecated" do expect { described_class.feature_removal_enabled } .to raise_error(Flipper::ConfigurationDeprecated) end end describe 'configure' do it 'yields configuration instance' do described_class.configure do |config| expect(config).to be_instance_of(Flipper::UI::Configuration) end end describe 'banner' do it 'does not include the banner if banner_text is not set' do get '/features' expect(last_response.body).not_to include('Production Environment') end describe 'when set' do around do |example| begin @original_banner_text = described_class.configuration.banner_text described_class.configuration.banner_text = 'Production Environment' example.run ensure described_class.configuration.banner_text = @original_banner_text end end it 'includes banner' do get '/features' expect(last_response.body).to include('Production Environment') end end end describe "application_breadcrumb_href" do it 'does not have an application_breadcrumb_href by default' do expect(configuration.application_breadcrumb_href).to be(nil) end context 'with application_breadcrumb_href not set' do before do @original_application_breadcrumb_href = configuration.application_breadcrumb_href configuration.application_breadcrumb_href = nil end after do configuration.application_breadcrumb_href = @original_application_breadcrumb_href end it 'does not add App breadcrumb' do get '/features' expect(last_response.body).not_to include('App') end end context 'with application_breadcrumb_href set' do before do @original_application_breadcrumb_href = configuration.application_breadcrumb_href configuration.application_breadcrumb_href = '/myapp' end after do configuration.application_breadcrumb_href = @original_application_breadcrumb_href end it 'does add App breadcrumb' do get '/features' expect(last_response.body).to include('App') end end context 'with application_breadcrumb_href set to full url' do before do @original_application_breadcrumb_href = configuration.application_breadcrumb_href configuration.application_breadcrumb_href = 'https://myapp.com/' end after do configuration.application_breadcrumb_href = @original_application_breadcrumb_href end it 'does add App breadcrumb' do get '/features' expect(last_response.body).to include('App') end end end describe "feature_creation_enabled" do it 'sets feature_creation_enabled to true by default' do expect(configuration.feature_creation_enabled).to be(true) end context 'with feature_creation_enabled set to true' do before do @original_feature_creation_enabled = configuration.feature_creation_enabled configuration.feature_creation_enabled = true end it 'has the add_feature button' do get '/features' expect(last_response.body).to include('Add Feature') end after do configuration.feature_creation_enabled = @original_feature_creation_enabled end end context 'with feature_creation_enabled set to false' do before do @original_feature_creation_enabled = configuration.feature_creation_enabled configuration.feature_creation_enabled = false end it 'does not have the add_feature button' do get '/features' expect(last_response.body).not_to include('Add Feature') end after do configuration.feature_creation_enabled = @original_feature_creation_enabled end end end describe "feature_removal_enabled" do it 'sets feature_removal_enabled to true by default' do expect(configuration.feature_removal_enabled).to be(true) end context 'with feature_removal_enabled set to true' do before do @original_feature_removal_enabled = configuration.feature_removal_enabled configuration.feature_removal_enabled = true end it 'has the add_feature button' do get '/features/test' expect(last_response.body).to include('Delete') end after do configuration.feature_removal_enabled = @original_feature_removal_enabled end end context 'with feature_removal_enabled set to false' do before do @original_feature_removal_enabled = configuration.feature_removal_enabled configuration.feature_removal_enabled = false end it 'does not have the add_feature button' do get '/features/test' expect(last_response.body).not_to include('Delete') end after do configuration.feature_removal_enabled = @original_feature_removal_enabled end end end end end flipper-0.21.0/spec/flipper_integration_spec.rb000066400000000000000000000352421404600161700215750ustar00rootroot00000000000000require 'helper' require 'flipper/feature' RSpec.describe Flipper do let(:adapter) { Flipper::Adapters::Memory.new } let(:flipper) { described_class.new(adapter) } let(:feature) { flipper[:search] } let(:admin_group) { flipper.group(:admins) } let(:dev_group) { flipper.group(:devs) } let(:admin_thing) do double 'Non Flipper Thing', flipper_id: 1, admin?: true, dev?: false end let(:dev_thing) do double 'Non Flipper Thing', flipper_id: 10, admin?: false, dev?: true end let(:admin_truthy_thing) do double 'Non Flipper Thing', flipper_id: 1, admin?: 'true-ish', dev?: false end let(:admin_falsey_thing) do double 'Non Flipper Thing', flipper_id: 1, admin?: nil, dev?: false end let(:pitt) { Flipper::Actor.new(1) } let(:clooney) { Flipper::Actor.new(10) } let(:five_percent_of_actors) { flipper.actors(5) } let(:five_percent_of_time) { flipper.time(5) } before do described_class.register(:admins, &:admin?) described_class.register(:devs, &:dev?) end describe '#enable' do context 'with no arguments' do before do @result = feature.enable end it 'returns true' do expect(@result).to eq(true) end it 'enables feature for all' do expect(feature.enabled?).to eq(true) end it 'adds feature to set of features' do expect(flipper.features.map(&:name)).to include(:search) end end context 'with a group' do before do @result = feature.enable(admin_group) end it 'returns true' do expect(@result).to eq(true) end it 'enables feature for non flipper thing in group' do expect(feature.enabled?(admin_thing)).to eq(true) end it 'does not enable feature for non flipper thing in other group' do expect(feature.enabled?(dev_thing)).to eq(false) end it 'enables feature for flipper actor in group' do expect(feature.enabled?(flipper.actor(admin_thing))).to eq(true) end it 'does not enable for flipper actor not in group' do expect(feature.enabled?(flipper.actor(dev_thing))).to eq(false) end it 'does not enable feature for all' do expect(feature.enabled?).to eq(false) end it 'adds feature to set of features' do expect(flipper.features.map(&:name)).to include(:search) end end context 'with an actor' do before do @result = feature.enable(pitt) end it 'returns true' do expect(@result).to eq(true) end it 'enables feature for actor' do expect(feature.enabled?(pitt)).to eq(true) end it 'does not enable feature for other actors' do expect(feature.enabled?(clooney)).to eq(false) end it 'adds feature to set of features' do expect(flipper.features.map(&:name)).to include(:search) end end context 'with a percentage of actors' do before do @result = feature.enable(five_percent_of_actors) end it 'returns true' do expect(@result).to eq(true) end it 'enables feature for actor within percentage' do enabled = (1..100).select do |i| thing = Flipper::Actor.new(i) feature.enabled?(thing) end.size expect(enabled).to be_within(2).of(5) end it 'adds feature to set of features' do expect(flipper.features.map(&:name)).to include(:search) end end context 'with a float percentage of actors' do before do @result = feature.enable_percentage_of_actors 5.1 end it 'returns true' do expect(@result).to eq(true) end it 'enables feature for actor within percentage' do enabled = (1..100).select do |i| thing = Flipper::Actor.new(i) feature.enabled?(thing) end.size expect(enabled).to be_within(2).of(5) end it 'adds feature to set of features' do expect(flipper.features.map(&:name)).to include(:search) end end context 'with a percentage of time' do before do @gate = feature.gate(:percentage_of_time) @result = feature.enable(five_percent_of_time) end it 'returns true' do expect(@result).to eq(true) end it 'enables feature for time within percentage' do allow(@gate).to receive_messages(rand: 0.04) expect(feature.enabled?).to eq(true) end it 'does not enable feature for time not within percentage' do allow(@gate).to receive_messages(rand: 0.10) expect(feature.enabled?).to eq(false) end it 'adds feature to set of features' do expect(flipper.features.map(&:name)).to include(:search) end end context 'with argument that has no gate' do it 'raises error' do thing = Object.new expect do feature.enable(thing) end.to raise_error(Flipper::GateNotFound, "Could not find gate for #{thing.inspect}") end end end describe '#disable' do context 'with no arguments' do before do # ensures that time gate is stubbed with result that would be true for pitt @gate = feature.gate(:percentage_of_time) allow(@gate).to receive_messages(rand: 0.04) feature.enable admin_group feature.enable pitt feature.enable five_percent_of_actors feature.enable five_percent_of_time @result = feature.disable end it 'returns true' do expect(@result).to be(true) end it 'disables feature' do expect(feature.enabled?).to eq(false) end it 'disables for individual actor' do expect(feature.enabled?(pitt)).to eq(false) end it 'disables actor in group' do expect(feature.enabled?(admin_thing)).to eq(false) end it 'disables actor in percentage of actors' do enabled = (1..100).select do |i| thing = Flipper::Actor.new(i) feature.enabled?(thing) end.size expect(enabled).to be(0) end it 'disables percentage of time' do expect(feature.enabled?(pitt)).to eq(false) end it 'adds feature to set of features' do expect(flipper.features.map(&:name)).to include(:search) end end context 'with a group' do before do feature.enable dev_group feature.enable admin_group @result = feature.disable(admin_group) end it 'returns true' do expect(@result).to eq(true) end it 'disables the feature for non flipper thing in the group' do expect(feature.enabled?(admin_thing)).to eq(false) end it 'does not disable feature for non flipper thing in other groups' do expect(feature.enabled?(dev_thing)).to eq(true) end it 'disables feature for flipper actor in group' do expect(feature.enabled?(flipper.actor(admin_thing))).to eq(false) end it 'does not disable feature for flipper actor in other groups' do expect(feature.enabled?(flipper.actor(dev_thing))).to eq(true) end it 'adds feature to set of features' do expect(flipper.features.map(&:name)).to include(:search) end end context 'with an actor' do before do feature.enable pitt feature.enable clooney @result = feature.disable(pitt) end it 'returns true' do expect(@result).to eq(true) end it 'disables feature for actor' do expect(feature.enabled?(pitt)).to eq(false) end it 'does not disable feature for other actors' do expect(feature.enabled?(clooney)).to eq(true) end it 'adds feature to set of features' do expect(flipper.features.map(&:name)).to include(:search) end end context 'with a percentage of actors' do before do @result = feature.disable(flipper.actors(0)) end it 'returns true' do expect(@result).to eq(true) end it 'disables feature' do enabled = (1..100).select do |i| thing = Flipper::Actor.new(i) feature.enabled?(thing) end.size expect(enabled).to be(0) end it 'adds feature to set of features' do expect(flipper.features.map(&:name)).to include(:search) end end context 'with a percentage of time' do before do @gate = feature.gate(:percentage_of_time) @result = feature.disable(flipper.time(0)) end it 'returns true' do expect(@result).to eq(true) end it 'disables feature for time within percentage' do allow(@gate).to receive_messages(rand: 0.04) expect(feature.enabled?).to eq(false) end it 'disables feature for time not within percentage' do allow(@gate).to receive_messages(rand: 0.10) expect(feature.enabled?).to eq(false) end it 'adds feature to set of features' do expect(flipper.features.map(&:name)).to include(:search) end end context 'with argument that has no gate' do it 'raises error' do thing = Object.new expect do feature.disable(thing) end.to raise_error(Flipper::GateNotFound, "Could not find gate for #{thing.inspect}") end end end describe '#enabled?' do context 'with no arguments' do it 'defaults to false' do expect(feature.enabled?).to eq(false) end end context 'with no arguments, but boolean enabled' do before do feature.enable end it 'returns true' do expect(feature.enabled?).to eq(true) end end context 'for actor in enabled group' do before do feature.enable admin_group end it 'returns true' do expect(feature.enabled?(flipper.actor(admin_thing))).to eq(true) expect(feature.enabled?(admin_thing)).to eq(true) end it 'returns true for truthy block values' do expect(feature.enabled?(flipper.actor(admin_truthy_thing))).to eq(true) end end context 'for actor in disabled group' do it 'returns false' do expect(feature.enabled?(flipper.actor(dev_thing))).to eq(false) expect(feature.enabled?(dev_thing)).to eq(false) end it 'returns false for falsey block values' do expect(feature.enabled?(flipper.actor(admin_falsey_thing))).to eq(false) end end context 'for enabled actor' do before do feature.enable pitt end it 'returns true' do expect(feature.enabled?(pitt)).to eq(true) end end context 'for not enabled actor' do it 'returns false' do expect(feature.enabled?(clooney)).to eq(false) end it 'returns true if boolean enabled' do feature.enable expect(feature.enabled?(clooney)).to eq(true) end end context 'for enabled percentage of time' do before do # ensure percentage of time returns percentage that makes five percent # of time true @gate = feature.gate(:percentage_of_time) allow(@gate).to receive_messages(rand: 0.04) feature.enable five_percent_of_time end it 'returns true' do expect(feature.enabled?).to eq(true) expect(feature.enabled?(nil)).to eq(true) expect(feature.enabled?(pitt)).to eq(true) expect(feature.enabled?(admin_thing)).to eq(true) end end context 'for enabled float percentage of time' do before do # ensure percentage of time returns percentage that makes 4.1 percent # of time true @gate = feature.gate(:percentage_of_time) allow(@gate).to receive_messages(rand: 0.04) feature.enable_percentage_of_time 4.1 end it 'returns true' do expect(feature.enabled?).to eq(true) expect(feature.enabled?(nil)).to eq(true) expect(feature.enabled?(pitt)).to eq(true) expect(feature.enabled?(admin_thing)).to eq(true) end end context 'for NOT enabled integer percentage of time' do before do # ensure percentage of time returns percentage that makes enabled? false @gate = feature.gate(:percentage_of_time) allow(@gate).to receive_messages(rand: 0.10) feature.enable five_percent_of_time end it 'returns false' do expect(feature.enabled?).to eq(false) expect(feature.enabled?(nil)).to eq(false) expect(feature.enabled?(pitt)).to eq(false) expect(feature.enabled?(admin_thing)).to eq(false) end it 'returns true if boolean enabled' do feature.enable expect(feature.enabled?).to eq(true) expect(feature.enabled?(nil)).to eq(true) expect(feature.enabled?(pitt)).to eq(true) expect(feature.enabled?(admin_thing)).to eq(true) end end context 'for NOT enabled float percentage of time' do before do # ensure percentage of time returns percentage that makes enabled? false @gate = feature.gate(:percentage_of_time) allow(@gate).to receive_messages(rand: 0.10) feature.enable_percentage_of_time 9.9 end it 'returns false' do expect(feature.enabled?).to eq(false) expect(feature.enabled?(nil)).to eq(false) expect(feature.enabled?(pitt)).to eq(false) expect(feature.enabled?(admin_thing)).to eq(false) end it 'returns true if boolean enabled' do feature.enable expect(feature.enabled?).to eq(true) expect(feature.enabled?(nil)).to eq(true) expect(feature.enabled?(pitt)).to eq(true) expect(feature.enabled?(admin_thing)).to eq(true) end end context 'for a non flipper thing' do before do feature.enable admin_group end it 'returns true if in enabled group' do expect(feature.enabled?(admin_thing)).to eq(true) end it 'returns false if not in enabled group' do expect(feature.enabled?(dev_thing)).to eq(false) end it 'returns true if boolean enabled' do feature.enable expect(feature.enabled?(admin_thing)).to eq(true) expect(feature.enabled?(dev_thing)).to eq(true) end end end context 'enabling multiple groups, disabling everything, then enabling one group' do before do feature.enable(admin_group) feature.enable(dev_group) feature.disable feature.enable(admin_group) end it 'enables feature for object in enabled group' do expect(feature.enabled?(admin_thing)).to eq(true) end it 'does not enable feature for object in not enabled group' do expect(feature.enabled?(dev_thing)).to eq(false) end end end flipper-0.21.0/spec/flipper_spec.rb000066400000000000000000000270501404600161700171700ustar00rootroot00000000000000require 'helper' require 'flipper/cloud' RSpec.describe Flipper do describe '.new' do it 'returns new instance of dsl' do instance = described_class.new(double('Adapter')) expect(instance).to be_instance_of(Flipper::DSL) end end describe '.configure' do it 'yield configuration instance' do described_class.configure do |config| expect(config).to be_instance_of(Flipper::Configuration) end end end describe '.configuration' do it 'returns configuration instance' do expect(described_class.configuration).to be_instance_of(Flipper::Configuration) end end describe '.configuration=' do it "sets configuration instance" do configuration = Flipper::Configuration.new described_class.configuration = configuration expect(described_class.configuration).to be(configuration) end end describe '.instance' do it 'returns DSL instance using result of default invocation' do instance = described_class.new(Flipper::Adapters::Memory.new) described_class.configure do |config| config.default { instance } end expect(described_class.instance).to be(instance) expect(described_class.instance).to be(described_class.instance) # memoized end it 'is reset when configuration is changed' do described_class.configure do |config| config.default { described_class.new(Flipper::Adapters::Memory.new) } end original_instance = described_class.instance new_config = Flipper::Configuration.new new_config.default { described_class.new(Flipper::Adapters::Memory.new) } described_class.configuration = new_config expect(described_class.instance).not_to be(original_instance) end end describe '.instance=' do it 'updates Flipper.instance' do instance = described_class.new(Flipper::Adapters::Memory.new) described_class.instance = instance expect(described_class.instance).to be(instance) end end describe "delegation to instance" do let(:group) { Flipper::Types::Group.new(:admins) } let(:actor) { Flipper::Actor.new("1") } before do described_class.configure do |config| config.default { described_class.new(Flipper::Adapters::Memory.new) } end end it 'delegates enabled? to instance' do expect(described_class.enabled?(:search)).to eq(described_class.instance.enabled?(:search)) described_class.instance.enable(:search) expect(described_class.enabled?(:search)).to eq(described_class.instance.enabled?(:search)) end it 'delegates enable to instance' do described_class.enable(:search) expect(described_class.instance.enabled?(:search)).to be(true) end it 'delegates disable to instance' do described_class.disable(:search) expect(described_class.instance.enabled?(:search)).to be(false) end it 'delegates bool to instance' do expect(described_class.bool).to eq(described_class.instance.bool) end it 'delegates boolean to instance' do expect(described_class.boolean).to eq(described_class.instance.boolean) end it 'delegates enable_actor to instance' do described_class.enable_actor(:search, actor) expect(described_class.instance.enabled?(:search, actor)).to be(true) end it 'delegates disable_actor to instance' do described_class.disable_actor(:search, actor) expect(described_class.instance.enabled?(:search, actor)).to be(false) end it 'delegates actor to instance' do expect(described_class.actor(actor)).to eq(described_class.instance.actor(actor)) end it 'delegates enable_group to instance' do described_class.enable_group(:search, group) expect(described_class.instance[:search].enabled_groups).to include(group) end it 'delegates disable_group to instance' do described_class.disable_group(:search, group) expect(described_class.instance[:search].enabled_groups).not_to include(group) end it 'delegates enable_percentage_of_actors to instance' do described_class.enable_percentage_of_actors(:search, 5) expect(described_class.instance[:search].percentage_of_actors_value).to be(5) end it 'delegates disable_percentage_of_actors to instance' do described_class.disable_percentage_of_actors(:search) expect(described_class.instance[:search].percentage_of_actors_value).to be(0) end it 'delegates actors to instance' do expect(described_class.actors(5)).to eq(described_class.instance.actors(5)) end it 'delegates percentage_of_actors to instance' do expected = described_class.instance.percentage_of_actors(5) expect(described_class.percentage_of_actors(5)).to eq(expected) end it 'delegates enable_percentage_of_time to instance' do described_class.enable_percentage_of_time(:search, 5) expect(described_class.instance[:search].percentage_of_time_value).to be(5) end it 'delegates disable_percentage_of_time to instance' do described_class.disable_percentage_of_time(:search) expect(described_class.instance[:search].percentage_of_time_value).to be(0) end it 'delegates time to instance' do expect(described_class.time(56)).to eq(described_class.instance.time(56)) end it 'delegates percentage_of_time to instance' do expected = described_class.instance.percentage_of_time(56) expect(described_class.percentage_of_time(56)).to eq(expected) end it 'delegates features to instance' do described_class.instance.add(:search) expect(described_class.features).to eq(described_class.instance.features) expect(described_class.features).to include(described_class.instance[:search]) end it 'delegates feature to instance' do expect(described_class.feature(:search)).to eq(described_class.instance.feature(:search)) end it 'delegates [] to instance' do expect(described_class[:search]).to eq(described_class.instance[:search]) end it 'delegates preload to instance' do described_class.instance.enable(:search) expect(described_class.preload([:search])).to eq(described_class.instance.preload([:search])) end it 'delegates preload_all to instance' do described_class.instance.enable(:search) described_class.instance.enable(:stats) expect(described_class.preload_all).to eq(described_class.instance.preload_all) end it 'delegates add to instance' do expect(described_class.add(:search)).to eq(described_class.instance.add(:search)) end it 'delegates exist? to instance' do expect(described_class.exist?(:search)).to eq(described_class.instance.exist?(:search)) end it 'delegates remove to instance' do expect(described_class.remove(:search)).to eq(described_class.instance.remove(:search)) end it 'delegates import to instance' do other = described_class.new(Flipper::Adapters::Memory.new) other.enable(:search) described_class.import(other) expect(described_class.enabled?(:search)).to be(true) end it 'delegates adapter to instance' do expect(described_class.adapter).to eq(described_class.instance.adapter) end it 'delegates memoize= to instance' do expect(described_class.adapter.memoizing?).to be(false) described_class.memoize = true expect(described_class.adapter.memoizing?).to be(true) end it 'delegates memoizing? to instance' do expect(described_class.memoizing?).to eq(described_class.adapter.memoizing?) end it 'delegates sync stuff to instance and does nothing' do expect(described_class.sync).to be(nil) expect(described_class.sync_secret).to be(nil) end it 'delegates sync stuff to instance for Flipper::Cloud' do stub = stub_request(:get, "https://www.flippercloud.io/adapter/features"). with({ headers: { 'Flipper-Cloud-Token'=>'asdf', }, }).to_return(status: 200, body: '{"features": {}}', headers: {}) cloud_configuration = Flipper::Cloud::Configuration.new({ token: "asdf", sync_secret: "tasty", }) described_class.configure do |config| config.default { Flipper::Cloud::DSL.new(cloud_configuration) } end described_class.sync expect(described_class.sync_secret).to eq("tasty") expect(stub).to have_been_requested end end describe '.register' do it 'adds a group to the group_registry' do registry = Flipper::Registry.new described_class.groups_registry = registry group = described_class.register(:admins, &:admin?) expect(registry.get(:admins)).to eq(group) end it 'adds a group to the group_registry for string name' do registry = Flipper::Registry.new described_class.groups_registry = registry group = described_class.register('admins', &:admin?) expect(registry.get(:admins)).to eq(group) end it 'raises exception if group already registered' do described_class.register(:admins) {} expect do described_class.register(:admins) {} end.to raise_error(Flipper::DuplicateGroup, 'Group :admins has already been registered') end end describe '.groups' do it 'returns array of group instances' do admins = described_class.register(:admins, &:admin?) preview_features = described_class.register(:preview_features, &:preview_features?) expect(described_class.groups).to eq(Set[ admins, preview_features, ]) end end describe '.group_names' do it 'returns array of group names' do described_class.register(:admins, &:admin?) described_class.register(:preview_features, &:preview_features?) expect(described_class.group_names).to eq(Set[ :admins, :preview_features, ]) end end describe '.unregister_groups' do it 'clear group registry' do expect(described_class.groups_registry).to receive(:clear) described_class.unregister_groups end end describe '.group_exists' do it 'returns true if the group is already created' do group = described_class.register('admins', &:admin?) expect(described_class.group_exists?(:admins)).to eq(true) end it 'returns false when the group is not yet registered' do expect(described_class.group_exists?(:non_existing)).to eq(false) end end describe '.group' do context 'for registered group' do before do @group = described_class.register(:admins) {} end it 'returns group' do expect(described_class.group(:admins)).to eq(@group) end it 'returns group with string key' do expect(described_class.group('admins')).to eq(@group) end end context 'for unregistered group' do before do @group = described_class.group(:cats) end it 'returns group' do expect(@group).to be_instance_of(Flipper::Types::Group) expect(@group.name).to eq(:cats) end it 'does not add group to registry' do expect(described_class.group_exists?(@group.name)).to be(false) end end end describe '.groups_registry' do it 'returns a registry instance' do expect(described_class.groups_registry).to be_instance_of(Flipper::Registry) end end describe '.groups_registry=' do it 'sets groups_registry registry' do registry = Flipper::Registry.new described_class.groups_registry = registry expect(described_class.instance_variable_get('@groups_registry')).to eq(registry) end end end flipper-0.21.0/spec/helper.rb000066400000000000000000000051771404600161700160020ustar00rootroot00000000000000$LOAD_PATH.unshift(File.expand_path('../../lib', __FILE__)) require 'pp' require 'pathname' FlipperRoot = Pathname(__FILE__).dirname.join('..').expand_path require 'rubygems' require 'bundler' Bundler.setup(:default) require 'pry' require 'webmock/rspec' WebMock.disable_net_connect!(allow_localhost: true) require 'flipper' require 'flipper/ui' require 'flipper/api' Dir[FlipperRoot.join('spec/support/**/*.rb')].sort.each { |f| require f } RSpec.configure do |config| config.before(:example) do Flipper.unregister_groups Flipper.configuration = nil end config.disable_monkey_patching! config.filter_run focus: true config.run_all_when_everything_filtered = true end RSpec.shared_examples_for 'a percentage' do it 'initializes with value' do percentage = described_class.new(12) expect(percentage).to be_instance_of(described_class) end it 'converts string values to integers when initializing' do percentage = described_class.new('15') expect(percentage.value).to eq(15) end it 'has a value' do percentage = described_class.new(19) expect(percentage.value).to eq(19) end it 'raises exception for value higher than 100' do expect do described_class.new(101) end.to raise_error(ArgumentError, 'value must be a positive number less than or equal to 100, but was 101') end it 'raises exception for negative value' do expect do described_class.new(-1) end.to raise_error(ArgumentError, 'value must be a positive number less than or equal to 100, but was -1') end end RSpec.shared_examples_for 'a DSL feature' do it 'returns instance of feature' do expect(feature).to be_instance_of(Flipper::Feature) end it 'sets name' do expect(feature.name).to eq(:stats) end it 'sets adapter' do expect(feature.adapter.name).to eq(dsl.adapter.name) end it 'sets instrumenter' do expect(feature.instrumenter).to eq(dsl.instrumenter) end it 'memoizes the feature' do expect(dsl.send(method_name, :stats)).to equal(feature) end it 'raises argument error if not string or symbol' do expect do dsl.send(method_name, Object.new) end.to raise_error(ArgumentError, /must be a String or Symbol/) end end RSpec.shared_examples_for 'a DSL boolean method' do it 'returns boolean with value set' do result = subject.send(method_name, true) expect(result).to be_instance_of(Flipper::Types::Boolean) expect(result.value).to be(true) result = subject.send(method_name, false) expect(result).to be_instance_of(Flipper::Types::Boolean) expect(result.value).to be(false) end end flipper-0.21.0/spec/support/000077500000000000000000000000001404600161700157005ustar00rootroot00000000000000flipper-0.21.0/spec/support/descriptions.yml000066400000000000000000000000641404600161700211310ustar00rootroot00000000000000some_awesome_feature: 'Awesome feature description' flipper-0.21.0/spec/support/fake_udp_socket.rb000066400000000000000000000004721404600161700213560ustar00rootroot00000000000000class FakeUDPSocket attr_reader :buffer def initialize @buffer = [] end def send(message, *_rest) @buffer.push [message] end def recv @buffer.shift end def clear @buffer = [] end def to_s inspect end def inspect "" end end flipper-0.21.0/spec/support/spec_helpers.rb000066400000000000000000000036361404600161700207110ustar00rootroot00000000000000require 'climate_control' require 'json' require 'rack/test' module SpecHelpers def self.included(base) base.let(:flipper) { build_flipper } base.let(:app) { build_app(flipper) } end def build_app(flipper, options = {}) Flipper::UI.app(flipper, options) do |builder| builder.use Rack::Session::Cookie, secret: 'test' end end def build_api(flipper, options = {}) Flipper::Api.app(flipper, options) end def build_flipper(adapter = build_memory_adapter) Flipper.new(adapter) end def build_memory_adapter Flipper::Adapters::Memory.new end def json_response JSON.parse(last_response.body) end def api_error_code_reference_url 'https://github.com/jnunemaker/flipper/tree/master/docs/api#error-code-reference' end def api_not_found_response { 'code' => 1, 'message' => 'Feature not found.', 'more_info' => api_error_code_reference_url, } end def api_flipper_id_is_missing_response { 'code' => 4, 'message' => 'Required parameter flipper_id is missing.', 'more_info' => api_error_code_reference_url, } end def api_positive_percentage_error_response { 'code' => 3, 'message' => 'Percentage must be a positive number less than or equal to 100.', 'more_info' => api_error_code_reference_url, } end def with_modified_env(options, &block) ClimateControl.modify(options, &block) end def silence # Store the original stderr and stdout in order to restore them later original_stderr = $stderr original_stdout = $stdout # Redirect stderr and stdout output = $stderr = $stdout = StringIO.new yield $stderr = original_stderr $stdout = original_stdout # Return output output.string end end RSpec.configure do |config| config.order = :random Kernel.srand config.seed config.include Rack::Test::Methods config.include SpecHelpers end flipper-0.21.0/test/000077500000000000000000000000001404600161700142115ustar00rootroot00000000000000flipper-0.21.0/test/adapters/000077500000000000000000000000001404600161700160145ustar00rootroot00000000000000flipper-0.21.0/test/adapters/active_record_test.rb000066400000000000000000000041201404600161700222060ustar00rootroot00000000000000require 'test_helper' require 'flipper/adapters/active_record' # Turn off migration logging for specs ActiveRecord::Migration.verbose = false class ActiveRecordTest < MiniTest::Test prepend Flipper::Test::SharedAdapterTests ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') def setup @adapter = Flipper::Adapters::ActiveRecord.new ActiveRecord::Base.connection.execute <<-SQL CREATE TABLE flipper_features ( id integer PRIMARY KEY, key text NOT NULL UNIQUE, created_at datetime NOT NULL, updated_at datetime NOT NULL ) SQL ActiveRecord::Base.connection.execute <<-SQL CREATE TABLE flipper_gates ( id integer PRIMARY KEY, feature_key text NOT NULL, key text NOT NULL, value text DEFAULT NULL, created_at datetime NOT NULL, updated_at datetime NOT NULL ) SQL ActiveRecord::Base.connection.execute <<-SQL CREATE UNIQUE INDEX index_gates_on_keys_and_value on flipper_gates (feature_key, key, value) SQL end def teardown ActiveRecord::Base.connection.execute("DROP table IF EXISTS `flipper_features`") ActiveRecord::Base.connection.execute("DROP table IF EXISTS `flipper_gates`") end def test_models_honor_table_name_prefixes_and_suffixes ActiveRecord::Base.table_name_prefix = :foo_ ActiveRecord::Base.table_name_suffix = :_bar Flipper::Adapters::ActiveRecord.send(:remove_const, :Feature) Flipper::Adapters::ActiveRecord.send(:remove_const, :Gate) load("flipper/adapters/active_record.rb") assert_equal "foo_flipper_features_bar", Flipper::Adapters::ActiveRecord::Feature.table_name assert_equal "foo_flipper_gates_bar", Flipper::Adapters::ActiveRecord::Gate.table_name ensure ActiveRecord::Base.table_name_prefix = "" ActiveRecord::Base.table_name_suffix = "" Flipper::Adapters::ActiveRecord.send(:remove_const, :Feature) Flipper::Adapters::ActiveRecord.send(:remove_const, :Gate) load("flipper/adapters/active_record.rb") end end flipper-0.21.0/test/adapters/dalli_test.rb000066400000000000000000000010701404600161700204630ustar00rootroot00000000000000require 'test_helper' require 'flipper/adapters/dalli' require 'logger' class DalliTest < MiniTest::Test prepend Flipper::Test::SharedAdapterTests def setup url = ENV.fetch('MEMCACHED_URL', 'localhost:11211') @cache = Dalli::Client.new(url) Dalli.logger = Logger.new('/dev/null') @cache.flush memory_adapter = Flipper::Adapters::Memory.new @adapter = Flipper::Adapters::Dalli.new(memory_adapter, @cache) rescue Dalli::NetworkError ENV['CI'] ? raise : skip('Memcached not available') end def teardown @cache.flush end end flipper-0.21.0/test/adapters/memory_test.rb000066400000000000000000000002511404600161700207060ustar00rootroot00000000000000require 'test_helper' class MemoryTest < MiniTest::Test prepend Flipper::Test::SharedAdapterTests def setup @adapter = Flipper::Adapters::Memory.new end end flipper-0.21.0/test/adapters/mongo_test.rb000066400000000000000000000014021404600161700205140ustar00rootroot00000000000000require 'test_helper' require 'flipper/adapters/mongo' class MongoTest < MiniTest::Test prepend Flipper::Test::SharedAdapterTests def setup host = ENV.fetch('MONGODB_HOST', '127.0.0.1') port = '27017' logger = Logger.new('/dev/null') client = Mongo::Client.new(["#{host}:#{port}"], server_selection_timeout: 0.01, database: 'testing', logger: logger) collection = client['testing'] begin collection.drop collection.create rescue Mongo::Error::NoServerAvailable ENV['CI'] ? raise : skip('Mongo not available') rescue Mongo::Error::OperationFailure end @adapter = Flipper::Adapters::Mongo.new(collection) end end flipper-0.21.0/test/adapters/pstore_test.rb000066400000000000000000000007321404600161700207160ustar00rootroot00000000000000require 'test_helper' require 'flipper/adapters/pstore' class PstoreTest < MiniTest::Test prepend Flipper::Test::SharedAdapterTests def setup dir = FlipperRoot.join('tmp').tap(&:mkpath) pstore_file = dir.join('flipper.pstore') pstore_file.unlink if pstore_file.exist? @adapter = Flipper::Adapters::PStore.new(pstore_file) end def test_defaults_path_to_flipper_pstore assert_equal Flipper::Adapters::PStore.new.path, 'flipper.pstore' end end flipper-0.21.0/test/adapters/redis_cache_test.rb000066400000000000000000000010161404600161700216270ustar00rootroot00000000000000require 'test_helper' require 'flipper/adapters/redis_cache' class RedisCacheTest < MiniTest::Test prepend Flipper::Test::SharedAdapterTests def setup url = ENV.fetch('REDIS_URL', 'redis://localhost:6379') @cache = Redis.new(url: url).tap(&:flushdb) memory_adapter = Flipper::Adapters::Memory.new @adapter = Flipper::Adapters::RedisCache.new(memory_adapter, @cache) rescue Redis::CannotConnectError ENV['CI'] ? raise : skip('Reids not available') end def teardown @cache.flushdb end end flipper-0.21.0/test/adapters/redis_test.rb000066400000000000000000000006221404600161700205060ustar00rootroot00000000000000require 'test_helper' require 'flipper/adapters/redis' class RedisTest < MiniTest::Test prepend Flipper::Test::SharedAdapterTests def setup url = ENV.fetch('REDIS_URL', 'redis://localhost:6379') client = Redis.new(url: url).tap(&:flushdb) @adapter = Flipper::Adapters::Redis.new(client) rescue Redis::CannotConnectError ENV['CI'] ? raise : skip('Redis not available') end end flipper-0.21.0/test/adapters/sequel_test.rb000066400000000000000000000013231404600161700206750ustar00rootroot00000000000000require 'test_helper' require 'sequel' Sequel::Model.db = Sequel.sqlite(':memory:') Sequel.extension :migration, :core_extensions require 'flipper/adapters/sequel' require 'generators/flipper/templates/sequel_migration' class SequelTest < MiniTest::Test prepend Flipper::Test::SharedAdapterTests def feature_class Flipper::Adapters::Sequel::Feature end def gate_class Flipper::Adapters::Sequel::Gate end def setup CreateFlipperTablesSequel.new(Sequel::Model.db).up feature_class.dataset = feature_class.dataset gate_class.dataset = gate_class.dataset @adapter = Flipper::Adapters::Sequel.new end def teardown CreateFlipperTablesSequel.new(Sequel::Model.db).down end end flipper-0.21.0/test/test_helper.rb000066400000000000000000000003321404600161700170520ustar00rootroot00000000000000require 'bundler/setup' require 'flipper' require 'minitest/autorun' require 'minitest/unit' Dir['./lib/flipper/test/*.rb'].sort.each { |f| require(f) } FlipperRoot = Pathname(__FILE__).dirname.join('..').expand_path flipper-0.21.0/test_rails/000077500000000000000000000000001404600161700154035ustar00rootroot00000000000000flipper-0.21.0/test_rails/generators/000077500000000000000000000000001404600161700175545ustar00rootroot00000000000000flipper-0.21.0/test_rails/generators/flipper/000077500000000000000000000000001404600161700212155ustar00rootroot00000000000000flipper-0.21.0/test_rails/generators/flipper/active_record_generator_test.rb000066400000000000000000000026241404600161700274640ustar00rootroot00000000000000require 'helper' require 'active_record' require 'rails/generators/test_case' require 'generators/flipper/active_record_generator' class FlipperActiveRecordGeneratorTest < Rails::Generators::TestCase tests Flipper::Generators::ActiveRecordGenerator destination File.expand_path('../../../../tmp', __FILE__) setup :prepare_destination def test_generates_migration run_generator migration_version = if Rails::VERSION::MAJOR.to_i < 5 "" else "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" end assert_migration 'db/migrate/create_flipper_tables.rb', <<~MIGRATION class CreateFlipperTables < ActiveRecord::Migration#{migration_version} def self.up create_table :flipper_features do |t| t.string :key, null: false t.timestamps null: false end add_index :flipper_features, :key, unique: true create_table :flipper_gates do |t| t.string :feature_key, null: false t.string :key, null: false t.string :value t.timestamps null: false end add_index :flipper_gates, [:feature_key, :key, :value], unique: true end def self.down drop_table :flipper_gates drop_table :flipper_features end end MIGRATION end end flipper-0.21.0/test_rails/helper.rb000066400000000000000000000004071404600161700172100ustar00rootroot00000000000000require 'rubygems' require 'bundler' Bundler.setup(:default) require 'rails' require 'rails/test_help' begin ActiveSupport::TestCase.test_order = :random rescue NoMethodError # no biggie, means we are on older version of AS that doesn't have this option end