nwbort_obsidian-harvest/main.ts
2026-05-18 05:46:58 +00:00

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();
}
}));
}
}