mirror of
https://github.com/nwbort/obsidian-harvest.git
synced 2026-07-22 06:43:24 +00:00
962 lines
34 KiB
TypeScript
962 lines
34 KiB
TypeScript
import { App, FuzzySuggestModal, FuzzyMatch, Notice, Plugin, PluginSettingTab, SecretComponent, Setting, MarkdownPostProcessorContext, requestUrl, TFile } from 'obsidian';
|
|
|
|
// --- HARVEST API TYPES ---
|
|
interface HarvestClient {
|
|
id: number;
|
|
name: string;
|
|
currency: string;
|
|
}
|
|
|
|
interface HarvestUser {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
interface HarvestProject {
|
|
id: number;
|
|
name: string;
|
|
code: string;
|
|
}
|
|
|
|
interface HarvestTask {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
interface HarvestUserAssignment {
|
|
id: number;
|
|
is_project_manager: boolean;
|
|
is_active: boolean;
|
|
use_default_rates: boolean;
|
|
budget: number | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
hourly_rate: number | null;
|
|
}
|
|
|
|
interface HarvestTaskAssignment {
|
|
id: number;
|
|
billable: boolean;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
hourly_rate: number | null;
|
|
budget: number | null;
|
|
task: HarvestTask;
|
|
}
|
|
|
|
interface HarvestTimeEntry {
|
|
id: number;
|
|
spent_date: string;
|
|
hours: number;
|
|
hours_without_timer: number;
|
|
rounded_hours: number;
|
|
notes: string;
|
|
is_locked: boolean;
|
|
locked_reason: string | null;
|
|
approval_status: string;
|
|
is_closed: boolean;
|
|
is_billed: boolean;
|
|
timer_started_at: string | null;
|
|
started_time: string | null;
|
|
ended_time: string | null;
|
|
is_running: boolean;
|
|
billable: boolean;
|
|
budgeted: boolean;
|
|
billable_rate: number | null;
|
|
cost_rate: number | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
user: HarvestUser;
|
|
client: HarvestClient;
|
|
project: HarvestProject;
|
|
task: HarvestTask;
|
|
user_assignment: HarvestUserAssignment;
|
|
task_assignment: Omit<HarvestTaskAssignment, 'task'>;
|
|
invoice: string | null;
|
|
external_reference: string | null;
|
|
}
|
|
|
|
interface HarvestProjectFull {
|
|
id: number;
|
|
name: string;
|
|
code: string;
|
|
is_active: boolean;
|
|
is_billable: boolean;
|
|
is_fixed_fee: boolean;
|
|
bill_by: string;
|
|
budget: number | null;
|
|
budget_by: string;
|
|
budget_is_monthly: boolean;
|
|
notify_when_over_budget: boolean;
|
|
over_budget_notification_percentage: number;
|
|
show_budget_to_all: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
starts_on: string;
|
|
ends_on: string | null;
|
|
over_budget_notification_date: string | null;
|
|
notes: string | null;
|
|
cost_budget: number | null;
|
|
cost_budget_include_expenses: boolean;
|
|
hourly_rate: number | null;
|
|
fee: number | null;
|
|
client: HarvestClient;
|
|
task_assignments?: HarvestTaskAssignment[];
|
|
}
|
|
|
|
interface HarvestTimeEntriesResponse {
|
|
time_entries: HarvestTimeEntry[];
|
|
per_page: number;
|
|
total_pages: number;
|
|
total_entries: number;
|
|
next_page: number | null;
|
|
previous_page: number | null;
|
|
page: number;
|
|
links: {
|
|
first: string;
|
|
next: string | null;
|
|
previous: string | null;
|
|
last: string;
|
|
};
|
|
}
|
|
|
|
interface HarvestProjectsResponse {
|
|
projects: HarvestProjectFull[];
|
|
per_page: number;
|
|
total_pages: number;
|
|
total_entries: number;
|
|
next_page: number | null;
|
|
previous_page: number | null;
|
|
page: number;
|
|
links: {
|
|
first: string;
|
|
next: string | null;
|
|
previous: string | null;
|
|
last: string;
|
|
};
|
|
}
|
|
|
|
interface HarvestTaskAssignmentsResponse {
|
|
task_assignments: HarvestTaskAssignment[];
|
|
per_page: number;
|
|
total_pages: number;
|
|
total_entries: number;
|
|
next_page: number | null;
|
|
previous_page: number | null;
|
|
page: number;
|
|
links: {
|
|
first: string;
|
|
next: string | null;
|
|
previous: string | null;
|
|
last: string;
|
|
};
|
|
}
|
|
|
|
interface HarvestCurrentUser {
|
|
id: number;
|
|
first_name: string;
|
|
last_name: string;
|
|
email: string;
|
|
}
|
|
|
|
// --- PLUGIN TYPES ---
|
|
// Stores the last used project/task for a given folder path to use as a default when starting a new timer.
|
|
interface FolderProjectCache {
|
|
[folderPath: string]: {
|
|
projectId: number;
|
|
taskId: number;
|
|
};
|
|
}
|
|
|
|
// --- HQL TYPES ---
|
|
|
|
type ISODate = string;
|
|
|
|
enum QueryType {
|
|
SUMMARY = 'SUMMARY',
|
|
LIST = 'LIST',
|
|
}
|
|
|
|
interface HarvestQuery {
|
|
type: QueryType;
|
|
from: ISODate;
|
|
to: ISODate;
|
|
}
|
|
|
|
// --- HQL PARSER ---
|
|
function parseQuery(source: string): HarvestQuery {
|
|
const tokens = source.trim().split(/\s+/).map(t => t.toUpperCase());
|
|
if (tokens.length < 2) throw new Error("Query is too short.");
|
|
|
|
const type = tokens[0] as QueryType;
|
|
if (type !== QueryType.LIST && type !== QueryType.SUMMARY) {
|
|
throw new Error(`Invalid query type: ${type as string}. Must be LIST or SUMMARY.`);
|
|
}
|
|
|
|
const { from, to } = parseTimeRange(tokens.slice(1));
|
|
|
|
return { type, from, to };
|
|
}
|
|
|
|
function parseTimeRange(tokens: string[]): { from: ISODate, to: ISODate } {
|
|
const today = new Date();
|
|
const formatDate = (date: Date): ISODate => {
|
|
const year = date.getFullYear();
|
|
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
|
const day = date.getDate().toString().padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
};
|
|
|
|
let from: Date;
|
|
let to: Date;
|
|
|
|
switch (tokens[0]) {
|
|
case 'TODAY':
|
|
from = today;
|
|
to = today;
|
|
break;
|
|
case 'WEEK': {
|
|
const dayOfWeek = today.getDay();
|
|
const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
|
from = new Date(today.getFullYear(), today.getMonth(), today.getDate() + daysToMonday);
|
|
to = new Date(from.getFullYear(), from.getMonth(), from.getDate() + 6);
|
|
break;
|
|
}
|
|
case 'MONTH':
|
|
from = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
to = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
|
break;
|
|
case 'PAST': {
|
|
const count = parseInt(tokens[1]);
|
|
if (isNaN(count) || tokens[2] !== 'DAYS') throw new Error("Invalid PAST format. Use 'PAST <number> DAYS'.");
|
|
to = today;
|
|
from = new Date(new Date().setDate(today.getDate() - (count - 1)));
|
|
break;
|
|
}
|
|
case 'FROM': {
|
|
if (tokens.length < 4 || tokens[2] !== 'TO') throw new Error("Invalid FROM...TO format.");
|
|
from = new Date(tokens[1]);
|
|
to = new Date(tokens[3]);
|
|
if (isNaN(from.getTime()) || isNaN(to.getTime())) throw new Error("Invalid date format in FROM...TO. Use YYYY-MM-DD.");
|
|
break;
|
|
}
|
|
default:
|
|
throw new Error(`Unknown time range specifier: ${tokens[0]}`);
|
|
}
|
|
|
|
return { from: formatDate(from), to: formatDate(to) };
|
|
}
|
|
|
|
// --- HQL SERIALIZERS (for freezing results) ---
|
|
function serializeListToMarkdown(entries: HarvestTimeEntry[], query: HarvestQuery): string {
|
|
const lines: string[] = [];
|
|
lines.push(`> [!info] Harvest Time Entries (${query.from} to ${query.to})`);
|
|
lines.push('');
|
|
lines.push('| Project | Task | Date | Hours |');
|
|
lines.push('| --- | --- | --- | ---: |');
|
|
for (const entry of entries) {
|
|
lines.push(`| ${entry.project.name} | ${entry.task.name} | ${entry.spent_date} | ${entry.hours.toFixed(2)} |`);
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function serializeSummaryToMarkdown(entries: HarvestTimeEntry[], query: HarvestQuery): string {
|
|
let totalHours = 0;
|
|
const projectTotals: { [key: string]: number } = {};
|
|
|
|
for (const entry of entries) {
|
|
totalHours += entry.hours;
|
|
const projectName = entry.project.name;
|
|
if (!projectTotals[projectName]) {
|
|
projectTotals[projectName] = 0;
|
|
}
|
|
projectTotals[projectName] += entry.hours;
|
|
}
|
|
|
|
const sortedProjects = Object.keys(projectTotals).sort((a, b) => projectTotals[b] - projectTotals[a]);
|
|
|
|
const lines: string[] = [];
|
|
lines.push(`> [!info] Harvest Time Summary (${query.from} to ${query.to})`);
|
|
lines.push(`> **Total hours: ${totalHours.toFixed(2)}**`);
|
|
for (const projectName of sortedProjects) {
|
|
const projectHours = projectTotals[projectName];
|
|
lines.push(`> - ${projectName}: ${projectHours.toFixed(2)} hours`);
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// --- HQL RENDERER ---
|
|
function renderReport(container: HTMLElement, entries: HarvestTimeEntry[], query: HarvestQuery, onFreeze?: () => Promise<void>) {
|
|
container.empty();
|
|
const wrapper = container.createDiv({ cls: 'harvest-report' });
|
|
|
|
if (entries.length === 0) {
|
|
wrapper.createEl('p', { text: 'No time entries found for the selected period.' });
|
|
return;
|
|
}
|
|
|
|
if (query.type === QueryType.LIST) {
|
|
renderList(wrapper, entries);
|
|
} else if (query.type === QueryType.SUMMARY) {
|
|
renderSummary(wrapper, entries);
|
|
}
|
|
|
|
// Add "Freeze Results" button if callback provided
|
|
if (onFreeze) {
|
|
const buttonContainer = wrapper.createDiv({ cls: 'harvest-freeze-container' });
|
|
const freezeButton = buttonContainer.createEl('button', {
|
|
text: '❆',
|
|
cls: 'harvest-freeze-button',
|
|
attr: { 'aria-label': 'Freeze results' }
|
|
});
|
|
freezeButton.addEventListener('click', () => { void onFreeze(); });
|
|
}
|
|
}
|
|
|
|
function renderList(container: HTMLElement, entries: HarvestTimeEntry[]) {
|
|
const table = container.createEl('table', { cls: 'harvest-table' });
|
|
const thead = table.createTHead();
|
|
const headerRow = thead.insertRow();
|
|
headerRow.createEl('th', { text: 'Project' });
|
|
headerRow.createEl('th', { text: 'Task' });
|
|
headerRow.createEl('th', { text: 'Date' });
|
|
headerRow.createEl('th', { text: 'Hours' });
|
|
|
|
const tbody = table.createTBody();
|
|
for (const entry of entries) {
|
|
const row = tbody.insertRow();
|
|
row.createEl('td', { text: entry.project.name });
|
|
row.createEl('td', { text: entry.task.name });
|
|
row.createEl('td', { text: entry.spent_date });
|
|
row.createEl('td', { text: entry.hours.toFixed(2), cls: 'harvest-hours' });
|
|
}
|
|
}
|
|
|
|
function renderSummary(container: HTMLElement, entries: HarvestTimeEntry[]) {
|
|
let totalHours = 0;
|
|
const projectTotals: { [key: string]: number } = {};
|
|
|
|
for (const entry of entries) {
|
|
totalHours += entry.hours;
|
|
const projectName = entry.project.name;
|
|
if (!projectTotals[projectName]) {
|
|
projectTotals[projectName] = 0;
|
|
}
|
|
projectTotals[projectName] += entry.hours;
|
|
}
|
|
|
|
container.createEl('h3', { text: 'Time summary' });
|
|
const summaryDiv = container.createDiv({ cls: 'harvest-summary' });
|
|
summaryDiv.createEl('p').createEl('strong', { text: `Total hours: ${totalHours.toFixed(2)}` });
|
|
|
|
// Bar Chart
|
|
const barChartContainer = summaryDiv.createDiv({ cls: 'harvest-barchart-container' });
|
|
const colors = ['#84b65a', '#c25956', '#59a7c2', '#c29b59', '#8e59c2', '#c2598e', '#5ac28a'];
|
|
let colorIndex = 0;
|
|
|
|
const sortedProjects = Object.keys(projectTotals).sort((a, b) => projectTotals[b] - projectTotals[a]);
|
|
|
|
for (const projectName of sortedProjects) {
|
|
const projectHours = projectTotals[projectName];
|
|
const percentage = totalHours > 0 ? (projectHours / totalHours) * 100 : 0;
|
|
const color = colors[colorIndex % colors.length];
|
|
|
|
const bar = barChartContainer.createDiv({ cls: 'harvest-barchart-bar' });
|
|
bar.style.setProperty('--bar-width', `${percentage}%`);
|
|
bar.style.setProperty('--bar-color', color);
|
|
bar.title = `${projectName}: ${projectHours.toFixed(2)} hours`;
|
|
colorIndex++;
|
|
}
|
|
|
|
// Legend
|
|
const legendContainer = summaryDiv.createDiv({ cls: 'harvest-barchart-legend' });
|
|
colorIndex = 0;
|
|
for (const projectName of sortedProjects) {
|
|
const projectHours = projectTotals[projectName];
|
|
const color = colors[colorIndex % colors.length];
|
|
|
|
const legendItem = legendContainer.createDiv({ cls: 'harvest-legend-item' });
|
|
const colorSwatch = legendItem.createDiv({ cls: 'harvest-legend-swatch' });
|
|
colorSwatch.style.backgroundColor = color;
|
|
legendItem.createSpan({ text: `${projectName}: ${projectHours.toFixed(2)} hours` });
|
|
colorIndex++;
|
|
}
|
|
}
|
|
|
|
|
|
// --- HQL PROCESSOR ---
|
|
const hqlProcessor = (plugin: HarvestPlugin) => async (
|
|
source: string,
|
|
el: HTMLElement,
|
|
ctx: MarkdownPostProcessorContext
|
|
) => {
|
|
try {
|
|
const query = parseQuery(source);
|
|
if (!query) return;
|
|
|
|
el.setText('Loading report...');
|
|
|
|
const entries = await plugin.getTimeEntries(query);
|
|
|
|
if (entries) {
|
|
// Create the freeze callback to replace code block with static markdown
|
|
const onFreeze = async () => {
|
|
const sectionInfo = ctx.getSectionInfo(el);
|
|
if (!sectionInfo) {
|
|
new Notice('Could not locate code block in file.');
|
|
return;
|
|
}
|
|
|
|
const file = plugin.app.vault.getAbstractFileByPath(ctx.sourcePath);
|
|
if (!file || !(file instanceof TFile)) {
|
|
new Notice('Could not find the file to update.');
|
|
return;
|
|
}
|
|
|
|
// Generate the markdown based on query type
|
|
const markdown = query.type === QueryType.LIST
|
|
? serializeListToMarkdown(entries, query)
|
|
: serializeSummaryToMarkdown(entries, query);
|
|
|
|
// Read the file and replace the code block
|
|
const content = await plugin.app.vault.read(file);
|
|
const lines = content.split('\n');
|
|
|
|
// Replace the lines from lineStart to lineEnd (inclusive) with the markdown
|
|
const newLines = [
|
|
...lines.slice(0, sectionInfo.lineStart),
|
|
markdown,
|
|
...lines.slice(sectionInfo.lineEnd + 1)
|
|
];
|
|
|
|
await plugin.app.vault.modify(file, newLines.join('\n'));
|
|
new Notice('Results have been frozen.');
|
|
};
|
|
|
|
renderReport(el, entries, query, onFreeze);
|
|
} else {
|
|
el.setText('Failed to fetch report.');
|
|
}
|
|
} catch (e) {
|
|
el.setText(`Error processing Harvest query: ${e instanceof Error ? e.message : String(e)}`);
|
|
}
|
|
};
|
|
|
|
|
|
// Settings interface
|
|
interface HarvestPluginSettings {
|
|
personalAccessToken: string;
|
|
accountId: string;
|
|
pollingInterval: number;
|
|
folderProjectCache: FolderProjectCache;
|
|
credentialsMigrated: boolean;
|
|
}
|
|
|
|
// Default settings
|
|
const DEFAULT_SETTINGS: HarvestPluginSettings = {
|
|
personalAccessToken: '',
|
|
accountId: '',
|
|
pollingInterval: 5, // 5 minutes
|
|
folderProjectCache: {},
|
|
credentialsMigrated: false,
|
|
}
|
|
|
|
// Helper function to format decimal hours as h:mm
|
|
function formatHoursMinutes(decimalHours: number): string {
|
|
const hours = Math.floor(decimalHours);
|
|
const minutes = Math.floor((decimalHours - hours) * 60);
|
|
return `${hours}:${minutes.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
// Main Plugin Class
|
|
export default class HarvestPlugin extends Plugin {
|
|
settings!: HarvestPluginSettings;
|
|
statusBarItemEl!: HTMLElement;
|
|
runningTimer: HarvestTimeEntry | null = null;
|
|
timerInterval!: number;
|
|
projectCache: HarvestProjectFull[] = [];
|
|
userId: number | null = null;
|
|
isOffline: boolean = false;
|
|
|
|
async onload() {
|
|
//Read in settings
|
|
await this.loadSettings();
|
|
await this.migrateCredentials();
|
|
|
|
// Set up status bar
|
|
this.statusBarItemEl = this.addStatusBarItem();
|
|
this.statusBarItemEl.setText('Harvest');
|
|
this.statusBarItemEl.addClass('mod-clickable');
|
|
this.statusBarItemEl.addEventListener('click', () => void this.toggleTimer());
|
|
|
|
// Set up settings
|
|
this.addSettingTab(new HarvestSettingTab(this.app, this));
|
|
|
|
// Initial loading of user and projects (silent on startup to avoid popup spam if offline)
|
|
if (this.settings.personalAccessToken && this.settings.accountId) {
|
|
await this.fetchCurrentUserId(true);
|
|
}
|
|
|
|
// Warm up the project cache on startup
|
|
void this.fetchAllTrackableProjects();
|
|
|
|
this.addCommand({
|
|
id: 'start-timer',
|
|
name: 'Start timer',
|
|
callback: () => {
|
|
void new ProjectSuggestModal(this.app, this, this.app.workspace.getActiveFile()).open();
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'stop-timer',
|
|
name: 'Stop timer',
|
|
callback: async () => {
|
|
if (this.runningTimer) {
|
|
await this.stopTimer(this.runningTimer.id);
|
|
} else {
|
|
new Notice('No timer is currently running.');
|
|
}
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'toggle-timer',
|
|
name: 'Toggle timer',
|
|
callback: async () => {
|
|
await this.toggleTimer();
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'refresh-projects',
|
|
name: 'Refresh projects',
|
|
callback: async () => {
|
|
new Notice('Refreshing project list...');
|
|
await this.fetchAllTrackableProjects(true); // Force a refresh
|
|
new Notice('Project list has been updated.');
|
|
}
|
|
});
|
|
|
|
// Register HQL code block processor
|
|
this.registerMarkdownCodeBlockProcessor('harvest', hqlProcessor(this));
|
|
|
|
// Use the polling interval from settings
|
|
const pollingMinutes = this.settings.pollingInterval > 0 ? this.settings.pollingInterval : 5;
|
|
this.timerInterval = window.setInterval(() => void this.updateRunningTimer(), pollingMinutes * 60 * 1000);
|
|
|
|
void this.updateRunningTimer();
|
|
}
|
|
|
|
onunload() {
|
|
if (this.timerInterval) {
|
|
activeWindow.clearInterval(this.timerInterval);
|
|
}
|
|
}
|
|
|
|
async loadSettings() {
|
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()) as HarvestPluginSettings;
|
|
}
|
|
|
|
async saveSettings() {
|
|
await this.saveData(this.settings);
|
|
}
|
|
|
|
async migrateCredentials() {
|
|
if (this.settings.credentialsMigrated) return;
|
|
|
|
const rawToken = this.settings.personalAccessToken;
|
|
const rawAccountId = this.settings.accountId;
|
|
|
|
if (rawToken) {
|
|
this.app.secretStorage.setSecret('harvest-token', rawToken);
|
|
this.settings.personalAccessToken = 'harvest-token';
|
|
}
|
|
if (rawAccountId) {
|
|
this.app.secretStorage.setSecret('harvest-account-id', rawAccountId);
|
|
this.settings.accountId = 'harvest-account-id';
|
|
}
|
|
|
|
this.settings.credentialsMigrated = true;
|
|
await this.saveSettings();
|
|
}
|
|
|
|
async request<T = unknown>(
|
|
endpoint: string,
|
|
method: string = 'GET',
|
|
body: Record<string, unknown> | null = null,
|
|
silent: boolean = false
|
|
): Promise<T | null> {
|
|
const token = this.app.secretStorage.getSecret(this.settings.personalAccessToken) ?? this.settings.personalAccessToken;
|
|
const accountId = this.app.secretStorage.getSecret(this.settings.accountId) ?? this.settings.accountId;
|
|
|
|
if (!token || !accountId) {
|
|
if (!silent) {
|
|
new Notice('Harvest API credentials are not set.');
|
|
}
|
|
return null;
|
|
}
|
|
const headers = {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Harvest-Account-Id': accountId,
|
|
'User-Agent': 'Obsidian Harvest Integration',
|
|
'Content-Type': 'application/json'
|
|
};
|
|
try {
|
|
const response = await requestUrl({
|
|
url: `https://api.harvestapp.com/v2${endpoint}`,
|
|
method: method,
|
|
headers: headers,
|
|
body: body ? JSON.stringify(body) : undefined
|
|
});
|
|
|
|
if (response.status >= 400) {
|
|
if (!silent) {
|
|
new Notice(`Harvest API error: ${(response.json as { message?: string }).message || response.status}`);
|
|
}
|
|
return null;
|
|
}
|
|
// Successful response - we're back online
|
|
this.isOffline = false;
|
|
return response.json as T;
|
|
} catch (error) {
|
|
// Network error - mark as offline
|
|
this.isOffline = true;
|
|
if (!silent) {
|
|
new Notice('Failed to connect to API.');
|
|
}
|
|
console.error('Harvest API request error:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async fetchCurrentUserId(silent: boolean = false) {
|
|
const me = await this.request<HarvestCurrentUser>('/users/me', 'GET', null, silent);
|
|
if (me && me.id) {
|
|
this.userId = me.id;
|
|
} else {
|
|
this.userId = null;
|
|
if (!silent && !this.isOffline) {
|
|
new Notice('Could not retrieve user ID.');
|
|
}
|
|
console.error('Failed to fetch user ID.');
|
|
}
|
|
}
|
|
|
|
async getTimeEntries(query: HarvestQuery): Promise<HarvestTimeEntry[]> {
|
|
if (!this.userId) {
|
|
new Notice('User ID not found. Cannot fetch your time entries.');
|
|
return [];
|
|
}
|
|
const endpoint = `/time_entries?from=${query.from}&to=${query.to}&user_id=${this.userId}`;
|
|
const data = await this.request<HarvestTimeEntriesResponse>(endpoint);
|
|
if (data && data.time_entries) {
|
|
return data.time_entries;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
async fetchAllTrackableProjects(forceRefresh: boolean = false): Promise<HarvestProjectFull[]> {
|
|
// Return from cache unless refresh is forced
|
|
if (this.projectCache.length > 0 && !forceRefresh) {
|
|
return this.projectCache;
|
|
}
|
|
|
|
// Get both managed projects and project in time entries. For projects where a user is not a manager,
|
|
// the only way to get a list of projects assigned to that user is through the time entries
|
|
// Use silent mode for background startup calls (non-forced refresh)
|
|
const silent = !forceRefresh;
|
|
const managedProjects = await this.getManagedProjects(silent);
|
|
const recentProjects = await this.getRecentProjectsFromTimeEntries(silent);
|
|
const combinedProjectMap = new Map<number, HarvestProjectFull>();
|
|
managedProjects.forEach(proj => combinedProjectMap.set(proj.id, proj));
|
|
recentProjects.forEach(proj => {
|
|
if (!combinedProjectMap.has(proj.id)) {
|
|
combinedProjectMap.set(proj.id, proj);
|
|
}
|
|
});
|
|
const sortedProjects = Array.from(combinedProjectMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
this.projectCache = sortedProjects;
|
|
return this.projectCache;
|
|
}
|
|
|
|
async getManagedProjects(silent: boolean = false): Promise<HarvestProjectFull[]> {
|
|
let allProjects: HarvestProjectFull[] = [];
|
|
let page = 1;
|
|
let totalPages = 1;
|
|
do {
|
|
const data = await this.request<HarvestProjectsResponse>(`/projects?is_active=true&page=${page}`, 'GET', null, silent);
|
|
if (data && data.projects) {
|
|
allProjects = allProjects.concat(data.projects);
|
|
totalPages = data.total_pages;
|
|
page++;
|
|
} else { break; }
|
|
} while (page <= totalPages);
|
|
return allProjects;
|
|
}
|
|
|
|
async getRecentProjectsFromTimeEntries(silent: boolean = false): Promise<HarvestProjectFull[]> {
|
|
if (!this.userId) return [];
|
|
const thirtyDaysAgo = new Date();
|
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
const fromDate = thirtyDaysAgo.toISOString().split('T')[0];
|
|
const data = await this.request<HarvestTimeEntriesResponse>(`/time_entries?from=${fromDate}&user_id=${this.userId}`, 'GET', null, silent);
|
|
if (!data || !data.time_entries) return [];
|
|
const recentProjectsMap = new Map<number, HarvestProjectFull>();
|
|
data.time_entries.forEach((entry: HarvestTimeEntry) => {
|
|
if (entry.project && !recentProjectsMap.has(entry.project.id)) {
|
|
// Convert the simplified project from time entry to HarvestProjectFull
|
|
recentProjectsMap.set(entry.project.id, entry.project as unknown as HarvestProjectFull);
|
|
}
|
|
});
|
|
return Array.from(recentProjectsMap.values());
|
|
}
|
|
|
|
async startTimer(projectId: number, taskId: number, activeFile: TFile | null) {
|
|
// Save the selected project/task to the cache for the current folder to use as future default
|
|
if (activeFile && activeFile.parent) {
|
|
const folderPath = activeFile.parent.path;
|
|
this.settings.folderProjectCache[folderPath] = { projectId, taskId };
|
|
await this.saveSettings();
|
|
}
|
|
|
|
const today = new Date();
|
|
const year = today.getFullYear();
|
|
const month = (today.getMonth() + 1).toString().padStart(2, '0');
|
|
const day = today.getDate().toString().padStart(2, '0');
|
|
const spentDate = `${year}-${month}-${day}`;
|
|
|
|
// Check for existing entry today for this project/task to restart it
|
|
if (this.userId) {
|
|
const data = await this.request<HarvestTimeEntriesResponse>(`/time_entries?from=${spentDate}&to=${spentDate}&user_id=${this.userId}`);
|
|
if (data && data.time_entries) {
|
|
const existingEntry = data.time_entries.find(
|
|
(entry: HarvestTimeEntry) => entry.project.id === projectId && entry.task.id === taskId
|
|
);
|
|
|
|
if (existingEntry) {
|
|
const result = await this.request(`/time_entries/${existingEntry.id}/restart`, 'PATCH');
|
|
if (result) {
|
|
new Notice('Timer restarted!');
|
|
void this.updateRunningTimer();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// No existing entry found, create a new one
|
|
const body = {
|
|
project_id: projectId,
|
|
task_id: taskId,
|
|
spent_date: spentDate,
|
|
};
|
|
const result = await this.request('/time_entries', 'POST', body);
|
|
if (result) {
|
|
new Notice('Timer started!');
|
|
void this.updateRunningTimer();
|
|
}
|
|
}
|
|
|
|
async updateRunningTimer() {
|
|
if (!this.userId) return;
|
|
const data = await this.request<HarvestTimeEntriesResponse>(`/time_entries?is_running=true&user_id=${this.userId}`, 'GET', null, true);
|
|
|
|
// Check if we're offline after the request
|
|
if (this.isOffline) {
|
|
this.statusBarItemEl.setText('Harvest: no network connection');
|
|
return;
|
|
}
|
|
|
|
if (data && data.time_entries && data.time_entries.length > 0) {
|
|
this.runningTimer = data.time_entries[0];
|
|
const { project, task, hours } = this.runningTimer;
|
|
this.statusBarItemEl.setText(`Harvest: ${project.name} - ${task.name} (${formatHoursMinutes(hours)})`);
|
|
} else {
|
|
this.runningTimer = null;
|
|
this.statusBarItemEl.setText('Harvest: no timer running');
|
|
}
|
|
}
|
|
|
|
async stopTimer(timerId: number) {
|
|
const result = await this.request(`/time_entries/${timerId}/stop`, 'PATCH');
|
|
if (result) {
|
|
new Notice('Timer stopped.');
|
|
void this.updateRunningTimer();
|
|
}
|
|
}
|
|
|
|
async toggleTimer() {
|
|
await this.updateRunningTimer();
|
|
if (this.runningTimer) {
|
|
new Notice('Stopping timer...');
|
|
await this.stopTimer(this.runningTimer.id);
|
|
} else {
|
|
new Notice('No timer running. Starting a new one...');
|
|
new ProjectSuggestModal(this.app, this, this.app.workspace.getActiveFile()).open();
|
|
}
|
|
}
|
|
}
|
|
|
|
// -- MODAL CLASSES --
|
|
class ProjectSuggestModal extends FuzzySuggestModal<HarvestProjectFull> {
|
|
plugin: HarvestPlugin;
|
|
activeFile: TFile | null;
|
|
|
|
constructor(app: App, plugin: HarvestPlugin, activeFile: TFile | null) {
|
|
super(app);
|
|
this.plugin = plugin;
|
|
this.activeFile = activeFile;
|
|
}
|
|
|
|
getItems(): HarvestProjectFull[] {
|
|
let projects = [...this.plugin.projectCache];
|
|
|
|
if (this.activeFile && this.activeFile.parent) {
|
|
const folderPath = this.activeFile.parent.path;
|
|
const cachedInfo = this.plugin.settings.folderProjectCache[folderPath];
|
|
|
|
if (cachedInfo) {
|
|
const cachedProjectIndex = projects.findIndex(p => p.id === cachedInfo.projectId);
|
|
|
|
if (cachedProjectIndex > -1) {
|
|
// Move the cached project to the front of the list to pre-select it
|
|
const cachedProject = projects.splice(cachedProjectIndex, 1)[0];
|
|
projects.unshift(cachedProject);
|
|
}
|
|
}
|
|
}
|
|
|
|
return projects;
|
|
}
|
|
|
|
getItemText(project: HarvestProjectFull): string {
|
|
return project.name;
|
|
}
|
|
|
|
renderSuggestion(match: FuzzyMatch<HarvestProjectFull>, el: HTMLElement) {
|
|
const project = match.item;
|
|
el.createDiv({ text: project.name });
|
|
el.createEl('small', { text: project.client?.name || 'No client' });
|
|
}
|
|
|
|
onChooseItem(project: HarvestProjectFull) {
|
|
void this.handleProjectChoice(project);
|
|
}
|
|
|
|
private async handleProjectChoice(project: HarvestProjectFull) {
|
|
let tasks = project.task_assignments;
|
|
if (!tasks) {
|
|
const data = await this.plugin.request<HarvestTaskAssignmentsResponse>(`/projects/${project.id}/task_assignments`);
|
|
tasks = data?.task_assignments;
|
|
}
|
|
|
|
if (tasks && tasks.length > 0) {
|
|
new TaskSuggestModal(this.app, this.plugin, project, tasks, this.activeFile).open();
|
|
} else {
|
|
new Notice('No tasks found for this project.');
|
|
}
|
|
}
|
|
}
|
|
|
|
class TaskSuggestModal extends FuzzySuggestModal<HarvestTaskAssignment> {
|
|
plugin: HarvestPlugin;
|
|
project: HarvestProjectFull;
|
|
tasks: HarvestTaskAssignment[];
|
|
activeFile: TFile | null;
|
|
|
|
constructor(app: App, plugin: HarvestPlugin, project: HarvestProjectFull, tasks: HarvestTaskAssignment[], activeFile: TFile | null) {
|
|
super(app);
|
|
this.plugin = plugin;
|
|
this.project = project;
|
|
this.tasks = tasks;
|
|
this.activeFile = activeFile;
|
|
}
|
|
|
|
getItems(): HarvestTaskAssignment[] {
|
|
let tasks = [...this.tasks]; // Create a mutable copy
|
|
|
|
if (this.activeFile && this.activeFile.parent) {
|
|
const folderPath = this.activeFile.parent.path;
|
|
const cachedInfo = this.plugin.settings.folderProjectCache[folderPath];
|
|
|
|
// Check if the cache is for the currently selected project
|
|
if (cachedInfo && cachedInfo.projectId === this.project.id) {
|
|
const cachedTaskIndex = tasks.findIndex(t => t.task.id === cachedInfo.taskId);
|
|
|
|
if (cachedTaskIndex > -1) {
|
|
// Move the cached task to the front of the list to pre-select it
|
|
const cachedTask = tasks.splice(cachedTaskIndex, 1)[0];
|
|
tasks.unshift(cachedTask);
|
|
}
|
|
}
|
|
}
|
|
|
|
return tasks;
|
|
}
|
|
|
|
getItemText(taskAssignment: HarvestTaskAssignment): string {
|
|
return taskAssignment.task.name;
|
|
}
|
|
|
|
renderSuggestion(match: FuzzyMatch<HarvestTaskAssignment>, el: HTMLElement) {
|
|
el.createDiv({ text: match.item.task.name });
|
|
}
|
|
|
|
onChooseItem(taskAssignment: HarvestTaskAssignment) {
|
|
void this.plugin.startTimer(this.project.id, taskAssignment.task.id, this.activeFile);
|
|
}
|
|
}
|
|
|
|
// -- SETTINGS TAB CLASS --
|
|
class HarvestSettingTab extends PluginSettingTab {
|
|
plugin: HarvestPlugin;
|
|
constructor(app: App, plugin: HarvestPlugin) {
|
|
super(app, plugin);
|
|
this.plugin = plugin;
|
|
}
|
|
display(): void {
|
|
const { containerEl } = this;
|
|
containerEl.empty();
|
|
new Setting(containerEl).setName('Credentials').setHeading();
|
|
new Setting(containerEl)
|
|
.setName('Personal access token')
|
|
.setDesc('Select or create a secret in SecretStorage containing your Harvest personal access token.')
|
|
.addComponent(el => new SecretComponent(this.app, el)
|
|
.setValue(this.plugin.settings.personalAccessToken)
|
|
.onChange(async (value) => {
|
|
this.plugin.settings.personalAccessToken = value;
|
|
await this.plugin.saveSettings();
|
|
if (this.plugin.settings.accountId) {
|
|
await this.plugin.fetchCurrentUserId();
|
|
}
|
|
}));
|
|
new Setting(containerEl)
|
|
.setName('Account ID')
|
|
.setDesc('Select or create a secret in SecretStorage containing your Harvest account ID.')
|
|
.addComponent(el => new SecretComponent(this.app, el)
|
|
.setValue(this.plugin.settings.accountId)
|
|
.onChange(async (value) => {
|
|
this.plugin.settings.accountId = value;
|
|
await this.plugin.saveSettings();
|
|
if (this.plugin.settings.personalAccessToken) {
|
|
await this.plugin.fetchCurrentUserId();
|
|
}
|
|
}));
|
|
new Setting(containerEl).setName('Configuration').setHeading();
|
|
new Setting(containerEl)
|
|
.setName('Polling interval')
|
|
.setDesc('How often to check for a running timer, in minutes. Requires a reload to take effect.')
|
|
.addText(text => text
|
|
.setPlaceholder('Default: 5')
|
|
.setValue(String(this.plugin.settings.pollingInterval))
|
|
.onChange(async (value) => {
|
|
const interval = parseInt(value);
|
|
if (!isNaN(interval) && interval > 0) {
|
|
this.plugin.settings.pollingInterval = interval;
|
|
await this.plugin.saveSettings();
|
|
}
|
|
}));
|
|
}
|
|
}
|