@whtg/harvest-auto
Version:
一键导入日报到Harvest的CLI工具,One-click import daily reports to Harvest CLI tool
404 lines (376 loc) • 12.2 kB
JavaScript
/**
* harvest-auto CLI 工具
* 支持: 命令行导入日报JSON至Harvest (需.env配置)、也支持 require 引用
* 路径健壮升级:自动去除路径首尾空格、引号、换行
*/
require('dotenv').config();
const { program } = require('commander');
const fs = require('fs');
const os = require('os');
const path = require('path');
const axios = require('axios');
const inquirer = require('inquirer');
const prompt = inquirer.createPromptModule();
const ora = require('ora').default;
const {
parseMarkdownToDailyReports,
} = require('./utils/markdownToDailyReports');
const {
ensureUserEnvFileInteractive,
changeUserEnvFileInteractive,
} = require('./utils/envSetup');
const { MSG } = require('./i18n/messages');
// ------ 环境变量与配置 ------
// ------ 工时生成 ------
function normalHoursArr(len, min = 1.46, max = 3, dayTotal = 8, LANG = 'CN') {
const maxTries = 200;
// 先快速排除不合法的请求
if (min * len > dayTotal || max * len < dayTotal) {
throw new Error(MSG[LANG].HOURS_RANGE_ERROR);
}
for (let t = 0; t < maxTries; t++) {
let left = dayTotal;
const arr = [];
for (let i = 0; i < len; i++) {
let localMin = min;
let localMax = max;
// 剩余最小/最大用于保证必合法
if (i < len - 1) {
// 后面条数*最小工时
localMax = Math.min(max, left - min * (len - i - 1));
localMin = Math.max(min, left - max * (len - i - 1));
if (localMax < localMin) {
// 本轮采样失败,重采
break;
}
// 偏向于2左右
let bias = 2;
let range = localMax - localMin;
// 加入偏好中心
let v = +(localMin + Math.random() * range).toFixed(2);
if (range >= 1) {
// 带中心偏移
v = +Math.max(
localMin,
Math.min(localMax, bias + (Math.random() - 0.5) * range * 0.8)
).toFixed(2);
}
arr.push(v);
left -= v;
} else {
// 最后一条直接补齐
arr.push(+left.toFixed(2));
}
}
// 验证合法
if (
arr.length === len &&
arr.every((v) => v >= min && v <= max) &&
+arr.reduce((a, b) => a + b, 0).toFixed(2) === +dayTotal.toFixed(2)
) {
return arr;
}
}
throw new Error(MSG[LANG].HOURS_SAMPLING_ERROR);
}
// ------ Harvest接口 ------
async function createTimeEntry(
{ date, project, task, notes, hours },
{ PROJECT_MAP, TASK_MAP, HARVEST_ACCOUNT_ID, HARVEST_TOKEN, USER_AGENT },
LANG = 'CN'
) {
const project_id = PROJECT_MAP[project];
const task_id = TASK_MAP[task];
if (!project_id) throw new Error(MSG[LANG].PROJECT_NOT_MAPPED(project));
if (!task_id) throw new Error(MSG[LANG].TASK_NOT_MAPPED(task));
const data = { project_id, task_id, spent_date: date, hours, notes };
return axios({
url: 'https://api.harvestapp.com/v2/time_entries',
method: 'post',
headers: {
'Harvest-Account-Id': HARVEST_ACCOUNT_ID,
Authorization: `Bearer ${HARVEST_TOKEN}`,
'User-Agent': USER_AGENT,
'Content-Type': 'application/json',
},
data,
});
}
// ------ 502自动重试封装 ------
async function createTimeEntryWithRetry(
args,
opts,
LANG = 'CN',
maxRetry = 3,
delay = 1000
) {
let lastErr;
for (let i = 0; i < maxRetry; i++) {
try {
return await createTimeEntry(args, opts, LANG);
} catch (err) {
lastErr = err;
// 检查是否是502网关错误
const status = err.response?.status || err.status;
if (status !== 502) throw err;
// 502: 自动重试,延迟递增
if (i < maxRetry - 1) {
await new Promise((r) => setTimeout(r, delay * (i + 1)));
}
}
}
throw lastErr;
}
// ------ 核心导入逻辑(可单独require引用) ------
/**
* 导入所有日报,每日总工时保证 >=8 小时,单条工时1.4~2.4随机,最后一条补足。
* 传入 opts 以携带PROJECT_MAP、TASK_MAP等
*/
async function fillAllReports(dailyReports, opts, LANG) {
for (const { date, items } of dailyReports) {
const arrLen = items.length;
// 区间必须吻合采样逻辑
const min = 1.46;
const max = 3;
// 求最大、最小允许总和
const minSum = +(min * arrLen).toFixed(2);
const maxSum = +(max * arrLen).toFixed(2);
// 动态生成 dayTotal,使其处于允许区间且几个工作日不会完全一样
let dayTotal;
let tries = 0;
do {
dayTotal = +(8 + Math.random() * 2).toFixed(2); // 8~10 之间取2位
tries++;
// 超范围自动强收敛
if (dayTotal < minSum) dayTotal = minSum;
if (dayTotal > maxSum) dayTotal = maxSum;
} while (++tries < 5 && (dayTotal < minSum || dayTotal > maxSum));
let hoursArr;
try {
hoursArr = normalHoursArr(arrLen, min, max, dayTotal, LANG);
} catch (e) {
// 容错:前n-1条为min,最后一条补足
hoursArr = Array(arrLen).fill(min);
hoursArr[arrLen - 1] = +(dayTotal - min * (arrLen - 1)).toFixed(2);
if (hoursArr[arrLen - 1] > max) {
console.warn(
MSG[LANG].WARNING_HOURS_ITEMS(date, dayTotal, arrLen, max, true)
);
} else {
console.warn(
MSG[LANG].WARNING_HOURS_ITEMS(date, dayTotal, arrLen, max, false)
);
}
}
for (let i = 0; i < arrLen; i++) {
const item = items[i];
const hours = hoursArr[i];
// project/task都去除全角/半角空白,始终取明细自身字段
const project =
item.project && typeof item.project === 'string'
? item.project.replace(/[\s\u3000]/g, '').trim()
: '';
const task =
item.task && typeof item.task === 'string'
? item.task.replace(/[\s\u3000]/g, '').trim()
: '';
// 使用 ora 动画
const spinner = ora(
MSG[LANG].SUBMIT_PROGRESS(date, project, task, hours)
).start();
try {
await createTimeEntryWithRetry(
{
...item,
date,
project,
task,
hours,
},
opts,
LANG
);
spinner.succeed(
MSG[LANG].SUBMIT_OK(date, project, task, hours, item.notes)
);
} catch (err) {
spinner.fail(
MSG[LANG].SUBMIT_FAIL(
date,
project,
task,
hours,
item.notes,
err.response?.data || err.message || err
)
);
}
}
}
}
module.exports = { fillAllReports };
// ------ CLI部分 ------
if (require.main === module) {
// 捕获 Ctrl+C,友好中断提示(多语言)
process.on('SIGINT', () => {
// 用 env 里正确的 HARVEST_AUTO_LANG
const { MSG } = require('./i18n/messages');
let LANG = (process.env.HARVEST_AUTO_LANG || 'CN').toUpperCase().trim();
if (!MSG[LANG]) LANG = 'CN';
console.log('\x1b[33m%s\x1b[0m', MSG[LANG].SIGINT);
process.exit(0);
});
// 首次运行时交互式创建配置
(async () => {
const homedir = os.homedir();
const userEnvPath = path.join(homedir, '.harvest-auto.env');
await ensureUserEnvFileInteractive();
// 这里确保 env 文件已经确保存在,然后动态加载
require('dotenv').config({ path: userEnvPath });
// 重新读取配置
const HARVEST_ACCOUNT_ID = process.env.HARVEST_ACCOUNT_ID;
const HARVEST_TOKEN = process.env.HARVEST_TOKEN;
const USER_AGENT = process.env.USER_AGENT;
let PROJECT_MAP = {};
try {
PROJECT_MAP = JSON.parse(process.env.PROJECT_MAP || '{}');
} catch (e) {
console.error('\x1b[31m%s\x1b[0m', MSG[LANG].PROJECT_MAP_INVALID);
process.exit(1);
}
let TASK_MAP = {};
try {
TASK_MAP = JSON.parse(process.env.TASK_MAP || '{}');
} catch (e) {
console.error('\x1b[31m%s\x1b[0m', MSG[LANG].TASK_MAP_INVALID);
process.exit(1);
}
// 在 dotenv 加载后定义 HARVEST_AUTO_LANG,并用于后续所有国际化
let LANG = (process.env.HARVEST_AUTO_LANG || 'CN').toUpperCase().trim();
// 防御性:不支持的 lang fallback
if (!MSG[LANG]) LANG = 'CN';
program
.name('harvest-auto')
.usage('-f <日报json> [options]')
.description(MSG[LANG].CLI_DESC)
.option('-f, --file <file>', MSG[LANG].FILE_PATH_ASK)
.option('--dry-run', MSG[LANG].DRY_RUN_DESC)
.option(
'-c, --change',
LANG === 'CN'
? '交互式修改 ~/.harvest-auto.env 配置文件'
: 'Interactively change ~/.harvest-auto.env config'
)
.helpOption('-h, --help', MSG[LANG].HELP_DESC)
.parse(process.argv);
let { file, dryRun, change } = program.opts();
try {
// 新增: 修改env配置命令
if (change) {
await changeUserEnvFileInteractive();
process.exit(0);
}
// 路径净化函数:去除首尾空格、引号和换行
function cleanPath(val) {
if (!val) return val;
return (
val
.trim()
.replace(/^"+|"+$/g, '')
.replace(/\r?\n/g, '')
// 通用反斜杠转义字符还原(空格、&, (, )、中文符号等终端粘贴都支持)
.replace(/\\(.)/g, '$1')
);
}
try {
// 如未指定文件参数,则用inquirer交互获取
if (!file) {
const { inputFile } = await prompt([
{
type: 'input',
name: 'inputFile',
message: MSG[LANG].FILE_PATH_ASK,
validate(val) {
let p = cleanPath(val);
if (p.startsWith('~')) {
p = path.join(process.env.HOME, p.slice(1));
}
if (!fs.existsSync(p)) {
return MSG[LANG].FILE_NOT_FOUND(p);
}
return true;
},
},
]);
file = inputFile;
}
// 确认环节和后续都清理路径
let p = cleanPath(file);
if (p.startsWith('~')) {
p = path.join(process.env.HOME, p.slice(1));
}
const { confirmRead } = await prompt([
{
type: 'confirm',
name: 'confirmRead',
message: MSG[LANG].FILE_PATH_CONFIRM(p),
default: true,
},
]);
if (!confirmRead) {
console.log(MSG[LANG].CANCEL);
process.exit(0);
}
let dailyReports = [];
try {
const content = fs.readFileSync(p, 'utf-8');
try {
// 先尝试 JSON 格式
dailyReports = JSON.parse(content);
} catch {
// 非 JSON 时,尝试用 md/daylog 转换器
dailyReports = parseMarkdownToDailyReports(content);
}
if (!Array.isArray(dailyReports) || dailyReports.length === 0)
throw new Error(MSG[LANG].NO_VALID_REPORT);
} catch (e) {
console.error(MSG[LANG].READ_FAIL, e.message || e);
process.exit(1);
}
if (dryRun) {
console.log(JSON.stringify(dailyReports, null, 2));
console.log(MSG[LANG].DRY_RUN);
} else {
await fillAllReports(
dailyReports,
{
PROJECT_MAP,
TASK_MAP,
HARVEST_ACCOUNT_ID,
HARVEST_TOKEN,
USER_AGENT,
},
LANG
);
// 所有填报完成后全局成功提示
console.log('\x1b[32m%s\x1b[0m', MSG[LANG].SUBMIT_FINISH);
}
} catch (err) {
if (err && err.name === 'ExitPromptError') {
let tip = MSG[LANG].CANCEL + '\n' + (MSG[LANG].EXIT_MID || '');
console.log('\x1b[33m%s\x1b[0m', tip);
process.exit(0);
}
throw err;
}
} catch (err) {
if (err && err.name === 'ExitPromptError') {
let tip = MSG[LANG].CANCEL + '\n' + (MSG[LANG].EXIT_MID || '');
console.log('\x1b[33m%s\x1b[0m', tip);
process.exit(0);
}
throw err;
}
})();
}