Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Outlook / Microsoft Events API connection #74

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.idea

# Logs
logs
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ You can also omit the Jira # and time spent and add it later.
* Add, edit and delete worklogs directly from the Chrome Extension;
* Keep track of how many hours you already spent in the tasks;
* Supports SAML and Basic Authentication with Jira app token.
* Also allows to sync events from Outlook Calendar to worklog list for easy logging.

## Getting Started
Before using it, you need to do two things:
Expand All @@ -39,6 +40,18 @@ If by only providing the Jira Hostname the connection fails, you'll need to conf

*_The extension uses the **Jira Hostname** to build the URL and API calls to the Jira instance like this: **`https://jira.atlassian.com/`**`rest/api/2/search`._

### Outlook integration
To sync events from Outlook Calendar to the worklog list, you need to check **Enable Outlook Sync** and provide both the **Tenant ID** and **Client ID**.
You can get these values from the Azure Portal. If you don't have them, please consult your IT department.

For setting up the app in Azure, you can follow the steps below:
1. Go to the [Azure Portal](https://entra.microsoft.com/) and create a new app registration
2. Copy the **Tenant ID** and **Client ID** from the app overview to the extension's [options page](chrome-extension://pekbjnkonfmgjfnbpmindidammhgmjji/options.html)
3. In the app registration, go to the **Authentication** section and add:
- Redirect URI: https://pekbjnkonfmgjfnbpmindidammhgmjji.chromiumapp.org/
- Implicit grant: Access tokens
4. Open **API permissions** and add the Microsoft Graph permission **Calendars.ReadBasic**. Your IT department may need to approve this permission depending on your global account settings, even if it does not require admin consent.

## Some Images

![Main popup screen](/screenshots/popup.png "Main Screen")
Expand Down
6 changes: 3 additions & 3 deletions chrome-extension/js/controller.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
window.Controller = window.Controller || {}
window.Controller.LogController = (function (JiraHelper, Model, JiraParser) {
window.Controller.LogController = (function (JiraHelper, OutlookHelper, Model, JiraParser) {
'use strict'
function init () {
return JiraHelper.init()
return Promise.all([JiraHelper.init(), OutlookHelper.init()])
}

function getWorklogsByDay (worklogDate) {
Expand Down Expand Up @@ -144,4 +144,4 @@ window.Controller.LogController = (function (JiraHelper, Model, JiraParser) {
init: init,
getInvalidFields: getInvalidFields
}
})(window.JiraHelper, window.Model, window.JiraParser)
})(window.JiraHelper, window.OutlookHelper, window.Model, window.JiraParser)
71 changes: 56 additions & 15 deletions chrome-extension/js/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ var chrome = window.chrome || {}
var JiraHelper = window.JiraHelper || {}

// Saves options to chrome.storage
function saveOptions (options) {
function saveOptions (jiraOptions, outlookOptions) {
// make sure to not save user password, as chrome storage is not encrypted (https://developer.chrome.com/apps/storage#using-sync).
// The JESSIONID authentication cookie will be remembered by the browser once User clicks 'Test Connection' anyway,
// and Jira will consider the JESSIONID cookie and ignore the basic auth settings for the requests.
options.password = ''
jiraOptions.password = ''

chrome.storage.sync.set(
{
jiraOptions: options
jiraOptions: jiraOptions,
outlookOptions: outlookOptions
},
function () {
// Update status to let user know options were saved.
Expand All @@ -28,24 +29,34 @@ function saveOptions (options) {
function restoreOptions () {
chrome.storage.sync.get(
{
jiraOptions: {}
jiraOptions: {},
outlookOptions: {}
},
function (items) {
restoreOptionsToInput(items.jiraOptions)
restoreJiraOptionsToInput(items.jiraOptions)
restoreOutlookOptionsToInput(items.outlookOptions)
}
)
}

var jiraUrlInput, userInput, passwordInput, tokenInput
var jiraUrlInput, userInput, passwordInput, tokenInput, outlookSyncEnabledInput, tenantIDInput, clientIDInput, filterPrivateEventsInput, filterSubjectsInput

function restoreOptionsToInput (options) {
function restoreJiraOptionsToInput (options) {
jiraUrlInput.value = options.jiraUrl || ''
userInput.value = options.user || ''
passwordInput.value = options.password || ''
tokenInput.value = options.token || ''
}

function getOptionsFromInput () {
function restoreOutlookOptionsToInput (options) {
outlookSyncEnabledInput.checked = options.outlookSyncEnabled
tenantIDInput.value = options.tenantID || ''
clientIDInput.value = options.clientID || ''
filterPrivateEventsInput.checked = options.filterPrivateEvents
filterSubjectsInput.value = options.filterSubjects || 'Lunch Break\nNotes'
}

function getJiraOptionsFromInput () {
return {
jiraUrl: jiraUrlInput.value,
user: userInput.value,
Expand All @@ -54,28 +65,58 @@ function getOptionsFromInput () {
}
}

function getOutlookOptionsFromInput () {
return {
outlookSyncEnabled: outlookSyncEnabledInput.checked,
tenantID: tenantIDInput.value,
clientID: clientIDInput.value,
filterSubjects: filterSubjectsInput.value,
filterPrivateEvents: filterPrivateEventsInput.checked
}
}

document.addEventListener('DOMContentLoaded', () => {
restoreOptions()
jiraUrlInput = document.getElementById('jiraUrl')
userInput = document.getElementById('user')
passwordInput = document.getElementById('password')
tokenInput = document.getElementById('token')

outlookSyncEnabledInput = document.getElementById('outlookSyncEnabled')
tenantIDInput = document.getElementById('tenantID')
clientIDInput = document.getElementById('clientID')
filterPrivateEventsInput = document.getElementById('filterPrivateEvents')
filterSubjectsInput = document.getElementById('filterSubjects')

document.getElementById('save').addEventListener('click', () => {
saveOptions(getOptionsFromInput())
saveOptions(getJiraOptionsFromInput(), getOutlookOptionsFromInput())
})
document.getElementById('testConnection').addEventListener('click', () => {
var jiraOptions = getOptionsFromInput()
document.getElementById('testConnectionJira').addEventListener('click', () => {
var jiraOptions = getJiraOptionsFromInput()
console.log(jiraOptions)
JiraHelper.testConnection(jiraOptions)
.then(result => {
console.info('connection successful', result)
saveOptions(getOptionsFromInput())
alert('Connection [OK]')
console.info('connection successful')
saveOptions(getJiraOptionsFromInput(), getOutlookOptionsFromInput())
alert('Jira Connection [OK]')
})
.catch(error => {
console.error('connection failed', error)
alert('Jira Connection [FAILED]. Please double-check the options. Error: ' + error)
})
})
document.getElementById('testConnectionOutlook').addEventListener('click', () => {
var outlookOptions = getOutlookOptionsFromInput()
console.log(outlookOptions)
OutlookHelper.testConnection(outlookOptions)
.then(result => {
console.info('connection successful')
saveOptions(getJiraOptionsFromInput(), getOutlookOptionsFromInput())
alert('Outlook Connection [OK]')
})
.catch(error => {
console.error('connection failed', error)
alert('Connection [FAILED]. Please double-check the options.')
alert('Outlook Connection [FAILED]. Please double-check the options. Error: ' + error)
})
})
})
163 changes: 163 additions & 0 deletions chrome-extension/js/outlook-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
(function (chrome) {
var outlookOptions = {}

function setOutlookOptions (options) {
outlookOptions = options
}

function getOutlookOptions () {
return outlookOptions
}

function testConnection (options) {
if (!options.outlookSyncEnabled) {
console.log('Outlook sync is disabled')
return Promise.resolve()
}

return getToken(options);
}

function init () {
return new Promise((resolve, reject) => {
chrome.storage.sync.get(
{
outlookOptions: {}
},
function (items) {
setOutlookOptions(items.outlookOptions)
testConnection(items.outlookOptions)
.then(resolve)
.catch(reject)
}
)
})
}

function getToken(options = outlookOptions) {
return new Promise((resolve, reject) => {
if (options.outlookSyncEnabled && (!options.tenantID || !options.clientID)) {
console.log('Outlook options are not set');
reject('Outlook options are not set');
return;
}

chrome.identity.launchWebAuthFlow(
{
url: `https://login.microsoftonline.com/${options.tenantID}/oauth2/v2.0/authorize?` +
'response_type=token' +
'&response_mode=fragment' +
'&client_id=' + options.clientID +
'&redirect_uri=' + chrome.identity.getRedirectURL() +
'&scope=Calendars.ReadBasic',
interactive: true
},
function (responseWithToken) {
console.log('Authorization response:', responseWithToken);
if (chrome.runtime.lastError || !responseWithToken) {
reject('Authorization failed: ' + chrome.runtime.lastError);
return;
}

const token = extractAccessToken(responseWithToken);
if (token) {
resolve(token);
} else {
reject('Failed to extract access token from response: ' + responseWithToken);
}
}
);
})
}

function extractAccessToken(responseUrl) {
const params = new URLSearchParams(responseUrl.split('#')[1]);
return params.get('access_token');
}

async function fetchAllPages(url, accessToken) {
let allResults = [];

async function fetchPage(url) {
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});

if (!response.ok) {
throw new Error('Failed to fetch calendar entries');
}

const data = await response.json();
allResults = allResults.concat(data.value);

if (data['@odata.nextLink']) {
await fetchPage(data['@odata.nextLink']);
}
}

await fetchPage(url);
return allResults;
}

function fetchCalendarEntries(accessToken, worklogDate, options = outlookOptions) {
return new Promise((resolve, reject) => {
const dateStart = new Date(new Date(worklogDate).setHours(0, 0, 0, 0));
const dateEnd = new Date(new Date(worklogDate).setHours(23, 59, 59, 999));
const url = `https://graph.microsoft.com/v1.0/me/calendar/calendarView?startDateTime=${dateStart.toISOString()}&endDateTime=${dateEnd.toISOString()}`;

fetchAllPages(url, accessToken)
.then(data => {
console.log('Calendar events:', data);

// Sort events by start date
data.sort((a, b) => new Date(a.start.dateTime) - new Date(b.start.dateTime));

const filterSubjects = options.filterSubjects.split('\n');

// Filter out events based on the specified criteria
const filteredData = data.filter(event =>
!event.isAllDay &&
!event.isCancelled &&
(!options.filterPrivateEvents || event.sensitivity !== 'private') &&
!filterSubjects.includes(event.subject)
);

const worklogItems = filteredData.map(event => {
const startTime = new Date(event.start.dateTime);
const endTime = new Date(event.end.dateTime);
const durationMinutes = (endTime - startTime) / (1000 * 60); // Convert milliseconds to minutes
const durationString = durationMinutes >= 60
? `${Math.floor(durationMinutes / 60)}h ${durationMinutes % 60}m`
: `${durationMinutes}m`;
const worklogString = `${event.subject} ${durationString} ${event.subject}`;
return worklogString;
});

console.log('Worklog items:', worklogItems);

Controller.LogController.bulkInsert(worklogItems.join('\n'))
.then(() => {
mediator.trigger('view.table.new-worklog.changed', {})
resolve()
});
})
.catch(error => {
reject('Error fetching calendar entries: ' + error);
});
});
}

window.OutlookHelper = {
init: init,
getOutlookOptions: getOutlookOptions,
testConnection: testConnection,
getToken: getToken,
fetchCalendarEntries: fetchCalendarEntries,
}
})(window.chrome)

if (typeof module !== 'undefined') { module.exports = window.OutlookHelper }
14 changes: 8 additions & 6 deletions chrome-extension/js/update-script.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
/* global chrome */
var updateScript = (function () {
function saveOptions (jiraOptions) {
function saveOptions (jiraOptions, outlookOptions) {
return new Promise(resolve => {
chrome.storage.sync.set(
{
jiraOptions: jiraOptions
jiraOptions: jiraOptions,
outlookOptions: outlookOptions
},
function () {
resolve()
Expand All @@ -16,19 +17,20 @@ var updateScript = (function () {
return new Promise(resolve => {
chrome.storage.sync.get(
{
jiraOptions: {}
jiraOptions: {},
outlookOptions: {}
},
function (options) {
resolve(options.jiraOptions)
resolve(options.jiraOptions, options.outlookOptions)
}
)
})
}
function removePassword () {
var getPromise = getOptions()
var savePromise = getPromise.then((jiraOptions) => {
var savePromise = getPromise.then((jiraOptions, outlookOptions) => {
jiraOptions.password = ''
return saveOptions(jiraOptions)
return saveOptions(jiraOptions, outlookOptions)
})
return savePromise
}
Expand Down
Loading