├── .gitignore ├── BillomatInvoices ├── README.md ├── billomat_icon.png ├── billomat_logo.png ├── form.json ├── localization.json ├── plugin.json └── script.js ├── ClockifyImporter ├── README.md ├── clockify_icon.png ├── form.json ├── localization.json ├── plugin.json └── script.js ├── EarlyImporter ├── form.json ├── icon.png ├── localization.json ├── plugin.json └── script.js ├── Hookmark ├── icon.png ├── localization.json └── plugin.json ├── JiraImporter ├── .editorconfig ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── form.json ├── jira_icon.png ├── localization.json ├── plugin.json └── script.js ├── LexofficeInvoices ├── README.md ├── form.json ├── lexoffice_icon.png ├── lexoffice_logo.png ├── localization.json ├── plugin.json └── script.js ├── MiteImporter ├── icon.png ├── localization.json └── plugin.json ├── OpenProjectImporter ├── .editorconfig ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── form.json ├── localization.json ├── openproject_icon.png ├── plugin.json └── script.js ├── README.md ├── Raycast ├── icon.png ├── localization.json └── plugin.json ├── Send to Tyme.grandtotalplugin ├── Info.plist ├── README.md ├── de.lproj │ └── Localizable.strings ├── en.lproj │ └── Localizable.strings ├── grandtotal_icon.png ├── index.js ├── localization.json └── plugin.json ├── SevDeskInvoices ├── README.md ├── form.json ├── localization.json ├── plugin.json ├── script.js ├── sevdesk_icon.png └── sevdesk_logo.png ├── TogglImporter ├── README.md ├── form.json ├── localization.json ├── plugin.json ├── script.js └── toggl_icon.png ├── Tyme.grandtotalplugin ├── Icon.icns ├── Info.plist ├── README.md ├── de.lproj │ └── Localizable.strings ├── en.lproj │ └── Localizable.strings ├── grandtotal_icon.png ├── index.js ├── localization.json └── plugin.json ├── Zapier ├── icon.png ├── localization.json └── plugin.json └── guides ├── importing_data.md ├── plugins_macos.png └── scripting_helpers.md /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /BillomatInvoices/README.md: -------------------------------------------------------------------------------- 1 | # Billomat Invoices 2 | 3 | This plugin lets you export your recorded times to [Billomat](https://www.billomat.com). 4 | You can view a preview of the invoice before actually sending it to Billomat. 5 | 6 | The plugin uses the [Billomat API](https://www.billomat.com/api) to send the data to Billomat. -------------------------------------------------------------------------------- /BillomatInvoices/billomat_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/BillomatInvoices/billomat_icon.png -------------------------------------------------------------------------------- /BillomatInvoices/billomat_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/BillomatInvoices/billomat_logo.png -------------------------------------------------------------------------------- /BillomatInvoices/form.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "billomatID", 4 | "type": "text", 5 | "name": "input.billomatID", 6 | "placeholder": "input.billomatID.placeholder", 7 | "persist": true 8 | }, 9 | { 10 | "id": "apiKey", 11 | "type": "securetext", 12 | "name": "input.key", 13 | "placeholder": "input.key.placeholder", 14 | "persist": true 15 | }, 16 | { 17 | "id": "apiKeyHint", 18 | "type": "hint", 19 | "name": "", 20 | "value": "input.key.hint" 21 | }, 22 | { 23 | "id": "separator1", 24 | "type": "separator", 25 | "name": "" 26 | }, 27 | { 28 | "id": "startDate", 29 | "type": "date", 30 | "name": "input.startdate" 31 | }, 32 | { 33 | "id": "endDate", 34 | "type": "date", 35 | "name": "input.enddate" 36 | }, 37 | { 38 | "id": "teamMemberID", 39 | "type": "teammembers", 40 | "name": "" 41 | }, 42 | { 43 | "id": "taskIDs", 44 | "type": "tasks", 45 | "name": "" 46 | }, 47 | { 48 | "id": "prefixProject", 49 | "type": "checkbox", 50 | "name": "input.prefixProject", 51 | "value": "false", 52 | "persist": true 53 | }, 54 | { 55 | "id": "separator2", 56 | "type": "separator", 57 | "name": "" 58 | }, 59 | { 60 | "id": "showNotes", 61 | "type": "checkbox", 62 | "name": "input.notes", 63 | "value": "true", 64 | "persist": true 65 | }, 66 | { 67 | "id": "showTimesInNotes", 68 | "type": "checkbox", 69 | "name": "input.note.times", 70 | "value": "false", 71 | "persist": true 72 | }, 73 | { 74 | "id": "clusterOption", 75 | "type": "dropdown", 76 | "name": "input.cluster", 77 | "values": [ 78 | { 79 | "0": "input.cluster.none" 80 | }, 81 | { 82 | "1": "input.cluster.dayTask" 83 | }, 84 | { 85 | "2": "input.cluster.dayTaskNoSub" 86 | } 87 | ], 88 | "persist": true 89 | }, 90 | { 91 | "id": "separator3", 92 | "type": "separator", 93 | "name": "" 94 | }, 95 | { 96 | "id": "clientContact", 97 | "type": "dropdown", 98 | "name": "input.clientcontact", 99 | "valueFunction": "billomatResolver.getClientContacts()", 100 | "valueFunctionReloadable": true, 101 | "persist": true 102 | }, 103 | { 104 | "id": "unitsHint", 105 | "type": "hint", 106 | "name": "", 107 | "value": "input.unit.hint" 108 | }, 109 | { 110 | "id": "separator4", 111 | "type": "separator", 112 | "name": "" 113 | }, 114 | { 115 | "id": "onlyUnbilled", 116 | "type": "checkbox", 117 | "name": "input.unbilled", 118 | "value": "false", 119 | "persist": true 120 | }, 121 | { 122 | "id": "markAsBilled", 123 | "type": "checkbox", 124 | "name": "input.billed", 125 | "value": "false", 126 | "persist": true 127 | }, 128 | { 129 | "id": "includeNonBillable", 130 | "type": "checkbox", 131 | "name": "input.nonbillable", 132 | "value": "false", 133 | "persist": true 134 | } 135 | ] 136 | -------------------------------------------------------------------------------- /BillomatInvoices/localization.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "plugin.name": "Billomat Invoices", 4 | "plugin.summary": "With the Billomat plugin, you can create invoices from the hours recorded in the successful accounting software. The integration with time tracking in Tyme allows you to automatically transfer these hours to Billomat. This way, you can optimize your billing process.", 5 | "locale.identifier": "en-US", 6 | "input.key": "API Token", 7 | "input.key.placeholder": "Please enter your API-Token", 8 | "input.billomatID": "Billomat ID", 9 | "input.billomatID.placeholder": "Please enter your billomatID", 10 | "input.key.hint": "You can find your billomatID here: [Billomat](https://www.billomat.net) > Settings > My Account > Company > billomatID and your API Token here: [Billomat](https://www.billomat.net) > Settings > Administration > User > Edit > API access.", 11 | "input.startdate": "From", 12 | "input.enddate": "To", 13 | "input.clientcontact": "Contact / Client", 14 | "input.billed": "Mark exported time entries as billed", 15 | "input.unbilled": "Export only unbilled time entries", 16 | "input.nonbillable": "Include non-billable tasks", 17 | "input.data.empty": "Could not fetch data from Billomat. Please check your API-Token Key.", 18 | "input.notes": "Include notes in invoice", 19 | "input.note.times": "Display times in notes", 20 | "input.prefixProject": "Prefix with project title", 21 | "input.cluster": "Summarize Times", 22 | "input.cluster.none": "Do not Summarize", 23 | "input.cluster.dayTask": "Summarize By Day and Task", 24 | "input.cluster.dayTaskNoSub": "Summarize By Day and Task (No Sub-tasks)", 25 | "input.unit.hint": "Please note that the units (Hour, Piece, km) must exist in Billomat. Otherwise Billomat cannot assign them automatically.", 26 | "unit.hours": "Hours", 27 | "unit.kilometer": "km", 28 | "unit.quantity": "Pieces", 29 | "invoice.header": "Invoice items", 30 | "invoice.position": "Position", 31 | "invoice.price": "Price", 32 | "invoice.quantity": "Quantity", 33 | "invoice.unit": "Unit", 34 | "invoice.net": "Net" 35 | }, 36 | "de": { 37 | "plugin.name": "Billomat Rechnungen", 38 | "plugin.summary": "Mit dem Billomat Plugin erstellst du Rechnungen aus deinen mit Tyme erfassten Stunden in der erfolgreichen Buchhaltungssoftware. Die Integration mit Zeiterfassung in Tyme ermöglicht es dir, diese Stunden automatisch in Billomat zu übertragen. So behältst du den Überblick und optimierst deinen Abrechnungsprozess.", 39 | "locale.identifier": "de-DE", 40 | "input.key": "API Token", 41 | "input.key.placeholder": "Bitte gib deinen API-Token ein", 42 | "input.billomatID": "Billomat ID", 43 | "input.billomatID.placeholder": "Bitte gib deine billomatID hier ein", 44 | "input.key.hint": "Deine billomatID kannst du unter [Billomat](https://www.billomat.net) > Einstellungen > Mein Konto > Firma > billomatID und den API Token unter [Billomat](https://www.billomat.net) > Einstellungen > Administration > Nutzer > Bearbeiten > API Zugriff finden.", 45 | "input.startdate": "Von", 46 | "input.enddate": "Bis", 47 | "input.clientcontact": "Kontakt / Kunde", 48 | "input.billed": "Exportierte Zeiten als berechnet markieren", 49 | "input.unbilled": "Nur nicht berechnete Zeiteinträge exportieren", 50 | "input.nonbillable": "Nicht berechenbare Aufgaben inkludieren", 51 | "input.data.empty": "Es konnten keine Daten von Billomat geladen werden. Bitte überprüfe deinen API-Token.", 52 | "input.notes": "Notizen in die Rechnung übernehmen", 53 | "input.note.times": "Zeiten in Notizen anzeigen", 54 | "input.prefixProject": "Präfix Projekt-Titel", 55 | "input.cluster": "Zeiten Zusammenfassen", 56 | "input.cluster.none": "Nicht zusammenfassen", 57 | "input.cluster.dayTask": "Nach Tag und Aufgabe zusammenfassen", 58 | "input.cluster.dayTaskNoSub": "Nach Tag und Aufgabe zusammenfassen (Keine Unteraufgaben)", 59 | "input.unit.hint": "Beachte bitte, dass die Einheiten (Stunde, Stück, km) in Billomat existieren müssen. Ansonsten kann Billomat diese nicht automatisch zuordnen.", 60 | "unit.hours": "Stunden", 61 | "unit.kilometer": "km", 62 | "unit.quantity": "Stück", 63 | "invoice.header": "Rechnungspositionen", 64 | "invoice.position": "Position", 65 | "invoice.price": "Preis", 66 | "invoice.quantity": "Anzahl", 67 | "invoice.unit": "Einheit", 68 | "invoice.net": "Netto" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /BillomatInvoices/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "billomat_invoices", 3 | "tymeMinVersion": "2024.21", 4 | "version": "2.5", 5 | "type": "export", 6 | "author": "Unit Numberfive oHG", 7 | "authorUrl": "https://www.tyme-app.com", 8 | "icon": "billomat_icon.png", 9 | "scriptName": "script.js", 10 | "scriptMain": "billomatResolver.createNewInvoice()", 11 | "scriptPreview": "timeEntriesConverter.generatePreview()", 12 | "formName": "form.json", 13 | "localizationName": "localization.json", 14 | "internalType": "plugin_4", 15 | "internalLink": "https://www.billomat.com" 16 | } 17 | -------------------------------------------------------------------------------- /BillomatInvoices/script.js: -------------------------------------------------------------------------------- 1 | class TimeEntriesConverter { 2 | constructor() { 3 | 4 | } 5 | 6 | timeEntriesFromFormValues(useClusterOption) { 7 | return tyme.timeEntries( 8 | formValue.startDate, 9 | formValue.endDate, 10 | formValue.taskIDs, 11 | null, 12 | formValue.onlyUnbilled ? 0 : null, 13 | formValue.includeNonBillable ? null : true, 14 | formValue.teamMemberID, 15 | useClusterOption ? formValue.clusterOption : null 16 | ).filter(function (timeEntry) { 17 | return parseFloat(timeEntry.sum) > 0; 18 | }) 19 | } 20 | 21 | timeEntryIDs() { 22 | return this.timeEntriesFromFormValues(false) 23 | .map(function (entry) { 24 | return entry.id; 25 | }); 26 | } 27 | 28 | aggregatedTimeEntryData() { 29 | let data = 30 | this.timeEntriesFromFormValues(true) 31 | .reduce(function (data, timeEntry) { 32 | const key = timeEntry.task_id + timeEntry.subtask_id; 33 | 34 | if (data[key] == null) { 35 | let entry = { 36 | 'project': '', 37 | 'name': '', 38 | 'quantity': 0.0, 39 | 'unit': '', 40 | 'price': parseFloat(timeEntry.rate), 41 | 'note': '', 42 | 'sum': 0.0 43 | }; 44 | 45 | // unit: Stk=1, Std=9, km=10 46 | 47 | if (timeEntry.type === 'timed') { 48 | entry.unit = utils.localize('unit.hours') 49 | } else if (timeEntry.type === 'mileage') { 50 | entry.unit = utils.localize('unit.kilometer') 51 | } else if (timeEntry.type === 'fixed') { 52 | entry.unit = utils.localize('unit.quantity') 53 | } 54 | 55 | entry.project = timeEntry.project; 56 | entry.name = timeEntry.task; 57 | 58 | if (timeEntry.subtask.length > 0) { 59 | entry.name += ': ' + timeEntry.subtask 60 | } 61 | 62 | data[key] = entry; 63 | } 64 | 65 | let currentQuantity = 0; 66 | 67 | if (timeEntry.type === 'timed') { 68 | currentQuantity = parseFloat(timeEntry.duration) / 60.0 69 | data[key].quantity += currentQuantity; 70 | } else if (timeEntry.type === 'mileage') { 71 | currentQuantity = parseFloat(timeEntry.distance) 72 | data[key].quantity += currentQuantity; 73 | } else if (timeEntry.type === 'fixed') { 74 | currentQuantity = parseFloat(timeEntry.quantity) 75 | data[key].quantity += currentQuantity; 76 | } 77 | 78 | data[key].sum += timeEntry.sum; 79 | 80 | if (formValue.showTimesInNotes && timeEntry.type !== "fixed") { 81 | 82 | if (data[key].note.length > 0) { 83 | data[key].note += '
'; 84 | } 85 | 86 | if (timeEntry.hasOwnProperty("start") && timeEntry.hasOwnProperty("end")) { 87 | data[key].note += this.formatDate(timeEntry.start, false) + " "; 88 | data[key].note += this.formatDate(timeEntry.start, true) + " - "; 89 | data[key].note += this.formatDate(timeEntry.end, true); 90 | } else if (timeEntry.hasOwnProperty("date")) { 91 | data[key].note += this.formatDate(timeEntry.date, false); 92 | } 93 | 94 | data[key].note += " (" + this.roundNumber(currentQuantity, 2) + " " + data[key].unit + ")"; 95 | 96 | if (timeEntry.note.length > 0) { 97 | data[key].note += "
"; 98 | data[key].note += timeEntry.note; 99 | } 100 | 101 | } else if (timeEntry.note.length > 0) { 102 | if (data[key].note.length > 0) { 103 | data[key].note += '
'; 104 | } 105 | data[key].note += timeEntry.note; 106 | } 107 | 108 | return data; 109 | 110 | }.bind(this), {}); 111 | 112 | return Object.keys(data) 113 | .map(function (key) { 114 | return data[key]; 115 | }) 116 | .sort(function (a, b) { 117 | return a.name > b.name; 118 | }); 119 | } 120 | 121 | formatDate(dateString, timeOnly) { 122 | let locale = utils.localize('locale.identifier'); 123 | if (timeOnly) { 124 | return (new Date(dateString)).toLocaleTimeString(locale, {hour: '2-digit', minute: '2-digit'}); 125 | } else { 126 | return (new Date(dateString)).toLocaleDateString(locale); 127 | } 128 | } 129 | 130 | roundNumber(num, places) { 131 | return (+(Math.round(num + "e+" + places) + "e-" + places)).toFixed(places); 132 | } 133 | 134 | generatePreview() { 135 | const data = this.aggregatedTimeEntryData() 136 | const total = data.reduce(function (sum, timeEntry) { 137 | sum += timeEntry.sum; 138 | return sum; 139 | }, 0.0); 140 | 141 | let str = ''; 142 | str += '![](plugins/BillomatInvoices/billomat_logo.png)\n'; 143 | str += '## ' + utils.localize('invoice.header') + '\n'; 144 | 145 | str += '|' + utils.localize('invoice.position'); 146 | str += '|' + utils.localize('invoice.price'); 147 | str += '|' + utils.localize('invoice.quantity'); 148 | str += '|' + utils.localize('invoice.unit'); 149 | str += '|' + utils.localize('invoice.net'); 150 | str += '|\n'; 151 | 152 | str += '|-|-:|-:|-|-:|\n'; 153 | 154 | data.forEach((entry) => { 155 | 156 | var name = entry.name; 157 | 158 | if (formValue.showNotes) { 159 | name = '**' + entry.name + '**'; 160 | name += '
' + entry.note.replace(/\n/g, '
'); 161 | } 162 | 163 | if (formValue.prefixProject) { 164 | name = '**' + entry.project + ':** ' + name; 165 | } 166 | 167 | str += '|' + name; 168 | str += '|' + entry.price.toFixed(2) + ' ' + tyme.currencySymbol(); 169 | str += '|' + entry.quantity.toFixed(2); 170 | str += '|' + entry.unit; 171 | str += '|' + entry.sum.toFixed(2) + ' ' + tyme.currencySymbol(); 172 | str += '|\n'; 173 | }); 174 | 175 | str += '|||||**' + total.toFixed(2) + ' ' + tyme.currencySymbol() + '**|\n'; 176 | return utils.markdownToHTML(str); 177 | } 178 | } 179 | 180 | class BillomatResolver { 181 | constructor(billomatID, apiKey, timeEntriesConverter) { 182 | this.apiKey = apiKey; 183 | this.billomatID = billomatID.length !== 0 ? billomatID : "default"; 184 | this.headers = {'X-BillomatApiKey': this.apiKey}; 185 | this.baseURL = 'https://' + this.billomatID + '.billomat.net/api/'; 186 | this.timeEntriesConverter = timeEntriesConverter; 187 | } 188 | 189 | getClientContacts() { 190 | this.clients = []; 191 | 192 | const entriesPerPage = 500; 193 | let currentPage = 1; 194 | let newContactCount = 1; 195 | 196 | while (newContactCount > 0) { 197 | newContactCount = this.getClientPage(currentPage, entriesPerPage); 198 | ++currentPage; 199 | } 200 | 201 | if (this.clients.length === 0) { 202 | this.clients.push({ 203 | 'name': utils.localize('input.data.empty'), 204 | 'value': '' 205 | }); 206 | } 207 | 208 | return this.clients; 209 | } 210 | 211 | getClientPage(page, entriesPerPage) { 212 | const url = this.baseURL + 'clients'; 213 | const response = utils.request(url, 'GET', this.headers, {'per_page': entriesPerPage, 'page': page}); 214 | const statusCode = response['statusCode']; 215 | const result = response['result']; 216 | 217 | if (statusCode === 200) { 218 | const parsedData = JSON.parse(result); 219 | 220 | if (!parsedData['clients']["client"]) { 221 | return 0; 222 | } 223 | 224 | const clients = parsedData['clients']["client"]; 225 | 226 | for (let client of clients) { 227 | this.clients.push({ 228 | 'name': client['name'] + " - " + client['first_name'] + " " + client['last_name'], 229 | 'value': client['id'] 230 | }); 231 | } 232 | 233 | return clients.length; 234 | } else { 235 | return 0; 236 | } 237 | } 238 | 239 | createNewInvoice() { 240 | const invoiceID = this.makeCreateInvoiceCall(); 241 | 242 | if (invoiceID !== null) { 243 | if (formValue.markAsBilled) { 244 | const timeEntryIDs = this.timeEntriesConverter.timeEntryIDs(); 245 | tyme.setBillingState(timeEntryIDs, 1); 246 | } 247 | 248 | tyme.openURL('https://' + this.billomatID + '.billomat.net/app/invoices/show/entityId/' + invoiceID); 249 | } 250 | } 251 | 252 | makeCreateInvoiceCall() { 253 | const clientID = formValue.clientContact; 254 | const data = this.timeEntriesConverter.aggregatedTimeEntryData() 255 | let invoiceItems = []; 256 | 257 | data.forEach((entry) => { 258 | const name = formValue.prefixProject ? entry.project + ": " + entry.name : entry.name; 259 | const note = formValue.showNotes ? entry.note.replaceAll('
', '\n') : ''; 260 | 261 | invoiceItems.push({ 262 | "invoice-item": { 263 | "unit": entry.unit, 264 | "unit_price": entry.price.toFixed(2), 265 | "quantity": entry.quantity.toFixed(2), 266 | "title": name, 267 | "description": note 268 | } 269 | }) 270 | }); 271 | 272 | let params = { 273 | "invoice": { 274 | "client_id": clientID, 275 | "supply_date_type": "SUPPLY_TEXT", 276 | "supply_date": formValue.startDate.toLocaleDateString() + " - " + formValue.endDate.toLocaleDateString(), 277 | "net_gross": "NET", 278 | "invoice-items": invoiceItems 279 | } 280 | }; 281 | 282 | const url = this.baseURL + 'invoices'; 283 | const response = utils.request(url, 'POST', this.headers, params); 284 | const statusCode = response['statusCode']; 285 | const result = response['result']; 286 | 287 | if (statusCode === 201) { 288 | const parsedData = JSON.parse(result); 289 | return parsedData["invoice"]["id"]; 290 | } else { 291 | tyme.showAlert('Billomat API Error', JSON.stringify(response)); 292 | return null; 293 | } 294 | } 295 | } 296 | 297 | const timeEntriesConverter = new TimeEntriesConverter(); 298 | const billomatResolver = new BillomatResolver(formValue.billomatID, formValue.apiKey, timeEntriesConverter); -------------------------------------------------------------------------------- /ClockifyImporter/README.md: -------------------------------------------------------------------------------- 1 | # Clockify Importer 2 | 3 | This plugin imports your data from [Clockify](https://clockify.me). 4 | 5 | The plugin uses the [Clockify API](https://clockify.me/developers-api) to fetch the data. -------------------------------------------------------------------------------- /ClockifyImporter/clockify_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/ClockifyImporter/clockify_icon.png -------------------------------------------------------------------------------- /ClockifyImporter/form.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "clockifyKey", 4 | "type": "securetext", 5 | "name": "input.key", 6 | "placeholder": "input.key.placeholder", 7 | "persist": true 8 | }, 9 | { 10 | "id": "clockifyKeyHint", 11 | "type": "hint", 12 | "name": "", 13 | "value": "input.key.hint" 14 | }, 15 | { 16 | "id": "separator", 17 | "type": "separator", 18 | "name": "" 19 | }, 20 | { 21 | "id": "splitTasksNonBillable", 22 | "type": "checkbox", 23 | "name": "input.billable.tasks", 24 | "value": "false", 25 | "persist": true 26 | } 27 | ] 28 | -------------------------------------------------------------------------------- /ClockifyImporter/localization.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "plugin.name": "Clockify", 4 | "plugin.summary": "Import your data from Clockify right into Tyme.", 5 | "input.key": "Clockify API Key", 6 | "input.key.placeholder": "Please enter your API key", 7 | "input.key.hint": "You can view the API key in your [User Settings](https://app.clockify.me/user/settings).\n\nAll clients, projects, tasks and time entries of the last two years will be imported.\n\nIf there are time entries from different users in your Clockify account, the importer will try to match them to your Tyme users based on the email address of the user.", 8 | "input.billable.tasks": "Create separate tasks for non-billable time entries." 9 | }, 10 | "de": { 11 | "plugin.name": "Clockify", 12 | "plugin.summary": "Importiere deine Daten aus Clockify direkt in Tyme.", 13 | "input.key": "Clockify API Key", 14 | "input.key.placeholder": "Bitte gib deinen API Schlüssel ein", 15 | "input.key.hint": "Du kannst den API-Schlüssel in deinen [Benutzer Einstellungen](https://app.clockify.me/user/settings) einsehen.\n\nEs werden alle Kunden, Projekte, Aufgaben und Zeiteinträge der letzten zwei Jahre importiert.\n\nWenn in deinem Clockify-Konto Zeiteinträge von verschiedenen Benutzern vorhanden sind, versucht der Importer, diese anhand der E-Mail-Adresse des Benutzers deinen Tyme-Nutzern zuzuordnen.", 16 | "input.billable.tasks": "Separate Aufgaben für nicht-berechenbare Zeiteinträge anlegen." 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ClockifyImporter/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "clockify_importer", 3 | "tymeMinVersion": "2024.11", 4 | "version": "1.3", 5 | "type": "import", 6 | "author": "Unit Numberfive oHG", 7 | "authorUrl": "https://www.tyme-app.com", 8 | "icon": "clockify_icon.png", 9 | "scriptName": "script.js", 10 | "scriptMain": "importer.start()", 11 | "scriptPreview": "", 12 | "formName": "form.json", 13 | "localizationName": "localization.json", 14 | "internalType": "time_1", 15 | "internalLink": "" 16 | } 17 | -------------------------------------------------------------------------------- /ClockifyImporter/script.js: -------------------------------------------------------------------------------- 1 | class ClockifyApiClient { 2 | 3 | constructor(apiKey) { 4 | this.apiKey = apiKey; 5 | this.baseURL = 'https://api.clockify.me/api/v1/'; 6 | } 7 | 8 | getJSON(path, params = null) { 9 | const response = utils.request(this.baseURL + path, 'GET', {'X-Api-Key': this.apiKey}, params); 10 | const statusCode = response['statusCode']; 11 | const result = response['result']; 12 | 13 | if (statusCode === 200) { 14 | return JSON.parse(result); 15 | } else { 16 | tyme.showAlert('Clockify API Error', JSON.stringify(response)); 17 | return null; 18 | } 19 | } 20 | } 21 | 22 | class ClockifyImporter { 23 | constructor(apiKey) { 24 | this.apiClient = new ClockifyApiClient(apiKey); 25 | } 26 | 27 | start() { 28 | if (!this.getUser()) { 29 | return; 30 | } 31 | if (!this.getWorkspaces()) { 32 | return; 33 | } 34 | 35 | this.users = this.generateLookup("users", {"status": "ACTIVE"}); 36 | 37 | const activeClients = this.generateLookup("clients", {"archived": "false"}); 38 | const archivedClients = this.generateLookup("clients", {"archived": "true"}); 39 | this.clients = {...activeClients, ...archivedClients}; 40 | 41 | const activeProjects = this.generateLookup("projects", {"archived": "false"}); 42 | const archivedProjects = this.generateLookup("projects", {"archived": "true"}); 43 | this.projects = {...activeProjects, ...archivedProjects}; 44 | 45 | this.tasks = {}; 46 | 47 | for (let projectID in this.projects) { 48 | let activeTasks = this.generateLookup( 49 | "projects/" + projectID + "/tasks", 50 | { 51 | "is-active": true, 52 | "project-required": true, 53 | "task-required": false 54 | } 55 | ); 56 | 57 | let archivedTasks = this.generateLookup( 58 | "projects/" + projectID + "/tasks", 59 | { 60 | "is-active": false, 61 | "project-required": true, 62 | "task-required": false 63 | } 64 | ); 65 | 66 | this.tasks = {...this.tasks, ...activeTasks, ...archivedTasks}; 67 | } 68 | 69 | let startDate = new Date(); 70 | startDate.setFullYear(startDate.getFullYear() - 2); 71 | let endDate = new Date(); 72 | 73 | for (let userID in this.users) { 74 | const timeEntries = this.generateLookup( 75 | "user/" + userID + "/time-entries/", 76 | { 77 | "start": startDate.toISOString(), 78 | "end": endDate.toISOString() 79 | } 80 | ); 81 | this.timeEntries = {...this.timeEntries, ...timeEntries}; 82 | } 83 | 84 | this.processData(); 85 | } 86 | 87 | extractEstimate(estimateString) { 88 | let matches = estimateString.match(/PT([\d]+)H/); 89 | 90 | if (matches && matches.length > 0) { 91 | return parseInt(matches[1]) * 60 * 60; 92 | } 93 | 94 | return 0; 95 | } 96 | 97 | processData() { 98 | const idPrefix = "clockify-"; 99 | 100 | for (let clientID in this.clients) { 101 | const client = this.clients[clientID]; 102 | const id = idPrefix + client["id"]; 103 | 104 | let tymeCategory = Category.fromID(id) ?? Category.create(id); 105 | tymeCategory.name = client["name"]; 106 | tymeCategory.isCompleted = client["archived"]; 107 | } 108 | 109 | for (let projectID in this.projects) { 110 | const project = this.projects[projectID]; 111 | const id = idPrefix + project["id"]; 112 | const clientID = idPrefix + project["clientId"]; 113 | 114 | let hourlyRate = 0; 115 | 116 | if (project["hourlyRate"]) { 117 | hourlyRate = project["hourlyRate"]["amount"]; 118 | } 119 | 120 | if (project['billable'] && hourlyRate === 0) { 121 | hourlyRate = this.workspaceHourlyRate / 100.0; 122 | } else { 123 | hourlyRate = hourlyRate / 100.0; 124 | } 125 | 126 | let tymeProject = Project.fromID(id) ?? Project.create(id); 127 | tymeProject.name = project["name"]; 128 | tymeProject.note = project["note"]; 129 | tymeProject.isCompleted = project["archived"]; 130 | tymeProject.color = parseInt(project["color"].replace("#", "0x")); 131 | tymeProject.defaultHourlyRate = hourlyRate; 132 | 133 | const tymeCategory = Category.fromID(clientID); 134 | if (tymeCategory) { 135 | tymeProject.category = tymeCategory; 136 | if (tymeCategory.isCompleted) { 137 | tymeProject.isCompleted = true; 138 | } 139 | } 140 | 141 | let plannedDuration = 0; 142 | 143 | if (project["timeEstimate"] && project["timeEstimate"]["active"] && project["timeEstimate"]["type"] === "MANUAL") { 144 | plannedDuration = this.extractEstimate(project["timeEstimate"]["estimate"]); 145 | } 146 | 147 | tymeProject.plannedDuration = plannedDuration; 148 | } 149 | 150 | for (let taskID in this.tasks) { 151 | const task = this.tasks[taskID]; 152 | const id = idPrefix + task["id"]; 153 | const projectID = idPrefix + task["projectId"]; 154 | 155 | let tymeTask = TimedTask.fromID(id) ?? TimedTask.create(id); 156 | tymeTask.name = task["name"]; 157 | tymeTask.note = task["note"]; 158 | tymeTask.isCompleted = task["status"] === "DONE"; 159 | tymeTask.billable = task["billable"]; 160 | const project = Project.fromID(projectID); 161 | tymeTask.project = project; 162 | 163 | if (project.isCompleted) { 164 | tymeTask.isCompleted = true; 165 | } 166 | 167 | if (task["estimate"]) { 168 | tymeTask.plannedDuration = this.extractEstimate(task["estimate"]); 169 | } 170 | 171 | if (task["hourlyRate"]) { 172 | tymeTask.hourlyRate = task["hourlyRate"]["amount"] 173 | } 174 | } 175 | 176 | for (let timeEntryID in this.timeEntries) { 177 | const timeEntry = this.timeEntries[timeEntryID]; 178 | const id = idPrefix + timeEntry["id"]; 179 | 180 | if (!timeEntry["timeInterval"]) { 181 | continue; 182 | } 183 | 184 | let taskID = ""; 185 | 186 | if (!timeEntry["taskId"]) { 187 | const projectID = idPrefix + timeEntry["projectId"]; 188 | taskID = idPrefix + timeEntry["projectId"] + "-default"; 189 | let tymeTask = TimedTask.fromID(taskID) ?? TimedTask.create(taskID); 190 | tymeTask.project = Project.fromID(projectID); 191 | tymeTask.name = "Default Task"; 192 | } else { 193 | taskID = idPrefix + timeEntry["taskId"]; 194 | } 195 | 196 | let tymeEntry = TimeEntry.fromID(id) ?? TimeEntry.create(id); 197 | tymeEntry.note = timeEntry["description"]; 198 | tymeEntry.timeStart = Date.parse(timeEntry["timeInterval"]["start"]); 199 | tymeEntry.timeEnd = Date.parse(timeEntry["timeInterval"]["end"]); 200 | 201 | let parentTask = TimedTask.fromID(taskID); 202 | 203 | if (formValue.splitTasksNonBillable && parentTask.billable && !timeEntry["billable"]) { 204 | let nonBillableTaskID = taskID + "_nb"; 205 | let nonBillableTask = TimedTask.fromID(nonBillableTaskID) ?? TimedTask.create(nonBillableTaskID); 206 | nonBillableTask.name = parentTask.name + " (Non-billable)"; 207 | nonBillableTask.billable = false; 208 | nonBillableTask.isCompleted = parentTask.isCompleted; 209 | nonBillableTask.plannedDuration = parentTask.plannedDuration; 210 | nonBillableTask.hourlyRate = parentTask.hourlyRate; 211 | nonBillableTask.project = Project.fromID(idPrefix + timeEntry["projectId"]); 212 | parentTask = nonBillableTask; 213 | } 214 | 215 | tymeEntry.parentTask = parentTask; 216 | 217 | const userID = timeEntry["userId"]; 218 | const user = this.users[userID]; 219 | const tymeUserID = tyme.userIDForEmail(user["email"]); 220 | 221 | if (tymeUserID) { 222 | tymeEntry.userID = tymeUserID; 223 | } 224 | } 225 | } 226 | 227 | getUser() { 228 | const userResponse = this.apiClient.getJSON("user"); 229 | if (!userResponse) { 230 | return false; 231 | } 232 | 233 | this.userID = userResponse["id"]; 234 | this.activeWorkspaceID = userResponse["activeWorkspace"]; 235 | 236 | return true; 237 | } 238 | 239 | getWorkspaces() { 240 | const workspacesResponse = this.apiClient.getJSON("workspaces"); 241 | if (!workspacesResponse) { 242 | return false; 243 | } 244 | 245 | this.workspaceHourlyRate = 0; 246 | 247 | const activeWorkspace = 248 | workspacesResponse 249 | .filter(function (workspace) { 250 | return workspace.id === this.activeWorkspaceID; 251 | }.bind(this))[0]; 252 | 253 | if (activeWorkspace.hourlyRate) { 254 | this.workspaceHourlyRate = activeWorkspace.hourlyRate.amount; 255 | } 256 | 257 | return true 258 | } 259 | 260 | generateLookup(path, additionalParams = {}) { 261 | let lookUpData = {}; 262 | let page = 1; 263 | let finished = false; 264 | 265 | do { 266 | const params = { 267 | "page": page, 268 | ...additionalParams 269 | }; 270 | const response = this.apiClient.getJSON("workspaces/" + this.activeWorkspaceID + "/" + path, params); 271 | 272 | if (!response || response.length === 0) { 273 | finished = true; 274 | } 275 | 276 | response.forEach(function (entry) { 277 | const id = entry["id"]; 278 | lookUpData[id] = entry; 279 | }.bind(this)); 280 | 281 | page++; 282 | } 283 | while (!finished); 284 | 285 | return lookUpData; 286 | } 287 | } 288 | 289 | const importer = new ClockifyImporter(formValue.clockifyKey); 290 | -------------------------------------------------------------------------------- /EarlyImporter/form.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "separator", 4 | "type": "separator", 5 | "name": "" 6 | }, 7 | { 8 | "id": "apiKey", 9 | "type": "securetext", 10 | "name": "input.key", 11 | "persist": true 12 | }, 13 | { 14 | "id": "apiSecret", 15 | "type": "securetext", 16 | "name": "input.secret", 17 | "persist": true 18 | }, 19 | { 20 | "id": "apiKeyHint", 21 | "type": "hint", 22 | "name": "", 23 | "value": "input.hint" 24 | }, 25 | { 26 | "id": "dateRange", 27 | "type": "daterange", 28 | "name": "input.daterange" 29 | } 30 | ] 31 | -------------------------------------------------------------------------------- /EarlyImporter/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/EarlyImporter/icon.png -------------------------------------------------------------------------------- /EarlyImporter/localization.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "plugin.name": "Early (formerly Timeular)", 4 | "plugin.summary": "Import your activities & time entries from Early right into Tyme.", 5 | "input.key": "API Key", 6 | "input.secret": "API Secret", 7 | "input.hint": "You can find the API key & secret in your [Early Account](https://product.early.app/#/settings/account).\n\nAll data in the set date range is imported, up to a maximum of two years in the past. A folder will become a project and an activity a task. Note that Early currently does not expose billing states.\n\nTyme tries to match users based on their email. So be sure to use the same email address in Early and in Tyme for all users. If you have only one user in Early or no team in Tyme, everything will be automatically matched to you.", 8 | "input.daterange": "Date Range" 9 | }, 10 | "de": { 11 | "plugin.name": "Early (formerly Timeular)", 12 | "plugin.summary": "Importiere deine Aktivitäten und Zeiteinträge aus Early direkt in Tyme.", 13 | "input.key": "API Key", 14 | "input.secret": "API Secret", 15 | "input.hint": "Den API-Key & Secret findest du in deinem [Early-Konto](https://product.early.app/#/settings/account).\n\nAlle Daten des eingestellten Datumsbereiches werden importiert, maximal zwei Jahre in die Vergangenheit. Ein Ordner wird zu einem Projekt und eine Aktivität zu einer Aufgabe. Hinweis: Early stellt derzeit keinen Abrechnungsstatus über die API zur Verfügung.\n\nTyme versucht, Benutzer anhand ihrer E-Mail-Adresse zuzuordnen. Achte daher darauf, dass alle Benutzer in Early und Tyme dieselbe E-Mail-Adresse verwenden. Wenn du nur einen Benutzer in Early oder kein Team in Tyme hast, wird alles automatisch dir zugeordnet.", 16 | "input.daterange": "Zeitraum" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /EarlyImporter/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "early_importer", 3 | "tymeMinVersion": "2025.3", 4 | "version": "1.3", 5 | "type": "import", 6 | "author": "Unit Numberfive oHG", 7 | "authorUrl": "https://www.tyme-app.com", 8 | "icon": "icon.png", 9 | "scriptName": "script.js", 10 | "scriptMain": "importer.start()", 11 | "scriptPreview": "", 12 | "formName": "form.json", 13 | "localizationName": "localization.json", 14 | "internalType": "time_4", 15 | "internalLink": "" 16 | } 17 | -------------------------------------------------------------------------------- /EarlyImporter/script.js: -------------------------------------------------------------------------------- 1 | class EarlyApiClient { 2 | constructor(apiKey, apiSecret) { 3 | this.apiKey = apiKey; 4 | this.apiSecret = apiSecret; 5 | this.baseURL = 'https://api.early.app/api/v4/'; 6 | this.accessToken = null; 7 | } 8 | 9 | hasAccessToken() { 10 | return this.accessToken !== null; 11 | } 12 | 13 | getAccessToken() { 14 | if (this.hasAccessToken()) { 15 | return this.accessToken; 16 | } 17 | 18 | const response = utils.request( 19 | this.baseURL + "developer/sign-in", 20 | "POST", 21 | {}, 22 | { 23 | "apiKey": this.apiKey, 24 | "apiSecret": this.apiSecret 25 | } 26 | ); 27 | 28 | const statusCode = response['statusCode']; 29 | let result = response['result']; 30 | 31 | if (statusCode === 200) { 32 | result = JSON.parse(result); 33 | this.accessToken = result["token"]; 34 | } 35 | 36 | return this.accessToken; 37 | } 38 | 39 | getJSON(method, path, params = {}) { 40 | const accessToken = this.getAccessToken(); 41 | 42 | if (!accessToken) { 43 | return; 44 | } 45 | 46 | const headers = {"Authorization": "Bearer " + accessToken}; 47 | const response = utils.request(this.baseURL + path, method, headers, params); 48 | const statusCode = response['statusCode']; 49 | const result = response['result']; 50 | 51 | if (statusCode === 200) { 52 | return JSON.parse(result); 53 | } else { 54 | return null; 55 | } 56 | } 57 | } 58 | 59 | class EarlyImporter { 60 | constructor(apiKey, apiSecret) { 61 | this.apiClient = new EarlyApiClient(apiKey, apiSecret); 62 | } 63 | 64 | start() { 65 | this.timeEntries = []; 66 | this.getReportData(); 67 | this.getCurrentTracking(); 68 | this.parseData(); 69 | } 70 | 71 | getLeavesData() { 72 | // to be implemented later, when Tyme supports importing absence data 73 | 74 | for (let i = 1; i <= 2; i++) { 75 | let startDate = new Date(); 76 | startDate.setFullYear(startDate.getFullYear() - i); 77 | let endDate = new Date(); 78 | endDate.setFullYear(endDate.getFullYear() - (i - 1)); 79 | 80 | const startString = startDate.toISOString().split('T')[0] 81 | const endString = endDate.toISOString().split('T')[0] 82 | 83 | const response = this.apiClient.getJSON( 84 | "GET", 85 | "leaves", 86 | { 87 | "start": startString, 88 | "end": endString 89 | } 90 | ); 91 | } 92 | } 93 | 94 | getCurrentTracking() { 95 | const response = this.apiClient.getJSON( 96 | "GET", 97 | "tracking" 98 | ); 99 | 100 | if (response) { 101 | this.timeEntries.push(response); 102 | } 103 | } 104 | 105 | getReportData() { 106 | 107 | for (let i = 1; i <= 2; i++) { 108 | let startDate = new Date(); 109 | startDate.setFullYear(startDate.getFullYear() - i); 110 | let endDate = new Date(); 111 | endDate.setFullYear(endDate.getFullYear() - (i - 1)); 112 | 113 | startDate = startDate > formValue.dateRange[0] ? startDate : formValue.dateRange[0]; 114 | startDate.setUTCHours(0,0,0,0); 115 | endDate = endDate < formValue.dateRange[1] ? endDate : formValue.dateRange[1]; 116 | endDate.setUTCHours(23,59,59,999); 117 | 118 | if (endDate - startDate < 0) { 119 | continue; 120 | } 121 | 122 | const startString = startDate.toISOString().split('T')[0] 123 | const endString = endDate.toISOString().split('T')[0] 124 | const response = this.apiClient.getJSON( 125 | "POST", 126 | "report", 127 | { 128 | "date": { 129 | "start": startString, 130 | "end": endString 131 | }, 132 | "fileType": "json" 133 | } 134 | ); 135 | 136 | if (response) { 137 | this.timeEntries.push(...response["timeEntries"]); 138 | } 139 | } 140 | } 141 | 142 | parseData() { 143 | for (const timeEntry of this.timeEntries) { 144 | const idPrefix = "early-"; 145 | 146 | const timeEntryID = idPrefix + timeEntry["id"]; 147 | const activityID = idPrefix + timeEntry["activity"]["id"]; 148 | const activityName = timeEntry["activity"]["name"]; 149 | const activityColor = timeEntry["activity"]["color"]; 150 | 151 | let folderID = idPrefix + timeEntry["activity"]["folderId"]; 152 | let folderName = "Default"; 153 | 154 | if (timeEntry["folder"]) { 155 | folderName = timeEntry["folder"]["name"]; 156 | } 157 | 158 | let userEmail = ""; 159 | 160 | if (timeEntry["folder"]) { 161 | userEmail = timeEntry["user"]["email"]; 162 | } 163 | 164 | let start = 0; 165 | let end = 0; 166 | 167 | if (timeEntry["duration"]) { 168 | start = Date.parse(timeEntry["duration"]["startedAt"]); 169 | end = Date.parse(timeEntry["duration"]["stoppedAt"]); 170 | } else if (timeEntry["startedAt"]) { 171 | start = Date.parse(timeEntry["startedAt"]); 172 | } 173 | 174 | const note = timeEntry["note"]["text"] ?? ""; 175 | 176 | const categoryID = idPrefix + "default-category"; 177 | let tymeCategory = Category.fromID(categoryID) 178 | if (!tymeCategory) { 179 | tymeCategory = Category.create(categoryID); 180 | tymeCategory.name = "Early Import"; 181 | } 182 | 183 | let tymeProject = Project.fromID(folderID) ?? Project.create(folderID); 184 | tymeProject.name = folderName; 185 | tymeProject.color = parseInt(activityColor.replace("#", "0x")); 186 | tymeProject.category = tymeCategory; 187 | 188 | let tymeTask = TimedTask.fromID(activityID) ?? TimedTask.create(activityID); 189 | tymeTask.name = activityName; 190 | tymeTask.project = tymeProject; 191 | 192 | let tymeEntry = TimeEntry.fromID(timeEntryID) ?? TimeEntry.create(timeEntryID); 193 | tymeEntry.note = note; 194 | tymeEntry.timeStart = start; 195 | tymeEntry.timeEnd = end; 196 | tymeEntry.parentTask = tymeTask; 197 | 198 | const tymeUserID = tyme.userIDForEmail(userEmail); 199 | 200 | if (tymeUserID) { 201 | tymeEntry.userID = tymeUserID; 202 | } 203 | } 204 | } 205 | } 206 | 207 | const importer = new EarlyImporter( 208 | formValue.apiKey, 209 | formValue.apiSecret 210 | ); 211 | 212 | -------------------------------------------------------------------------------- /Hookmark/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/Hookmark/icon.png -------------------------------------------------------------------------------- /Hookmark/localization.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "plugin.name": "Hookmark Bookmarks", 4 | "plugin.summary": "The Hookmark plugin lets you create bookmarks from time entries, projects and tasks in Tyme. You can access these bookmarks directly, making it easier to organise and control your work." 5 | }, 6 | "de": { 7 | "plugin.name": "Hookmark Bookmarks", 8 | "plugin.summary": "Mit dem Hookmark Plugin erstellst du Bookmarks aus Zeiteinträgen, Projekten und Aufgaben in Tyme. Die Integration ermöglicht es dir, auf diese Bookmarks direkt zuzugreifen, was die Organisation und Kontrolle deiner eigenen Arbeit erheblich erleichtern kann." 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Hookmark/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "hookmark_plugin", 3 | "tymeMinVersion": "2024.11", 4 | "version": "1.1", 5 | "type": "export", 6 | "author": "Unit Numberfive oHG", 7 | "authorUrl": "https://www.tyme-app.com/en/blog-2024-05-01-hookmark/?hide_nav", 8 | "icon": "icon.png", 9 | "scriptName": "", 10 | "scriptMain": "", 11 | "scriptPreview": "", 12 | "formName": "", 13 | "localizationName": "localization.json", 14 | "isBuiltIn": true, 15 | "internalType": "plugin_7", 16 | "internalLink": "/blog-2024-05-01-hookmark/" 17 | } 18 | -------------------------------------------------------------------------------- /JiraImporter/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /JiraImporter/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "trailingComma": "es5", 5 | "semi": true, 6 | "singleQuote": true, 7 | "bracketSpacing": true, 8 | "printWidth": 120, 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /JiraImporter/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.2 2 | 3 | - Changed format of project IDs. 4 | 5 | # 1.0 6 | 7 | - Initial release of the Jira Import Plugin. 8 | -------------------------------------------------------------------------------- /JiraImporter/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Lars Malewski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /JiraImporter/README.md: -------------------------------------------------------------------------------- 1 | # Jira Importer 2 | 3 | This plugin imports your open and assigned issues from Jira. It can also update tasks based on time entries in a given time period, which are not assigned to you or currently open in Jira. 4 | 5 | The plugin uses the [Jira Rest API](https://developer.atlassian.com/cloud/jira/platform/rest/v3) to fetch the data. The Jira URL can be specified. 6 | 7 | ## Functionality 8 | 9 | ### Categories and Projects 10 | 11 | For each project in Jira a Tyme project is created. Categories will not be created, but can be set manually. 12 | 13 | ### Tasks (Issues) 14 | 15 | A task is created for each issue in Jira. 16 | 17 | The following data is set or updated: 18 | | Tyme | Jira | 19 | | --- | --- | 20 | | Name | Issue summary | 21 | | Start date | Select between Jira date fields. | 22 | | End date | Select between Jira date fields. | 23 | | Planned Duration | Select between Jira number fields. | 24 | 25 | ### Excluded Projects 26 | 27 | Certain projects can be excluded from import by using the excluded projects option. A comma-separated list of project keys can be specified. Issues of these projects are not imported. 28 | 29 | ### Updating Tasks (Issues) from Time Entries 30 | 31 | If the option to update tasks is enabled, the time entries in Tyme for the selected range will be selected. The associated tasks of these time entries are then retrieved and updated individually, even if they have been closed in Jira or are no longer assigned to you. All status with the status category "Done" are considered closed. 32 | 33 | ## License 34 | 35 | Distributed under the MIT License. See the LICENSE file for more info. 36 | 37 | > [!IMPORTANT] 38 | > This License only applies to the JiraImporter Plugin. 39 | -------------------------------------------------------------------------------- /JiraImporter/form.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "jiraURL", 4 | "type": "text", 5 | "name": "input.url", 6 | "placeholder": "input.url.placeholder", 7 | "persist": true 8 | }, 9 | { 10 | "id": "jiraUser", 11 | "type": "text", 12 | "name": "input.user", 13 | "placeholder": "input.user.placeholder", 14 | "persist": true 15 | }, 16 | { 17 | "id": "jiraKey", 18 | "type": "securetext", 19 | "name": "input.key", 20 | "placeholder": "input.key.placeholder", 21 | "actionFunction": "importer.check(true)", 22 | "persist": true 23 | }, 24 | { 25 | "id": "jiraKeyHint", 26 | "type": "hint", 27 | "name": "", 28 | "value": "input.key.hint" 29 | }, 30 | { 31 | "id": "sep_1", 32 | "type": "separator", 33 | "name": "" 34 | }, 35 | { 36 | "id": "durationField", 37 | "type": "dropdown", 38 | "name": "input.fields.plannedDuration", 39 | "persist": true, 40 | "valueFunction": "importer.configuration?.fieldOptions('plannedDuration')", 41 | "value": "jira_import_empty", 42 | "valueFunctionReloadable": true 43 | }, 44 | { 45 | "id": "startDateField", 46 | "type": "dropdown", 47 | "name": "input.fields.startDate", 48 | "persist": true, 49 | "valueFunction": "importer.configuration?.fieldOptions('startDate')", 50 | "value": "jira_import_empty", 51 | "valueFunctionReloadable": true 52 | }, 53 | { 54 | "id": "dueDateField", 55 | "type": "dropdown", 56 | "name": "input.fields.dueDate", 57 | "persist": true, 58 | "valueFunction": "importer.configuration?.fieldOptions('dueDate')", 59 | "value": "jira_import_empty", 60 | "valueFunctionReloadable": true 61 | }, 62 | { 63 | "id": "insertIssueNumberHint", 64 | "type": "hint", 65 | "name": "", 66 | "value": "input.fields.hint" 67 | }, 68 | { 69 | "id": "sep_2", 70 | "type": "separator", 71 | "name": "" 72 | }, 73 | { 74 | "id": "excludedProjects", 75 | "type": "text", 76 | "name": "input.excludedProjects", 77 | "persist": true 78 | }, 79 | { 80 | "id": "excludedProjectsHint", 81 | "type": "hint", 82 | "name": "", 83 | "value": "input.excludedProjects.hint" 84 | }, 85 | { 86 | "id": "sep_3", 87 | "type": "separator", 88 | "name": "" 89 | }, 90 | { 91 | "id": "insertIssueNumber", 92 | "type": "dropdown", 93 | "name": "input.insertIssueNumber", 94 | "persist": true, 95 | "values": [ 96 | { 97 | "no": "input.insertIssueNumber.no" 98 | }, 99 | { 100 | "prepend": "input.insertIssueNumber.prepend" 101 | }, 102 | { 103 | "append": "input.insertIssueNumber.append" 104 | } 105 | ] 106 | }, 107 | { 108 | "id": "insertIssueNumberHint", 109 | "type": "hint", 110 | "name": "", 111 | "value": "input.insertIssueNumber.hint" 112 | }, 113 | { 114 | "id": "sep_4", 115 | "type": "separator", 116 | "name": "" 117 | }, 118 | { 119 | "id": "updateTasks", 120 | "type": "checkbox", 121 | "persist": true, 122 | "name": "input.updateTasks" 123 | }, 124 | { 125 | "id": "updateTimeFrame", 126 | "type": "dropdown", 127 | "name": "input.updateTasksTimeFrame", 128 | "persist": true, 129 | "values": [ 130 | { 131 | "14": "input.timeFrame.14" 132 | }, 133 | { 134 | "30": "input.timeFrame.30" 135 | }, 136 | { 137 | "60": "input.timeFrame.60" 138 | } 139 | ] 140 | }, 141 | { 142 | "id": "updateTimeFrameHint", 143 | "type": "hint", 144 | "name": "", 145 | "value": "input.updateTasks.hint" 146 | } 147 | ] 148 | -------------------------------------------------------------------------------- /JiraImporter/jira_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/JiraImporter/jira_icon.png -------------------------------------------------------------------------------- /JiraImporter/localization.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "plugin.name": "Jira", 4 | "plugin.summary": "Import your currently assigned issues from Jira right into Tyme.", 5 | "input.url": "Jira URL", 6 | "input.url.placeholder": "Please enter your Jira URL", 7 | "input.user": "Username", 8 | "input.user.placeholder": "Please enter your Jira username", 9 | "input.key": "API Key", 10 | "input.key.placeholder": "Please enter your API key", 11 | "input.key.hint": "You can generate a API Token in your [Atlassian Account](https://id.atlassian.com/manage-profile/security/api-tokens).\n\nAll open issues assigned to you will be imported. The projects for the issues will also be created.", 12 | "input.insertIssueNumber": "Issue Key", 13 | "input.insertIssueNumber.no": "Don't show", 14 | "input.insertIssueNumber.prepend": "Before the title", 15 | "input.insertIssueNumber.append": "Behind the title", 16 | "input.insertIssueNumber.hint": "Inserts the issue key into the task title.", 17 | "input.fields.plannedDuration": "Planned Time", 18 | "input.fields.startDate": "Start", 19 | "input.fields.dueDate": "Deadline", 20 | "input.fields.hint": "Select Jira fields, that should be used for Planned Time, Start and Deadline.", 21 | "input.fields.skip": "Skip field", 22 | "input.excludedProjects": "Excluded Projects", 23 | "input.excludedProjects.hint": "A comma-separated list of project keys that should not be imported.", 24 | "input.updateTasks": "Update tasks based on time entries", 25 | "input.updateTasksTimeFrame": "Time frame", 26 | "input.updateTasks.hint": "Updates previously imported tasks based on the time entries from the selected time period, even if the issue have been closed in Jira or are no longer assigned to you.", 27 | "input.timeFrame.14": "14 Days", 28 | "input.timeFrame.30": "30 Days", 29 | "input.timeFrame.60": "60 Days", 30 | "error.failedToLoadUser": "Could not load user. Check your entered URL, API Key and username." 31 | }, 32 | "de": { 33 | "plugin.name": "Jira", 34 | "plugin.summary": "Importiere deine aktuell zugewiesenen Aufgaben aus Jira direkt in Tyme.", 35 | "input.url": "Jira URL", 36 | "input.url.placeholder": "Bitte gib deine Jira URL ein", 37 | "input.user": "Benutzername", 38 | "input.user.placeholder": "Bitte gib deinen Jira Benutzernamen ein", 39 | "input.key": "API-Token", 40 | "input.key.placeholder": "Bitte gib deinen API Schlüssel ein", 41 | "input.key.hint": "Du kannst einen API-Token in deinem [Atlassian Account](https://id.atlassian.com/manage-profile/security/api-tokens) erstellen.\n\nEs werden alle offenen und dir zugewiesenen Vorgänge importiert. Die Projekte für die Vorgänge werden ebenfalls erstellt.", 42 | "input.insertIssueNumber": "Schlüssel", 43 | "input.insertIssueNumber.no": "Nicht anzeigen", 44 | "input.insertIssueNumber.prepend": "Vor dem Titel", 45 | "input.insertIssueNumber.append": "Hinter dem Titel", 46 | "input.insertIssueNumber.hint": "Fügt den Vorgangsschlüssel in den Namen der Aufgabe ein.", 47 | "input.fields.plannedDuration": "Geplante Zeit", 48 | "input.fields.startDate": "Start", 49 | "input.fields.dueDate": "Abgabe", 50 | "input.fields.hint": "Wähle die Jira Felder, die für geplante Zeit, Start und Abgabe genutzt werden sollen.", 51 | "input.fields.skip": "Feld überspringen", 52 | "input.excludedProjects": "Ausgeschlossene\nProjekte", 53 | "input.excludedProjects.hint": "Eine Komma getrennte Liste von Projektschlüsseln, die nicht importiert werden sollen.", 54 | "input.updateTasks": "Aufgaben anhand der Zeiteinträge aktualisieren", 55 | "input.updateTasksTimeFrame": "Zeitraum", 56 | "input.updateTasks.hint": "Aktualisiert zuvor impotierte Vorgänge, anhand der Zeiteinträge aus dem gewählten Zeitraum, auch wenn die Vorgänge in Jira geschlossen wurden oder dir nicht mehr zugewiesen sind.", 57 | "input.timeFrame.14": "14 Tage", 58 | "input.timeFrame.30": "30 Tage", 59 | "input.timeFrame.60": "60 Tage", 60 | "error.failedToLoadUser": "Konnte Benutzer nicht laden. Bitte prüfe die eingegebene URL, API Key und Benutzername." 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /JiraImporter/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "jira_importer", 3 | "tymeMinVersion": "2024.11", 4 | "version": "1.2", 5 | "type": "import", 6 | "author": "Lars Malewski", 7 | "authorUrl": "https://github.com/tyme-app/plugins/tree/main/JiraImporter", 8 | "icon": "jira_icon.png", 9 | "scriptName": "script.js", 10 | "scriptMain": "importer.start()", 11 | "scriptPreview": "", 12 | "formName": "form.json", 13 | "localizationName": "localization.json", 14 | "internalType": "project_2", 15 | "internalLink": "" 16 | } 17 | -------------------------------------------------------------------------------- /JiraImporter/script.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tyme Project object. 3 | * @typedef {Object} Project 4 | * @property {string} id 5 | * @property {string} name 6 | * @property {boolean} isCompleted 7 | */ 8 | 9 | /** 10 | * Tyme TimedTask object. 11 | * @typedef {Object} TimedTask 12 | * @property {string} id 13 | * @property {string} name 14 | * @property {boolean} isCompleted 15 | * @property {number|null} [plannedDuration] 16 | * @property {Date|null} [startDate] 17 | * @property {Date|null} [dueDate] 18 | * @property {Project} project 19 | */ 20 | 21 | /** 22 | * Jira project. 23 | */ 24 | class JiraProject { 25 | /** 26 | * Create a Jira project. 27 | * @param {number} id The ID of the project. 28 | * @param {string} key Key of the project. 29 | * @param {string} name Name of the project. 30 | */ 31 | constructor(id, key, name) { 32 | this.id = id; 33 | this.key = key; 34 | this.name = name; 35 | } 36 | 37 | /** 38 | * Tyme project id of the project. 39 | * @returns {string} 40 | */ 41 | get projectId() { 42 | return 'jira-p' + this.id; 43 | } 44 | 45 | /** 46 | * Tyme project id of the project. 47 | * @deprecated This is the old format. Use `projectId` instead. 48 | * @returns {string} 49 | */ 50 | get oldProjectId() { 51 | return 'jira-' + this.id; 52 | } 53 | 54 | /** 55 | * Tyme project object for this Jira project. 56 | * @returns {Project} Tyme `Project` object. 57 | */ 58 | getProject() { 59 | const project = Project.fromID(this.projectId); 60 | if (project) { 61 | return project; 62 | } 63 | 64 | // Check for an old existing project and update (can be removed in the future) 65 | const oldProject = Project.fromID(this.oldProjectId); 66 | if (oldProject) { 67 | oldProject.id = this.projectId; 68 | return oldProject; 69 | } 70 | 71 | return Project.create(this.projectId); 72 | } 73 | 74 | /** 75 | * Create or update an existing Tyme project with the data from the Jira project. 76 | */ 77 | createOrUpdateTymeProject() { 78 | const tymeProject = this.getProject(); 79 | tymeProject.name = this.name; 80 | } 81 | } 82 | 83 | /** 84 | * Jira issue. 85 | */ 86 | class JiraIssue { 87 | /** 88 | * Create a Jira issue. 89 | * @param {number} id Jira issue id. 90 | * @param {string} key Jira issue key. 91 | * @param {string} summary Jira issue summary. 92 | * @param {boolean} isCompleted Indicates if task is completed. 93 | * @param {string} start ISO8601 formatted date string. 94 | * @param {string} due ISO8601 formatted date string. 95 | * @param {number} duration Duration in seconds. 96 | */ 97 | constructor(id, key, summary, isCompleted, start, due, duration) { 98 | this.id = id; 99 | this.key = key; 100 | this.summary = summary; 101 | this.isCompleted = isCompleted; 102 | this.start = start; 103 | this.due = due; 104 | this.duration = duration; 105 | } 106 | 107 | /** 108 | * Tyme issue id of the project. 109 | * @returns {string} 110 | */ 111 | get issueId() { 112 | return 'jira-' + this.id; 113 | } 114 | 115 | /** 116 | * Planned duration of issue 117 | * @returns {number|null} The planned duration in seconds. 118 | */ 119 | get plannedDuration() { 120 | const parsedDuration = parseInt(this.duration, 10); 121 | 122 | if (isNaN(parsedDuration) || parsedDuration === null || parsedDuration === undefined) { 123 | return null; 124 | } 125 | 126 | return parsedDuration; 127 | } 128 | 129 | /** 130 | * Start date of issue. 131 | * @returns {Date|null} 132 | */ 133 | get startDate() { 134 | return this.parseDate(this.start); 135 | } 136 | 137 | /** 138 | * Due date of issue. 139 | * @returns {Date|null} 140 | */ 141 | get dueDate() { 142 | return this.parseDate(this.due); 143 | } 144 | 145 | /** 146 | * Formatted task name based on import setting. 147 | * @returns {string} Formatted task name. 148 | */ 149 | get taskName() { 150 | switch (formValue.insertIssueNumber) { 151 | case 'prepend': 152 | return `[${this.key}] ${this.summary}`; 153 | case 'append': 154 | return `${this.summary} [${this.key}]`; 155 | default: 156 | return this.summary; 157 | } 158 | } 159 | 160 | /** 161 | * Create or update an existing Tyme task with the data from the Jira issue. 162 | * @param {Project} project Tyme project for this issue. 163 | */ 164 | createOrUpdateTymeTask(project) { 165 | /** @type {TimedTask} */ 166 | let tymeTask = TimedTask.fromID(this.issueId) ?? TimedTask.create(this.issueId); 167 | tymeTask.name = this.taskName; 168 | tymeTask.isCompleted = this.isCompleted; 169 | tymeTask.plannedDuration = this.plannedDuration; 170 | tymeTask.startDate = this.startDate; 171 | tymeTask.dueDate = this.dueDate; 172 | tymeTask.project = project; 173 | } 174 | 175 | /** 176 | * Parses a date string to a JavaScript Date object. 177 | * @param {string} dateString The date string to be parsed. 178 | * @returns {Date|null} The parsed date or `null`. 179 | */ 180 | parseDate(dateString) { 181 | if (dateString === null || dateString === undefined || dateString?.trim() === '') { 182 | return null; 183 | } 184 | 185 | const parsedDate = Date.parse(dateString); 186 | return isNaN(parsedDate) ? null : parsedDate; 187 | } 188 | } 189 | 190 | /** 191 | * Jira field configuration. 192 | */ 193 | class JiraFieldConfiguration { 194 | /** 195 | * Skipped field configuration. 196 | */ 197 | static skippedField = { value: 'jira_import_skip', name: utils.localize('input.fields.skip') }; 198 | 199 | /** 200 | * 201 | * @param {JiraApiClient} client 202 | * @returns 203 | */ 204 | constructor(client) { 205 | this.client = client; 206 | this.fields = undefined; 207 | } 208 | 209 | /** 210 | * Planned duration field. 211 | * @returns {string|undefined} Returns the field name or `undefined` if field should be skipped. 212 | */ 213 | get plannedDurationField() { 214 | return this.skipField(formValue.durationField) ? undefined : formValue.durationField; 215 | } 216 | 217 | /** 218 | * Start date field. 219 | * @returns {string|undefined} Returns the field name or `undefined` if field should be skipped. 220 | */ 221 | get startDateField() { 222 | return this.skipField(formValue.startDateField) ? undefined : formValue.startDateField; 223 | } 224 | 225 | /** 226 | * Due date field. 227 | * @returns {string|undefined} Returns the field name or `undefined` if field should be skipped. 228 | */ 229 | get dueDateField() { 230 | return this.skipField(formValue.dueDateField) ? undefined : formValue.dueDateField; 231 | } 232 | 233 | /** 234 | * Check if field should be skipped for the import. 235 | * @param {string} field The field value. 236 | * @returns {boolean} Returns `true` if the field should be skipped. 237 | */ 238 | skipField(field) { 239 | field === JiraFieldConfiguration.skippedField.value; 240 | } 241 | 242 | /** 243 | * Load Jira field configuration. 244 | */ 245 | loadFields() { 246 | this.fields = this.client.fetch('field')?.sort((a, b) => { 247 | return a.name.localeCompare(b.name); 248 | }); 249 | } 250 | 251 | /** 252 | * Get field options for the specified field. 253 | * @param {string} field The field for which the options should be loaded. Can be one of `plannedDuration`, `startDate` or `dueDate`. 254 | * @returns {array} Array of options for the given field. 255 | */ 256 | fieldOptions(fieldOption) { 257 | if (!this.fields) { 258 | this.loadFields(); 259 | } 260 | 261 | const filteredFields = this.fields.filter(field => { 262 | switch (fieldOption) { 263 | case 'plannedDuration': 264 | return field?.schema?.type === 'number'; 265 | case 'startDate': 266 | case 'dueDate': 267 | return field?.schema?.type === 'date' || field?.schema?.type === 'datetime'; 268 | default: 269 | return false; 270 | } 271 | }); 272 | 273 | // Prepend the "Skipped field" configuration 274 | return [JiraFieldConfiguration.skippedField].concat( 275 | filteredFields.map(field => { 276 | return { value: field.id, name: field.name }; 277 | }) 278 | ); 279 | } 280 | } 281 | 282 | /** 283 | * Jira API Client 284 | */ 285 | class JiraApiClient { 286 | /** 287 | * Import JQL query. 288 | */ 289 | get importJql() { 290 | let excludedProjects = ''; 291 | 292 | if (formValue.excludedProjects.trim() !== '') { 293 | // Build string with excluded projects 294 | const exclude = formValue.excludedProjects 295 | .split(',') 296 | .map(project => project.trim()) 297 | .filter(project => project !== '') 298 | .map(project => `"${project}"`) 299 | .join(','); 300 | 301 | if (exclude) { 302 | excludedProjects = ` AND project NOT IN (${exclude})`; 303 | } 304 | } 305 | 306 | return 'assignee = currentUser() AND statuscategory != done' + excludedProjects; 307 | } 308 | 309 | /** 310 | * Create a new Jira API Client. 311 | * @param {string} url Jira URL. 312 | * @param {string} apiKey Jira API Key. 313 | * @param {string} user Jira username. 314 | */ 315 | constructor(url, apiKey, user) { 316 | this.apiKey = apiKey; 317 | this.user = user; 318 | this.baseURL = url + '/rest/api/3/'; 319 | } 320 | 321 | /** 322 | * Fetch data from the Jira API. 323 | * @param {string} path Path to request data from. 324 | * @param {object} params Params to add to the request. 325 | * @returns {object} Parsed JSON response, or `null`. 326 | */ 327 | fetch(path, params = null) { 328 | const headers = { 329 | Authorization: 'Basic ' + utils.base64Encode(this.user + ':' + this.apiKey), 330 | }; 331 | const response = utils.request(this.baseURL + path, 'GET', headers, params); 332 | const statusCode = response['statusCode']; 333 | const result = response['result']; 334 | 335 | if (statusCode === 200) { 336 | return JSON.parse(result); 337 | } 338 | if (statusCode === 404) { 339 | return null; 340 | } 341 | 342 | tyme.showAlert('Jira API Error', JSON.stringify(response)); 343 | return null; 344 | } 345 | 346 | /** 347 | * Load issues from Jira. 348 | * @param {number[]} issueIds Array of issue ids to load. Leave empty to 349 | * load all open and assigned issues of the user. 350 | * @returns {object} Object with issues. 351 | */ 352 | loadIssues(issueIds) { 353 | const issues = {}; 354 | let startAt = 0; 355 | let finished = false; 356 | 357 | do { 358 | const params = { 359 | jql: issueIds ? this.updateJql(issueIds) : this.importJql, 360 | maxResults: 50, 361 | startAt: startAt, 362 | }; 363 | const response = this.fetch('search', params); 364 | 365 | if (!response || response.issues.length === 0) { 366 | finished = true; 367 | } 368 | 369 | response.issues.forEach(issue => { 370 | issues[issue.key] = issue; 371 | }); 372 | 373 | startAt += 50; 374 | } while (!finished); 375 | 376 | utils.log(`Loaded ${Object.keys(issues).length} issues`); 377 | 378 | return issues; 379 | } 380 | 381 | /** 382 | * Load an issue from Jira. 383 | * @param {string} issueKey The issue key. 384 | * @returns {object} Loaded issue. 385 | */ 386 | loadIssue(issueKey) { 387 | return this.fetch('issue/' + issueKey); 388 | } 389 | 390 | /** 391 | * Load user data. 392 | * @returns {boolean} Returns `true` if user data was loaded. 393 | */ 394 | loadUser() { 395 | const userResponse = this.fetch('myself'); 396 | 397 | if (!userResponse) { 398 | return false; 399 | } 400 | 401 | return true; 402 | } 403 | 404 | /** 405 | * Update JQL query. 406 | * @param {number[]} issueIds Array of issue ids. 407 | */ 408 | updateJql(issueIds) { 409 | return `id IN (${issueIds.join(',')})`; 410 | } 411 | } 412 | 413 | /** 414 | * Jira importer 415 | */ 416 | class JiraImporter { 417 | /** 418 | * Jira field configuration. 419 | * @type {JiraFieldConfiguration} 420 | */ 421 | configuration; 422 | 423 | /** 424 | * Jira API client. 425 | * @type {JiraApiClient} 426 | */ 427 | client; 428 | 429 | /** 430 | * @type {object} 431 | */ 432 | issues; 433 | 434 | /** 435 | * Create a Jira Importer. 436 | * @param {JiraApiClient} client Jira API Client. 437 | * @param {JiraFieldConfiguration} configuration Jira field configuration. 438 | */ 439 | constructor() { 440 | this.check(); 441 | } 442 | 443 | /** 444 | * Check that the required fields are filled. 445 | * @param {boolean} reload Reload fields when they are enabled. 446 | */ 447 | check(reload = false) { 448 | if (formValue.jiraURL.trim() !== '' && formValue.jiraKey.trim() !== '' && formValue.jiraUser.trim() !== '') { 449 | this.createClient(); 450 | this.enableFields(true, reload); 451 | } else { 452 | this.enableFields(false); 453 | } 454 | } 455 | 456 | /** 457 | * Create the Jira client and configuration object. 458 | */ 459 | createClient() { 460 | this.client = new JiraApiClient(formValue.jiraURL, formValue.jiraKey, formValue.jiraUser); 461 | this.configuration = new JiraFieldConfiguration(this.client); 462 | } 463 | 464 | /** 465 | * Enable or disable additional fields for import. 466 | * @param {boolean} enable Enable or disable fields. Defaults to `true`. 467 | * @param {boolean} reload Reloads the fields when enabling it. 468 | */ 469 | enableFields(enable = true, reload = false) { 470 | if (formElement.durationField) { 471 | formElement.durationField.enabled = enable; 472 | if (enable && reload) { 473 | formElement.durationField.reload(); 474 | } 475 | } 476 | if (formElement.startDateField) { 477 | formElement.startDateField.enabled = enable; 478 | if (enable && reload) { 479 | formElement.startDateField.reload(); 480 | } 481 | } 482 | if (formElement.dueDateField) { 483 | formElement.dueDateField.enabled = enable; 484 | if (enable && reload) { 485 | formElement.dueDateField.reload(); 486 | } 487 | } 488 | } 489 | 490 | /** 491 | * Main method to start the import. 492 | */ 493 | start() { 494 | // Check if we can load the user data 495 | if (!this.client.loadUser()) { 496 | tyme.showAlert(utils.localize('error.failedToLoadUser')); 497 | return; 498 | } 499 | 500 | // Load issues from Jira 501 | this.issues = this.client.loadIssues(); 502 | 503 | // Extract project data from issues 504 | this.projects = {}; 505 | for (let issueKey in this.issues) { 506 | const projectData = this.issues[issueKey].fields.project; 507 | 508 | if (!projectData) { 509 | continue; // Skip if project data is undefined 510 | } 511 | 512 | const project = new JiraProject(projectData.id, projectData.key, projectData.name); 513 | this.projects[projectData.id] = project; 514 | } 515 | 516 | // Create projects and process issues 517 | this.processProjects(); 518 | this.processIssues(); 519 | 520 | if (formValue.updateTasks === true) { 521 | const ids = this.recentlyProcessedIds(); 522 | 523 | if (ids.length === 0) { 524 | // return if there are no tasks to update 525 | return; 526 | } 527 | 528 | this.issues = this.client.loadIssues(ids); 529 | this.processIssues(); 530 | } 531 | } 532 | 533 | /** 534 | * Create and update projects for the given projects. 535 | */ 536 | processProjects() { 537 | for (let projectId in this.projects) { 538 | const project = this.projects[projectId]; 539 | 540 | if (!project) { 541 | continue; 542 | } 543 | 544 | project.createOrUpdateTymeProject(); 545 | } 546 | } 547 | 548 | /** 549 | * Create and update tasks for the given issues. 550 | */ 551 | processIssues() { 552 | for (let issueKey in this.issues) { 553 | const issue = this.issues[issueKey]; 554 | const project = this.projects[issue.fields.project.id]; 555 | 556 | if (!issue || !project) { 557 | continue; 558 | } 559 | 560 | const jiraIssue = new JiraIssue( 561 | issue.id, 562 | issue.key, 563 | issue.fields.summary, 564 | this.isClosed(issue?.fields?.status?.statusCategory?.key), 565 | this.configuration.startDateField ? issue?.fields?.[this.configuration.startDateField] : null, 566 | this.configuration.dueDateField ? issue?.fields?.[this.configuration.dueDateField] : null, 567 | this.configuration.plannedDurationField ? issue.fields?.[this.configuration.plannedDurationField] : null 568 | ); 569 | 570 | utils.log(JSON.stringify(jiraIssue)); 571 | 572 | jiraIssue.createOrUpdateTymeTask(Project.fromID(project.projectId)); 573 | } 574 | } 575 | 576 | /** 577 | * Check if status category is a closed status. 578 | * @param {string} statusCategoryKey Status category of the status. 579 | * @returns {boolean} `true` if status category is closed. 580 | */ 581 | isClosed(statusCategoryKey) { 582 | return statusCategoryKey === 'done'; 583 | } 584 | 585 | /** 586 | * Extracts the recently processed issue ids from time entries of the 587 | * selected time frame. 588 | * @returns {number[]} Array with issue IDs. 589 | */ 590 | recentlyProcessedIds() { 591 | let start = new Date(); 592 | start.setDate(start.getDate() - formValue.updateTimeFrame); 593 | const end = new Date(); 594 | 595 | // Load time entries for given time frame 596 | const timeEntries = tyme.timeEntries(start, end); 597 | const regex = new RegExp(/(?:jira-)\d+/); 598 | 599 | // Array of issue ids 600 | const issues = new Set(); 601 | 602 | for (const entry of timeEntries) { 603 | const match = regex.exec(entry.task_id); 604 | // Skip issues that doesn't match the given regex (i.e. manual created 605 | // tasks or imported by another importer) 606 | if (!match) { 607 | continue; 608 | } 609 | 610 | // Extract only the issue id 611 | issues.add(match[0].replace('jira-', '')); 612 | } 613 | 614 | return Array.from(issues); 615 | } 616 | } 617 | 618 | const importer = new JiraImporter(); 619 | -------------------------------------------------------------------------------- /LexofficeInvoices/README.md: -------------------------------------------------------------------------------- 1 | # lexoffice Invoices 2 | 3 | This plugin lets you export your recorded times to [Lexware Office](https://office.lexware.de). 4 | You can view a preview of the invoice before actually sending it to Lexoffice. 5 | 6 | The plugin uses the [Lexware Office API](https://developers.lexoffice.io/docs/) to send the data to lexoffice. -------------------------------------------------------------------------------- /LexofficeInvoices/form.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "lexofficeAuthButton", 4 | "type": "button", 5 | "name": "button.auth", 6 | "actionFunction": "lexOfficeAPIClient.startAuthFlow()" 7 | }, 8 | { 9 | "id": "lexofficeKeyHint", 10 | "type": "hint", 11 | "name": "", 12 | "value": "button.auth.hint" 13 | }, 14 | { 15 | "id": "separator1", 16 | "type": "separator", 17 | "name": "" 18 | }, 19 | { 20 | "id": "startDate", 21 | "type": "date", 22 | "name": "input.startdate" 23 | }, 24 | { 25 | "id": "endDate", 26 | "type": "date", 27 | "name": "input.enddate" 28 | }, 29 | { 30 | "id": "teamMemberID", 31 | "type": "teammembers", 32 | "name": "" 33 | }, 34 | { 35 | "id": "taskIDs", 36 | "type": "tasks", 37 | "name": "" 38 | }, 39 | { 40 | "id": "prefixProject", 41 | "type": "checkbox", 42 | "name": "input.prefixProject", 43 | "value": "false", 44 | "persist": true 45 | }, 46 | { 47 | "id": "separator2", 48 | "type": "separator", 49 | "name": "" 50 | }, 51 | { 52 | "id": "showNotes", 53 | "type": "checkbox", 54 | "name": "input.notes", 55 | "value": "true", 56 | "persist": true 57 | }, 58 | { 59 | "id": "showTimesInNotes", 60 | "type": "checkbox", 61 | "name": "input.note.times", 62 | "value": "false", 63 | "persist": true 64 | }, 65 | { 66 | "id": "clusterOption", 67 | "type": "dropdown", 68 | "name": "input.cluster", 69 | "values": [ 70 | { 71 | "0": "input.cluster.none" 72 | }, 73 | { 74 | "1": "input.cluster.dayTask" 75 | }, 76 | { 77 | "2": "input.cluster.dayTaskNoSub" 78 | } 79 | ], 80 | "persist": true 81 | }, 82 | { 83 | "id": "separator3", 84 | "type": "separator", 85 | "name": "" 86 | }, 87 | { 88 | "id": "roundingOption", 89 | "type": "dropdown", 90 | "name": "input.rounding", 91 | "values": [ 92 | { 93 | "4": "input.rounding.4" 94 | }, 95 | { 96 | "2": "input.rounding.2" 97 | }, 98 | { 99 | "0": "input.rounding.0" 100 | } 101 | ], 102 | "persist": true 103 | }, 104 | { 105 | "id": "taxRate", 106 | "type": "dropdown", 107 | "name": "input.taxrate", 108 | "values": [ 109 | { 110 | "19": "19%" 111 | }, 112 | { 113 | "7": "7%" 114 | }, 115 | { 116 | "0": "0%" 117 | } 118 | ], 119 | "persist": true 120 | }, 121 | { 122 | "id": "taxType", 123 | "type": "dropdown", 124 | "name": "input.taxtype", 125 | "values": [ 126 | { 127 | "gross": "taxtype.gross" 128 | }, 129 | { 130 | "net": "taxtype.net" 131 | }, 132 | { 133 | "vatfree": "taxtype.vatfree" 134 | }, 135 | { 136 | "intraCommunitySupply": "taxtype.intraCommunitySupply" 137 | }, 138 | { 139 | "constructionService13b": "taxtype.constructionService13b" 140 | }, 141 | { 142 | "externalService13b": "taxtype.externalService13b" 143 | }, 144 | { 145 | "thirdPartyCountryService": "taxtype.thirdPartyCountryService" 146 | }, 147 | { 148 | "thirdPartyCountryDelivery": "taxtype.thirdPartyCountryDelivery" 149 | } 150 | ], 151 | "persist": true 152 | }, 153 | { 154 | "id": "contactID", 155 | "type": "dropdown", 156 | "name": "input.contact", 157 | "valueFunction": "lexOfficeResolver.getContacts()", 158 | "valueFunctionReloadable": false, 159 | "persist": true 160 | }, 161 | { 162 | "id": "separator4", 163 | "type": "separator", 164 | "name": "" 165 | }, 166 | { 167 | "id": "onlyUnbilled", 168 | "type": "checkbox", 169 | "name": "input.unbilled", 170 | "value": "false", 171 | "persist": true 172 | }, 173 | { 174 | "id": "markAsBilled", 175 | "type": "checkbox", 176 | "name": "input.billed", 177 | "value": "false", 178 | "persist": true 179 | }, 180 | { 181 | "id": "includeNonBillable", 182 | "type": "checkbox", 183 | "name": "input.nonbillable", 184 | "value": "false", 185 | "persist": true 186 | } 187 | ] 188 | -------------------------------------------------------------------------------- /LexofficeInvoices/lexoffice_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/LexofficeInvoices/lexoffice_icon.png -------------------------------------------------------------------------------- /LexofficeInvoices/lexoffice_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/LexofficeInvoices/lexoffice_logo.png -------------------------------------------------------------------------------- /LexofficeInvoices/localization.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "plugin.name": "Lexware Office Invoices", 4 | "plugin.summary": "With the Lexware Office Plugin, you can create invoices from your recorded hours in the top accounting software for taxes and finances. The partnership with time tracking in Tyme enables seamless integration. This simplifies invoicing and optimizes your billing process.", 5 | "locale.identifier": "en-US", 6 | "api.invoice.error.title": "Could not create invoice", 7 | "not.connected.message": "Tyme is not connected to Lexware Office. Please click on 'Connect with Lexware Office'.", 8 | "button.auth": "Connect with Lexware Office", 9 | "button.auth.hint": "To create an invoice for Lexware Office, you need to give Tyme access to Lexware Office once. Access can be revoked at any time in your [Lexware Office account](https://app.lexoffice.de/addons/) (Settings > Extensions).", 10 | "input.startdate": "From", 11 | "input.enddate": "To", 12 | "input.contact": "Client", 13 | "input.rounding": "Quantity Rounding", 14 | "input.rounding.4": "4 Decimal Places", 15 | "input.rounding.2": "2 Decimal Places", 16 | "input.rounding.0": "No Decimal Places", 17 | "input.taxrate": "Tax Rate", 18 | "input.taxtype": "Tax Type", 19 | "input.billed": "Mark exported time entries as billed", 20 | "input.unbilled": "Export only unbilled time entries", 21 | "input.nonbillable": "Include non-billable tasks", 22 | "input.clients.empty": "Could not fetch clients. Please connect with Lexware Office first.", 23 | "input.prefixProject": "Prefix with project title", 24 | "input.cluster": "Summarize Times", 25 | "input.cluster.none": "Do not Summarize", 26 | "input.cluster.dayTask": "Summarize By Day and Task", 27 | "input.cluster.dayTaskNoSub": "Summarize By Day and Task (No Sub-tasks)", 28 | "input.notes": "Include notes in invoice", 29 | "input.note.times": "Display times in notes", 30 | "unit.hours": "Hours", 31 | "unit.kilometer": "km", 32 | "unit.quantity": "Pieces", 33 | "invoice.header": "Invoice items", 34 | "invoice.position": "Position", 35 | "invoice.price": "Price", 36 | "invoice.quantity": "Quantity", 37 | "invoice.unit": "Unit", 38 | "invoice.net": "Net", 39 | "taxtype.gross": "Gross", 40 | "taxtype.net": "Net", 41 | "taxtype.vatfree": "Vat Free", 42 | "taxtype.intraCommunitySupply": "Intra-community supply according to §13b UStG", 43 | "taxtype.constructionService13b": "Construction services according to §13b UStG", 44 | "taxtype.externalService13b": "Third-party services within the EU according to §13b UStG", 45 | "taxtype.thirdPartyCountryService": "Services provided to third countries", 46 | "taxtype.thirdPartyCountryDelivery": "Export deliveries to third countries" 47 | }, 48 | "de": { 49 | "plugin.name": "Lexware Office Rechnungen", 50 | "plugin.summary": "Mit dem Lexware Office Plugin erstellst du Rechnungen aus deinen erfassten Stunden in der Top Buchhaltungssoftware für Steuern & Finanzen. Die Partnerschaft mit Zeiterfassung in Tyme ermöglicht eine nahtlose Integration. Dies vereinfacht die Rechnungserstellung und optimiert deinen Abrechnungsprozess", 51 | "locale.identifier": "de-DE", 52 | "api.invoice.error.title": "Die Rechnung konnte nicht erstellt werden", 53 | "not.connected.message": "Tyme ist nicht mit Lexware Office verbunden. Klicke bitte auf 'Mit Lexware Office verbinden'.", 54 | "button.auth": "Mit Lexware Office verbinden", 55 | "button.auth.hint": "Um eine Rechnung für Lexware Office zu erstellen, musst du Tyme einmalig den Zugriff auf Lexware Office geben. Der Zugriff kann jederzeit in deinem [Lexware Office-Konto](https://app.lexoffice.de/addons/) (Einstellungen > Erweiterungen) widerrufen werden.", 56 | "input.startdate": "Von", 57 | "input.enddate": "Bis", 58 | "input.contact": "Kunde", 59 | "input.rounding": "Rundung der Anzahl", 60 | "input.rounding.4": "4 Nachkommastellen", 61 | "input.rounding.2": "2 Nachkommastellen", 62 | "input.rounding.0": "Keine Nachkommastellen", 63 | "input.taxrate": "Mehrwertsteuersatz", 64 | "input.taxtype": "Steuerart", 65 | "input.billed": "Exportierte Zeiten als berechnet markieren", 66 | "input.unbilled": "Nur nicht berechnete Zeiteinträge exportieren", 67 | "input.nonbillable": "Nicht berechenbare Aufgaben inkludieren", 68 | "input.clients.empty": "Es konnten keine Kunden abgerufen werden. Bitte verbinde dich zuerst mit Lexware Office.", 69 | "input.prefixProject": "Präfix Projekt-Titel", 70 | "input.cluster": "Zeiten Zusammenfassen", 71 | "input.cluster.none": "Nicht zusammenfassen", 72 | "input.cluster.dayTask": "Nach Tag und Aufgabe zusammenfassen", 73 | "input.cluster.dayTaskNoSub": "Nach Tag und Aufgabe zusammenfassen (Keine Unteraufgaben)", 74 | "input.notes": "Notizen in die Rechnung übernehmen", 75 | "input.note.times": "Zeiten in Notizen anzeigen", 76 | "unit.hours": "Stunden", 77 | "unit.kilometer": "km", 78 | "unit.quantity": "Stück", 79 | "invoice.header": "Rechnungspositionen", 80 | "invoice.position": "Position", 81 | "invoice.price": "Preis", 82 | "invoice.quantity": "Anzahl", 83 | "invoice.unit": "Einheit", 84 | "invoice.net": "Netto", 85 | "taxtype.gross": "Brutto", 86 | "taxtype.net": "Netto", 87 | "taxtype.vatfree": "Steuerfrei", 88 | "taxtype.intraCommunitySupply": "Innergemeinschaftliche Lieferung gem. §13b UStG", 89 | "taxtype.constructionService13b": "Bauleistungen gem. §13b UStG", 90 | "taxtype.externalService13b": "Fremdleistungen innerhalb der EU gem. §13b UStG", 91 | "taxtype.thirdPartyCountryService": "Dienstleistungen an Drittländer", 92 | "taxtype.thirdPartyCountryDelivery": "Ausfuhrlieferungen an Drittländer" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /LexofficeInvoices/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "lexoffice_invoices", 3 | "tymeMinVersion": "2024.21", 4 | "version": "3.3", 5 | "type": "export", 6 | "author": "Unit Numberfive oHG", 7 | "authorUrl": "https://www.tyme-app.com", 8 | "icon": "lexoffice_icon.png", 9 | "scriptName": "script.js", 10 | "scriptMain": "lexOfficeResolver.createInvoice()", 11 | "scriptPreview": "lexOfficeResolver.generatePreview()", 12 | "formName": "form.json", 13 | "localizationName": "localization.json", 14 | "internalType": "plugin_1", 15 | "internalLink": "/partner-lexware-office/" 16 | } 17 | -------------------------------------------------------------------------------- /LexofficeInvoices/script.js: -------------------------------------------------------------------------------- 1 | class TimeEntriesConverter { 2 | constructor() { 3 | 4 | } 5 | 6 | timeEntriesFromFormValues(useClusterOption) { 7 | return tyme.timeEntries( 8 | formValue.startDate, 9 | formValue.endDate, 10 | formValue.taskIDs, 11 | null, 12 | formValue.onlyUnbilled ? 0 : null, 13 | formValue.includeNonBillable ? null : true, 14 | formValue.teamMemberID, 15 | useClusterOption ? formValue.clusterOption : null 16 | ).filter(function (timeEntry) { 17 | return parseFloat(timeEntry.sum) > 0; 18 | }) 19 | } 20 | 21 | timeEntryIDs() { 22 | return this.timeEntriesFromFormValues(false) 23 | .map(function (entry) { 24 | return entry.id; 25 | }); 26 | } 27 | 28 | aggregatedTimeEntryData() { 29 | let data = 30 | this.timeEntriesFromFormValues(true) 31 | .reduce(function (data, timeEntry) { 32 | const key = timeEntry.task_id + timeEntry.subtask_id; 33 | 34 | if (data[key] == null) { 35 | let entry = { 36 | 'project': '', 37 | 'name': '', 38 | 'quantity': 0.0, 39 | 'unit': '', 40 | 'price': parseFloat(timeEntry.rate), 41 | 'note': '' 42 | }; 43 | 44 | if (timeEntry.type === 'timed') { 45 | entry.unit = utils.localize('unit.hours') 46 | } else if (timeEntry.type === 'mileage') { 47 | entry.unit = utils.localize('unit.kilometer') 48 | } else if (timeEntry.type === 'fixed') { 49 | entry.unit = utils.localize('unit.quantity') 50 | } 51 | 52 | entry.project = timeEntry.project; 53 | entry.name = timeEntry.task; 54 | 55 | if (timeEntry.subtask.length > 0) { 56 | entry.name += ': ' + timeEntry.subtask 57 | } 58 | 59 | data[key] = entry; 60 | } 61 | 62 | let currentQuantity = 0; 63 | 64 | if (timeEntry.type === 'timed') { 65 | currentQuantity = parseFloat(timeEntry.duration) / 60.0 66 | data[key].quantity += currentQuantity; 67 | } else if (timeEntry.type === 'mileage') { 68 | currentQuantity = parseFloat(timeEntry.distance) 69 | data[key].quantity += currentQuantity; 70 | } else if (timeEntry.type === 'fixed') { 71 | currentQuantity = parseFloat(timeEntry.quantity) 72 | data[key].quantity += currentQuantity; 73 | } 74 | 75 | if (formValue.showTimesInNotes && timeEntry.type !== "fixed") { 76 | 77 | if (data[key].note.length > 0) { 78 | data[key].note += '\n'; 79 | } 80 | 81 | if (timeEntry.hasOwnProperty("start") && timeEntry.hasOwnProperty("end")) { 82 | data[key].note += this.formatDate(timeEntry.start, false) + " "; 83 | data[key].note += this.formatDate(timeEntry.start, true) + " - "; 84 | data[key].note += this.formatDate(timeEntry.end, true); 85 | } else if (timeEntry.hasOwnProperty("date")) { 86 | data[key].note += this.formatDate(timeEntry.date, false); 87 | } 88 | 89 | data[key].note += " (" + this.roundNumber(currentQuantity, 2) + " " + data[key].unit + ")"; 90 | 91 | if (timeEntry.note.length > 0) { 92 | data[key].note += "\n"; 93 | data[key].note += timeEntry.note; 94 | } 95 | 96 | } else if (timeEntry.note.length > 0) { 97 | if (data[key].note.length > 0) { 98 | data[key].note += '\n'; 99 | } 100 | data[key].note += timeEntry.note; 101 | } 102 | 103 | return data; 104 | 105 | }.bind(this), {}); 106 | 107 | let sortedData = Object.keys(data) 108 | .map(function (key) { 109 | return data[key]; 110 | }) 111 | .sort(function (a, b) { 112 | return a.name > b.name; 113 | }); 114 | 115 | sortedData.forEach((entry) => { 116 | if (entry.note.length > 1800) { 117 | entry.note = entry.note.substring(0, 1799) + "…"; 118 | } 119 | }); 120 | 121 | return sortedData; 122 | } 123 | 124 | formatDate(dateString, timeOnly) { 125 | let locale = utils.localize('locale.identifier'); 126 | if (timeOnly) { 127 | return (new Date(dateString)).toLocaleTimeString(locale, {hour: '2-digit', minute: '2-digit'}); 128 | } else { 129 | return (new Date(dateString)).toLocaleDateString(locale); 130 | } 131 | } 132 | 133 | roundNumber(num, places) { 134 | return (+(Math.round(num + "e+" + places) + "e-" + places)).toFixed(places); 135 | } 136 | 137 | generatePreview(isAuthenticated) { 138 | const data = this.aggregatedTimeEntryData() 139 | 140 | let total = 0.0; 141 | let str = ''; 142 | 143 | str += '![](plugins/LexofficeInvoices/lexoffice_logo.png)\n'; 144 | 145 | if (!isAuthenticated) { 146 | str += "#### " + utils.localize('not.connected.message') + "\n"; 147 | } 148 | 149 | str += '## ' + utils.localize('invoice.header') + '\n'; 150 | 151 | str += '|' + utils.localize('invoice.position'); 152 | str += '|' + utils.localize('invoice.price'); 153 | str += '|' + utils.localize('invoice.quantity'); 154 | str += '|' + utils.localize('invoice.unit'); 155 | str += '|' + utils.localize('invoice.net'); 156 | str += '|\n'; 157 | 158 | str += '|-|-:|-:|-|-:|\n'; 159 | 160 | data.forEach((entry) => { 161 | let name = entry.name; 162 | 163 | if (formValue.showNotes) { 164 | name = '**' + entry.name + '**'; 165 | name += '
' + entry.note.replace(/\n/g, '
'); 166 | } 167 | 168 | if (formValue.prefixProject) { 169 | name = '**' + entry.project + ':** ' + name; 170 | } 171 | 172 | let price = this.roundNumber(entry.price, 2); 173 | let quantity = this.roundNumber(entry.quantity, formValue.roundingOption); 174 | let sum = this.roundNumber(parseFloat(price) * parseFloat(quantity), 2); 175 | 176 | total += parseFloat(sum); 177 | 178 | str += '|' + name; 179 | str += '|' + price + ' ' + tyme.currencySymbol(); 180 | str += '|' + quantity; 181 | str += '|' + entry.unit; 182 | str += '|' + this.roundNumber(sum, 2) + ' ' + tyme.currencySymbol(); 183 | str += '|\n'; 184 | }); 185 | 186 | str += '|||||**' + this.roundNumber(total, 2) + ' ' + tyme.currencySymbol() + '**|\n'; 187 | return utils.markdownToHTML(str); 188 | } 189 | } 190 | 191 | class LexOfficeResolver { 192 | constructor(lexOfficeAPIClient, timeEntriesConverter) { 193 | this.lexOfficeAPIClient = lexOfficeAPIClient; 194 | this.timeEntriesConverter = timeEntriesConverter; 195 | this.invoicePath = '/v1/invoices/'; 196 | this.contactPath = '/v1/contacts/'; 197 | } 198 | 199 | getContacts() { 200 | this.contacts = []; 201 | 202 | const totalPages = this.getContactPage(0); 203 | for (let i = 1; i <= totalPages; i++) { 204 | this.getContactPage(i); 205 | } 206 | 207 | if (this.contacts.length === 0) { 208 | this.contacts.push({ 209 | 'name': utils.localize('input.clients.empty'), 210 | 'value': '' 211 | }); 212 | } 213 | 214 | return this.contacts; 215 | } 216 | 217 | getContactPage(page) { 218 | const response = this.lexOfficeAPIClient.callResource( 219 | this.contactPath, 220 | 'GET', 221 | {'page': page, 'size': 25}, 222 | false 223 | ); 224 | 225 | if (response == null) { 226 | return 0; 227 | } 228 | 229 | const statusCode = response['statusCode']; 230 | const result = response['result']; 231 | 232 | if (statusCode === 200) { 233 | const parsedData = JSON.parse(result); 234 | const content = parsedData['content']; 235 | const totalPages = parsedData['totalPages']; 236 | 237 | content 238 | .filter(function (contact) { 239 | return contact.roles.hasOwnProperty('customer'); 240 | }) 241 | .forEach((contact, index) => { 242 | let contactObject = { 243 | 'value': contact.id 244 | } 245 | 246 | if (contact.hasOwnProperty('company') && contact.company.hasOwnProperty('name')) { 247 | contactObject.name = contact.company.name; 248 | } else if (contact.hasOwnProperty('person') && contact.person.hasOwnProperty('firstName') && contact.person.hasOwnProperty('lastName')) { 249 | contactObject.name = contact.person.firstName + ' ' + contact.person.lastName; 250 | } 251 | 252 | this.contacts.push(contactObject); 253 | }); 254 | 255 | return totalPages; 256 | } else { 257 | return 0 258 | } 259 | } 260 | 261 | generatePreview() { 262 | return this.timeEntriesConverter.generatePreview( 263 | this.lexOfficeAPIClient.isAuthenticated() 264 | ) 265 | } 266 | 267 | createInvoice() { 268 | const invoiceID = this.makeCreateInvoiceCall(); 269 | 270 | if (invoiceID !== null) { 271 | if (formValue.markAsBilled) { 272 | const timeEntryIDs = this.timeEntriesConverter.timeEntryIDs(); 273 | tyme.setBillingState(timeEntryIDs, 1); 274 | } 275 | 276 | this.lexOfficeAPIClient.editInvoice(invoiceID); 277 | } 278 | } 279 | 280 | makeCreateInvoiceCall() { 281 | const data = this.timeEntriesConverter.aggregatedTimeEntryData() 282 | let lineItems = []; 283 | 284 | const taxPercentage = 1.0 + parseFloat(formValue.taxRate) / 100.0; 285 | 286 | data.forEach((entry) => { 287 | const name = formValue.prefixProject ? entry.project + ": " + entry.name : entry.name; 288 | const note = formValue.showNotes ? entry.note : ''; 289 | 290 | const lineItem = { 291 | 'type': 'custom', 292 | 'name': name.length > 255 ? (name.substring(0, 254) + "…") : name, 293 | 'description': note, 294 | 'quantity': this.timeEntriesConverter.roundNumber(entry.quantity, formValue.roundingOption), 295 | 'unitName': entry.unit, 296 | 'unitPrice': { 297 | 'currency': tyme.currencyCode(), 298 | 'taxRatePercentage': formValue.taxRate 299 | } 300 | }; 301 | 302 | if (formValue.taxType === 'gross') { 303 | lineItem['unitPrice']['grossAmount'] = (entry.price * taxPercentage).toFixed(2); 304 | } else { 305 | lineItem['unitPrice']['netAmount'] = entry.price.toFixed(2); 306 | } 307 | 308 | lineItems.push(lineItem); 309 | }); 310 | 311 | const params = { 312 | 'voucherDate': new Date().toISOString(), 313 | 'address': { 314 | 'contactId': formValue.contactID 315 | }, 316 | 'lineItems': lineItems, 317 | 'totalPrice': { 318 | 'currency': tyme.currencyCode() 319 | }, 320 | 'taxConditions': { 321 | 'taxType': formValue.taxType 322 | }, 323 | 'shippingConditions': { 324 | 'shippingType': 'serviceperiod', 325 | 'shippingDate': formValue.startDate.toISOString(), 326 | 'shippingEndDate': formValue.endDate.toISOString() 327 | } 328 | } 329 | 330 | const response = this.lexOfficeAPIClient.callResource( 331 | this.invoicePath, 332 | 'POST', 333 | params, 334 | true 335 | ); 336 | 337 | if (response == null) { 338 | return null; 339 | } 340 | 341 | const statusCode = response['statusCode']; 342 | const result = response['result']; 343 | const parsedData = JSON.parse(result); 344 | 345 | if (statusCode === 201) { 346 | return parsedData['id']; 347 | } else { 348 | if (parsedData['message'] != null) { 349 | 350 | let errorMessage = parsedData['message']; 351 | 352 | if (parsedData['details'] != null) { 353 | parsedData['details'].forEach((detailedError) => { 354 | if (detailedError["field"] != null && detailedError["message"] != null) { 355 | errorMessage += "\n\n"; 356 | 357 | const field = detailedError["field"] 358 | .replace(/lineitems/i, "position") 359 | .replace(/([0-9])/i, function (match, p1, offset, string) { 360 | return '' + (parseInt(p1) + 1); 361 | }); 362 | errorMessage += field + ": " + detailedError["message"]; 363 | } 364 | }); 365 | } 366 | 367 | tyme.showAlert(utils.localize('api.invoice.error.title'), errorMessage); 368 | } else { 369 | tyme.showAlert(utils.localize('api.invoice.error.title'), JSON.stringify(response)); 370 | } 371 | return null; 372 | } 373 | } 374 | } 375 | 376 | class LexOfficeAPIClient { 377 | constructor() { 378 | this.baseURL = 'https://api.tyme-app.com/lex/'; 379 | this.lexTokenKey = 'lexoffice_token'; 380 | this.authCodeKey = 'lexoffice_auth_code'; 381 | } 382 | 383 | editInvoice(invoiceID) { 384 | tyme.openURL(this.baseURL + 'invoice/edit/' + invoiceID); 385 | } 386 | 387 | startAuthFlow() { 388 | tyme.openURL(this.baseURL + 'auth/new'); 389 | } 390 | 391 | hasAuthCode() { 392 | return tyme.getSecureValue(this.authCodeKey) != null; 393 | } 394 | 395 | isAuthenticated() { 396 | return tyme.getSecureValue(this.lexTokenKey) != null 397 | } 398 | 399 | fetchTokenFromCode() { 400 | const url = this.baseURL + 'auth/code'; 401 | const code = tyme.getSecureValue(this.authCodeKey); 402 | const response = utils.request(url, 'POST', {}, {'code': code}); 403 | const statusCode = response['statusCode']; 404 | const result = response['result']; 405 | 406 | tyme.setSecureValue(this.authCodeKey, null); 407 | 408 | if (statusCode === 200) { 409 | const json = JSON.parse(result); 410 | tyme.setSecureValue(this.lexTokenKey, json['lex_token']); 411 | return true; 412 | } else { 413 | utils.log('lexoffice Auth Error ' + JSON.stringify(response)); 414 | tyme.setSecureValue(this.lexTokenKey, null); 415 | return false; 416 | } 417 | } 418 | 419 | callResource(path, method, params, doAuth) { 420 | if (!this.isAuthenticated()) { 421 | if (this.hasAuthCode()) { 422 | this.fetchTokenFromCode(); 423 | } else if (doAuth) { 424 | this.startAuthFlow(); 425 | return null; 426 | } 427 | } 428 | 429 | const url = this.baseURL + 'resource'; 430 | const lexofficeToken = tyme.getSecureValue(this.lexTokenKey); 431 | 432 | const combinedParams = { 433 | 'path': path, 434 | 'method': method, 435 | 'params': params, 436 | 'lex_token': lexofficeToken 437 | } 438 | 439 | const response = utils.request(url, 'POST', {}, combinedParams); 440 | 441 | if (response['statusCode'] === 401 && this.isAuthenticated()) { 442 | tyme.setSecureValue(this.lexTokenKey, null); 443 | } 444 | 445 | return response; 446 | } 447 | } 448 | 449 | const timeEntriesConverter = new TimeEntriesConverter(); 450 | const lexOfficeAPIClient = new LexOfficeAPIClient(); 451 | const lexOfficeResolver = new LexOfficeResolver(lexOfficeAPIClient, timeEntriesConverter); 452 | -------------------------------------------------------------------------------- /MiteImporter/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/MiteImporter/icon.png -------------------------------------------------------------------------------- /MiteImporter/localization.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "plugin.name": "mite", 4 | "plugin.summary": "Import your data from mite right into Tyme." 5 | }, 6 | "de": { 7 | "plugin.name": "mite", 8 | "plugin.summary": "Importiere deine Daten aus mite direkt in Tyme." 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /MiteImporter/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mite_importer", 3 | "tymeMinVersion": "2024.11", 4 | "version": "1.1", 5 | "type": "import", 6 | "author": "Unit Numberfive oHG", 7 | "authorUrl": "https://www.tyme-app.com/en/plugin-built-in/?hide_nav", 8 | "icon": "icon.png", 9 | "scriptName": "", 10 | "scriptMain": "", 11 | "scriptPreview": "", 12 | "formName": "", 13 | "localizationName": "localization.json", 14 | "isBuiltIn": true, 15 | "internalType": "time_3", 16 | "internalLink": "" 17 | } 18 | -------------------------------------------------------------------------------- /OpenProjectImporter/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /OpenProjectImporter/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "trailingComma": "es5", 5 | "semi": true, 6 | "singleQuote": true, 7 | "bracketSpacing": true, 8 | "printWidth": 120, 9 | "arrowParens": "avoid" 10 | } -------------------------------------------------------------------------------- /OpenProjectImporter/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.5 2 | 3 | - Introduces unique IDs for category and project. 4 | 5 | # 1.2 6 | 7 | - Deeply nested projects are now imported correctly. The topmost project is always used as the category. 8 | - Internal structure of the import plugin optimized. 9 | 10 | # 1.1 11 | 12 | - Option added to display the work package number before or after the task title. 13 | 14 | # 1.0 15 | 16 | - Initial release of the OpenProject Import Plugin. 17 | -------------------------------------------------------------------------------- /OpenProjectImporter/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Lars Malewski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /OpenProjectImporter/README.md: -------------------------------------------------------------------------------- 1 | # OpenProject Importer 2 | 3 | This plugin imports your open and assigned work packages from [OpenProject](https://www.openproject.org). It can also update tasks based on time entries in a given time period, which are not assigned to you or currently open. 4 | 5 | The plugin uses the [OpenProject API](https://www.openproject.org/docs/api/) to fetch the data. The OpenProject URL can be specified if a self-hosted version is used. 6 | 7 | ## Functionality 8 | 9 | ### Categories and Projects 10 | 11 | For each project in OpenProject, a category as well a project is created. If you have child projects in OpenProject, the projects are assigned to the same category. 12 | 13 | ### Tasks (Work Packages) 14 | 15 | A task is created for each work package in OpenProject. 16 | 17 | The following data is set or updated: 18 | | Tyme | OpenProject | 19 | | --- | --- | 20 | | Name | Work package subject | 21 | | Completed | Based on "closed" status of work package status | 22 | | Start date | Start date of work package | 23 | | End date | Due date of work package | 24 | | Planned Duration | Estimated time of work package | 25 | 26 | ### Updating Tasks (Work Packages) from Time Entries 27 | 28 | If the option to update tasks is enabled, the time entries in Tyme for the selected range will be selected. The associated tasks are then retrieved and updated individually, even if they have been closed in OpenProject or are no longer assigned to you. 29 | 30 | > [!NOTE] 31 | > If you have a high number of different tasks, the import will take much longer. This is because each work package must be retrieved individually to update the tasks. 32 | 33 | ## License 34 | 35 | Distributed under the MIT License. See the LICENSE file for more info. 36 | 37 | > [!IMPORTANT] 38 | > This License only applies to the OpenProjectImporter Plugin. 39 | -------------------------------------------------------------------------------- /OpenProjectImporter/form.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "openProjectURL", 4 | "type": "text", 5 | "name": "input.url", 6 | "placeholder": "input.url.placeholder", 7 | "persist": true, 8 | "value": "https://community.openproject.org" 9 | }, 10 | { 11 | "id": "openProjectKey", 12 | "type": "securetext", 13 | "name": "input.key", 14 | "placeholder": "input.key.placeholder", 15 | "persist": true 16 | }, 17 | { 18 | "id": "openProjectKeyHint", 19 | "type": "hint", 20 | "name": "", 21 | "value": "input.key.hint" 22 | }, 23 | { 24 | "id": "sep_1", 25 | "type": "separator", 26 | "name": "" 27 | }, 28 | { 29 | "id": "insertWorkPackageNumber", 30 | "type": "dropdown", 31 | "name": "input.insertWorkPackageNumber", 32 | "persist": true, 33 | "values": [ 34 | { 35 | "no": "input.insertWorkPackageNumber.no" 36 | }, 37 | { 38 | "prepend": "input.insertWorkPackageNumber.prepend" 39 | }, 40 | { 41 | "append": "input.insertWorkPackageNumber.append" 42 | } 43 | ] 44 | }, 45 | { 46 | "id": "insertWorkPackageNumberHint", 47 | "type": "hint", 48 | "name": "", 49 | "value": "input.insertWorkPackageNumber.hint" 50 | }, 51 | { 52 | "id": "sep_3", 53 | "type": "separator", 54 | "name": "" 55 | }, 56 | { 57 | "id": "updateTasks", 58 | "type": "checkbox", 59 | "persist": true, 60 | "name": "input.updateTasks" 61 | }, 62 | { 63 | "id": "updateTimeFrame", 64 | "type": "dropdown", 65 | "name": "input.updateTasksTimeFrame", 66 | "persist": true, 67 | "values": [ 68 | { 69 | "14": "input.timeFrame.14" 70 | }, 71 | { 72 | "30": "input.timeFrame.30" 73 | }, 74 | { 75 | "60": "input.timeFrame.60" 76 | } 77 | ] 78 | }, 79 | { 80 | "id": "updateTimeFrameHint", 81 | "type": "hint", 82 | "name": "", 83 | "value": "input.updateTasks.hint" 84 | } 85 | ] -------------------------------------------------------------------------------- /OpenProjectImporter/localization.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "plugin.name": "OpenProject", 4 | "plugin.summary": "Import your currently assigned work packages from OpenProject right into Tyme.", 5 | "input.url": "OpenProject URL", 6 | "input.url.placeholder": "Please enter yout OpenProject URL", 7 | "input.key": "OpenProject API Key", 8 | "input.key.placeholder": "Please enter your API key", 9 | "input.key.hint": "Generate the access token under My Account → Access Token.\n\nAll open work packages assigned to you will be imported, the projects for the work packages will also be created.", 10 | "input.insertWorkPackageNumber": "Work package ID", 11 | "input.insertWorkPackageNumber.no": "Don't show", 12 | "input.insertWorkPackageNumber.prepend": "Before the title", 13 | "input.insertWorkPackageNumber.append": "Behind the title", 14 | "input.insertWorkPackageNumber.hint": "Inserts the work package number into the task title.", 15 | "input.updateTasks": "Update tasks based on time entries", 16 | "input.updateTasksTimeFrame": "Time frame", 17 | "input.updateTasks.hint": "Updates tasks based on the time entries from the selected period, even if they have been closed in OpenProject or are no longer assigned to you.", 18 | "input.timeFrame.14": "14 Days", 19 | "input.timeFrame.30": "30 Days", 20 | "input.timeFrame.60": "60 Days", 21 | "error.couldNotLoadUser": "Could not load user. Check your entered API Key and URL." 22 | }, 23 | "de": { 24 | "plugin.name": "OpenProject", 25 | "plugin.summary": "Importiere deine aktuell zugewiesenen Arbeitspakete aus OpenProject direkt in Tyme.", 26 | "input.url": "OpenProject URL", 27 | "input.url.placeholder": "Bitte gib deine OpenProject URL ein", 28 | "input.key": "OpenProject API Key", 29 | "input.key.placeholder": "Bitte gib deinen API Schlüssel ein", 30 | "input.key.hint": "Erstelle einen Zugriffstoken unter Mein Konto → Zugriffstokens.\n\nEs werden alle offenen und dir zugewiesenen Arbeitspakete importiert, die Projekte für die Arbeitspakete werden ebenfalls erstellt.", 31 | "input.insertWorkPackageNumber": "Arbeitspaketnummer", 32 | "input.insertWorkPackageNumber.no": "Nicht anzeigen", 33 | "input.insertWorkPackageNumber.prepend": "Vor dem Titel", 34 | "input.insertWorkPackageNumber.append": "Hinter dem Titel", 35 | "input.insertWorkPackageNumber.hint": "Fügt die Arbeitspaketnummer in den Titel der Aufgabe ein.", 36 | "input.updateTasks": "Aufgaben anhand der Zeiteinträge aktualisieren", 37 | "input.updateTasksTimeFrame": "Zeitraum", 38 | "input.updateTasks.hint": "Aktualisiert Aufgaben, anhand der Zeiteinträge aus dem gewählten Zeitraum, auch wenn die Arbeitspakete in OpenProject geschlossen wurden oder dir nicht mehr zugewiesen sind.", 39 | "input.timeFrame.14": "14 Tage", 40 | "input.timeFrame.30": "30 Tage", 41 | "input.timeFrame.60": "60 Tage", 42 | "error.couldNotLoadUser": "Konnte Benutzerdaten nicht laden. Prüfe die URL und den API Key." 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /OpenProjectImporter/openproject_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/OpenProjectImporter/openproject_icon.png -------------------------------------------------------------------------------- /OpenProjectImporter/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "openproject_importer", 3 | "tymeMinVersion": "2024.11", 4 | "version": "1.5", 5 | "type": "import", 6 | "author": "Lars Malewski", 7 | "authorUrl": "https://github.com/tyme-app/plugins/tree/main/OpenProjectImporter", 8 | "icon": "openproject_icon.png", 9 | "scriptName": "script.js", 10 | "scriptMain": "importer.start()", 11 | "scriptPreview": "", 12 | "formName": "form.json", 13 | "localizationName": "localization.json", 14 | "internalType": "project_1", 15 | "internalLink": "" 16 | } 17 | -------------------------------------------------------------------------------- /OpenProjectImporter/script.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tyme Project object. 3 | * @typedef {Object} Category 4 | * @property {string} id 5 | * @property {string} name 6 | * @property {boolean} isCompleted 7 | */ 8 | 9 | /** 10 | * Tyme Project object. 11 | * @typedef {Object} Project 12 | * @property {string} id 13 | * @property {string} name 14 | * @property {boolean} isCompleted 15 | */ 16 | 17 | /** 18 | * Tyme TimedTask object. 19 | * @typedef {Object} TimedTask 20 | * @property {string} id 21 | * @property {string} name 22 | * @property {boolean} isCompleted 23 | * @property {number|null} [plannedDuration] 24 | * @property {Date|null} [startDate] 25 | * @property {Date|null} [dueDate] 26 | * @property {Project} project 27 | */ 28 | 29 | /** 30 | * OpenProject category. 31 | */ 32 | class OpenProjectCategory { 33 | /** 34 | * Create an OpenProject category. 35 | * @param {number} id Category ID. 36 | * @param {string} name Name of the category. 37 | */ 38 | constructor(id, name) { 39 | this.id = id; 40 | this.name = name; 41 | } 42 | 43 | /** 44 | * Tyme category ID of this category. 45 | * @returns {string} 46 | */ 47 | get tymeCategoryId() { 48 | return 'openproject-c' + this.id; 49 | } 50 | 51 | /** 52 | * Tyme category ID of this category. 53 | * @deprecated This is the old format. Use the `tymeCategoryId` instead. 54 | * @returns {string} 55 | */ 56 | get oldTymeCategoryId() { 57 | return 'openproject-' + this.id; 58 | } 59 | 60 | /** 61 | * Tyme category object for this category. 62 | * @returns {Category} Tyme `Category` object. 63 | */ 64 | getCategory() { 65 | const category = Category.fromID(this.tymeCategoryId); 66 | if (category) { 67 | return category; 68 | } 69 | 70 | // Check for old category and update (can be removed in the future) 71 | const oldCategory = Category.fromID(this.oldTymeCategoryId); 72 | if (oldCategory) { 73 | oldCategory.id = this.tymeCategoryId; 74 | return oldCategory; 75 | } 76 | 77 | return Category.create(this.tymeCategoryId); 78 | } 79 | 80 | /** 81 | * Create or update an existing category in Tyme. 82 | */ 83 | createOrUpdateCategory() { 84 | let tymeCategory = this.getCategory(); 85 | tymeCategory.name = this.name; 86 | } 87 | } 88 | 89 | /** 90 | * OpenProject project. 91 | */ 92 | class OpenProjectProject { 93 | /** 94 | * Create an OpenProject project. 95 | * @param {string} name Name of the project. 96 | * @param {boolean} isCompleted Indicates if this project is completed. 97 | * @param {string} projectUrl URL of the project. 98 | * @param {string} parentUrl URL of the parent project. 99 | * @param {OpenProjectCategory?} category Category of this project. 100 | */ 101 | constructor(name, isCompleted, projectUrl, parentUrl, category) { 102 | this.name = name; 103 | this.isCompleted = isCompleted; 104 | this.projectUrl = projectUrl; 105 | this.parentUrl = parentUrl; 106 | this.category = category; 107 | } 108 | 109 | /** 110 | * The project id of the project. 111 | * @returns {number} 112 | */ 113 | get projectId() { 114 | return extractProjectIdFromUrl(this.projectUrl); 115 | } 116 | 117 | /** 118 | * The project id of the parent project. 119 | * @returns {number?} 120 | */ 121 | get parentProjectId() { 122 | return extractProjectIdFromUrl(this.parentUrl); 123 | } 124 | 125 | /** 126 | * Tyme project ID of this project. 127 | * @returns {string} 128 | */ 129 | get tymeProjectId() { 130 | return 'openproject-p' + this.projectId; 131 | } 132 | 133 | /** 134 | * Tyme project ID of this project. 135 | * @deprecated This is the old format. Use the `tymeProjectId` instead. 136 | * @returns {string} 137 | */ 138 | get oldTymeProjectId() { 139 | return 'openproject-' + this.projectId; 140 | } 141 | 142 | /** 143 | * Tyme project object for this project. 144 | * @returns {Project} Tyme `Project` object. 145 | */ 146 | getProject() { 147 | const project = Project.fromID(this.tymeProjectId); 148 | if (project) { 149 | return project; 150 | } 151 | 152 | // Check for old project and update (can be removed in the future) 153 | const oldProject = Project.fromID(this.oldTymeProjectId); 154 | if (oldProject) { 155 | oldProject.id = this.tymeProjectId; 156 | return oldProject; 157 | } 158 | 159 | return Project.create(this.tymeProjectId); 160 | } 161 | 162 | /** 163 | * Create or update an existing project in Tyme. 164 | */ 165 | createOrUpdateProject() { 166 | let tymeProject = this.getProject(); 167 | tymeProject.name = this.name; 168 | tymeProject.isCompleted = this.isCompleted; 169 | tymeProject.category = this.category ? Category.fromID(this.category.tymeCategoryId) : null; 170 | } 171 | } 172 | 173 | /** 174 | * OpenProject work package. 175 | */ 176 | class OpenProjectWorkPackage { 177 | /** 178 | * Create an OpenProject work package. 179 | * @param {number} id Work package ID. 180 | * @param {string} name Name of the work package. 181 | * @param {string} start Start date of the work package. 182 | * @param {string} due Due date of the work package. 183 | * @param {string} estimatedTime Estimated time of the work package. 184 | * @param {boolean} isCompleted Indicates if this work package is completed. 185 | * @param {OpenProjectProject} project Project of this work package. 186 | */ 187 | constructor(id, name, start, due, estimatedTime, isCompleted, project) { 188 | this.id = id; 189 | this.name = name; 190 | this.start = start; 191 | this.due = due; 192 | this.estimatedTime = estimatedTime; 193 | this.isCompleted = isCompleted; 194 | this.project = project; 195 | } 196 | 197 | /** 198 | * Tyme task ID of this work package. 199 | * @returns {string} 200 | */ 201 | get workPackageId() { 202 | return 'openproject-' + this.id; 203 | } 204 | 205 | /** 206 | * Planned duration of the work package. 207 | * @returns {number?} 208 | */ 209 | get plannedDuration() { 210 | if (!this.estimatedTime) { 211 | return; 212 | } 213 | 214 | return extractEstimate(this.estimatedTime); 215 | } 216 | 217 | /** 218 | * Parsed start date of the work package. 219 | * @returns {Date?} 220 | */ 221 | get startDate() { 222 | return this.parseDate(this.start); 223 | } 224 | 225 | /** 226 | * Parsed due date of the work package. 227 | * @returns {Date?} 228 | */ 229 | get dueDate() { 230 | return this.parseDate(this.due); 231 | } 232 | 233 | /** 234 | * Create or update an existing task in Tyme with the data from the work package. 235 | */ 236 | createOrUpdateTask() { 237 | /** @type {TimedTask} */ 238 | let tymeTask = TimedTask.fromID(this.workPackageId) ?? TimedTask.create(this.workPackageId); 239 | tymeTask.name = this.createTaskName(); 240 | tymeTask.project = Project.fromID(this.project.tymeProjectId); 241 | tymeTask.isCompleted = this.isCompleted; 242 | tymeTask.startDate = this.startDate; 243 | tymeTask.dueDate = this.dueDate; 244 | tymeTask.plannedDuration = this.plannedDuration; 245 | } 246 | 247 | /** 248 | * Creates the task name from a work package name and id. 249 | * @returns Task name based on import setting. 250 | */ 251 | createTaskName() { 252 | switch (formValue.insertWorkPackageNumber) { 253 | case 'prepend': 254 | return `[${this.id}] ${this.name}`; 255 | case 'append': 256 | return `${this.name} [${this.id}]`; 257 | default: 258 | return this.name; 259 | } 260 | } 261 | 262 | /** 263 | * Parses a date string to a JavaScript Date object. 264 | * @param {string} dateString The date string to be parsed. 265 | * @returns {Date|null} The parsed date or `null`. 266 | */ 267 | parseDate(dateString) { 268 | if (dateString === null || dateString === undefined || dateString?.trim() === '') { 269 | return null; 270 | } 271 | 272 | const parsedDate = Date.parse(dateString); 273 | return isNaN(parsedDate) ? null : parsedDate; 274 | } 275 | } 276 | 277 | /** 278 | * OpenProject API client. 279 | */ 280 | class OpenProjectApiClient { 281 | /** 282 | * Work package statuses. 283 | */ 284 | statuses; 285 | 286 | /** 287 | * OpenProject projects. 288 | */ 289 | projects = {}; 290 | 291 | /** 292 | * OpenProject categories. 293 | */ 294 | categories = {}; 295 | 296 | /** 297 | * Create a new OpenProjectApiClient. 298 | * @param {string} url OpenProject URL. 299 | * @param {string} apiKey OpenProject API key. 300 | */ 301 | constructor(url, apiKey) { 302 | if (OpenProjectApiClient._instance) { 303 | return OpenProjectApiClient._instance; 304 | } 305 | OpenProjectApiClient._instance = this; 306 | 307 | this.apiKey = apiKey; 308 | this.baseURL = url + '/api/v3/'; 309 | } 310 | 311 | getJSON(path, params = null) { 312 | utils.log(this.baseURL + path); 313 | const headers = { 314 | Authorization: 'Basic ' + utils.base64Encode('apikey:' + this.apiKey), 315 | }; 316 | const response = utils.request(this.baseURL + path, 'GET', headers, params); 317 | const statusCode = response['statusCode']; 318 | const result = response['result']; 319 | 320 | if (statusCode === 200) { 321 | return JSON.parse(result); 322 | } 323 | if (statusCode === 404) { 324 | return undefined; 325 | } 326 | 327 | tyme.showAlert('OpenProject API Error', JSON.stringify(response)); 328 | return null; 329 | } 330 | 331 | /** 332 | * Load user. 333 | * @returns {boolean} Returns `true` if user could be loaded. 334 | */ 335 | loadUser() { 336 | if (!this.getJSON('users/me')) { 337 | return false; 338 | } 339 | 340 | return true; 341 | } 342 | 343 | /** 344 | * Load statuses of work packages. 345 | * @returns {void} 346 | */ 347 | loadStatuses() { 348 | const response = this.getJSON('statuses'); 349 | 350 | utils.log(`Loaded ${response.count} statuses`); 351 | 352 | this.statuses = response._embedded.elements; 353 | } 354 | 355 | /** 356 | * Check if the given status (href) from OpenProject is a closed status. 357 | * @param {string} statusHref Href of the status (i.e. "/api/v3/statuses/1"). 358 | * @returns {boolean} Returns `true` if the given status is closed. 359 | */ 360 | isClosed(statusHref) { 361 | if (!statusHref) { 362 | return false; 363 | } 364 | 365 | if (!this.statuses) { 366 | this.loadStatuses(); 367 | } 368 | 369 | return this.statuses.find(status => status._links.self.href === statusHref)?.isClosed ?? false; 370 | } 371 | 372 | /** 373 | * Load work packages from OpenProject. 374 | * @returns Object with work package IDs as keys and work packages. 375 | */ 376 | loadWorkPackages() { 377 | const assignedToMe = '[{"status":{"operator":"o","values":[]}},{"assignee":{"operator":"=","values":["me"]}}]'; 378 | let workPackages = {}; 379 | let page = 1; 380 | let finished = false; 381 | 382 | do { 383 | const params = { 384 | offset: page, 385 | pageSize: 100, 386 | filters: assignedToMe, 387 | }; 388 | const response = this.getJSON('work_packages', params); 389 | 390 | if (!response || response.count === 0) { 391 | finished = true; 392 | } 393 | 394 | response._embedded.elements.forEach(workPackage => { 395 | const project = this.loadProject(extractProjectIdFromUrl(workPackage?._links?.project?.href)); 396 | workPackages[workPackage.id] = new OpenProjectWorkPackage( 397 | workPackage.id, 398 | workPackage.subject, 399 | workPackage.startDate, 400 | workPackage.dueDate, 401 | workPackage.estimatedTime, 402 | this.isClosed(workPackage?._links?.status?.href), 403 | project 404 | ); 405 | }); 406 | 407 | page++; 408 | } while (!finished); 409 | 410 | utils.log(`Loaded ${Object.keys(workPackages).length} work packages`); 411 | 412 | return workPackages; 413 | } 414 | 415 | /** 416 | * Load a work package from OpenProject. 417 | * @param {number} workPackageId The work package ID. 418 | * @returns {OpenProjectWorkPackage?} Loaded work package. 419 | */ 420 | loadWorkPackage(workPackageId) { 421 | const response = this.getJSON('work_packages/' + workPackageId); 422 | 423 | if (!response) { 424 | utils.log(`Work package with id ${workPackageId} could not be loaded.`); 425 | return null; 426 | } 427 | 428 | const project = this.loadProject(extractProjectIdFromUrl(response?._links?.project?.href)); 429 | 430 | return new OpenProjectWorkPackage( 431 | response.id, 432 | response.subject, 433 | response.startDate, 434 | response.dueDate, 435 | response.estimatedTime, 436 | this.isClosed(response?._links?.status?.href), 437 | project 438 | ); 439 | } 440 | 441 | /** 442 | * Get array of project IDs. 443 | * @returns {number[]} 444 | */ 445 | getProjectIds() { 446 | return Object.keys(this.projects).map(key => parseInt(key)); 447 | } 448 | 449 | /** 450 | * Load multiple projects from OpenProject. 451 | * 452 | * This will make a request for each project. 453 | * @param {number[]} projectIds Array of project IDs. 454 | * @returns Object with project IDs as keys and projects. 455 | */ 456 | loadProjects(projectIds) { 457 | let projects = {}; 458 | 459 | for (const projectId of projectIds) { 460 | const project = this.loadProject(projectId); 461 | 462 | if (!project) { 463 | continue; 464 | } 465 | 466 | projects[project.id] = project; 467 | } 468 | 469 | utils.log(`Loaded ${Object.keys(projects).length} projects`); 470 | 471 | return projects; 472 | } 473 | 474 | /** 475 | * Load a project from OpenProject. 476 | * @param {number} projectId The project ID. 477 | * @returns {OpenProjectProject?} Loaded project. 478 | */ 479 | loadProject(projectId) { 480 | // Check if project was already loaded, so return it 481 | const cachedProject = this.projects[projectId]; 482 | if (cachedProject) { 483 | return cachedProject; 484 | } 485 | 486 | // Load project from OpenProject 487 | const response = this.getJSON('projects/' + projectId); 488 | 489 | if (!response) { 490 | utils.log(`Project with id ${projectId} could not be loaded.`); 491 | return null; 492 | } 493 | 494 | const project = new OpenProjectProject( 495 | response.name, 496 | !response.active, 497 | response._links.self.href, 498 | response?._links?.parent?.href 499 | ); 500 | const category = this.categoryForProject(project); 501 | project.category = category; 502 | 503 | this.projects[response.id] = project; 504 | 505 | return project; 506 | } 507 | 508 | /** 509 | * Get the category for a given project. 510 | * @param {OpenProjectProject} project OpenProject project. 511 | * @returns {OpenProjectCategory} OpenProject category. 512 | */ 513 | categoryForProject(project) { 514 | // Find the highest parent of the project 515 | if (project.parentProjectId) { 516 | const parentProject = this.loadProject(project.parentProjectId); 517 | return this.categoryForProject(parentProject); 518 | } 519 | 520 | // Try to load previous created category 521 | const cachedCategory = this.categories[project.id]; 522 | if (cachedCategory) { 523 | return cachedCategory; 524 | } 525 | 526 | // Create new category 527 | const category = new OpenProjectCategory(project.projectId, project.name); 528 | this.categories[project.projectId] = category; 529 | 530 | return category; 531 | } 532 | } 533 | 534 | /** 535 | * OpenProject Importer class. 536 | */ 537 | class OpenProjectImporter { 538 | /** 539 | * Create a new OpenProject Importer. 540 | * @param {OpenProjectApiClient} client OpenProjectApiClient 541 | */ 542 | constructor(client) { 543 | this.apiClient = client; 544 | } 545 | 546 | /** 547 | * Starts the OpenProject import. 548 | */ 549 | start() { 550 | if (!this.apiClient.loadUser()) { 551 | tyme.showAlert('Error', utils.localize('error.couldNotLoadUser')); 552 | return; 553 | } 554 | 555 | // Update workpackages if they should be updated by time entries 556 | if (formValue.updateTasks) { 557 | this.updateRecentWorkPackages(); 558 | } 559 | 560 | // Load work packages from OpenProject 561 | const workPackages = this.apiClient.loadWorkPackages(); 562 | 563 | // Create projects and work packages 564 | this.processCategories(); 565 | this.processProjects(); 566 | this.processTasks(workPackages); 567 | } 568 | 569 | /** 570 | * Process categories. 571 | */ 572 | processCategories() { 573 | for (const categoryId in this.apiClient.categories) { 574 | const category = this.apiClient.categories[categoryId]; 575 | category.createOrUpdateCategory(); 576 | } 577 | } 578 | 579 | /** 580 | * Process projects. 581 | */ 582 | processProjects() { 583 | for (const projectId of this.apiClient.getProjectIds()) { 584 | const project = this.apiClient.loadProject(projectId); 585 | 586 | if (!project) { 587 | continue; 588 | } 589 | 590 | project.createOrUpdateProject(); 591 | } 592 | } 593 | 594 | /** 595 | * Process tasks. 596 | * @param {object} workPackages 597 | */ 598 | processTasks(workPackages) { 599 | for (const workPackageId in workPackages) { 600 | const workPackage = workPackages[workPackageId]; 601 | workPackage.createOrUpdateTask(); 602 | } 603 | } 604 | 605 | /** 606 | * Updates recent processed work packages. 607 | */ 608 | updateRecentWorkPackages() { 609 | const workPackagesToUpdate = this.recentlyProcessedWorkPackageIds(); 610 | 611 | for (const workPackageId of workPackagesToUpdate) { 612 | const workPackage = this.apiClient.loadWorkPackage(workPackageId); 613 | if (workPackage) { 614 | utils.log('Update Work Package #' + workPackageId); 615 | workPackage.createOrUpdateTask(); 616 | } 617 | } 618 | } 619 | 620 | /** 621 | * Extracts the recently processed work packages from the time entries of 622 | * the selected time frame. 623 | * @returns {number[]} Array with work package IDs. 624 | */ 625 | recentlyProcessedWorkPackageIds() { 626 | let start = new Date(); 627 | start.setDate(start.getDate() - formValue.updateTimeFrame); 628 | const end = new Date(); 629 | 630 | // Load time entries for given time frame 631 | const timeEntries = tyme.timeEntries(start, end); 632 | const regex = new RegExp(/(?:openproject-)\d+/); 633 | 634 | // Array of work packages 635 | const workPackageIds = new Set(); 636 | 637 | for (const entry of timeEntries) { 638 | const match = regex.exec(entry.task_id); 639 | if (!match) { 640 | continue; 641 | } 642 | 643 | // Extract only the work package id 644 | workPackageIds.add(match[0].replace('openproject-', '')); 645 | } 646 | 647 | return Array.from(workPackageIds); 648 | } 649 | } 650 | 651 | const client = new OpenProjectApiClient(formValue.openProjectURL, formValue.openProjectKey); 652 | const importer = new OpenProjectImporter(client); 653 | 654 | // HELPER 655 | 656 | /** 657 | * Extract the project ID from a project URL. 658 | * @param {string} projectUrl Project URL. 659 | * @returns {number?} Project ID. 660 | */ 661 | function extractProjectIdFromUrl(projectUrl) { 662 | if (!projectUrl) { 663 | return null; 664 | } 665 | 666 | const projectId = projectUrl.substring(projectUrl.lastIndexOf('/') + 1); 667 | return parseInt(projectId); 668 | } 669 | 670 | /** 671 | * Extracts the estimated time from an ISO 8601 duration. 672 | * @param {*} estimateString ISO 8601 duration string. 673 | * @returns Estimated duration in seconds. 674 | */ 675 | function extractEstimate(estimateString) { 676 | var matches = estimateString.match( 677 | /(-)?P(?:([.,\d]+)Y)?(?:([.,\d]+)M)?(?:([.,\d]+)W)?(?:([.,\d]+)D)?(?:T(?:([.,\d]+)H)?(?:([.,\d]+)M)?(?:([.,\d]+)S)?)?/ 678 | ); 679 | 680 | const duration = { 681 | isNegative: matches[1] !== undefined, 682 | years: matches[2] ?? 0, 683 | months: matches[3] ?? 0, 684 | weeks: matches[4] ?? 0, 685 | days: matches[5] ?? 0, 686 | hours: matches[6] ?? 0, 687 | minutes: matches[7] ?? 0, 688 | seconds: matches[8] ?? 0, 689 | }; 690 | 691 | let estimateInSeconds = 0; 692 | // TODO: Calculate months and years 693 | estimateInSeconds += 60 * 60 * 24 * 7 * duration.weeks; 694 | estimateInSeconds += 60 * 60 * 24 * duration.days; 695 | estimateInSeconds += 60 * 60 * duration.hours; 696 | estimateInSeconds += 60 * duration.minutes; 697 | estimateInSeconds += duration.seconds; 698 | 699 | return estimateInSeconds; 700 | } 701 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tyme Plugins 2 | 3 | ![macOS Plugins](/guides/plugins_macos.png) 4 | 5 | ## Overview 6 | 7 | Tyme on your Mac and iPhone offers a **JavaScript plugin interface**, which lets you customize the export of your logged time entries or lets your import any data. 8 | Using a plugin you can transform logged time entries into any format you need or send it to any webservice. 9 | 10 | Plugins that are already available can be downloaded directly in Tyme via: **Tyme > Preferences > Plugins** 11 | 12 | If you want to create you own plug in, you can just create a new folder in Tyme's plugin folder and start developing: 13 | 14 | ```javascript 15 | ~/Library/Containers/com.tyme-app.Tyme3-macOS/Data/Library/Application Support/plugins/[YOUR_PLUGIN_FOLDER]/ 16 | ``` 17 | 18 | Please [let us know](https://www.tyme-app.com/en/contact/) of your plugin or file a PR, so we can add it to the official list of plugins. 19 | 20 | ## Structure of a Plugin 21 | 22 | A plugin consists of at least these components: 23 | 24 | ### plugin.json 25 | 26 | This file defines the type, version, compatibility and entry point for the plugin: 27 | 28 | ```javascript 29 | { 30 | "id": "unique_id_of_your_plugin", 31 | "tymeMinVersion": "2024.1", // the minimum compatible version of Tyme for this plugin 32 | "version": "1.0", 33 | "type": "[export|import]", 34 | "author": "John Doe", 35 | "authorUrl": "https://www.tyme-app.com", 36 | "icon": "some_icon92x92.png", 37 | "scriptName": "script.js", 38 | "scriptMain": "createInvoice()", // the method to call when exporting 39 | "scriptPreview": "generatePreview()", // the method to call when generating a preview (HTML is expected in return), only export plugins 40 | "formName": "form.json", 41 | "localizationName": "localization.json" 42 | } 43 | ``` 44 | 45 | ### Plugin JavaScript File 46 | 47 | This is where your logic resides in. Note that you can not use browser specific calls. 48 | See **Scripting with JavaScript** below for more details. 49 | 50 | ### Plugin Form 51 | 52 | If your plugin needs options the user can choose from. This is the place to define them. 53 | 54 | Forms can be used to let the user configure the export data before actually exporting it. 55 | A form is a JSON file with the following structure: 56 | 57 | ```javascript 58 | [ 59 | { 60 | "id": "someUniqueID", 61 | "type": "[button|securetext|text|separator|date|daterange|teammembers|tasks|checkbox|dropdown]", 62 | "name": "label or localization key", 63 | "placeholder": "label or localization key", // only text 64 | "persist": false, 65 | "value": "initial value", 66 | "values": [ // only dropdown 67 | {"key1": "label or localization key"}, 68 | {"key1": "label or localization key"} 69 | ], 70 | "actionFunction": "openWebsite()", // all elements, except of separator 71 | "valueFunction": "getClients()", // only dropdown 72 | "valueFunctionReloadable": true // shows a button to reload the dropdown 73 | }, 74 | ] 75 | 76 | // 2024.5: 'daterange' type added 77 | // 2024.5: 'actionFunction' for all elements added (Previously only button). 78 | 79 | // Please set the tymeMinVersion to 2024.5 if you plan to use the above features. 80 | 81 | 82 | ``` 83 | 84 | Values of all form elements are available in your script in the global variable **formValue**. 85 | 86 | ```javascript 87 | // access a form value 88 | formValue.someUniqueID; 89 | ``` 90 | 91 | You can update properties of a rendered form element: 92 | 93 | ```javascript 94 | class FormElement { 95 | isHidden // bool 96 | enabled // bool 97 | reload() // Calls the valueFunction of a dropdown to reload it. Tyme 2024.14 needed 98 | } 99 | ``` 100 | 101 | All form element can be accessed via the **formElement** property: 102 | 103 | ```javascript 104 | // update a form element 105 | formElement.someUniqueID.isHidden = !formValue.includeNonBillable; 106 | formElement.someUniqueID.enabled = !formValue.markAsBilled; 107 | formElement.someUniqueID.reload(); 108 | ``` 109 | 110 | Each form element can have an **actionFunction** that is called whenever its value changes. 111 | 112 | ```javascript 113 | // call an action, if the value of a form element changes 114 | billableCheckboxClicked() { 115 | formElement.markAsBilled.enabled = !formValue.onlyBillable; 116 | } 117 | ``` 118 | 119 | Using the property **persist** you can define, if the users entered values should be remembered next time the form is opened. 120 | For example you can use **persist: true** to save an API token. Values from the securetext are saved in the users local keychain, all other values are saved in a plain text document. 121 | 122 | The property **valueFunction** is a special property. Tyme will call the method defined by the value function and 123 | expects an array with name-value pairs in return. Use this to dynamically fill a dropdown. 124 | 125 | ```javascript 126 | getClients() 127 | { 128 | return [ 129 | { 130 | "name": "Name", 131 | "value": "some_value" 132 | } 133 | ]; 134 | } 135 | ``` 136 | 137 | 138 | ### Localization File 139 | 140 | Translation file. Current supported languages are German and English. 141 | 142 | ```javascript 143 | { 144 | "en": { 145 | "plugin.name": "Awesome Plugin", // required 146 | "plugin.summary": "This is what the plugin does…", // required 147 | "input.key": "Secret Key", 148 | "input.key.placeholder": "Please enter your personal key", 149 | … 150 | }, 151 | "de": { 152 | "plugin.name": "Geniales Plugin", // required 153 | "plugin.summary": "Dieses Plugin macht Folgendes…", // required 154 | "input.key": "Geheimer Schlüssel", 155 | "input.key.placeholder": "Bitte gib deinen persönlichen Schlüssel ein", 156 | … 157 | } 158 | } 159 | ``` 160 | 161 | ## Scripting with JavaScript 162 | 163 | Since the JavaScript runtime your script is running in does not have any browser specific calls, we created utility 164 | classes to cover the most prominent calls. 165 | 166 | Please refer to the [Scripting Calls](/guides/scripting_helpers.md) page for details. 167 | 168 | ### Importing Data 169 | 170 | When you import data, you obviously need to check for already existing tasks or time entries, create new ones on demand or delete obsolete ones. 171 | 172 | Please refer to the [Importing Data](/guides/importing_data.md) page for details. -------------------------------------------------------------------------------- /Raycast/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/Raycast/icon.png -------------------------------------------------------------------------------- /Raycast/localization.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "plugin.name": "Raycast Fast Access", 4 | "plugin.summary": "With the Raycast Plugin, you can start and stop your tasks in Tyme directly from the Raycast Launcher. The seamless integration makes it even easier for you to track your times and make your workflow smoother." 5 | }, 6 | "de": { 7 | "plugin.name": "Raycast Schnellzugriff", 8 | "plugin.summary": "Mit dem Raycast Plugin startest und stoppst du deine Aufgaben in Tyme direkt aus dem Raycast Launcher. Die nahtlose Integration ermöglicht es dir, deine Zeiten noch einfacher zu erfassen und deinen Workflow noch effizienter zu gestalten." 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Raycast/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "raycast_plugin", 3 | "tymeMinVersion": "2024.11", 4 | "version": "1.1", 5 | "type": "export", 6 | "author": "Unit Numberfive oHG", 7 | "authorUrl": "https://www.tyme-app.com/en/blog-2024-04-30-raycast/?hide_nav", 8 | "icon": "icon.png", 9 | "scriptName": "", 10 | "scriptMain": "", 11 | "scriptPreview": "", 12 | "formName": "", 13 | "localizationName": "localization.json", 14 | "isBuiltIn": true, 15 | "internalType": "plugin_6", 16 | "internalLink": "/blog-2024-04-30-raycast/" 17 | } 18 | -------------------------------------------------------------------------------- /Send to Tyme.grandtotalplugin/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleIdentifier 6 | com.mediaatelier.estimates.sendToTyme 7 | copyright 8 | © 2023 Stefan Fürst 9 | CFBundleVersion 10 | 3.1 11 | GrandTotalMinimumVersion 12 | 6.0 13 | types 14 | 15 | estimates 16 | 17 | RequiresFullDiskAccess 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Send to Tyme.grandtotalplugin/README.md: -------------------------------------------------------------------------------- 1 | # GrandTotal Offers 2 | 3 | The **Offer Plugin** that enables you to automatically add clients, projects and tasks from a [GrandTotal](https://www.mediaatelier.com) draft invoice to Tyme. 4 | 5 | You just have to install their GrandTotal Part. Please refer to the [GrandTotal Plugin Guide](https://www.tyme-app.com/en/grandtotal-plugin) on how to install it. -------------------------------------------------------------------------------- /Send to Tyme.grandtotalplugin/de.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "SendToTyme" = "An Tyme senden"; 2 | -------------------------------------------------------------------------------- /Send to Tyme.grandtotalplugin/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "SendToTyme" = "Send to Tyme"; 2 | -------------------------------------------------------------------------------- /Send to Tyme.grandtotalplugin/grandtotal_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/Send to Tyme.grandtotalplugin/grandtotal_icon.png -------------------------------------------------------------------------------- /Send to Tyme.grandtotalplugin/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | Sends an offer from GrandTotal to Tyme. 3 | 4 | Keep in mind that you can't use browser specific calls. Use following calls 5 | 6 | --Loading httpContents-- 7 | String loadURL(method,url,headers); 8 | 9 | --Files-- 10 | BOOL fileExists(path); 11 | BOOL fileIsDirectory(path); 12 | String contentsOfFile(path); 13 | String plistToJSON(String); 14 | Array of Strings contentsOfDirectory(path); 15 | 16 | NSHomeDirectory variable 17 | 18 | --Logging to the Console-- 19 | log(value); 20 | 21 | --Base64-- 22 | String base64Encode(string); 23 | 24 | Expected result is a JSON representation: 25 | 26 | { 27 | categories: [ 28 | name 29 | projects: [ 30 | name 31 | tasks: [ 32 | name 33 | plannedDuration 34 | hourlyRate 35 | subtasks: [ 36 | name 37 | plannedDuration 38 | hourlyRate 39 | ] 40 | ] 41 | ] 42 | ] 43 | } 44 | 45 | Make *sure* the uid you provide is not just a plain integer. Use your domain as prefix. 46 | 47 | Dates must be returned as strings in ISO 8601 format (e.g., 2004-02-12T15:19:21+00:00) 48 | 49 | Returning a string will present it as warning. 50 | 51 | To see how to add global variables (Settings) look at the Info.plist of this sample. 52 | 53 | Keep in mind that for security reasons passwords are stored in the keychain and 54 | you will have to enter them again after modifying your code. 55 | */ 56 | 57 | send(); 58 | 59 | function send() { 60 | var tyme3Path = NSHomeDirectory + "/Library/Containers/com.tyme-app.Tyme3-macOS/Data/Library/Application Support/GrandTotal/offers/"; 61 | var offer = query().record().valueForKey("interchangeRecord"); 62 | var fileName = Math.random().toString(36).slice(-5) + ".plist"; 63 | var URL = tyme3Path + fileName; 64 | 65 | var categoryName = offer.clientName; 66 | var projectName = offer.project === "" ? offer.subject : offer.project; 67 | var tasks = []; 68 | var parentTask = null; 69 | 70 | offer.allItems.forEach((item, index) => { 71 | var task = { 72 | "name": item.name, 73 | "plannedDuration": ((item.quantity * item.rate) / item.rate) * 60.0 * 60.0, 74 | "hourlyRate": item.rate, 75 | "subtasks": [] 76 | }; 77 | 78 | if (item.entityName.toLowerCase() == "title") { 79 | parentTask = task; 80 | tasks.push(parentTask); 81 | } else { 82 | if (parentTask !== null) { 83 | parentTask.subtasks.push(task); 84 | } else { 85 | tasks.push(task); 86 | } 87 | } 88 | }); 89 | 90 | var plistData = {}; 91 | plistData.categories = [{ 92 | "name": categoryName, 93 | projects: [{ 94 | "name": projectName, 95 | "tasks": tasks 96 | }] 97 | }]; 98 | 99 | writeToURL(plistData, URL); 100 | launchURL("tyme://grandtotal/offer/" + fileName); 101 | } -------------------------------------------------------------------------------- /Send to Tyme.grandtotalplugin/localization.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "plugin.name": "GrandTotal Tasks From Offers", 4 | "plugin.summary": "With this plugin you can use GrandTotal quotes to generate projects and tasks for your time tracking in Tyme. This allows you to make the most of the synergies between the two tools, saving time and avoiding errors between quote and invoice." 5 | }, 6 | "de": { 7 | "plugin.name": "GrandTotal Aufgaben aus Angeboten", 8 | "plugin.summary": "Mit diesem Plugin nutzt du GrandTotal-Angebote, um Projekte und Aufgaben für die Zeiterfassung in Tyme zu generieren. So sparst du Zeit, vermeidest Fehler zwischen Angebot und Abrechnung und nutzt die Synergien der beiden Tools optimal." 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Send to Tyme.grandtotalplugin/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "grandtotal_offers", 3 | "tymeMinVersion": "2024.11", 4 | "version": "3.3", 5 | "type": "import", 6 | "author": "Unit Numberfive oHG", 7 | "authorUrl": "https://www.tyme-app.com/en/grandtotal-plugin/?hide_nav", 8 | "icon": "grandtotal_icon.png", 9 | "scriptName": "", 10 | "scriptMain": "THE OFFER FUNCTIONALITY IS ALREADY BUILT IN INTO TYME. THIS PLUGIN IS THE GRANDTOTAL COUNTERPART.", 11 | "scriptPreview": "", 12 | "formName": "", 13 | "localizationName": "localization.json", 14 | "isBuiltIn": true, 15 | "internalType": "plugin_5", 16 | "internalLink": "/partner-grandtotal/" 17 | } 18 | -------------------------------------------------------------------------------- /SevDeskInvoices/README.md: -------------------------------------------------------------------------------- 1 | # sevdesk Invoices 2 | 3 | This plugin lets you export your recorded times to [sevdesk](https://www.sevdesk.de). 4 | You can view a preview of the invoice before actually sending it to sevdesk. 5 | 6 | The plugin uses the [sevdesk API](https://api.sevdesk.de/#operation/createInvoiceByFactory) to send the data to SevDesk. -------------------------------------------------------------------------------- /SevDeskInvoices/form.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "apiKey", 4 | "type": "securetext", 5 | "name": "input.key", 6 | "placeholder": "input.key.placeholder", 7 | "persist": true 8 | }, 9 | { 10 | "id": "apiKeyHint", 11 | "type": "hint", 12 | "name": "", 13 | "value": "input.key.hint" 14 | }, 15 | { 16 | "id": "separator1", 17 | "type": "separator", 18 | "name": "" 19 | }, 20 | { 21 | "id": "startDate", 22 | "type": "date", 23 | "name": "input.startdate" 24 | }, 25 | { 26 | "id": "endDate", 27 | "type": "date", 28 | "name": "input.enddate" 29 | }, 30 | { 31 | "id": "teamMemberID", 32 | "type": "teammembers", 33 | "name": "" 34 | }, 35 | { 36 | "id": "taskIDs", 37 | "type": "tasks", 38 | "name": "" 39 | }, 40 | { 41 | "id": "prefixProject", 42 | "type": "checkbox", 43 | "name": "input.prefixProject", 44 | "value": "false", 45 | "persist": true 46 | }, 47 | { 48 | "id": "separator2", 49 | "type": "separator", 50 | "name": "" 51 | }, 52 | { 53 | "id": "showNotes", 54 | "type": "checkbox", 55 | "name": "input.notes", 56 | "value": "true", 57 | "persist": true 58 | }, 59 | { 60 | "id": "showTimesInNotes", 61 | "type": "checkbox", 62 | "name": "input.note.times", 63 | "value": "false", 64 | "persist": true 65 | }, 66 | { 67 | "id": "clusterOption", 68 | "type": "dropdown", 69 | "name": "input.cluster", 70 | "values": [ 71 | { 72 | "0": "input.cluster.none" 73 | }, 74 | { 75 | "1": "input.cluster.dayTask" 76 | }, 77 | { 78 | "2": "input.cluster.dayTaskNoSub" 79 | } 80 | ], 81 | "persist": true 82 | }, 83 | { 84 | "id": "separator3", 85 | "type": "separator", 86 | "name": "" 87 | }, 88 | { 89 | "id": "taxRate", 90 | "type": "dropdown", 91 | "name": "input.taxrate", 92 | "values": [ 93 | { 94 | "19": "19%" 95 | }, 96 | { 97 | "7": "7%" 98 | }, 99 | { 100 | "0": "0%" 101 | }, 102 | { 103 | "": "" 104 | }, 105 | { 106 | "20": "20%" 107 | }, 108 | { 109 | "10": "10%" 110 | }, 111 | { 112 | "13": "13%" 113 | }, 114 | { 115 | "": "" 116 | }, 117 | { 118 | "7.7": "7.7%" 119 | }, 120 | { 121 | "3.7": "3.7%" 122 | }, 123 | { 124 | "2.5": "2.5%" 125 | } 126 | ], 127 | "persist": true 128 | }, 129 | { 130 | "id": "contactID", 131 | "type": "dropdown", 132 | "name": "input.contact", 133 | "valueFunction": "sevdeskResolver.getContacts()", 134 | "valueFunctionReloadable": true, 135 | "persist": true 136 | }, 137 | { 138 | "id": "userID", 139 | "type": "dropdown", 140 | "name": "input.user", 141 | "valueFunction": "sevdeskResolver.getSevUser()", 142 | "valueFunctionReloadable": true, 143 | "persist": true 144 | }, 145 | { 146 | "id": "separator4", 147 | "type": "separator", 148 | "name": "" 149 | }, 150 | { 151 | "id": "onlyUnbilled", 152 | "type": "checkbox", 153 | "name": "input.unbilled", 154 | "value": "false", 155 | "persist": true 156 | }, 157 | { 158 | "id": "markAsBilled", 159 | "type": "checkbox", 160 | "name": "input.billed", 161 | "value": "false", 162 | "persist": true 163 | }, 164 | { 165 | "id": "includeNonBillable", 166 | "type": "checkbox", 167 | "name": "input.nonbillable", 168 | "value": "false", 169 | "persist": true 170 | } 171 | ] 172 | -------------------------------------------------------------------------------- /SevDeskInvoices/localization.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "plugin.name": "sevdesk Invoices", 4 | "plugin.summary": "With the sevdesk plugin, you can create invoices from the hours you have tracked in the successful accounting and finance software. The partnership with time tracking in Tyme enables seamless integration. This simplifies invoicing and optimizes your billing process.", 5 | "locale.identifier": "en-US", 6 | "input.key": "API Token", 7 | "input.key.placeholder": "Please enter your API-Token", 8 | "input.key.hint": "You can find your API token under your [user](https://my.sevdesk.de/#/admin/userManagement) > API token.", 9 | "input.startdate": "From", 10 | "input.enddate": "To", 11 | "input.contact": "Contact", 12 | "input.user": "sevdesk User", 13 | "input.taxrate": "Tax Rate", 14 | "input.billed": "Mark exported time entries as billed", 15 | "input.unbilled": "Export only unbilled time entries", 16 | "input.nonbillable": "Include non-billable tasks", 17 | "input.data.empty": "Could not fetch data from sevdesk. Please check your API-Token Key.", 18 | "input.prefixProject": "Prefix with project title", 19 | "input.notes": "Include notes in invoice", 20 | "input.note.times": "Display times in notes", 21 | "input.cluster": "Summarize Times", 22 | "input.cluster.none": "Do not Summarize", 23 | "input.cluster.dayTask": "Summarize By Day and Task", 24 | "input.cluster.dayTaskNoSub": "Summarize By Day and Task (No Sub-tasks)", 25 | "unit.hours": "Hours", 26 | "unit.kilometer": "km", 27 | "unit.quantity": "Pieces", 28 | "invoice.header": "Invoice items", 29 | "invoice.position": "Position", 30 | "invoice.price": "Price", 31 | "invoice.quantity": "Quantity", 32 | "invoice.unit": "Unit", 33 | "invoice.net": "Net", 34 | "export.error.title": "Error", 35 | "export.empty.error": "The invoice must contain at least one item." 36 | }, 37 | "de": { 38 | "plugin.name": "sevdesk Rechnungen", 39 | "plugin.summary": "Mit dem sevdesk Plugin erstellst du Rechnungen aus deinen erfassten Stunden in der erfolgreichen Software für Buchhaltung & Finanzen. Die Partnerschaft mit Zeiterfassung in Tyme ermöglicht eine nahtlose Integration. Dies vereinfacht die Rechnungserstellung und optimiert deinen Abrechnungsprozess.", 40 | "locale.identifier": "de-DE", 41 | "input.key": "API Token", 42 | "input.key.placeholder": "Bitte gib deinen API-Token ein", 43 | "input.key.hint": "Deinen API-Token kannst du unter deinem [Benutzer](https://my.sevdesk.de/#/admin/userManagement) > API-Token finden.", 44 | "input.startdate": "Von", 45 | "input.enddate": "Bis", 46 | "input.contact": "Kontakt", 47 | "input.user": "sevdesk Nutzer", 48 | "input.taxrate": "Mehrwertsteuersatz", 49 | "input.billed": "Exportierte Zeiten als berechnet markieren", 50 | "input.unbilled": "Nur nicht berechnete Zeiteinträge exportieren", 51 | "input.nonbillable": "Nicht berechenbare Aufgaben inkludieren", 52 | "input.data.empty": "Es konnten keine Daten von sevdesk geladen werden. Bitte überprüfe deinen API-Token.", 53 | "input.prefixProject": "Präfix Projekt-Titel", 54 | "input.notes": "Notizen in die Rechnung übernehmen", 55 | "input.note.times": "Zeiten in Notizen anzeigen", 56 | "input.cluster": "Zeiten Zusammenfassen", 57 | "input.cluster.none": "Nicht zusammenfassen", 58 | "input.cluster.dayTask": "Nach Tag und Aufgabe zusammenfassen", 59 | "input.cluster.dayTaskNoSub": "Nach Tag und Aufgabe zusammenfassen (Keine Unteraufgaben)", 60 | "unit.hours": "Stunden", 61 | "unit.kilometer": "km", 62 | "unit.quantity": "Stück", 63 | "invoice.header": "Rechnungspositionen", 64 | "invoice.position": "Position", 65 | "invoice.price": "Preis", 66 | "invoice.quantity": "Anzahl", 67 | "invoice.unit": "Einheit", 68 | "invoice.net": "Netto", 69 | "export.error.title": "Fehler", 70 | "export.empty.error": "Die Rechnung muss mindestens eine Position enthalten." 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /SevDeskInvoices/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "sevdesk_invoices", 3 | "tymeMinVersion": "2024.21", 4 | "version": "3.2", 5 | "type": "export", 6 | "author": "Unit Numberfive oHG", 7 | "authorUrl": "https://www.tyme-app.com", 8 | "icon": "sevdesk_icon.png", 9 | "scriptName": "script.js", 10 | "scriptMain": "sevdeskResolver.createNewInvoice()", 11 | "scriptPreview": "timeEntriesConverter.generatePreview()", 12 | "formName": "form.json", 13 | "localizationName": "localization.json", 14 | "internalType": "plugin_2", 15 | "internalLink": "/partner-sevdesk/" 16 | } 17 | -------------------------------------------------------------------------------- /SevDeskInvoices/script.js: -------------------------------------------------------------------------------- 1 | class TimeEntriesConverter { 2 | constructor() { 3 | 4 | } 5 | 6 | timeEntriesFromFormValues(useClusterOption) { 7 | return tyme.timeEntries( 8 | formValue.startDate, 9 | formValue.endDate, 10 | formValue.taskIDs, 11 | null, 12 | formValue.onlyUnbilled ? 0 : null, 13 | formValue.includeNonBillable ? null : true, 14 | formValue.teamMemberID, 15 | useClusterOption ? formValue.clusterOption : null 16 | ).filter(function (timeEntry) { 17 | return parseFloat(timeEntry.sum) > 0; 18 | }) 19 | } 20 | 21 | timeEntryIDs() { 22 | return this.timeEntriesFromFormValues(false) 23 | .map(function (entry) { 24 | return entry.id; 25 | }); 26 | } 27 | 28 | aggregatedTimeEntryData() { 29 | let data = 30 | this.timeEntriesFromFormValues(true) 31 | .reduce(function (data, timeEntry) { 32 | const key = timeEntry.task_id + timeEntry.subtask_id; 33 | 34 | if (data[key] == null) { 35 | let entry = { 36 | 'project': '', 37 | 'name': '', 38 | 'quantity': 0.0, 39 | 'unit': '', 40 | 'price': parseFloat(timeEntry.rate), 41 | 'note': '', 42 | 'sum': 0.0 43 | }; 44 | 45 | // unit: Stk=1, Std=9, km=10 46 | 47 | if (timeEntry.type === 'timed') { 48 | entry.unit = utils.localize('unit.hours') 49 | entry.unitID = 9; 50 | } else if (timeEntry.type === 'mileage') { 51 | entry.unit = utils.localize('unit.kilometer') 52 | entry.unitID = 10; 53 | } else if (timeEntry.type === 'fixed') { 54 | entry.unit = utils.localize('unit.quantity') 55 | entry.unitID = 1; 56 | } 57 | 58 | entry.project = timeEntry.project; 59 | entry.name = timeEntry.task; 60 | 61 | if (timeEntry.subtask.length > 0) { 62 | entry.name += ': ' + timeEntry.subtask 63 | } 64 | 65 | data[key] = entry; 66 | } 67 | 68 | let currentQuantity = 0; 69 | 70 | if (timeEntry.type === 'timed') { 71 | currentQuantity = parseFloat(timeEntry.duration) / 60.0 72 | data[key].quantity += currentQuantity; 73 | } else if (timeEntry.type === 'mileage') { 74 | currentQuantity = parseFloat(timeEntry.distance) 75 | data[key].quantity += currentQuantity; 76 | } else if (timeEntry.type === 'fixed') { 77 | currentQuantity = parseFloat(timeEntry.quantity) 78 | data[key].quantity += currentQuantity; 79 | } 80 | 81 | if (formValue.showTimesInNotes && timeEntry.type !== "fixed") { 82 | 83 | if (data[key].note.length > 0) { 84 | data[key].note += '
'; 85 | } 86 | 87 | if (timeEntry.hasOwnProperty("start") && timeEntry.hasOwnProperty("end")) { 88 | data[key].note += this.formatDate(timeEntry.start, false) + " "; 89 | data[key].note += this.formatDate(timeEntry.start, true) + " - "; 90 | data[key].note += this.formatDate(timeEntry.end, true); 91 | } else if (timeEntry.hasOwnProperty("date")) { 92 | data[key].note += this.formatDate(timeEntry.date, false); 93 | } 94 | 95 | data[key].note += " (" + this.roundNumber(currentQuantity, 2) + " " + data[key].unit + ")"; 96 | 97 | if (timeEntry.note.length > 0) { 98 | data[key].note += "
"; 99 | data[key].note += timeEntry.note; 100 | } 101 | 102 | } else if (timeEntry.note.length > 0) { 103 | if (data[key].note.length > 0) { 104 | data[key].note += '
'; 105 | } 106 | data[key].note += timeEntry.note; 107 | } 108 | 109 | return data; 110 | 111 | }.bind(this), {}); 112 | 113 | return Object.keys(data) 114 | .map(function (key) { 115 | return data[key]; 116 | }) 117 | .sort(function (a, b) { 118 | return a.name > b.name; 119 | }); 120 | } 121 | 122 | formatDate(dateString, timeOnly) { 123 | let locale = utils.localize('locale.identifier'); 124 | if (timeOnly) { 125 | return (new Date(dateString)).toLocaleTimeString(locale, {hour: '2-digit', minute: '2-digit'}); 126 | } else { 127 | return (new Date(dateString)).toLocaleDateString(locale); 128 | } 129 | } 130 | 131 | roundNumber(num, places) { 132 | return (+(Math.round(num + "e+" + places) + "e-" + places)).toFixed(places); 133 | } 134 | 135 | generatePreview() { 136 | const data = this.aggregatedTimeEntryData() 137 | 138 | let total = 0.0; 139 | var str = ''; 140 | str += '![](plugins/SevDeskInvoices/sevdesk_logo.png)\n'; 141 | str += '## ' + utils.localize('invoice.header') + '\n'; 142 | 143 | str += '|' + utils.localize('invoice.position'); 144 | str += '|' + utils.localize('invoice.price'); 145 | str += '|' + utils.localize('invoice.quantity'); 146 | str += '|' + utils.localize('invoice.unit'); 147 | str += '|' + utils.localize('invoice.net'); 148 | str += '|\n'; 149 | 150 | str += '|-|-:|-:|-|-:|\n'; 151 | 152 | data.forEach((entry) => { 153 | 154 | var name = entry.name; 155 | 156 | if (formValue.showNotes) { 157 | name = '**' + entry.name + '**'; 158 | name += '
' + entry.note.replace(/\n/g, '
'); 159 | } 160 | 161 | if (formValue.prefixProject) { 162 | name = '**' + entry.project + ':** ' + name; 163 | } 164 | 165 | let price = this.roundNumber(entry.price, 2); 166 | let quantity = this.roundNumber(entry.quantity, 2); 167 | let sum = this.roundNumber(parseFloat(price) * parseFloat(quantity), 2); 168 | 169 | total += parseFloat(sum); 170 | 171 | str += '|' + name; 172 | str += '|' + price + ' ' + tyme.currencySymbol(); 173 | str += '|' + quantity; 174 | str += '|' + entry.unit; 175 | str += '|' + sum + ' ' + tyme.currencySymbol(); 176 | str += '|\n'; 177 | }); 178 | 179 | str += '|||||**' + this.roundNumber(total, 2) + ' ' + tyme.currencySymbol() + '**|\n'; 180 | return utils.markdownToHTML(str); 181 | } 182 | } 183 | 184 | class SevDeskResolver { 185 | constructor(apiKey, timeEntriesConverter) { 186 | this.apiKey = apiKey; 187 | this.timeEntriesConverter = timeEntriesConverter; 188 | this.baseURL = 'https://my.sevdesk.de/api'; 189 | this.invoicePath = '/v1/Invoice/Factory/saveInvoice'; 190 | this.invoiceNumberPath = '/v1/Invoice/Factory/getNextInvoiceNumber'; 191 | this.contactsPath = '/v1/Contact'; 192 | this.userPath = '/v1/SevUser'; 193 | } 194 | 195 | getSevUser() { 196 | const url = this.baseURL + this.userPath; 197 | const response = utils.request(url, 'GET', {'Authorization': this.apiKey}, null); 198 | const statusCode = response['statusCode']; 199 | const result = response['result']; 200 | 201 | if (statusCode === 200) { 202 | const parsedData = JSON.parse(result); 203 | const contacts = parsedData['objects']; 204 | let contactList = []; 205 | 206 | contacts.forEach((contact, index) => { 207 | contactList.push({ 208 | "name": contact["fullname"], 209 | "value": contact["id"], 210 | }) 211 | }); 212 | 213 | return contactList; 214 | 215 | } else { 216 | return [ 217 | { 218 | 'name': utils.localize('input.data.empty'), 219 | 'value': '' 220 | } 221 | ] 222 | } 223 | } 224 | 225 | getContacts() { 226 | let allContacts = []; 227 | let offset = 0; 228 | let newContactCount = 1; 229 | 230 | while (newContactCount > 0) { 231 | let contacts = this.getContactsInternal(offset, 500); 232 | newContactCount = contacts.length; 233 | allContacts = allContacts.concat(contacts); 234 | offset += 500; 235 | } 236 | 237 | allContacts.sort(function (a, b) { 238 | return a["name"] < b["name"] ? -1 : 1; 239 | }); 240 | 241 | if (allContacts.length === 0) { 242 | allContacts.push( 243 | { 244 | 'name': utils.localize('input.data.empty'), 245 | 'value': '' 246 | } 247 | ); 248 | } 249 | 250 | return allContacts; 251 | } 252 | 253 | getContactsInternal(offset, limit) { 254 | const url = this.baseURL + this.contactsPath; 255 | const response = utils.request( 256 | url, 257 | 'GET', 258 | {'Authorization': this.apiKey}, 259 | { 260 | "depth": 1, 261 | "limit": limit, 262 | "offset": offset 263 | } 264 | ); 265 | const statusCode = response['statusCode']; 266 | const result = response['result']; 267 | 268 | if (statusCode === 200) { 269 | const parsedData = JSON.parse(result); 270 | const contacts = parsedData['objects']; 271 | let contactList = []; 272 | 273 | contacts.forEach((contact, index) => { 274 | if (contact["name"]) { 275 | contactList.push({ 276 | "name": contact["name"], 277 | "value": contact["id"], 278 | }) 279 | } else if (contact["surename"] && contact["familyname"]) { 280 | contactList.push({ 281 | "name": contact["surename"] + " " + contact["familyname"], 282 | "value": contact["id"], 283 | }) 284 | } 285 | }); 286 | 287 | return contactList; 288 | 289 | } else { 290 | return []; 291 | } 292 | } 293 | 294 | createNewInvoice() { 295 | const invoiceID = this.makeCreateNewInvoiceCall(); 296 | 297 | if (invoiceID !== null) { 298 | if (formValue.markAsBilled) { 299 | const timeEntryIDs = this.timeEntriesConverter.timeEntryIDs(); 300 | tyme.setBillingState(timeEntryIDs, 1); 301 | } 302 | tyme.openURL('https://my.sevdesk.de/#/fi/edit/type/RE/id/' + invoiceID); 303 | } 304 | } 305 | 306 | getInvoiceNumber() { 307 | const params = { 308 | "objectType": "Invoice", 309 | "type": "RE" 310 | }; 311 | const url = this.baseURL + this.invoiceNumberPath; 312 | const response = utils.request(url, 'GET', {'Authorization': this.apiKey}, params); 313 | const statusCode = response['statusCode']; 314 | const result = response['result']; 315 | 316 | if (statusCode === 200) { 317 | const parsedData = JSON.parse(result); 318 | return parsedData["objects"]; 319 | } else { 320 | return null; 321 | } 322 | } 323 | 324 | makeCreateNewInvoiceCall() { 325 | const data = this.timeEntriesConverter.aggregatedTimeEntryData() 326 | let invoicePosSave = []; 327 | 328 | data.forEach((entry) => { 329 | const name = formValue.prefixProject ? entry.project + ": " + entry.name : entry.name; 330 | const note = formValue.showNotes ? entry.note : ''; 331 | const quantity = this.timeEntriesConverter.roundNumber(entry.quantity, 2); 332 | 333 | invoicePosSave.push({ 334 | "objectName": "InvoicePos", 335 | "quantity": quantity, 336 | "price": entry.price, 337 | "name": name, 338 | "unity": { 339 | "id": entry.unitID, 340 | "objectName": "Unity" 341 | }, 342 | "text": note, 343 | "taxRate": formValue.taxRate, 344 | "mapAll": true 345 | }) 346 | }); 347 | 348 | const params = { 349 | "invoice": { 350 | "id": null, 351 | "objectName": "Invoice", 352 | "invoiceNumber": this.getInvoiceNumber(), 353 | "contact": { 354 | "id": formValue.contactID, 355 | "objectName": "Contact" 356 | }, 357 | "contactPerson": { 358 | "id": formValue.userID, 359 | "objectName": "SevUser" 360 | }, 361 | "invoiceDate": new Date().toISOString(), 362 | "discount": 0, 363 | "deliveryDate": formValue.startDate.toISOString(), 364 | "deliveryDateUntil": formValue.endDate.toISOString(), 365 | "status": "100", 366 | "taxRate": formValue.taxRate, 367 | "taxType": "default", 368 | "invoiceType": "RE", 369 | "currency": tyme.currencyCode(), 370 | "mapAll": true 371 | }, 372 | "invoicePosSave": invoicePosSave, 373 | "takeDefaultAddress": true 374 | } 375 | 376 | const url = this.baseURL + this.invoicePath; 377 | const response = utils.request(url, 'POST', {'Authorization': this.apiKey}, params); 378 | const statusCode = response['statusCode']; 379 | const result = response['result']; 380 | 381 | if (statusCode === 201) { 382 | const parsedData = JSON.parse(result); 383 | return parsedData["objects"]["invoice"]["id"]; 384 | } else if (statusCode === 422) { 385 | tyme.showAlert( 386 | utils.localize('export.error.title'), 387 | utils.localize('export.empty.error') 388 | ); 389 | return null; 390 | } else if (statusCode === 401) { 391 | const parsedData = JSON.parse(result); 392 | tyme.showAlert('sevdesk API Error ' + parsedData["status"], parsedData["message"]); 393 | return null; 394 | } else { 395 | tyme.showAlert('sevdesk API Error', JSON.stringify(response)); 396 | return null; 397 | } 398 | } 399 | } 400 | 401 | const timeEntriesConverter = new TimeEntriesConverter(); 402 | const sevdeskResolver = new SevDeskResolver(formValue.apiKey, timeEntriesConverter); -------------------------------------------------------------------------------- /SevDeskInvoices/sevdesk_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/SevDeskInvoices/sevdesk_icon.png -------------------------------------------------------------------------------- /SevDeskInvoices/sevdesk_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/SevDeskInvoices/sevdesk_logo.png -------------------------------------------------------------------------------- /TogglImporter/README.md: -------------------------------------------------------------------------------- 1 | # Toggl Importer 2 | 3 | This plugin imports your data from [toggl](https://track.toggl.com). 4 | 5 | The plugin uses the [toggl API](https://github.com/toggl/toggl_api_docs/) to fetch the data. -------------------------------------------------------------------------------- /TogglImporter/form.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "togglKey", 4 | "type": "securetext", 5 | "name": "input.key", 6 | "placeholder": "input.key.placeholder", 7 | "persist": true 8 | }, 9 | { 10 | "id": "toggleKeyHint", 11 | "type": "hint", 12 | "name": "", 13 | "value": "input.key.hint" 14 | } 15 | ] 16 | -------------------------------------------------------------------------------- /TogglImporter/localization.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "plugin.name": "toggl", 4 | "plugin.summary": "Import your data from toggl right into Tyme.", 5 | "input.key": "Toggl API Key", 6 | "input.key.placeholder": "Please enter your API key", 7 | "input.key.hint": "You can find the token in your [toggl profile](https://track.toggl.com/profile).\n\nAll clients, projects, tasks and time entries of the last two years will be imported.\n\nIf there are time entries from different users in your toggl account, the importer will try to match them to your Tyme users based on the email address of the user." 8 | }, 9 | "de": { 10 | "plugin.name": "toggl", 11 | "plugin.summary": "Importiere deine Daten aus toggl direkt in Tyme.", 12 | "input.key": "Toggl API Key", 13 | "input.key.placeholder": "Bitte gib deinen API Schlüssel ein", 14 | "input.key.hint": "Du findest den API Token in deinem [toggl Profil](https://track.toggl.com/profile).\n\nEs werden alle Kunden, Projekte, Aufgaben und Zeiteinträge der letzten zwei Jahre importiert.\n\nWenn in deinem toggl-Konto Zeiteinträge von verschiedenen Benutzern vorhanden sind, versucht der Importer, diese anhand der E-Mail-Adresse des Benutzers deinen Tyme-Nutzern zuzuordnen." 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /TogglImporter/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "toggl_importer", 3 | "tymeMinVersion": "2024.11", 4 | "version": "1.6", 5 | "type": "import", 6 | "author": "Unit Numberfive oHG", 7 | "authorUrl": "https://www.tyme-app.com", 8 | "icon": "toggl_icon.png", 9 | "scriptName": "script.js", 10 | "scriptMain": "importer.start()", 11 | "scriptPreview": "", 12 | "formName": "form.json", 13 | "localizationName": "localization.json", 14 | "internalType": "time_2", 15 | "internalLink": "" 16 | } 17 | -------------------------------------------------------------------------------- /TogglImporter/script.js: -------------------------------------------------------------------------------- 1 | class TogglApiClient { 2 | 3 | constructor(apiKey) { 4 | this.apiKey = apiKey; 5 | this.baseURL = 'https://api.track.toggl.com'; 6 | } 7 | 8 | request(path, params = null, method = "GET") { 9 | const response = utils.request( 10 | this.baseURL + path, 11 | method, 12 | {'Authorization': 'Basic ' + utils.base64Encode(this.apiKey + ':api_token')}, 13 | params 14 | ); 15 | 16 | const statusCode = response['statusCode']; 17 | const result = response['result']; 18 | const headers = response['headers']; 19 | 20 | if (statusCode === 200) { 21 | return [JSON.parse(result), headers]; 22 | } else { 23 | return [null, headers]; 24 | } 25 | } 26 | } 27 | 28 | class TogglImporter { 29 | constructor(apiKey) { 30 | this.apiClient = new TogglApiClient(apiKey); 31 | } 32 | 33 | start() { 34 | if (!this.getWorkspaces()) { 35 | return; 36 | } 37 | 38 | this.getUsers(); 39 | this.getClients(); 40 | this.getProjects(); 41 | this.getTasks(); 42 | this.getTimeEntries(); 43 | 44 | this.processData(); 45 | } 46 | 47 | processData() { 48 | const idPrefix = "toggl-"; 49 | const defaultHourlyRate = this.workspaces[0]["default_hourly_rate"]; 50 | const rounding = this.workspaces[0]["rounding"] + 1; 51 | const roundingMinutes = this.workspaces[0]["rounding_minutes"]; 52 | 53 | for (let clientID in this.clients) { 54 | const client = this.clients[clientID]; 55 | const id = idPrefix + clientID; 56 | 57 | let tymeCategory = Category.fromID(id) ?? Category.create(id); 58 | tymeCategory.name = client["name"]; 59 | } 60 | 61 | for (let projectID in this.projects) { 62 | const project = this.projects[projectID]; 63 | const id = idPrefix + projectID; 64 | const clientID = idPrefix + project["cid"]; 65 | 66 | let tymeProject = Project.fromID(id) ?? Project.create(id); 67 | tymeProject.name = project["name"]; 68 | tymeProject.isCompleted = !project["active"]; 69 | 70 | if (project["color"] && typeof project["color"] === 'string') { 71 | tymeProject.color = parseInt(project["color"].replace("#", "0x")); 72 | } 73 | tymeProject.defaultHourlyRate = project["rate"] ?? defaultHourlyRate; 74 | tymeProject.roundingMethod = rounding; 75 | tymeProject.roundingMinutes = roundingMinutes; 76 | 77 | if (!project["auto_estimates"]) { 78 | tymeProject.plannedDuration = project["estimated_hours"] * 60 * 60; 79 | } 80 | 81 | tymeProject.category = Category.fromID(clientID); 82 | } 83 | 84 | for (let taskID in this.tasks) { 85 | const task = this.tasks[taskID]; 86 | 87 | const id = idPrefix + taskID; 88 | const projectID = idPrefix + task["project_id"]; 89 | let billable = true; 90 | const togglProject = this.projects[task["project_id"]] 91 | 92 | if (togglProject) { 93 | billable = togglProject["billable"]; 94 | } 95 | 96 | let tymeTask = TimedTask.fromID(id) ?? TimedTask.create(id); 97 | tymeTask.name = task["name"]; 98 | tymeTask.isCompleted = !task["active"]; 99 | tymeTask.billable = billable; 100 | tymeTask.plannedDuration = task["estimated_seconds"]; 101 | tymeTask.roundingMethod = rounding; 102 | tymeTask.roundingMinutes = roundingMinutes; 103 | 104 | const project = Project.fromID(projectID); 105 | 106 | tymeTask.project = project; 107 | 108 | if (project.isCompleted) { 109 | tymeTask.isCompleted = true; 110 | } 111 | } 112 | 113 | for (const timeEntry of this.timeEntries) { 114 | const projectID = idPrefix + (timeEntry["project_id"] ?? "-default"); 115 | const taskID = idPrefix + (timeEntry["task_id"] ?? (projectID + "-default")); 116 | 117 | /* 118 | { 119 | "user_id": 11238397, 120 | "username": "sandbox_tester2@tyme-app.com", 121 | "project_id": 205181464, 122 | "task_id": null, 123 | "billable": false, 124 | "description": "something else", 125 | "tag_ids": [], 126 | "billable_amount_in_cents": null, 127 | "hourly_rate_in_cents": null, 128 | "currency": "USD", 129 | "time_entries": [ 130 | { 131 | "id": 3587874298, 132 | "seconds": 3600, 133 | "start": "2024-09-02T07:00:00+02:00", 134 | "stop": "2024-09-02T08:00:00+02:00", 135 | "at": "2024-09-02T08:25:48+00:00", 136 | "at_tz": "2024-09-02T10:25:48+02:00" 137 | } 138 | ], 139 | "row_number": 1 140 | } 141 | 142 | */ 143 | 144 | 145 | // make sure the tasks & project exists. if there is none in toggl. create a default one 146 | 147 | let tymeProject = Project.fromID(projectID); 148 | if (!tymeProject) { 149 | tymeProject = Project.create(projectID); 150 | tymeProject.name = "Default"; 151 | tymeProject.color = 0xFF9900; 152 | tymeProject.defaultHourlyRate = defaultHourlyRate; 153 | } 154 | 155 | let tymeTask = TimedTask.fromID(taskID); 156 | if (!tymeTask) { 157 | tymeTask = TimedTask.create(taskID); 158 | tymeTask.name = "Default"; 159 | tymeTask.project = tymeProject; 160 | } 161 | 162 | for (const subTimeEntry of timeEntry["time_entries"]) { 163 | const timeEntryID = idPrefix + subTimeEntry["id"]; 164 | 165 | let tymeEntry = TimeEntry.fromID(timeEntryID) ?? TimeEntry.create(timeEntryID); 166 | tymeEntry.note = timeEntry["description"]; 167 | tymeEntry.timeStart = Date.parse(subTimeEntry["start"]); 168 | tymeEntry.timeEnd = Date.parse(subTimeEntry["stop"]); 169 | tymeEntry.parentTask = TimedTask.fromID(taskID); 170 | 171 | const userID = timeEntry["user_id"]; 172 | const user = this.users[userID]; 173 | const tymeUserID = tyme.userIDForEmail(user["email"]); 174 | 175 | if (tymeUserID) { 176 | tymeEntry.userID = tymeUserID; 177 | } 178 | } 179 | } 180 | } 181 | 182 | getUsers() { 183 | this.users = {}; 184 | 185 | for (const workspace of this.workspaces) { 186 | const id = workspace["id"]; 187 | const [usersResponse, headers] = this.apiClient.request("/api/v9/workspaces/" + id + "/users"); 188 | 189 | if (!usersResponse) { 190 | continue; 191 | } 192 | 193 | usersResponse.forEach(function (entry) { 194 | const id = entry["id"]; 195 | this.users[id] = entry; 196 | }.bind(this)); 197 | } 198 | } 199 | 200 | getWorkspaces() { 201 | const [workspaceResponse, headers] = this.apiClient.request("/api/v9/workspaces"); 202 | if (!workspaceResponse) { 203 | return false; 204 | } 205 | 206 | this.workspaces = workspaceResponse; 207 | return true; 208 | } 209 | 210 | getClients() { 211 | this.clients = {}; 212 | 213 | for (const workspace of this.workspaces) { 214 | const id = workspace["id"]; 215 | const [clientsResponse, headers] = this.apiClient.request("/api/v9/workspaces/" + id + "/clients"); 216 | 217 | if (!clientsResponse) { 218 | continue; 219 | } 220 | 221 | clientsResponse.forEach(function (entry) { 222 | const id = entry["id"]; 223 | this.clients[id] = entry; 224 | }.bind(this)); 225 | } 226 | } 227 | 228 | getProjects() { 229 | this.projects = {}; 230 | 231 | for (const workspace of this.workspaces) { 232 | const id = workspace["id"]; 233 | const path = "/api/v9/workspaces/" + id + "/projects"; 234 | 235 | const [activeProjectsResponse, headers1] = this.apiClient.request(path, {"active": "true"}); 236 | if (activeProjectsResponse) { 237 | activeProjectsResponse.forEach(function (entry) { 238 | const id = entry["id"]; 239 | this.projects[id] = entry; 240 | }.bind(this)); 241 | } 242 | 243 | const [inactiveProjectsResponse, headers2] = this.apiClient.request(path, {"active": "false"}); 244 | 245 | if (inactiveProjectsResponse) { 246 | inactiveProjectsResponse.forEach(function (entry) { 247 | const id = entry["id"]; 248 | this.projects[id] = entry; 249 | }.bind(this)); 250 | } 251 | } 252 | } 253 | 254 | getTasks() { 255 | this.tasks = {}; 256 | 257 | for (const workspace of this.workspaces) { 258 | const id = workspace["id"]; 259 | const path = "/api/v9/workspaces/" + id + "/tasks"; 260 | 261 | const [activeTasksResponse, headers1] = this.apiClient.request(path, {"active": "true"}); 262 | if (activeTasksResponse && Array.isArray(activeTasksResponse.data)) { 263 | activeTasksResponse.data.forEach(function (entry) { 264 | const id = entry["id"]; 265 | this.tasks[id] = entry; 266 | }.bind(this)); 267 | } 268 | 269 | const [inactiveTasksResponse, headers2] = this.apiClient.request(path, {"active": "false"}); 270 | if (inactiveTasksResponse && Array.isArray(inactiveTasksResponse.data)) { 271 | inactiveTasksResponse.data.forEach(function (entry) { 272 | const id = entry["id"]; 273 | this.tasks[id] = entry; 274 | }.bind(this)); 275 | } 276 | } 277 | } 278 | 279 | getTimeEntries() { 280 | this.timeEntries = []; 281 | 282 | for (const workspace of this.workspaces) { 283 | const id = workspace["id"]; 284 | 285 | for (let i = 1; i <= 2; i++) { 286 | 287 | let startDate = new Date(); 288 | startDate.setFullYear(startDate.getFullYear() - i); 289 | let endDate = new Date(); 290 | endDate.setFullYear(endDate.getFullYear() - (i - 1)); 291 | 292 | let finished = false; 293 | let firstRow = null; 294 | 295 | do { 296 | const params = { 297 | "start_date": startDate.toISOString().split('T')[0], 298 | "end_date": endDate.toISOString().split('T')[0], 299 | "user_agent": "tyme_toggl_import", 300 | "first_row_number": firstRow, 301 | "page_size": 50 302 | }; 303 | 304 | const [timeEntriesResponse, headers] = this.apiClient.request( 305 | "/reports/api/v3/workspace/" + id + "/search/time_entries", 306 | params, 307 | "POST" 308 | ); 309 | 310 | firstRow = parseInt(headers["x-next-row-number"]) || null; 311 | 312 | if (timeEntriesResponse && Array.isArray(timeEntriesResponse)) { 313 | this.timeEntries.push(...timeEntriesResponse); 314 | 315 | if (timeEntriesResponse.length === 0 || firstRow == null) { 316 | finished = true; 317 | } 318 | } else { 319 | finished = true; 320 | } 321 | } 322 | while (!finished); 323 | } 324 | } 325 | } 326 | } 327 | 328 | const importer = new TogglImporter(formValue.togglKey); 329 | -------------------------------------------------------------------------------- /TogglImporter/toggl_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/TogglImporter/toggl_icon.png -------------------------------------------------------------------------------- /Tyme.grandtotalplugin/Icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/Tyme.grandtotalplugin/Icon.icns -------------------------------------------------------------------------------- /Tyme.grandtotalplugin/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleIconFile 6 | Icon.icns 7 | CFBundleIdentifier 8 | com.tyme-app.Tyme3.grandtotalimporter 9 | CFBundleVersion 10 | 3.1 11 | Globals 12 | 13 | 14 | label 15 | More about Tyme 16 | type 17 | link 18 | url 19 | https://www.tyme-app.com 20 | 21 | 22 | GlobalsInfo 23 | Info for Plugin 24 | GrandTotalMinimumVersion 25 | 4 26 | callOnDeactivate 27 | 28 | copyright 29 | © 2013-2023 Unit Numberfive oHG 30 | types 31 | 32 | timeimporter 33 | 34 | TimeImporterEntryURLTemplate 35 | tyme://time_entry/%@ 36 | RequiresFullDiskAccess 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /Tyme.grandtotalplugin/README.md: -------------------------------------------------------------------------------- 1 | # GrandTotal Invoices 2 | 3 | The **Invoice Plugin** which lets you create invoices from billable times in Tyme automatically. 4 | 5 | You just have to install their [GrandTotal](https://www.mediaatelier.com) Part. Please refer to the [GrandTotal Plugin Guide](https://www.tyme-app.com/en/grandtotal-plugin) on how to install it. -------------------------------------------------------------------------------- /Tyme.grandtotalplugin/de.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "Info for Plugin" = "Die GrandTotal Unterstützung muss in den Tyme-Einstellungen (Plugins) aktiviert sein.\nKostenlose Einträge werden nicht gelistet."; 2 | "More about Tyme" = "Mehr über Tyme erfahren"; 3 | "Please check Settings" = "Bitte Einstellungen überprüfen"; -------------------------------------------------------------------------------- /Tyme.grandtotalplugin/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "Info for Plugin" = "GrandTotal support needs to be activated in Tyme's Preferences (Plugins).\nEntries with zero cost are not listed."; 2 | "More about Tyme" = "More about Tyme"; 3 | "Please check Settings" = "Please check Settings"; -------------------------------------------------------------------------------- /Tyme.grandtotalplugin/grandtotal_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/Tyme.grandtotalplugin/grandtotal_icon.png -------------------------------------------------------------------------------- /Tyme.grandtotalplugin/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | Keep in mind that you can't use browser specific calls. Use following calls 3 | 4 | --Loading httpContents-- 5 | String loadURL(method,url,headers); 6 | 7 | --Files-- 8 | BOOL fileExists(path); 9 | BOOL fileIsDirectory(path); 10 | String contentsOfFile(path); 11 | String plistToJSON(String); 12 | Array of Strings contentsOfDirectory(path); 13 | 14 | NSHomeDirectory variable 15 | 16 | --Logging to the Console-- 17 | log(value); 18 | 19 | --Base64-- 20 | String base64Encode(string); 21 | 22 | Expected result is a JSON string representation of an array. 23 | 24 | [ 25 | {"startDate":"2015-05-24T17:49:27+02:00","client":"Client A","project":"My Project","minutes":120,"notes":"HTML Coding","user":"me","cost":200,"uid":"com.toggle.233283908"}, 26 | {"startDate":"2015-05-24T16:58:00+02:00","client":"Client B","project":"Other Project","minutes":10,"notes":"Fixing bugs","user":"me","cost":16.666666666666664,"uid":"com.toggle.233275239"} 27 | ] 28 | 29 | Make *sure* the uid you provide is not just a plain integer. Use your domain as prefix. 30 | 31 | Dates must be returned as strings in ISO 8601 format (e.g., 2004-02-12T15:19:21+00:00) 32 | 33 | Returning a string will present it as warning. 34 | 35 | To see how to add global variables (Settings) look at the Info.plist of this sample. 36 | 37 | Keep in mind that for security reasons passwords are stored in the keychain and 38 | you will have to enter them again after modifying your code. 39 | 40 | */ 41 | 42 | timedEntries(); 43 | 44 | function timedEntries() { 45 | var tyme2Path = NSHomeDirectory + "/Library/Containers/de.lgerckens.Tyme2/Data/Library/Application Support/GrandtotalData/"; 46 | var tyme3Path = NSHomeDirectory + "/Library/Containers/com.tyme-app.Tyme3-macOS/Data/Library/Application Support/GrandTotal/data/"; 47 | var tyme3StateURL = NSHomeDirectoryURL + "/Library/Containers/com.tyme-app.Tyme3-macOS/Data/Library/Application%20Support/GrandTotal/state/"; 48 | 49 | // write the billed urls 50 | 51 | try { 52 | var billedUIDs = getBilledUIDs(""); 53 | var billedURL = tyme3StateURL + "billed.plist"; 54 | writeToURL(billedUIDs, billedURL); 55 | } catch (exception) { 56 | log(exception); 57 | } 58 | 59 | try { 60 | var paidUIDs = getPaidUIDs(""); 61 | var paidURL = tyme3StateURL + "paid.plist"; 62 | writeToURL(paidUIDs, paidURL); 63 | } catch (exception) { 64 | log(exception); 65 | } 66 | 67 | var path = tyme2Path 68 | 69 | if (fileExists(tyme3Path)) { 70 | path = tyme3Path 71 | } 72 | 73 | var folderContents = contentsOfDirectory(path); 74 | var str = "["; 75 | 76 | for (var i = 0; i < folderContents.length; i++) { 77 | var content = contentsOfFile(folderContents[i]); 78 | 79 | if (content && content.length > 0) { 80 | content = content.replace(new RegExp('\n', 'g'), '\\n'); 81 | str += content; 82 | 83 | if (i < folderContents.length - 1) { 84 | str += ","; 85 | } 86 | } 87 | } 88 | 89 | str += "]"; 90 | return str; 91 | } 92 | -------------------------------------------------------------------------------- /Tyme.grandtotalplugin/localization.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "plugin.name": "GrandTotal Invoices", 4 | "plugin.summary": "With the GrandTotal plugin, you can create invoices from your tracked hours and keep track of the payment status. The integration with time tracking in Tyme enables seamless integration. This simplifies invoicing and optimizes your billing process." 5 | }, 6 | "de": { 7 | "plugin.name": "GrandTotal Rechnungen", 8 | "plugin.summary": "Mit dem GrandTotal Plugin erstellst du Rechnungen aus deinen erfassten Stunden und behältst den Überblick über den Status der Zahlungen. Die Integration mit Zeiterfassung in Tyme ermöglicht eine nahtlose Integration. Dies vereinfacht die Rechnungserstellung und optimiert deinen Abrechnungsprozess." 9 | } 10 | } -------------------------------------------------------------------------------- /Tyme.grandtotalplugin/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "grandtotal_invoices", 3 | "tymeMinVersion": "2024.11", 4 | "version": "3.3", 5 | "type": "export", 6 | "author": "Unit Numberfive oHG", 7 | "authorUrl": "https://www.tyme-app.com/en/grandtotal-plugin/?hide_nav", 8 | "icon": "grandtotal_icon.png", 9 | "scriptName": "", 10 | "scriptMain": "THE INVOICE FUNCTIONALITY IS ALREADY BUILT IN INTO TYME. THIS PLUGIN IS THE GRANDTOTAL COUNTERPART.", 11 | "scriptPreview": "", 12 | "formName": "", 13 | "localizationName": "localization.json", 14 | "isBuiltIn": true, 15 | "internalType": "plugin_3", 16 | "internalLink": "/partner-grandtotal/" 17 | } 18 | -------------------------------------------------------------------------------- /Zapier/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/Zapier/icon.png -------------------------------------------------------------------------------- /Zapier/localization.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "plugin.name": "Zapier", 4 | "plugin.summary": "Integrate Tyme into your workflows using actions and triggers for Zapier." 5 | }, 6 | "de": { 7 | "plugin.name": "Zapier", 8 | "plugin.summary": "Integriere Tyme in deine Workflows mittels Aktionen und Triggern für Zapier." 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Zapier/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "zapier_plugin", 3 | "tymeMinVersion": "2024.11", 4 | "version": "1.0", 5 | "type": "export", 6 | "author": "Unit Numberfive oHG", 7 | "authorUrl": "https://www.tyme-app.com/en/blog-2024-12-04-zapier/?hide_nav", 8 | "icon": "icon.png", 9 | "scriptName": "", 10 | "scriptMain": "", 11 | "scriptPreview": "", 12 | "formName": "", 13 | "localizationName": "localization.json", 14 | "isBuiltIn": true, 15 | "internalType": "plugin_8", 16 | "internalLink": "https://zapier.com/apps/tyme/integrations" 17 | } 18 | -------------------------------------------------------------------------------- /guides/importing_data.md: -------------------------------------------------------------------------------- 1 | # Importing Data 2 | 3 | To import data into Tyme, you can first use the built-in calls to either fetch the data form a webserver or let the user choose a file from the disk. 4 | 5 | Then you can use the following classes in a plugin script to create the entire project structure, tasks, time entries, rides and expenses. 6 | 7 | > Note that every object in Tyme needs to have its own **unique ID**. 8 | > Duplicated IDs can lead to **unpredictable behavior**! 9 | > 10 | > The **unique ID** is an alphanumeric value. Please add a prefix to avoid possible ID clashes. If you do not provide an ID, Tyme will generate one. So it's safe to call **Category.create()**. 11 | 12 | This example checks, if a category exists, creates one on demand, creates a new project and connects it to the category: 13 | 14 | ```javascript 15 | const id = "prefix_id1"; 16 | let category = Category.fromID(id) ?? Category.create(id); 17 | category.name = "My Category"; 18 | 19 | let project = Project.create("prefix_id2"); 20 | project.name = "My Project"; 21 | project.category = category; 22 | ``` 23 | 24 | ## List of available classes 25 | 26 | ```javascript 27 | class Category { 28 | id // string 29 | name // string 30 | color // numeric value (0x334455) 31 | isCompleted // bool 32 | static create(id) 33 | static fromID(id) 34 | delete() 35 | } 36 | ``` 37 | 38 | ```javascript 39 | class Project { 40 | id // string 41 | name // string 42 | isCompleted // bool 43 | color // numeric value (0x334455) 44 | defaultHourlyRate // float 45 | plannedBudget // float 46 | plannedDuration // int, seconds 47 | trackingMode // int, 0=slot, 1=cluster 48 | startDate // date, optional 49 | dueDate // date, optional 50 | category // the category the project is contained in 51 | static create(id) 52 | static fromID(id) 53 | delete() 54 | } 55 | ``` 56 | 57 | ```javascript 58 | class TimedTask { 59 | id // string 60 | name // string 61 | isCompleted // bool 62 | billable // bool 63 | hourlyRate // float 64 | roundingMethod // int, down=0, nearest=1, up=2 65 | roundingMinutes // int 66 | plannedBudget // float 67 | plannedDuration // int, seconds 68 | startDate // date, optional 69 | dueDate // date, optional 70 | project // the project the task is contained in 71 | static create(id) 72 | static fromID(id) 73 | delete() 74 | } 75 | ``` 76 | 77 | ```javascript 78 | class TimedSubtask { 79 | id // string 80 | name // string 81 | isCompleted // bool 82 | billable // bool 83 | hourlyRate // float 84 | plannedBudget // float 85 | plannedDuration // int, seconds 86 | startDate // date, optional 87 | dueDate // date, optional 88 | parentTask // the task the sub-task is contained in 89 | static create(id) 90 | static fromID(id) 91 | delete() 92 | } 93 | ``` 94 | 95 | ```javascript 96 | class TimeEntry { 97 | id // string 98 | billingState // unbilled=0, billed=1, paid=2 99 | note // string 100 | timeStart // date 101 | timeEnd // date 102 | userID // string, optional 103 | parentTask // the task the entry is contained in 104 | static create(id) 105 | static fromID(id) 106 | delete() 107 | } 108 | ``` 109 | 110 | ```javascript 111 | class MileageTask { 112 | id // string 113 | name // string 114 | isCompleted // bool 115 | billable // bool 116 | kilometerRate // float, kilometers 117 | plannedBudget // float 118 | project // the project the task is contained in 119 | static create(id) 120 | static fromID(id) 121 | delete() 122 | } 123 | ``` 124 | 125 | ```javascript 126 | class MileageEntry { 127 | id // string 128 | billingState // unbilled=0, billed=1, paid=2 129 | traveledDistance // float, kilometers 130 | note // string 131 | timeStart // date 132 | timeEnd // date 133 | userID // string, optional 134 | parentTask // the task the entry is contained in 135 | static create(id) 136 | static fromID(id) 137 | delete() 138 | } 139 | ``` 140 | 141 | ```javascript 142 | class ExpenseGroup { 143 | id // string 144 | name // string 145 | isCompleted // bool 146 | billable // bool 147 | plannedBudget // float 148 | project // the project the group is contained in 149 | static create(id) 150 | static fromID(id) 151 | delete() 152 | } 153 | ``` 154 | 155 | ```javascript 156 | class Expense { 157 | id // string 158 | name // string 159 | note // string 160 | isCompleted // bool 161 | billable // bool 162 | quantity // float 163 | price // float 164 | purchaseDate // date 165 | userID // string, optional 166 | group // the group the expense is contained in 167 | static create(id) 168 | static fromID(id) 169 | delete() 170 | } 171 | ``` 172 | 173 | -------------------------------------------------------------------------------- /guides/plugins_macos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tyme-app/plugins/af3cf25535bf1c8a0328c9b617d6bdf2a9fbe48d/guides/plugins_macos.png -------------------------------------------------------------------------------- /guides/scripting_helpers.md: -------------------------------------------------------------------------------- 1 | The following calls are available in your plugin scripts. 2 | 3 | ### Tyme Specific Calls 4 | 5 | ```javascript 6 | /* 7 | start & end: Date, mandatory 8 | taskIDs: [string] or null 9 | limit: int or null 10 | billingState unbilled = 0, billed = 1, paid = 2 11 | billable: boolean or null 12 | userID: string or null 13 | clusterOption: 0 = none, 1 = cluster by task & day 14 | 15 | returns: A list of time entries with the following properties: 16 | [ 17 | { 18 | "billing" : "UNBILLED", 19 | "category" : "Category Name", 20 | "category_id" : "78DDDB63", 21 | "distance" : 0, 22 | "distance_unit" : "km", 23 | "duration" : 1, 24 | "duration_unit" : "m", 25 | "end" : "2022-05-02T13:40:00+02:00", 26 | "id" : "F3C4DF06", 27 | "note" : "", 28 | "project" : "Project Name", 29 | "project_id" : "6C2EE2B1", 30 | "quantity" : 0, 31 | "rate" : 0, 32 | "rate_unit" : "EUR", 33 | "rounding_method" : "NEAREST", 34 | "rounding_minutes" : 1, 35 | "start" : "2022-05-02T13:39:00+02:00", // ISO 8601 36 | "subtask" : "", 37 | "subtask_id" : "", 38 | "sum" : 0, 39 | "sum_unit" : "EUR", 40 | "task" : "Task Name", 41 | "task_id" : "F8F95C9D", 42 | "type" : "timed", 43 | "user" : "", 44 | "user_id" : "" 45 | } 46 | ] 47 | */ 48 | tyme.timeEntries(start, end, taskIDs, limit, billingState, billable, userID) 49 | 50 | /* 51 | Sets the billing state of given time entries by their ID 52 | timeEntryIDs: uniqueIDs of the time entries to be modified 53 | billingState: unbilled = 0, billed = 1, paid = 2 54 | */ 55 | tyme.setBillingState(timeEntryIDs, billingState) 56 | 57 | // Tries to fetch the userID of a team user by their email 58 | tyme.userIDForEmail(email) 59 | 60 | // Shows an alert 61 | tyme.showAlert(title, message) 62 | 63 | // The currently used currency code 64 | tyme.currencyCode() 65 | 66 | // The currently used currency symbol 67 | tyme.currencySymbol() 68 | 69 | // Opens an URL 70 | tyme.openURL(url) 71 | 72 | // Opens a dialog and asks the user where to save the content to file. 73 | tyme.openSaveDialog(fileName, content) 74 | 75 | /* 76 | Lets the user select a file from disk. 77 | title: The title of the dialog 78 | fileTypes: array. Allowed file extensions, can be empty 79 | resultFunction: function (fileContents) { … }); 80 | */ 81 | tyme.selectFile(title, fileTypes, resultFunction) 82 | 83 | // Saves a value to the local device keychain. Use this method to store secure values 84 | tyme.setSecureValue(key, value) 85 | 86 | // Retrieves a value from the local device keychain 87 | tyme.getSecureValue(key) 88 | 89 | ``` 90 | 91 | ### General Calls 92 | 93 | ```javascript 94 | // Removes a file. File access is restricted to the plugin folder 95 | utils.removeFile(fileName) 96 | 97 | // Checks if a file exists. File access is restricted to the plugin folder 98 | utils.fileExists(fileName) 99 | 100 | // Writes the content to a file. File access is restricted to the plugin folder 101 | utils.writeToFile(fileName, content) 102 | 103 | // Loads the content of the file and returns it. File access is restricted to the plugin folder 104 | utils.contentsOfFile(fileName) 105 | 106 | // base64 encodes a string 107 | utils.base64Encode(string) 108 | 109 | // base64 decodes a string 110 | utils.base64Decode(string) 111 | 112 | // Returns the localized string for a given identifier 113 | utils.localize(string) 114 | 115 | // Logs a value to a debug log 116 | // (Enable it in Tyme > Preferences > Developer > Debug Log) 117 | utils.log(string) 118 | 119 | // Converts a markdown string to HTML 120 | utils.markdownToHTML(markdown) 121 | 122 | /* 123 | Makes a synchronous HTTP request 124 | Returns an object: { "statusCode": 200, "result": string, "headers": Object } 125 | 126 | Standard headers are: 127 | Content-Type: application/json; charset=utf-8 128 | Accept: application/json 129 | These can be overidden using the headers parameter 130 | */ 131 | utils.request(url, method, headers, parameters) 132 | ``` 133 | --------------------------------------------------------------------------------