Skip to content

Commit

Permalink
Add stable release
Browse files Browse the repository at this point in the history
  • Loading branch information
artursopelnik committed Oct 31, 2023
1 parent f8b1515 commit d35fd74
Show file tree
Hide file tree
Showing 15 changed files with 457 additions and 2 deletions.
9 changes: 9 additions & 0 deletions .env-sample
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"
18 changes: 18 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
# Ignore files inside time-entries
/time-entries/*.pdf
/time-entries/*.csv
/time-entries/*.json

# Ignore build files
/build

# Ignore package lock
package-lock.json
yarn.lock

# Node Modules
node_modules

# IntelliJ IDEA
.idea

# Logs
logs
*.log
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
18
6 changes: 6 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"semi": true,
"trailingComma": "none",
"singleQuote": true,
"printWidth": 80
}
85 changes: 83 additions & 2 deletions README.md
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"
```
26 changes: 26 additions & 0 deletions package.json
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"
}
31 changes: 31 additions & 0 deletions src/csvtojson.ts
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.');
});
});
});
13 changes: 13 additions & 0 deletions src/env.d.ts
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;
}
3 changes: 3 additions & 0 deletions src/getTimeEntriesDirectory.ts
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');
1 change: 1 addition & 0 deletions src/getTimeInSeconds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default (time: string) => +time.replace(',', '.') * 60;
93 changes: 93 additions & 0 deletions src/importTimeEntries.ts
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);
}
});
});
64 changes: 64 additions & 0 deletions src/postData.ts
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 added tabula-1.0.5-jar-with-dependencies.jar
Binary file not shown.
Empty file added time-entries/.gitkeep
Empty file.
Loading

0 comments on commit d35fd74

Please sign in to comment.