-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
f8b1515
commit d35fd74
Showing
15 changed files
with
457 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
PRODUCTIVE_API_TOKEN="YOUR_API_TOKEN" | ||
PRODUCTIVE_ORGANISATION_ID="YOUR_ORGANISATION_ID" | ||
PRODUCTIVE_USER_ID="YOUR_USER_ID" | ||
PRODUCTIVE_SERVICE_ID="YOUR_SERVICE_ID" | ||
|
||
# Time Entries Mapping | ||
PRODUCTIVE_ENTRY_DATE="Datum" | ||
PRODUCTIVE_ENTRY_TIME="Faktura\rin Std." | ||
PRODUCTIVE_ENTRY_NOTE="Beschreibung der Tätigkeit" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
18 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
{ | ||
"semi": true, | ||
"trailingComma": "none", | ||
"singleQuote": true, | ||
"printWidth": 80 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,83 @@ | ||
# Productive-Time-Entries-Importer | ||
Import time entries into Productive | ||
# Import time entries into [Productive](https://developer.productive.io/) via PDF, CSF or JSON files | ||
|
||
*Since there are no breaks in productive, the come and go times are not imported by default. Just the total working hours per day are stored.* | ||
|
||
|
||
## 💡 Requirements | ||
- Node.js 18 (works also with 16+, but slower, and experimental hints) | ||
- Git | ||
|
||
|
||
## 🚀 Setup | ||
|
||
- Choose compatible node version `nvm use` | ||
- Install dependencies `npm install` | ||
- Export time entries from third-party systems and save them in the `./time-entries` directory (PDF, CSV or JSON allowed) | ||
- Copy the `.env-sample` as `.env` file and enter your data | ||
- Compile, convert and import data into productive with `npm start` or do it manually step by step | ||
* `npm run compile` | ||
* `npm run convert` | ||
* `npm run import` | ||
|
||
|
||
## 🧞 Commands | ||
|
||
All commands are run from the root of the project, from a terminal: | ||
|
||
| Command | Action | | ||
|:-----------------------|:--------------------------------------------------------------------| | ||
| `npm install` | Installs dependencies | | ||
| `npm start` | Alias for `npm run compile`, `npm run convert` and `npm run import` | | ||
| `npm run compile` | Alias for `npm run format` and transpile typescript source code | | ||
| `npm run format` | Format typescript source code with prettier | | ||
| `npm run convert` | Alias for `npm run convert:csv` and `npm run convert:json` | | ||
| `npm run convert:csv` | Use tabula to recognize data from PDFs and extract them as CSV | | ||
| `npm run convert:json` | Convert csv to json | | ||
| `npm run import` | Import json with Productive V2 API | | ||
|
||
|
||
## 💁 FAQ | ||
|
||
### Where can I find the organization id? | ||
|
||
Login to [productive](https://app.productive.io) and copy the ID from the URL. | ||
|
||
`app.productive.io/`**9314**`-company/dashboards/71632` | ||
|
||
### Where can I find the user id? | ||
|
||
Login to [productive](https://app.productive.io), go to your account page and copy the ID from the URL. | ||
|
||
`app.productive.io/9314-company/people/`**438083**`/overview` | ||
|
||
### Where can I find the service id? | ||
|
||
Login to [productive](https://app.productive.io), click on the blue **Add** button "Time entry", choose project, service, open DevTools Network tab in Google Chrome and click on save. | ||
|
||
Look for the POST Request `https://api.productive.io/api/v2/time_entries` and open the response `relationships.service.data.id` | ||
|
||
### How to generate authorization token for API? | ||
|
||
Login to [productive](https://app.productive.io), go to [api integration page](https://app.productive.io/[YOUR_ORGANISATION_ID]/settings/api-integrations) and generate a Personal Access API Token with Read / Write Permissions. | ||
|
||
### Your PDF to CSV Task is not working? | ||
|
||
In principle, this can have many causes. Sometimes the PDF version is outdated, encrypted, or the PDF is an unsupported XFA-PDF. | ||
|
||
Firefox PDF Reader can open these PDF files. The "print and save as PDF" trick can be used to save outdated or broken PDF as compatible PDF. | ||
|
||
Otherwise, the [PDF reader](https://mozilla.github.io/pdf.js/web/viewer.html) can also be used directly as an online tool in Google Chrome. | ||
|
||
### Your CSV to JSON Task is not working? | ||
|
||
Below is an example of a compatible CSV file: | ||
|
||
``` | ||
Datum,"Tätigkeit | ||
von","Tätigkeit | ||
bis","Pause | ||
in Std.","Faktura | ||
in Std.",KM,Beschreibung der Tätigkeit | ||
02.05.2023,09:30:00,18:30:00,"0,50","8,50",0,ABC-2021 Pact-Broker / Bearer Authentication | ||
03.05.2023,08:30:00,17:00:00,"0,50","8,00",0,"Pact-Broker-Test, ENV AWS Secret Manager" | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
{ | ||
"name": "productive-time-entries-importer", | ||
"description": "Import time entries into productive", | ||
"version": "1.0.0", | ||
"scripts": { | ||
"start": "npm run compile && npm run convert && npm run import", | ||
"compile": "npm run format && tsc --rootDir src", | ||
"format": "prettier --config .prettierrc 'src/**/*.ts' --write", | ||
"convert": "npm run convert:csv && npm run convert:json", | ||
"convert:csv": "java -jar tabula-1.0.5-jar-with-dependencies.jar --lattice -format CSV --batch time-entries", | ||
"convert:json": "node build/csvtojson", | ||
"import": "node build/importTimeEntries.js" | ||
}, | ||
"devDependencies": { | ||
"@types/luxon": "^3.3.0", | ||
"@types/node": "^20.3.3", | ||
"axios": "^1.4.0", | ||
"csvtojson": "^2.0.10", | ||
"dotenv": "^16.3.1", | ||
"luxon": "^3.3.0", | ||
"prettier": "^3.0.0", | ||
"typescript": "^5.1.6" | ||
}, | ||
"author": "[email protected]", | ||
"license": "UNLICENSED" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
import fs from 'fs'; | ||
import csv from 'csvtojson'; | ||
import directoryPath from './getTimeEntriesDirectory'; | ||
|
||
fs.readdir(directoryPath, (err?: unknown, files?: string[]) => { | ||
if (err || !files) { | ||
return console.log('Unable to scan directory: ' + err); | ||
} | ||
|
||
const csvFiles: string[] = files.filter((file: string) => | ||
file.includes('.csv') | ||
); | ||
|
||
if (csvFiles.length === 0) { | ||
return console.log( | ||
'No csv could be found in the time-entries directory. Please run *npm run convert* first.' | ||
); | ||
} | ||
|
||
csvFiles.map(async (file: string) => { | ||
const csvFilePath = directoryPath + '/' + file; | ||
const fileName = file.split('.csv')[0]; | ||
const jsonObj = await csv().fromFile(csvFilePath); | ||
const jsonFile = directoryPath + '/' + fileName + '.json'; | ||
|
||
fs.writeFile(jsonFile, JSON.stringify(jsonObj), function (err?: unknown) { | ||
if (err) throw err; | ||
console.log('File is created successfully.'); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
interface ProductiveEnv extends NodeJS.ProcessEnv { | ||
readonly PRODUCTIVE_API_TOKEN: string; | ||
readonly PRODUCTIVE_ORGANISATION_ID: string; | ||
readonly PRODUCTIVE_USER_ID: string; | ||
readonly PRODUCTIVE_SERVICE_ID: string; | ||
readonly PRODUCTIVE_ENTRY_DATE: string | undefined; | ||
readonly PRODUCTIVE_ENTRY_TIME: string | undefined; | ||
readonly PRODUCTIVE_ENTRY_NOTE: string | undefined; | ||
} | ||
|
||
export interface TypedProcess extends NodeJS.Process { | ||
readonly env: ProductiveEnv; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import path from 'path'; | ||
|
||
export default path.join(__dirname, '../time-entries'); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export default (time: string) => +time.replace(',', '.') * 60; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
import 'dotenv/config'; | ||
import fs from 'fs'; | ||
import directoryPath from './getTimeEntriesDirectory'; | ||
import { DateTime } from 'luxon'; | ||
import { TypedProcess } from './env'; | ||
import postData from './postData'; | ||
|
||
const { PRODUCTIVE_ENTRY_DATE, PRODUCTIVE_ENTRY_TIME, PRODUCTIVE_ENTRY_NOTE } = | ||
(process as TypedProcess).env; | ||
|
||
type TypedTimeEntry = Record<string, string>; | ||
|
||
const importTimeEntries = (arr: TypedTimeEntry[]) => { | ||
const entriesWithDate = arr.filter( | ||
(el: TypedTimeEntry) => | ||
el['Datum'] !== undefined && typeof el['Datum'] === 'string' | ||
); | ||
|
||
entriesWithDate.map((el: TypedTimeEntry) => { | ||
const time: string | undefined = PRODUCTIVE_ENTRY_TIME | ||
? el[PRODUCTIVE_ENTRY_TIME] | ||
: undefined; | ||
const date: string | undefined = PRODUCTIVE_ENTRY_DATE | ||
? el[PRODUCTIVE_ENTRY_DATE] | ||
: undefined; | ||
const note: string | undefined = PRODUCTIVE_ENTRY_NOTE | ||
? el[PRODUCTIVE_ENTRY_NOTE] | ||
: undefined; | ||
|
||
let parsedDate: DateTime | undefined; | ||
|
||
if (date !== undefined) { | ||
const fromISO = DateTime.fromISO(date); | ||
|
||
if (fromISO.isValid) { | ||
parsedDate = fromISO; | ||
} else { | ||
// fallback dateFormat dd.MM.yyyy | ||
const fromFormat = DateTime.fromFormat(date, 'dd.MM.yyyy'); | ||
if (fromFormat.isValid) { | ||
parsedDate = fromFormat; | ||
} | ||
} | ||
} | ||
|
||
if (time === undefined) { | ||
return; | ||
} | ||
|
||
if (parsedDate === undefined) { | ||
return; | ||
} | ||
|
||
if (!parsedDate.isValid) { | ||
return; | ||
} | ||
|
||
const postDate = parsedDate.toISODate(); | ||
|
||
if (postDate === null) { | ||
return; | ||
} | ||
|
||
return postData(postDate, time, note); | ||
}); | ||
}; | ||
|
||
fs.readdir(directoryPath, (err?: unknown, files?: string[]) => { | ||
if (err || !files) { | ||
return console.log('Error - Unable to scan directory: ' + err); | ||
} | ||
|
||
const jsonFiles: string[] = files.filter((file: string) => | ||
file.includes('.json') | ||
); | ||
|
||
if (jsonFiles.length === 0) { | ||
return console.log( | ||
'No json could be found in the time-entries directory. Please run npm run convert first.' | ||
); | ||
} | ||
|
||
jsonFiles.map((file) => { | ||
const jsonFilePath: string = directoryPath + '/' + file; | ||
|
||
try { | ||
const data: string = fs.readFileSync(jsonFilePath, 'utf8'); | ||
return importTimeEntries(JSON.parse(data)); | ||
} catch (err) { | ||
return console.log('Error - Unable to read file: ' + err); | ||
} | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
import { TypedProcess } from './env'; | ||
import axios, { AxiosInstance } from 'axios'; | ||
import getTimeInSeconds from './getTimeInSeconds'; | ||
|
||
const { | ||
PRODUCTIVE_API_TOKEN: token, | ||
PRODUCTIVE_ORGANISATION_ID: organizationId, | ||
PRODUCTIVE_USER_ID: personId, | ||
PRODUCTIVE_SERVICE_ID: serviceId | ||
} = (process as TypedProcess).env; | ||
|
||
const axiosInstance: AxiosInstance = axios.create({ | ||
baseURL: 'https://api.productive.io/api/v2', | ||
timeout: 5000, | ||
headers: { | ||
'X-Auth-Token': `${token}`, | ||
'X-Organization-Id': `${organizationId}`, | ||
'Content-Type': 'application/vnd.api+json', | ||
Accept: | ||
'text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5' | ||
} | ||
}); | ||
|
||
const createData = (date: string, time: string, note: string | undefined) => { | ||
return { | ||
data: { | ||
type: 'time_entries', | ||
attributes: { | ||
date, | ||
...(note ? { note } : {}), | ||
time: getTimeInSeconds(time) | ||
}, | ||
relationships: { | ||
person: { | ||
data: { | ||
type: 'people', | ||
id: personId | ||
} | ||
}, | ||
service: { | ||
data: { | ||
type: 'services', | ||
id: serviceId | ||
} | ||
} | ||
} | ||
} | ||
}; | ||
}; | ||
|
||
export default async (date: string, time: string, note: string | undefined) => { | ||
const data = createData(date, time, note); | ||
|
||
try { | ||
const { | ||
data: { | ||
data: { id: timeEntryId } | ||
} | ||
} = await axiosInstance.post('/time_entries', data); | ||
console.log(`time-entry is created with id: ${timeEntryId}`); | ||
} catch (err: unknown) { | ||
console.log(err); | ||
} | ||
}; |
Binary file not shown.
Empty file.
Oops, something went wrong.