Compare commits

..

8 commits
1.0.2 ... main

12 changed files with 101 additions and 51 deletions

21
CHANGELOG.md Normal file
View file

@ -0,0 +1,21 @@
# Changelog
All notable changes to this project will be documented in this file.
## [1.0.5] - 2025-11-21
### Added
- Include folder subfolders as hierarchical `Level1..LevelN` CSV columns. Folder segments under the configured `Customers` folder are now emitted as Level columns before the project filename and markdown headers.
- README examples updated to use generic placeholders (`<CustomerName>`, `<Subfolder>`, `<ProjectName>`, `<Task>`).
### Changed
- CSV header format changed to: `CustomerName,Level1,Level2,...,Task` (project filename moved into `Level` columns to maintain consistent hierarchical ordering).
- CSV writer now uses a canonical `levels` array on parsed tasks which contains: folder segments, project filename, document headers, and parent task text.
### Notes
- The plugin bundle (`main.js`) should be rebuilt after the version bump. Run `npm run build` locally before publishing.
- If you distribute the plugin via GitHub Releases, create a release tagged `v1.0.5` and attach the rebuilt `main.js`, `manifest.json`, and `styles.css`.
---
## Previous releases
- See repository history for older versions.

View file

@ -169,14 +169,26 @@ Standard Markdown task checkboxes:
CSV with dynamic columns (comma-separated by default, configurable to semicolon): CSV with dynamic columns (comma-separated by default, configurable to semicolon):
```csv ```csv
CustomerName,ProjectName,Header1,Header2,Header3,Task CustomerName,Level1,Level2,Level3,Task
Customer A,Project 1,Section,Outstanding task Customer A,Project 1,Section,Outstanding task
Customer A,Project 1,Section,Task with sub-items Customer A,Project 1,Section,Task with sub-items
Customer A,Project 1,Section,Sub-task (nested) Customer A,Project 1,Section,Sub-task (nested)
Customer A,Project 1,Section,Subsection,Another task Customer A,Project 1,Section,Subsection,Another task
``` ```
The delimiter can be changed to semicolon (`;`) in the plugin settings for better compatibility with European Excel versions. Notes:
- **Level columns**: The plugin uses generic `Level1..LevelN` columns for hierarchical path data. These contain, in order: any subfolder segments under the customer folder, then the project (markdown file) name, followed by markdown header segments and parent task text as present.
- **Compression**: When `Compress Levels` is enabled the CSV will omit empty trailing `Level` columns for each row; when disabled rows are padded to the maximum level depth.
- The delimiter can be changed to semicolon (`;`) in the plugin settings for better compatibility with European Excel versions.
Example
For a file under the `Customers` folder such as `Customers/<CustomerName>/<Subfolder...>/<ProjectName>.md` containing a top-level task in a `Section`, a produced CSV row will look like:
```csv
CustomerName,Level1,Level2,Task
<CustomerName>,<Subfolder>,<ProjectName>,Section - Outstanding task
```
## Technical Details ## Technical Details

36
main.js
View file

