├── .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 += '\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 += '\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 | 
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 += '\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 |
--------------------------------------------------------------------------------