Compare commits

...

10 commits

Author SHA1 Message Date
Pavel Gnedov
8ced9107f5 v0.2.3 2023-11-09 14:38:57 +07:00
Pavel Gnedov
4d880c01d0 Added field user_id verification 2023-11-09 12:45:39 +07:00
Pavel Gnedov
9870b874a7 v0.2.2 2023-11-09 08:08:23 +07:00
Pavel Gnedov
9fe3a4b24c Fixed home page url 2023-11-09 08:07:05 +07:00
Pavel Gnedov
5454f7cb7d v0.2.1 2023-11-09 08:01:46 +07:00
Pavel Gnedov
46ef40ed7d Добавлена обработка ошибок 2023-11-09 07:55:08 +07:00
Pavel Gnedov
fce544b386 Readme updated 2022-02-14 02:03:28 +07:00
Pavel Gnedov
6c672cb45d Sleep added between redmine api calls 2022-02-14 01:52:04 +07:00
Pavel Gnedov
623b62e6ff dev 2022-02-07 00:46:07 +07:00
Pavel Gnedov
ca34058fcf dev 2022-02-07 00:04:53 +07:00
10 changed files with 878 additions and 663 deletions

View file

@ -7,5 +7,8 @@ charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
[src/logic/*]
indent_size = 4
[*.md] [*.md]
trim_trailing_whitespace = false trim_trailing_whitespace = false

View file

@ -2,5 +2,35 @@
"extends": [ "extends": [
"oclif", "oclif",
"oclif-typescript" "oclif-typescript"
] ],
"rules": {
"quotes": "off",
"indent": "off",
"semi": "off",
"unicorn/prefer-node-protocol": "off",
"unicorn/prefer-ternary": "off",
"dot-notation": "off",
"padding-line-between-statements": "off",
"camelcase": "off",
"unicorn/no-console-spaces": "off",
"new-cap": "off",
"no-await-in-loop": "off",
"unicorn/no-for-loop": "off",
"comma-dangle": "off",
"node/no-missing-import": "off",
"no-else-return": "off",
"quote-props": "off",
"no-inner-declarations": "off",
"no-process-exit": "off",
"unicorn/no-process-exit": "off",
"eol-last": "off",
"no-prototype-builtins": "off",
"padded-blocks": "off",
"unicorn/prefer-number-properties": "off",
"operator-assignment": "off",
"no-negated-condition": "off",
"unicorn/catch-error-name": "off",
"@typescript-eslint/ban-ts-comment": "off",
"unicorn/prefer-includes": "off"
}
} }

359
README.md
View file

@ -1,26 +1,148 @@
oclif-hello-world Redmine Time Manager
================= ====================
oclif example Hello World CLI Cli tool for redmine time managment
[![oclif](https://img.shields.io/badge/cli-oclif-brightgreen.svg)](https://oclif.io) Main idea:
[![Version](https://img.shields.io/npm/v/oclif-hello-world.svg)](https://npmjs.org/package/oclif-hello-world)
[![CircleCI](https://circleci.com/gh/oclif/hello-world/tree/main.svg?style=shield)](https://circleci.com/gh/oclif/hello-world/tree/main) * You prepare configuration for transformation your csv for redmine time entries api format only once
[![Downloads/week](https://img.shields.io/npm/dw/oclif-hello-world.svg)](https://npmjs.org/package/oclif-hello-world) * Regularly call this script for transfering your time entries and saving it in redmine
[![License](https://img.shields.io/npm/l/oclif-hello-world.svg)](https://github.com/oclif/hello-world/blob/main/package.json)
Examples
========
Transformation from Microsoft Excel or LibreOffice Calc or GoogleDocs Spreadsheets:
Your configuration (`config.json`) may be as:
```json
{
"csv": {
"delimiter": "\t",
"columns": true,
"encoding": "utf8",
"quote": false
},
"rules": {
"columns": {
"issue": "Issue",
"activity": "Action",
"comment": "Description",
"time": "Time"
}
},
"time_type": "time",
"date_source": "argument",
"date_format": "yyyy-MM-dd",
"issue_regexp": "\\d+",
"redmine": {
"url": "http://token@redmine.example.org",
"user_id": 123,
"default_issue": 456,
"activities": {
"Code": 1,
"Code Review": 2,
"Test": 3
},
"default_activity_code": 1
}
}
```
Next for GNU/Linux with Xorg, you can select table, tap CTRL+C for coping in system clipboard, and call command:
```bash
xsel -o | redmine-time-manager save --config=config.json --date=2022-02-01 --rewrite
```
Or for other system you can save into csv file (for example `my-daily-time.csv`), and call command:
```bash
redmine-time-manager save --config=config.json --from-file=my-daily-time.csv --date=2022-02-01
```
Transformation from [Hamster Time Tracker](https://github.com/projecthamster/hamster):
Your configuration (`config.json`) may be as:
```json
{
"csv": {
"delimiter": "\t",
"columns": true,
"encoding": "utf8",
"quote": false
},
"rules": {
"columns": {
"issue": "занятие",
"activity": "метки",
"comment": "описание",
"time": "длительность в минутах",
"date": "время начала",
"category": "категория"
}
},
"time_type": "minutes",
"date_source": "column",
"date_format": "yyyy-MM-dd HH:mm",
"issue_regexp": "\\d+",
"query": "select * from ? where category = 'Work'",
"redmine": {
"url": "http://token@redmine.example.org",
"user_id": 123,
"default_issue": 456,
"activities": {
"Code": 1,
"Code Review": 2,
"Test": 3
},
"default_activity_code": 1
}
}
```
Hamster Time Tracker supported export reports to tsv format (csv by tab delimiters), and you can call command:
```bash
redmine-time-manager save --config=config.json --from-file=my-daily-time.tsv
```
Description of config params
============================
* `csv` - for this section see https://csv.js.org/parse/options/
* `rules.columns` - mapping rules for column names, must defined aliases for time, issue, activity, date, comment
* `time_type` - time format, may be "hours"/"minutes"/"time"
* `date_source` - source of date, may be "column"/"argument"
* `issue_regexp` - regexp of issue number, mostly - `"\\d+"`
* `date_format` - format of date, for example `yyyy-MM-dd`
* `redmine` - params for redmine
* `url`
* `user_id` - your user id
* `default_issue` - default issue number
* `activities` - mapping rules of activity_id aliases in your Redmine instance
Additional:
* `query` - filter of date like sql syntax \
for example: `select * from ? where category = 'Work'`
# TOC
<!-- toc --> <!-- toc -->
* [TOC](#toc)
* [Usage](#usage) * [Usage](#usage)
* [Commands](#commands) * [Commands](#commands)
<!-- tocstop --> <!-- tocstop -->
# Usage # Usage
<!-- usage --> <!-- usage -->
```sh-session ```sh-session
$ npm install -g redmine-time-manager-2 $ npm install -g redmine-time-manager
$ redmine-time-manager COMMAND $ redmine-time-manager COMMAND
running command... running command...
$ redmine-time-manager (--version) $ redmine-time-manager (--version)
redmine-time-manager-2/0.0.0 linux-x64 node-v16.13.1 redmine-time-manager/0.2.3 linux-x64 node-v21.0.0
$ redmine-time-manager --help [COMMAND] $ redmine-time-manager --help [COMMAND]
USAGE USAGE
$ redmine-time-manager COMMAND $ redmine-time-manager COMMAND
@ -29,56 +151,8 @@ USAGE
<!-- usagestop --> <!-- usagestop -->
# Commands # Commands
<!-- commands --> <!-- commands -->
* [`redmine-time-manager hello PERSON`](#redmine-time-manager-hello-person)
* [`redmine-time-manager hello world`](#redmine-time-manager-hello-world)
* [`redmine-time-manager help [COMMAND]`](#redmine-time-manager-help-command) * [`redmine-time-manager help [COMMAND]`](#redmine-time-manager-help-command)
* [`redmine-time-manager plugins`](#redmine-time-manager-plugins) * [`redmine-time-manager save`](#redmine-time-manager-save)
* [`redmine-time-manager plugins:inspect PLUGIN...`](#redmine-time-manager-pluginsinspect-plugin)
* [`redmine-time-manager plugins:install PLUGIN...`](#redmine-time-manager-pluginsinstall-plugin)
* [`redmine-time-manager plugins:link PLUGIN`](#redmine-time-manager-pluginslink-plugin)
* [`redmine-time-manager plugins:uninstall PLUGIN...`](#redmine-time-manager-pluginsuninstall-plugin)
* [`redmine-time-manager plugins update`](#redmine-time-manager-plugins-update)
* [`redmine-time-manager save [FILE]`](#redmine-time-manager-save-file)
## `redmine-time-manager hello PERSON`
Say hello
```
USAGE
$ redmine-time-manager hello [PERSON] -f <value>
ARGUMENTS
PERSON Person to say hello to
FLAGS
-f, --from=<value> (required) Whom is saying hello
DESCRIPTION
Say hello
EXAMPLES
$ oex hello friend --from oclif
hello friend from oclif! (./src/commands/hello/index.ts)
```
_See code: [dist/commands/hello/index.ts](https://github.com/pavel-g/redmine-time-manager-cli/blob/v0.0.0/dist/commands/hello/index.ts)_
## `redmine-time-manager hello world`
Say hello world
```
USAGE
$ redmine-time-manager hello world
DESCRIPTION
Say hello world
EXAMPLES
$ oex hello world
hello world! (./src/commands/hello/world.ts)
```
## `redmine-time-manager help [COMMAND]` ## `redmine-time-manager help [COMMAND]`
@ -100,170 +174,29 @@ DESCRIPTION
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v5.1.10/src/commands/help.ts)_ _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v5.1.10/src/commands/help.ts)_
## `redmine-time-manager plugins` ## `redmine-time-manager save`
List installed plugins. Save time entries to redmine. Full documentation in README.
``` ```
USAGE USAGE
$ redmine-time-manager plugins [--core] $ redmine-time-manager save -c <value> [--from-file <value>] [--date <value>] [--dry] [--rewrite]
FLAGS FLAGS
--core Show core plugins. -c, --config=<value> (required) [default: redmine-time-manager-config.json] Json config
--date=<value> Date. Must defined when config.date_source = "argument"
--dry For testing calls. Delete and write operations will be disabled.
--from-file=<value> Csv file. If undefined, will use stdin
--rewrite Redmine data at choosed date will be rewrited.
DESCRIPTION DESCRIPTION
List installed plugins. Save time entries to redmine. Full documentation in README.
EXAMPLES EXAMPLES
$ redmine-time-manager plugins $ redmine-time-manager save --config=config.json --date=2000-01-01 --rewrite --from-file=my-work-time.csv # reading from csv file
$ xsel -o | redmine-time-manager save --config=config.json --date=2000-01-01 --rewrite # reading csv from stdin from Xorg clipboard
``` ```
_See code: [@oclif/plugin-plugins](https://github.com/oclif/plugin-plugins/blob/v2.0.11/src/commands/plugins/index.ts)_ _See code: [dist/commands/save.ts](https://github.com/pavel-g/redmine-time-manager/blob/v0.2.3/dist/commands/save.ts)_
## `redmine-time-manager plugins:inspect PLUGIN...`
Displays installation properties of a plugin.
```
USAGE
$ redmine-time-manager plugins:inspect PLUGIN...
ARGUMENTS
PLUGIN [default: .] Plugin to inspect.
FLAGS
-h, --help Show CLI help.
-v, --verbose
DESCRIPTION
Displays installation properties of a plugin.
EXAMPLES
$ redmine-time-manager plugins:inspect myplugin
```
## `redmine-time-manager plugins:install PLUGIN...`
Installs a plugin into the CLI.
```
USAGE
$ redmine-time-manager plugins:install PLUGIN...
ARGUMENTS
PLUGIN Plugin to install.
FLAGS
-f, --force Run yarn install with force flag.
-h, --help Show CLI help.
-v, --verbose
DESCRIPTION
Installs a plugin into the CLI.
Can be installed from npm or a git url.
Installation of a user-installed plugin will override a core plugin.
e.g. If you have a core plugin that has a 'hello' command, installing a user-installed plugin with a 'hello' command
will override the core plugin implementation. This is useful if a user needs to update core plugin functionality in
the CLI without the need to patch and update the whole CLI.
ALIASES
$ redmine-time-manager plugins add
EXAMPLES
$ redmine-time-manager plugins:install myplugin
$ redmine-time-manager plugins:install https://github.com/someuser/someplugin
$ redmine-time-manager plugins:install someuser/someplugin
```
## `redmine-time-manager plugins:link PLUGIN`
Links a plugin into the CLI for development.
```
USAGE
$ redmine-time-manager plugins:link PLUGIN
ARGUMENTS
PATH [default: .] path to plugin
FLAGS
-h, --help Show CLI help.
-v, --verbose
DESCRIPTION
Links a plugin into the CLI for development.
Installation of a linked plugin will override a user-installed or core plugin.
e.g. If you have a user-installed or core plugin that has a 'hello' command, installing a linked plugin with a 'hello'
command will override the user-installed or core plugin implementation. This is useful for development work.
EXAMPLES
$ redmine-time-manager plugins:link myplugin
```
## `redmine-time-manager plugins:uninstall PLUGIN...`
Removes a plugin from the CLI.
```
USAGE
$ redmine-time-manager plugins:uninstall PLUGIN...
ARGUMENTS
PLUGIN plugin to uninstall
FLAGS
-h, --help Show CLI help.
-v, --verbose
DESCRIPTION
Removes a plugin from the CLI.
ALIASES
$ redmine-time-manager plugins unlink
$ redmine-time-manager plugins remove
```
## `redmine-time-manager plugins update`
Update installed plugins.
```
USAGE
$ redmine-time-manager plugins update [-h] [-v]
FLAGS
-h, --help Show CLI help.
-v, --verbose
DESCRIPTION
Update installed plugins.
```
## `redmine-time-manager save [FILE]`
Save time entries to redmine
```
USAGE
$ redmine-time-manager save [FILE] [-n <value>] [-f]
FLAGS
-f, --force
-n, --name=<value> name to print
DESCRIPTION
Save time entries to redmine
EXAMPLES
$ redmine-time-manager save
```
_See code: [dist/commands/save.ts](https://github.com/pavel-g/redmine-time-manager-cli/blob/v0.0.0/dist/commands/save.ts)_
<!-- commandsstop --> <!-- commandsstop -->

View file

@ -1,15 +1,15 @@
{ {
"name": "redmine-time-manager-2", "name": "redmine-time-manager",
"version": "0.1.0", "version": "0.2.3",
"description": "Redmine Time Manager", "description": "Redmine Time Manager",
"author": "Pavel Gnedov @pavel-g", "author": "Pavel Gnedov @pavel-g",
"bin": { "bin": {
"redmine-time-manager": "./bin/run" "redmine-time-manager": "./bin/run"
}, },
"homepage": "https://github.com/pavel-g/redmine-time-manager-cli", "homepage": "https://github.com/pavel-g/redmine-time-manager",
"license": "MIT", "license": "MIT",
"main": "dist/index.js", "main": "dist/index.js",
"repository": "pavel-g/redmine-time-manager-cli", "repository": "pavel-g/redmine-time-manager",
"files": [ "files": [
"/bin", "/bin",
"/dist", "/dist",
@ -64,7 +64,7 @@
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
}, },
"bugs": "https://github.com/pavel-g/redmine-time-manager-cli/issues", "bugs": "https://github.com/pavel-g/redmine-time-manager/issues",
"keywords": [ "keywords": [
"oclif" "oclif"
], ],

View file

@ -40,7 +40,7 @@ export const defaultConfig: ConfigTypes.Config = {
} }
}; };
export function getConfig(force: boolean = false): ConfigTypes.Config { export function getConfig(force = false): ConfigTypes.Config {
if (!force && !Args.args['config']) { if (!force && !Args.args['config']) {
return defaultConfig; return defaultConfig;
} }

View file

@ -1,16 +1,21 @@
import {config} from "./config"; import {config} from "./config";
import axios from "axios"; import axios from "axios";
export async function loadEntries(date: string): Promise<Record<string, any>> { type TimeEntry = Record<string, any>;
type TimeEntriesResponse = {time_entries: TimeEntry[]};
export async function loadEntries(date: string): Promise<TimeEntry[]> {
const url = `${config.redmine.url}/time_entries.json`; const url = `${config.redmine.url}/time_entries.json`;
const params = { const params = {
user_id: config.redmine.user_id, user_id: config.redmine.user_id,
from: date, from: date,
to: date to: date
}; };
const resp = await axios.get<Record<string, any>>(url, {params: params}); const resp = await axios.get<TimeEntriesResponse>(url, {params: params});
if (!resp || resp.status !== 200 || !resp.data || !resp.data.time_entries) { if (!resp || resp.status !== 200 || !resp.data || !resp.data.time_entries) {
throw new Error('Не удалось загрузить записи за дату'); throw new Error('Не удалось загрузить записи за дату');
} }
return resp.data.time_entries; return resp.data.time_entries.filter((entry) => {
return entry.user.id === config.redmine.user_id;
});
} }

View file

@ -5,15 +5,17 @@ import {config} from "./config";
import axios from "axios"; import axios from "axios";
import {loadEntries} from "./open"; import {loadEntries} from "./open";
import {ColumnConverter, filterByQuery, getDate, getItemForRedmine, TimeEntryForRedmine} from "./csv"; import {ColumnConverter, filterByQuery, getDate, getItemForRedmine, TimeEntryForRedmine} from "./csv";
import {uniq} from "./utils"; import {sleep, uniq} from './utils';
import GetStdin from '../utils/get-stdin';
function readContentFromFile(fileName: string): string { function readContentFromFile(fileName: string): string {
return fs.readFileSync(fileName, {encoding: 'utf8'}); return fs.readFileSync(fileName, {encoding: 'utf8'});
} }
async function readContentFromStdin(): Promise<string> { async function readContentFromStdin(): Promise<string> {
const GetStdin = await import('get-stdin'); // const GetStdin = await import('get-stdin');
return await GetStdin.default(); // const GetStdin = await import('get-stdin');
return await GetStdin();
} }
async function readContent(): Promise<string> { async function readContent(): Promise<string> {
@ -26,32 +28,47 @@ async function readContent(): Promise<string> {
return content; return content;
} }
function backupEntries(entries: Record<string, any>): void { function backupEntries(entries: Record<string, any>[]): void {
const fileName = `entries-${args['date']}.json`; const fileName = `entries-${args['date']}.json`;
const content = JSON.stringify(entries); const content = JSON.stringify(entries);
fs.writeFileSync(fileName, content, {encoding: "utf8"}); fs.writeFileSync(fileName, content, {encoding: "utf8"});
} }
async function deleteEntries(entries: Record<string, any>): Promise<void> { async function deleteEntries(entries: Record<string, any>[], userId: any): Promise<void> {
await Promise.all( let i: number;
entries.map(async (entry: Record<string, any>) => { for (i = 0; i < entries.length; i++) {
const entry = entries[i];
if (entry?.user?.id != userId) {
continue;
}
const url = `${config.redmine.url}/time_entries/${entry.id}.xml`; const url = `${config.redmine.url}/time_entries/${entry.id}.xml`;
if (args['dry']) { if (args['dry']) {
console.log('Delete time entry:', {url, entry}); console.log('Delete time entry:', {url, entry});
} else { } else {
await axios.delete(url); await axios.delete(url);
} }
}) await sleep(100);
); }
} }
async function cleanTimeEntries(items: TimeEntryForRedmine[]): Promise<void> { async function cleanTimeEntries(items: TimeEntryForRedmine[], userId: any): Promise<void> {
const dates = getUniqDates(items); const dates = getUniqDates(items);
for (let i = 0; i < dates.length; i++) { for (let i = 0; i < dates.length; i++) {
const date = dates[i]; const date = dates[i];
const entries = await loadEntries(date); let entries;
try {
entries = await loadEntries(date);
} catch (ex) {
console.error(`Ошибка при поиске существующих записей на ${date}`, ex);
return;
}
backupEntries(entries); backupEntries(entries);
await deleteEntries(entries); try {
await deleteEntries(entries, userId);
} catch (ex) {
console.error(`Ошибка при удалении записей на ${date}`, ex);
return;
}
} }
} }
@ -72,7 +89,12 @@ async function saveItem(item: TimeEntryForRedmine): Promise<boolean> {
if (args['dry']) { if (args['dry']) {
console.log('Save time entry:', {url, data, params: params}); console.log('Save time entry:', {url, data, params: params});
} else { } else {
const resp = await axios.post(url, data, {params: params}); let resp;
try {
resp = await axios.post(url, data, {params: params});
} catch (ex) {
console.error('Ошибка при сохранении', {url: url, data: data, resp: resp}); // DEBUG
}
if (!resp || !resp.data) { if (!resp || !resp.data) {
console.error(`Не удалось сохранить в redmine запись `, item); console.error(`Не удалось сохранить в redmine запись `, item);
return false; return false;
@ -91,6 +113,7 @@ async function saveCsv(csvData: TimeEntryForRedmine[]): Promise<void> {
} }
const saveResult = await saveItem(item); const saveResult = await saveItem(item);
if (saveResult) successCount++; if (saveResult) successCount++;
await sleep(100);
} }
console.log(`Сохранено записей: ${successCount}`); console.log(`Сохранено записей: ${successCount}`);
} }
@ -113,8 +136,20 @@ export async function save(): Promise<void> {
}) })
.map(item => item.item) as TimeEntryForRedmine[]; .map(item => item.item) as TimeEntryForRedmine[];
if (args['rewrite']) await cleanTimeEntries(items); if (args['rewrite']) {
console.log('Очистка существующих записей...')
const userId = config.redmine.user_id;
if (!userId) {
console.error('В конфигурационном файле не указан user_id');
process.exit(1);
return;
}
await cleanTimeEntries(items, userId);
console.log('Очистка существующих записей завершена')
}
console.log('Сохранение новых записей...');
await saveCsv(items); await saveCsv(items);
console.log('Сохранение новых записей завершено');
} }
export function getUniqDates(items: TimeEntryForRedmine[]): string[] { export function getUniqDates(items: TimeEntryForRedmine[]): string[] {

View file

@ -1,34 +1,40 @@
export function invert(obj: Record<any, any>): Record<any, any> { export function invert(obj: Record<any, any>): Record<any, any> {
const newObj = {}; const newObj = {}
let key; let key
for (key in obj) { for (key in obj) {
if (!obj.hasOwnProperty(key)) continue; if (!obj.hasOwnProperty(key)) continue
const value = obj[key]; const value = obj[key]
// @ts-ignore // @ts-ignore
newObj[value] = key; newObj[value] = key
} }
return newObj; return newObj
} }
export function uniq<T>(src: T[]): T[] { export function uniq<T>(src: T[]): T[] {
const res = []; const res = []
let i: number; let i: number
for (i = 0; i < src.length; i++) { for (i = 0; i < src.length; i++) {
const value = src[i]; const value = src[i]
if (res.indexOf(value) < 0) { if (res.indexOf(value) < 0) {
res.push(value); res.push(value)
} }
} }
return res; return res
} }
export function assign(target: Record<string, any>, src: Record<string, any>): Record<string, any> { export function assign(target: Record<string, any>, src: Record<string, any>): Record<string, any> {
let key: string; let key: string
for (key in src) { for (key in src) {
if (src.hasOwnProperty(key)) { if (src.hasOwnProperty(key)) {
console.debug('rewrite key:', key, ' with value:', src[key]); // DEBUG console.debug('rewrite key:', key, ' with value:', src[key]) // DEBUG
target[key] = src[key]; target[key] = src[key]
} }
} }
return target; return target
}
export async function sleep(timeout: number): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, timeout)
});
} }

33
src/utils/get-stdin.ts Normal file
View file

@ -0,0 +1,33 @@
const {stdin} = process;
export default async function getStdin() {
let result = '';
if (stdin.isTTY) {
return result;
}
stdin.setEncoding('utf8');
for await (const chunk of stdin) {
result += chunk;
}
return result;
}
getStdin.buffer = async () => {
const result = [];
let length = 0;
if (stdin.isTTY) {
return Buffer.concat([]);
}
for await (const chunk of stdin) {
result.push(chunk);
length += chunk.length;
}
return Buffer.concat(result, length);
};

960
yarn.lock

File diff suppressed because it is too large Load diff