├── .docker ├── mysql │ └── init │ │ └── 01-databases.sql ├── node │ └── Dockerfile └── redmine │ ├── Dockerfile │ ├── additional_environment.rb │ ├── configuration.yml │ ├── database.yml │ └── seeds.rb ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── validation.yml ├── .gitignore ├── .rubocop.yml ├── LICENSE ├── PluginGemfile ├── README.md ├── Rakefile ├── app ├── controllers │ ├── t2r_base_controller.rb │ ├── t2r_import_controller.rb │ ├── t2r_redmine_controller.rb │ └── t2r_toggl_controller.rb ├── models │ ├── toggl_mapping.rb │ ├── toggl_service.rb │ ├── toggl_time_entry.rb │ ├── toggl_time_entry_group.rb │ └── toggl_workspace.rb └── views │ └── t2r_import │ └── index.html.erb ├── assets.src ├── .eslintignore ├── .eslintrc ├── .mocharc.yaml ├── javascripts │ ├── t2r.ts │ ├── t2r │ │ ├── datetime.ts │ │ ├── flash.ts │ │ ├── i18n.ts │ │ ├── models.ts │ │ ├── renderers.ts │ │ ├── request.ts │ │ ├── services.ts │ │ ├── storage.ts │ │ ├── utils.ts │ │ └── widgets.ts │ └── test │ │ ├── register.js │ │ ├── t2r │ │ ├── datetime │ │ │ ├── datetime.test.ts │ │ │ └── duration.test.ts │ │ └── storage │ │ │ ├── localstorage.test.ts │ │ │ └── temporarystorage.test.ts │ │ └── tsconfig.json ├── package.json └── tsconfig.json ├── assets ├── images │ └── loader.gif ├── javascripts │ ├── t2r.js │ └── t2r │ │ ├── datetime.js │ │ ├── flash.js │ │ ├── i18n.js │ │ ├── models.js │ │ ├── renderers.js │ │ ├── request.js │ │ ├── services.js │ │ ├── storage.js │ │ ├── utils.js │ │ └── widgets.js └── stylesheets │ └── t2r.css ├── config ├── locales │ ├── en.yml │ ├── es.yml │ ├── fr.yml │ └── ja.yml └── routes.rb ├── db └── migrate │ ├── 001_create_toggl_api_key_field.rb │ ├── 002_create_toggl_time_entries_table.rb │ ├── 003_rename_toggl_time_entries_table.rb │ ├── 004_rename_toggl_api_key_field.rb │ └── 005_change_toggl_mappings_toggl_id_to_bigint.rb ├── docker-compose.yml ├── init.rb ├── lib ├── patches │ └── time_entry.rb └── toggl_2_redmine.rb └── test ├── fixtures ├── custom_fields.yml ├── email_addresses.yml ├── enabled_modules.yml ├── enumerations.yml ├── issue_statuses.yml ├── issues.yml ├── members.yml ├── projects.yml ├── roles.yml ├── settings.yml ├── time_entries.yml ├── toggl │ ├── time_entries.json │ └── workspaces.json ├── toggl_mappings.yml ├── trackers.yml └── users.yml ├── integration ├── models │ └── toggl_mapping_test.rb ├── t2r_base_controller_test.rb ├── t2r_import_controller_test.rb ├── t2r_redmine_controller_test.rb └── t2r_toggl_controller_test.rb ├── test_helper.rb └── unit ├── models ├── toggl_mapping_test.rb ├── toggl_service_test.rb ├── toggl_time_entry_group_test.rb ├── toggl_time_entry_test.rb └── toggl_workspace_test.rb └── toggl_2_redmine_test.rb /.docker/mysql/init/01-databases.sql: -------------------------------------------------------------------------------- 1 | -- Create databases. 2 | CREATE DATABASE IF NOT EXISTS `redmine_development`; 3 | CREATE DATABASE IF NOT EXISTS `redmine_test`; 4 | 5 | -- Grant permissions. 6 | GRANT ALL PRIVILEGES ON redmine_development.* TO 'redmine'; 7 | GRANT ALL PRIVILEGES ON redmine_test.* TO 'redmine'; 8 | -------------------------------------------------------------------------------- /.docker/node/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine 2 | WORKDIR /app 3 | 4 | RUN apk add bash 5 | 6 | CMD ["tail", "-f", "/dev/null"] 7 | -------------------------------------------------------------------------------- /.docker/redmine/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM redmine:4.2 2 | 3 | ENV LANG=en_us 4 | 5 | RUN apt update -qq > /dev/null \ 6 | && apt install -qqy build-essential make vim less > /dev/null 7 | 8 | CMD ["rails", "server", "-b", "0.0.0.0"] 9 | -------------------------------------------------------------------------------- /.docker/redmine/additional_environment.rb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jigarius/toggl2redmine/c54a2caef1800d4e33526c1ba752358ff41ba49a/.docker/redmine/additional_environment.rb -------------------------------------------------------------------------------- /.docker/redmine/configuration.yml: -------------------------------------------------------------------------------- 1 | # = Redmine configuration file 2 | # 3 | # Each environment has it's own configuration options. If you are only 4 | # running in production, only the production block needs to be configured. 5 | # Environment specific configuration options override the default ones. 6 | # 7 | # Note that this file needs to be a valid YAML file. 8 | # DO NOT USE TABS! Use 2 spaces instead of tabs for identation. 9 | 10 | # default configuration options for all environments 11 | default: 12 | # Absolute path to the directory where attachments are stored. 13 | # The default is the 'files' directory in your Redmine instance. 14 | # Your Redmine instance needs to have write permission on this 15 | # directory. 16 | # Examples: 17 | attachments_storage_path: /usr/src/redmine/files/ 18 | 19 | # Configuration of the autologin cookie. 20 | autologin_cookie_name: autologin 21 | autologin_cookie_path: / 22 | autologin_cookie_secure: false 23 | 24 | # Configuration of SCM executable command. 25 | scm_subversion_command: svn 26 | scm_mercurial_command: hg 27 | scm_git_command: git 28 | scm_cvs_command: cvs 29 | scm_bazaar_command: bzr 30 | scm_darcs_command: darcs 31 | 32 | # SCM paths validation. 33 | # 34 | # You can configure a regular expression for each SCM that will be used to 35 | # validate the path of new repositories (eg. path entered by users with the 36 | # "Manage repositories" permission and path returned by reposman.rb). 37 | # The regexp will be wrapped with \A \z, so it must match the whole path. 38 | # And the regexp is case sensitive. 39 | # 40 | # You can match the project identifier by using %project% in the regexp. 41 | # 42 | # You can also set a custom hint message for each SCM that will be displayed 43 | # on the repository form instead of the default one. 44 | # 45 | # Examples: 46 | # scm_subversion_path_regexp: file:///svnpath/[a-z0-9_]+ 47 | # scm_subversion_path_info: SVN URL (eg. file:///svnpath/foo) 48 | # 49 | # scm_git_path_regexp: /gitpath/%project%(\.[a-z0-9_])?/ 50 | # 51 | # scm_subversion_path_regexp: 52 | # scm_mercurial_path_regexp: 53 | # scm_git_path_regexp: 54 | # scm_cvs_path_regexp: 55 | # scm_bazaar_path_regexp: 56 | # scm_darcs_path_regexp: 57 | # scm_filesystem_path_regexp: 58 | 59 | # Absolute path to the SCM commands errors (stderr) log file. 60 | # The default is to log in the 'log' directory of your Redmine instance. 61 | scm_stderr_log_file: /var/log/redmine/redmine/redmine_scm_stderr.log 62 | 63 | # Key used to encrypt sensitive data in the database (SCM and LDAP passwords). 64 | # If you don't want to enable data encryption, just leave it blank. 65 | # WARNING: losing/changing this key will make encrypted data unreadable. 66 | # 67 | # If you want to encrypt existing passwords in your database: 68 | # * set the cipher key here in your configuration file 69 | # * encrypt data using 'rake db:encrypt RAILS_ENV=production' 70 | # 71 | # If you have encrypted data and want to change this key, you have to: 72 | # * decrypt data using 'rake db:decrypt RAILS_ENV=production' first 73 | # * change the cipher key here in your configuration file 74 | # * encrypt data using 'rake db:encrypt RAILS_ENV=production' 75 | # database_cipher_key: 76 | 77 | # Set this to false to disable plugins' assets mirroring on startup. 78 | # You can use `rake redmine:plugins:assets` to manually mirror assets 79 | # to public/plugin_assets when you install/upgrade a Redmine plugin. 80 | # 81 | mirror_plugins_assets_on_startup: false 82 | 83 | # Your secret key for verifying cookie session data integrity. If you 84 | # change this key, all old sessions will become invalid! Make sure the 85 | # secret is at least 30 characters and all random, no regular words or 86 | # you'll be exposed to dictionary attacks. 87 | # 88 | # If you have a load-balancing Redmine cluster, you have to use the 89 | # same secret token on each machine. 90 | #secret_token: 'change it to a long random string' 91 | 92 | # Requires users to re-enter their password for sensitive actions (editing 93 | # of account data, project memberships, application settings, user, group, 94 | # role, auth source management and project deletion). Disabled by default. 95 | # Timeout is set in minutes. 96 | # 97 | sudo_mode: false 98 | sudo_mode_timeout: 15 99 | 100 | # Absolute path (e.g. /usr/bin/convert, c:/im/convert.exe) to 101 | # the ImageMagick's `convert` binary. Used to generate attachment thumbnails. 102 | imagemagick_convert_command: /usr/bin/convert 103 | 104 | # Configuration of RMagick font. 105 | # 106 | # Redmine uses RMagick in order to export gantt png. 107 | # You don't need this setting if you don't install RMagick. 108 | # 109 | # In CJK (Chinese, Japanese and Korean), 110 | # in order to show CJK characters correctly, 111 | # you need to set this configuration. 112 | # 113 | # Because there is no standard font across platforms in CJK, 114 | # you need to set a font installed in your server. 115 | # 116 | # This setting is not necessary in non CJK. 117 | # rmagick_font_path: 118 | 119 | # Maximum number of simultaneous AJAX uploads 120 | max_concurrent_ajax_uploads: 1 121 | 122 | # Configure OpenIdAuthentication.store 123 | # 124 | # allowed values: :memory, :file, :memcache 125 | openid_authentication_store: :memory 126 | 127 | # Path to store backups (docker-redmine only) 128 | backup_storage_path: /usr/src/redmine/backups 129 | backup_expiry: 0 130 | 131 | # specific configuration options for production environment 132 | # that overrides the default ones 133 | production: 134 | 135 | # specific configuration options for development environment 136 | # that overrides the default ones 137 | development: 138 | email_delivery: 139 | # Send emails to Mailhog. 140 | delivery_method: :smtp 141 | smtp_settings: 142 | tls: false 143 | address: mailhog 144 | port: 1025 145 | -------------------------------------------------------------------------------- /.docker/redmine/database.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | adapter: mysql2 3 | host: mysql 4 | port: 3306 5 | username: <%= ENV.fetch('REDMINE_DB_USERNAME') %> 6 | password: <%= ENV.fetch('REDMINE_DB_PASSWORD') %> 7 | encoding: <%= ENV.fetch('REDMINE_DB_ENCODING') %> 8 | 9 | development: 10 | <<: *default 11 | database: redmine_development 12 | 13 | test: 14 | <<: *default 15 | database: redmine_test 16 | -------------------------------------------------------------------------------- /.docker/redmine/seeds.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Toggl 2 Redmine: Seed data 4 | # 5 | # To use this file, copy it to "redmine/db/seeds.rb" (or symlink) and execute 6 | # the command "rake db:seed". 7 | # 8 | # Using fixtures for seeding the database is not the best idea, however, 9 | # it helps manage all sample data using fixtures and prevents duplication. 10 | 11 | require 'active_record/fixtures' 12 | 13 | lambda { 14 | # For DEVELOPMENT use only. 15 | return unless Rails.env == 'development' 16 | 17 | fixture_directory = File.join(Toggl2Redmine.root, 'test', 'fixtures') 18 | fixture_set_names = Dir[File.join(fixture_directory, '*.yml')].map do |f| 19 | File.basename(f, '.yml') 20 | end 21 | 22 | ActiveRecord::FixtureSet.create_fixtures( 23 | fixture_directory, 24 | fixture_set_names 25 | ) 26 | }.call 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: Toggl report stuck at loading entries 5 | labels: bug 6 | assignees: jigarius 7 | 8 | --- 9 | 10 | **Bug summary** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Device information** 27 | - Type: [e.g. Desktop / Laptop / Handheld] 28 | - OS: [e.g. Windows 10 / Ubuntu 18.4 / MacOS 10.14.4] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/workflows/validation.yml: -------------------------------------------------------------------------------- 1 | name: validation 2 | on: 3 | - push 4 | jobs: 5 | lint_ruby: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - name: Install Ruby 10 | uses: ruby/setup-ruby@v1 11 | with: 12 | ruby-version: '2.7' 13 | - name: Install gems 14 | run: gem install rubocop 15 | - name: Lint 16 | run: rubocop 17 | test_ruby: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Test Redmine Plugin 21 | uses: two-pack/redmine-plugin-test-action@v2 22 | with: 23 | plugin_name: toggl2redmine 24 | redmine_version: v4.0 25 | ruby_version: v2.7 26 | database: mysql 27 | check_javascript: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v2 31 | - name: Install node 32 | uses: actions/setup-node@v2 33 | with: 34 | node-version: '16' 35 | - name: Install node packages 36 | working-directory: ./assets.src 37 | run: npm install 38 | - name: Lint 39 | working-directory: ./assets.src 40 | run: npm run lint 41 | - name: Test 42 | working-directory: ./assets.src 43 | run: npm run test 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE Files 2 | .idea 3 | 4 | # OS Files 5 | .DS_Store 6 | 7 | # Node 8 | node_modules 9 | assets.src/package-lock.json 10 | assets/javascripts/test 11 | 12 | # Miscellaneous. 13 | .rails 14 | .redmine 15 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: enable 3 | 4 | Layout/LineLength: 5 | AllowHeredoc: true 6 | AllowURI: true 7 | Max: 120 8 | Layout/MultilineMethodCallIndentation: 9 | EnforcedStyle: indented 10 | 11 | Metrics/AbcSize: 12 | Enabled: false 13 | Metrics/CyclomaticComplexity: 14 | Enabled: false 15 | Metrics/BlockLength: 16 | Enabled: false 17 | Metrics/ClassLength: 18 | Enabled: false 19 | Metrics/MethodLength: 20 | Enabled: false 21 | Metrics/PerceivedComplexity: 22 | Enabled: false 23 | 24 | Naming/VariableNumber: 25 | CheckSymbols: false 26 | 27 | Style/CaseEquality: 28 | AllowOnConstant: true 29 | Style/Documentation: 30 | Enabled: false 31 | Style/IfUnlessModifier: 32 | Enabled: false 33 | Style/HashEachMethods: 34 | Enabled: false 35 | Style/SymbolArray: 36 | Enabled: false -------------------------------------------------------------------------------- /PluginGemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | gem 'pry-byebug', group: [:development, :test] 4 | gem 'pry-rails', group: [:development, :test] 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Toggl 2 Redmine 2 | 3 | ![Redmine Version](https://img.shields.io/badge/Redmine-4.x-green.svg) 4 | 5 | This dandy Redmine plugin imports time entries from Toggl to Redmine using 6 | REST API service calls for both Toggl and Redmine. 7 | 8 | Additionally, the plugin groups similar Toggl time entries into a single Redmine 9 | entry. So, even if you start and stop your timer for a particular task multiple 10 | times on Toggl, at the end of day, when you import the time entries to Redmine, 11 | they are grouped by the issue ID and the description, which keeps Redmine clean. 12 | 13 | ## Disclaimer 14 | 15 | This plugin has been made and tested with love and care. However, the makers 16 | of this plugin are in no way responsible for any damages - direct or indirect - 17 | caused by the use of this plugin. In short, use it at your own risk. 18 | 19 | ## Installation 20 | 21 | * Copy the plugin directory into the `plugins` directory of Redmine. 22 | * Run database migrations. 23 | * You can read more about 24 | [plugin installation](http://www.redmine.org/projects/redmine/wiki/Plugins) on redmine.org 25 | ```bash 26 | RAILS_ENV=production bundle exec rake redmine:plugins:migrate 27 | ``` 28 | * This creates a _Toggl API Token_ field on the user profile. 29 | * Your database **must** support [transactions](https://en.wikipedia.org/wiki/Database_transaction). 30 | * Without transaction support, users might end up importing duplicate 31 | time entries. 32 | 33 | ## Usage 34 | 35 | Here's a quick video to get you started. 36 | 37 | [![Toggl 2 Redmine Video Tutorial](https://img.youtube.com/vi/FdwWUYllop4/0.jpg)](https://www.youtube.com/watch?v=FdwWUYllop4&t=162) 38 | 39 | If a certain topic is not mentioned in the video, you can find more information on it by reading this document. 40 | 41 | ### One-time Setup 42 | 43 | * Go to the _My Account_ page on Redmine (`/my/account`). 44 | * Paste in your _Toggl API Token_ and save your profile. 45 | * You can find this in your Toggl _profile settings_ page. 46 | * Update your time zone on Toggl and Redmine - this makes your time reports 47 | show correctly according to your timezone. 48 | * *Important:* Confirm with your Redmine administrator whether you need to 49 | update your timezone. Some organizations use Redmine without configuring 50 | timezones to avoid certain timezone-related bugs in Redmine. 51 | 52 | ### Regular Usage 53 | 54 | * Login to Toggl and log your time when you're working. 55 | * Make sure your task description is in one of the following formats: 56 | ``` 57 | #1919 Feed the bunny wabbit. 58 | Tracker #1919 Feed the bunny wabbit. 59 | ``` 60 | * You can use the Toggl browser extension to make this easier. 61 | * `#1919` is the Redmine issue ID. 62 | * `Feed the bunny wabbit` is the comment. 63 | * When you're done working for the day, visit the _My Timesheet_ page on Redmine 64 | and click on the _Toggl_ tab on Redmine (`/toggl2redmine`). 65 | * Most of the options on this page have useful tooltips. If you are confused 66 | about what something does, simply hover over the item to see if it has an 67 | informational tooltip. 68 | * You should see the time you've already logged on Redmine (if any) under the 69 | heading _Time logged on Redmine_. 70 | * You should see the time you've logged on Toggl for the day under the 71 | heading _Time logged on Toggl_. 72 | * If you want to import entries from some other date, you can change the 73 | _Date_ filter and any other options as per your requirements. 74 | * If you change any options, make sure you press _Apply_ for them to 75 | take effect. 76 | * Now, in the Toggl report, check the entries you want to import into Redmine. 77 | * For each entry, you can modify the comments, activity and time as per your 78 | requirements. 79 | * You can enter time as in decimal or as `hh:mm`. For example, `1h 30m` can 80 | be written as `1.5` or `1:30` in the input boxes. 81 | * Once you've reviewed everything, click on the _Import to Redmine_ button 82 | towards the bottom of the page. 83 | * After you import the data, you cannot undo it, so BE CAREFUL. 84 | * You will see a success (or failure) message next to each item. 85 | * Entries which imported successfully will be marked in green. 86 | * Entries which failed to import will be marked in red. 87 | 88 | ### Advanced options 89 | 90 | #### Default Activity 91 | 92 | You can specify a _Default activity_ in the options form. This activity will 93 | be pre-populated in your Toggl report, making it easier to import data. 94 | 95 | #### Toggl Workspace 96 | 97 | If you use multiple workspaces on Toggl, you can choose the workspace from 98 | which you want to import data using the _Toggl Workspace_ field in the options 99 | form. 100 | 101 | #### Date 102 | 103 | As mentioned before, the _Date_ option allows you to import time entries from 104 | past dates. 105 | 106 | #### Duration rounding 107 | 108 | You can use this option to round your time entries as per your requirements. 109 | Let's say, the option to round to the nearest 10 minutes. There are 3 ways in 110 | which you can round your time entries. 111 | 112 | * *Round Up:* 1h 26m becomes 1h 30m. 113 | * *Round Down:* 1h 26m becomes 1h 20m. 114 | * *Round Off:* 1h 26m becomes 1h 30m whereas 1h 24m becomes 1h 20m. 115 | 116 | To disable rounding, you can choose the *Don't round* option. 117 | 118 | ## Development 119 | 120 | Want to fiddle with the code? Or just get a demo of the plugin? If you use 121 | Docker, you can do so with ease. 122 | 123 | * Clone the code repository. 124 | ``` 125 | # Replace x.y with a real branch name, e.g. 5.x 126 | git clone --branch x.y git@github.com:jigarius/toggl2redmine.git 127 | cd toggl2redmine 128 | ``` 129 | * Prepare docker containers. 130 | ``` 131 | docker compose up 132 | # When Redmine is ready, you'll see a message like: 133 | # INFO WEBrick::HTTPServer#start: pid=X port=3000 134 | # At this point, press Ctrl+C and run the next command. 135 | docker compose start 136 | ``` 137 | * Provision the environment, e.g. create seed data, etc. 138 | ``` 139 | rake provision 140 | ``` 141 | 142 | Run `rake info` to learn how to access your demo installation! 143 | 144 | ### Testing 145 | 146 | Thanks to the Docker setup, the plugin code can easily be linted and tested. 147 | 148 | * `rake reset RAILS_ENV=test`: Prepare/reset the test environment. 149 | * `rake lint`: Run Rubocop. 150 | * `rake test`: Run tests. 151 | 152 | ### Mailhog 153 | 154 | Mailhog has been included in the Docker setup so that you can easily reset 155 | your password or test Toggl 2 Redmine with more than one user accounts. 156 | 157 | # Acknowledgements 158 | 159 | * Thanks [Evolving Web](https://evolvingweb.ca/) for funding the initial 160 | development of this plugin. 161 | * Thanks [Jigarius](https://jigarius.com/about) (that's me) 162 | for spending many evenings and weekends to make this plugin possible. 163 | * Thanks [JetBrains](https://jetbrains.com/) for their Open Source License, 164 | without which development would have been very difficult. 165 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | REDMINE_URL = 'http://localhost:3000' 4 | MAILHOG_URL = 'http://localhost:8025' 5 | 6 | desc "SSH into a service. Defaults to 'redmine'." 7 | task :ssh do 8 | rails_env = ENV.fetch('RAILS_ENV', 'development') 9 | service = ENV.fetch('SERVICE', 'redmine') 10 | 11 | sh "docker compose exec -e RAILS_ENV=#{rails_env} #{service} bash" 12 | end 13 | 14 | desc 'Execute a Rails command' 15 | task :rails do |_t, args| 16 | sh "docker compose exec redmine rails #{args.extras.join(' ')}" 17 | end 18 | 19 | desc 'Launch MySQL' 20 | task :mysql do |_t, args| 21 | args.with_defaults( 22 | user: 'root', 23 | pass: 'root', 24 | rails_env: ENV.fetch('RAILS_ENV', 'development') 25 | ) 26 | sh "docker compose exec mysql mysql -u#{args.user} -p#{args.pass} redmine_#{args.rails_env}" 27 | end 28 | 29 | desc 'Reset the database.' 30 | task :reset do 31 | rails_env = ENV.fetch('RAILS_ENV') do 32 | abort('Env var RAILS_ENV cannot be empty') 33 | end 34 | 35 | commands = [ 36 | 'db:drop', 37 | 'db:create', 38 | 'db:migrate', 39 | 'redmine:plugins:migrate' 40 | ] 41 | 42 | commands << 'db:seed' if rails_env == 'development' 43 | 44 | # If all commands are sent at once, redmine:plugins:migrate fails. 45 | # Hence, the commands are being sent separately. 46 | commands.each do |command| 47 | sh "docker compose exec -e RAILS_ENV=#{rails_env} redmine rake #{command}" 48 | end 49 | 50 | puts "The env '#{rails_env}' has been reset." 51 | end 52 | 53 | desc 'Provision the environment.' 54 | task :provision do 55 | puts 'Installing dev packages...' 56 | sleep(2) 57 | sh 'docker compose exec redmine bundle lock --add-platform x86-mingw32 x64-mingw32 x86-mswin32' 58 | sh 'docker compose exec redmine bundle config set with "default dev test"' 59 | sh 'docker compose exec redmine bundle install' 60 | 61 | puts 'Preparing database...' 62 | sleep(2) 63 | sh 'docker compose exec redmine rake db:seed' 64 | 65 | puts <<~RESULT 66 | ====== 67 | Redmine is ready! 68 | ====== 69 | RESULT 70 | 71 | Rake::Task[:info].invoke 72 | end 73 | 74 | desc 'Dev env info.' 75 | task :info do 76 | puts <<~INFO 77 | Sample time entries exist for john.doe on Nov 03, 2012. 78 | 79 | USERS 80 | ---------------------------------------------------- 81 | Login | Email address | Password 82 | ---------------------------------------------------- 83 | admin | admin@example.com | admin 84 | jsmith | jsmith@example.com | jsmith 85 | ---------------------------------------------------- 86 | 87 | URLS 88 | ---------------------------------------------------- 89 | Redmine | #{REDMINE_URL}/ 90 | Toggl 2 Redmine | #{REDMINE_URL}/toggl2redmine 91 | Mailhog | #{MAILHOG_URL}/ 92 | ---------------------------------------------------- 93 | INFO 94 | end 95 | 96 | desc 'Lint all code.' 97 | task :lint do 98 | Rake::Task['lint_ruby'].execute 99 | Rake::Task['lint_javascript'].execute 100 | end 101 | 102 | desc 'Lint Ruby code.' 103 | task :lint_ruby do 104 | sh 'docker compose exec redmine rubocop -c plugins/toggl2redmine/.rubocop.yml plugins/toggl2redmine' 105 | end 106 | 107 | desc 'Lint JavaScript code.' 108 | task :lint_javascript do 109 | sh 'docker compose exec -w /app/assets.src/javascripts node npm run lint' 110 | end 111 | 112 | desc 'Test all code.' 113 | task :test do 114 | Rake::Task['test_ruby'].execute 115 | Rake::Task['test_javascript'].execute 116 | end 117 | 118 | desc 'Test Ruby code.' 119 | task :test_ruby do 120 | file = ENV.fetch('TEST', nil) 121 | type = ENV.fetch('TYPE', nil) 122 | type = type ? ":#{type}" : nil 123 | 124 | command = 125 | if file 126 | "test TEST=plugins/toggl2redmine/#{file}" 127 | else 128 | "redmine:plugins:test#{type} NAME=toggl2redmine" 129 | end 130 | 131 | sh "docker compose exec -e RAILS_ENV=test redmine rake #{command}" 132 | end 133 | 134 | desc 'Test JavaScript code.' 135 | task :test_javascript do 136 | sh 'docker compose exec -w /app/assets.src node npm run test' 137 | end 138 | 139 | desc 'Watch and compile CSS and JS assets.' 140 | task :watch do 141 | sh 'docker compose exec -w /app/assets.src node npm run watch' 142 | end 143 | -------------------------------------------------------------------------------- /app/controllers/t2r_base_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class T2rBaseController < ApplicationController 4 | attr_reader :user 5 | 6 | before_action :require_login, :validate_user 7 | 8 | protected 9 | 10 | def toggl_service 11 | @toggl_service ||= TogglService.new(@toggl_api_token) 12 | end 13 | 14 | # Checks if the current user has a valid Toggl API token. 15 | def validate_user 16 | @user = User.current 17 | 18 | # Must have a Toggl API token. 19 | field = UserCustomField.find_by_name('Toggl API Token') 20 | @toggl_api_token = @user.custom_field_value(field) 21 | return if @toggl_api_token.present? 22 | 23 | flash[:error] = I18n.t 't2r.text_add_toggl_api_key' 24 | redirect_to my_account_path 25 | end 26 | 27 | def user_can_view_issue?(issue) 28 | return true if @user.admin? 29 | 30 | user_is_member_of?(@user, issue.project) 31 | end 32 | 33 | def user_is_member_of?(user, project) 34 | Member.where(user: user, project: project).count.positive? 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /app/controllers/t2r_import_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Toggl2Redmine Import controller. 4 | class T2rImportController < T2rBaseController 5 | include ApplicationHelper 6 | 7 | menu_item :toggl2redmine 8 | 9 | class ImportError < StandardError; end 10 | 11 | class MembershipError < StandardError; end 12 | 13 | class PermissionError < StandardError; end 14 | 15 | class DuplicateImportError < StandardError; end 16 | 17 | def index 18 | @translations = translation_hash( 19 | 't2r.error.ajax_load', 20 | 't2r.error.list_empty', 21 | 't2r.error.no_entries_selected', 22 | 't2r.error.date_invalid', 23 | 't2r.import_confirmation' 24 | ) 25 | end 26 | 27 | def import 28 | parse_params 29 | import_check_permissions 30 | 31 | # Save the Redmine time entry and map Toggl time entries to it. 32 | ActiveRecord::Base.transaction do 33 | @time_entry.save! 34 | params[:toggl_ids].each do |toggl_id| 35 | TogglMapping.create!(time_entry: @time_entry, toggl_id: toggl_id) 36 | end 37 | end 38 | 39 | render json: true, status: 201 40 | rescue ActionController::ParameterMissing, 41 | ActiveRecord::RecordInvalid, 42 | DuplicateImportError => e 43 | render json: { errors: [e.message] }, status: 400 44 | rescue MembershipError, 45 | PermissionError => e 46 | render json: { errors: [e.message] }, status: 403 47 | rescue ActiveRecord::ActiveRecordError => e 48 | messages = [e.message] 49 | 50 | # If the transaction couldn't be rolled back, raise an alert. 51 | if @time_entry.id 52 | @time_entry.delete 53 | messages << I18n.t('t2r.text_db_transaction_warning') 54 | end 55 | 56 | render json: { errors: messages }, status: 503 57 | end 58 | 59 | private 60 | 61 | # Parses request parameters for "import" action. 62 | # 63 | # - Prepares a @time_entry object 64 | # - Prepares a @toggl_ids array 65 | def parse_params 66 | params[:toggl_ids]&.keep_if { |id| id.respond_to?(:to_i) && id.to_i.positive? } 67 | @toggl_ids = params.require(:toggl_ids) 68 | 69 | # Abort if Toggl entries have already been imported. 70 | # This prevents re-imports for DBs without transaction support. 71 | if TogglMapping.where(toggl_id: params[:toggl_ids]).count.positive? 72 | raise DuplicateImportError, 'Toggl ID has already been imported.' 73 | end 74 | 75 | @time_entry = TimeEntry.new do |te| 76 | attributes = params.require(:time_entry) 77 | .permit( 78 | :activity_id, 79 | :comments, 80 | :hours, 81 | :issue_id, 82 | :spent_on 83 | ) 84 | te.assign_attributes(attributes) 85 | te.user = @user 86 | te.project = te.issue&.project 87 | te.validate! 88 | end 89 | end 90 | 91 | def import_check_permissions 92 | # If there's no project, we cannot check permissions. 93 | return unless @time_entry.project 94 | 95 | unless user_is_member_of?(@user, @time_entry.project) 96 | raise MembershipError, 'You are not a member of this project.' 97 | end 98 | 99 | return if @user.allowed_to?(:log_time, @time_entry.project) 100 | 101 | raise PermissionError, 'You are not allowed to log time on this project.' 102 | end 103 | 104 | def translation_hash(*keys) 105 | result = {} 106 | keys.each { |k| result[k] = I18n.t(k) } 107 | result 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /app/controllers/t2r_redmine_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class T2rRedmineController < T2rBaseController 4 | include Rails.application.routes.url_helpers 5 | 6 | def read_time_entries 7 | parse_params 8 | 9 | time_entries = TimeEntry.where( 10 | user: @user, 11 | spent_on: params[:from]..params[:till] 12 | ).order(:id) 13 | 14 | result = time_entries.map do |time_entry| 15 | item = time_entry.as_json( 16 | only: %i[id comments hours], 17 | include: { 18 | issue: { 19 | only: %i[id subject], 20 | include: { 21 | tracker: { only: %i[id name] } 22 | } 23 | }, 24 | project: { only: %i[id name status] }, 25 | activity: { only: %i[id name] } 26 | } 27 | ) 28 | 29 | if item['issue'] 30 | item['issue']['is_closed'] = time_entry.issue.closed? 31 | item['issue']['path'] = issue_path(time_entry.issue) 32 | end 33 | 34 | item['project']['path'] = project_path(time_entry.project) if item['project'] 35 | 36 | item 37 | end 38 | 39 | render json: { time_entries: result } 40 | rescue ActionController::ParameterMissing => e 41 | render json: { errors: [e.message] }, status: 400 42 | end 43 | 44 | private 45 | 46 | def parse_params 47 | params[:from] = Time.parse(params.require(:from)) 48 | params[:till] = Time.parse(params.require(:till)) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /app/controllers/t2r_toggl_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class T2rTogglController < T2rBaseController 4 | include Rails.application.routes.url_helpers 5 | 6 | def read_time_entries 7 | parse_params 8 | 9 | time_entries = toggl_service.load_time_entries( 10 | start_date: params[:from], 11 | end_date: params[:till], 12 | workspace_id: params[:workspace_id] 13 | ) 14 | groups = TogglTimeEntryGroup.group(time_entries) 15 | 16 | result = groups.merge(groups) do |_, group| 17 | item = group.as_json 18 | item['issue'] = nil 19 | item['project'] = nil 20 | 21 | if group.issue && user_can_view_issue?(group.issue) 22 | item['issue'] = 23 | group.issue.as_json( 24 | only: %i[id subject], 25 | include: { 26 | tracker: { only: %i[id name] } 27 | } 28 | ) 29 | item['issue']['is_closed'] = group.issue.closed? 30 | item['issue']['path'] = issue_path(group.issue) 31 | end 32 | 33 | if group.project && user_is_member_of?(@user, group.project) 34 | item['project'] = 35 | group.project.as_json(only: %i[id name status]) 36 | item['project']['path'] = project_path(group.project) 37 | end 38 | 39 | item 40 | end 41 | 42 | render json: result 43 | rescue ActionController::ParameterMissing => e 44 | render json: { errors: [e.message] }, status: 400 45 | rescue TogglService::Error => e 46 | response = e.response 47 | render json: { errors: response.body }, status: response.code 48 | end 49 | 50 | def read_workspaces 51 | @workspaces = toggl_service.load_workspaces 52 | render json: @workspaces 53 | end 54 | 55 | private 56 | 57 | def parse_params 58 | params[:from] = Time.parse(params.require(:from)) 59 | params[:till] = Time.parse(params.require(:till)) 60 | 61 | wid = params.fetch(:workspace_id, nil) 62 | params[:workspace_id] = wid ? wid&.to_i : nil 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /app/models/toggl_mapping.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A mapping between a TogglTimeEntry and a Redmine TimeEntry. 4 | class TogglMapping < ActiveRecord::Base 5 | attr_readonly :id 6 | 7 | belongs_to :time_entry 8 | 9 | validates :time_entry, presence: true 10 | validates :toggl_id, 11 | presence: true, 12 | numericality: { 13 | only_integer: true, 14 | greater_than: 0 15 | }, 16 | uniqueness: { message: 'has already been imported' } 17 | end 18 | -------------------------------------------------------------------------------- /app/models/toggl_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'net/http' 4 | 5 | # Toggl web service helper. 6 | class TogglService 7 | attr_reader :api_key 8 | 9 | ENDPOINT = 'https://api.track.toggl.com' 10 | 11 | class Error < StandardError 12 | attr_reader :request, :response 13 | 14 | def initialize(message = nil, request = nil, response = nil) 15 | super(message) 16 | 17 | @request = request 18 | @response = response 19 | end 20 | end 21 | 22 | def initialize(api_key) 23 | @api_key = api_key 24 | end 25 | 26 | # Loads time entries form Toggl. 27 | def load_time_entries( 28 | start_date:, 29 | end_date:, 30 | workspace_id: nil 31 | ) 32 | if workspace_id && !workspace_id.is_a?(Integer) 33 | raise ArgumentError, 'Workspace ID must be a valid integer' 34 | end 35 | 36 | raw_entries = get('/api/v8/time_entries', { start_date: start_date.iso8601, end_date: end_date.iso8601 }) 37 | 38 | # The workspace filter is only supported on certain versions of the 39 | # Toggl API. Thus, it is easier to filter out such records ourselves. 40 | raw_entries = raw_entries.keep_if { |r| workspace_id == r['wid'] } if workspace_id 41 | raw_entries.map { |e| TogglTimeEntry.new(e.symbolize_keys) } 42 | end 43 | 44 | # Loads workspaces from Toggl. 45 | def load_workspaces 46 | get('/api/v8/workspaces') 47 | .map { |w| TogglWorkspace.new(w.symbolize_keys) } 48 | end 49 | 50 | private 51 | 52 | # Makes a GET request to Toggl. 53 | def get(path, data = nil) 54 | uri = URI(ENDPOINT + path) 55 | uri.query = data.to_query if data.present? 56 | 57 | request = Net::HTTP::Get.new(uri) 58 | request.basic_auth @api_key, 'api_token' 59 | request['Accept'] = 'application/json' 60 | 61 | http = Net::HTTP.new(uri.host, uri.port) 62 | http.use_ssl = true 63 | response = http.request(request) 64 | 65 | raise_on_error(request, response) 66 | 67 | JSON.parse(response.body) 68 | end 69 | 70 | def raise_on_error(request, response) 71 | return if response.code == '200' 72 | 73 | raise Error.new("Toggl error: #{response.body}.", request, response) 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /app/models/toggl_time_entry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A time entry tag object received from Toggl. 4 | class TogglTimeEntry 5 | include ActiveModel::Model 6 | 7 | attr_reader :id, :wid, :issue_id, :duration, :at, :comments 8 | 9 | def initialize(attributes = {}) 10 | @id = attributes[:id].to_i 11 | @wid = attributes[:wid].to_i 12 | @duration = attributes[:duration].to_i 13 | @at = attributes[:at] 14 | @issue_id = nil 15 | @comments = nil 16 | 17 | parse_description(attributes[:description]) 18 | end 19 | 20 | # Returns a key for grouping the time entry. 21 | def key 22 | [ 23 | (@issue_id || 0).to_s, 24 | @comments.to_s.downcase, 25 | status 26 | ].join(':') 27 | end 28 | 29 | # Finds the associated Redmine issue. 30 | def issue 31 | Issue.find_by(id: @issue_id) 32 | end 33 | 34 | # Gets the status of the entry. 35 | def status 36 | return 'running' if running? 37 | return 'imported' if imported? 38 | 39 | 'pending' 40 | end 41 | 42 | # Whether the record has already been imported to Redmine. 43 | def imported? 44 | mapping.present? 45 | end 46 | 47 | # Whether the timer is currently running. 48 | def running? 49 | @duration.negative? 50 | end 51 | 52 | def to_hash 53 | instance_values.merge({ status: status }) 54 | end 55 | 56 | # == operator 57 | def ==(other) 58 | TogglTimeEntry === other && 59 | @id == other.id && 60 | @wid == other.wid && 61 | @duration == other.duration && 62 | @at == other.at && 63 | @issue_id == other.issue_id && 64 | @comments == other.comments 65 | end 66 | 67 | private 68 | 69 | # Parses a Toggl description and returns its components. 70 | def parse_description(description) 71 | matches = description.to_s.strip.scan(/^[^#]*#+(\d+)\s*(.*)?$/).first 72 | return unless matches&.count == 2 73 | 74 | @issue_id = matches[0].to_i 75 | @comments = matches[1] 76 | end 77 | 78 | # Finds the associated Toggl mapping. 79 | def mapping 80 | TogglMapping.find_by(toggl_id: @id) 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /app/models/toggl_time_entry_group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A group of time entries that have the same key. 4 | class TogglTimeEntryGroup 5 | include ActiveModel::Model 6 | 7 | attr_reader :duration 8 | 9 | # Constructor 10 | def initialize(*entries) 11 | @entries = {} 12 | @duration = 0 13 | 14 | entries.each { |e| self << e } 15 | end 16 | 17 | def key 18 | @entries.values.first&.key 19 | end 20 | 21 | def issue_id 22 | @entries.values.first&.issue_id 23 | end 24 | 25 | def issue 26 | @entries.values.first&.issue 27 | end 28 | 29 | def project 30 | issue&.project 31 | end 32 | 33 | def comments 34 | @entries.values.first&.comments 35 | end 36 | 37 | def imported? 38 | @entries.values.first&.imported? || false 39 | end 40 | 41 | def running? 42 | @entries.values.first&.running? || false 43 | end 44 | 45 | def status 46 | @entries.values.first&.status 47 | end 48 | 49 | def <<(entry) 50 | unless TogglTimeEntry === entry 51 | raise ArgumentError, "Argument must be a #{TogglTimeEntry.name}" 52 | end 53 | 54 | if !@entries.empty? && key != entry.key 55 | raise ArgumentError, "Only items with key '#{key}' can be added" 56 | end 57 | 58 | @entries[entry.id] = entry 59 | @duration += entry.duration 60 | end 61 | 62 | def ==(other) 63 | TogglTimeEntryGroup === other && 64 | entries.to_set == other.entries.to_set 65 | end 66 | 67 | def entries 68 | @entries.values 69 | end 70 | 71 | def errors 72 | result = [] 73 | result << I18n.t('t2r.error.project_closed') if issue&.project&.closed? 74 | result << I18n.t('t2r.error.issue_id_missing') if entries.length.positive? && !issue_id 75 | result << I18n.t('t2r.error.issue_not_found') if issue_id && !issue 76 | result 77 | end 78 | 79 | def to_hash 80 | { 81 | key: key, 82 | ids: @entries.keys, 83 | issue_id: issue_id, 84 | comments: comments, 85 | duration: duration, 86 | status: status, 87 | errors: errors 88 | } 89 | end 90 | 91 | # 92 | # Groups TogglTimeEntry objects into TogglTimeEntryGroup objects. 93 | # 94 | # Items with the same .key are put in the same group. 95 | def self.group(entries) 96 | output = {} 97 | entries.each do |entry| 98 | output[entry.key] = TogglTimeEntryGroup.new unless output.key?(entry.key) 99 | output[entry.key] << entry 100 | end 101 | output 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /app/models/toggl_workspace.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A time entry tag object received from Toggl. 4 | class TogglWorkspace 5 | include ActiveModel::Model 6 | include ActiveModel::Conversion 7 | 8 | attr_reader :id, :name 9 | 10 | def initialize(attributes = {}) 11 | @id = attributes[:id] 12 | @name = attributes[:name] 13 | end 14 | 15 | def ==(other) 16 | TogglWorkspace === other && 17 | @id == other.id && 18 | @name == other.name 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/views/t2r_import/index.html.erb: -------------------------------------------------------------------------------- 1 |

