您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Gantt recalculating by priority order with autoordering in left menu
// ==UserScript== // @name Better youtrack gantt recalculation // @description Gantt recalculating by priority order with autoordering in left menu // @namespace http://tampermonkey.net/ // @version 0.1 // @author Brin Dmitriy // @license MIT // @match http://tasker.*.ru/gantt-charts/* // @icon https://www.google.com/s2/favicons?sz=64&domain=vseinstrumenti.ru // @grant none // @run-at document-end // ==/UserScript== (function () { 'use strict' const CONFIG_PRIORITY_FIELD_NAME = 'Приоритет' const CONFIG_HOURS_IN_DAY = 5 const isNotWorking = (day) => new Date(day).getDay() === 0 || new Date(day).getDay() === 6 const addWorkingTime = (startDay, hoursInDay, hours) => { let resultDate = new Date(startDay) let workDays = Math.floor(hours / hoursInDay) let hoursLeft = workDays === 0 ? hours : hours % (workDays * hoursInDay) let hoursResult = resultDate.getUTCHours() + hoursLeft if (hoursResult >= hoursInDay) { hoursResult -= hoursInDay workDays++ } resultDate.setUTCHours(hoursResult) while (isNotWorking(resultDate)) { resultDate.setUTCDate(resultDate.getUTCDate() + 1) } for (let i = 0; i < workDays; i++) { resultDate.setUTCDate(resultDate.getUTCDate() + 1) while (isNotWorking(resultDate)) { resultDate.setUTCDate(resultDate.getUTCDate() + 1) } } return resultDate } class YtApi { token = '' getGantIdFromUrl(url) { return url.match(/gantt-charts\/(\d+-\d+)/)[1] ?? '' } getIssueFieldValue(issue, fieldName) { for (const fieldData of issue.fields) { const currentFieldName = fieldData.projectCustomField.field.localizedName ?? fieldData.projectCustomField.field.name if (currentFieldName === fieldName) { return fieldData.value } } return null } async request(uri, options) { if (this.token === '') { let k = 0 while (window.localStorage.key(k) !== null) { let key = window.localStorage.key(k) if (key.indexOf('-token') !== -1) { this.token = JSON.parse(window.localStorage.getItem(key)).accessToken break } k++ } } const sendingOptions = Object.assign({}, options) sendingOptions.headers = Object.assign({}, options?.headers ?? {}, { 'Authorization': 'Bearer ' + this.token}) return fetch(uri, sendingOptions).then((resp) => resp.json()) } async getIssues(query) { const fieldsText = 'fields=$type,attachments(id),commentsCount,created,fields($type,hasStateMachine,id,isUpdatable,name,localizedName,projectCustomField($type,bundle(id),canBeEmpty,emptyFieldText,field(fieldType(isMultiValue,valueType),id,localizedName,name,ordinal),id,isEstimation,isPublic,isSpentTime,ordinal,size),value($type,archived,avatarUrl,buildIntegration,buildLink,color(id),description,fullName,id,isResolved,localizedName,login,markdownText,minutes,name,presentation,ringId,text)),hasEmail,id,idReadable,links(direction,id,issuesSize,linkType(aggregation,directed,localizedName,localizedSourceToTarget,localizedTargetToSource,name,sourceToTarget,targetToSource,uid),trimmedIssues($type,comments($type),created,id,idReadable,isDraft,numberInProject,project(id,ringId),reporter(id),resolved,summary,voters(hasVote),votes,watchers(hasStar)),unresolvedIssuesSize),project($type,id,isDemo,leader(id),name,ringId,shortName),reporter($type,avatarUrl,email,fullName,id,isLocked,issueRelatedGroup(icon),login,name,online,profiles(general(trackOnlineStatus)),ringId),resolved,summary,tags(color(id),id,isUpdatable,isUsable,name,owner(id),query),transaction(authorId,timestamp),trimmedDescription,updated,updater($type,avatarUrl,email,fullName,id,isLocked,issueRelatedGroup(icon),login,name,online,profiles(general(trackOnlineStatus)),ringId),visibility($type,implicitPermittedUsers($type,avatarUrl,email,fullName,id,isLocked,issueRelatedGroup(icon),login,name,online,profiles(general(trackOnlineStatus)),ringId),permittedGroups($type,allUsersGroup,icon,id,name,ringId),permittedUsers($type,avatarUrl,email,fullName,id,isLocked,issueRelatedGroup(icon),login,name,online,profiles(general(trackOnlineStatus)),ringId)),voters(hasVote),votes,watchers(hasStar)' return this.request('/api/issues?skip=0&top=500&query=' + encodeURIComponent(query) + '&' + fieldsText) } async getGantt(ganttId) { const fieldsText = 'fields=aggregationLink(aggregation,directed,id,localizedSourceToTarget,localizedTargetToSource,sourceToTarget,targetToSource),dependencyLink(aggregation,directed,id,localizedSourceToTarget,localizedTargetToSource,sourceToTarget,targetToSource),dependencyLinkOutward,estimationField(fieldType(id,isBundleType,presentation,valueType),id,name),estimationTimeUnit(id),fieldConstraint(prototype(id,name)),id,isRecalculating,isUpdatable,members(dependsOn(estimation,id,spentTime,startDate),estimation,fieldConstraint(value(id)),id,isParent,issue($type,attachments(id),commentsCount,created,fields($type,hasStateMachine,id,isUpdatable,name,localizedName,projectCustomField($type,bundle(id),canBeEmpty,emptyFieldText,field(fieldType(isMultiValue,valueType),id,localizedName,name,ordinal),id,isEstimation,isPublic,isSpentTime,ordinal,size),value($type,archived,avatarUrl,buildIntegration,buildLink,color(id),description,fullName,id,isResolved,localizedName,login,markdownText,minutes,name,presentation,ringId,text)),hasEmail,id,idReadable,links(direction,id,issuesSize,linkType(aggregation,directed,localizedName,localizedSourceToTarget,localizedTargetToSource,name,sourceToTarget,targetToSource,uid),trimmedIssues($type,comments($type),created,id,idReadable,isDraft,numberInProject,project(id,ringId),reporter(id),resolved,summary,voters(hasVote),votes,watchers(hasStar)),unresolvedIssuesSize),project($type,id,isDemo,leader(id),name,ringId,shortName),reporter($type,avatarUrl,email,fullName,id,isLocked,issueRelatedGroup(icon),login,name,online,profiles(general(trackOnlineStatus)),ringId),resolved,summary,tags(color(id),id,isUpdatable,isUsable,name,owner(id),query),transaction(authorId,timestamp),updated,updater($type,avatarUrl,email,fullName,id,isLocked,issueRelatedGroup(icon),login,name,online,profiles(general(trackOnlineStatus)),ringId),visibility($type,implicitPermittedUsers($type,avatarUrl,email,fullName,id,isLocked,issueRelatedGroup(icon),login,name,online,profiles(general(trackOnlineStatus)),ringId),permittedGroups($type,allUsersGroup,icon,id,name,ringId),permittedUsers($type,avatarUrl,email,fullName,id,isLocked,issueRelatedGroup(icon),login,name,online,profiles(general(trackOnlineStatus)),ringId)),voters(hasVote),votes,watchers(hasStar)),parent(estimation,id,spentTime,startDate),spentTime,startDate),name,projects(id,name,ringId,shortName),readSharingSettings(permittedGroups(allUsersGroup,icon,id,name,ringId),permittedUsers(avatarUrl,email,fullName,guest,id,issueRelatedGroup(icon),login,ringId)),startDate,startDateField(fieldType(id,isBundleType,presentation,valueType),id,name),status(errors(field,id,messages),warnings(field,id,messages)),timezone(id,offset,presentation),updateSharingSettings(permittedGroups(allUsersGroup,icon,id,name,ringId),permittedUsers(avatarUrl,email,fullName,guest,id,issueRelatedGroup(icon),login,ringId)),userSettings(scale(id)),wipConstraint(wipLimit)' return this.request('/api/gantts/' + ganttId + '?$top=-1&' + fieldsText) } async changeGanttMembersOrder(ganttId, group, members) { let uri = '/api/gantts/' + ganttId + '/members' let method = 'POST' let sendData if (group != null) { uri += '/' + group sendData = { childMembers: [] } members.forEach((member) => { sendData.childMembers.push({ id: member.id }) }) } else { sendData = [] members.forEach((member) => { sendData.push({ id: member.id }) }) method = 'PUT' } return this.request(uri, { method: method, headers: { 'Content-Type': 'application/json;charset=utf-8', }, body: JSON.stringify(sendData) }) } async updateGanttMember(ganttId, memberDataForUpdate) { const uri = '/api/gantts/' + ganttId + '/members/' + memberDataForUpdate.id return this.request(uri, { method: 'POST', headers: { 'Content-Type': 'application/json;charset=utf-8', }, body: JSON.stringify(memberDataForUpdate) }) } } const YtApiService = new YtApi() function waitForElm(selector) { return new Promise(resolve => { if (document.querySelector(selector)) { return resolve(document.querySelector(selector)); } const observer = new MutationObserver(mutations => { if (document.querySelector(selector)) { resolve(document.querySelector(selector)); observer.disconnect(); } }); observer.observe(document.body, { childList: true, subtree: true }); }); } waitForElm('div[data-test="chart-body"] div[data-test="bar"]').then(async (elm) => { const ganttButtonGroup = document.querySelector('div[data-test="gantt-toolbar"] div:nth-child(2)') const recalculateOrigButton = ganttButtonGroup.querySelector('span:nth-child(1) button') const realRecalcButton = document.createElement("button") realRecalcButton.textContent = 'Real recalculate' realRecalcButton.classList = recalculateOrigButton.classList const onRecalcHandler = async () => { const ganttId = YtApiService.getGantIdFromUrl(location.pathname) const ganttData = await YtApiService.getGantt(ganttId) // get gantt members as tree const ganttMembersMap = new Map() const ganttMembersTree = [] for (const gaanttMember of ganttData.members) { const expandedGanttMember = Object.assign({}, gaanttMember, { childs: [] }) ganttMembersMap.set(expandedGanttMember.id, expandedGanttMember) if (gaanttMember.parent == null) { ganttMembersTree.push(expandedGanttMember) } else { ganttMembersMap.get(expandedGanttMember.parent.id).childs.push(expandedGanttMember) } } ganttMembersMap.clear() // sort gantt members by priority const sortiingQueue = [ganttMembersTree] let i = 10 while(sortiingQueue.length > 0) { const arrForSort = sortiingQueue.shift() arrForSort.sort((a, b) => { const aPriority = YtApiService.getIssueFieldValue(a.issue, CONFIG_PRIORITY_FIELD_NAME) const bPrioirity = YtApiService.getIssueFieldValue(b.issue, CONFIG_PRIORITY_FIELD_NAME) return bPrioirity - aPriority }) for (const ganttMember of arrForSort) { if (ganttMember.childs.length > 0) { sortiingQueue.push(ganttMember.childs) } } if (i-- === 0) break } // create priority groups for updating and inline version of tree const inlineGanttMembersTree = [] const priorityGroups = [] const fillPriorityGroups = (resultArray, members, parent) => { const groupNumber = resultArray.length for (const member of members) { inlineGanttMembersTree.push(member) if (resultArray[groupNumber] == null) { resultArray[groupNumber] = {parent: parent, childs: []} } resultArray[groupNumber].childs.push(member) if (member.childs.length > 0) { fillPriorityGroups(resultArray, member.childs, member) } } } fillPriorityGroups(priorityGroups, ganttMembersTree) const promises = [] priorityGroups.forEach((priorityGroup) => { promises.push(YtApiService.changeGanttMembersOrder(ganttId, priorityGroup.parent?.id, priorityGroup.childs)) }) //Start date calculating const startDate = ganttData.startDate ?? new Date().getTime() const wipConstraint = ganttData.wipConstraint.wipLimit ?? 1 const nextStartDates = [] for (i = 0; i < wipConstraint; i++) { //todo there is bug if startDate is not working nextStartDates.push(startDate) } const currentStream = 1 inlineGanttMembersTree.forEach((member, i) => { if (member.isParent) { member.startDate = nextStartDates[0] promises.push(YtApiService.updateGanttMember(ganttId, {id: member.id, startDate: member.startDate})) return } const nextStartDate = nextStartDates.shift() member.startDate = nextStartDate promises.push(YtApiService.updateGanttMember(ganttId, {id: member.id, startDate: member.startDate})) let spentTime = Math.ceil((member.spentTime > member.estimation ? member.spentTime : (member.issue.resolved != null ? member.spentTime : member.estimation)) / 60) const endDate = addWorkingTime(nextStartDate, CONFIG_HOURS_IN_DAY, spentTime).getTime() nextStartDates.push(endDate) nextStartDates.sort() }) Promise.all(promises).then(() => location.reload()) } realRecalcButton.addEventListener('click', onRecalcHandler, false) ganttButtonGroup.prepend(realRecalcButton) }) })()