Save
diff --git a/dialogTimesheet.html b/dialogTimesheet.html
new file mode 100644
index 0000000..0bd82ea
--- /dev/null
+++ b/dialogTimesheet.html
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dialogs.gs b/dialogs.gs
index ca5ecee..021fcff 100644
--- a/dialogs.gs
+++ b/dialogs.gs
@@ -27,8 +27,8 @@ function dialogSettings() {
var dialog = getDialog('dialogSettings', getServerCfg());
dialog
- .setWidth(320)
- .setHeight(260)
+ .setWidth(340)
+ .setHeight(400)
.setSandboxMode(HtmlService.SandboxMode.IFRAME);
log('Processed: %s', dialog);
@@ -45,32 +45,11 @@ function getServerCfg() {
available: getCfg('available'),
url: getCfg('jira_url'),
username: getCfg('jira_username'),
- password: getCfg('jira_password')
+ password: getCfg('jira_password'),
+ workhours: getVar('workhours')
};
}
-/**
- * @desc Save Jira server settings, provided in dialog form and perform
- * a connection test to Jira api.
- * @param jsonFormData {object} JSON Form object of all form values
- * @return {object} Object({status: [boolean], response: [string]})
- */
-function saveSettings(jsonFormData) {
- var url = trimChar(jsonFormData.jira_url, "/");
- setCfg('available', false);
- setCfg('jira_url', url);
- setCfg('jira_username', jsonFormData.jira_username);
- setCfg('jira_password', jsonFormData.jira_password);
-
- var test = testConnection();
-
- if (url.indexOf('atlassian.net') == -1) {
- setCfg('server_type', 'server');
- }
-
- return {status: test.status, message: test.response};
-}
-
/* Dialog: Settings - END */
/**
@@ -129,7 +108,7 @@ function insertIssuesFromFilter(jsonFormData) {
return;
}
- var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
+ var sheet = getTicketSheet();
var cell = sheet.getActiveCell();
var table = new IssueTable(sheet, cell, responseData);
@@ -139,6 +118,9 @@ function insertIssuesFromFilter(jsonFormData) {
response.status = true;
+ // toast with status message
+ SpreadsheetApp.getActiveSpreadsheet().toast("Finished inserting " + (responseData.issues.length||"n/a") + " Jira issues.", "Status", 5);
+
} else {
// Something funky is up with the JSON response.
response.message = "Failed to retrieve jira issues!";
@@ -155,7 +137,7 @@ function insertIssuesFromFilter(jsonFormData) {
var data = {
jql: filter.jql,
fields: jsonFormData['columns'] || [],
- maxResults: LIST_ISSUES_MAX_RESULT,
+ maxResults: 1000,
validateQuery: (getCfg('server_type') == 'onDemand') ? 'strict' : true
};
@@ -187,4 +169,27 @@ function dialogAbout() {
SpreadsheetApp.getUi().showModalDialog(dialog, 'About');
}
-/* Dialog: About - END */
\ No newline at end of file
+/* Dialog: About - END */
+
+
+/* Dialog: Worklog */
+
+/**
+ * @desc Dialog to create worklog based on user/group selection
+ */
+function dialogTimesheet() {
+ if(!hasSettings(true)) return;
+
+ var dialog = getDialog('dialogTimesheet');
+
+ dialog
+ .setWidth(420)
+ .setHeight(360)
+ .setSandboxMode(HtmlService.SandboxMode.IFRAME);
+
+ log('Processed: %s', dialog);
+
+ SpreadsheetApp.getUi().showModalDialog(dialog, 'Create Time Report');
+}
+
+/* Dialog: Worklog - END */
diff --git a/jiraApi.gs b/jiraApi.gs
index fc4edc8..cf1c396 100644
--- a/jiraApi.gs
+++ b/jiraApi.gs
@@ -5,20 +5,28 @@
*/
var restMethods = {
'onDemand': {
- 'dashboard': '/dashboard',
- 'issueStatus': {method: '/issue/{issueIdOrKey}', queryparams:{fields: ['status']}},
- 'filter': {method: '/filter/{filterId}'},
+ 'dashboard' : '/dashboard',
+ 'issueStatus' : {method: '/issue/{issueIdOrKey}', queryparams:{fields: ['status']}},
+ 'worklogOfIssue': {method: '/issue/{issueIdOrKey}/worklog'},
+ 'filter' : {method: '/filter/{filterId}'},
//'search': {method: '/search', queryparams: {jql:'', fields: [], properties: [], maxResults: 100, validateQuery: 'strict'}} // GET
- 'search': {method: '/search'}, // POST
- 'myFilters': {method: '/filter/my', queryparams: {includeFavourites: 'false'}}
+ 'search' : {method: '/search'}, // POST
+ 'myFilters' : {method: '/filter/my', queryparams: {includeFavourites: 'false'}},
+
+ 'userSearch' : {method: '/user/search', queryparams: {startAt:0, maxResults: 1000, username:'%'}},
+ 'groupSearch' : {method: '/groups/picker', queryparams: {maxResults: 1000, query: ''}}
},
'server': {
- 'dashboard': '/dashboard',
- 'issueStatus': {method: '/issue/{issueIdOrKey}', queryparams:{fields: ['status']}},
- 'filter': {method: '/filter/{filterId}'},
- 'search': {method: '/search'}, // POST
+ 'dashboard' : '/dashboard',
+ 'issueStatus' : {method: '/issue/{issueIdOrKey}', queryparams:{fields: ['status']}},
+ 'worklogOfIssue': {method: '/issue/{issueIdOrKey}/worklog'},
+ 'filter' : {method: '/filter/{filterId}'},
+ 'search' : {method: '/search'}, // POST
// server api doesnt support /filter/my
- 'myFilters': {method: '/filter/favourite', queryparams: {includeFavourites: 'false'}}
+ 'myFilters' : {method: '/filter/favourite', queryparams: {includeFavourites: 'false'}},
+
+ 'userSearch' : {method: '/user/search', queryparams: {startAt:0, maxResults: 1000, username:'%'}},
+ 'groupSearch' : {method: '/groups/picker', queryparams: {maxResults: 1000, query: ''}}
}
};
@@ -72,15 +80,17 @@ function testConnection() {
*/
function Request() {
var statusCode, httpResponse, responseData,
- available = getCfg('available'),
- url = getCfg('jira_url'),
- username = getCfg('jira_username'),
- password = getCfg('jira_password'),
+ available, url, username, password,
jiraMethod = null,
jiraQueryParams = {};
this.init = function() {
- // prepare for initialization if necessary
+ server_type = getCfg('server_type') || 'onDemand';
+ available = getCfg('available');
+ url = getCfg('jira_url');
+ username = getCfg('jira_username');
+ password = getCfg('jira_password');
+ jiraMethod = null;
};
/**
@@ -145,7 +155,6 @@ function Request() {
return this;
}
- var server_type = getCfg('server_type') || 'onDemand';
jiraMethod = (typeof restMethods[server_type][method] === 'object') ? restMethods[server_type][method].method : restMethods[server_type][method];
jiraQueryParams = (typeof restMethods[server_type][method] === 'object') ? restMethods[server_type][method].queryparams : {};
diff --git a/jiraCommon.gs b/jiraCommon.gs
index e21d6cb..6472287 100644
--- a/jiraCommon.gs
+++ b/jiraCommon.gs
@@ -1,5 +1,3 @@
-var LIST_ISSUES_MAX_RESULT = 200;
-
// const not available, but better solution needed
var CELLTYPE_EMPTY = -1;
var CELLTYPE_JIRAID = 10; // entire cell includes Jira ticket id only ("JIRA-123" or "JIRA-123 [Status]")
@@ -255,6 +253,27 @@ function getFilter(filterId) {
return filter;
}
+/**
+ * @desc Fetch all active users and groups for dialog selection.
+ * @param {boolean} minimal Returning data only includes minimal info (displayName,name[,active])
+ * @return {object} Object({"users":[{
b.displayName) ? 1 : ((b.displayName > a.displayName) ? -1 : 0);} );
+
+ return result;
+}
+
/**
* @desc Helper to convert indiv. jira field/property objects
* into simple objects for using as cell data.
@@ -321,7 +340,7 @@ function unifyIssueAttrib(attrib, data) {
case 'duedate':
// very dirty - just cant get arround timezone issue when date is in format 'YYYY-MM-DD'
// @TODO: require proper generic solution for this
- var _duedate = data.fields.duedate || 'n/a';
+ var _duedate = data.fields.duedate || null;
_duedate = (_duedate.length == 10) ? _duedate + 'T12:00:00' : _duedate;
resp = {
value: _duedate,
@@ -368,6 +387,41 @@ function unifyIssueAttrib(attrib, data) {
};
break;
+ case 'user':
+ resp = {
+ displayName: data.displayName + (data.active==true?'':' (X)'),
+ name: data.name,
+ emailAddress: data.emailAddress,
+ active: data.active,
+
+ value: data.displayName,
+ format: "@"
+ };
+ break;
+ case 'group':
+ var _dName = (data.labels.length > 0) ? data.labels[0].text : data.name;
+ resp = {
+ displayName: _dName,
+ name: data.name,
+ value: _dName,
+ format: "@"
+ };
+ break;
+ case 'userMin':
+ resp = {
+ displayName: data.displayName + (data.active==true?'':' (X)'),
+ name: data.name,
+ active: data.active,
+ };
+ break;
+ case 'groupMin':
+ var _dName = (data.labels.length > 0) ? data.labels[0].text : data.name;
+ resp = {
+ displayName: _dName,
+ name: data.name,
+ };
+ break;
+
default:
log('unifyIssueAttrib(' + attrib + ') no format defined yet.');
//log(data.fields[attrib]);
diff --git a/jiraTicketInfo.gs b/jiraTicketInfo.gs
index cbc937f..2ac247f 100644
--- a/jiraTicketInfo.gs
+++ b/jiraTicketInfo.gs
@@ -35,6 +35,9 @@ function refreshTickets() {
rows.getCell(rowIdx, colIdx).setValue(newValue);
break;
}
+
+ SpreadsheetApp.flush();
+
} else {
// Something funky is up with the JSON response.
log(rowIdx, "Failed to retrieve ticket data for ID [" + jiraCell.ticketId + "]!");
diff --git a/jsLib.gs b/jsLib.gs
index 8eb98f5..7e293cc 100644
--- a/jsLib.gs
+++ b/jsLib.gs
@@ -112,3 +112,148 @@ function trimChar(origString, charToTrim) {
var regEx = new RegExp("^[" + charToTrim + "]+|[" + charToTrim + "]+$", "g");
return origString.replace(regEx, "");
}
+
+/**
+ * @desc Check if passed string is valid or not.
+ * @param date {String} String which gets validated as date (ie: '2017-05-31')
+ * @return {Boolean}
+ */
+function isDate(date) {
+ return ((new Date(date) !== "Invalid Date" && !isNaN(new Date(date)) ) ? true : false);
+}
+
+
+/**
+ * @desc The fill() method fills all the elements of an array from a start index to an end index with a static value.
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/fill
+ * @param value {mixed} Value to fill an array.
+ * @param start {integer} Optional, Start index, defaults to 0.
+ * @param end {integer} End index, defaults to this.length.
+ * @return {Array} The modified array.
+ */
+if (!Array.prototype.fill) {
+ Array.prototype.fill = function(value) {
+
+ // Steps 1-2.
+ if (this == null) {
+ throw new TypeError('this is null or not defined');
+ }
+
+ var O = Object(this);
+
+ // Steps 3-5.
+ var len = O.length >>> 0;
+
+ // Steps 6-7.
+ var start = arguments[1];
+ var relativeStart = start >> 0;
+
+ // Step 8.
+ var k = relativeStart < 0 ?
+ Math.max(len + relativeStart, 0) :
+ Math.min(relativeStart, len);
+
+ // Steps 9-10.
+ var end = arguments[2];
+ var relativeEnd = end === undefined ?
+ len : end >> 0;
+
+ // Step 11.
+ var final = relativeEnd < 0 ?
+ Math.max(len + relativeEnd, 0) :
+ Math.min(relativeEnd, len);
+
+ // Step 12.
+ while (k < final) {
+ O[k] = value;
+ k++;
+ }
+
+ // Step 13.
+ return O;
+ };
+}
+
+/**
+ * @desc Converts time difference into human readable format.
+ * Returns difference in %d %h %m %s
+ *
+ * Sample call: formatTimeDiff(183599000) returns '2d 2h 59m 59s'
+ * or: formatTimeDiff(new Date('2017-08-03T12:59:59'), new Date('2017-08-01T10:00:00')) return '2d 2h 59m 59s'
+ *
+ * @param {Integer|Date} Either the time difference in seconds as integer,
+ * or two Date() objects.
+ * @param {Date} Optional Date() object to compare with first param Date()
+ * @return {String}
+ */
+function formatTimeDiff() {
+ var delta, response = '';
+ if(arguments.length == 1) {
+ // delta passed to convert
+ delta = arguments[0];
+ } else if (arguments.length == 2) {
+ // get total seconds between the times
+ if ( arguments[1] > arguments[0] ) {
+ delta = Math.abs(arguments[1] - arguments[0]) / 1000;
+ } else {
+ delta = Math.abs(arguments[0] - arguments[1]) / 1000;
+ }
+ } else {
+ throw 'formatTime() accepts 1 or 2 arguments.';
+ }
+
+ // calculate (and subtract) whole days (workday=8h)
+ var workhoursInSeconds = parseFloat(getVar('workhours')) * 3600;
+ var days = Math.floor(delta / workhoursInSeconds);
+ delta -= days * workhoursInSeconds;
+
+ // calculate (and subtract) whole hours
+ var hours = Math.floor(delta / 3600) % 24;
+ delta -= hours * 3600;
+
+ // calculate (and subtract) whole minutes
+ var minutes = Math.floor(delta / 60) % 60;
+ delta -= minutes * 60;
+
+ // what's left is seconds
+ var seconds = Math.floor(delta % 60);
+
+ response += days > 0 ? days + 'd ' : '';
+ response += hours > 0 ? hours + 'h ' : '';
+ response += minutes > 0 ? minutes + 'm ' : '';
+ response += seconds > 0 ? seconds + 's ' : '';
+
+ return response.trim();
+};
+
+/**
+ * @desc Converts time difference or seconds passed into hours.
+ *
+ * Sample call: formatTimeDiff(5400) returns '1.5' (hours)
+ * or: formatTimeDiff(new Date('2017-08-01T08:30:00'), new Date('2017-08-01T10:00:00')) return '1.5'
+ *
+ * @param {Integer|Date} Either the time difference in seconds as integer,
+ * or 2 Date objects (from - to).
+ * @param {Date} Optional Date() object to compare with first param Date()
+ * @return {Number}
+ */
+function formatWorkhours() {
+ var delta, response = '';
+ if(arguments.length == 1) {
+ // delta passed to convert
+ delta = arguments[0];
+ } else if (arguments.length == 2) {
+ // get total seconds between the times
+ if ( arguments[1] > arguments[0] ) {
+ delta = Math.abs(arguments[1] - arguments[0]) / 1000;
+ } else {
+ delta = Math.abs(arguments[0] - arguments[1]) / 1000;
+ }
+ } else {
+ throw 'formatWorkhours() accepts 1 or 2 arguments.';
+ }
+
+ var hours = Math.round(delta / 3600 * 100) / 100;
+
+ return hours;
+}
diff --git a/search.gs b/search.gs
new file mode 100644
index 0000000..4923632
--- /dev/null
+++ b/search.gs
@@ -0,0 +1,242 @@
+/*function testSearch() {
+ var s = new Search('worklogDate>="2017-07-02" and worklogDate<="2017-07-11" and worklogAuthor="jrosemeier"');
+ s.setOrderBy('updated', 'DESC')
+ .setFields(['id','key','issuetype','project','status','summary']);
+
+ onSuccess = function(a,b,c) {
+ log('%s', '----------ON SUCCESS-----------');
+ log('%s %s %s', JSON.stringify(a), b, c);
+ log('%s', '---------------------1');
+
+ log('AMOUNT: %s !', a.length);
+ };
+ onFailure = function(a,b,c) {
+ log('%s', '----------ON FAILURE-----------');
+ log('a:%s b:%s c:%s', a, b, c);
+ log('%s', '---------------------1');
+ };
+
+ s.search()
+ .withSuccessHandler(onSuccess)
+ .withFailureHandler(onFailure)
+ ;
+}*/
+
+
+/**
+ * @desc Class 'Search' API abstraction with pagination handling.
+ * Performs a JQL POST search request to JIRA Rest API.
+ * @param searchQuery {String} JQL Query statement
+ */
+function Search(searchQuery) {
+ var fields = ['key'],
+ startAt = 0, maxResults = 1000, maxPerPage = 500,
+ queryStr = searchQuery, orderBy = '', orderDir = 'ASC';
+ var response = {
+ 'data': [],
+ 'status': -1,
+ 'errorMessage': ''
+ };
+
+ /**
+ * @desc Initialize anything necessary for the class object
+ * @return void
+ */
+ this.init = function() {}
+
+ /**
+ * @desc Set the list of jira issue fields to be returned in search response for each issue.
+ * @param aFields {Array} Array of jira issue fields
+ * @return {this} Allow chaining
+ */
+ this.setFields = function(aFields) {
+ if(aFields.constructor == Array) {
+ fields = aFields;
+ } else {
+ throw '{aFields} is not an Array.';
+ }
+
+ return this;
+ }
+
+ /**
+ * @desc Set result offset start for pagination of search results.
+ * @param iStartAt {Number} Number, default:0
+ * @return {this} Allow chaining
+ */
+ this.setStartAt = function(iStartAt) {
+ if(iStartAt.constructor == Number) {
+ startAt = iStartAt;
+ } else {
+ throw '{iStartAt} is not a Number.';
+ }
+
+ return this;
+ }
+
+ /**
+ * @desc Set result offset limit for pagination of search results.
+ * @param iMaxResults {Number} Number, default:1000
+ * @return {this} Allow chaining
+ */
+ this.setMaxResults = function(iMaxResults) {
+ if(iMaxResults.constructor == Number) {
+ maxResults = iMaxResults;
+ } else {
+ throw '{iMaxResults} is not a Number.';
+ }
+
+ return this;
+ }
+
+ /**
+ * @desc Set max results per page
+ * @param iMaxPerPage {Number} Integer of how many results max per page to fetch
+ * @return {this} Allow chaining
+ */
+ this.setMaxPerPage = function(iMaxPerPage) {
+ if(iMaxPerPage.constructor == Number) {
+ maxPerPage = iMaxPerPage;
+ } else {
+ throw '{iMaxPerPage} is not a Number.';
+ }
+
+ return this;
+ }
+
+ /**
+ * @desc Set Order of results (JQL order by clause)
+ * @param sOrderBy {String} Jira field to order by. Example: 'updated'
+ * @param sDir {String} Direction of order; 'ASC' or 'DESC'
+ * @return {this} Allow Chaining
+ */
+ this.setOrderBy = function(sOrderBy, sDir) {
+ sOrderBy = sOrderBy || '', sDir = sDir || 'ASC';
+
+ if( sOrderBy != '' ) orderBy = sOrderBy;
+ if( sDir != '' ) orderDir = sDir;
+
+ return this;
+ }
+
+ /**
+ * @desc Callback Success handler
+ * @param fn {function} Method to call on successfull request (statue=200)
+ * @return {this} Allow chaining
+ */
+ this.withSuccessHandler = function(fn) {
+ if(response.status === 200) {
+ fn.call(this, response.data, response.status, response.errorMessage);
+ }
+ return this;
+ };
+
+ /**
+ * @desc Callback Failure handler
+ * @param fn {function} Method to call on failed request (status!==200)
+ * @return {this} Allow chaining
+ */
+ this.withFailureHandler = function(fn) {
+ if(response.status !== 200) {
+ log("withFailureHandler: %s", response);
+ fn.call(this, response.data, response.status, response.errorMessage);
+ }
+ return this;
+ };
+
+ /**
+ * @desc Prepare JQL search query
+ * @return {String}
+ */
+ var getJql = function() {
+ var jql = queryStr + ' ORDER BY ' + orderBy + ' ' + orderDir;
+ log('Search JQL: [%s]', jql);
+
+ //return encodeURIComponent(jql); //only when api call is performed as GET
+ return jql;
+ }
+
+ /**
+ * @desc OnSuccess handler for search request
+ * @param resp {Object} JSON response object from Jira
+ * @param httpResp {Object}
+ * @param status {Number}
+ * @return void
+ */
+ var onSuccess = function(resp, httpResp, status) {
+ var _total = parseInt(resp.total || 0);
+
+ // nothing found - return class response
+ if( _total == 0 ) {
+ response = {
+ 'data' : resp.issues || resp,
+ 'status' : status,
+ 'errorMessage' : resp.hasOwnProperty('warningMessages') ? resp.warningMessages : 'No results found.'
+ };
+ return;
+ }
+
+ // add current results and status
+ response.data.push.apply(response.data, resp.issues || []);
+ response.status = status;
+
+ // pagination / sub-requests required?
+ var _countTotalResults = parseInt(resp.startAt) + parseInt(resp.maxResults);
+ if( (_countTotalResults < _total) && (_countTotalResults < maxResults) ) {
+ // more data to fetch
+
+ var subSearch = new Search( queryStr );
+ subSearch.setOrderBy( orderBy, orderDir )
+ .setFields( fields )
+ .setMaxPerPage( maxPerPage )
+ .setMaxResults( maxResults )
+ .setStartAt( _countTotalResults );
+
+ subSearch.search().withSuccessHandler(function(data, status, msg) {
+ // append results to parent results
+ response.data.push.apply(response.data, data);
+ response.status = status;
+ }); // dont bubble up failure - 1st call was successfull so we soft-fail and response with results found so far
+ }
+ }
+
+ /**
+ * @desc OnFailure handler for search request
+ * @param resp {Object} JSON response object from Jira
+ * @param httpResp {Object}
+ * @param status {Number}
+ * @return void
+ */
+ var onFailure = function(resp, httpResp, status) {
+ Logger.log('search:onFailure: [%s] %s', status, resp);
+
+ var msgs = resp.hasOwnProperty('errorMessages') ? resp.errorMessages : [];
+ msgs = msgs.concat((resp.hasOwnProperty('warningMessages') ? resp.warningMessages : []));
+
+ response.status = status;
+ response.errorMessage = msgs.join("\n");
+ }
+
+ /**
+ * @desc Perform Search
+ * @return {this} Allow chaining
+ */
+ this.search = function() {
+ log("search with start:%s and maxResults:%s and field:[%s]", startAt, maxPerPage, fields);
+ var data = {
+ jql : getJql(),
+ fields : fields,
+ startAt : startAt,
+ maxResults : maxPerPage
+ };
+
+ var request = new Request();
+ request.call('search', data, {'method' : 'post'})
+ .withSuccessHandler(onSuccess)
+ .withFailureHandler(onFailure);
+
+ return this;
+ }
+
+ this.init();
+}
diff --git a/storage.gs b/storage.gs
index 33b4b27..ea5e88e 100644
--- a/storage.gs
+++ b/storage.gs
@@ -101,20 +101,46 @@ function initDefaults() {
var build = getVar('BUILD') || 0;
var isInitialized = getVar('defaults_initialized') || 'false';
if (isInitialized == 'true' && build == BUILD) return;
-
+
setVar('BUILD', BUILD);
-
+
// set default jira issue columns
//var columnDefaults = getVar('jiraColumnDefault');
//columnDefaults = (columnDefaults != null) ? JSON.parse(columnDefaults) : jiraColumnDefault;
columnDefaults = jiraColumnDefault; //@TODO: allow user to change default columns
setVar('jiraColumnDefault', JSON.stringify(columnDefaults));
-
+
+ setVar('workhours', 8);
+
// Jira onDemand or Server
var server_type = getCfg('server_type');
if (server_type == null) server_type = 'onDemand';
setCfg('server_type', server_type);
-
+
// set done
setVar('defaults_initialized', 'true');
}
+
+/**
+ * @desc Save Jira server settings, provided in dialog form and perform
+ * a connection test to Jira api.
+ * @param jsonFormData {object} JSON Form object of all form values
+ * @return {object} Object({status: [boolean], response: [string]})
+ */
+function saveSettings(jsonFormData) {
+ var url = trimChar(jsonFormData.jira_url, "/");
+ setCfg('available', false);
+ setCfg('jira_url', url);
+ setCfg('jira_username', jsonFormData.jira_username);
+ setCfg('jira_password', jsonFormData.jira_password);
+ setVar('workhours', jsonFormData.ts_workhours);
+
+ var test = testConnection();
+
+ if (url.indexOf('atlassian.net') == -1) {
+ setCfg('server_type', 'server');
+ }
+
+ return {status: test.status, message: test.response};
+}
+
diff --git a/userManager.gs b/userManager.gs
new file mode 100644
index 0000000..7ae28b8
--- /dev/null
+++ b/userManager.gs
@@ -0,0 +1,136 @@
+/**
+ * @desc Returns a list of users that match the search string.
+ * This resource cannot be accessed anonymously.
+ * Sample:
+ * findUser('a%')
+ * findUser('ab%')
+ * findUser('abc%')
+ * findUser('z%')
+ * @param usernameTerm {string} A query string used to search username, name or e-mail address
+ * @param {boolean} minimal Def:FALSE; Returning data only includes minimal info (displayName,name[,active])
+ * @return {Array}
+ */
+function findUser(usernameTerm, minimal) {
+ var method = 'userSearch',
+ usernameTerm = usernameTerm || '%',
+ minimal = minimal || false,
+ users = [];
+
+ /**
+ * @desc OnSuccess handler
+ * @param resp {Object} JSON response object from Jira
+ * @param httpResp {Object}
+ * @param status {Number}
+ * @return Mixed
+ */
+ var ok = function(resp, httpResp, status){
+ if(resp) {
+ if(resp.length == 0) {
+ Browser.msgBox("No users were found to match your search.", Browser.Buttons.OK);
+ return users;
+ }
+
+ var user;
+ for(var i=0; i Date.parse(wlDateTo) ) {
+ wlDateFrom = wlDateTo;
+ wlDateTo = _d;
+ }
+ wlQuery += 'worklogDate>="' + wlDateFrom.toISOString().substring(0, 10) +'" ' +
+ 'AND worklogDate<="' + wlDateTo.toISOString().substring(0, 10) + '"';
+
+ if(jsonFormData.wlAuthorG) {
+ wlQuery += ' AND worklogAuthor in membersOf("' + jsonFormData.wlAuthorG + '")';
+ } else {
+ wlQuery += ' AND worklogAuthor="' + jsonFormData.wlAuthorU + '"';
+ }
+
+ var authorName = jsonFormData.wlAuthorName ? jsonFormData.wlAuthorName : (
+ jsonFormData.wlAuthorG ? jsonFormData.wlAuthorG : jsonFormData.wlAuthorU
+ );
+
+ /* Get all affected jira issues */
+
+ // Search API returns max 20 worklogs per issue - we have to get worklog indiv. per issue later in iterated requests
+ var search = new Search(wlQuery);
+ search.setOrderBy('created', 'DESC')
+ .setFields(['id','key','issuetype','priority','status','summary']);
+
+ /* OnSucess, start prepping Timesheet Table and perform subsequent api searches for all worklogs per individual jira issue */
+ var onSuccess = function(data, status, errorMessage) {
+ log('Issue with worklogs founds: %s !', data.length);
+ //log('%s %s %s', JSON.stringify(data), status, errorMessage);
+
+ if(data.length == 0) {
+ Browser.msgBox("Jira Worklog",
+ "Apparently there are no issues with worklogs available for \"" + authorName + "\" in the requested time period.",
+ Browser.Buttons.OK);
+ return;
+ }
+
+ // prep new TimesheetTable then request actual worklogs
+ var timeSheetTable = new TimesheetTable1({
+ periodFrom: wlDateFrom,
+ periodTo: wlDateTo
+ });
+ timeSheetTable.setWorktimeFormat( (parseInt(jsonFormData.wlTimeFormat)==1 ? formatTimeDiff : formatWorkhours) );
+ timeSheetTable.addHeader(authorName, 'Time Sheet');
+
+ // foreach jira issue, fetch worklogs and fill sheet row
+ (data || []).forEach(function(issue, index) {
+ log('============= (data || []).forEach() =================');
+ log('issue= icon:%s; key:%s; summary:%s; priority:%s ',
+ unifyIssueAttrib('issuetype', issue),
+ unifyIssueAttrib('key', issue),
+ unifyIssueAttrib('summary', issue),
+ unifyIssueAttrib('priority', issue)
+ );
+
+ // perform worklog request
+ var request = new Request();
+ request.call('worklogOfIssue',{issueIdOrKey: issue.id})
+ .withFailureHandler(function(resp, httpResp, status) {
+ Logger.log("Failed to retrieve worklogs for issue with status [" + status + "]!\\n" + resp.errorMessages.join("\\n"));
+ })
+ .withSuccessHandler(function(resp, httpResp, status) {
+ // we have all logs here for 1 jira issue
+ if(!resp) { return; }
+
+ // get only the data we need and safe sme bytes
+ var worklogs = resp.worklogs.filter(function(wlog) { // get only logs for user we searched for
+ // remove some unused props
+ if(wlog.updateAuthor) wlog.updateAuthor = undefined;
+ if(wlog.author.avatarUrls) wlog.author.avatarUrls = undefined;
+
+ //@TODO: make compatible with worklogs of groups ( memberOf("") )
+ return wlog.author.name === jsonFormData.wlAuthorU;
+ })/*.forEach(function(wlog){ // pass to table, add row
+ timeSheetTable.addRow(issue, {
+ "started": wlog.started,
+ "authorName": wlog.author.name,
+ "timeSpentSeconds": wlog.timeSpentSeconds
+ });
+ })*/
+ ;
+
+ timeSheetTable.addRow(issue, worklogs);
+
+ }); // END: withSuccessHandler()
+ //END: request.call('worklogOfIssue')
+ });//END: (data || []).forEach()
+
+ // add table footer
+ timeSheetTable.addFooter();
+ }; //END: onSuccess()
+
+ var onFailure = function(resp, status , errorMessage) {
+ log('worklog::onFailure: resp:%s status:%s msg:%s', resp, status, errorMessage);
+ Browser.msgBox("Jira Worklog",
+ "Failure during request to Jira server.\\nStatus:" + (status||-1) + " \\nMessage:'" + errorMessage + "'",
+ Browser.Buttons.OK);
+ };
+
+ search.search()
+ .withSuccessHandler(onSuccess)
+ .withFailureHandler(onFailure)
+ ;
+}
+
+
+/**
+ * @desc
+ * @param options {object}
+ * {
+ * sheet: ,
+ * periodFrom: Date object of period starting date
+ * periodTo: Date object of period end date
+ * periodInterval: Interval to list period in columns ('day', 'week')
+ * periodFormat: Date format to use for period column headers
+ * }
+ * @return {TimesheetTable1}
+ */
+function TimesheetTable1(options) {
+ var sheet, initRange, currentRowIdx = 0, numIssueRows = 0,
+ dataRowFields = ['issuetype', 'key', 'summary', 'priority'],
+ numColumns = 0,
+ periodCfg = {
+ 'from' : null,
+ 'to' : null,
+ 'interval': 'day',
+ 'format' : "EEE\n d/MMM"
+ },
+ periodTotals = {}, rowTimesTpl = {},
+ worklogFormatFn = formatTimeDiff;
+
+ /**
+ * @desc Initialization, validation
+ */
+ this.init = function(options) {
+ sheet = options.sheet ? options.sheet : getTicketSheet();
+ initRange = sheet.getActiveCell();
+ currentRowIdx = initRange.getRow(), currentColIdx = initRange.getColumn();
+
+ if ( !options.periodFrom || !isDate(options.periodFrom)) {
+ throw '{periodFrom} in options has to be defined of type Date().';
+ }
+ if ( !options.periodTo || !isDate(options.periodTo)) {
+ throw '{periodTo} in options has to be defined of type Date().';
+ }
+
+ periodCfg.from = options.periodFrom;
+ periodCfg.to = options.periodTo;
+
+ if ( options.periodInterval && (options.periodInterval === 'day' || options.periodInterval === 'week')) {
+ periodCfg.interval = options.periodInterval;
+ }
+ periodCfg.format = (options.periodFormat === 'week') ? "'w'w ''yy" : periodCfg.format;
+
+ // periods and their sub totals
+ var _dateIdx = periodCfg.from, _period;
+ while(_dateIdx <= periodCfg.to) {
+ periodTotals[Utilities.formatDate(_dateIdx, 'UTC', 'yyyy-MM-dd')] = 0; // init total seconds of work
+ // add 1 day or 1 week to date
+ _dateIdx = new Date(_dateIdx.setTime(_dateIdx.getTime() + (periodCfg.interval == 'week' ? 7 : 1) * 86400000));
+ }
+ // set row times tpl
+ rowTimesTpl = JSON.parse(JSON.stringify(periodTotals));
+
+ // number of columns our table with consist of
+ numColumns = dataRowFields.length + Object.keys(periodTotals).length + 1; // count = data cols + periods + 1 row total
+ };
+
+ /**
+ * @desc Set function to be passed on every worklog time spent. For formatting of time.
+ * @param fn {Function}
+ * @return {this} Allow chaining
+ */
+ this.setWorktimeFormat = function(fn) {
+ worklogFormatFn = fn || formatTimeDiff;
+ };
+
+ /**
+ * @desc Header of table (2 lines)
+ * @param author {String} Name of author we searched worklogs for
+ * @param title {String} Table title; default:'Time Sheet'
+ * @return {this} Allow chaining
+ */
+ this.addHeader = function(author, title) {
+ title = title || 'Time Sheet';
+
+ var values = Array(numColumns-1).fill(''); // empty row of values
+ values.unshift(title); // set title to 1st cell
+ var formats = Array(numColumns).fill('bold'),
+ fontColors = Array(numColumns).fill('#000'),
+ bgColors = Array(numColumns).fill('#3399ff')
+ ;
+
+ // header
+ range = sheet.getRange(currentRowIdx++, currentColIdx, 1, values.length);
+ range.clearContent()
+ .clearNote()
+ .clearFormat()
+ .setBackgrounds([ bgColors ])
+ .setFontColors([ fontColors ])
+ .setValues([ values ])
+ .setFontWeights([ formats ]);
+
+ // 2. row - sub title
+ values = Array(dataRowFields.length-1).fill('');
+ values.unshift('Summary for "' + author + '"');
+ // attach period head lines
+ var _tz = SpreadsheetApp.getActive().getSpreadsheetTimeZone();
+
+ for(var key in periodTotals) {
+ values.push( Utilities.formatDate(new Date(key + 'T00:00:00.000+0000'), _tz, periodCfg.format) );
+ }
+ values.push('Total');
+
+ bgColors = Array(numColumns).fill('#fff');
+
+ range = sheet.getRange(currentRowIdx++, currentColIdx, 1, values.length);
+ range.clearContent()
+ .clearNote()
+ .clearFormat()
+ .setBackgrounds([ bgColors ])
+ .setValues([ values ])
+ .setFontWeights([ formats ]);
+
+ // all period and total columns to be centered
+ sheet.getRange(currentRowIdx-1, currentColIdx+dataRowFields.length-1, 1, values.length-dataRowFields.length+1).setHorizontalAlignment("center");
+
+ // set cell widths
+ sheet.setColumnWidth(currentColIdx, 30);
+ sheet.setColumnWidth(currentColIdx+1, 80);
+ sheet.setColumnWidth(currentColIdx+2, 240);
+ sheet.setColumnWidth(currentColIdx+3, 30);
+
+ SpreadsheetApp.flush();
+
+ return this;
+ };
+
+ /**
+ * @desc Add Table footer
+ * @return {this} Allow chaining
+ */
+ this.addFooter = function() {
+ var values = Array(dataRowFields.length-1).fill('');
+ values.unshift('Total (' + numIssueRows + ' issues):');
+ var formats = Array(numColumns).fill('bold'),
+ fontColors = Array(numColumns).fill('#000'),
+ bgColors = Array(numColumns).fill('#3399ff')
+ ;
+ log('periodTotals: [%s]', periodTotals);
+
+ // set totals on each period column + overall total column
+ var _totalTimeSpent = 0;
+ for (var key in periodTotals) {
+ _totalTimeSpent += periodTotals[key];
+ values.push( worklogFormatFn(periodTotals[key]) );
+ }
+ values.push(worklogFormatFn(_totalTimeSpent));
+
+ // footer
+ range = sheet.getRange(currentRowIdx++, currentColIdx, 1, values.length);
+ range.clearContent()
+ .clearNote()
+ .clearFormat()
+ .setBackgrounds([ bgColors ])
+ .setFontColors([ fontColors ])
+ .setValues([ values ])
+ .setFontWeights([ formats ]);
+
+ // all period and total columns to be centered
+ sheet.getRange(currentRowIdx-1, currentColIdx+dataRowFields.length-1, 1, values.length-dataRowFields.length+1).setHorizontalAlignment("center");
+ sheet.getRange(currentRowIdx-1, values.length).setHorizontalAlignment("right");
+
+ SpreadsheetApp.flush();
+
+ // set width of period columns
+ for(var c=(dataRowFields.length+1); c <= values.length; c++) {
+ sheet.setColumnWidth(c, 70);
+ }
+
+ return this;
+ }
+
+ /**
+ * @desc Add individual timesheet/worklog row to table
+ * @param issue {Object} JSON response object of an jira issue
+ * @param worklogs {ArrayOfObjects} Array of JSON objects from Jira worklog search response
+ * @return {this} Allow chaining
+ */
+ this.addRow = function(issue, worklogs) {
+ var rowTimes = JSON.parse(JSON.stringify(rowTimesTpl)), // bad, but we want a clone and not a reference
+ rowTotal = 0,
+ values = [],
+ formats = Array(numColumns).fill('@'),
+ fontColors = Array(numColumns).fill('#000'),
+ bgColors = Array(numColumns).fill( (currentRowIdx % 2) ? '#fff' : '#e6ffe6')
+ ;
+
+ // add timespent to issues period totals
+ worklogs.forEach(function(worklog) {
+ var _period = Utilities.formatDate(new Date(worklog.started), 'UTC', 'yyyy-MM-dd');
+ if( rowTimes[_period] !== undefined ) {
+ rowTimes[_period] += parseInt(worklog.timeSpentSeconds);
+ }
+ });
+
+ // add jira issue data to first columns with some custom cell formatters
+ dataRowFields.forEach(function(field) {
+ var _val = unifyIssueAttrib(field, issue);
+ switch (field) {
+ case 'issuetype':
+ case 'priority':
+ _val = '=IMAGE("' + _val.iconUrl + '"; 4; 16; 16)';
+ break;
+ case 'key':
+ _val = '=HYPERLINK("' + _val.link + '";"' + _val.value + '")';
+ break;
+ case 'summary':
+ default:
+ _val = _val.value;
+ break;
+ }
+
+ values.push(_val);
+ });
+
+ // add timespent to overall period totals
+ for(var key in rowTimes) {
+ rowTotal += rowTimes[key];
+ values.push( worklogFormatFn(rowTimes[key]) );
+ if(periodTotals[key] !== undefined) {
+ periodTotals[key] += parseInt(rowTimes[key]);
+ }
+ }
+
+ values.push( worklogFormatFn(rowTotal) ); // row total
+ formats[formats.length - 1] = 'bold';
+
+ range = sheet.getRange(currentRowIdx++, currentColIdx, 1, values.length);
+ range.clearContent()
+ .clearNote()
+ .clearFormat()
+ .setBackgrounds([ bgColors ])
+ .setValues([ values ])
+ .setFontWeights([ formats ])
+ .activate();
+
+ // 1st col IssueTypeIcon align center
+ sheet.getRange(currentRowIdx-1, 1, 1, 1).setHorizontalAlignment("center");
+ // all period and total columns to be centered
+ sheet.getRange(currentRowIdx-1, currentColIdx+dataRowFields.length-1, 1, values.length-dataRowFields.length).setHorizontalAlignment("center");
+ sheet.getRange(currentRowIdx-1, values.length, 1, values.length).setHorizontalAlignment("right");
+
+ ++numIssueRows;
+
+ SpreadsheetApp.flush();
+
+ return this;
+ };
+
+
+ this.init(options);
+}