mirror of
https://github.com/tailormade-eu/obsidian-task-export-plugin.git
synced 2026-07-22 06:41:42 +00:00
Implement complete Obsidian Task Export Plugin
Co-authored-by: RaoulJacobs <18635340+RaoulJacobs@users.noreply.github.com>
This commit is contained in:
parent
b5292f6d23
commit
883a134221
17 changed files with 3456 additions and 0 deletions
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Node modules
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
main.js
|
||||
main.js.map
|
||||
*.js.map
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# npm package files
|
||||
*.tgz
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.log
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 Raoul Jacobs
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
48
esbuild.config.mjs
Normal file
48
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = (process.argv[2] === 'production');
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ['src/main.ts'],
|
||||
bundle: true,
|
||||
external: [
|
||||
'obsidian',
|
||||
'electron',
|
||||
'@codemirror/autocomplete',
|
||||
'@codemirror/collab',
|
||||
'@codemirror/commands',
|
||||
'@codemirror/language',
|
||||
'@codemirror/lint',
|
||||
'@codemirror/search',
|
||||
'@codemirror/state',
|
||||
'@codemirror/view',
|
||||
'@lezer/common',
|
||||
'@lezer/highlight',
|
||||
'@lezer/lr',
|
||||
...builtins],
|
||||
format: 'cjs',
|
||||
target: 'es2018',
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : 'inline',
|
||||
treeShaking: true,
|
||||
outfile: 'main.js',
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "task-export-plugin",
|
||||
"name": "Task Export Tool",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "1.0.0",
|
||||
"description": "Export outstanding tasks to CSV for time tracking integration",
|
||||
"author": "Raoul Jacobs",
|
||||
"authorUrl": "https://github.com/tailormade-eu",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
2406
package-lock.json
generated
Normal file
2406
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
31
package.json
Normal file
31
package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "obsidian-task-export-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "Export outstanding tasks from Obsidian vault to CSV for ManicTime integration",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [
|
||||
"obsidian",
|
||||
"plugin",
|
||||
"tasks",
|
||||
"export",
|
||||
"csv",
|
||||
"time-tracking"
|
||||
],
|
||||
"author": "Raoul Jacobs",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"obsidian": "latest",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4"
|
||||
}
|
||||
}
|
||||
119
src/csv-writer.ts
Normal file
119
src/csv-writer.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { TaskItem } from './types';
|
||||
|
||||
/**
|
||||
* Exports tasks to CSV format with proper escaping.
|
||||
* Ported from C# CsvExporter.cs
|
||||
*/
|
||||
export class CsvWriter {
|
||||
/**
|
||||
* Generates CSV content from tasks with UTF-8 BOM encoding.
|
||||
*/
|
||||
generateCsv(tasks: TaskItem[], compressLevels: boolean = false, includeHeader: boolean = true): string {
|
||||
if (tasks.length === 0) {
|
||||
throw new Error('No tasks to export');
|
||||
}
|
||||
|
||||
// Determine the maximum level depth based on compression mode
|
||||
const maxLevelDepth = compressLevels
|
||||
? this.determineMaxCompressedLevelDepth(tasks)
|
||||
: this.determineMaxLevelDepth(tasks);
|
||||
|
||||
let csv = '';
|
||||
|
||||
// Build header row based on max level depth
|
||||
if (includeHeader) {
|
||||
csv += 'CustomerName,ProjectName';
|
||||
for (let i = 1; i <= maxLevelDepth; i++) {
|
||||
csv += `,Level${i}`;
|
||||
}
|
||||
csv += ',Task\n';
|
||||
}
|
||||
|
||||
// Write data rows
|
||||
for (const task of tasks) {
|
||||
csv += this.escapeField(task.customerName);
|
||||
csv += ',';
|
||||
csv += this.escapeField(task.projectName);
|
||||
|
||||
if (compressLevels) {
|
||||
// Compressed mode: only output non-empty levels, skipping empty slots
|
||||
const nonEmptyLevels = task.levels.filter(l => l !== '');
|
||||
|
||||
// Output non-empty levels (no padding needed - each row can have different column count)
|
||||
for (const level of nonEmptyLevels) {
|
||||
csv += ',';
|
||||
csv += this.escapeField(level);
|
||||
}
|
||||
} else {
|
||||
// Non-compressed mode: output all levels including empty slots to preserve hierarchy
|
||||
for (let i = 0; i < maxLevelDepth; i++) {
|
||||
csv += ',';
|
||||
if (i < task.levels.length) {
|
||||
csv += this.escapeField(task.levels[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
csv += ',';
|
||||
csv += this.escapeField(task.task);
|
||||
csv += '\n';
|
||||
}
|
||||
|
||||
// Add UTF-8 BOM for Excel compatibility
|
||||
return '\uFEFF' + csv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the maximum level depth across all tasks (includes empty slots).
|
||||
*/
|
||||
private determineMaxLevelDepth(tasks: TaskItem[]): number {
|
||||
let maxDepth = 0;
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.levels.length > maxDepth) {
|
||||
maxDepth = task.levels.length;
|
||||
}
|
||||
}
|
||||
|
||||
return maxDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the maximum number of non-empty levels across all tasks.
|
||||
*/
|
||||
private determineMaxCompressedLevelDepth(tasks: TaskItem[]): number {
|
||||
let maxDepth = 0;
|
||||
|
||||
for (const task of tasks) {
|
||||
const nonEmptyCount = task.levels.filter(l => l !== '').length;
|
||||
if (nonEmptyCount > maxDepth) {
|
||||
maxDepth = nonEmptyCount;
|
||||
}
|
||||
}
|
||||
|
||||
return maxDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes a CSV field according to RFC 4180.
|
||||
* - Wraps in quotes if contains comma, newline, or quote
|
||||
* - Doubles any quotes inside the field
|
||||
*/
|
||||
private escapeField(field: string): string {
|
||||
if (!field) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Check if escaping is needed
|
||||
const needsQuotes = field.includes(',') || field.includes('"') || field.includes('\n') || field.includes('\r');
|
||||
|
||||
if (needsQuotes) {
|
||||
// Double any existing quotes
|
||||
field = field.replace(/"/g, '""');
|
||||
// Wrap in quotes
|
||||
return `"${field}"`;
|
||||
}
|
||||
|
||||
return field;
|
||||
}
|
||||
}
|
||||
111
src/exporter.ts
Normal file
111
src/exporter.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { App, TFile, normalizePath } from 'obsidian';
|
||||
import { TaskItem } from './types';
|
||||
import { MarkdownParser } from './parser';
|
||||
import { CsvWriter } from './csv-writer';
|
||||
|
||||
/**
|
||||
* Extracts tasks from a directory structure of markdown files.
|
||||
* Ported from C# TaskExtractor.cs
|
||||
*/
|
||||
export class TaskExporter {
|
||||
private parser: MarkdownParser;
|
||||
private csvWriter: CsvWriter;
|
||||
private app: App;
|
||||
|
||||
constructor(app: App) {
|
||||
this.app = app;
|
||||
this.parser = new MarkdownParser();
|
||||
this.csvWriter = new CsvWriter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all outstanding tasks from the Customers directory.
|
||||
*/
|
||||
async extractTasks(customersFolder: string, verbose: boolean = false): Promise<TaskItem[]> {
|
||||
const allTasks: TaskItem[] = [];
|
||||
|
||||
const normalizedPath = normalizePath(customersFolder);
|
||||
|
||||
// Check if folder exists
|
||||
const folder = this.app.vault.getAbstractFileByPath(normalizedPath);
|
||||
if (!folder) {
|
||||
console.error(`Customers directory not found: ${normalizedPath}`);
|
||||
return allTasks;
|
||||
}
|
||||
|
||||
// Get all markdown files in the vault
|
||||
const allFiles = this.app.vault.getMarkdownFiles();
|
||||
|
||||
// Filter files that are in the customers folder
|
||||
const customerFiles = allFiles.filter(file =>
|
||||
file.path.startsWith(normalizedPath + '/')
|
||||
);
|
||||
|
||||
// Process each file
|
||||
for (const file of customerFiles) {
|
||||
// Extract customer name and project name from file path
|
||||
const { customerName, projectName } = this.extractCustomerAndProject(file, normalizedPath);
|
||||
|
||||
// Skip files in hidden directories
|
||||
if (customerName.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.log(`Processing: ${customerName} / ${projectName}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await this.app.vault.read(file);
|
||||
const tasks = this.parser.parseFile(content, customerName, projectName);
|
||||
|
||||
if (tasks.length > 0) {
|
||||
allTasks.push(...tasks);
|
||||
|
||||
if (verbose) {
|
||||
console.log(` Found ${tasks.length} task(s)`);
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
console.warn(`Failed to process file ${file.path}:`, ex);
|
||||
}
|
||||
}
|
||||
|
||||
return allTasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports tasks to CSV file.
|
||||
*/
|
||||
async exportToFile(
|
||||
tasks: TaskItem[],
|
||||
outputPath: string,
|
||||
compressLevels: boolean = false,
|
||||
includeHeader: boolean = true
|
||||
): Promise<void> {
|
||||
const csv = this.csvWriter.generateCsv(tasks, compressLevels, includeHeader);
|
||||
const normalizedPath = normalizePath(outputPath);
|
||||
await this.app.vault.adapter.write(normalizedPath, csv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts customer name and project name from file path.
|
||||
* Customer name is the first folder under customersFolder.
|
||||
* Project name is the filename without extension.
|
||||
*/
|
||||
private extractCustomerAndProject(file: TFile, customersFolder: string): { customerName: string, projectName: string } {
|
||||
// Remove the customers folder prefix from the path
|
||||
let relativePath = file.path.substring(customersFolder.length + 1);
|
||||
|
||||
// Split into parts
|
||||
const parts = relativePath.split('/');
|
||||
|
||||
// Customer name is the first folder
|
||||
const customerName = parts[0];
|
||||
|
||||
// Project name is the filename without extension
|
||||
const projectName = file.basename;
|
||||
|
||||
return { customerName, projectName };
|
||||
}
|
||||
}
|
||||
98
src/file-watcher.ts
Normal file
98
src/file-watcher.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { TFile } from 'obsidian';
|
||||
|
||||
/**
|
||||
* Manages file watching with debouncing for automatic export.
|
||||
*/
|
||||
export class FileWatcher {
|
||||
private debounceTimer: number | null = null;
|
||||
private callback: () => void;
|
||||
private debounceDelay: number;
|
||||
private isEnabled: boolean = false;
|
||||
private customersFolder: string;
|
||||
|
||||
constructor(callback: () => void, debounceDelay: number, customersFolder: string) {
|
||||
this.callback = callback;
|
||||
this.debounceDelay = debounceDelay * 1000; // Convert to milliseconds
|
||||
this.customersFolder = customersFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the debounce delay.
|
||||
*/
|
||||
setDebounceDelay(seconds: number): void {
|
||||
this.debounceDelay = seconds * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the customers folder path.
|
||||
*/
|
||||
setCustomersFolder(folder: string): void {
|
||||
this.customersFolder = folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers the debounced callback.
|
||||
*/
|
||||
trigger(): void {
|
||||
if (!this.isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing timer
|
||||
if (this.debounceTimer !== null) {
|
||||
window.clearTimeout(this.debounceTimer);
|
||||
}
|
||||
|
||||
// Set new timer
|
||||
this.debounceTimer = window.setTimeout(() => {
|
||||
this.callback();
|
||||
this.debounceTimer = null;
|
||||
}, this.debounceDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file should trigger an export.
|
||||
*/
|
||||
shouldTrigger(file: TFile | null): boolean {
|
||||
if (!this.isEnabled || !file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only trigger for markdown files
|
||||
if (file.extension !== 'md') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only trigger for files in the customers folder
|
||||
if (!file.path.startsWith(this.customersFolder + '/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables the file watcher.
|
||||
*/
|
||||
enable(): void {
|
||||
this.isEnabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables the file watcher and cancels any pending callbacks.
|
||||
*/
|
||||
disable(): void {
|
||||
this.isEnabled = false;
|
||||
if (this.debounceTimer !== null) {
|
||||
window.clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the watcher is enabled.
|
||||
*/
|
||||
isWatcherEnabled(): boolean {
|
||||
return this.isEnabled;
|
||||
}
|
||||
}
|
||||
226
src/main.ts
Normal file
226
src/main.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import { App, Notice, Plugin, TFile } from 'obsidian';
|
||||
import { TaskExportSettings, DEFAULT_SETTINGS } from './types';
|
||||
import { TaskExportSettingTab } from './settings';
|
||||
import { TaskExporter } from './exporter';
|
||||
import { FileWatcher } from './file-watcher';
|
||||
|
||||
/**
|
||||
* Main plugin class for Task Export Tool.
|
||||
*/
|
||||
export default class TaskExportPlugin extends Plugin {
|
||||
settings: TaskExportSettings;
|
||||
exporter: TaskExporter;
|
||||
fileWatcher: FileWatcher;
|
||||
statusBarItem: HTMLElement;
|
||||
lastExportTime: Date | null = null;
|
||||
exportInProgress: boolean = false;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
||||
// Initialize exporter
|
||||
this.exporter = new TaskExporter(this.app);
|
||||
|
||||
// Initialize file watcher
|
||||
this.fileWatcher = new FileWatcher(
|
||||
() => this.autoExport(),
|
||||
this.settings.debounceDelay,
|
||||
this.settings.customersFolder
|
||||
);
|
||||
|
||||
// Add ribbon icon
|
||||
this.addRibbonIcon('file-text', 'Export Outstanding Tasks', async () => {
|
||||
await this.exportTasks();
|
||||
});
|
||||
|
||||
// Add command: Export Outstanding Tasks
|
||||
this.addCommand({
|
||||
id: 'export-tasks',
|
||||
name: 'Export Outstanding Tasks',
|
||||
callback: async () => {
|
||||
await this.exportTasks();
|
||||
}
|
||||
});
|
||||
|
||||
// Add command: Toggle Auto-Export
|
||||
this.addCommand({
|
||||
id: 'toggle-auto-export',
|
||||
name: 'Toggle Auto-Export',
|
||||
callback: async () => {
|
||||
this.settings.autoExport = !this.settings.autoExport;
|
||||
await this.saveSettings();
|
||||
this.updateFileWatcher();
|
||||
|
||||
const status = this.settings.autoExport ? 'enabled' : 'disabled';
|
||||
new Notice(`Auto-export ${status}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Add command: Open Export Settings
|
||||
this.addCommand({
|
||||
id: 'open-settings',
|
||||
name: 'Open Export Settings',
|
||||
callback: () => {
|
||||
// @ts-ignore - accessing private API
|
||||
this.app.setting.open();
|
||||
// @ts-ignore - accessing private API
|
||||
this.app.setting.openTabById(this.manifest.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Add settings tab
|
||||
this.addSettingTab(new TaskExportSettingTab(this.app, this));
|
||||
|
||||
// Add status bar item
|
||||
this.statusBarItem = this.addStatusBarItem();
|
||||
this.updateStatusBar();
|
||||
|
||||
// Register file events
|
||||
this.registerFileEvents();
|
||||
|
||||
// Update file watcher state
|
||||
this.updateFileWatcher();
|
||||
|
||||
console.log('Task Export Plugin loaded');
|
||||
}
|
||||
|
||||
onunload() {
|
||||
// Disable file watcher
|
||||
this.fileWatcher.disable();
|
||||
console.log('Task Export Plugin unloaded');
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers file modification events.
|
||||
*/
|
||||
private registerFileEvents() {
|
||||
// Register vault modify event (fires when files are modified)
|
||||
this.registerEvent(
|
||||
this.app.vault.on('modify', (file) => {
|
||||
if (file instanceof TFile && this.fileWatcher.shouldTrigger(file)) {
|
||||
if (this.settings.exportOnModify || this.settings.exportOnSave) {
|
||||
this.fileWatcher.trigger();
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the file watcher based on current settings.
|
||||
*/
|
||||
updateFileWatcher() {
|
||||
if (this.settings.autoExport) {
|
||||
this.fileWatcher.enable();
|
||||
this.fileWatcher.setDebounceDelay(this.settings.debounceDelay);
|
||||
this.fileWatcher.setCustomersFolder(this.settings.customersFolder);
|
||||
} else {
|
||||
this.fileWatcher.disable();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs automatic export (called by file watcher).
|
||||
*/
|
||||
private async autoExport() {
|
||||
try {
|
||||
await this.exportTasks(false); // Silent mode for auto-export
|
||||
} catch (error) {
|
||||
console.error('Auto-export failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports tasks to CSV.
|
||||
*/
|
||||
async exportTasks(showNotice: boolean = true) {
|
||||
// Prevent concurrent exports
|
||||
if (this.exportInProgress) {
|
||||
if (showNotice) {
|
||||
new Notice('Export already in progress...');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.exportInProgress = true;
|
||||
|
||||
try {
|
||||
// Extract tasks
|
||||
const tasks = await this.exporter.extractTasks(this.settings.customersFolder, false);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
if (showNotice && this.settings.showNotifications) {
|
||||
new Notice('No outstanding tasks found');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Export to CSV
|
||||
await this.exporter.exportToFile(
|
||||
tasks,
|
||||
this.settings.outputPath,
|
||||
this.settings.compressLevels,
|
||||
this.settings.includeHeader
|
||||
);
|
||||
|
||||
// Update last export time
|
||||
this.lastExportTime = new Date();
|
||||
this.updateStatusBar();
|
||||
|
||||
// Show success notification
|
||||
if (showNotice && this.settings.showNotifications) {
|
||||
new Notice(`Exported ${tasks.length} task(s) to ${this.settings.outputPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
if (showNotice && this.settings.showNotifications) {
|
||||
new Notice(`Export failed: ${error.message}`);
|
||||
}
|
||||
} finally {
|
||||
this.exportInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status bar with last export time.
|
||||
*/
|
||||
private updateStatusBar() {
|
||||
if (!this.lastExportTime) {
|
||||
this.statusBarItem.setText('');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - this.lastExportTime.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
|
||||
let timeText = '';
|
||||
if (minutes < 1) {
|
||||
timeText = 'just now';
|
||||
} else if (minutes === 1) {
|
||||
timeText = '1 minute ago';
|
||||
} else if (minutes < 60) {
|
||||
timeText = `${minutes} minutes ago`;
|
||||
} else {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours === 1) {
|
||||
timeText = '1 hour ago';
|
||||
} else {
|
||||
timeText = `${hours} hours ago`;
|
||||
}
|
||||
}
|
||||
|
||||
this.statusBarItem.setText(`✅ Last export: ${timeText}`);
|
||||
|
||||
// Update again in a minute
|
||||
window.setTimeout(() => this.updateStatusBar(), 60000);
|
||||
}
|
||||
}
|
||||
153
src/parser.ts
Normal file
153
src/parser.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { TaskItem } from './types';
|
||||
|
||||
/**
|
||||
* Parses markdown files to extract outstanding tasks with their hierarchical context.
|
||||
* Ported from C# MarkdownParser.cs
|
||||
*/
|
||||
export class MarkdownParser {
|
||||
private static readonly HEADER_REGEX = /^(#{2,})\s+(.+)$/;
|
||||
private static readonly TASK_REGEX = /^(\s*)-\s+\[([ ])\]\s+(.+)$/;
|
||||
private static readonly COMPLETED_TASK_REGEX = /^(\s*)-\s+\[(x|X)\]/;
|
||||
private static readonly CHECKMARK_REGEX = /✅\s+\d{4}-\d{2}-\d{2}/;
|
||||
|
||||
/**
|
||||
* Parses a markdown file and extracts all outstanding tasks.
|
||||
*/
|
||||
parseFile(content: string, customerName: string, projectName: string): TaskItem[] {
|
||||
const tasks: TaskItem[] = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Use an array to track headers at any depth (0-indexed, where 0 is ##, 1 is ###, etc.)
|
||||
const headers: string[] = [];
|
||||
|
||||
// Track parent tasks for nested structure
|
||||
const parentTaskStack: Array<{indentLevel: number, taskText: string}> = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Check for headers (##, ###, ####, etc.)
|
||||
const headerMatch = line.match(MarkdownParser.HEADER_REGEX);
|
||||
if (headerMatch) {
|
||||
const headerLevel = headerMatch[1].length - 1; // Subtract 1 because ## is level 1 (index 0)
|
||||
const headerText = headerMatch[2].trim();
|
||||
|
||||
// Ensure the headers array is large enough
|
||||
while (headers.length <= headerLevel) {
|
||||
headers.push('');
|
||||
}
|
||||
|
||||
// Set this header level
|
||||
headers[headerLevel] = headerText;
|
||||
|
||||
// Clear any deeper levels
|
||||
for (let j = headerLevel + 1; j < headers.length; j++) {
|
||||
headers[j] = '';
|
||||
}
|
||||
|
||||
// Clear parent task stack when we hit a new header
|
||||
parentTaskStack.length = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip completed tasks
|
||||
if (MarkdownParser.COMPLETED_TASK_REGEX.test(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for unchecked tasks
|
||||
const taskMatch = line.match(MarkdownParser.TASK_REGEX);
|
||||
if (taskMatch) {
|
||||
const indent = taskMatch[1].length;
|
||||
const taskText = taskMatch[3].trim();
|
||||
|
||||
// Skip tasks with checkmark indicators
|
||||
if (MarkdownParser.CHECKMARK_REGEX.test(taskText)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this task has sub-tasks by looking ahead
|
||||
const hasSubTasks = this.hasSubTasks(lines, i + 1, indent);
|
||||
|
||||
if (hasSubTasks) {
|
||||
// This is a parent task, push it onto the stack
|
||||
// Remove any parent tasks at the same or deeper level
|
||||
while (parentTaskStack.length > 0 && parentTaskStack[parentTaskStack.length - 1].indentLevel >= indent) {
|
||||
parentTaskStack.pop();
|
||||
}
|
||||
parentTaskStack.push({indentLevel: indent, taskText: taskText});
|
||||
} else {
|
||||
// This is a task to export
|
||||
// Remove parent tasks at the same or deeper level
|
||||
while (parentTaskStack.length > 0 && parentTaskStack[parentTaskStack.length - 1].indentLevel >= indent) {
|
||||
parentTaskStack.pop();
|
||||
}
|
||||
|
||||
// Build the task item with proper header hierarchy
|
||||
const task: TaskItem = {
|
||||
customerName: customerName,
|
||||
projectName: projectName,
|
||||
task: taskText,
|
||||
levels: []
|
||||
};
|
||||
|
||||
// Add document headers - keep all levels including empty ones for proper structure
|
||||
const levels: string[] = [];
|
||||
|
||||
// Copy all header levels (including empty ones) to preserve hierarchy
|
||||
levels.push(...headers);
|
||||
|
||||
// Add parent task levels
|
||||
if (parentTaskStack.length > 0) {
|
||||
const parents = [...parentTaskStack].reverse();
|
||||
for (const parent of parents) {
|
||||
levels.push(parent.taskText);
|
||||
}
|
||||
}
|
||||
|
||||
task.levels = levels;
|
||||
tasks.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a task has sub-tasks by looking at following lines.
|
||||
*/
|
||||
private hasSubTasks(lines: string[], startIndex: number, currentIndent: number): boolean {
|
||||
for (let i = startIndex; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Empty lines don't matter
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we hit a header, no more sub-tasks
|
||||
if (MarkdownParser.HEADER_REGEX.test(line)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this is a task
|
||||
const taskMatch = line.match(MarkdownParser.TASK_REGEX);
|
||||
if (taskMatch) {
|
||||
const indent = taskMatch[1].length;
|
||||
|
||||
// If indented more than current task, it's a sub-task
|
||||
if (indent > currentIndent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If at same or less indent, no sub-tasks
|
||||
if (indent <= currentIndent) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
128
src/settings.ts
Normal file
128
src/settings.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||
import TaskExportPlugin from './main';
|
||||
|
||||
/**
|
||||
* Settings tab for the Task Export plugin.
|
||||
*/
|
||||
export class TaskExportSettingTab extends PluginSettingTab {
|
||||
plugin: TaskExportPlugin;
|
||||
|
||||
constructor(app: App, plugin: TaskExportPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
|
||||
containerEl.empty();
|
||||
|
||||
containerEl.createEl('h2', { text: 'Task Export Tool Settings' });
|
||||
|
||||
// Output Path
|
||||
new Setting(containerEl)
|
||||
.setName('Output path')
|
||||
.setDesc('CSV file location (relative to vault root)')
|
||||
.addText(text => text
|
||||
.setPlaceholder('outstanding_tasks.csv')
|
||||
.setValue(this.plugin.settings.outputPath)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.outputPath = value || 'outstanding_tasks.csv';
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// Customers Folder
|
||||
new Setting(containerEl)
|
||||
.setName('Customers folder')
|
||||
.setDesc('Root folder containing customer files')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Customers')
|
||||
.setValue(this.plugin.settings.customersFolder)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.customersFolder = value || 'Customers';
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.updateFileWatcher();
|
||||
}));
|
||||
|
||||
// Auto Export Toggle
|
||||
new Setting(containerEl)
|
||||
.setName('Auto export')
|
||||
.setDesc('Automatically export tasks when files change')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.autoExport)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.autoExport = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.updateFileWatcher();
|
||||
}));
|
||||
|
||||
// Export on Save
|
||||
new Setting(containerEl)
|
||||
.setName('Export on save')
|
||||
.setDesc('Trigger export when files are saved')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.exportOnSave)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.exportOnSave = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// Export on Modify
|
||||
new Setting(containerEl)
|
||||
.setName('Export on modify')
|
||||
.setDesc('Trigger export when files are modified (more frequent)')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.exportOnModify)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.exportOnModify = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// Show Notifications
|
||||
new Setting(containerEl)
|
||||
.setName('Show notifications')
|
||||
.setDesc('Display notifications on export completion')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.showNotifications)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.showNotifications = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// Compress Levels
|
||||
new Setting(containerEl)
|
||||
.setName('Compress levels')
|
||||
.setDesc('Remove empty hierarchy columns from CSV output')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.compressLevels)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.compressLevels = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// Include Header
|
||||
new Setting(containerEl)
|
||||
.setName('Include header')
|
||||
.setDesc('Include CSV header row in output')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.includeHeader)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.includeHeader = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// Debounce Delay
|
||||
new Setting(containerEl)
|
||||
.setName('Debounce delay')
|
||||
.setDesc('Wait time after changes before exporting (1-30 seconds)')
|
||||
.addSlider(slider => slider
|
||||
.setLimits(1, 30, 1)
|
||||
.setValue(this.plugin.settings.debounceDelay)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.debounceDelay = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.updateFileWatcher();
|
||||
}));
|
||||
}
|
||||
}
|
||||
39
src/types.ts
Normal file
39
src/types.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Settings for the Task Export plugin
|
||||
*/
|
||||
export interface TaskExportSettings {
|
||||
outputPath: string; // CSV output path (relative to vault)
|
||||
customersFolder: string; // Root customer folder path
|
||||
autoExport: boolean; // Enable auto-export
|
||||
exportOnSave: boolean; // Trigger on file save
|
||||
exportOnModify: boolean; // Trigger on file modify
|
||||
showNotifications: boolean; // Show export notifications
|
||||
compressLevels: boolean; // Remove empty hierarchy columns
|
||||
includeHeader: boolean; // Include CSV header row
|
||||
debounceDelay: number; // Debounce delay in seconds (1-30)
|
||||
}
|
||||
|
||||
/**
|
||||
* Default settings values
|
||||
*/
|
||||
export const DEFAULT_SETTINGS: TaskExportSettings = {
|
||||
outputPath: 'outstanding_tasks.csv',
|
||||
customersFolder: 'Customers',
|
||||
autoExport: false,
|
||||
exportOnSave: true,
|
||||
exportOnModify: false,
|
||||
showNotifications: true,
|
||||
compressLevels: false,
|
||||
includeHeader: true,
|
||||
debounceDelay: 3
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a task extracted from a markdown file with its hierarchical context.
|
||||
*/
|
||||
export interface TaskItem {
|
||||
customerName: string;
|
||||
projectName: string;
|
||||
levels: string[]; // Hierarchical headers and parent tasks
|
||||
task: string; // The actual task text
|
||||
}
|
||||
3
styles.css
Normal file
3
styles.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/* Task Export Plugin Styles */
|
||||
|
||||
/* Add any custom styling for the plugin here if needed */
|
||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
14
version-bump.mjs
Normal file
14
version-bump.mjs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
// read minAppVersion from manifest.json and bump version to target version
|
||||
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "1.0.0"
|
||||
}
|
||||
Loading…
Reference in a new issue