Toggl 2 Redmine

2 | 3 |
4 |
5 | 6 | <%= t 't2r.label_basic_options' %> 7 | 8 |
9 | 10 | 11 | 12 | 17 | 25 | 26 | 27 | 32 | 39 | 40 | 41 |
13 | 16 | 18 | 21 | 22 | <%= t('t2r.label_last_imported', date: ' ').html_safe %> 23 | 24 |
28 | 31 | 33 | 38 |
42 |
43 |
44 | 81 |

82 | <%= t :button_apply %> 84 | <%= t :button_reset %> 86 | <%= t 'label_help' %> 89 | 90 |

91 |
92 | 93 | <%= form_tag({}, :data => {:cm_url => time_entries_context_menu_path}) do -%> 94 |
95 |

96 | <%= t 't2r.caption_redmine_report' %>

97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 |
<%= t :label_issue %><%= t :label_comment_plural %><%= t :label_activity %><%= t 't2r.label_hour_plural' %>
<%= t 't2r.redmine_report_footer' %>
119 |
120 | <% end -%> 121 | 122 |
123 |

124 | <%= t 't2r.caption_toggl_report' %>

125 | 126 | 127 | 128 | 133 | 134 | 135 | 136 | 137 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 |
129 | 132 | <%= t 't2r.label_status' %><%= t :label_issue %><%= t :label_comment_plural %><%= t :label_activity %> 138 | <%= t 't2r.label_hour_plural' %> 140 |
<%= t 't2r.toggl_report_footer' %>
156 |
157 | 158 |
159 |
160 | 161 | <% content_for :header_tags do %> 162 | <%= stylesheet_link_tag 't2r', :plugin => 'toggl2redmine' %> 163 | 168 | <%= javascript_include_tag "t2r.js", :type => "module", :plugin => 'toggl2redmine' %> 169 | <% end %> 170 | 171 | <% html_title "Toggl 2 Redmine" %> 172 | <%= context_menu %> 173 | -------------------------------------------------------------------------------- /assets.src/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /assets.src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "rules": { 13 | "no-var": 0, 14 | "@typescript-eslint/no-explicit-any": 2, 15 | "@typescript-eslint/no-this-alias": [ 16 | "error", 17 | { 18 | "allowedNames": ["that"] 19 | } 20 | ] 21 | } 22 | } -------------------------------------------------------------------------------- /assets.src/.mocharc.yaml: -------------------------------------------------------------------------------- 1 | spec: './javascripts/test/**/*.test.ts' 2 | require: 3 | - './javascripts/test/register.js' 4 | - 'jsdom-global/register.js' 5 | timeout: 10000 6 | -------------------------------------------------------------------------------- /assets.src/javascripts/t2r/datetime.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A wrapper for JavaScript's Date object. 3 | */ 4 | export class DateTime { 5 | 6 | readonly date: Date; 7 | 8 | constructor(date: Date | undefined = undefined) { 9 | this.date = date || new Date() 10 | } 11 | 12 | /** 13 | * Format date as YYYY-MM-DD. 14 | * 15 | * @param {Date} date 16 | * A date. 17 | * 18 | * @returns {String} 19 | * HTML-friendly date, e.g. 2021-02-28. 20 | */ 21 | toHTMLDateString(): string { 22 | const yyyy = this.date.getFullYear(); 23 | const mm = (this.date.getMonth() + 1).toString().padStart(2, '0') 24 | const dd = this.date.getDate().toString().padStart(2, '0') 25 | 26 | return `${yyyy}-${mm}-${dd}` 27 | } 28 | 29 | /** 30 | * Format date in ISO format. 31 | * 32 | * Example: 2021-08-28T10:32:43.144Z 33 | * 34 | * @param zeroTime 35 | * Whether time should be set to 00:00:00. 36 | */ 37 | toISOString(zeroTime = false): string { 38 | if (!zeroTime) { 39 | return this.date.toISOString() 40 | } 41 | 42 | return this.date.toISOString().split('T')[0] + 'T00:00:00.000Z' 43 | } 44 | 45 | /** 46 | * Creates an instance from a date string. 47 | * 48 | * @param {string} date 49 | * The string to parse as a date. 50 | * 51 | * @returns {DateTime|undefined} 52 | * The date as an object. 53 | */ 54 | static fromString(date: string): DateTime { 55 | // Don't use Date.parse() as it works differently depending on the browser. 56 | const dateParts: number[] = date.split(/[^\d]/).map((part) => { 57 | return parseInt(part) 58 | }); 59 | 60 | // Must have at least the "date" part. 61 | if (dateParts.length < 3) { 62 | throw `Invalid date: ${date}` 63 | } 64 | 65 | // Assume time parts to be 00 if not defined. 66 | for (let i = 3; i <= 6; i++) { 67 | if (typeof dateParts[i] === 'undefined') { 68 | dateParts[i] = 0; 69 | } 70 | } 71 | 72 | // No part of the date can be non-numeric. 73 | for (let i = 1; i <= 6; i++) { 74 | if (isNaN(dateParts[i])) throw `Invalid date: ${date}` 75 | } 76 | 77 | if (dateParts[1] < 1 || dateParts[1] > 12) { 78 | throw `Invalid date: ${date}` 79 | } 80 | 81 | try { 82 | return new DateTime(new Date( 83 | dateParts[0], 84 | dateParts[1] - 1, 85 | dateParts[2], 86 | dateParts[3], 87 | dateParts[4], 88 | dateParts[5], 89 | dateParts[6] 90 | )); 91 | } catch(e) { 92 | console.error('Invalid date', date) 93 | throw `Invalid date: ${date}` 94 | } 95 | } 96 | 97 | } 98 | 99 | export enum DurationRoundingMethod { 100 | Up = 'U', 101 | Down = 'D', 102 | Regular = 'R' 103 | } 104 | 105 | /** 106 | * Toggl to Redmine time duration. 107 | * 108 | * @param {string} 109 | * A duration as hh:mm or seconds. 110 | */ 111 | export class Duration { 112 | 113 | // Number of seconds in the duration. 114 | private _seconds: number; 115 | 116 | /** 117 | * Creates a Duration object. 118 | * 119 | * @param duration [Optional] A duration. 120 | * 121 | * @example d = Duration(90) 122 | * @example d = Duration('90') 123 | * @example d = Duration('1:30') 124 | */ 125 | constructor(duration: number | string = 0) { 126 | this._seconds = 0 127 | duration = duration || 0; 128 | 129 | if ('number' === typeof duration) { 130 | this.seconds = duration; 131 | return 132 | } 133 | 134 | if (duration.match(/^\d+$/)) { 135 | this.seconds = parseInt(duration) 136 | return 137 | } 138 | 139 | try { 140 | this.setHHMM(duration); 141 | } catch (e) { 142 | throw 'Error: "' + duration + '" is not a number or an hh:mm string.'; 143 | } 144 | } 145 | 146 | get hours(): number { 147 | return Math.floor(this._seconds / 3600) 148 | } 149 | 150 | get minutes(): number { 151 | return Math.floor(this._seconds / 60) 152 | } 153 | 154 | get seconds(): number { 155 | return this._seconds 156 | } 157 | 158 | set seconds(value: number) { 159 | if (value < 0) { 160 | throw `Value cannot be negative: ${value}` 161 | } 162 | 163 | this._seconds = value 164 | } 165 | 166 | /** 167 | * Sets duration from hours and minutes. 168 | * 169 | * Supported formats: 170 | * - 2 = 2h 00m 171 | * - 2:30 = 2h 30m 172 | * - :5 = 0h 5m 173 | * - :30 = 0h 30m 174 | * - 2.50 = 2h 30m 175 | * - .5 = 0h 30m 176 | * 177 | * @param {string} hhmm 178 | */ 179 | setHHMM(hhmm: string): void { 180 | let parts: string[] = [] 181 | let pattern: RegExp 182 | let hh: number | null 183 | let mm: number | null 184 | const error = `Invalid hh:mm format: ${hhmm}` 185 | 186 | // Parse hh only. Ex: 2 = 2h 00m. 187 | pattern = /^(\d{0,2})$/ 188 | if (hhmm.match(pattern)) { 189 | const matches = hhmm.match(pattern) as RegExpMatchArray 190 | hh = parseInt(matches.pop() as string) 191 | this.seconds = hh * 60 * 60 192 | return 193 | } 194 | 195 | // Parse hh:mm duration. Ex: 2:30 = 2h 30m. 196 | pattern = /^(\d{0,2}):(\d{0,2})$/; 197 | if (hhmm.match(pattern)) { 198 | const matches = hhmm.match(pattern) as RegExpMatchArray 199 | parts = matches.slice(-2) 200 | mm = parseInt(parts.pop() || '0') 201 | hh = parseInt(parts.pop() || '0') 202 | 203 | if (mm > 59) throw error 204 | 205 | this.seconds = hh * 60 * 60 + mm * 60 206 | return 207 | } 208 | 209 | // Parse hh.mm as decimal. Ex: 2.5 = 2h 30m. 210 | pattern = /^(\d{0,2})\.(\d{1,2})$/ 211 | if (hhmm.match(pattern)) { 212 | const matches = hhmm.match(pattern) as RegExpMatchArray 213 | parts = matches.slice(-2) 214 | hh = parseInt(parts[0] || '0') 215 | hh = Math.round(hh) 216 | mm = parseInt(parts[1] || '0') 217 | mm = (60 * mm) / Math.pow(10, parts[1].length) 218 | 219 | this.seconds = hh * 60 * 60 + mm * 60 220 | return 221 | } 222 | 223 | throw error 224 | } 225 | 226 | /** 227 | * Gets the duration as hours and minutes. 228 | * 229 | * @return string 230 | * Time in hh:mm format. 231 | */ 232 | asHHMM(): string { 233 | const hh: string = this.hours.toString().padStart(2, '0') 234 | const mm: string = (this.minutes % 60).toString().padStart(2, '0') 235 | 236 | return `${hh}:${mm}` 237 | } 238 | 239 | /** 240 | * Gets the duration as hours in decimals. 241 | * 242 | * @return string 243 | * Time in hours (decimal). Ex: 1.5 for 1 hr 30 min. 244 | */ 245 | asDecimal(): string { 246 | // Only consider full minutes. 247 | const hours: number = this.minutes / 60 248 | // Convert to hours. Ex: 0h 25m becomes 0.416. 249 | const output: string = hours.toFixed(3) 250 | // Since toFixed rounds off the last digit, we ignore it. 251 | return output.substr(0, output.length - 1); 252 | } 253 | 254 | add(other: Duration): void { 255 | this.seconds = this.seconds + other.seconds 256 | } 257 | 258 | sub(other: Duration): void { 259 | // Duration cannot be negative. 260 | this.seconds = Math.max(this.seconds - other.seconds, 0) 261 | } 262 | 263 | /** 264 | * Rounds to the nearest minutes. 265 | * 266 | * @param {*} minutes 267 | * Number of minutes to round to. Ex: 5, 10 or 15. 268 | * @param {DurationRoundingMethod} method 269 | * Rounding logic. 270 | */ 271 | roundTo(minutes: number, method: DurationRoundingMethod): void { 272 | if (0 === minutes) return 273 | const seconds: number = minutes * 60; 274 | 275 | // Do nothing if no correction / rounding is required. 276 | const correction: number = this.seconds % seconds; 277 | if (correction === 0) return 278 | 279 | // Round according to rounding method. 280 | switch (method) { 281 | case DurationRoundingMethod.Regular: 282 | if (correction >= seconds / 2) { 283 | this.roundTo(minutes, DurationRoundingMethod.Up); 284 | } 285 | else { 286 | this.roundTo(minutes, DurationRoundingMethod.Down); 287 | } 288 | break; 289 | 290 | case DurationRoundingMethod.Up: 291 | this.add(new Duration(seconds - correction)); 292 | break; 293 | 294 | case DurationRoundingMethod.Down: 295 | this.sub(new Duration(correction)); 296 | break; 297 | 298 | default: 299 | throw 'Invalid rounding method.'; 300 | } 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /assets.src/javascripts/t2r/flash.ts: -------------------------------------------------------------------------------- 1 | enum Type { 2 | Notice = 'notice', 3 | Error = 'error', 4 | Warning = 'warning' 5 | } 6 | 7 | /** 8 | * Displays a rails-style flash message. 9 | */ 10 | function message(text: string, type: Type = Type.Notice): void { 11 | $('#content').prepend(`
${text.trim()}
`); 12 | } 13 | 14 | export function error(text: string): void { 15 | message(text, Type.Error) 16 | } 17 | 18 | export function warning(text: string): void { 19 | message(text, Type.Warning) 20 | } 21 | 22 | export function notice(text: string): void { 23 | message(text, Type.Notice) 24 | } 25 | 26 | /** 27 | * Clears all flash messages. 28 | */ 29 | export function clear(): void { 30 | $('.t2r.flash').remove(); 31 | } 32 | -------------------------------------------------------------------------------- /assets.src/javascripts/t2r/i18n.ts: -------------------------------------------------------------------------------- 1 | interface StringMap { [index:string]: string } 2 | 3 | // UI translations. 4 | declare const T2R_TRANSLATIONS: StringMap 5 | 6 | /** 7 | * Equivalent of I18n.t(). 8 | * 9 | * @param {string} key 10 | * String ID. 11 | * @param {{}} vars 12 | * Key-value pair of variables to replace. 13 | * 14 | * @example 15 | * T2R.t('hello', { name: 'Junior' }); 16 | * 17 | * This replaces '@name' with 'Junior'. 18 | * 19 | * @returns {string} 20 | * Translated string if available. 21 | */ 22 | export function translate(key: string, vars: StringMap = {}): string { 23 | if (typeof T2R_TRANSLATIONS[key] === 'undefined') { 24 | const lang = $('html').attr('lang') || '??' 25 | return `translation missing: ${lang}.${key}` 26 | } 27 | 28 | let result: string = T2R_TRANSLATIONS[key]; 29 | for (const name in vars) { 30 | result = result.replace('@' + name, vars[name]) 31 | } 32 | 33 | return result; 34 | } 35 | -------------------------------------------------------------------------------- /assets.src/javascripts/t2r/models.ts: -------------------------------------------------------------------------------- 1 | import * as datetime from "./datetime.js" 2 | 3 | export interface TimeEntryActivity { 4 | id: number 5 | name: string 6 | active: boolean 7 | is_default: boolean 8 | } 9 | 10 | export interface Issue { 11 | id: number 12 | subject: string 13 | path: string 14 | tracker: Tracker 15 | is_closed: boolean 16 | } 17 | 18 | export interface Project { 19 | id: number 20 | name: string 21 | path: string 22 | status: number // todo: Use enum? 23 | } 24 | 25 | export interface Tracker { 26 | id: number 27 | name: string 28 | } 29 | 30 | export interface TimeEntry { 31 | id: number 32 | hours: string 33 | duration: datetime.Duration 34 | comments: string 35 | activity: TimeEntryActivity 36 | issue: Issue 37 | project: Project 38 | spent_on?: string 39 | } 40 | 41 | export interface TogglWorkspace { 42 | id: number 43 | name: string 44 | } 45 | 46 | export interface TogglTimeEntry { 47 | key: string 48 | comments: string 49 | // todo: Use only datetime.Duration 50 | duration: datetime.Duration | number 51 | roundedDuration?: datetime.Duration 52 | errors: string[] 53 | ids: number[] 54 | issue_id: number | null 55 | status: string // todo: Use enum? 56 | issue: Issue | null 57 | project: Project | null 58 | } 59 | 60 | export interface KeyedTogglTimeEntryCollection { 61 | [index:string]: TogglTimeEntry 62 | } 63 | -------------------------------------------------------------------------------- /assets.src/javascripts/t2r/renderers.ts: -------------------------------------------------------------------------------- 1 | import * as models from './models.js' 2 | import * as utils from './utils.js' 3 | import * as datetime from './datetime.js' 4 | 5 | declare const T2R_BUTTON_ACTIONS: string 6 | declare const contextMenuRightClick: { (): void } 7 | 8 | export function renderRedmineProjectLabel(project: models.Project): string { 9 | const classes = ['project']; 10 | if (project.status != 1) { 11 | classes.push('closed') 12 | } 13 | 14 | return '' 15 | + utils.htmlEntityEncode(project.name) 16 | + ''; 17 | } 18 | 19 | export function renderRedmineProjectStubLabel(): string { 20 | return '-' 21 | } 22 | 23 | export function renderRedmineIssueLabel(issue: models.Issue): string { 24 | const classes = ['issue'] 25 | if (issue.is_closed) { 26 | classes.push('closed') 27 | } 28 | 29 | return '' 30 | + utils.htmlEntityEncode(issue ? issue.tracker.name : '-') 31 | + utils.htmlEntityEncode(issue ? ' #' + issue.id : '') 32 | + utils.htmlEntityEncode(issue.subject ? ': ' + issue.subject : '') 33 | + '' 34 | } 35 | 36 | export function renderRedmineIssueStubLabel(issueId: number | null): string { 37 | if (!issueId) { 38 | return 'Unknown' 39 | } 40 | 41 | return '#' + issueId.toString() + ': -' 42 | } 43 | 44 | export function renderTogglRow(data: models.TogglTimeEntry): JQuery { 45 | const issue = data.issue 46 | const issueLabel: string = issue ? renderRedmineIssueLabel(issue) : renderRedmineIssueStubLabel(data.issue_id) 47 | const project = data.project || null 48 | const projectLabel = project ? renderRedmineProjectLabel(project) : renderRedmineProjectStubLabel() 49 | const oDuration = data.duration as datetime.Duration 50 | const rDuration = data.roundedDuration as datetime.Duration 51 | 52 | const markup = '' 53 | + '' 54 | + '' 55 | + '' 56 | + '' 59 | + projectLabel + '
' + issueLabel 60 | + '' 61 | + '' 62 | + '' 63 | + '' 64 | + '' 65 | + '' 66 | + '' 67 | + '' 68 | + '' 69 | + '' 70 | + ''; 71 | 72 | // Attach the entry for reference. 73 | const $tr = $(markup); 74 | $tr.data('t2r.entry', data); 75 | 76 | let statusLabel: HTMLElement | null = null 77 | 78 | switch (data.status) { 79 | case 'pending': 80 | if (data.errors.length > 0) { 81 | $tr.addClass('t2r-error'); 82 | $tr.find(':input').attr('disabled', 'disabled'); 83 | statusLabel = renderImportStatusLabel('Invalid', data.errors.join("\n"), 'error') 84 | } 85 | break; 86 | 87 | case 'imported': 88 | $tr.find('.cb-import').removeAttr('checked'); 89 | $tr.addClass('t2r-success'); 90 | $tr.find(':input').attr('disabled', 'disabled'); 91 | statusLabel = renderImportStatusLabel('Imported') 92 | break; 93 | 94 | case 'running': 95 | $tr.addClass('t2r-running'); 96 | $tr.find(':input').attr('disabled', 'disabled'); 97 | statusLabel = renderImportStatusLabel( 98 | 'Running', 99 | 'The timer is still running on Toggl.', 100 | 'error' 101 | ) 102 | break; 103 | 104 | default: 105 | throw `Unrecognized status: ${data.status}.` 106 | } 107 | 108 | if (statusLabel) { 109 | $tr.find('td.status').html(statusLabel) 110 | } 111 | 112 | return $tr; 113 | } 114 | 115 | export function renderRedmineRow(data: models.TimeEntry): JQuery { 116 | const issue = data.issue 117 | const issueLabel = renderRedmineIssueLabel(issue) 118 | const project = data.project 119 | const projectLabel = renderRedmineProjectLabel(project) 120 | const oDuration = data.duration 121 | oDuration.roundTo(1, datetime.DurationRoundingMethod.Up) 122 | 123 | const markup = '' 124 | + '' 125 | + projectLabel + '
' + issueLabel 126 | + '' 127 | + '' 128 | + '' + utils.htmlEntityEncode(data.comments) + '' 129 | + '' + utils.htmlEntityEncode(data.activity.name) + '' 130 | + '' + oDuration.asHHMM() + '' 131 | + '' + T2R_BUTTON_ACTIONS + '' 132 | + ''; 133 | 134 | const $tr = $(markup) 135 | $tr.find('.js-contextmenu').on('click', contextMenuRightClick) 136 | 137 | return $tr 138 | } 139 | 140 | /** 141 | * Returns an import status label element. 142 | * 143 | * @param {string} label 144 | * A label. 145 | * @param {string} description 146 | * A description (displayed as tooltip). 147 | * @param {string} icon 148 | * An icon. One of checked, error, warn. 149 | * 150 | * @return {HTMLElement} 151 | * A span element. 152 | */ 153 | export function renderImportStatusLabel( 154 | label: string, 155 | description: string | null = null, 156 | icon = 'checked' 157 | ): HTMLElement { 158 | const el = document.createElement('span') 159 | 160 | el.innerHTML = label 161 | el.dataset.t2rWidget = 'Tooltip' 162 | el.classList.add('icon', `icon-${icon}`) 163 | if (description) { 164 | el.setAttribute('title', description) 165 | } 166 | 167 | return el 168 | } 169 | -------------------------------------------------------------------------------- /assets.src/javascripts/t2r/request.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Request Queue. 3 | * 4 | * Sequentially executes AJAX requests. 5 | */ 6 | export class RequestQueue { 7 | 8 | /** 9 | * Requests to be processed. 10 | * @private 11 | */ 12 | private _items: JQuery.AjaxSettings[] 13 | 14 | /** 15 | * Whether a request is in progress. 16 | * @private 17 | */ 18 | private _requestInProgress: boolean 19 | 20 | constructor() { 21 | this._items = [] 22 | this._requestInProgress = false 23 | } 24 | 25 | /** 26 | * Number of requests currently in the queue. 27 | */ 28 | get length(): number { 29 | return this._items.length 30 | } 31 | 32 | /** 33 | * Adds an AJAX request to the execution queue. 34 | * 35 | * Requests be executed one after the other until all items in the queue have 36 | * been processed. 37 | */ 38 | addItem(opts: JQuery.AjaxSettings): void { 39 | this._items.push(opts) 40 | this.processItem() 41 | } 42 | 43 | /** 44 | * Processes the next request. 45 | */ 46 | processItem(): void { 47 | if (this.length === 0 || this._requestInProgress) return 48 | this._requestInProgress = true; 49 | 50 | const that = this 51 | const opts = this._items.shift() 52 | if (opts === undefined) { 53 | return 54 | } 55 | 56 | console.debug('Processing AJAX queue (' + this.length + ' remaining).', opts) 57 | 58 | const originalCallback = opts.complete as JQuery.Ajax.CompleteCallback 59 | opts.complete = function (xhr: JQuery.jqXHR, status: JQuery.Ajax.TextStatus) { 60 | if (typeof originalCallback !== 'undefined') { 61 | (originalCallback).call(this, xhr, status) 62 | } 63 | 64 | // Process the next item in the queue, if any. 65 | that._requestInProgress = false 66 | that.processItem() 67 | }; 68 | 69 | // Process current item. 70 | $.ajax(opts); 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /assets.src/javascripts/t2r/services.ts: -------------------------------------------------------------------------------- 1 | import * as datetime from "./datetime.js" 2 | import * as models from "./models.js" 3 | import * as flash from "./flash.js" 4 | import {translate as t} from "./i18n.js" 5 | import {RequestQueue} from "./request.js" 6 | import {TemporaryStorage} from "./storage.js" 7 | import {TimeEntryActivity} from "./models.js" 8 | 9 | interface GetLastExportDateCallback { 10 | (date: datetime.DateTime | null): void 11 | } 12 | 13 | interface GetTimeEntriesParams { 14 | from: datetime.DateTime 15 | till: datetime.DateTime 16 | } 17 | 18 | interface GetTimeEntriesResponse { 19 | time_entries: models.TimeEntry[] 20 | } 21 | 22 | interface GetTimeEntriesCallback { 23 | (entries: models.TimeEntry[] | null): void 24 | } 25 | 26 | interface GetTimeEntryActivitiesResponse { 27 | time_entry_activities: models.TimeEntryActivity[] 28 | } 29 | 30 | interface GetTimeEntryActivitiesCallback { 31 | (activities: TimeEntryActivity[] | null): void 32 | } 33 | 34 | interface GetTogglTimeEntriesParams { 35 | from: datetime.DateTime 36 | till: datetime.DateTime 37 | workspaceId: number | null 38 | } 39 | 40 | interface GetTogglTimeEntriesCallback { 41 | (entries: models.KeyedTogglTimeEntryCollection): void 42 | } 43 | 44 | interface GetTogglWorkspacesCallback { 45 | (workspaces: models.TogglWorkspace[] | null): void 46 | } 47 | 48 | interface PostTimeEntryParams { 49 | time_entry: { 50 | spent_on: string 51 | issue_id: number 52 | comments: string 53 | activity_id: number | null 54 | hours: string 55 | }, 56 | toggl_ids: number[] 57 | } 58 | 59 | interface PostTimeEntryCallback { 60 | (errors: string[]): void 61 | } 62 | 63 | /** 64 | * Sends requests to Redmine API endpoints. 65 | */ 66 | export class RedmineAPIService { 67 | readonly _apiKey: string 68 | readonly _baseUrl: string 69 | readonly _cache: TemporaryStorage 70 | public requestQueue: RequestQueue 71 | 72 | constructor(apiKey: string) { 73 | this._baseUrl = window.location.origin 74 | this._apiKey = apiKey 75 | this._cache = new TemporaryStorage() 76 | this.requestQueue = new RequestQueue() 77 | } 78 | 79 | /** 80 | * Sends an AJAX request to Redmine with the given options. 81 | * 82 | * Automatically injects auth headers. 83 | * 84 | * @param opts 85 | * Request options. 86 | */ 87 | request(opts: JQuery.AjaxSettings): void { 88 | if (!opts.url) throw 'Missing required parameter: url' 89 | 90 | if (opts.url.match(/^\//)) { 91 | opts.url = this._baseUrl + opts.url 92 | } 93 | 94 | opts.headers = opts.headers || {} 95 | opts.headers['X-Redmine-API-Key'] = this._apiKey 96 | opts.timeout = opts.timeout || 3000 97 | 98 | this.requestQueue.addItem(opts) 99 | } 100 | 101 | handleRequestSuccess(type: string, data: Type): void { 102 | console.debug(`Request succeeded: ${type}`, data) 103 | } 104 | 105 | handleRequestError(type: string): void { 106 | flash.error(t('t2r.error.ajax_load')) 107 | console.error(`Request failed: ${type}`) 108 | } 109 | 110 | /** 111 | * Fetches Redmine time entries. 112 | * 113 | * @param {object} params 114 | * Query parameters. 115 | * @param {function} callback 116 | * Receives time entries or null. 117 | */ 118 | getTimeEntries(params: GetTimeEntriesParams, callback: GetTimeEntriesCallback): void { 119 | const that = this 120 | this.request({ 121 | async: true, 122 | method: 'get', 123 | url: '/toggl2redmine/redmine/time_entries', 124 | data: { 125 | from: params.from.toISOString(true), 126 | till: params.till.toISOString(true) 127 | }, 128 | success: function (data: GetTimeEntriesResponse) { 129 | if (typeof data.time_entries === 'undefined') { 130 | that.handleRequestError('Redmine time entries') 131 | callback(null) 132 | return 133 | } 134 | 135 | that.handleRequestSuccess('Redmine time entries', data) 136 | 137 | const time_entries: models.TimeEntry[] = data.time_entries.map((entry: models.TimeEntry) => { 138 | entry.duration = new datetime.Duration( 139 | Math.floor(parseFloat(entry.hours) * 3600) 140 | ) 141 | return entry 142 | }) 143 | 144 | callback(time_entries); 145 | }, 146 | error: () => { 147 | that.handleRequestError('Redmine time entries') 148 | callback(null) 149 | } 150 | }); 151 | } 152 | 153 | /** 154 | * Fetches and caches time entry activities. 155 | * 156 | * @param callback 157 | * function (activities, null) {} 158 | */ 159 | getTimeEntryActivities(callback: GetTimeEntryActivitiesCallback): void { 160 | const activities: TimeEntryActivity[] = this._cache.get('redmine.activities') 161 | if (activities) { 162 | callback(activities) 163 | return 164 | } 165 | 166 | const that = this 167 | this.request({ 168 | url: '/enumerations/time_entry_activities.json', 169 | success: (data: GetTimeEntryActivitiesResponse) => { 170 | that.handleRequestSuccess('Time entry activities', data) 171 | that._cache.set('redmine.activities', data.time_entry_activities) 172 | callback(data.time_entry_activities) 173 | }, 174 | error: () => { 175 | that.handleRequestError('Time entry activities') 176 | callback(null) 177 | } 178 | }); 179 | } 180 | 181 | /** 182 | * Fetches the last date on which time entries were found for the current user. 183 | * 184 | * Time entries for future dates are ignored. 185 | * 186 | * @param {GetLastExportDateCallback} callback 187 | * Receives a Date object or null. 188 | */ 189 | getLastImportDate(callback: GetLastExportDateCallback): void { 190 | const opts: JQuery.AjaxSettings = {} 191 | opts.url = '/time_entries.json' 192 | opts.data = { 193 | user_id: 'me', 194 | limit: 1, 195 | // Ignore entries made in the future. 196 | to: (new datetime.DateTime()).toHTMLDateString() 197 | } 198 | 199 | const that = this 200 | opts.success = (data: GetTimeEntriesResponse) => { 201 | this.handleRequestSuccess('Last import date', data) 202 | if (data.time_entries.length === 0) { 203 | callback(null) 204 | return 205 | } 206 | 207 | const lastTimeEntry = data.time_entries.pop() as models.TimeEntry 208 | const lastImportDate = datetime.DateTime.fromString(`${lastTimeEntry.spent_on} 00:00:00`) 209 | callback(lastImportDate) 210 | } 211 | opts.error = () => { 212 | that.handleRequestError('Last import date') 213 | callback(null) 214 | } 215 | 216 | this.request(opts) 217 | } 218 | 219 | /** 220 | * Fetches Toggl time entries. 221 | * 222 | * @param {object} params 223 | * Query parameters. 224 | * @param {function} callback 225 | * Receives Toggl time entry groups or null. 226 | */ 227 | getTogglTimeEntries(params: GetTogglTimeEntriesParams, callback: GetTogglTimeEntriesCallback): void { 228 | const data = { 229 | from: params.from.toISOString(), 230 | till: params.till.toISOString(), 231 | workspace_id: params.workspaceId || null 232 | } 233 | 234 | this.request({ 235 | url: '/toggl2redmine/toggl/time_entries', 236 | data: data, 237 | success: (time_entries: models.KeyedTogglTimeEntryCollection) => { 238 | this.handleRequestSuccess('Toggl time entries', time_entries) 239 | callback(time_entries) 240 | }, 241 | error: () => { 242 | this.handleRequestError('Toggl time entries') 243 | callback({}) 244 | } 245 | }) 246 | } 247 | 248 | /** 249 | * Fetches and caches Toggl workspaces. 250 | * 251 | * @param {function} callback 252 | * Receives workspaces or null. 253 | */ 254 | getTogglWorkspaces(callback: GetTogglWorkspacesCallback): void { 255 | const workspaces = this._cache.get('toggl.workspaces') 256 | if (workspaces) { 257 | callback(workspaces) 258 | return 259 | } 260 | 261 | const that = this 262 | this.request({ 263 | url: '/toggl2redmine/toggl/workspaces', 264 | success: (workspaces: models.TogglWorkspace[]) => { 265 | that.handleRequestSuccess('Toggl workspaces', workspaces) 266 | that._cache.set('toggl.workspaces', workspaces) 267 | callback(workspaces) 268 | }, 269 | error: () => { 270 | that.handleRequestError('Toggl workspaces') 271 | callback(null) 272 | } 273 | }) 274 | } 275 | 276 | /** 277 | * Attempts to create a Time Entry on Redmine. 278 | * 279 | * @param {PostTimeEntryParams} params 280 | * Time entry data. 281 | * @param {PostTimeEntryCallback} callback 282 | * Receives an array of error messages, which is empty on success. 283 | */ 284 | postTimeEntry(params: PostTimeEntryParams, callback: PostTimeEntryCallback): void { 285 | const that = this 286 | this.request({ 287 | async: true, 288 | url: '/toggl2redmine/import', 289 | method: 'post', 290 | data: JSON.stringify(params), 291 | contentType: 'application/json', 292 | success: (data: string[]) => { 293 | that.handleRequestSuccess('Time entry import', data) 294 | callback([]) 295 | }, 296 | error: function(xhr: JQuery.jqXHR) { 297 | that.handleRequestError('Time entry import') 298 | let errors: string[] 299 | 300 | try { 301 | const oResponse = JSON.parse(xhr.responseText) 302 | errors = (typeof oResponse.errors === 'undefined') ? ['Unknown error'] : oResponse.errors 303 | } catch (e) { 304 | errors = ['The server returned an unexpected response'] 305 | } 306 | 307 | callback(errors) 308 | } 309 | }); 310 | } 311 | 312 | } 313 | -------------------------------------------------------------------------------- /assets.src/javascripts/t2r/storage.ts: -------------------------------------------------------------------------------- 1 | /* eslint @typescript-eslint/no-explicit-any: 0 */ 2 | 3 | /** 4 | * Wrapper around the browser's local storage. 5 | */ 6 | export class LocalStorage { 7 | private readonly _prefix: string 8 | 9 | constructor(prefix: string) { 10 | this._prefix = prefix 11 | 12 | if (typeof window['localStorage'] === 'undefined') { 13 | throw new Error("Missing browser feature: localStorage"); 14 | } 15 | } 16 | 17 | get prefix(): string { 18 | return this._prefix 19 | } 20 | 21 | get(key: string, fallback: Type | undefined = undefined): string | Type | undefined { 22 | const value = window.localStorage.getItem(this.prefix + key) 23 | if (value !== null) { 24 | return value 25 | } 26 | 27 | return fallback 28 | } 29 | 30 | set(key: string, value: Type): Type { 31 | if (value === null || typeof value === 'undefined') { 32 | return this.delete(key) 33 | } 34 | 35 | try { 36 | window.localStorage.setItem(this.prefix + key, (value as any).toString()) 37 | return value; 38 | } catch(e) { 39 | console.error('Value not representable as string', value) 40 | throw 'Value could not be stored' 41 | } 42 | } 43 | 44 | delete(key: string): any { 45 | const value = this.get(key) 46 | window.localStorage.removeItem(this.prefix + key) 47 | return value 48 | } 49 | } 50 | 51 | /** 52 | * A storage handler to store data in temporary memory. 53 | * 54 | * All data is lost when the page is reloaded 55 | */ 56 | export class TemporaryStorage { 57 | data: any 58 | constructor() { 59 | this.data = {} 60 | } 61 | 62 | get(key: string, fallback: any = undefined): any { 63 | if (typeof this.data[key] !== 'undefined') { 64 | return this.data[key] 65 | } 66 | 67 | return fallback 68 | } 69 | 70 | set(key: string, value: Type): Type { 71 | if (value === null || typeof value === 'undefined') { 72 | this.delete(key) 73 | return value 74 | } 75 | 76 | this.data[key] = value 77 | return this.data[key] 78 | } 79 | 80 | delete(key: string): any { 81 | const value = this.get(key) 82 | 83 | if (key in this.data) { 84 | delete this.data[key] 85 | } 86 | 87 | return value 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /assets.src/javascripts/t2r/utils.ts: -------------------------------------------------------------------------------- 1 | import * as datetime from "./datetime.js" 2 | 3 | /** 4 | * Replaces certain characters with HTML entities. 5 | */ 6 | export function htmlEntityEncode(str: string): string { 7 | return $('
') 8 | .text(str) 9 | .text() 10 | .replace(/"/g, '"') 11 | .replace(/'/g, ''') 12 | .replace(//g, '>'); 14 | } 15 | 16 | /** 17 | * Gets date from window.location.hash. 18 | */ 19 | export function getDateFromLocationHash(): string | undefined { 20 | const matches = window.location.hash.match(/^#?([\d]{4}-[\d]{2}-[\d]{2})$/); 21 | if (!matches) return 22 | 23 | const match: string = matches.pop() as string 24 | try { 25 | return datetime.DateTime.fromString(match).toHTMLDateString() 26 | } catch(e) { 27 | console.debug('Date not detected in URL fragment') 28 | } 29 | } 30 | 31 | /** 32 | * Event Callback. 33 | * 34 | * Callbacks for custom events. 35 | */ 36 | export interface EventListener { (): void } 37 | 38 | /** 39 | * Event Manager. 40 | * 41 | * Handles registry and dispatch of events. 42 | */ 43 | export class EventManager { 44 | readonly listeners: { [index: string]: EventListener[] } 45 | 46 | constructor() { 47 | this.listeners = {} 48 | } 49 | 50 | public on(eventName: string, callback: EventListener): void { 51 | if (typeof this.listeners[eventName] === 'undefined') { 52 | this.listeners[eventName] = [] 53 | } 54 | 55 | this.listeners[eventName].push(callback) 56 | } 57 | 58 | public trigger(eventName: string): void { 59 | if (typeof this.listeners[eventName] === 'undefined') return 60 | 61 | for (const listener of this.listeners[eventName]) { 62 | listener() 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /assets.src/javascripts/t2r/widgets.ts: -------------------------------------------------------------------------------- 1 | import * as models from "./models.js" 2 | import * as datetime from "./datetime.js" 3 | import {RedmineAPIService} from "./services.js"; 4 | 5 | declare const T2R_REDMINE_API_KEY: string; 6 | 7 | // Redmine service. 8 | const redmineService = new RedmineAPIService(T2R_REDMINE_API_KEY) 9 | 10 | interface DropdownOption { 11 | id: number | string, 12 | name: string 13 | } 14 | 15 | interface DropdownOptionDictionary { 16 | [index:string]: string 17 | } 18 | 19 | function buildDropdownFromDictionary(data: { 20 | options: DropdownOptionDictionary, 21 | attributes?: { [index:string]: string } 22 | placeholder?: string 23 | }): JQuery { 24 | const $el = $('' 41 | + '' 42 | + '' 43 | + '' 46 | + projectLabel + '
' + issueLabel 47 | + '' 48 | + '' 49 | + '' 50 | + '' 51 | + '' 52 | + '' 53 | + '' 54 | + '' 55 | + '' 56 | + '' 57 | + ''; 58 | const $tr = $(markup); 59 | $tr.data('t2r.entry', data); 60 | let statusLabel = null; 61 | switch (data.status) { 62 | case 'pending': 63 | if (data.errors.length > 0) { 64 | $tr.addClass('t2r-error'); 65 | $tr.find(':input').attr('disabled', 'disabled'); 66 | statusLabel = renderImportStatusLabel('Invalid', data.errors.join("\n"), 'error'); 67 | } 68 | break; 69 | case 'imported': 70 | $tr.find('.cb-import').removeAttr('checked'); 71 | $tr.addClass('t2r-success'); 72 | $tr.find(':input').attr('disabled', 'disabled'); 73 | statusLabel = renderImportStatusLabel('Imported'); 74 | break; 75 | case 'running': 76 | $tr.addClass('t2r-running'); 77 | $tr.find(':input').attr('disabled', 'disabled'); 78 | statusLabel = renderImportStatusLabel('Running', 'The timer is still running on Toggl.', 'error'); 79 | break; 80 | default: 81 | throw `Unrecognized status: ${data.status}.`; 82 | } 83 | if (statusLabel) { 84 | $tr.find('td.status').html(statusLabel); 85 | } 86 | return $tr; 87 | } 88 | export function renderRedmineRow(data) { 89 | const issue = data.issue; 90 | const issueLabel = renderRedmineIssueLabel(issue); 91 | const project = data.project; 92 | const projectLabel = renderRedmineProjectLabel(project); 93 | const oDuration = data.duration; 94 | oDuration.roundTo(1, datetime.DurationRoundingMethod.Up); 95 | const markup = '' 96 | + '' 97 | + projectLabel + '
' + issueLabel 98 | + '' 99 | + '' 100 | + '' + utils.htmlEntityEncode(data.comments) + '' 101 | + '' + utils.htmlEntityEncode(data.activity.name) + '' 102 | + '' + oDuration.asHHMM() + '' 103 | + '' + T2R_BUTTON_ACTIONS + '' 104 | + ''; 105 | const $tr = $(markup); 106 | $tr.find('.js-contextmenu').on('click', contextMenuRightClick); 107 | return $tr; 108 | } 109 | export function renderImportStatusLabel(label, description = null, icon = 'checked') { 110 | const el = document.createElement('span'); 111 | el.innerHTML = label; 112 | el.dataset.t2rWidget = 'Tooltip'; 113 | el.classList.add('icon', `icon-${icon}`); 114 | if (description) { 115 | el.setAttribute('title', description); 116 | } 117 | return el; 118 | } 119 | //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmVuZGVyZXJzLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vYXNzZXRzLnNyYy9qYXZhc2NyaXB0cy90MnIvcmVuZGVyZXJzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUNBLE9BQU8sS0FBSyxLQUFLLE1BQU0sWUFBWSxDQUFBO0FBQ25DLE9BQU8sS0FBSyxRQUFRLE1BQU0sZUFBZSxDQUFBO0FBS3pDLE1BQU0sVUFBVSx5QkFBeUIsQ0FBQyxPQUF1QjtJQUMvRCxNQUFNLE9BQU8sR0FBRyxDQUFDLFNBQVMsQ0FBQyxDQUFDO0lBQzVCLElBQUksT0FBTyxDQUFDLE1BQU0sSUFBSSxDQUFDLEVBQUU7UUFDdkIsT0FBTyxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsQ0FBQTtLQUN2QjtJQUVELE9BQU8sV0FBVyxHQUFHLE9BQU8sQ0FBQyxJQUFJLEdBQUcsV0FBVyxHQUFHLE9BQU8sQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLEdBQUcsNEJBQTRCO1VBQzlGLEtBQUssQ0FBQyxnQkFBZ0IsQ0FBQyxPQUFPLENBQUMsSUFBSSxDQUFDO1VBQ3BDLGVBQWUsQ0FBQztBQUN0QixDQUFDO0FBRUQsTUFBTSxVQUFVLDZCQUE2QjtJQUMzQyxPQUFPLGlEQUFpRCxDQUFBO0FBQzFELENBQUM7QUFFRCxNQUFNLFVBQVUsdUJBQXVCLENBQUMsS0FBbUI7SUFDekQsTUFBTSxPQUFPLEdBQUcsQ0FBQyxPQUFPLENBQUMsQ0FBQTtJQUN6QixJQUFJLEtBQUssQ0FBQyxTQUFTLEVBQUU7UUFDbkIsT0FBTyxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsQ0FBQTtLQUN2QjtJQUVELE9BQU8sV0FBVyxHQUFHLEtBQUssQ0FBQyxJQUFJLEdBQUcsV0FBVyxHQUFHLE9BQU8sQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLEdBQUcsb0JBQW9CO1VBQ3BGLEtBQUssQ0FBQyxnQkFBZ0IsQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxPQUFPLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQyxHQUFHLENBQUM7VUFDeEQsS0FBSyxDQUFDLGdCQUFnQixDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUMsSUFBSSxHQUFHLEtBQUssQ0FBQyxFQUFFLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQztVQUNwRCxLQUFLLENBQUMsZ0JBQWdCLENBQUMsS0FBSyxDQUFDLE9BQU8sQ0FBQyxDQUFDLENBQUMsSUFBSSxHQUFHLEtBQUssQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQztVQUNqRSxNQUFNLENBQUE7QUFDWixDQUFDO0FBRUQsTUFBTSxVQUFVLDJCQUEyQixDQUFDLE9BQXNCO0lBQ2hFLElBQUksQ0FBQyxPQUFPLEVBQUU7UUFDWixPQUFPLFNBQVMsQ0FBQTtLQUNqQjtJQUVELE9BQU8sR0FBRyxHQUFHLE9BQU8sQ0FBQyxRQUFRLEVBQUUsR0FBRyxLQUFLLENBQUE7QUFDekMsQ0FBQztBQUVELE1BQU0sVUFBVSxjQUFjLENBQUMsSUFBMkI7SUFDeEQsTUFBTSxLQUFLLEdBQUcsSUFBSSxDQUFDLEtBQUssQ0FBQTtJQUN4QixNQUFNLFVBQVUsR0FBVyxLQUFLLENBQUMsQ0FBQyxDQUFDLHVCQUF1QixDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUMsQ0FBQywyQkFBMkIsQ0FBQyxJQUFJLENBQUMsUUFBUSxDQUFDLENBQUE7SUFDOUcsTUFBTSxPQUFPLEdBQUcsSUFBSSxDQUFDLE9BQU8sSUFBSSxJQUFJLENBQUE7SUFDcEMsTUFBTSxZQUFZLEdBQUcsT0FBTyxDQUFDLENBQUMsQ0FBQyx5QkFBeUIsQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDLENBQUMsNkJBQTZCLEVBQUUsQ0FBQTtJQUNuRyxNQUFNLFNBQVMsR0FBRyxJQUFJLENBQUMsUUFBNkIsQ0FBQTtJQUNwRCxNQUFNLFNBQVMsR0FBRyxJQUFJLENBQUMsZUFBb0MsQ0FBQTtJQUUzRCxNQUFNLE1BQU0sR0FBRyxpQ0FBaUM7VUFDNUMsMElBQTBJO1VBQzFJLDBCQUEwQjtVQUMxQixvQkFBb0I7VUFDcEIsZ0RBQWdEO1VBQ2hELGNBQWMsR0FBRyxLQUFLLENBQUMsZ0JBQWdCLENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsRUFBRSxDQUFDLFFBQVEsRUFBRSxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUMsR0FBRyxJQUFJO1VBQ2hGLFNBQVMsR0FBRyxLQUFLLENBQUMsZ0JBQWdCLENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsRUFBRSxDQUFDLFFBQVEsRUFBRSxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUMsR0FBRyxNQUFNO1VBQzdFLFlBQVksR0FBRyxRQUFRLEdBQUcsVUFBVTtVQUNwQyxPQUFPO1VBQ1AsdUJBQXVCO1VBQ3ZCLHFEQUFxRCxHQUFHLEtBQUssQ0FBQyxnQkFBZ0IsQ0FBQyxJQUFJLENBQUMsUUFBUSxDQUFDLEdBQUcsc0JBQXNCO1VBQ3RILE9BQU87VUFDUCx1QkFBdUI7VUFDdkIsa0lBQWtJO1VBQ2xJLE9BQU87VUFDUCxvQkFBb0I7VUFDcEIsMkhBQTJILEdBQUcsU0FBUyxDQUFDLE1BQU0sRUFBRSxHQUFHLFlBQVksR0FBRyxTQUFTLENBQUMsTUFBTSxFQUFFLEdBQUcsNkJBQTZCO1VBQ3BOLE9BQU87VUFDUCxPQUFPLENBQUM7SUFHWixNQUFNLEdBQUcsR0FBRyxDQUFDLENBQUMsTUFBTSxDQUFDLENBQUM7SUFDdEIsR0FBRyxDQUFDLElBQUksQ0FBQyxXQUFXLEVBQUUsSUFBSSxDQUFDLENBQUM7SUFFNUIsSUFBSSxXQUFXLEdBQXVCLElBQUksQ0FBQTtJQUUxQyxRQUFRLElBQUksQ0FBQyxNQUFNLEVBQUU7UUFDbkIsS0FBSyxTQUFTO1lBQ1osSUFBSSxJQUFJLENBQUMsTUFBTSxDQUFDLE1BQU0sR0FBRyxDQUFDLEVBQUU7Z0JBQzFCLEdBQUcsQ0FBQyxRQUFRLENBQUMsV0FBVyxDQUFDLENBQUM7Z0JBQzFCLEdBQUcsQ0FBQyxJQUFJLENBQUMsUUFBUSxDQUFDLENBQUMsSUFBSSxDQUFDLFVBQVUsRUFBRSxVQUFVLENBQUMsQ0FBQztnQkFDaEQsV0FBVyxHQUFHLHVCQUF1QixDQUFDLFNBQVMsRUFBRSxJQUFJLENBQUMsTUFBTSxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsRUFBRSxPQUFPLENBQUMsQ0FBQTthQUNsRjtZQUNELE1BQU07UUFFUixLQUFLLFVBQVU7WUFDYixHQUFHLENBQUMsSUFBSSxDQUFDLFlBQVksQ0FBQyxDQUFDLFVBQVUsQ0FBQyxTQUFTLENBQUMsQ0FBQztZQUM3QyxHQUFHLENBQUMsUUFBUSxDQUFDLGFBQWEsQ0FBQyxDQUFDO1lBQzVCLEdBQUcsQ0FBQyxJQUFJLENBQUMsUUFBUSxDQUFDLENBQUMsSUFBSSxDQUFDLFVBQVUsRUFBRSxVQUFVLENBQUMsQ0FBQztZQUNoRCxXQUFXLEdBQUcsdUJBQXVCLENBQUMsVUFBVSxDQUFDLENBQUE7WUFDakQsTUFBTTtRQUVSLEtBQUssU0FBUztZQUNaLEdBQUcsQ0FBQyxRQUFRLENBQUMsYUFBYSxDQUFDLENBQUM7WUFDNUIsR0FBRyxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsQ0FBQyxJQUFJLENBQUMsVUFBVSxFQUFFLFVBQVUsQ0FBQyxDQUFDO1lBQ2hELFdBQVcsR0FBRyx1QkFBdUIsQ0FDbkMsU0FBUyxFQUNULHNDQUFzQyxFQUN0QyxPQUFPLENBQ1IsQ0FBQTtZQUNELE1BQU07UUFFUjtZQUNFLE1BQU0sd0JBQXdCLElBQUksQ0FBQyxNQUFNLEdBQUcsQ0FBQTtLQUMvQztJQUVELElBQUksV0FBVyxFQUFFO1FBQ2YsR0FBRyxDQUFDLElBQUksQ0FBQyxXQUFXLENBQUMsQ0FBQyxJQUFJLENBQUMsV0FBVyxDQUFDLENBQUE7S0FDeEM7SUFFRCxPQUFPLEdBQUcsQ0FBQztBQUNiLENBQUM7QUFFRCxNQUFNLFVBQVUsZ0JBQWdCLENBQUMsSUFBc0I7SUFDckQsTUFBTSxLQUFLLEdBQUcsSUFBSSxDQUFDLEtBQUssQ0FBQTtJQUN4QixNQUFNLFVBQVUsR0FBRyx1QkFBdUIsQ0FBQyxLQUFLLENBQUMsQ0FBQTtJQUNqRCxNQUFNLE9BQU8sR0FBRyxJQUFJLENBQUMsT0FBTyxDQUFBO0lBQzVCLE1BQU0sWUFBWSxHQUFHLHlCQUF5QixDQUFDLE9BQU8sQ0FBQyxDQUFBO0lBQ3ZELE1BQU0sU0FBUyxHQUFHLElBQUksQ0FBQyxRQUFRLENBQUE7SUFDL0IsU0FBUyxDQUFDLE9BQU8sQ0FBQyxDQUFDLEVBQUUsUUFBUSxDQUFDLHNCQUFzQixDQUFDLEVBQUUsQ0FBQyxDQUFBO0lBRXhELE1BQU0sTUFBTSxHQUFHLHFCQUFxQixHQUFHLElBQUksQ0FBQyxFQUFFLEdBQUcsdUNBQXVDO1VBQ3BGLHNCQUFzQjtVQUN0QixZQUFZLEdBQUcsUUFBUSxHQUFHLFVBQVU7VUFDcEMsNkNBQTZDLEdBQUcsSUFBSSxDQUFDLEVBQUUsR0FBRyxhQUFhO1VBQ3ZFLE9BQU87VUFDUCx1QkFBdUIsR0FBRyxLQUFLLENBQUMsZ0JBQWdCLENBQUMsSUFBSSxDQUFDLFFBQVEsQ0FBQyxHQUFHLE9BQU87VUFDekUsdUJBQXVCLEdBQUcsS0FBSyxDQUFDLGdCQUFnQixDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsSUFBSSxDQUFDLEdBQUcsT0FBTztVQUM5RSxvQkFBb0IsR0FBRyxTQUFTLENBQUMsTUFBTSxFQUFFLEdBQUcsT0FBTztVQUNuRCxzQkFBc0IsR0FBRyxrQkFBa0IsR0FBRyxPQUFPO1VBQ3JELE9BQU8sQ0FBQztJQUVaLE1BQU0sR0FBRyxHQUFHLENBQUMsQ0FBQyxNQUFNLENBQUMsQ0FBQTtJQUNyQixHQUFHLENBQUMsSUFBSSxDQUFDLGlCQUFpQixDQUFDLENBQUMsRUFBRSxDQUFDLE9BQU8sRUFBRSxxQkFBcUIsQ0FBQyxDQUFBO0lBRTlELE9BQU8sR0FBRyxDQUFBO0FBQ1osQ0FBQztBQWVELE1BQU0sVUFBVSx1QkFBdUIsQ0FDckMsS0FBYSxFQUNiLGNBQTZCLElBQUksRUFDakMsSUFBSSxHQUFHLFNBQVM7SUFFaEIsTUFBTSxFQUFFLEdBQUcsUUFBUSxDQUFDLGFBQWEsQ0FBQyxNQUFNLENBQUMsQ0FBQTtJQUV6QyxFQUFFLENBQUMsU0FBUyxHQUFHLEtBQUssQ0FBQTtJQUNwQixFQUFFLENBQUMsT0FBTyxDQUFDLFNBQVMsR0FBRyxTQUFTLENBQUE7SUFDaEMsRUFBRSxDQUFDLFNBQVMsQ0FBQyxHQUFHLENBQUMsTUFBTSxFQUFFLFFBQVEsSUFBSSxFQUFFLENBQUMsQ0FBQTtJQUN4QyxJQUFJLFdBQVcsRUFBRTtRQUNmLEVBQUUsQ0FBQyxZQUFZLENBQUMsT0FBTyxFQUFFLFdBQVcsQ0FBQyxDQUFBO0tBQ3RDO0lBRUQsT0FBTyxFQUFFLENBQUE7QUFDWCxDQUFDIn0= -------------------------------------------------------------------------------- /assets/javascripts/t2r/request.js: -------------------------------------------------------------------------------- 1 | export class RequestQueue { 2 | constructor() { 3 | this._items = []; 4 | this._requestInProgress = false; 5 | } 6 | get length() { 7 | return this._items.length; 8 | } 9 | addItem(opts) { 10 | this._items.push(opts); 11 | this.processItem(); 12 | } 13 | processItem() { 14 | if (this.length === 0 || this._requestInProgress) 15 | return; 16 | this._requestInProgress = true; 17 | const that = this; 18 | const opts = this._items.shift(); 19 | if (opts === undefined) { 20 | return; 21 | } 22 | console.debug('Processing AJAX queue (' + this.length + ' remaining).', opts); 23 | const originalCallback = opts.complete; 24 | opts.complete = function (xhr, status) { 25 | if (typeof originalCallback !== 'undefined') { 26 | (originalCallback).call(this, xhr, status); 27 | } 28 | that._requestInProgress = false; 29 | that.processItem(); 30 | }; 31 | $.ajax(opts); 32 | } 33 | } 34 | //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmVxdWVzdC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uL2Fzc2V0cy5zcmMvamF2YXNjcmlwdHMvdDJyL3JlcXVlc3QudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBS0EsTUFBTSxPQUFPLFlBQVk7SUFjdkI7UUFDRSxJQUFJLENBQUMsTUFBTSxHQUFHLEVBQUUsQ0FBQTtRQUNoQixJQUFJLENBQUMsa0JBQWtCLEdBQUcsS0FBSyxDQUFBO0lBQ2pDLENBQUM7SUFLRCxJQUFJLE1BQU07UUFDUixPQUFPLElBQUksQ0FBQyxNQUFNLENBQUMsTUFBTSxDQUFBO0lBQzNCLENBQUM7SUFRRCxPQUFPLENBQUMsSUFBeUI7UUFDL0IsSUFBSSxDQUFDLE1BQU0sQ0FBQyxJQUFJLENBQUMsSUFBSSxDQUFDLENBQUE7UUFDdEIsSUFBSSxDQUFDLFdBQVcsRUFBRSxDQUFBO0lBQ3BCLENBQUM7SUFLRCxXQUFXO1FBQ1QsSUFBSSxJQUFJLENBQUMsTUFBTSxLQUFLLENBQUMsSUFBSSxJQUFJLENBQUMsa0JBQWtCO1lBQUUsT0FBTTtRQUN4RCxJQUFJLENBQUMsa0JBQWtCLEdBQUcsSUFBSSxDQUFDO1FBRS9CLE1BQU0sSUFBSSxHQUFHLElBQUksQ0FBQTtRQUNqQixNQUFNLElBQUksR0FBRyxJQUFJLENBQUMsTUFBTSxDQUFDLEtBQUssRUFBRSxDQUFBO1FBQ2hDLElBQUksSUFBSSxLQUFLLFNBQVMsRUFBRTtZQUN0QixPQUFNO1NBQ1A7UUFFRCxPQUFPLENBQUMsS0FBSyxDQUFDLHlCQUF5QixHQUFHLElBQUksQ0FBQyxNQUFNLEdBQUcsY0FBYyxFQUFFLElBQUksQ0FBQyxDQUFBO1FBRTdFLE1BQU0sZ0JBQWdCLEdBQUcsSUFBSSxDQUFDLFFBQWlELENBQUE7UUFDL0UsSUFBSSxDQUFDLFFBQVEsR0FBRyxVQUFVLEdBQWlCLEVBQUUsTUFBOEI7WUFDekUsSUFBSSxPQUFPLGdCQUFnQixLQUFLLFdBQVcsRUFBRTtnQkFDM0MsQ0FBQyxnQkFBZ0IsQ0FBQyxDQUFDLElBQUksQ0FBQyxJQUFJLEVBQUUsR0FBRyxFQUFFLE1BQU0sQ0FBQyxDQUFBO2FBQzNDO1lBR0QsSUFBSSxDQUFDLGtCQUFrQixHQUFHLEtBQUssQ0FBQTtZQUMvQixJQUFJLENBQUMsV0FBVyxFQUFFLENBQUE7UUFDcEIsQ0FBQyxDQUFDO1FBR0YsQ0FBQyxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsQ0FBQztJQUNmLENBQUM7Q0FFRiJ9 -------------------------------------------------------------------------------- /assets/javascripts/t2r/storage.js: -------------------------------------------------------------------------------- 1 | export class LocalStorage { 2 | constructor(prefix) { 3 | this._prefix = prefix; 4 | if (typeof window['localStorage'] === 'undefined') { 5 | throw new Error("Missing browser feature: localStorage"); 6 | } 7 | } 8 | get prefix() { 9 | return this._prefix; 10 | } 11 | get(key, fallback = undefined) { 12 | const value = window.localStorage.getItem(this.prefix + key); 13 | if (value !== null) { 14 | return value; 15 | } 16 | return fallback; 17 | } 18 | set(key, value) { 19 | if (value === null || typeof value === 'undefined') { 20 | return this.delete(key); 21 | } 22 | try { 23 | window.localStorage.setItem(this.prefix + key, value.toString()); 24 | return value; 25 | } 26 | catch (e) { 27 | console.error('Value not representable as string', value); 28 | throw 'Value could not be stored'; 29 | } 30 | } 31 | delete(key) { 32 | const value = this.get(key); 33 | window.localStorage.removeItem(this.prefix + key); 34 | return value; 35 | } 36 | } 37 | export class TemporaryStorage { 38 | constructor() { 39 | this.data = {}; 40 | } 41 | get(key, fallback = undefined) { 42 | if (typeof this.data[key] !== 'undefined') { 43 | return this.data[key]; 44 | } 45 | return fallback; 46 | } 47 | set(key, value) { 48 | if (value === null || typeof value === 'undefined') { 49 | this.delete(key); 50 | return value; 51 | } 52 | this.data[key] = value; 53 | return this.data[key]; 54 | } 55 | delete(key) { 56 | const value = this.get(key); 57 | if (key in this.data) { 58 | delete this.data[key]; 59 | } 60 | return value; 61 | } 62 | } 63 | //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic3RvcmFnZS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uL2Fzc2V0cy5zcmMvamF2YXNjcmlwdHMvdDJyL3N0b3JhZ2UudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBS0EsTUFBTSxPQUFPLFlBQVk7SUFHdkIsWUFBWSxNQUFjO1FBQ3hCLElBQUksQ0FBQyxPQUFPLEdBQUcsTUFBTSxDQUFBO1FBRXJCLElBQUksT0FBTyxNQUFNLENBQUMsY0FBYyxDQUFDLEtBQUssV0FBVyxFQUFFO1lBQ2pELE1BQU0sSUFBSSxLQUFLLENBQUMsdUNBQXVDLENBQUMsQ0FBQztTQUMxRDtJQUNILENBQUM7SUFFRCxJQUFJLE1BQU07UUFDUixPQUFPLElBQUksQ0FBQyxPQUFPLENBQUE7SUFDckIsQ0FBQztJQUVELEdBQUcsQ0FBTyxHQUFXLEVBQUUsV0FBNkIsU0FBUztRQUMzRCxNQUFNLEtBQUssR0FBRyxNQUFNLENBQUMsWUFBWSxDQUFDLE9BQU8sQ0FBQyxJQUFJLENBQUMsTUFBTSxHQUFHLEdBQUcsQ0FBQyxDQUFBO1FBQzVELElBQUksS0FBSyxLQUFLLElBQUksRUFBRTtZQUNsQixPQUFPLEtBQUssQ0FBQTtTQUNiO1FBRUQsT0FBTyxRQUFRLENBQUE7SUFDakIsQ0FBQztJQUVELEdBQUcsQ0FBTyxHQUFXLEVBQUUsS0FBVztRQUNoQyxJQUFJLEtBQUssS0FBSyxJQUFJLElBQUksT0FBTyxLQUFLLEtBQUssV0FBVyxFQUFFO1lBQ2xELE9BQU8sSUFBSSxDQUFDLE1BQU0sQ0FBQyxHQUFHLENBQUMsQ0FBQTtTQUN4QjtRQUVELElBQUk7WUFDRixNQUFNLENBQUMsWUFBWSxDQUFDLE9BQU8sQ0FBQyxJQUFJLENBQUMsTUFBTSxHQUFHLEdBQUcsRUFBRyxLQUFhLENBQUMsUUFBUSxFQUFFLENBQUMsQ0FBQTtZQUN6RSxPQUFPLEtBQUssQ0FBQztTQUNkO1FBQUMsT0FBTSxDQUFDLEVBQUU7WUFDVCxPQUFPLENBQUMsS0FBSyxDQUFDLG1DQUFtQyxFQUFFLEtBQUssQ0FBQyxDQUFBO1lBQ3pELE1BQU0sMkJBQTJCLENBQUE7U0FDbEM7SUFDSCxDQUFDO0lBRUQsTUFBTSxDQUFDLEdBQVc7UUFDaEIsTUFBTSxLQUFLLEdBQUcsSUFBSSxDQUFDLEdBQUcsQ0FBQyxHQUFHLENBQUMsQ0FBQTtRQUMzQixNQUFNLENBQUMsWUFBWSxDQUFDLFVBQVUsQ0FBQyxJQUFJLENBQUMsTUFBTSxHQUFHLEdBQUcsQ0FBQyxDQUFBO1FBQ2pELE9BQU8sS0FBSyxDQUFBO0lBQ2QsQ0FBQztDQUNGO0FBT0QsTUFBTSxPQUFPLGdCQUFnQjtJQUUzQjtRQUNFLElBQUksQ0FBQyxJQUFJLEdBQUcsRUFBRSxDQUFBO0lBQ2hCLENBQUM7SUFFRCxHQUFHLENBQUMsR0FBVyxFQUFFLFdBQWdCLFNBQVM7UUFDeEMsSUFBSSxPQUFPLElBQUksQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLEtBQUssV0FBVyxFQUFFO1lBQ3pDLE9BQU8sSUFBSSxDQUFDLElBQUksQ0FBQyxHQUFHLENBQUMsQ0FBQTtTQUN0QjtRQUVELE9BQU8sUUFBUSxDQUFBO0lBQ2pCLENBQUM7SUFFRCxHQUFHLENBQU8sR0FBVyxFQUFFLEtBQVc7UUFDaEMsSUFBSSxLQUFLLEtBQUssSUFBSSxJQUFJLE9BQU8sS0FBSyxLQUFLLFdBQVcsRUFBRTtZQUNsRCxJQUFJLENBQUMsTUFBTSxDQUFDLEdBQUcsQ0FBQyxDQUFBO1lBQ2hCLE9BQU8sS0FBSyxDQUFBO1NBQ2I7UUFFRCxJQUFJLENBQUMsSUFBSSxDQUFDLEdBQUcsQ0FBQyxHQUFHLEtBQUssQ0FBQTtRQUN0QixPQUFPLElBQUksQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLENBQUE7SUFDdkIsQ0FBQztJQUVELE1BQU0sQ0FBQyxHQUFXO1FBQ2hCLE1BQU0sS0FBSyxHQUFHLElBQUksQ0FBQyxHQUFHLENBQUMsR0FBRyxDQUFDLENBQUE7UUFFM0IsSUFBSSxHQUFHLElBQUksSUFBSSxDQUFDLElBQUksRUFBRTtZQUNwQixPQUFPLElBQUksQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLENBQUE7U0FDdEI7UUFFRCxPQUFPLEtBQUssQ0FBQTtJQUNkLENBQUM7Q0FDRiJ9 -------------------------------------------------------------------------------- /assets/javascripts/t2r/utils.js: -------------------------------------------------------------------------------- 1 | import * as datetime from "./datetime.js"; 2 | export function htmlEntityEncode(str) { 3 | return $('
') 4 | .text(str) 5 | .text() 6 | .replace(/"/g, '"') 7 | .replace(/'/g, ''') 8 | .replace(//g, '>'); 10 | } 11 | export function getDateFromLocationHash() { 12 | const matches = window.location.hash.match(/^#?([\d]{4}-[\d]{2}-[\d]{2})$/); 13 | if (!matches) 14 | return; 15 | const match = matches.pop(); 16 | try { 17 | return datetime.DateTime.fromString(match).toHTMLDateString(); 18 | } 19 | catch (e) { 20 | console.debug('Date not detected in URL fragment'); 21 | } 22 | } 23 | export class EventManager { 24 | constructor() { 25 | this.listeners = {}; 26 | } 27 | on(eventName, callback) { 28 | if (typeof this.listeners[eventName] === 'undefined') { 29 | this.listeners[eventName] = []; 30 | } 31 | this.listeners[eventName].push(callback); 32 | } 33 | trigger(eventName) { 34 | if (typeof this.listeners[eventName] === 'undefined') 35 | return; 36 | for (const listener of this.listeners[eventName]) { 37 | listener(); 38 | } 39 | } 40 | } 41 | //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidXRpbHMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi9hc3NldHMuc3JjL2phdmFzY3JpcHRzL3Qyci91dGlscy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEtBQUssUUFBUSxNQUFNLGVBQWUsQ0FBQTtBQUt6QyxNQUFNLFVBQVUsZ0JBQWdCLENBQUMsR0FBVztJQUMxQyxPQUFPLENBQUMsQ0FBQyxTQUFTLENBQUM7U0FDaEIsSUFBSSxDQUFDLEdBQUcsQ0FBQztTQUNULElBQUksRUFBRTtTQUNOLE9BQU8sQ0FBQyxJQUFJLEVBQUUsUUFBUSxDQUFDO1NBQ3ZCLE9BQU8sQ0FBQyxJQUFJLEVBQUUsUUFBUSxDQUFDO1NBQ3ZCLE9BQU8sQ0FBQyxJQUFJLEVBQUUsTUFBTSxDQUFDO1NBQ3JCLE9BQU8sQ0FBQyxJQUFJLEVBQUUsTUFBTSxDQUFDLENBQUM7QUFDM0IsQ0FBQztBQUtELE1BQU0sVUFBVSx1QkFBdUI7SUFDckMsTUFBTSxPQUFPLEdBQUcsTUFBTSxDQUFDLFFBQVEsQ0FBQyxJQUFJLENBQUMsS0FBSyxDQUFDLCtCQUErQixDQUFDLENBQUM7SUFDNUUsSUFBSSxDQUFDLE9BQU87UUFBRSxPQUFNO0lBRXBCLE1BQU0sS0FBSyxHQUFXLE9BQU8sQ0FBQyxHQUFHLEVBQVksQ0FBQTtJQUM3QyxJQUFJO1FBQ0YsT0FBTyxRQUFRLENBQUMsUUFBUSxDQUFDLFVBQVUsQ0FBQyxLQUFLLENBQUMsQ0FBQyxnQkFBZ0IsRUFBRSxDQUFBO0tBQzlEO0lBQUMsT0FBTSxDQUFDLEVBQUU7UUFDVCxPQUFPLENBQUMsS0FBSyxDQUFDLG1DQUFtQyxDQUFDLENBQUE7S0FDbkQ7QUFDSCxDQUFDO0FBY0QsTUFBTSxPQUFPLFlBQVk7SUFHdkI7UUFDRSxJQUFJLENBQUMsU0FBUyxHQUFHLEVBQUUsQ0FBQTtJQUNyQixDQUFDO0lBRU0sRUFBRSxDQUFDLFNBQWlCLEVBQUUsUUFBdUI7UUFDbEQsSUFBSSxPQUFPLElBQUksQ0FBQyxTQUFTLENBQUMsU0FBUyxDQUFDLEtBQUssV0FBVyxFQUFFO1lBQ3BELElBQUksQ0FBQyxTQUFTLENBQUMsU0FBUyxDQUFDLEdBQUcsRUFBRSxDQUFBO1NBQy9CO1FBRUQsSUFBSSxDQUFDLFNBQVMsQ0FBQyxTQUFTLENBQUMsQ0FBQyxJQUFJLENBQUMsUUFBUSxDQUFDLENBQUE7SUFDMUMsQ0FBQztJQUVNLE9BQU8sQ0FBQyxTQUFpQjtRQUM5QixJQUFJLE9BQU8sSUFBSSxDQUFDLFNBQVMsQ0FBQyxTQUFTLENBQUMsS0FBSyxXQUFXO1lBQUUsT0FBTTtRQUU1RCxLQUFLLE1BQU0sUUFBUSxJQUFJLElBQUksQ0FBQyxTQUFTLENBQUMsU0FBUyxDQUFDLEVBQUU7WUFDaEQsUUFBUSxFQUFFLENBQUE7U0FDWDtJQUNILENBQUM7Q0FDRiJ9 -------------------------------------------------------------------------------- /assets/stylesheets/t2r.css: -------------------------------------------------------------------------------- 1 | /* UI States */ 2 | .t2r-loading { 3 | background-image: url("../images/loader.gif"); 4 | background-repeat: no-repeat; 5 | background-position: center center; 6 | min-height: 15px; 7 | min-width: 50px; 8 | } 9 | .t2r-loading * { 10 | display: none; 11 | } 12 | .t2r-error { 13 | background-color: #E6BBBA; 14 | } 15 | .t2r-success { 16 | background-color: #C6EB86; 17 | } 18 | 19 | /* Forms */ 20 | .t2r-form { 21 | display: block; 22 | margin-bottom: 2em; 23 | } 24 | 25 | /* Tables */ 26 | .t2r-table { 27 | margin-bottom: 2em !important; 28 | } 29 | .t2r-table th .t2r-widget-Tooltip { 30 | cursor: help; 31 | } 32 | .t2r-table th, 33 | .t2r-table td { 34 | text-align: left !important; 35 | } 36 | .t2r-table th.checkbox, 37 | .t2r-table td.checkbox { 38 | text-align: center !important; 39 | } 40 | .t2r-table td.activity { 41 | width: 150px; 42 | } 43 | .t2r-table td.comments { 44 | width: 30%; 45 | } 46 | .t2r-table th.status, 47 | .t2r-table td.status { 48 | text-align: center !important; 49 | width: 30px; 50 | } 51 | .t2r-table td.status span[title] { 52 | cursor: help; 53 | } 54 | .t2r-table th.hours, 55 | .t2r-table td.hours { 56 | width: 50px; 57 | } 58 | .t2r-table td.buttons { 59 | text-align: right !important; 60 | } 61 | .t2r-table td input, 62 | .t2r-table td select { 63 | max-width: 100%; 64 | } 65 | .t2r-table td.comments input { 66 | width: 100%; 67 | } 68 | .t2r-table tfoot { 69 | border-top: 1px solid #e4e4e4; 70 | } 71 | 72 | /* Miscellaneous */ 73 | #last-imported.t2r-loading { 74 | display: inline-block; 75 | } 76 | span.project { 77 | font-weight: bold; 78 | } 79 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | activerecord: 3 | models: 4 | toggl_mapping: 'Toggl mapping' 5 | attributes: 6 | toggl_mapping: 7 | id: 'ID' 8 | toggl_id: 'Toggl ID' 9 | time_entry_id: 'Time entry ID' 10 | t2r: 11 | button_import_to_redmine: 'Import to Redmine' 12 | caption_redmine_report: 'Time logged on Redmine' 13 | caption_toggl_report: 'Time logged on Toggl' 14 | label_default_activity: 'Default activity' 15 | label_basic_options: 'Basic options' 16 | label_advanced_options: 'Advanced options' 17 | label_toggl_workspace: 'Toggl workspace' 18 | label_duration_rounding: 'Duration rounding' 19 | label_hour_plural: 'Hours' 20 | label_last_imported: 'Last imported on %{date}' 21 | label_status: 'Status' 22 | text_add_toggl_api_key: 'To use Toggl 2 Redmine, please add a Toggl API Token to your account.' 23 | text_db_transaction_warning: 'Your database does not support transactions. Please ask your system administrator to refer to the README for Toggl 2 Redmine.' 24 | tooltip_date_filter: 'Choose the date for which you want to import time entries.' 25 | tooltip_duration_rounding_logic: 'Choose a logic for rounding Toggl durations.' 26 | tooltip_duration_rounding_value: 'All time entries from Toggl will be rounded to this number (minutes).' 27 | tooltip_hours_column_header: "1:50 means 1 hour 50 minutes. Press 'Up' or 'Down' to round the nearest 5 minutes. Combine with 'Shift' to round to the nearest 15 minutes." 28 | tooltip_status_column_header: "Put your mouse on the icon for a row to see its status. Example: Imported, Failed, Error." 29 | tooltip_help: 'Confused? Watch a short video tutorial.' 30 | redmine_report_footer: 'Time already logged' 31 | toggl_report_footer: 'Time to be imported' 32 | import_confirmation: 'This action cannot be undone. Do you really want to continue?' 33 | error: 34 | ajax_load: 'Could not retrieve data from server. Please refresh the page and try again.' 35 | list_empty: 'There are no items to display here.' 36 | project_closed: 'The project is closed.' 37 | issue_id_missing: 'Please mention the Redmine issue ID in your Toggl task description. Example: #1919 Feed the bunny wabbit.' 38 | issue_not_found: "This issue was either not found on Redmine or you don't have access to it. Make sure you're using a correct issue ID and that you're a member of the project." 39 | no_entries_selected: 'Please select the entries that you want to import to Redmine and try again.' 40 | date_invalid: 'Please enter a valid date.' 41 | -------------------------------------------------------------------------------- /config/locales/es.yml: -------------------------------------------------------------------------------- 1 | es: 2 | activerecord: 3 | attributes: 4 | toggl_mapping: 5 | id: 'ID' 6 | toggl_id: 'Toggl ID' 7 | time_entry_id: 'ID del registro de tiempo' 8 | t2r: 9 | button_import_to_redmine: 'Importar a Redmine' 10 | caption_redmine_report: 'Tiempo registrado en Redmine' 11 | caption_toggl_report: 'Tiempo registrado en Toggl' 12 | label_default_activity: 'Actividad predeterminada' 13 | label_basic_options: 'Opciones basicas' 14 | label_advanced_options: 'Opciones avanzadas' 15 | label_toggl_workspace: 'Workspace de Toggl' 16 | label_duration_rounding: 'Redondeo de duraciones' 17 | label_hour_plural: 'Horas' 18 | label_status: 'Estado' 19 | label_last_imported: 'Última importación el %{date}' 20 | text_add_toggl_api_key: 'Para usar Toggl 2 Redmine, agregue su clave de API de Toggl a su cuenta.' 21 | text_db_transaction_warning: 'Su base de datos no permite transacciones. Pídale al administrador del sistema que consulte el archivo README de Toggl 2 Redmine.' 22 | tooltip_date_filter: 'Eliga la fecha por la que desea importar registros de tiempo.' 23 | tooltip_duration_rounding_logic: 'Elija la lógica para el redondeo de duraciones.' 24 | tooltip_duration_rounding_value: 'Todos los registros de tiempo de Toggl se redondearán a este número (minutos).' 25 | tooltip_hours_column_header: "1:50 quiere decir 1 hora y 50 minutos. Presione 'Arriba' o 'Abajo' para redondear a los 5 minutos cercanos. Combine con 'Shift' para redondear a los 15 minutos más cercanos." 26 | tooltip_help: '¿Está confundido? Vea un breve tutorial.' 27 | redmine_report_footer: 'Tiempo ya registrado' 28 | toggl_report_footer: 'Tiempo a importar' 29 | error: 30 | ajax_load: 'No se pudieron leer los datos del servidor. Actualice la página para volver a intentarlo.' 31 | list_empty: 'No hay elementos para mostrar aquí.' 32 | project_closed: 'El proyecto está cerrado.' 33 | issue_id_missing: 'Mencione el ID de la petición de Redmine en la descripción de la tarea de Toggl. Ejemplo: #1919 Alimentar al bunny wabbit.' 34 | issue_not_found: "O esta petición no se encontró en Redmine o no tienes acceso a ella. Asegúrese de estar utilizando la ID de petición correcta y de ser miembro del proyecto." 35 | no_entries_selected: 'Seleccione las entradas que desea importar a Redmine y vuelva a intentarlo.' 36 | date_invalid: 'Por favor introduzca una fecha valida.' 37 | -------------------------------------------------------------------------------- /config/locales/fr.yml: -------------------------------------------------------------------------------- 1 | fr: 2 | activerecord: 3 | models: 4 | toggl_mapping: 'Toggl mapping' 5 | attributes: 6 | toggl_mapping: 7 | id: 'ID' 8 | toggl_id: 'ID de Toggl' 9 | time_entry_id: 'ID de temps passé' 10 | t2r: 11 | button_import_to_redmine: 'Importer vers Redmine' 12 | caption_redmine_report: 'Temps passé sur Redmine' 13 | caption_toggl_report: 'Temps passé sur Toggl' 14 | label_default_activity: 'Activité par défaut' 15 | label_basic_options: 'Options de base' 16 | label_advanced_options: 'Options avancées' 17 | label_toggl_workspace: 'Toggl workspace' 18 | label_duration_rounding: 'Arrondissement de durée' 19 | label_hour_plural: 'Heures' 20 | label_last_imported: 'Dernière importation le %{date}' 21 | label_status: 'État' 22 | text_add_toggl_api_key: 'Pour utiliser Toggl 2 Redmine, veuillez ajouter un jeton API Toggl à votre compte.' 23 | text_db_transaction_warning: "Votre base de données ne permet pas les transactions. Veuillez demander à votre administrateur système de lire le README de Toggl 2 Redmine." 24 | tooltip_date_filter: 'Choisissez la date pour laquelle vous souhaitez importer les entrées de temps.' 25 | tooltip_duration_rounding_logic: 'Choisissez une logique pour arrondir les durées de Toggl.' 26 | tooltip_duration_rounding_value: 'Toutes les entrées de temps de Toggl seront arrondies à ce nombre (minutes).' 27 | tooltip_hours_column_header: "1:50 signifie 1 heure 50 minutes. Appuyez sur 'Up' ou 'Down' pour arrondir aux 5 minutes les plus proches. Combinez avec 'Shift' pour arrondir aux 15 minutes les plus proches." 28 | tooltip_status_column_header: "Placez votre souris sur l'icône d'une ligne pour voir son statut. Exemple : Importation, Échec, Erreur." 29 | tooltip_help: 'Confus ? Regardez un court tutoriel vidéo.' 30 | redmine_report_footer: 'Temps déjà enregistré' 31 | toggl_report_footer: 'Temps à importer' 32 | error: 33 | ajax_load: 'Impossible de récupérer les données du serveur. Veuillez rafraîchir la page et réessayer.' 34 | list_empty: "Il n'y a aucun élément à afficher ici." 35 | project_closed: 'Le projet est fermé.' 36 | issue_id_missing: 'Please mention the Redmine issue ID in your Toggl task description. Example: #1919 Feed the bunny wabbit.' 37 | issue_not_found: "Soit cette pétition n'a pas été trouvée dans Redmine soit vous n'y avez pas accès. Assurez-vous que vous utilisez un ID de pétition correct et que vous êtes membre du projet." 38 | no_entries_selected: 'Veuillez sélectionner les entrées que vous souhaitez importer dans Redmine et réessayer.' 39 | date_invalid: 'Veuillez entrer une date valide.' 40 | -------------------------------------------------------------------------------- /config/locales/ja.yml: -------------------------------------------------------------------------------- 1 | ja: 2 | activerecord: 3 | attributes: 4 | toggl_mapping: 5 | id: 'ID' 6 | toggl_id: 'Toggl ID' 7 | time_entry_id: 'タイムエントリーID' 8 | t2r: 9 | button_import_to_redmine: 'Redmineにインポート' 10 | caption_redmine_report: 'Redmineに記録されている時間' 11 | caption_toggl_report: 'Togglに記録されている時間' 12 | label_default_activity: 'デフォルトの作業分類' 13 | label_basic_options: '基本オプション' 14 | label_advanced_options: '高度なオプション' 15 | label_toggl_workspace: 'Togglのワークスペース' 16 | label_duration_rounding: '時間の丸め' 17 | label_hour_plural: '時間' 18 | label_last_imported: '最後にインポートした日:%{date}' 19 | label_status: 'ステータス' 20 | text_add_toggl_api_key: 'Toggl 2 Redmineを利用するにはToggl API Tokenを設定してください' 21 | text_db_transaction_warning: '利用中のデータベースはトランザクションをサポートしていません。Toggl 2 RedmineのREADMEを参照するようにシステム管理者に依頼してください' 22 | tooltip_date_filter: 'インポートする日付を選択してください' 23 | tooltip_duration_rounding_logic: 'Togglの時間を丸める方法を選択してください' 24 | tooltip_duration_rounding_value: 'Togglの全ての時間エントリーはこの数値(分)単位で丸められます' 25 | tooltip_hours_column_header: "1:50は1時間50分を表しています。 'Up'または'Down'キーを押すと5分単位で丸められます、'Shift'キーとともに押すと15分単位で丸められます" 26 | tooltip_status_column_header: "各行のステータスを確認するにはアイコンにマウスを乗せます。 例:Imported、Failed、Error" 27 | tooltip_help: '混乱していますか?、チュートリアルを見る' 28 | redmine_report_footer: '既に記録されている時間' 29 | toggl_report_footer: 'インポートする時間' 30 | error: 31 | ajax_load: 'サーバーからデータを取得できませんでした。もう一度ページをリロードしてください。' 32 | list_empty: '表示する情報はありません' 33 | project_closed: 'プロジェクトは終了しています' 34 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Plugin's routes 4 | # See: http://guides.rubyonrails.org/routing.html 5 | 6 | # Dashboard. 7 | get 'toggl2redmine', to: 't2r_import#index' 8 | 9 | # Import. 10 | post 'toggl2redmine/import', to: 't2r_import#import' 11 | 12 | # Read Redmine time entries. 13 | get 'toggl2redmine/redmine/time_entries', to: 't2r_redmine#read_time_entries' 14 | 15 | # Read Toggl time entries. 16 | get 'toggl2redmine/toggl/time_entries', to: 't2r_toggl#read_time_entries' 17 | 18 | # Read Toggl workspaces. 19 | get 'toggl2redmine/toggl/workspaces', to: 't2r_toggl#read_workspaces' 20 | 21 | if Rails.env == 'test' 22 | get 'toggl2redmine/test', to: 't2r_test#index' 23 | end 24 | -------------------------------------------------------------------------------- /db/migrate/001_create_toggl_api_key_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Creates a "Toggl API Key" field. 4 | class CreateTogglApiKeyField < ActiveRecord::Migration[4.2] 5 | def up 6 | custom_field = CustomField.new_subclass_instance( 7 | 'UserCustomField', 8 | { 9 | name: 'Toggl API Key', 10 | field_format: 'string', 11 | min_length: 32, 12 | max_length: 32, 13 | regexp: '', 14 | default_value: '', 15 | is_required: 0, 16 | visible: 0, 17 | editable: 1, 18 | is_filter: 0 19 | } 20 | ) 21 | custom_field.save! 22 | end 23 | 24 | def down 25 | CustomField.find_by_name('Toggl API Key').destroy 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /db/migrate/002_create_toggl_time_entries_table.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Creates a "toggl_time_entries" table. 4 | class CreateTogglTimeEntriesTable < ActiveRecord::Migration[4.2] 5 | def change 6 | create_table :toggl_time_entries do |t| 7 | t.integer :toggl_id, null: false 8 | t.belongs_to :time_entry, null: false 9 | t.datetime :created_at, null: false 10 | end 11 | add_index :toggl_time_entries, [:toggl_id], unique: true, name: :toggle_id 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/003_rename_toggl_time_entries_table.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Renames "toggl_time_entries" table to "toggl_mappings". 4 | class RenameTogglTimeEntriesTable < ActiveRecord::Migration[4.2] 5 | def self.up 6 | rename_table :toggl_time_entries, :toggl_mappings 7 | end 8 | 9 | def self.down 10 | rename_table :toggl_mappings, :toggl_time_entries 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/004_rename_toggl_api_key_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Renames "Toggl API Token" field to "Toggl API Key". 4 | class RenameTogglApiKeyField < ActiveRecord::Migration[4.2] 5 | def self.up 6 | field = UserCustomField.find_by_name('Toggl API Key') 7 | field.name = 'Toggl API Token' 8 | field.save! 9 | end 10 | 11 | def self.down 12 | field = UserCustomField.find_by_name('Toggl API Token') 13 | field.name = 'Toggl API Key' 14 | field.save! 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/005_change_toggl_mappings_toggl_id_to_bigint.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ChangeTogglMappingsTogglIdToBigint < ActiveRecord::Migration[4.2] 4 | def up 5 | change_column :toggl_mappings, :toggl_id, :bigint 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | redmine: 3 | image: redmine:5.x-toggl2redmine 4 | container_name: t2r-5x-appserver 5 | platform: linux/x86_64 6 | hostname: appserver 7 | build: .docker/redmine 8 | ports: 9 | - "3000:3000" 10 | depends_on: 11 | - mysql 12 | - mailhog 13 | environment: 14 | RAILS_ENV: development 15 | REDMINE_LANG: en 16 | REDMINE_DB_MYSQL: mysql 17 | REDMINE_DB_ENCODING: utf8 18 | REDMINE_DB_USERNAME: redmine 19 | REDMINE_DB_PASSWORD: redmine 20 | REDMINE_PLUGINS_MIGRATE: 1 21 | volumes: 22 | - .:/usr/src/redmine/plugins/toggl2redmine 23 | - ./.docker/redmine/seeds.rb:/usr/src/redmine/db/seeds.rb 24 | - ./.docker/redmine/configuration.yml:/usr/src/redmine/config/configuration.yml 25 | - ./.docker/redmine/database.yml:/usr/src/redmine/config/database.yml 26 | - ./.docker/redmine/additional_environment.rb:/usr/src/redmine/config/additional_environment.rb 27 | mysql: 28 | image: mariadb:10.6 29 | container_name: t2r-5x-mysql 30 | hostname: mysql 31 | environment: 32 | MYSQL_ROOT_PASSWORD: root 33 | MYSQL_USER: redmine 34 | MYSQL_PASSWORD: redmine 35 | command: ['mysqld', '--character-set-server=utf8mb4', '--collation-server=utf8mb4_unicode_ci'] 36 | ports: 37 | - '3306:3306' 38 | volumes: 39 | - ./.docker/mysql/init:/docker-entrypoint-initdb.d 40 | node: 41 | image: node:16.x-toggl2redmine 42 | container_name: t2r-5x-node 43 | platform: linux/x86_64 44 | hostname: node 45 | build: .docker/node 46 | command: ['tail', '-f', '/dev/null'] 47 | volumes: 48 | - .:/app 49 | mailhog: 50 | image: mailhog/mailhog:v1.0.0 51 | container_name: t2r-5x-mailhog 52 | platform: linux/x86_64 53 | hostname: mailhog 54 | ports: 55 | - "8025:8025" 56 | - "1025:1025" 57 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.configuration.to_prepare do 4 | require_relative 'lib/toggl_2_redmine' 5 | 6 | Redmine::Plugin.register :toggl2redmine do 7 | # Package info. 8 | name 'Toggl 2 Redmine' 9 | author 'Jigarius' 10 | description 'Imports time entries from Toggl into Redmine.' 11 | version Toggl2Redmine::VERSION 12 | url 'https://github.com/jigarius/toggl2redmine' 13 | author_url 'https://jigarius.com/' 14 | 15 | # Menu items. 16 | menu :application_menu, 17 | :toggl2redmine, 18 | { controller: 't2r_import', action: 'index' }, 19 | caption: 'Toggl' 20 | end 21 | 22 | # Patches. 23 | require_relative 'lib/patches/time_entry' 24 | end 25 | -------------------------------------------------------------------------------- /lib/patches/time_entry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Toggl2Redmine 4 | # Patch Redmine's TimeEntry model. 5 | module TimeEntryPatch 6 | def self.included(base) 7 | base.class_eval do 8 | has_many :toggl_mappings, dependent: :destroy 9 | end 10 | end 11 | end 12 | end 13 | 14 | TimeEntry.include Toggl2Redmine::TimeEntryPatch 15 | -------------------------------------------------------------------------------- /lib/toggl_2_redmine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Toggl2Redmine 4 | VERSION = '4.4.1' 5 | 6 | def self.root 7 | File.dirname(File.dirname(__FILE__)) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/fixtures/custom_fields.yml: -------------------------------------------------------------------------------- 1 | toggl_api_token: 2 | type: UserCustomField 3 | name: 'Toggl API Token' 4 | field_format: 'string' 5 | min_length: 32 6 | max_length: 32 7 | regexp: '' 8 | default_value: '' 9 | is_required: 0 10 | visible: 0 11 | editable: 1 12 | is_filter: 0 13 | -------------------------------------------------------------------------------- /test/fixtures/email_addresses.yml: -------------------------------------------------------------------------------- 1 | admin: 2 | user: admin 3 | address: admin@example.com 4 | is_default: true 5 | 6 | jsmith: 7 | user: jsmith 8 | address: jsmith@example.com 9 | is_default: true 10 | -------------------------------------------------------------------------------- /test/fixtures/enabled_modules.yml: -------------------------------------------------------------------------------- 1 | alpha_time_tracking: 2 | project: alpha 3 | name: time_tracking 4 | 5 | alpha_issue_tracking: 6 | project: alpha 7 | name: issue_tracking 8 | 9 | bravo_time_tracking: 10 | project: bravo 11 | name: time_tracking 12 | 13 | bravo_issue_tracking: 14 | project: bravo 15 | name: issue_tracking 16 | 17 | charlie_time_tracking: 18 | project: charlie 19 | name: time_tracking 20 | 21 | charlie_issue_tracking: 22 | project: charlie 23 | name: issue_tracking 24 | 25 | delta_time_tracking: 26 | project: delta 27 | name: time_tracking 28 | 29 | delta_issue_tracking: 30 | project: delta 31 | name: issue_tracking 32 | -------------------------------------------------------------------------------- /test/fixtures/enumerations.yml: -------------------------------------------------------------------------------- 1 | # TimeEntryActivity 2 | activity_development: 3 | type: TimeEntryActivity 4 | name: Development 5 | position: 1 6 | active: true 7 | is_default: true 8 | 9 | activity_other: 10 | type: TimeEntryActivity 11 | name: Other 12 | position: 2 13 | active: true 14 | 15 | # IssuePriority 16 | priority_normal: 17 | type: IssuePriority 18 | name: Normal 19 | position: 1 20 | is_default: true 21 | 22 | -------------------------------------------------------------------------------- /test/fixtures/issue_statuses.yml: -------------------------------------------------------------------------------- 1 | open: 2 | name: Open 3 | is_closed: false 4 | 5 | closed: 6 | name: Closed 7 | is_closed: true 8 | -------------------------------------------------------------------------------- /test/fixtures/issues.yml: -------------------------------------------------------------------------------- 1 | alpha_001: 2 | subject: 'Abstract apples' 3 | author: admin 4 | project: alpha 5 | tracker: task 6 | priority: priority_normal 7 | status: open 8 | lft: 1 9 | rgt: 2 10 | 11 | alpha_002: 12 | subject: 'Boil bananas' 13 | author: admin 14 | project: alpha 15 | tracker: task 16 | priority: priority_normal 17 | status: open 18 | lft: 3 19 | rgt: 4 20 | 21 | bravo_001: 22 | subject: 'Condition cherries' 23 | author: admin 24 | project: bravo 25 | tracker: task 26 | priority: priority_normal 27 | status: open 28 | lft: 5 29 | rgt: 6 30 | 31 | bravo_002: 32 | subject: 'Dismantle dates' 33 | author: admin 34 | project: bravo 35 | tracker: task 36 | priority: priority_normal 37 | status: closed 38 | lft: 7 39 | rgt: 8 40 | 41 | charlie_001: 42 | subject: 'Extract essence' 43 | author: admin 44 | project: charlie 45 | tracker: task 46 | priority: priority_normal 47 | status: open 48 | lft: 9 49 | rgt: 10 50 | 51 | 52 | delta_001: 53 | subject: 'Make perfume' 54 | author: admin 55 | project: delta 56 | tracker: task 57 | priority: priority_normal 58 | status: open 59 | lft: 9 60 | rgt: 10 61 | -------------------------------------------------------------------------------- /test/fixtures/members.yml: -------------------------------------------------------------------------------- 1 | jsmith_alpha: 2 | user: jsmith 3 | project: alpha 4 | roles: 5 | - manager 6 | 7 | jsmith_bravo: 8 | user: jsmith 9 | project: bravo 10 | roles: 11 | - reviewer 12 | 13 | jsmith_charlie: 14 | user: jsmith 15 | project: charlie 16 | roles: 17 | - manager 18 | -------------------------------------------------------------------------------- /test/fixtures/projects.yml: -------------------------------------------------------------------------------- 1 | alpha: 2 | identifier: alpha 3 | name: Project alpha 4 | description: John Smith has 'manager' access to this project. 5 | lft: 1 6 | rgt: 2 7 | trackers: 8 | - task 9 | 10 | bravo: 11 | identifier: bravo 12 | name: Project bravo 13 | description: John Smith has 'reviewer' access to this project. 14 | lft: 3 15 | rgt: 4 16 | 17 | charlie: 18 | identifier: charlie 19 | name: Project charlie 20 | description: John Smith has 'manager' access to this project, but the project is closed. 21 | lft: 5 22 | rgt: 6 23 | status: 5 # Project.STATUS_CLOSED 24 | 25 | delta: 26 | identifier: delta 27 | name: Project delta 28 | description: John Smith doesn't have access to this project. 29 | lft: 7 30 | rgt: 8 31 | -------------------------------------------------------------------------------- /test/fixtures/roles.yml: -------------------------------------------------------------------------------- 1 | manager: 2 | name: Manager 3 | permissions: | 4 | --- 5 | - :view_issues 6 | - :view_time_entries 7 | - :log_time 8 | - :edit_time_entries 9 | - :edit_own_time_entries 10 | 11 | reviewer: 12 | name: Reviewer 13 | permissions: | 14 | --- 15 | - :view_issues 16 | - :view_time_entries 17 | -------------------------------------------------------------------------------- /test/fixtures/settings.yml: -------------------------------------------------------------------------------- 1 | rest_api_enabled: 2 | name: rest_api_enabled 3 | value: 1 4 | -------------------------------------------------------------------------------- /test/fixtures/time_entries.yml: -------------------------------------------------------------------------------- 1 | entry_001: 2 | project: alpha 3 | issue: alpha_001 4 | spent_on: 2012-11-03 5 | tyear: 2012 6 | tmonth: 11 7 | tweek: 45 8 | user: jsmith 9 | activity: activity_development 10 | hours: 0.50 11 | comments: Pellentesque ornare sem lacinia quam venenatis vestibulum. 12 | 13 | entry_002: 14 | project: alpha 15 | issue: alpha_001 16 | spent_on: 2012-11-03 17 | tyear: 2012 18 | tmonth: 11 19 | tweek: 45 20 | user: jsmith 21 | activity: activity_development 22 | hours: 0.25 23 | comments: Cras mattis consectetur purus sit amet fermentum. 24 | 25 | entry_003: 26 | project: alpha 27 | issue: alpha_002 28 | spent_on: 2012-11-03 29 | tyear: 2012 30 | tmonth: 11 31 | tweek: 45 32 | user: jsmith 33 | activity: activity_development 34 | hours: 1.25 35 | comments: Ut fermentum massa justo sit amet risus. 36 | 37 | entry_004: 38 | project: bravo 39 | issue: bravo_001 40 | spent_on: 2012-11-03 41 | tyear: 2012 42 | tmonth: 11 43 | tweek: 45 44 | user: jsmith 45 | activity: activity_other 46 | hours: 2.0 47 | comments: Fusce dapibus, tellus ac cursus commodo tortor mauris condimentum. 48 | 49 | entry_005: 50 | project: charlie 51 | issue: charlie_001 52 | spent_on: 2012-11-03 53 | tyear: 2012 54 | tmonth: 11 55 | tweek: 45 56 | user: admin 57 | activity: activity_other 58 | hours: 3.0 59 | comments: Lorem ipsum dolor sit amet. 60 | -------------------------------------------------------------------------------- /test/fixtures/toggl/time_entries.json: -------------------------------------------------------------------------------- 1 | [ 2 | {"id":1844094802,"guid":"775a91f797597df01275d852073e6710","wid":2618724,"billable":false,"start":"2021-01-17T18:50:04+00:00","stop":"2021-01-17T18:55:04+00:00","duration":300,"description":"#1 Preparing meal","duronly":false,"at":"2021-01-17T21:23:18+00:00","uid":3008088}, 3 | {"id":1844094618,"guid":"4d663aef640ced57f41fdc0ade3e9f12","wid":2618724,"billable":false,"start":"2021-01-17T19:00:37+00:00","stop":"2021-01-17T19:30:37+00:00","duration":1800,"description":"#1 Preparing meal","duronly":false,"at":"2021-01-17T21:23:22+00:00","uid":3008088}, 4 | {"id":1844094426,"guid":"ea9212850ca5809b7b60f6f65ce659e4","wid":2618724,"billable":false,"start":"2021-01-17T20:00:56+00:00","stop":"2021-01-17T20:12:56+00:00","duration":720,"description":"#1 Feeding the llamas","duronly":false,"at":"2021-01-17T21:22:19+00:00","uid":3008088}, 5 | {"id":1844093947,"guid":"87cbd93770ea3bac9d27ef456d91b1f9","wid":99,"billable":false,"start":"2021-01-17T21:00:51+00:00","stop":"2021-01-17T21:20:51+00:00","duration":1200,"description":"#2 Reticulating splines","duronly":false,"at":"2021-01-17T21:21:01+00:00","uid":3008088}, 6 | {"id":1844094084,"guid":"229e0d2cc35c020e73f23d88931eead6","wid":99,"billable":false,"start":"2021-01-17T21:20:21+00:00","stop":"2021-01-17T21:30:21+00:00","duration":600,"description":"#2 Reticulating splines","duronly":false,"at":"2021-01-17T21:21:34+00:00","uid":3008088} 7 | ] -------------------------------------------------------------------------------- /test/fixtures/toggl/workspaces.json: -------------------------------------------------------------------------------- 1 | [ 2 | {"id":2618724,"name":"Workspace 1","profile":0,"premium":false,"admin":true,"default_hourly_rate":0,"default_currency":"USD","only_admins_may_create_projects":false,"only_admins_see_billable_rates":false,"only_admins_see_team_dashboard":false,"projects_billable_by_default":true,"rounding":1,"rounding_minutes":0,"api_token":"5efea89ef74ed7bdbd2aa85f75fb1ab7","at":"2018-03-08T14:41:06+00:00","ical_enabled":true}, 3 | {"id":2721799,"name":"Workspace 2","profile":0,"premium":false,"admin":true,"default_hourly_rate":0,"default_currency":"USD","only_admins_may_create_projects":false,"only_admins_see_billable_rates":false,"only_admins_see_team_dashboard":false,"projects_billable_by_default":true,"rounding":1,"rounding_minutes":0,"api_token":"5efea89ef74ed7bdbd2aa85f75fb1ab7","at":"2018-03-08T14:41:06+00:00","ical_enabled":true}, 4 | {"id":2921822,"name":"Workspace 3","profile":0,"premium":false,"admin":true,"default_hourly_rate":0,"default_currency":"USD","only_admins_may_create_projects":false,"only_admins_see_billable_rates":false,"only_admins_see_team_dashboard":false,"projects_billable_by_default":true,"rounding":1,"rounding_minutes":0,"api_token":"5efea89ef74ed7bdbd2aa85f75fb1ab7","at":"2018-03-08T14:41:06+00:00","ical_enabled":true} 5 | ] -------------------------------------------------------------------------------- /test/fixtures/toggl_mappings.yml: -------------------------------------------------------------------------------- 1 | 1001: 2 | toggl_id: 1001 3 | time_entry: entry_001 4 | 5 | 1002: 6 | toggl_id: 1002 7 | time_entry: entry_002 8 | 9 | 1003: 10 | toggl_id: 1003 11 | time_entry: entry_003 12 | 13 | 1004: 14 | toggl_id: 1004 15 | time_entry: entry_004 16 | -------------------------------------------------------------------------------- /test/fixtures/trackers.yml: -------------------------------------------------------------------------------- 1 | task: 2 | name: Task 3 | default_status: open 4 | -------------------------------------------------------------------------------- /test/fixtures/users.yml: -------------------------------------------------------------------------------- 1 | admin: 2 | type: User 3 | status: 1 4 | language: en 5 | # password = admin 6 | salt: 82090c953c4a0000a7db253b0691a6b4 7 | hashed_password: b5b6ff9543bf1387374cdfa27a54c96d236a7150 8 | admin: true 9 | lastname: Caesar 10 | firstname: Jigarius 11 | login: admin 12 | 13 | jsmith: 14 | type: User 15 | status: 1 16 | language: en 17 | # password = jsmith 18 | salt: 67eb4732624d5a7753dcea7ce0bb7d7d 19 | hashed_password: bfbe06043353a677d0215b26a5800d128d5413bc 20 | admin: false 21 | lastname: Smith 22 | firstname: John 23 | login: jsmith 24 | -------------------------------------------------------------------------------- /test/integration/models/toggl_mapping_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | 3 | require_relative '../../test_helper' 4 | 5 | class TogglMappingTest < T2r::IntegrationTest 6 | fixtures :all 7 | 8 | def setup 9 | @user = users(:jsmith) 10 | end 11 | 12 | test 'deleting time entry deletes relevant toggl mappings' do 13 | time_entry = time_entries(:entry_001) 14 | 15 | mapping_1 = TogglMapping.create( 16 | toggl_id: 201, 17 | time_entry_id: time_entry.id, 18 | created_at: DateTime.strptime('2024-02-22T15:30:04+00:00') 19 | ) 20 | mapping_2 = TogglMapping.create( 21 | toggl_id: 202, 22 | time_entry_id: time_entry.id, 23 | created_at: DateTime.strptime('2024-02-22T15:30:04+00:00') 24 | ) 25 | 26 | assert time_entry.toggl_mapping_ids.include? mapping_1.id 27 | assert time_entry.toggl_mapping_ids.include? mapping_2.id 28 | 29 | assert time_entry.destroy 30 | 31 | refute TogglMapping.exists? mapping_1.id 32 | refute TogglMapping.exists? mapping_2.id 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/integration/t2r_base_controller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | 3 | require_relative '../test_helper' 4 | 5 | class T2rTestController < T2rBaseController 6 | def index 7 | render plain: 'OK', status: 200 8 | end 9 | end 10 | 11 | class T2rBaseControllerTest < T2r::IntegrationTest 12 | fixtures :all 13 | 14 | def setup 15 | @user = users(:jsmith) 16 | @field = custom_fields(:toggl_api_token) 17 | end 18 | 19 | test '#index requires login' do 20 | get '/toggl2redmine/test' 21 | 22 | assert_redirected_to signin_url(back_url: 'http://www.example.com/toggl2redmine/test') 23 | end 24 | 25 | test '#index requires a Toggl API key' do 26 | set_custom_field_value(@user, @field, '') 27 | log_user(@user.login, @user.login) 28 | 29 | get '/toggl2redmine/test' 30 | 31 | assert_redirected_to '/my/account' 32 | assert_equal 'To use Toggl 2 Redmine, please add a Toggl API Token to your account.', flash[:error] 33 | end 34 | 35 | test '#index succeeds for a user with a Toggl API key' do 36 | set_custom_field_value(@user, @field, 'fake-toggl-api-token') 37 | log_user(@user.login, @user.login) 38 | 39 | get '/toggl2redmine/test' 40 | 41 | assert_response :success 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/integration/t2r_import_controller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../test_helper' 4 | 5 | class T2rImportControllerTest < T2r::IntegrationTest 6 | fixtures :all 7 | 8 | def setup 9 | @user = users(:jsmith) 10 | @field = custom_fields(:toggl_api_token) 11 | 12 | set_custom_field_value(@user, @field, 'fake-toggl-api-token') 13 | log_user(@user.login, @user.login) 14 | end 15 | 16 | test 'Includes T2rBaseController' do 17 | assert(T2rImportController < T2rBaseController) 18 | end 19 | 20 | test ".import returns 400 response if 'toggl_ids' param is not present" do 21 | data = { 22 | time_entry: { 23 | activity_id: 1, 24 | comments: 'Special delivery!', 25 | hours: 4.0, 26 | issue_id: 1, 27 | spent_on: '2021-01-17' 28 | } 29 | } 30 | 31 | post '/toggl2redmine/import', params: data 32 | 33 | assert_response 400 34 | assert_equal( 35 | { 'errors' => ['param is missing or the value is empty: toggl_ids'] }, 36 | @response.parsed_body 37 | ) 38 | end 39 | 40 | test ".import returns 400 response if 'toggl_ids' param is empty" do 41 | data = { 42 | toggl_ids: [], 43 | time_entry: { 44 | activity_id: 1, 45 | comments: 'Special delivery!', 46 | hours: 4.0, 47 | issue_id: 1, 48 | spent_on: '2021-01-17' 49 | } 50 | } 51 | 52 | post '/toggl2redmine/import', params: data 53 | 54 | assert_response 400 55 | assert_equal( 56 | { 'errors' => ['param is missing or the value is empty: toggl_ids'] }, 57 | @response.parsed_body 58 | ) 59 | end 60 | 61 | test ".import returns 400 response if 'time_entry' param is not present" do 62 | post '/toggl2redmine/import', params: { toggl_ids: [999] } 63 | 64 | assert_response 400 65 | assert_equal( 66 | { 'errors' => ['param is missing or the value is empty: time_entry'] }, 67 | @response.parsed_body 68 | ) 69 | end 70 | 71 | test ".import returns 400 response if 'time_entry' param is not valid" do 72 | data = { 73 | toggl_ids: [999], 74 | time_entry: { 75 | activity_id: enumerations(:activity_development).id, 76 | comments: 'Special delivery!', 77 | hours: -1, 78 | issue_id: issues(:alpha_001).id, 79 | spent_on: '2021-01-17' 80 | } 81 | } 82 | 83 | post '/toggl2redmine/import', params: data 84 | 85 | assert_response 400 86 | assert_equal( 87 | { 'errors' => ['Validation failed: Hours is invalid'] }, 88 | @response.parsed_body 89 | ) 90 | end 91 | 92 | test '.import returns 403 response if user is not a project member' do 93 | data = { 94 | toggl_ids: [999], 95 | time_entry: { 96 | activity_id: enumerations(:activity_development).id, 97 | comments: 'Special delivery!', 98 | hours: 4.0, 99 | issue_id: issues(:delta_001).id, 100 | spent_on: '2021-01-17' 101 | } 102 | } 103 | 104 | post '/toggl2redmine/import', params: data 105 | 106 | assert_response 403 107 | assert_equal( 108 | { 'errors' => ['You are not a member of this project.'] }, 109 | @response.parsed_body 110 | ) 111 | end 112 | 113 | test ".import returns 403 response if user doesn't have 'log_time' permission for the project" do 114 | data = { 115 | toggl_ids: [999], 116 | time_entry: { 117 | activity_id: enumerations(:activity_development).id, 118 | comments: 'Special delivery!', 119 | hours: 4.0, 120 | issue_id: issues(:bravo_001).id, 121 | spent_on: '2021-01-17' 122 | } 123 | } 124 | 125 | post '/toggl2redmine/import', params: data 126 | 127 | assert_response 403 128 | assert_equal( 129 | { 'errors' => ['You are not allowed to log time on this project.'] }, 130 | @response.parsed_body 131 | ) 132 | end 133 | 134 | test '.import returns 400 response if time entry has already been imported' do 135 | data = { 136 | toggl_ids: [999, 1003], 137 | time_entry: { 138 | activity_id: enumerations(:activity_other).id, 139 | comments: "Listen to Friday I'm in Love", 140 | hours: 2.0, 141 | issue_id: issues(:alpha_001).id, 142 | spent_on: '2021-01-17' 143 | } 144 | } 145 | 146 | post '/toggl2redmine/import', params: data 147 | 148 | assert_response 400 149 | assert_equal( 150 | { 'errors' => ['Toggl ID has already been imported.'] }, 151 | @response.parsed_body 152 | ) 153 | end 154 | 155 | test '.import returns 400 response if the project is closed' do 156 | data = { 157 | toggl_ids: [50], 158 | time_entry: { 159 | activity_id: enumerations(:activity_other).id, 160 | comments: 'Organize party for the year 2020', 161 | hours: 4, 162 | issue_id: issues(:charlie_001).id, 163 | spent_on: '2021-07-20' 164 | } 165 | } 166 | 167 | post '/toggl2redmine/import', params: data 168 | 169 | assert_response 403 170 | assert_equal( 171 | { 'errors' => ['You are not allowed to log time on this project.'] }, 172 | @response.parsed_body 173 | ) 174 | end 175 | 176 | test '.import returns 503 response and rolls back if time entries cannot be saved' do 177 | data = { 178 | toggl_ids: [2001, 2002, 1003], 179 | time_entry: { 180 | activity_id: enumerations(:activity_other).id, 181 | comments: 'Get rich or die trying', 182 | hours: 2.0, 183 | issue_id: issues(:alpha_001).id, 184 | spent_on: '2021-01-17' 185 | } 186 | } 187 | 188 | # Say, somehow there are 2 competing requests that collide. 189 | # Not sure if this is possible, but feels safer with it. 190 | TogglMapping.expects(:where) 191 | .with(toggl_id: data[:toggl_ids].map(&:to_s)) 192 | .returns([]) 193 | 194 | assert_no_changes('TimeEntry.count') do 195 | assert_no_changes('TogglMapping.count') do 196 | post '/toggl2redmine/import', params: data 197 | end 198 | end 199 | 200 | assert_response 400 201 | assert_equal( 202 | { 'errors' => ['Validation failed: Toggl ID has already been imported'] }, 203 | @response.parsed_body 204 | ) 205 | end 206 | 207 | test ".import shows message if time entries cannot be saved and database doesn't support rollback" do 208 | skip 209 | end 210 | 211 | test '.import returns 201 response on success' do 212 | data = { 213 | toggl_ids: [2001, 2002, 2003], 214 | time_entry: { 215 | activity_id: enumerations(:activity_other).id, 216 | comments: 'Get rich or die trying', 217 | hours: 8.0, 218 | issue_id: issues(:alpha_001).id, 219 | spent_on: '2021-01-17' 220 | } 221 | } 222 | 223 | assert_difference('TimeEntry.count', 1) do 224 | assert_difference('TogglMapping.count', 3) do 225 | post '/toggl2redmine/import', params: data 226 | end 227 | end 228 | 229 | assert_response 201 230 | assert(@response.parsed_body) 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /test/integration/t2r_redmine_controller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../test_helper' 4 | 5 | class T2rRedmineControllerTest < T2r::IntegrationTest 6 | include Rails.application.routes.url_helpers 7 | fixtures :all 8 | make_my_diffs_pretty! 9 | 10 | def setup 11 | @user = users(:jsmith) 12 | @field = custom_fields(:toggl_api_token) 13 | 14 | set_custom_field_value(@user, @field, 'fake-toggl-api-token') 15 | log_user(@user.login, @user.login) 16 | end 17 | 18 | test 'Includes T2rBaseController' do 19 | assert(T2rRedmineController < T2rBaseController) 20 | end 21 | 22 | test '.read_time_entries returns time entries as JSON' do 23 | data = { 24 | from: '2012-11-03T00:00:00Z', 25 | till: '2012-11-03T00:00:00Z' 26 | } 27 | 28 | get "/toggl2redmine/redmine/time_entries?#{data.to_query}" 29 | 30 | e1 = time_entries(:entry_001) 31 | e2 = time_entries(:entry_002) 32 | e3 = time_entries(:entry_003) 33 | e4 = time_entries(:entry_004) 34 | 35 | expectation = { 36 | 'time_entries' => [ 37 | { 38 | 'id' => e3.id, 39 | 'hours' => 1.25, 40 | 'comments' => 'Ut fermentum massa justo sit amet risus.', 41 | 'issue' => { 42 | 'id' => e3.issue.id, 43 | 'subject' => 'Boil bananas', 44 | 'tracker' => { 'id' => e3.issue.tracker.id, 'name' => 'Task' }, 45 | 'is_closed' => e3.issue.closed?, 46 | 'path' => issue_path(e3.issue) 47 | }, 48 | 'project' => { 49 | 'id' => e3.project.id, 50 | 'name' => 'Project alpha', 51 | 'status' => 1, 52 | 'path' => project_path(e3.project) 53 | }, 54 | 'activity' => { 'id' => e3.activity.id, 'name' => 'Development' } 55 | }, 56 | { 57 | 'id' => e4.id, 58 | 'hours' => 2.0, 59 | 'comments' => 'Fusce dapibus, tellus ac cursus commodo tortor mauris condimentum.', 60 | 'issue' => { 61 | 'id' => e4.issue.id, 62 | 'subject' => 'Condition cherries', 63 | 'tracker' => { 'id' => e4.issue.tracker.id, 'name' => 'Task' }, 64 | 'is_closed' => e4.issue.closed?, 65 | 'path' => issue_path(e4.issue) 66 | }, 67 | 'project' => { 68 | 'id' => e4.project.id, 69 | 'name' => 'Project bravo', 70 | 'status' => 1, 71 | 'path' => project_path(e4.project) 72 | }, 73 | 'activity' => { 'id' => e4.activity.id, 'name' => 'Other' } 74 | }, 75 | { 76 | 'id' => e1.id, 77 | 'hours' => 0.5, 78 | 'comments' => 'Pellentesque ornare sem lacinia quam venenatis vestibulum.', 79 | 'issue' => { 80 | 'id' => e1.issue.id, 81 | 'subject' => 'Abstract apples', 82 | 'tracker' => { 'id' => e1.issue.tracker.id, 'name' => 'Task' }, 83 | 'is_closed' => e1.issue.closed?, 84 | 'path' => issue_path(e1.issue) 85 | }, 86 | 'project' => { 87 | 'id' => e1.project.id, 88 | 'name' => 'Project alpha', 89 | 'status' => 1, 90 | 'path' => project_path(e1.project) 91 | }, 92 | 'activity' => { 'id' => e1.activity.id, 'name' => 'Development' } 93 | }, 94 | { 95 | 'id' => e2.id, 96 | 'hours' => 0.25, 97 | 'comments' => 'Cras mattis consectetur purus sit amet fermentum.', 98 | 'issue' => { 99 | 'id' => e2.issue.id, 100 | 'subject' => 'Abstract apples', 101 | 'tracker' => { 'id' => e2.issue.tracker.id, 'name' => 'Task' }, 102 | 'is_closed' => e2.issue.closed?, 103 | 'path' => issue_path(e2.issue) 104 | }, 105 | 'project' => { 106 | 'id' => e2.project.id, 107 | 'name' => 'Project alpha', 108 | 'status' => 1, 109 | 'path' => project_path(e2.project) 110 | }, 111 | 'activity' => { 'id' => e2.activity.id, 'name' => 'Development' } 112 | } 113 | ] 114 | } 115 | 116 | assert_response :success 117 | 118 | # Comparing each record separately makes the diffs easier to read. 119 | assert_equal(expectation.keys, @response.parsed_body.keys) 120 | assert_equal(expectation['time_entries'].length, @response.parsed_body['time_entries'].length) 121 | 122 | expectation['time_entries'].each_with_index do |expected_item, i| 123 | assert_equal( 124 | expected_item, 125 | @response.parsed_body['time_entries'][i], 126 | "Item #{i + 1} is not as expected" 127 | ) 128 | end 129 | end 130 | 131 | test ".read_time_entries fails if param 'from' is missing" do 132 | data = { till: DateTime.now } 133 | 134 | get "/toggl2redmine/redmine/time_entries?#{data.to_query}" 135 | 136 | assert_response 400 137 | assert_equal( 138 | { 'errors' => ['param is missing or the value is empty: from'] }, 139 | @response.parsed_body 140 | ) 141 | end 142 | 143 | test ".read_time_entries fails if param 'till' is missing" do 144 | data = { from: DateTime.now } 145 | 146 | get "/toggl2redmine/redmine/time_entries?#{data.to_query}" 147 | 148 | assert_response 400 149 | assert_equal( 150 | { 'errors' => ['param is missing or the value is empty: till'] }, 151 | @response.parsed_body 152 | ) 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /test/integration/t2r_toggl_controller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../test_helper' 4 | 5 | class T2rTogglControllerTest < T2r::IntegrationTest 6 | include Rails.application.routes.url_helpers 7 | fixtures :all 8 | make_my_diffs_pretty! 9 | 10 | def setup 11 | @user = users(:jsmith) 12 | @field = custom_fields(:toggl_api_token) 13 | 14 | set_custom_field_value(@user, @field, 'fake-toggl-api-token') 15 | log_user(@user.login, @user.login) 16 | end 17 | 18 | test 'Includes T2rBaseController' do 19 | assert(T2rTogglController < T2rBaseController) 20 | end 21 | 22 | test '.read_time_entries returns time entries as JSON' do 23 | issue = issues(:alpha_001) 24 | 25 | time_entries = [ 26 | TogglTimeEntry.new( 27 | id: 154, 28 | wid: 2, 29 | duration: 300, 30 | at: '2021-01-17T21:21:34+00:00', 31 | description: "##{issue.id} Prepare food" 32 | ), 33 | TogglTimeEntry.new( 34 | id: 155, 35 | wid: 2, 36 | duration: 150, 37 | at: '2021-01-17T21:22:34+00:00', 38 | description: "##{issue.id} Prepare food" 39 | ), 40 | TogglTimeEntry.new( 41 | id: 246, 42 | wid: 2, 43 | duration: -1, 44 | at: '2021-01-17T21:51:34+00:00', 45 | description: "##{issue.id} Feed bunny" 46 | ) 47 | ] 48 | 49 | data = { 50 | from: '2021-01-17T05:00:00.000Z', 51 | till: '2021-01-18T04:59:59.000Z', 52 | workspace_id: 619 53 | } 54 | 55 | TogglService 56 | .any_instance 57 | .expects(:load_time_entries) 58 | .with( 59 | start_date: DateTime.parse(data[:from]), 60 | end_date: DateTime.parse(data[:till]), 61 | workspace_id: 619 62 | ) 63 | .returns(time_entries) 64 | 65 | get "/toggl2redmine/toggl/time_entries?#{data.to_query}" 66 | 67 | assert_response :success 68 | 69 | expectation = { 70 | "#{issue.id}:prepare food:pending" => { 71 | 'key' => "#{issue.id}:prepare food:pending", 72 | 'ids' => [154, 155], 73 | 'issue_id' => issue.id, 74 | 'comments' => 'Prepare food', 75 | 'duration' => 450, 76 | 'status' => 'pending', 77 | 'errors' => [], 78 | 'issue' => { 79 | 'id' => issue.id, 80 | 'subject' => 'Abstract apples', 81 | 'tracker' => { 82 | 'id' => issue.tracker.id, 83 | 'name' => 'Task' 84 | }, 85 | 'is_closed' => issue.closed?, 86 | 'path' => "/issues/#{issue.id}" 87 | }, 88 | 'project' => { 89 | 'id' => issue.project.id, 90 | 'name' => 'Project alpha', 91 | 'status' => 1, 92 | 'path' => '/projects/alpha' 93 | } 94 | }, 95 | "#{issue.id}:feed bunny:running" => { 96 | 'key' => "#{issue.id}:feed bunny:running", 97 | 'ids' => [246], 98 | 'issue_id' => issue.id, 99 | 'comments' => 'Feed bunny', 100 | 'duration' => -1, 101 | 'status' => 'running', 102 | 'errors' => [], 103 | 'issue' => { 104 | 'id' => issue.id, 105 | 'subject' => 'Abstract apples', 106 | 'tracker' => { 107 | 'id' => issue.tracker.id, 108 | 'name' => 'Task' 109 | }, 110 | 'is_closed' => false, 111 | 'path' => "/issues/#{issue.id}" 112 | }, 113 | 'project' => { 114 | 'id' => issue.project.id, 115 | 'name' => 'Project alpha', 116 | 'status' => 1, 117 | 'path' => '/projects/alpha' 118 | } 119 | } 120 | } 121 | 122 | assert_equal(expectation, @response.parsed_body) 123 | end 124 | 125 | test ".read_time_entries fails if param 'from' is missing" do 126 | data = { till: DateTime.now } 127 | 128 | get "/toggl2redmine/toggl/time_entries?#{data.to_query}" 129 | 130 | assert_response 400 131 | assert_equal( 132 | { 'errors' => ['param is missing or the value is empty: from'] }, 133 | @response.parsed_body 134 | ) 135 | end 136 | 137 | test ".read_time_entries fails if param 'till' is missing" do 138 | data = { from: DateTime.now } 139 | 140 | get "/toggl2redmine/toggl/time_entries?#{data.to_query}" 141 | 142 | assert_response 400 143 | assert_equal( 144 | { 'errors' => ['param is missing or the value is empty: till'] }, 145 | @response.parsed_body 146 | ) 147 | end 148 | 149 | test '.read_time_entries returns issue when it exists and the user belongs to the project' do 150 | issue = issues(:alpha_001) 151 | 152 | time_entries = [ 153 | TogglTimeEntry.new( 154 | id: 246, 155 | wid: 2, 156 | duration: -1, 157 | at: '2021-01-17T21:51:34+00:00', 158 | description: "##{issue.id} Feed bunny" 159 | ) 160 | ] 161 | 162 | data = { 163 | from: '2021-01-17T05:00:00.000Z', 164 | till: '2021-01-18T04:59:59.000Z' 165 | } 166 | 167 | TogglService 168 | .any_instance 169 | .expects(:load_time_entries) 170 | .with( 171 | start_date: DateTime.parse(data[:from]), 172 | end_date: DateTime.parse(data[:till]), 173 | workspace_id: nil 174 | ) 175 | .returns(time_entries) 176 | 177 | get "/toggl2redmine/toggl/time_entries?#{data.to_query}" 178 | 179 | assert_response :success 180 | 181 | expectation = { 182 | "#{issue.id}:feed bunny:running" => { 183 | 'key' => "#{issue.id}:feed bunny:running", 184 | 'ids' => [246], 185 | 'issue_id' => issue.id, 186 | 'comments' => 'Feed bunny', 187 | 'duration' => -1, 188 | 'status' => 'running', 189 | 'errors' => [], 190 | 'issue' => { 191 | 'id' => issue.id, 192 | 'subject' => issue.subject, 193 | 'tracker' => { 194 | 'id' => issue.tracker.id, 195 | 'name' => issue.tracker.name 196 | }, 197 | 'is_closed' => issue.closed?, 198 | 'path' => "/issues/#{issue.id}" 199 | }, 200 | 'project' => { 201 | 'id' => issue.project.id, 202 | 'name' => issue.project.name, 203 | 'status' => issue.project.status, 204 | 'path' => project_path(issue.project) 205 | } 206 | } 207 | } 208 | 209 | assert_equal(expectation.as_json, @response.parsed_body) 210 | end 211 | 212 | test ".read_time_entries returns issue as nil when it doesn't exist" do 213 | time_entries = [ 214 | TogglTimeEntry.new( 215 | id: 246, 216 | wid: 2, 217 | duration: 300, 218 | at: '2021-01-17T21:51:34+00:00', 219 | description: '#19 Feed bunny' 220 | ) 221 | ] 222 | 223 | data = { 224 | from: '2021-01-17T05:00:00.000Z', 225 | till: '2021-01-18T04:59:59.000Z' 226 | } 227 | 228 | TogglService 229 | .any_instance 230 | .expects(:load_time_entries) 231 | .with( 232 | start_date: DateTime.parse(data[:from]), 233 | end_date: DateTime.parse(data[:till]), 234 | workspace_id: nil 235 | ) 236 | .returns(time_entries) 237 | 238 | get "/toggl2redmine/toggl/time_entries?#{data.to_query}" 239 | 240 | assert_response :success 241 | 242 | expectation = { 243 | '19:feed bunny:pending' => { 244 | 'key' => '19:feed bunny:pending', 245 | 'ids' => [246], 246 | 'issue_id' => 19, 247 | 'comments' => 'Feed bunny', 248 | 'duration' => 300, 249 | 'status' => 'pending', 250 | 'errors' => [I18n.t('t2r.error.issue_not_found')], 251 | 'issue' => nil, 252 | 'project' => nil 253 | } 254 | } 255 | 256 | assert_equal(expectation.as_json, @response.parsed_body) 257 | end 258 | 259 | test ".read_time_entries returns issue as nil when user doesn't belong to the project" do 260 | issue = issues(:delta_001) 261 | 262 | time_entries = [ 263 | TogglTimeEntry.new( 264 | id: 246, 265 | wid: 2, 266 | duration: 300, 267 | at: '2021-01-17T21:51:34+00:00', 268 | description: "##{issue.id} Feed bunny" 269 | ) 270 | ] 271 | 272 | data = { 273 | from: '2021-01-17T05:00:00.000Z', 274 | till: '2021-01-18T04:59:59.000Z' 275 | } 276 | 277 | TogglService 278 | .any_instance 279 | .expects(:load_time_entries) 280 | .with( 281 | start_date: DateTime.parse(data[:from]), 282 | end_date: DateTime.parse(data[:till]), 283 | workspace_id: nil 284 | ) 285 | .returns(time_entries) 286 | 287 | get "/toggl2redmine/toggl/time_entries?#{data.to_query}" 288 | 289 | assert_response :success 290 | 291 | expectation = { 292 | "#{issue.id}:feed bunny:pending" => { 293 | 'key' => "#{issue.id}:feed bunny:pending", 294 | 'ids' => [246], 295 | 'issue_id' => issue.id, 296 | 'comments' => 'Feed bunny', 297 | 'duration' => 300, 298 | 'status' => 'pending', 299 | 'errors' => [], 300 | 'issue' => nil, 301 | 'project' => nil 302 | } 303 | } 304 | 305 | assert_equal(expectation.as_json, @response.parsed_body) 306 | end 307 | 308 | test '.read_workspaces exposes workspaces as JSON' do 309 | workspaces = [ 310 | TogglWorkspace.new(id: 1, name: 'Workspace 1'), 311 | TogglWorkspace.new(id: 2, name: 'Workspace 2') 312 | ] 313 | 314 | TogglService 315 | .any_instance 316 | .expects(:load_workspaces) 317 | .returns(workspaces) 318 | 319 | get '/toggl2redmine/toggl/workspaces' 320 | 321 | expectation = [ 322 | { 'id' => 1, 'name' => 'Workspace 1' }, 323 | { 'id' => 2, 'name' => 'Workspace 2' } 324 | ] 325 | 326 | assert_response :success 327 | assert_equal(expectation, @response.parsed_body) 328 | end 329 | end 330 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "#{Rails.root}/test/test_helper" 4 | 5 | module T2r 6 | class TestCase < ActiveSupport::TestCase 7 | self.fixture_path = 8 | File.join(Toggl2Redmine.root, 'test', 'fixtures') 9 | end 10 | 11 | class IntegrationTest < Redmine::IntegrationTest 12 | self.fixture_path = 13 | File.join(Toggl2Redmine.root, 'test', 'fixtures') 14 | 15 | protected 16 | 17 | def log_user(login, password) 18 | post signin_url, params: { 19 | username: login, 20 | password: password 21 | } 22 | end 23 | 24 | def set_custom_field_value(user, field, value) 25 | assert_not_nil(field, "Unexpected: Field 'Toggl API Token' not found") 26 | 27 | custom_value = 28 | CustomValue.find_by(customized: user, custom_field: field) || 29 | CustomValue.new(customized: user, custom_field: field) 30 | 31 | custom_value.value = value 32 | custom_value.save! 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/unit/models/toggl_mapping_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../test_helper' 4 | 5 | class TogglMappingTest < ActiveSupport::TestCase 6 | test 'simple attribute readers' do 7 | subject = TogglMapping.new( 8 | toggl_id: 19, 9 | time_entry_id: 89, 10 | created_at: DateTime.strptime('2021-01-16T15:30:04+00:00') 11 | ) 12 | 13 | assert_equal(19, subject.toggl_id) 14 | assert_equal(89, subject.time_entry_id) 15 | assert_equal(DateTime.strptime('2021-01-16T15:30:04+00:00'), subject.created_at) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/unit/models/toggl_service_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../test_helper' 4 | require 'base64' 5 | 6 | class TogglServiceTest < ActiveSupport::TestCase 7 | TOGGL_API_KEY = 'foobar' 8 | 9 | test '::ENDPOINT' do 10 | assert_equal('https://api.track.toggl.com', TogglService::ENDPOINT) 11 | end 12 | 13 | test '.api_key' do 14 | subject = TogglService.new(TOGGL_API_KEY) 15 | assert_equal('foobar', subject.api_key) 16 | end 17 | 18 | test '.load_time_entries raises Error on failure' do 19 | query = { 20 | start_date: Time.now, 21 | end_date: Time.now - 24.hours 22 | } 23 | 24 | mock_response = 25 | Net::HTTPServerError.new(1.0, '500', 'Service unavailable') 26 | mock_response.expects(:body).returns(nil) 27 | 28 | Net::HTTP 29 | .any_instance 30 | .expects(:request) 31 | .returns(mock_response) 32 | 33 | subject = TogglService.new(TOGGL_API_KEY) 34 | assert_raises(TogglService::Error) { subject.load_time_entries(query) } 35 | end 36 | 37 | test '.load_time_entries returns Array of TogglTimeEntry on success' do 38 | query = { 39 | start_date: Time.now, 40 | end_date: Time.now - 24.hours 41 | } 42 | 43 | mock_response = 44 | Net::HTTPSuccess.new(1.0, '200', 'OK') 45 | 46 | mock_response 47 | .expects(:body) 48 | .returns(mock_json_response('time_entries')) 49 | 50 | Net::HTTP 51 | .any_instance 52 | .expects(:request) 53 | .with do |request| 54 | request_hash = request.to_hash 55 | 56 | assert_equal(['application/json'], request_hash['accept']) 57 | assert_equal( 58 | [authorization_header(TOGGL_API_KEY, 'api_token')], 59 | request_hash['authorization'] 60 | ) 61 | 62 | assert_equal(TogglService::ENDPOINT, "#{request.uri.scheme}://#{request.uri.host}") 63 | assert_equal('/api/v8/time_entries', request.uri.path) 64 | end 65 | .returns(mock_response) 66 | 67 | subject = TogglService.new(TOGGL_API_KEY) 68 | result = subject.load_time_entries(query) 69 | 70 | # rubocop:disable Layout/LineLength 71 | expected = [ 72 | { id: 1_844_094_802, wid: 2_618_724, duration: 300, description: '#1 Preparing meal', at: '2021-01-17T21:23:18+00:00' }, 73 | { id: 1_844_094_618, wid: 2_618_724, duration: 1800, description: '#1 Preparing meal', at: '2021-01-17T21:23:22+00:00' }, 74 | { id: 1_844_094_426, wid: 2_618_724, duration: 720, description: '#1 Feeding the llamas', at: '2021-01-17T21:22:19+00:00' }, 75 | { id: 1_844_093_947, wid: 99, duration: 1200, description: '#2 Reticulating splines', at: '2021-01-17T21:21:01+00:00' }, 76 | { id: 1_844_094_084, wid: 99, duration: 600, description: '#2 Reticulating splines', at: '2021-01-17T21:21:34+00:00' } 77 | ].map { |r| TogglTimeEntry.new(r) } 78 | # rubocop:enable Layout/LineLength 79 | 80 | assert_equal(expected, result) 81 | end 82 | 83 | test '.load_time_entries filters entries by workspace ID' do 84 | query = { 85 | start_date: Time.now, 86 | end_date: Time.now - 24.hours, 87 | workspace_id: 99 88 | } 89 | 90 | mock_response = 91 | Net::HTTPSuccess.new(1.0, '200', 'OK') 92 | 93 | mock_response 94 | .expects(:body) 95 | .returns(mock_json_response('time_entries')) 96 | 97 | Net::HTTP 98 | .any_instance 99 | .expects(:request) 100 | .with do |request| 101 | # Workspace filter is handled by the TogglService. 102 | assert_equal( 103 | { 104 | start_date: query[:start_date].iso8601, 105 | end_date: query[:end_date].iso8601 106 | }.to_query, 107 | request.uri.query 108 | ) 109 | end 110 | .returns(mock_response) 111 | 112 | subject = TogglService.new(TOGGL_API_KEY) 113 | result = subject.load_time_entries(query) 114 | 115 | expected = [ 116 | { 117 | id: 1_844_093_947, 118 | wid: 99, 119 | duration: 1200, 120 | description: '#2 Reticulating splines', 121 | at: '2021-01-17T21:21:01+00:00' 122 | }, 123 | { 124 | id: 1_844_094_084, 125 | wid: 99, 126 | duration: 600, 127 | description: '#2 Reticulating splines', 128 | at: '2021-01-17T21:21:34+00:00' 129 | } 130 | ].map { |r| TogglTimeEntry.new(r) } 131 | 132 | assert_equal(expected, result) 133 | end 134 | 135 | test '.load_time_entries fails if workspace ID is not numeric' do 136 | query = { 137 | start_date: Time.now, 138 | end_date: Time.now - 24.hours, 139 | workspaceId: 'a' 140 | } 141 | 142 | subject = TogglService.new(TOGGL_API_KEY) 143 | assert_raises(ArgumentError) { subject.load_time_entries(query) } 144 | end 145 | 146 | test '.load_workspaces raises Error on failure' do 147 | mock_response = 148 | Net::HTTPServerError.new(1.0, '500', 'Service unavailable') 149 | mock_response.expects(:body).returns(nil) 150 | 151 | Net::HTTP 152 | .any_instance 153 | .expects(:request) 154 | .returns(mock_response) 155 | 156 | subject = TogglService.new(TOGGL_API_KEY) 157 | assert_raises(TogglService::Error) { subject.load_workspaces } 158 | end 159 | 160 | test '.load_workspaces returns Array of TogglWorkspace on success' do 161 | expected = mock_json_response('workspaces') 162 | 163 | mock_response = 164 | Net::HTTPSuccess.new(1.0, '200', 'OK') 165 | 166 | mock_response 167 | .expects(:body) 168 | .returns(expected) 169 | 170 | Net::HTTP 171 | .any_instance 172 | .expects(:request) 173 | .with do |request| 174 | request_hash = request.to_hash 175 | 176 | assert_equal(['application/json'], request_hash['accept']) 177 | assert_equal( 178 | [authorization_header(TOGGL_API_KEY, 'api_token')], 179 | request_hash['authorization'] 180 | ) 181 | 182 | assert_equal(TogglService::ENDPOINT, "#{request.uri.scheme}://#{request.uri.host}") 183 | assert_equal('/api/v8/workspaces', request.path) 184 | end 185 | .returns(mock_response) 186 | 187 | subject = TogglService.new(TOGGL_API_KEY) 188 | result = subject.load_workspaces 189 | 190 | expected = [ 191 | { id: 2_618_724, name: 'Workspace 1' }, 192 | { id: 2_721_799, name: 'Workspace 2' }, 193 | { id: 2_921_822, name: 'Workspace 3' } 194 | ].map { |r| TogglWorkspace.new(r) } 195 | 196 | assert_equal(expected, result) 197 | end 198 | 199 | private 200 | 201 | # type: time_entries|workspaces 202 | def mock_json_response(type) 203 | path = File.join(Toggl2Redmine.root, 'test', 'fixtures', 'toggl', "#{type}.json") 204 | File.read(path) 205 | end 206 | 207 | def authorization_header(username, password) 208 | value = Base64.encode64("#{username}:#{password}") 209 | "Basic #{value}".chomp 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /test/unit/models/toggl_time_entry_group_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../test_helper' 4 | 5 | class TogglTimeEntryGroupTest < T2r::TestCase 6 | fixtures :all 7 | 8 | test '.new' do 9 | expected = [ 10 | build_time_entry_record, 11 | build_time_entry_record, 12 | build_time_entry_record 13 | ] 14 | 15 | subject = TogglTimeEntryGroup.new(*expected) 16 | 17 | assert_equal(expected, subject.entries) 18 | end 19 | 20 | test '.key' do 21 | subject = TogglTimeEntryGroup.new 22 | 23 | assert_nil(subject.key) 24 | 25 | record = build_time_entry_record 26 | subject << record 27 | 28 | refute_nil(subject.key) 29 | assert_equal(record.key, subject.key) 30 | end 31 | 32 | test '.issue_id' do 33 | subject = TogglTimeEntryGroup.new 34 | 35 | assert_nil(subject.issue_id) 36 | 37 | record = build_time_entry_record 38 | subject << record 39 | 40 | refute_nil(subject.issue_id) 41 | assert_equal(record.issue_id, subject.issue_id) 42 | end 43 | 44 | test '.project' do 45 | subject = TogglTimeEntryGroup.new 46 | issue = issues(:alpha_001) 47 | subject << build_time_entry_record(description: "##{issue.id} Lorem ipsum") 48 | 49 | refute_nil(subject.project) 50 | assert_equal(issue.project, subject.project) 51 | end 52 | 53 | test '.duration' do 54 | subject = TogglTimeEntryGroup.new 55 | 56 | assert_predicate(subject.duration, :zero?) 57 | 58 | record = build_time_entry_record 59 | subject << record 60 | 61 | refute_nil(subject.duration) 62 | assert_equal(record.duration, subject.duration) 63 | end 64 | 65 | test '.comments' do 66 | subject = TogglTimeEntryGroup.new 67 | 68 | assert_nil(subject.comments) 69 | 70 | record = build_time_entry_record 71 | subject << record 72 | 73 | refute_nil(subject.comments) 74 | assert_equal(record.comments, subject.comments) 75 | end 76 | 77 | test '.status' do 78 | subject = TogglTimeEntryGroup.new 79 | 80 | assert_nil(subject.status) 81 | 82 | record = build_time_entry_record 83 | subject << record 84 | 85 | refute_nil(subject.status) 86 | assert_equal(record.status, subject.status) 87 | end 88 | 89 | test '.errors contains a message for closed project' do 90 | issue = issues(:charlie_001) 91 | subject = TogglTimeEntryGroup.new 92 | record = build_time_entry_record(description: "##{issue.id} Lorem ipsum") 93 | subject << record 94 | 95 | assert_equal([I18n.t('t2r.error.project_closed')], subject.errors) 96 | end 97 | 98 | test '.errors contains a message for missing issue id' do 99 | subject = TogglTimeEntryGroup.new 100 | record = build_time_entry_record(description: 'Lorem ipsum') 101 | subject << record 102 | 103 | assert_equal([I18n.t('t2r.error.issue_id_missing')], subject.errors) 104 | end 105 | 106 | test '.errors contains a message if issue id doesnt match an issue' do 107 | subject = TogglTimeEntryGroup.new 108 | record = build_time_entry_record(description: '#56 Lorem ipsum') 109 | subject << record 110 | 111 | assert_equal([I18n.t('t2r.error.issue_not_found')], subject.errors) 112 | end 113 | 114 | test '.imported?' do 115 | subject = TogglTimeEntryGroup.new 116 | 117 | refute(subject.imported?) 118 | 119 | record = build_time_entry_record 120 | subject << record 121 | 122 | refute_nil(subject.duration) 123 | assert_equal(record.imported?, subject.imported?) 124 | end 125 | 126 | test '.running? returns false if the group contains stopped entries' do 127 | subject = TogglTimeEntryGroup.new 128 | 129 | refute(subject.running?) 130 | 131 | subject << build_time_entry_record(duration: 300) 132 | 133 | refute(subject.running?) 134 | end 135 | 136 | test '.running? returns true if the group contains running entries' do 137 | subject = TogglTimeEntryGroup.new 138 | 139 | refute(subject.running?) 140 | 141 | subject << build_time_entry_record(duration: -1) 142 | 143 | assert(subject.running?) 144 | end 145 | 146 | test '<< inserts entries with same key' do 147 | subject = TogglTimeEntryGroup.new 148 | 149 | r1 = build_time_entry_record(description: '#150 Play video games', duration: 300) 150 | r2 = build_time_entry_record(description: '#150 Play video games', duration: 500) 151 | 152 | assert_equal(r1.key, r2.key) 153 | 154 | subject << r1 155 | subject << r2 156 | 157 | assert_equal(2, subject.entries.length) 158 | end 159 | 160 | test '<< inserts raises on entry type mismatch' do 161 | subject = TogglTimeEntryGroup.new( 162 | build_time_entry_record(description: '#150 Play video games', duration: 300) 163 | ) 164 | 165 | error = assert_raises(ArgumentError) { subject << BasicObject.new } 166 | assert_match('Argument must be a TogglTimeEntry', error.message) 167 | end 168 | 169 | test '<< inserts raises on entry key mismatch' do 170 | subject = TogglTimeEntryGroup.new( 171 | build_time_entry_record(description: '#150 Play video games', duration: 300) 172 | ) 173 | 174 | entries = [ 175 | # key different due to issue ID 176 | build_time_entry_record(description: '#152 Play video games'), 177 | # key different due to comments 178 | build_time_entry_record(description: '#150 Play games'), 179 | # key different due to status 180 | build_time_entry_record(description: '#150 Play video games', duration: -1) 181 | ] 182 | entries.each do |entry| 183 | error = assert_raises(ArgumentError) { subject << entry } 184 | assert_equal("Only items with key '#{subject.key}' can be added", error.message) 185 | end 186 | 187 | assert_equal(1, subject.entries.length) 188 | end 189 | 190 | test '.as_json serializes a group without entries' do 191 | subject = TogglTimeEntryGroup.new 192 | 193 | expected = { 194 | 'key' => nil, 195 | 'ids' => [], 196 | 'issue_id' => nil, 197 | 'duration' => 0, 198 | 'comments' => nil, 199 | 'status' => nil, 200 | 'errors' => [] 201 | } 202 | 203 | assert_equal(expected, subject.as_json) 204 | end 205 | 206 | test '.as_json serializes a group with entries' do 207 | issue = issues(:alpha_001) 208 | subject = TogglTimeEntryGroup.new 209 | 210 | r1 = build_time_entry_record(id: 20, duration: 30, description: "##{issue.id} Hello world") 211 | subject << r1 212 | 213 | r2 = build_time_entry_record(id: 30, duration: 20, description: "##{issue.id} Hello world") 214 | subject << r2 215 | 216 | expected = { 217 | 'key' => r1.key, 218 | 'ids' => [r1.id, r2.id], 219 | 'issue_id' => r1.issue_id, 220 | 'duration' => r1.duration + r2.duration, 221 | 'comments' => r1.comments, 222 | 'status' => r1.status, 223 | 'errors' => [] 224 | } 225 | 226 | assert_equal(expected, subject.as_json) 227 | end 228 | 229 | test '.group' do 230 | g1e1 = build_time_entry_record(description: '#121 Board meeting') 231 | g1e2 = build_time_entry_record(description: '#121 Board meeting') 232 | g1e3 = build_time_entry_record(description: '#121 Board meeting') 233 | 234 | g2e1 = build_time_entry_record(duration: -1, description: '#2 Timer running') 235 | 236 | g3e1 = build_time_entry_record(description: '#19 Board meeting') 237 | g3e2 = build_time_entry_record(description: '#19 Board meeting') 238 | 239 | g4e1 = build_time_entry_record(description: '#19 Send post-meeting report') 240 | 241 | expected = { 242 | g1e1.key => TogglTimeEntryGroup.new(g1e1, g1e2, g1e3), 243 | g2e1.key => TogglTimeEntryGroup.new(g2e1), 244 | g3e1.key => TogglTimeEntryGroup.new(g3e1, g3e2), 245 | g4e1.key => TogglTimeEntryGroup.new(g4e1) 246 | } 247 | 248 | assert_equal( 249 | expected, 250 | TogglTimeEntryGroup.group( 251 | [ 252 | g1e1, g1e2, g1e3, 253 | g2e1, 254 | g3e1, g3e2, 255 | g4e1 256 | ] 257 | ) 258 | ) 259 | end 260 | 261 | private 262 | 263 | # TODO: Create TogglTimeEntry#example 264 | def build_time_entry_record(attributes = {}) 265 | attributes = { 266 | id: rand(1..999), 267 | wid: rand(1..99), 268 | duration: rand(1..999), 269 | at: '2021-01-16T15:30:04+00:00', 270 | description: '#19 Lorem impsum' 271 | }.merge(attributes) 272 | 273 | TogglTimeEntry.new(attributes) 274 | end 275 | end 276 | -------------------------------------------------------------------------------- /test/unit/models/toggl_time_entry_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../test_helper' 4 | 5 | class TogglTimeEntryTest < ActiveSupport::TestCase 6 | SAMPLE_ATTRIBUTES = { 7 | id: 900_123, 8 | wid: 500, 9 | duration: 300, 10 | at: '2021-01-16T15:30:04+00:00', 11 | description: '#19 Bunny wabbit' 12 | }.freeze 13 | 14 | test 'simple attribute readers work correctly' do 15 | subject = build_subject 16 | 17 | assert_equal(SAMPLE_ATTRIBUTES[:id], subject.id) 18 | assert_equal(SAMPLE_ATTRIBUTES[:wid], subject.wid) 19 | assert_equal(SAMPLE_ATTRIBUTES[:duration], subject.duration) 20 | assert_equal(SAMPLE_ATTRIBUTES[:at], subject.at) 21 | assert_equal(19, subject.issue_id) 22 | assert_equal('Bunny wabbit', subject.comments) 23 | end 24 | 25 | test ".new can parse the description '#123'" do 26 | subject = build_subject(description: '#123') 27 | 28 | assert_equal(123, subject.issue_id) 29 | assert_equal('', subject.comments) 30 | end 31 | 32 | test ".new can parse the description '#123 hey there, amigo'" do 33 | subject = build_subject(description: '#123 hey there, amigo') 34 | 35 | assert_equal(123, subject.issue_id) 36 | assert_equal('hey there, amigo', subject.comments) 37 | end 38 | 39 | test ".new can parse the description 'hey there, amigo'" do 40 | subject = build_subject(description: 'hey there, amigo') 41 | 42 | assert_nil(subject.issue_id) 43 | assert_nil(subject.comments) 44 | end 45 | 46 | test ".new can parse the description ''" do 47 | subject = build_subject(description: '') 48 | 49 | assert_nil(subject.issue_id) 50 | assert_nil(subject.comments) 51 | end 52 | 53 | test ".new can parse the description '#123 #456 bunny wabbit'" do 54 | subject = build_subject(description: '#123 #456 bunny wabbit') 55 | 56 | assert_equal(123, subject.issue_id) 57 | assert_equal('#456 bunny wabbit', subject.comments) 58 | end 59 | 60 | test ".new can parse the description '##123 bunny wabbit'" do 61 | subject = build_subject(description: '##123 bunny wabbit') 62 | 63 | assert_equal(123, subject.issue_id) 64 | assert_equal('bunny wabbit', subject.comments) 65 | end 66 | 67 | test ".new can parse the description '#123 Bunny wrote '" do 68 | subject = build_subject(description: '#123 Bunny wrote ') 69 | 70 | assert_equal(123, subject.issue_id) 71 | assert_equal('Bunny wrote ', subject.comments) 72 | end 73 | 74 | test '.new ignores unimportant whitespace in description' do 75 | subject = build_subject(description: ' #123 hey there, amigo ') 76 | 77 | assert_equal(123, subject.issue_id) 78 | assert_equal('hey there, amigo', subject.comments) 79 | end 80 | 81 | test '.key returns correct key' do 82 | descriptions = { 83 | '19:bunny wabbit:pending' => build_subject(description: '#19 Bunny wabbit'), 84 | '19::pending' => build_subject(description: '#19'), 85 | '0::pending' => build_subject(description: ''), 86 | '19:bunny wabbit:running' => build_subject(duration: -1) 87 | } 88 | 89 | descriptions.each do |k, subject| 90 | assert_equal(k, subject.key) 91 | end 92 | end 93 | 94 | test '.imported? returns false when a related TogglMapping doesnt exist' do 95 | refute(build_subject.imported?) 96 | end 97 | 98 | test '.imported? returns true when a related TogglMapping exists' do 99 | subject = build_subject 100 | 101 | TogglMapping 102 | .expects(:find_by) 103 | .with(toggl_id: subject.id) 104 | .returns(TogglMapping.new) 105 | 106 | assert(subject.imported?) 107 | end 108 | 109 | test '.running? returns false when duration is non-negative' do 110 | refute(build_subject.running?) 111 | refute(build_subject(duration: 0).running?) 112 | end 113 | 114 | test '.running? returns true when duration is negative' do 115 | assert(build_subject(duration: -1).running?) 116 | end 117 | 118 | test ".status returns 'pending' when entry is not imported" do 119 | assert_equal('pending', build_subject.status) 120 | end 121 | 122 | test ".status returns 'running' when entry is running" do 123 | assert_equal('running', build_subject(duration: -1).status) 124 | end 125 | 126 | test ".status returns 'imported' when entry is imported" do 127 | subject = build_subject 128 | subject.expects(:imported?).returns(true) 129 | 130 | assert_equal('imported', subject.status) 131 | end 132 | 133 | test '.issue returns nil if issue does not exist' do 134 | subject = build_subject(description: "#999_999_999 doesn't exist") 135 | assert_nil(subject.issue) 136 | end 137 | 138 | test '.issue returns Issue if issue exists' do 139 | subject = build_subject 140 | issue = Issue.new 141 | 142 | Issue.expects(:find_by).with(id: subject.issue_id).once.returns(issue) 143 | 144 | assert_same(issue, subject.issue) 145 | end 146 | 147 | test '.as_json' do 148 | subject = build_subject 149 | expected = { 150 | 'id' => subject.id, 151 | 'wid' => subject.wid, 152 | 'duration' => subject.duration, 153 | 'at' => subject.at, 154 | 'issue_id' => subject.issue_id, 155 | 'comments' => subject.comments, 156 | 'status' => subject.status 157 | } 158 | 159 | assert_equal(expected, subject.as_json) 160 | end 161 | 162 | test '.==' do 163 | subject = TogglTimeEntry.new(SAMPLE_ATTRIBUTES) 164 | 165 | assert_equal( 166 | subject, 167 | build_subject 168 | ) 169 | refute_equal( 170 | subject, 171 | build_subject(id: 50) 172 | ) 173 | refute_equal( 174 | subject, 175 | build_subject(wid: 35) 176 | ) 177 | refute_equal( 178 | subject, 179 | build_subject(at: '2020-01-16T15:30:04+00:00') 180 | ) 181 | refute_equal( 182 | subject, 183 | build_subject(duration: 200) 184 | ) 185 | refute_equal( 186 | subject, 187 | build_subject(description: '#007 James Bond') 188 | ) 189 | end 190 | 191 | private 192 | 193 | def build_subject(attributes = {}) 194 | attributes = SAMPLE_ATTRIBUTES.merge(attributes) 195 | TogglTimeEntry.new(attributes) 196 | end 197 | end 198 | -------------------------------------------------------------------------------- /test/unit/models/toggl_workspace_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../test_helper' 4 | 5 | class TogglWorkspaceTest < ActiveSupport::TestCase 6 | test 'simple attribute readers work correctly' do 7 | subject = TogglWorkspace.new( 8 | id: 19, 9 | name: 'Bunny wabbit' 10 | ) 11 | 12 | assert_equal(19, subject.id) 13 | assert_equal('Bunny wabbit', subject.name) 14 | end 15 | 16 | test '.==' do 17 | subject = TogglWorkspace.new(id: 19, name: 'Bunny wabbit') 18 | 19 | assert_equal( 20 | TogglWorkspace.new(id: 19, name: 'Bunny wabbit'), 21 | subject 22 | ) 23 | 24 | refute_equal( 25 | TogglWorkspace.new(id: 20, name: 'Bunny wabbit'), 26 | subject 27 | ) 28 | 29 | refute_equal( 30 | TogglWorkspace.new(id: 19, name: 'Jerry mouse'), 31 | subject 32 | ) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/unit/toggl_2_redmine_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../test_helper' 4 | 5 | class Toggl2RedmineTest < ActiveSupport::TestCase 6 | test '::VERSION' do 7 | assert_match(/\d+\.\d+\.\d+/, Toggl2Redmine::VERSION) 8 | end 9 | 10 | test '.root' do 11 | assert_equal( 12 | File.join(Rails.root.to_s, 'plugins', 'toggl2redmine'), 13 | Toggl2Redmine.root 14 | ) 15 | end 16 | end 17 | --------------------------------------------------------------------------------