├── .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 | 
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 | [](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 |
92 |
93 | <%= form_tag({}, :data => {:cm_url => time_entries_context_menu_path}) do -%>
94 |
95 |
97 |
98 |
99 |
100 | <%= t :label_issue %> |
101 |
102 | <%= t :label_activity %> |
103 | <%= t 't2r.label_hour_plural' %> |
104 | |
105 |
106 |
107 |
108 |
109 |
110 |
111 | <%= t 't2r.redmine_report_footer' %> |
112 | |
113 | |
114 | |
115 | |
116 |
117 |
118 |
119 |
120 | <% end -%>
121 |
122 |
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 | + ''
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 = '';
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 = $('')
25 | const placeholder = data.placeholder || null
26 | const attributes = data.attributes || null
27 |
28 | if (placeholder) {
29 | $el.append(``)
30 | }
31 |
32 | if (attributes) {
33 | $el.attr(attributes)
34 | }
35 |
36 | for (const value in data.options) {
37 | const label = data.options[value]
38 | $el.append(``)
39 | }
40 |
41 | return $el
42 | }
43 |
44 | function buildDropdownFromRecords(data: {
45 | records: DropdownOption[],
46 | attributes?: { [index:string]: string },
47 | placeholder?: string
48 | }) {
49 | const options = {} as DropdownOptionDictionary
50 | for (const record of data.records) {
51 | options[record.id.toString()] = record.name
52 | }
53 |
54 | return buildDropdownFromDictionary({
55 | options: options,
56 | attributes: data.attributes,
57 | placeholder: data.placeholder
58 | })
59 | }
60 |
61 | interface WidgetInitCallback {
62 | (el: HTMLElement | HTMLInputElement): void
63 | }
64 |
65 | /**
66 | * Initializes all widgets in the given element.
67 | *
68 | * @param {HTMLElement} el
69 | */
70 | export function initialize(el = document.body): void {
71 | $(el).find('[data-t2r-widget]')
72 | .each(function () {
73 | const widgetList = this.getAttribute('data-t2r-widget')
74 | if (!widgetList) return
75 |
76 | for (const widget of widgetList.split(' ')) {
77 | // Initialize one widget only once per element.
78 | const flag = `Widget${widget}Ready`
79 | if (this.dataset[flag] == 'true') {
80 | continue
81 | }
82 |
83 |
84 | let initializer: WidgetInitCallback
85 | switch (widget) {
86 | case 'Tooltip':
87 | initializer = initTooltip
88 | break
89 |
90 | case 'TogglRow':
91 | initializer = initTogglRow
92 | break
93 |
94 | case 'DurationInput':
95 | initializer = initDurationInput
96 | break
97 |
98 | case 'DurationRoundingMethodDropdown':
99 | initializer = initDurationRoundingMethodDropdown
100 | break
101 |
102 | case 'RedmineActivityDropdown':
103 | initializer = initRedmineActivityDropdown
104 | break
105 |
106 | case 'TogglWorkspaceDropdown':
107 | initializer = initTogglWorkspaceDropdown
108 | break
109 |
110 | default:
111 | throw `Unrecognized widget: ${widget}`
112 | }
113 |
114 | this.dataset[flag] = 'true'
115 | this.classList.add(`t2r-widget-${widget}`)
116 | initializer(this)
117 | }
118 | });
119 | }
120 |
121 | function initTooltip(el: HTMLElement): void {
122 | $(el).tooltip();
123 | }
124 |
125 | function initTogglRow(el: HTMLElement): void {
126 | const $el = $(el);
127 |
128 | // If checkbox changes, update totals.
129 | $el.find('.cb-import')
130 | .on('change', function () {
131 | const $checkbox = $(this);
132 | const $tr = $checkbox.closest('tr');
133 |
134 | // If the row is marked for import, make fields required.
135 | if ($checkbox.is(':checked')) {
136 | $tr.find(':input')
137 | .not('.cb-import')
138 | .removeAttr('disabled')
139 | .attr('required', 'required');
140 | }
141 | // Otherwise, the fields are disabled.
142 | else {
143 | $tr.find(':input')
144 | .not('.cb-import')
145 | .removeAttr('required')
146 | .attr('disabled', 'disabled')
147 | }
148 | })
149 | .trigger('change')
150 |
151 | $el.find(':input').tooltip()
152 | }
153 |
154 | function initDurationInput(el: HTMLElement): void {
155 | const input = el as HTMLInputElement
156 | const $el = $(el);
157 |
158 | $el
159 | .on('input', function() {
160 | const val = $el.val() as string
161 | try {
162 | // If a duration object could be created, then the time is valid.
163 | new datetime.Duration(val)
164 | input.setCustomValidity('')
165 | } catch (e) {
166 | if (e instanceof Error) {
167 | input.setCustomValidity(e.toString())
168 | } else {
169 | throw e
170 | }
171 | }
172 | })
173 | // Update totals as the user updates hours.
174 | .on('keyup', function (e) {
175 | const $input = $(this)
176 | const dur = new datetime.Duration()
177 |
178 | // Detect current duration.
179 | try {
180 | dur.setHHMM(($input.val() as string));
181 | } catch(e) {
182 | return;
183 | }
184 |
185 | // Round to the nearest 5 minutes or 15 minutes.
186 | const mm = dur.minutes % 60
187 | const step = e.shiftKey ? 15 : 5
188 | let delta = 0
189 |
190 | // On "Up" press.
191 | if (e.key === 'ArrowUp') {
192 | delta = step - (mm % step);
193 | dur.add(new datetime.Duration(delta * 60));
194 | }
195 | // On "Down" press.
196 | else if (e.key === 'ArrowDown') {
197 | delta = (mm % step) || step;
198 | dur.sub(new datetime.Duration(delta * 60));
199 | }
200 | // Do nothing.
201 | else {
202 | return
203 | }
204 |
205 | // Update value in the input field.
206 | $(this).val(dur.asHHMM()).trigger('input').trigger('select');
207 | })
208 | .on('change', function () {
209 | const $input = $(this)
210 | const value = $input.val()
211 | const dur = new datetime.Duration()
212 |
213 | // Determine the visible value.
214 | try {
215 | dur.setHHMM(value as string)
216 | } catch(e) {
217 | console.debug(`Could not understand time: ${value}`)
218 | }
219 |
220 | // Update the visible value and the totals.
221 | $input.val(dur.asHHMM())
222 | });
223 | }
224 |
225 | function initDurationRoundingMethodDropdown(el: HTMLElement): void {
226 | const $el = $(el);
227 |
228 | // Prepare rounding options.
229 | const options: DropdownOptionDictionary = {}
230 | options[datetime.DurationRoundingMethod.Regular] = 'Round off'
231 | options[datetime.DurationRoundingMethod.Up] = 'Round up'
232 | options[datetime.DurationRoundingMethod.Down] = 'Round down'
233 |
234 | // Generate a SELECT element and use it's options.
235 | const $select = buildDropdownFromDictionary({
236 | placeholder: 'Don\'t round',
237 | options: options
238 | });
239 |
240 | $el.append($select.find('option'));
241 | }
242 |
243 | function initRedmineActivityDropdown(el: HTMLElement): void {
244 | const $el = $(el)
245 | redmineService.getTimeEntryActivities((activities: DropdownOption[] | null) => {
246 | if (activities === null) return
247 |
248 | // Generate a SELECT element and use its options.
249 | const $select = buildDropdownFromRecords({
250 | placeholder: $el.data('placeholder'),
251 | records: activities
252 | });
253 |
254 | $el.append($select.find('option')).val('');
255 |
256 | const value = $el.data('selected') || '';
257 | $el.val(value).removeData('selected');
258 | })
259 | }
260 |
261 | function initTogglWorkspaceDropdown(el: HTMLElement): void {
262 | const $el = $(el);
263 | redmineService.getTogglWorkspaces((workspaces: models.TogglWorkspace[] | null) => {
264 | if (workspaces === null) return
265 |
266 | // Generate a SELECT element and use its options.
267 | const $select = buildDropdownFromRecords({
268 | placeholder: $el.data('placeholder') as string,
269 | records: workspaces as DropdownOption[]
270 | })
271 |
272 | $el.append($select.find('option'))
273 |
274 | const value = $el.data('selected')
275 | if ('undefined' !== typeof value) {
276 | $el.val(value).data('selected', null)
277 | }
278 | });
279 | }
280 |
--------------------------------------------------------------------------------
/assets.src/javascripts/test/register.js:
--------------------------------------------------------------------------------
1 | require("ts-node").register({
2 | project: "javascripts/test/tsconfig.json",
3 | });
4 |
--------------------------------------------------------------------------------
/assets.src/javascripts/test/t2r/datetime/datetime.test.ts:
--------------------------------------------------------------------------------
1 | import {expect} from 'chai'
2 | import * as datetime from '../../../t2r/datetime'
3 |
4 | describe('class t2r.datetime.DateTime', () => {
5 | it('.constructor()', () => {
6 | const nativeDate = new Date(2021, 12, 16)
7 | const dt = new datetime.DateTime(nativeDate)
8 |
9 | expect(dt.date).to.equal(nativeDate)
10 | })
11 |
12 | it('.fromString()', () => {
13 | const dt = datetime.DateTime.fromString('2021-12-16')
14 | expect(dt.toHTMLDateString()).to.equal('2021-12-16')
15 | })
16 |
17 | it('.fromString() fails for invalid dates', () => {
18 | const entries = [
19 | '2021-13-26',
20 | '2021-12-a'
21 | ]
22 |
23 | for (const entry of entries) {
24 | expect(() => {
25 | datetime.DateTime.fromString(entry)
26 | }).to.throw(`Invalid date: ${entry}`)
27 | }
28 | })
29 |
30 | it('.toHTMLDateString()', () => {
31 | const oDate = new datetime.DateTime(new Date(2021, 11, 16))
32 |
33 | expect(oDate.toHTMLDateString()).to.equal('2021-12-16')
34 | })
35 |
36 | it('.toISOString()', () => {
37 | const nativeDate = new Date(2021, 11, 16, 3, 16, 30)
38 | const oDate = new datetime.DateTime(nativeDate)
39 |
40 | expect(oDate.toISOString()).to.equal('2021-12-16T03:16:30.000Z')
41 | })
42 |
43 | it('.toISOString() with zero time', () => {
44 | const nativeDate = new Date(2021, 11, 16, 3, 16, 0)
45 | const oDate = new datetime.DateTime(nativeDate)
46 |
47 | expect(oDate.toISOString(true)).to.equal('2021-12-16T00:00:00.000Z')
48 | })
49 | })
50 |
--------------------------------------------------------------------------------
/assets.src/javascripts/test/t2r/datetime/duration.test.ts:
--------------------------------------------------------------------------------
1 | import {expect} from 'chai'
2 | import * as datetime from '../../../t2r/datetime'
3 |
4 | describe('class t2r.datetime.Duration', () => {
5 | it('.constructor()', () => {
6 | let dur: datetime.Duration
7 |
8 | // Works with numbers.
9 | dur = new datetime.Duration(300)
10 | expect(dur.seconds).to.equal(300)
11 |
12 | // Works with number as a string.
13 | dur = new datetime.Duration('300')
14 | expect(dur.seconds).to.equal(300)
15 |
16 | // Works with hh:mm.
17 | dur = new datetime.Duration('0:05')
18 | expect(dur.seconds).to.equal(300)
19 | })
20 |
21 | it('.hours getter', () => {
22 | let dur: datetime.Duration
23 |
24 | dur = new datetime.Duration(3600)
25 | expect(dur.hours).to.equal(1)
26 |
27 | dur = new datetime.Duration(5400)
28 | expect(dur.hours).to.equal(1)
29 | })
30 |
31 | it('.minutes getter', () => {
32 | let dur: datetime.Duration
33 |
34 | dur = new datetime.Duration(300)
35 | expect(dur.minutes).to.equal(5)
36 |
37 | dur = new datetime.Duration(90)
38 | expect(dur.minutes).to.equal(1)
39 | })
40 |
41 | it('.seconds getter', () => {
42 | const dur = new datetime.Duration(15)
43 | expect(dur.seconds).to.equal(15)
44 | })
45 |
46 | it('.seconds setter', () => {
47 | const dur = new datetime.Duration(0)
48 | dur.seconds = 15
49 | expect(dur.seconds).to.equal(15)
50 | })
51 |
52 | it('.setHHMM() works with hh:mm', () => {
53 | const dur: datetime.Duration = new datetime.Duration()
54 |
55 | dur.setHHMM('00:05')
56 | expect(dur.asHHMM()).to.equal('00:05')
57 |
58 | dur.setHHMM('1:30')
59 | expect(dur.asHHMM()).to.equal('01:30')
60 | })
61 |
62 | it('.setHHMM() works with hh', () => {
63 | const dur: datetime.Duration = new datetime.Duration()
64 | dur.setHHMM('2')
65 | expect(dur.asHHMM()).to.equal('02:00')
66 | })
67 |
68 | it('.setHHMM() works with :mm', () => {
69 | const dur: datetime.Duration = new datetime.Duration()
70 |
71 | dur.setHHMM(':5')
72 | expect(dur.asHHMM()).to.equal('00:05')
73 |
74 | dur.setHHMM(':30')
75 | expect(dur.asHHMM()).to.equal('00:30')
76 | })
77 |
78 | it('.setHHMM() works with hh.mm', () => {
79 | const dur: datetime.Duration = new datetime.Duration()
80 |
81 | dur.setHHMM('.5')
82 | expect(dur.seconds).to.equal(1800)
83 | expect(dur.asHHMM()).to.equal('00:30')
84 |
85 | dur.setHHMM('2.25')
86 | expect(dur.asHHMM()).to.equal('02:15')
87 |
88 | expect(
89 | () => { dur.setHHMM('2.') }
90 | ).to.throw('Invalid hh:mm format: 2.')
91 |
92 | expect(
93 | () => { dur.setHHMM('.') }
94 | ).to.throw('Invalid hh:mm format: .')
95 | })
96 |
97 | it('.asHHMM()', () => {
98 | let dur: datetime.Duration
99 |
100 | dur = new datetime.Duration(0)
101 | expect(dur.asHHMM()).to.equal('00:00')
102 |
103 | dur = new datetime.Duration(300)
104 | expect(dur.asHHMM()).to.equal('00:05')
105 |
106 | dur = new datetime.Duration(5400)
107 | expect(dur.asHHMM()).to.equal('01:30')
108 | })
109 |
110 | it('.asDecimal()', () => {
111 | let dur: datetime.Duration
112 |
113 | dur = new datetime.Duration(0)
114 | expect(dur.asDecimal()).to.equal('0.00')
115 |
116 | dur = new datetime.Duration(300)
117 | expect(dur.asDecimal()).to.equal('0.08')
118 |
119 | dur = new datetime.Duration(900)
120 | expect(dur.asDecimal()).to.equal('0.25')
121 |
122 | dur = new datetime.Duration(1800)
123 | expect(dur.asDecimal()).to.equal('0.50')
124 |
125 | dur = new datetime.Duration(2700)
126 | expect(dur.asDecimal()).to.equal('0.75')
127 |
128 | dur = new datetime.Duration(3600)
129 | expect(dur.asDecimal()).to.equal('1.00')
130 | })
131 |
132 | it('.add()', () => {
133 | const dur = new datetime.Duration(300)
134 | dur.add(new datetime.Duration(150))
135 | expect(dur.seconds).to.equal(450)
136 | })
137 |
138 | it('.sub()', () => {
139 | const dur = new datetime.Duration(300)
140 | dur.sub(new datetime.Duration(150))
141 | expect(dur.seconds).to.equal(150)
142 | })
143 |
144 | it('.sub() does not allow negative durations', () => {
145 | const dur = new datetime.Duration(300)
146 | dur.sub(new datetime.Duration(450))
147 | expect(dur.seconds).to.equal(0)
148 | })
149 |
150 | it('.roundTo() can round off', () => {
151 | let dur: datetime.Duration
152 |
153 | // 5m to the nearest 5 minute.
154 | dur = new datetime.Duration('00:05')
155 | dur.roundTo(5, datetime.DurationRoundingMethod.Regular)
156 | expect(dur.asHHMM()).to.equal('00:05')
157 |
158 | // 7m 29s to the nearest 5 minute.
159 | dur = new datetime.Duration(7 * 60 + 29)
160 | dur.roundTo(5, datetime.DurationRoundingMethod.Regular)
161 | expect(dur.asHHMM()).to.equal('00:05')
162 |
163 | // 7m 30s to the nearest 5 minute.
164 | dur = new datetime.Duration(7.5 * 60)
165 | dur.roundTo(5, datetime.DurationRoundingMethod.Regular)
166 | expect(dur.asHHMM()).to.equal('00:10')
167 |
168 | // 7m 31s to the nearest 5 minute.
169 | dur = new datetime.Duration(7 * 60 + 31)
170 | dur.roundTo(5, datetime.DurationRoundingMethod.Regular)
171 | expect(dur.asHHMM()).to.equal('00:10')
172 | })
173 |
174 | it('.roundTo() can round up', () => {
175 | let dur: datetime.Duration
176 |
177 | // 5m to the nearest 5 minute.
178 | dur = new datetime.Duration('00:05')
179 | dur.roundTo(5, datetime.DurationRoundingMethod.Up)
180 | expect(dur.asHHMM()).to.equal('00:05')
181 |
182 | // 7m 29s to the nearest 5 minute.
183 | dur = new datetime.Duration(7 * 60 + 29)
184 | dur.roundTo(5, datetime.DurationRoundingMethod.Up)
185 | expect(dur.asHHMM()).to.equal('00:10')
186 |
187 | // 7m 30s to the nearest 5 minute.
188 | dur = new datetime.Duration(7.5 * 60)
189 | dur.roundTo(5, datetime.DurationRoundingMethod.Up)
190 | expect(dur.asHHMM()).to.equal('00:10')
191 |
192 | // 7m 31s to the nearest 5 minute.
193 | dur = new datetime.Duration(7 * 60 + 31)
194 | dur.roundTo(5, datetime.DurationRoundingMethod.Up)
195 | expect(dur.asHHMM()).to.equal('00:10')
196 | })
197 |
198 | it('.roundTo() can round down', () => {
199 | let dur: datetime.Duration
200 |
201 | // 5m to the nearest 5 minute.
202 | dur = new datetime.Duration('00:05')
203 | dur.roundTo(5, datetime.DurationRoundingMethod.Down)
204 | expect(dur.asHHMM()).to.equal('00:05')
205 |
206 | // 7m 29s to the nearest 5 minute.
207 | dur = new datetime.Duration(7 * 60 + 29)
208 | dur.roundTo(5, datetime.DurationRoundingMethod.Down)
209 | expect(dur.asHHMM()).to.equal('00:05')
210 |
211 | // 7m 30s to the nearest 5 minute.
212 | dur = new datetime.Duration(7.5 * 60)
213 | dur.roundTo(5, datetime.DurationRoundingMethod.Down)
214 | expect(dur.asHHMM()).to.equal('00:05')
215 |
216 | // 7m 31s to the nearest 5 minute.
217 | dur = new datetime.Duration(7 * 60 + 31)
218 | dur.roundTo(5, datetime.DurationRoundingMethod.Down)
219 | expect(dur.asHHMM()).to.equal('00:05')
220 | })
221 | })
222 |
--------------------------------------------------------------------------------
/assets.src/javascripts/test/t2r/storage/localstorage.test.ts:
--------------------------------------------------------------------------------
1 | import jsdom from 'jsdom-global'
2 | import {expect} from 'chai'
3 | import {LocalStorage} from '../../../t2r/storage'
4 |
5 | before(() => {
6 | jsdom(``, {url: 'https://localhost'});
7 | })
8 |
9 | after(() => {
10 | // Though it might look strange, this performs cleanup.
11 | jsdom()
12 | })
13 |
14 | describe('class t2r.storage.LocalStorage', () => {
15 | it('prefix is respected', () => {
16 | const storage1 = new LocalStorage('p1.')
17 | const storage2 = new LocalStorage('p2.')
18 |
19 | expect(storage1.prefix).to.equal('p1.')
20 | expect(storage2.prefix).to.equal('p2.')
21 |
22 | storage1.set('foo', 'bar')
23 | expect(storage1.get('foo')).to.equal('bar')
24 | expect(storage2.get('foo')).to.equal(undefined)
25 | })
26 |
27 | it('.set() works with non-empty values', () => {
28 | const storage = new LocalStorage('x.')
29 |
30 | expect(storage.set('foo', 'bar')).to.equal('bar')
31 | expect(storage.get('foo')).to.equal('bar')
32 | })
33 |
34 | it('.set() works with null', () => {
35 | const storage = new LocalStorage('x.')
36 |
37 | storage.set('foo', 'bar')
38 | storage.set('foo', null)
39 | expect(storage.get('foo')).to.equal(undefined)
40 | })
41 |
42 | it('.set() works with undefined', () => {
43 | const storage = new LocalStorage('x.')
44 |
45 | storage.set('foo', 'bar')
46 | storage.set('foo', undefined)
47 | expect(storage.get('foo')).to.equal(undefined)
48 | })
49 |
50 | it('.delete()', () => {
51 | const storage = new LocalStorage('x.')
52 |
53 | storage.set('foo', 'bar')
54 | expect(storage.delete('foo')).to.equal('bar')
55 | expect(storage.get('foo')).to.equal(undefined)
56 | })
57 | })
58 |
--------------------------------------------------------------------------------
/assets.src/javascripts/test/t2r/storage/temporarystorage.test.ts:
--------------------------------------------------------------------------------
1 | import {expect} from 'chai'
2 | import {TemporaryStorage} from '../../../t2r/storage'
3 |
4 | describe('class t2r.storage.TemporaryStorage', () => {
5 | it('.set() works with non-empty values', () => {
6 | const storage = new TemporaryStorage()
7 |
8 | expect(storage.set('foo', 'bar')).to.equal('bar')
9 | expect(storage.get('foo')).to.equal('bar')
10 | })
11 |
12 | it('.set() works with null', () => {
13 | const storage = new TemporaryStorage()
14 |
15 | storage.set('foo', 'bar')
16 | storage.set('foo', null)
17 | expect(storage.get('foo')).to.equal(undefined)
18 | })
19 |
20 | it('.set() works with undefined', () => {
21 | const storage = new TemporaryStorage()
22 |
23 | storage.set('foo', 'bar')
24 | storage.set('foo', undefined)
25 | expect(storage.get('foo')).to.equal(undefined)
26 | })
27 |
28 | it('.delete()', () => {
29 | const storage = new TemporaryStorage()
30 |
31 | storage.set('foo', 'bar')
32 | expect(storage.delete('foo')).to.equal('bar')
33 | expect(storage.get('foo')).to.equal(undefined)
34 | })
35 | })
36 |
--------------------------------------------------------------------------------
/assets.src/javascripts/test/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "module": "commonjs",
5 | "strict": false,
6 | "noImplicitAny": false
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/assets.src/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "toggl2redmine-js",
3 | "version": "1.0.0",
4 | "description": "JavaScript for the Toggl 2 Redmine plugin.",
5 | "main": "t2r.js",
6 | "scripts": {
7 | "lint": "eslint javascripts --ext .ts",
8 | "test": "mocha",
9 | "compile": "tsc",
10 | "watch": "tsc -w"
11 | },
12 | "repository": {
13 | "type": "git",
14 | "url": "git@github.com:jigarius/toggl2redmine.git"
15 | },
16 | "author": "Jigar Mehta",
17 | "license": "ISC",
18 | "bugs": {
19 | "url": "https://github.com/jigarius/toggl2redmine/issues"
20 | },
21 | "homepage": "https://github.com/jigarius/toggl2redmine#readme",
22 | "devDependencies": {
23 | "@types/chai": "^4.2.21",
24 | "@types/jquery": "^3.5.6",
25 | "@types/jqueryui": "^1.12.16",
26 | "@types/jsdom": ">=16.5.0",
27 | "@types/jsdom-global": "^3.0.2",
28 | "@types/mocha": "^9.1.1",
29 | "@typescript-eslint/eslint-plugin": "^4.28.4",
30 | "@typescript-eslint/parser": "^4.28.4",
31 | "chai": "^4.3.4",
32 | "eslint": "^7.31.0",
33 | "jsdom": "^20.0.0",
34 | "jsdom-global": "^3.0.2",
35 | "mocha": "^9.2.2",
36 | "ts-node": "^10.9.1",
37 | "typescript": "^4.8.4"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/assets.src/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "esModuleInterop": true,
4 | "forceConsistentCasingInFileNames": true,
5 | "inlineSourceMap": true,
6 | "module": "es2015",
7 | "moduleResolution": "node",
8 | "outDir": "../assets/javascripts",
9 | "removeComments": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "target": "es2015",
13 | "typeRoots": [
14 | "node_modules/@types"
15 | ]
16 | },
17 | "exclude": [
18 | "javascripts/test"
19 | ]
20 | }
--------------------------------------------------------------------------------
/assets/images/loader.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jigarius/toggl2redmine/c54a2caef1800d4e33526c1ba752358ff41ba49a/assets/images/loader.gif
--------------------------------------------------------------------------------
/assets/javascripts/t2r/datetime.js:
--------------------------------------------------------------------------------
1 | export class DateTime {
2 | constructor(date = undefined) {
3 | this.date = date || new Date();
4 | }
5 | toHTMLDateString() {
6 | const yyyy = this.date.getFullYear();
7 | const mm = (this.date.getMonth() + 1).toString().padStart(2, '0');
8 | const dd = this.date.getDate().toString().padStart(2, '0');
9 | return `${yyyy}-${mm}-${dd}`;
10 | }
11 | toISOString(zeroTime = false) {
12 | if (!zeroTime) {
13 | return this.date.toISOString();
14 | }
15 | return this.date.toISOString().split('T')[0] + 'T00:00:00.000Z';
16 | }
17 | static fromString(date) {
18 | const dateParts = date.split(/[^\d]/).map((part) => {
19 | return parseInt(part);
20 | });
21 | if (dateParts.length < 3) {
22 | throw `Invalid date: ${date}`;
23 | }
24 | for (let i = 3; i <= 6; i++) {
25 | if (typeof dateParts[i] === 'undefined') {
26 | dateParts[i] = 0;
27 | }
28 | }
29 | for (let i = 1; i <= 6; i++) {
30 | if (isNaN(dateParts[i]))
31 | throw `Invalid date: ${date}`;
32 | }
33 | if (dateParts[1] < 1 || dateParts[1] > 12) {
34 | throw `Invalid date: ${date}`;
35 | }
36 | try {
37 | return new DateTime(new Date(dateParts[0], dateParts[1] - 1, dateParts[2], dateParts[3], dateParts[4], dateParts[5], dateParts[6]));
38 | }
39 | catch (e) {
40 | console.error('Invalid date', date);
41 | throw `Invalid date: ${date}`;
42 | }
43 | }
44 | }
45 | export var DurationRoundingMethod;
46 | (function (DurationRoundingMethod) {
47 | DurationRoundingMethod["Up"] = "U";
48 | DurationRoundingMethod["Down"] = "D";
49 | DurationRoundingMethod["Regular"] = "R";
50 | })(DurationRoundingMethod || (DurationRoundingMethod = {}));
51 | export class Duration {
52 | constructor(duration = 0) {
53 | this._seconds = 0;
54 | duration = duration || 0;
55 | if ('number' === typeof duration) {
56 | this.seconds = duration;
57 | return;
58 | }
59 | if (duration.match(/^\d+$/)) {
60 | this.seconds = parseInt(duration);
61 | return;
62 | }
63 | try {
64 | this.setHHMM(duration);
65 | }
66 | catch (e) {
67 | throw 'Error: "' + duration + '" is not a number or an hh:mm string.';
68 | }
69 | }
70 | get hours() {
71 | return Math.floor(this._seconds / 3600);
72 | }
73 | get minutes() {
74 | return Math.floor(this._seconds / 60);
75 | }
76 | get seconds() {
77 | return this._seconds;
78 | }
79 | set seconds(value) {
80 | if (value < 0) {
81 | throw `Value cannot be negative: ${value}`;
82 | }
83 | this._seconds = value;
84 | }
85 | setHHMM(hhmm) {
86 | let parts = [];
87 | let pattern;
88 | let hh;
89 | let mm;
90 | const error = `Invalid hh:mm format: ${hhmm}`;
91 | pattern = /^(\d{0,2})$/;
92 | if (hhmm.match(pattern)) {
93 | const matches = hhmm.match(pattern);
94 | hh = parseInt(matches.pop());
95 | this.seconds = hh * 60 * 60;
96 | return;
97 | }
98 | pattern = /^(\d{0,2}):(\d{0,2})$/;
99 | if (hhmm.match(pattern)) {
100 | const matches = hhmm.match(pattern);
101 | parts = matches.slice(-2);
102 | mm = parseInt(parts.pop() || '0');
103 | hh = parseInt(parts.pop() || '0');
104 | if (mm > 59)
105 | throw error;
106 | this.seconds = hh * 60 * 60 + mm * 60;
107 | return;
108 | }
109 | pattern = /^(\d{0,2})\.(\d{1,2})$/;
110 | if (hhmm.match(pattern)) {
111 | const matches = hhmm.match(pattern);
112 | parts = matches.slice(-2);
113 | hh = parseInt(parts[0] || '0');
114 | hh = Math.round(hh);
115 | mm = parseInt(parts[1] || '0');
116 | mm = (60 * mm) / Math.pow(10, parts[1].length);
117 | this.seconds = hh * 60 * 60 + mm * 60;
118 | return;
119 | }
120 | throw error;
121 | }
122 | asHHMM() {
123 | const hh = this.hours.toString().padStart(2, '0');
124 | const mm = (this.minutes % 60).toString().padStart(2, '0');
125 | return `${hh}:${mm}`;
126 | }
127 | asDecimal() {
128 | const hours = this.minutes / 60;
129 | const output = hours.toFixed(3);
130 | return output.substr(0, output.length - 1);
131 | }
132 | add(other) {
133 | this.seconds = this.seconds + other.seconds;
134 | }
135 | sub(other) {
136 | this.seconds = Math.max(this.seconds - other.seconds, 0);
137 | }
138 | roundTo(minutes, method) {
139 | if (0 === minutes)
140 | return;
141 | const seconds = minutes * 60;
142 | const correction = this.seconds % seconds;
143 | if (correction === 0)
144 | return;
145 | switch (method) {
146 | case DurationRoundingMethod.Regular:
147 | if (correction >= seconds / 2) {
148 | this.roundTo(minutes, DurationRoundingMethod.Up);
149 | }
150 | else {
151 | this.roundTo(minutes, DurationRoundingMethod.Down);
152 | }
153 | break;
154 | case DurationRoundingMethod.Up:
155 | this.add(new Duration(seconds - correction));
156 | break;
157 | case DurationRoundingMethod.Down:
158 | this.sub(new Duration(correction));
159 | break;
160 | default:
161 | throw 'Invalid rounding method.';
162 | }
163 | }
164 | }
165 | //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZGF0ZXRpbWUuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi9hc3NldHMuc3JjL2phdmFzY3JpcHRzL3Qyci9kYXRldGltZS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFHQSxNQUFNLE9BQU8sUUFBUTtJQUluQixZQUFZLE9BQXlCLFNBQVM7UUFDNUMsSUFBSSxDQUFDLElBQUksR0FBRyxJQUFJLElBQUksSUFBSSxJQUFJLEVBQUUsQ0FBQTtJQUNoQyxDQUFDO0lBV0QsZ0JBQWdCO1FBQ2QsTUFBTSxJQUFJLEdBQUcsSUFBSSxDQUFDLElBQUksQ0FBQyxXQUFXLEVBQUUsQ0FBQztRQUNyQyxNQUFNLEVBQUUsR0FBRyxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsUUFBUSxFQUFFLEdBQUcsQ0FBQyxDQUFDLENBQUMsUUFBUSxFQUFFLENBQUMsUUFBUSxDQUFDLENBQUMsRUFBRSxHQUFHLENBQUMsQ0FBQTtRQUNqRSxNQUFNLEVBQUUsR0FBRyxJQUFJLENBQUMsSUFBSSxDQUFDLE9BQU8sRUFBRSxDQUFDLFFBQVEsRUFBRSxDQUFDLFFBQVEsQ0FBQyxDQUFDLEVBQUUsR0FBRyxDQUFDLENBQUE7UUFFMUQsT0FBTyxHQUFHLElBQUksSUFBSSxFQUFFLElBQUksRUFBRSxFQUFFLENBQUE7SUFDOUIsQ0FBQztJQVVELFdBQVcsQ0FBQyxRQUFRLEdBQUcsS0FBSztRQUMxQixJQUFJLENBQUMsUUFBUSxFQUFFO1lBQ2IsT0FBTyxJQUFJLENBQUMsSUFBSSxDQUFDLFdBQVcsRUFBRSxDQUFBO1NBQy9CO1FBRUQsT0FBTyxJQUFJLENBQUMsSUFBSSxDQUFDLFdBQVcsRUFBRSxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUMsQ0FBQyxDQUFDLENBQUMsR0FBRyxnQkFBZ0IsQ0FBQTtJQUNqRSxDQUFDO0lBV0QsTUFBTSxDQUFDLFVBQVUsQ0FBQyxJQUFZO1FBRTVCLE1BQU0sU0FBUyxHQUFhLElBQUksQ0FBQyxLQUFLLENBQUMsT0FBTyxDQUFD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
--------------------------------------------------------------------------------
/assets/javascripts/t2r/flash.js:
--------------------------------------------------------------------------------
1 | var Type;
2 | (function (Type) {
3 | Type["Notice"] = "notice";
4 | Type["Error"] = "error";
5 | Type["Warning"] = "warning";
6 | })(Type || (Type = {}));
7 | function message(text, type = Type.Notice) {
8 | $('#content').prepend(`${text.trim()}
`);
9 | }
10 | export function error(text) {
11 | message(text, Type.Error);
12 | }
13 | export function warning(text) {
14 | message(text, Type.Warning);
15 | }
16 | export function notice(text) {
17 | message(text, Type.Notice);
18 | }
19 | export function clear() {
20 | $('.t2r.flash').remove();
21 | }
22 | //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZmxhc2guanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi9hc3NldHMuc3JjL2phdmFzY3JpcHRzL3Qyci9mbGFzaC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxJQUFLLElBSUo7QUFKRCxXQUFLLElBQUk7SUFDUCx5QkFBaUIsQ0FBQTtJQUNqQix1QkFBZSxDQUFBO0lBQ2YsMkJBQW1CLENBQUE7QUFDckIsQ0FBQyxFQUpJLElBQUksS0FBSixJQUFJLFFBSVI7QUFLRCxTQUFTLE9BQU8sQ0FBQyxJQUFZLEVBQUUsT0FBYSxJQUFJLENBQUMsTUFBTTtJQUNyRCxDQUFDLENBQUMsVUFBVSxDQUFDLENBQUMsT0FBTyxDQUFDLHlCQUF5QixJQUFJLEtBQUssSUFBSSxDQUFDLElBQUksRUFBRSxRQUFRLENBQUMsQ0FBQztBQUMvRSxDQUFDO0FBRUQsTUFBTSxVQUFVLEtBQUssQ0FBQyxJQUFZO0lBQ2hDLE9BQU8sQ0FBQyxJQUFJLEVBQUUsSUFBSSxDQUFDLEtBQUssQ0FBQyxDQUFBO0FBQzNCLENBQUM7QUFFRCxNQUFNLFVBQVUsT0FBTyxDQUFDLElBQVk7SUFDbEMsT0FBTyxDQUFDLElBQUksRUFBRSxJQUFJLENBQUMsT0FBTyxDQUFDLENBQUE7QUFDN0IsQ0FBQztBQUVELE1BQU0sVUFBVSxNQUFNLENBQUMsSUFBWTtJQUNqQyxPQUFPLENBQUMsSUFBSSxFQUFFLElBQUksQ0FBQyxNQUFNLENBQUMsQ0FBQTtBQUM1QixDQUFDO0FBS0QsTUFBTSxVQUFVLEtBQUs7SUFDbkIsQ0FBQyxDQUFDLFlBQVksQ0FBQyxDQUFDLE1BQU0sRUFBRSxDQUFDO0FBQzNCLENBQUMifQ==
--------------------------------------------------------------------------------
/assets/javascripts/t2r/i18n.js:
--------------------------------------------------------------------------------
1 | export function translate(key, vars = {}) {
2 | if (typeof T2R_TRANSLATIONS[key] === 'undefined') {
3 | const lang = $('html').attr('lang') || '??';
4 | return `translation missing: ${lang}.${key}`;
5 | }
6 | let result = T2R_TRANSLATIONS[key];
7 | for (const name in vars) {
8 | result = result.replace('@' + name, vars[name]);
9 | }
10 | return result;
11 | }
12 | //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaTE4bi5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uL2Fzc2V0cy5zcmMvamF2YXNjcmlwdHMvdDJyL2kxOG4udHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBcUJBLE1BQU0sVUFBVSxTQUFTLENBQUMsR0FBVyxFQUFFLE9BQWtCLEVBQUU7SUFDekQsSUFBSSxPQUFPLGdCQUFnQixDQUFDLEdBQUcsQ0FBQyxLQUFLLFdBQVcsRUFBRTtRQUNoRCxNQUFNLElBQUksR0FBRyxDQUFDLENBQUMsTUFBTSxDQUFDLENBQUMsSUFBSSxDQUFDLE1BQU0sQ0FBQyxJQUFJLElBQUksQ0FBQTtRQUMzQyxPQUFPLHdCQUF3QixJQUFJLElBQUksR0FBRyxFQUFFLENBQUE7S0FDN0M7SUFFRCxJQUFJLE1BQU0sR0FBVyxnQkFBZ0IsQ0FBQyxHQUFHLENBQUMsQ0FBQztJQUMzQyxLQUFLLE1BQU0sSUFBSSxJQUFJLElBQUksRUFBRTtRQUN2QixNQUFNLEdBQUcsTUFBTSxDQUFDLE9BQU8sQ0FBQyxHQUFHLEdBQUcsSUFBSSxFQUFFLElBQUksQ0FBQyxJQUFJLENBQUMsQ0FBQyxDQUFBO0tBQ2hEO0lBRUQsT0FBTyxNQUFNLENBQUM7QUFDaEIsQ0FBQyJ9
--------------------------------------------------------------------------------
/assets/javascripts/t2r/models.js:
--------------------------------------------------------------------------------
1 | export {};
2 | //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibW9kZWxzLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vYXNzZXRzLnNyYy9qYXZhc2NyaXB0cy90MnIvbW9kZWxzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiIifQ==
--------------------------------------------------------------------------------
/assets/javascripts/t2r/renderers.js:
--------------------------------------------------------------------------------
1 | import * as utils from './utils.js';
2 | import * as datetime from './datetime.js';
3 | export function renderRedmineProjectLabel(project) {
4 | const classes = ['project'];
5 | if (project.status != 1) {
6 | classes.push('closed');
7 | }
8 | return ''
9 | + utils.htmlEntityEncode(project.name)
10 | + '';
11 | }
12 | export function renderRedmineProjectStubLabel() {
13 | return '-';
14 | }
15 | export function renderRedmineIssueLabel(issue) {
16 | const classes = ['issue'];
17 | if (issue.is_closed) {
18 | classes.push('closed');
19 | }
20 | return ''
21 | + utils.htmlEntityEncode(issue ? issue.tracker.name : '-')
22 | + utils.htmlEntityEncode(issue ? ' #' + issue.id : '')
23 | + utils.htmlEntityEncode(issue.subject ? ': ' + issue.subject : '')
24 | + '';
25 | }
26 | export function renderRedmineIssueStubLabel(issueId) {
27 | if (!issueId) {
28 | return 'Unknown';
29 | }
30 | return '#' + issueId.toString() + ': -';
31 | }
32 | export function renderTogglRow(data) {
33 | const issue = data.issue;
34 | const issueLabel = issue ? renderRedmineIssueLabel(issue) : renderRedmineIssueStubLabel(data.issue_id);
35 | const project = data.project || null;
36 | const projectLabel = project ? renderRedmineProjectLabel(project) : renderRedmineProjectStubLabel();
37 | const oDuration = data.duration;
38 | const rDuration = data.roundedDuration;
39 | const markup = ''
40 | + ' | '
41 | + ' | '
42 | + ''
43 | + ''
46 | + projectLabel + ' ' + issueLabel
47 | + ' | '
48 | + ''
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 = '';
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,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmVuZGVyZXJzLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vYXNzZXRzLnNyYy9qYXZhc2NyaXB0cy90MnIvcmVuZGVyZXJzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUNBLE9BQU8sS0FBSyxLQUFLLE1BQU0sWUFBWSxDQUFBO0FBQ25DLE9BQU8sS0FBSyxRQUFRLE1BQU0sZUFBZSxDQUFBO0FBS3pDLE1BQU0sVUFBVSx5QkFBeUIsQ0FBQyxPQUF1QjtJQUMvRCxNQUFNLE9BQU8sR0FBRyxDQUFDLFNBQVMsQ0FBQyxDQUFDO0lBQzVCLElBQUksT0FBTyxDQUFDLE1BQU0sSUFBSSxDQUFDLEVBQUU7UUFDdkIsT0FBTyxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsQ0FBQTtLQUN2QjtJQUVELE9BQU8sV0FBVyxHQUFHLE9BQU8sQ0FBQyxJQUFJLEdBQUcsV0FBVyxHQUFHLE9BQU8sQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLEdBQUcsNEJBQTRCO1VBQzlGLEtBQUssQ0FBQyxnQkFBZ0IsQ0FBQyxPQUFPLENBQUMsSUFBSSxDQUFDO1VBQ3BDLGVBQWUsQ0FBQztBQUN0QixDQUFDO0FBRUQsTUFBTSxVQUFVLDZCQUE2QjtJQUMzQyxPQUFPLGlEQUFpRCxDQUFBO0FBQzFELENBQUM7QUFFRCxNQUFNLFVBQVUsdUJBQXVCLENBQUMsS0FBbUI7SUFDekQsTUFBTSxPQUFPLEdBQUcsQ0FBQyxPQUFPLENBQUMsQ0FBQTtJQUN6QixJQUFJLEtBQUssQ0FBQyxTQUFTLEVBQUU7UUFDbkIsT0FBTyxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsQ0FBQTtLQUN2QjtJQUVELE9BQU8sV0FBVyxHQUFHLEtBQUssQ0FBQyxJQUFJLEdBQUcsV0FBVyxHQUFHLE9BQU8sQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLEdBQUcsb0JBQW9CO1VBQ3BGLEtBQUssQ0FBQyxnQkFBZ0IsQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxPQUFP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
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------