@ -53,9 +53,8 @@ var TaskExportSettingTab = class extends import_obsidian.PluginSettingTab {
display() { display() {
const { containerEl } = this; const { containerEl } = this;
containerEl.empty(); containerEl.empty();
new import_obsidian.Setting(containerEl).setName("Task Export Tool Settings").setHeading(); new import_obsidian.Setting(containerEl).setName("Output path").setDesc("CSV file location (relative to vault root)").addText((text) => text.setPlaceholder("tasks.csv").setValue(this.plugin.settings.outputPath).onChange((value) => {
new import_obsidian.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((value) => { this.plugin.settings.outputPath = value || "tasks.csv";
this.plugin.settings.outputPath = value || "outstanding_tasks.csv";
void this.plugin.saveSettings(); void this.plugin.saveSettings();
})); }));
new import_obsidian.Setting(containerEl).setName("Customers folder").setDesc("Root folder containing customer files").addText((text) => text.setPlaceholder("Customers").setValue(this.plugin.settings.customersFolder).onChange((value) => { new import_obsidian.Setting(containerEl).setName("Customers folder").setDesc("Root folder containing customer files").addText((text) => text.setPlaceholder("Customers").setValue(this.plugin.settings.customersFolder).onChange((value) => {
@ -88,7 +87,7 @@ var TaskExportSettingTab = class extends import_obsidian.PluginSettingTab {
this.plugin.settings.includeHeader = value; this.plugin.settings.includeHeader = value;
void this.plugin.saveSettings(); void this.plugin.saveSettings();
})); }));
new import_obsidian.Setting(containerEl).setName("CSV delimiter").setDesc("Choose delimiter for CSV output. Comma is standard, semicolon is common in Europe.").addDropdown((dropdown) => dropdown.addOption(",", "Comma (,)").addOption(";", "Semicolon (;)").setValue(this.plugin.settings.delimiter).onChange((value) => { new import_obsidian.Setting(containerEl).setName("CSV delimiter").setDesc("Delimiter for CSV output: comma is standard, semicolon is common in Europe").addDropdown((dropdown) => dropdown.addOption(",", "Comma (,)").addOption(";", "Semicolon (;)").setValue(this.plugin.settings.delimiter).onChange((value) => {
this.plugin.settings.delimiter = value; this.plugin.settings.delimiter = value;
void this.plugin.saveSettings(); void this.plugin.saveSettings();
})); }));
@ -108,7 +107,7 @@ var _MarkdownParser = class {
/** /**
* Parses a markdown file and extracts all outstanding tasks. * Parses a markdown file and extracts all outstanding tasks.
*/ */
parseFile(content, customerName, projectName) { parseFile(content, customerName, projectName, folderSegments = []) {
const tasks = []; const tasks = [];
const lines = content.split("\n"); const lines = content.split("\n");
const headers = []; const headers = [];
@ -156,6 +155,12 @@ var _MarkdownParser = class {
levels: [] levels: []
}; };
const levels = []; const levels = [];
if (folderSegments && folderSegments.length > 0) {
levels.push(...folderSegments);
}
if (projectName && projectName.length > 0) {
levels.push(projectName);
}
levels.push(...headers); levels.push(...headers);
if (parentTaskStack.length > 0) { if (parentTaskStack.length > 0) {
const parents = [...parentTaskStack].reverse(); const parents = [...parentTaskStack].reverse();
@ -217,7 +222,7 @@ var CsvWriter = class {
const maxLevelDepth = compressLevels ? this.determineMaxCompressedLevelDepth(tasks) : this.determineMaxLevelDepth(tasks); const maxLevelDepth = compressLevels ? this.determineMaxCompressedLevelDepth(tasks) : this.determineMaxLevelDepth(tasks);
let csv = ""; let csv = "";
if (includeHeader) { if (includeHeader) {
csv += `CustomerName${this.delimiter}ProjectName`; csv += `CustomerName`;
for (let i = 1; i <= maxLevelDepth; i++) { for (let i = 1; i <= maxLevelDepth; i++) {
csv += `${this.delimiter}Level${i}`; csv += `${this.delimiter}Level${i}`;
} }
@ -226,8 +231,6 @@ var CsvWriter = class {
} }
for (const task of tasks) { for (const task of tasks) {
csv += this.escapeField(task.customerName); csv += this.escapeField(task.customerName);
csv += this.delimiter;
csv += this.escapeField(task.projectName);
if (compressLevels) { if (compressLevels) {
const nonEmptyLevels = task.levels.filter((l) => l !== ""); const nonEmptyLevels = task.levels.filter((l) => l !== "");
for (const level of nonEmptyLevels) { for (const level of nonEmptyLevels) {
@ -314,7 +317,7 @@ var TaskExporter = class {
(file) => file.path.startsWith(normalizedPath + "/") (file) => file.path.startsWith(normalizedPath + "/")
); );
for (const file of customerFiles) { for (const file of customerFiles) {
const { customerName, projectName } = this.extractCustomerAndProject(file, normalizedPath); const { customerName, projectName, folderSegments } = this.extractCustomerAndProject(file, normalizedPath);
if (customerName.startsWith(".")) { if (customerName.startsWith(".")) {
continue; continue;
} }
@ -323,7 +326,7 @@ var TaskExporter = class {
} }
try { try {
const content = await this.app.vault.read(file); const content = await this.app.vault.read(file);
const tasks = this.parser.parseFile(content, customerName, projectName); const tasks = this.parser.parseFile(content, customerName, projectName, folderSegments);
if (tasks.length > 0) { if (tasks.length > 0) {
allTasks.push(...tasks); allTasks.push(...tasks);
if (verbose) { if (verbose) {
@ -355,7 +358,8 @@ var TaskExporter = class {
const parts = relativePath.split("/"); const parts = relativePath.split("/");
const customerName = parts[0]; const customerName = parts[0];
const projectName = file.basename; const projectName = file.basename;
return { customerName, projectName }; const folderSegments = parts.length > 2 ? parts.slice(1, parts.length - 1) : [];
return { customerName, projectName, folderSegments };
} }
}; };
@ -445,23 +449,23 @@ var TaskExportPlugin = class extends import_obsidian3.Plugin {
await this.loadSettings(); await this.loadSettings();
this.exporter = new TaskExporter(this.app); this.exporter = new TaskExporter(this.app);
this.fileWatcher = new FileWatcher( this.fileWatcher = new FileWatcher(
() => this.autoExport(), () => void this.autoExport(),
this.settings.debounceDelay, this.settings.debounceDelay,
this.settings.customersFolder this.settings.customersFolder
); );
this.addRibbonIcon("file-text", "Export Outstanding Tasks", async () => { this.addRibbonIcon("file-text", "Export outstanding tasks", async () => {
await this.exportTasks(); await this.exportTasks();
}); });
this.addCommand({ this.addCommand({
id: "export-tasks", id: "export-tasks",
name: "Export Outstanding Tasks", name: "Export outstanding tasks",
callback: async () => { callback: async () => {
await this.exportTasks(); await this.exportTasks();
} }
}); });
this.addCommand({ this.addCommand({
id: "toggle-auto-export", id: "toggle-auto-export",
name: "Toggle Auto-Export", name: "Toggle auto-export",
callback: async () => { callback: async () => {
this.settings.autoExport = !this.settings.autoExport; this.settings.autoExport = !this.settings.autoExport;
await this.saveSettings(); await this.saveSettings();
@ -472,7 +476,7 @@ var TaskExportPlugin = class extends import_obsidian3.Plugin {
}); });
this.addCommand({ this.addCommand({
id: "open-settings", id: "open-settings",
name: "Open Export Settings", name: "Open export settings",
callback: () => { callback: () => {
this.app.setting.open(); this.app.setting.open();
this.app.setting.openTabById(this.manifest.id); this.app.setting.openTabById(this.manifest.id);

View file

@ -1,7 +1,7 @@
{ {
"id": "task-export-to-csv", "id": "task-export-to-csv",
"name": "Task Export Tool", "name": "Task Export Tool",
"version": "1.0.2", "version": "1.0.5",
"minAppVersion": "1.0.0", "minAppVersion": "1.0.0",
"description": "Export outstanding tasks to CSV for time tracking integration (e.g. ManicTime).", "description": "Export outstanding tasks to CSV for time tracking integration (e.g. ManicTime).",
"author": "Raoul Jacobs", "author": "Raoul Jacobs",

View file

@ -1,6 +1,6 @@
{ {
"name": "obsidian-task-export-plugin", "name": "obsidian-task-export-plugin",
"version": "1.0.2", "version": "1.0.5",
"description": "Export outstanding tasks from Obsidian vault to CSV for ManicTime integration", "description": "Export outstanding tasks from Obsidian vault to CSV for ManicTime integration",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View file

@ -28,7 +28,8 @@ export class CsvWriter {
// Build header row based on max level depth // Build header row based on max level depth
if (includeHeader) { if (includeHeader) {
csv += `CustomerName${this.delimiter}ProjectName`; // Emit CustomerName, then Level columns (first level may be project name or a subfolder), then Task
csv += `CustomerName`;
for (let i = 1; i <= maxLevelDepth; i++) { for (let i = 1; i <= maxLevelDepth; i++) {
csv += `${this.delimiter}Level${i}`; csv += `${this.delimiter}Level${i}`;
} }
@ -38,9 +39,7 @@ export class CsvWriter {
// Write data rows // Write data rows
for (const task of tasks) { for (const task of tasks) {
csv += this.escapeField(task.customerName); csv += this.escapeField(task.customerName);
csv += this.delimiter;
csv += this.escapeField(task.projectName);
if (compressLevels) { if (compressLevels) {
// Compressed mode: only output non-empty levels, skipping empty slots // Compressed mode: only output non-empty levels, skipping empty slots
const nonEmptyLevels = task.levels.filter(l => l !== ''); const nonEmptyLevels = task.levels.filter(l => l !== '');
@ -59,7 +58,6 @@ export class CsvWriter {
} }
} }
} }
csv += this.delimiter; csv += this.delimiter;
csv += this.escapeField(task.task); csv += this.escapeField(task.task);
csv += '\n'; csv += '\n';

View file

@ -43,8 +43,8 @@ export class TaskExporter {
// Process each file // Process each file
for (const file of customerFiles) { for (const file of customerFiles) {
// Extract customer name and project name from file path // Extract customer name, project name and folder segments from file path
const { customerName, projectName } = this.extractCustomerAndProject(file, normalizedPath); const { customerName, projectName, folderSegments } = this.extractCustomerAndProject(file, normalizedPath);
// Skip files in hidden directories // Skip files in hidden directories
if (customerName.startsWith('.')) { if (customerName.startsWith('.')) {
@ -57,7 +57,8 @@ export class TaskExporter {
try { try {
const content = await this.app.vault.read(file); const content = await this.app.vault.read(file);
const tasks = this.parser.parseFile(content, customerName, projectName); // Pass folderSegments into parser so levels can include folder hierarchy
const tasks = this.parser.parseFile(content, customerName, projectName, folderSegments);
if (tasks.length > 0) { if (tasks.length > 0) {
allTasks.push(...tasks); allTasks.push(...tasks);
@ -96,7 +97,7 @@ export class TaskExporter {
* Customer name is the first folder under customersFolder. * Customer name is the first folder under customersFolder.
* Project name is the filename without extension. * Project name is the filename without extension.
*/ */
private extractCustomerAndProject(file: TFile, customersFolder: string): { customerName: string, projectName: string } { private extractCustomerAndProject(file: TFile, customersFolder: string): { customerName: string, projectName: string, folderSegments: string[] } {
// Remove the customers folder prefix from the path // Remove the customers folder prefix from the path
let relativePath = file.path.substring(customersFolder.length + 1); let relativePath = file.path.substring(customersFolder.length + 1);
@ -108,7 +109,11 @@ export class TaskExporter {
// Project name is the filename without extension // Project name is the filename without extension
const projectName = file.basename; const projectName = file.basename;
return { customerName, projectName }; // Folder segments are the intermediate path parts between the customer folder and the file name
// parts = [ customer, sub1, sub2, ..., filename ] so folderSegments should be parts.slice(1, parts.length - 1)
const folderSegments = parts.length > 2 ? parts.slice(1, parts.length - 1) : [];
return { customerName, projectName, folderSegments };
} }
} }

View file

@ -23,20 +23,20 @@ export default class TaskExportPlugin extends Plugin {
// Initialize file watcher // Initialize file watcher
this.fileWatcher = new FileWatcher( this.fileWatcher = new FileWatcher(
() => this.autoExport(), () => void this.autoExport(),
this.settings.debounceDelay, this.settings.debounceDelay,
this.settings.customersFolder this.settings.customersFolder
); );
// Add ribbon icon // Add ribbon icon
this.addRibbonIcon('file-text', 'Export Outstanding Tasks', async () => { this.addRibbonIcon('file-text', 'Export outstanding tasks', async () => {
await this.exportTasks(); await this.exportTasks();
}); });
// Add command: Export Outstanding Tasks // Add command: Export Outstanding Tasks
this.addCommand({ this.addCommand({
id: 'export-tasks', id: 'export-tasks',
name: 'Export Outstanding Tasks', name: 'Export outstanding tasks',
callback: async () => { callback: async () => {
await this.exportTasks(); await this.exportTasks();
} }
@ -45,7 +45,7 @@ export default class TaskExportPlugin extends Plugin {
// Add command: Toggle Auto-Export // Add command: Toggle Auto-Export
this.addCommand({ this.addCommand({
id: 'toggle-auto-export', id: 'toggle-auto-export',
name: 'Toggle Auto-Export', name: 'Toggle auto-export',
callback: async () => { callback: async () => {
this.settings.autoExport = !this.settings.autoExport; this.settings.autoExport = !this.settings.autoExport;
await this.saveSettings(); await this.saveSettings();
@ -59,7 +59,7 @@ export default class TaskExportPlugin extends Plugin {
// Add command: Open Export Settings // Add command: Open Export Settings
this.addCommand({ this.addCommand({
id: 'open-settings', id: 'open-settings',
name: 'Open Export Settings', name: 'Open export settings',
callback: () => { callback: () => {
// @ts-ignore - accessing private API // @ts-ignore - accessing private API
this.app.setting.open(); this.app.setting.open();

View file

@ -13,7 +13,7 @@ export class MarkdownParser {
/** /**
* Parses a markdown file and extracts all outstanding tasks. * Parses a markdown file and extracts all outstanding tasks.
*/ */
parseFile(content: string, customerName: string, projectName: string): TaskItem[] { parseFile(content: string, customerName: string, projectName: string, folderSegments: string[] = []): TaskItem[] {
const tasks: TaskItem[] = []; const tasks: TaskItem[] = [];
const lines = content.split('\n'); const lines = content.split('\n');
@ -91,9 +91,19 @@ export class MarkdownParser {
levels: [] levels: []
}; };
// Add document headers - keep all levels including empty ones for proper structure // Build levels with folder segments first, then document headers
const levels: string[] = []; const levels: string[] = [];
// Insert folder segments (subfolders under the customer folder)
if (folderSegments && folderSegments.length > 0) {
levels.push(...folderSegments);
}
// Insert the project name as a level after folder segments (so it appears after subfolders)
if (projectName && projectName.length > 0) {
levels.push(projectName);
}
// Copy all header levels (including empty ones) to preserve hierarchy // Copy all header levels (including empty ones) to preserve hierarchy
levels.push(...headers); levels.push(...headers);

View file

@ -1,4 +1,4 @@
import { PluginSettingTab, Setting } from 'obsidian'; import { App, PluginSettingTab, Setting } from 'obsidian';
import TaskExportPlugin from './main'; import TaskExportPlugin from './main';
/** /**
@ -7,7 +7,7 @@ import TaskExportPlugin from './main';
export class TaskExportSettingTab extends PluginSettingTab { export class TaskExportSettingTab extends PluginSettingTab {
plugin: TaskExportPlugin; plugin: TaskExportPlugin;
constructor(app: any, plugin: TaskExportPlugin) { constructor(app: App, plugin: TaskExportPlugin) {
super(app, plugin); super(app, plugin);
this.plugin = plugin; this.plugin = plugin;
} }
@ -17,19 +17,15 @@ export class TaskExportSettingTab extends PluginSettingTab {
containerEl.empty(); containerEl.empty();
new Setting(containerEl)
.setName('Task Export Tool Settings')
.setHeading();
// Output Path // Output Path
new Setting(containerEl) new Setting(containerEl)
.setName('Output path') .setName('Output path')
.setDesc('CSV file location (relative to vault root)') .setDesc('CSV file location (relative to vault root)')
.addText(text => text .addText(text => text
.setPlaceholder('outstanding_tasks.csv') .setPlaceholder('tasks.csv')
.setValue(this.plugin.settings.outputPath) .setValue(this.plugin.settings.outputPath)
.onChange((value) => { .onChange((value) => {
this.plugin.settings.outputPath = value || 'outstanding_tasks.csv'; this.plugin.settings.outputPath = value || 'tasks.csv';
void this.plugin.saveSettings(); void this.plugin.saveSettings();
})); }));
@ -116,7 +112,7 @@ export class TaskExportSettingTab extends PluginSettingTab {
// CSV Delimiter // CSV Delimiter
new Setting(containerEl) new Setting(containerEl)
.setName('CSV delimiter') .setName('CSV delimiter')
.setDesc('Choose delimiter for CSV output. Comma is standard, semicolon is common in Europe.') .setDesc('Delimiter for CSV output: comma is standard, semicolon is common in Europe')
.addDropdown(dropdown => dropdown .addDropdown(dropdown => dropdown
.addOption(',', 'Comma (,)') .addOption(',', 'Comma (,)')
.addOption(';', 'Semicolon (;)') .addOption(';', 'Semicolon (;)')

View file

@ -36,6 +36,8 @@ export const DEFAULT_SETTINGS: TaskExportSettings = {
export interface TaskItem { export interface TaskItem {
customerName: string; customerName: string;
projectName: string; projectName: string;
levels: string[]; // Hierarchical headers and parent tasks // `levels` contains folder segments (subfolders under the customer),
// followed by document header levels (##, ###, ...), then parent task texts.
levels: string[];
task: string; // The actual task text task: string; // The actual task text
} }

View file

@ -1,5 +1,7 @@
{ {
"1.0.0": "1.0.0", "1.0.0": "1.0.0",
"1.0.1": "1.0.0", "1.0.1": "1.0.0",
"1.0.2": "1.0.0" "1.0.2": "1.0.0",
"1.0.3": "1.0.0",
"1.0.4": "1.0.0"
} }