Compare commits

...

22 commits
1.0.0 ... main

Author SHA1 Message Date
Raoul Jacobs
fcc0c3f4f0 chore(release): 1.0.5 include folder levels and README updates 2025-11-21 19:47:15 +01:00
Raoul Jacobs
6fdae87c5a docs: Update README to clarify CSV export format and hierarchical data representation 2025-11-21 19:44:59 +01:00
Raoul Jacobs
5c98896917 feat: Enhance task parsing to include folder hierarchy in CSV export 2025-11-21 19:42:17 +01:00
Raoul Jacobs
01a5d99d0b feat: Enhance CSV export by including folder hierarchy and refining task parsing 2025-11-21 19:42:07 +01:00
Raoul Jacobs
ea5ea0f4a1 Release v1.0.4 - CSV description refinement 2025-11-17 10:07:40 +01:00
Raoul Jacobs
97db6c49ca Release v1.0.3 - UI text improvements 2025-11-17 09:52:10 +01:00
Raoul Jacobs
3ae738b61a refactor: Standardize task export command and setting names to lowercase 2025-11-17 08:43:50 +01:00
Raoul Jacobs
12f39a7bba fix: Use void operator in file watcher to prevent unhandled promise rejections 2025-11-17 07:39:15 +01:00
Raoul Jacobs
b1ac40acf6 fix: Update .gitignore to retain main.js for plugin distribution
feat: Add main.js file generated by ESBuild for task export functionality
2025-11-16 16:03:25 +01:00
RaoulJacobs
c1aa33783a
Merge pull request #3 from tailormade-eu/copilot/improve-console-methods
Address Obsidian review feedback for v1.0.2 release
2025-11-15 09:51:16 +01:00
copilot-swe-agent[bot]
cd731a4231 Address Obsidian plugin review feedback for version 1.0.2
Co-authored-by: RaoulJacobs <18635340+RaoulJacobs@users.noreply.github.com>
2025-11-15 08:22:15 +00:00
copilot-swe-agent[bot]
02a9a5e0c3 Initial plan 2025-11-15 08:15:09 +00:00
RaoulJacobs
8eda84ca3a
Merge pull request #2 from tailormade-eu/copilot/implement-delimiter-feature-update
Add configurable CSV delimiter (comma/semicolon) for v1.0.1
2025-11-15 09:09:01 +01:00
copilot-swe-agent[bot]
1ba60a8ccb feat: Add configurable CSV delimiter feature (comma/semicolon)
Co-authored-by: RaoulJacobs <18635340+RaoulJacobs@users.noreply.github.com>
2025-11-14 14:02:50 +00:00
copilot-swe-agent[bot]
a7cda7916d Initial commit: Planning delimiter feature implementation
Co-authored-by: RaoulJacobs <18635340+RaoulJacobs@users.noreply.github.com>
2025-11-14 13:59:57 +00:00
copilot-swe-agent[bot]
b5fe601584 Initial plan 2025-11-14 13:57:45 +00:00
Raoul Jacobs
4aec30b703 Bump version to 1.0.1 and update implementation prompt 2025-11-14 14:56:55 +01:00
Raoul Jacobs
b135489121 Add configurable CSV delimiter feature to Obsidian plugin 2025-11-14 14:49:12 +01:00
Raoul Jacobs
3086921473 Add ManicTime reference to description 2025-11-14 14:11:27 +01:00
Raoul Jacobs
c7008f29e2 Change plugin ID to task-export-to-csv 2025-11-14 13:59:58 +01:00
Raoul Jacobs
c686648e33 Fix plugin ID: remove 'plugin' from id, add description punctuation 2025-11-14 13:57:59 +01:00
Raoul Jacobs
30b11493ad Update license year to 2025 and remove unnecessary peer dependencies from package-lock.json 2025-11-14 13:26:05 +01:00
16 changed files with 1126 additions and 74 deletions

4
.gitignore vendored
View file

@ -1,8 +1,8 @@
# Node modules # Node modules
node_modules/ node_modules/
# Build output # Build output (keep main.js for plugin distribution)
main.js # main.js should be committed for Obsidian plugin releases
main.js.map main.js.map
*.js.map *.js.map

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

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2024 Raoul Jacobs Copyright (c) 2025 Raoul Jacobs
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View file

@ -36,7 +36,7 @@ This is a TypeScript port of the [markdown-task-export](https://github.com/tailo
1. Download the latest release from [GitHub Releases](https://github.com/tailormade-eu/obsidian-task-export-plugin/releases) 1. Download the latest release from [GitHub Releases](https://github.com/tailormade-eu/obsidian-task-export-plugin/releases)
2. Extract `main.js`, `manifest.json`, and `styles.css` to: 2. Extract `main.js`, `manifest.json`, and `styles.css` to:
``` ```
VaultFolder/.obsidian/plugins/task-export-plugin/ VaultFolder/.obsidian/plugins/task-export-to-csv/
``` ```
3. Reload Obsidian 3. Reload Obsidian
4. Enable plugin in Settings → Community Plugins 4. Enable plugin in Settings → Community Plugins
@ -105,8 +105,13 @@ View last export time and status in bottom status bar:
| **Export on Save** | Trigger on file save | `true` | Toggle | | **Export on Save** | Trigger on file save | `true` | Toggle |
| **Export on Modify** | Trigger on any modification | `false` | Toggle | | **Export on Modify** | Trigger on any modification | `false` | Toggle |
| **Show Notifications** | Display export notifications | `true` | Toggle | | **Show Notifications** | Display export notifications | `true` | Toggle |
| **CSV Delimiter** | Choose comma or semicolon delimiter | `,` (comma) | Dropdown |
| **Compress Levels** | Remove empty hierarchy columns | `false` | Toggle |
| **Include Header** | Include CSV header row in output | `true` | Toggle |
| **Debounce Delay** | Wait time after changes (seconds) | `3` | Number | | **Debounce Delay** | Wait time after changes (seconds) | `3` | Number |
**Note for European Users**: If Excel doesn't open the CSV correctly, try changing the delimiter to semicolon in settings. Many European locales use semicolon as the standard CSV delimiter since comma is used as the decimal separator.
### Accessing Settings ### Accessing Settings
Settings → Plugin Options → Task Export Tool Settings → Plugin Options → Task Export Tool
@ -139,7 +144,7 @@ YourVault/
├── outstanding_tasks.csv ← Output file ├── outstanding_tasks.csv ← Output file
└── .obsidian/ └── .obsidian/
└── plugins/ └── plugins/
└── task-export-plugin/ └── task-export-to-csv/
``` ```
### Markdown Task Format ### Markdown Task Format
@ -161,16 +166,30 @@ Standard Markdown task checkboxes:
## Output Format ## Output Format
CSV with dynamic columns: 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
``` ```
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
### Technology Stack ### Technology Stack
@ -275,7 +294,7 @@ Install [Hot Reload Plugin](https://github.com/pjeby/hot-reload) for automatic p
### Plugin Not Loading ### Plugin Not Loading
1. Check console for errors: `Ctrl/Cmd + Shift + I` 1. Check console for errors: `Ctrl/Cmd + Shift + I`
2. Verify files in `.obsidian/plugins/task-export-plugin/`: 2. Verify files in `.obsidian/plugins/task-export-to-csv/`:
- `main.js` - `main.js`
- `manifest.json` - `manifest.json`
- `styles.css` (optional) - `styles.css` (optional)

View file

@ -0,0 +1,363 @@
# Add Configurable CSV Delimiter Feature to Obsidian Plugin v1.0.1
## Context
The C# console application (markdown-task-export) has been updated to **version 1.0.1** with a configurable CSV delimiter feature. This allows users to choose between comma (`,`) and semicolon (`;`) delimiters.
**Reference Implementation**: [markdown-task-export v1.0.1](https://github.com/tailormade-eu/markdown-task-export/releases/tag/1.0.1)
## Task Overview
**Implement the delimiter feature** from the C# version (v1.0.1) into the Obsidian plugin and:
1. Add delimiter configuration to settings
2. Update CSV generation logic
3. Update field escaping logic
4. Build the plugin (`npm run build`)
5. Test with both delimiters
6. Update version to 1.0.1
7. Commit and prepare for release
## Changes in C# Version (Iteration 1 - v1.0.1)
### 1. ExportOptions.cs
Added `Delimiter` property:
```csharp
public char Delimiter { get; set; } = ',';
```
### 2. CsvExporter.cs
- Added constructor parameter to accept delimiter
- Updated all hardcoded commas to use `_delimiter` field
- Updated `EscapeField()` method to check for the configured delimiter
```csharp
public CsvExporter(char delimiter = ',')
{
_delimiter = delimiter;
}
private string EscapeField(string field)
{
bool needsQuotes = field.Contains(_delimiter) || field.Contains('"') || ...
// Rest of escaping logic
}
```
### 3. Program.cs
Added command-line argument:
```bash
-d, --delimiter <value> CSV delimiter: 'comma' or 'semicolon' (default: comma)
```
Accepts: `comma`, `,`, `semicolon`, or `;`
### 4. Usage Examples
```bash
# Use semicolon delimiter (useful for Excel in Europe)
dotnet run -- -i "./Customers" --delimiter semicolon
# Short form
dotnet run -- -i "./Customers" -d ";"
# Default comma
dotnet run -- -i "./Customers"
```
## Required Changes for Obsidian Plugin (Iteration 2)
### 1. Update TaskExportSettings Interface
Add delimiter setting:
```typescript
interface TaskExportSettings {
outputPath: string;
customersFolder: string;
autoExport: boolean;
exportOnSave: boolean;
exportOnModify: boolean;
showNotifications: boolean;
compressLevels: boolean;
includeHeader: boolean;
debounceDelay: number;
delimiter: ',' | ';'; // NEW: CSV delimiter
}
```
Default value should be `','` (comma).
### 2. Update Settings UI
Add a dropdown/toggle in the settings panel:
```typescript
new 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(async (value) => {
this.plugin.settings.delimiter = value as ',' | ';';
await this.plugin.saveSettings();
}));
```
### 3. Update CSV Writer/Exporter
Update the CSV generation logic to use the configured delimiter:
```typescript
class CsvWriter {
private delimiter: string;
constructor(delimiter: ',' | ';' = ',') {
this.delimiter = delimiter;
}
private escapeField(field: string): string {
if (!field) return '';
// Check if escaping is needed
const needsQuotes = field.includes(this.delimiter) ||
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;
}
generateCsv(tasks: TaskItem[], includeHeader: boolean): string {
const lines: string[] = [];
// Build header
if (includeHeader) {
const headerParts = ['CustomerName', 'ProjectName', ...levelHeaders, 'Task'];
lines.push(headerParts.join(this.delimiter));
}
// Build data rows
for (const task of tasks) {
const parts = [
this.escapeField(task.customerName),
this.escapeField(task.projectName),
...task.levels.map(l => this.escapeField(l)),
this.escapeField(task.task)
];
lines.push(parts.join(this.delimiter));
}
return lines.join('\n');
}
}
```
### 4. Update Export Logic
Pass the delimiter setting to the CSV writer:
```typescript
async exportTasks() {
const tasks = await this.extractTasks();
const writer = new CsvWriter(this.settings.delimiter);
const csv = writer.generateCsv(
tasks,
this.settings.includeHeader
);
await this.app.vault.adapter.write(
this.settings.outputPath,
csv
);
}
```
### 5. Update Status Bar/Notifications
Optionally show which delimiter was used:
```typescript
new Notice(`Exported ${taskCount} tasks (${delimiterName})`);
```
Where `delimiterName` is:
- `','``"comma-separated"`
- `';'``"semicolon-separated"`
## Why This Feature?
**Regional Differences:**
- **USA, UK**: Comma is standard CSV delimiter
- **Europe** (Germany, France, etc.): Semicolon is standard because comma is used as decimal separator (1,234.56 → 1.234,56)
- **Excel Behavior**: Excel auto-detects delimiter based on system locale
**Use Cases:**
1. European users need semicolon for Excel to open CSV correctly
2. Data contains many commas (descriptions, addresses) → semicolon avoids escaping
3. Compatibility with time tracking systems expecting specific delimiter
## Testing Requirements
Test both delimiters:
```typescript
// Test cases
const testData = [
{ task: 'Simple task', expected: 'Simple task' },
{ task: 'Task, with comma', expected: '"Task, with comma"' }, // comma mode
{ task: 'Task; with semicolon', expected: '"Task; with semicolon"' }, // semicolon mode
{ task: 'Task with "quotes"', expected: '"Task with ""quotes"""' }, // both modes
];
```
Verify:
- ✅ Comma delimiter (default) works
- ✅ Semicolon delimiter works
- ✅ Field escaping respects the chosen delimiter
- ✅ Setting persists across Obsidian restarts
- ✅ Excel opens files correctly with both delimiters
## Implementation Priority
**Priority Level**: High - Include in v1.0.1 release
- Feature parity with C# version (v1.0.1)
- Adds significant value for European users
- Simple to implement (mirrors C# version)
- Must be included before publishing to Obsidian community
## Build and Release Steps
After implementing the delimiter feature:
### 1. Build the Plugin
```bash
cd obsidian-task-export-plugin
# Install dependencies (if not already done)
npm install
# Build production version
npm run build
```
This generates `main.js` in the root directory.
### 2. Verify Version Numbers
Ensure all version files are updated to **1.0.1**:
- ✅ `manifest.json``"version": "1.0.1"`
- ✅ `package.json``"version": "1.0.1"`
- ✅ `versions.json` → Add entry `"1.0.1": "1.0.0"`
### 3. Test the Plugin
```bash
# Copy files to test vault
cp main.js manifest.json styles.css ~/.obsidian/plugins/task-export-to-csv/
# Open Obsidian and test:
# 1. Check settings for delimiter dropdown
# 2. Export with comma delimiter
# 3. Export with semicolon delimiter
# 4. Verify CSV output correctness
```
### 4. Commit Changes
```bash
git add .
git commit -m "Release v1.0.1: Add configurable CSV delimiter"
git push origin main
```
### 5. Create Git Tag
```bash
git tag -a 1.0.1 -m "v1.0.1 - Add configurable CSV delimiter"
git push origin 1.0.1
```
### 6. Create GitHub Release
- Go to GitHub Releases
- Tag: `1.0.1`
- Title: `v1.0.1 - Configurable CSV Delimiter`
- Upload files: `main.js`, `manifest.json`, `styles.css`
- Add release notes (see below)
### 7. Update Obsidian Community Plugin PR
Update the PR in obsidian-releases repository:
- Change version to `1.0.1`
- Ensure release has required files
- Wait for review
## Release Notes Template
```markdown
## What's New in v1.0.1
### ✨ New Features
- **Configurable CSV Delimiter**: Choose between comma (`,`) or semicolon (`;`) in settings
- Improved Excel compatibility for European locales
- Maintains feature parity with C# console app v1.0.1
### 🎨 User Interface
- New dropdown in settings: "CSV Delimiter"
- Options: Comma (standard) or Semicolon (European)
### 🌍 Regional Support
Better support for regions where semicolon is the standard CSV delimiter (many European countries where comma is used as decimal separator).
### 🔧 Technical Details
- Field escaping respects the chosen delimiter
- CSV output follows RFC 4180 standard
- UTF-8 with BOM encoding for Excel compatibility
---
**Full Changelog**: https://github.com/tailormade-eu/obsidian-task-export-plugin/compare/1.0.0...1.0.1
```
## Documentation Updates
Update README.md to mention delimiter option in settings:
```markdown
### Settings
| Setting | Description | Default |
|---------|-------------|---------|
| **CSV Delimiter** | Choose comma or semicolon delimiter | Comma |
```
Add usage note:
```markdown
**Note for European Users**: If Excel doesn't open the CSV correctly, try changing the delimiter to semicolon in settings.
```
## Summary
Port the delimiter feature from C# version to Obsidian plugin by:
1. Add `delimiter: ',' | ';'` to settings (default: `','`)
2. Add dropdown in settings UI
3. Update CSV writer to use configurable delimiter
4. Update field escaping to check for the configured delimiter
5. Test with both delimiters
6. Document in README
This maintains feature parity between Iteration 1 (C# console app) and Iteration 2 (Obsidian plugin).

607
main.js Normal file
View file

@ -0,0 +1,607 @@
/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/main.ts
var main_exports = {};
__export(main_exports, {
default: () => TaskExportPlugin
});
module.exports = __toCommonJS(main_exports);
var import_obsidian3 = require("obsidian");
// src/types.ts
var DEFAULT_SETTINGS = {
outputPath: "outstanding_tasks.csv",
customersFolder: "Customers",
autoExport: false,
exportOnSave: true,
exportOnModify: false,
showNotifications: true,
compressLevels: false,
includeHeader: true,
debounceDelay: 3,
delimiter: ","
};
// src/settings.ts
var import_obsidian = require("obsidian");
var TaskExportSettingTab = class extends import_obsidian.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
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) => {
this.plugin.settings.outputPath = value || "tasks.csv";
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) => {
this.plugin.settings.customersFolder = value || "Customers";
void this.plugin.saveSettings();
this.plugin.updateFileWatcher();
}));
new import_obsidian.Setting(containerEl).setName("Auto export").setDesc("Automatically export tasks when files change").addToggle((toggle) => toggle.setValue(this.plugin.settings.autoExport).onChange((value) => {
this.plugin.settings.autoExport = value;
void this.plugin.saveSettings();
this.plugin.updateFileWatcher();
}));
new import_obsidian.Setting(containerEl).setName("Export on save").setDesc("Trigger export when files are saved").addToggle((toggle) => toggle.setValue(this.plugin.settings.exportOnSave).onChange((value) => {
this.plugin.settings.exportOnSave = value;
void this.plugin.saveSettings();
}));
new import_obsidian.Setting(containerEl).setName("Export on modify").setDesc("Trigger export when files are modified (more frequent)").addToggle((toggle) => toggle.setValue(this.plugin.settings.exportOnModify).onChange((value) => {
this.plugin.settings.exportOnModify = value;
void this.plugin.saveSettings();
}));
new import_obsidian.Setting(containerEl).setName("Show notifications").setDesc("Display notifications on export completion").addToggle((toggle) => toggle.setValue(this.plugin.settings.showNotifications).onChange((value) => {
this.plugin.settings.showNotifications = value;
void this.plugin.saveSettings();
}));
new import_obsidian.Setting(containerEl).setName("Compress levels").setDesc("Remove empty hierarchy columns from CSV output").addToggle((toggle) => toggle.setValue(this.plugin.settings.compressLevels).onChange((value) => {
this.plugin.settings.compressLevels = value;
void this.plugin.saveSettings();
}));
new import_obsidian.Setting(containerEl).setName("Include header").setDesc("Include CSV header row in output").addToggle((toggle) => toggle.setValue(this.plugin.settings.includeHeader).onChange((value) => {
this.plugin.settings.includeHeader = value;
void this.plugin.saveSettings();
}));
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;
void this.plugin.saveSettings();
}));
new import_obsidian.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((value) => {
this.plugin.settings.debounceDelay = value;
void this.plugin.saveSettings();
this.plugin.updateFileWatcher();
}));
}
};
// src/exporter.ts
var import_obsidian2 = require("obsidian");
// src/parser.ts
var _MarkdownParser = class {
/**
* Parses a markdown file and extracts all outstanding tasks.
*/
parseFile(content, customerName, projectName, folderSegments = []) {
const tasks = [];
const lines = content.split("\n");
const headers = [];
const parentTaskStack = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const headerMatch = line.match(_MarkdownParser.HEADER_REGEX);
if (headerMatch) {
const headerLevel = headerMatch[1].length - 1;
const headerText = headerMatch[2].trim();
while (headers.length <= headerLevel) {
headers.push("");
}
headers[headerLevel] = headerText;
for (let j = headerLevel + 1; j < headers.length; j++) {
headers[j] = "";
}
parentTaskStack.length = 0;
continue;
}
if (_MarkdownParser.COMPLETED_TASK_REGEX.test(line)) {
continue;
}
const taskMatch = line.match(_MarkdownParser.TASK_REGEX);
if (taskMatch) {
const indent = taskMatch[1].length;
const taskText = taskMatch[3].trim();
if (_MarkdownParser.CHECKMARK_REGEX.test(taskText)) {
continue;
}
const hasSubTasks = this.hasSubTasks(lines, i + 1, indent);
if (hasSubTasks) {
while (parentTaskStack.length > 0 && parentTaskStack[parentTaskStack.length - 1].indentLevel >= indent) {
parentTaskStack.pop();
}
parentTaskStack.push({ indentLevel: indent, taskText });
} else {
while (parentTaskStack.length > 0 && parentTaskStack[parentTaskStack.length - 1].indentLevel >= indent) {
parentTaskStack.pop();
}
const task = {
customerName,
projectName,
task: taskText,
levels: []
};
const levels = [];
if (folderSegments && folderSegments.length > 0) {
levels.push(...folderSegments);
}
if (projectName && projectName.length > 0) {
levels.push(projectName);
}
levels.push(...headers);
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.
*/
hasSubTasks(lines, startIndex, currentIndent) {
for (let i = startIndex; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) {
continue;
}
if (_MarkdownParser.HEADER_REGEX.test(line)) {
return false;
}
const taskMatch = line.match(_MarkdownParser.TASK_REGEX);
if (taskMatch) {
const indent = taskMatch[1].length;
if (indent > currentIndent) {
return true;
}
if (indent <= currentIndent) {
return false;
}
}
}
return false;
}
};
var MarkdownParser = _MarkdownParser;
MarkdownParser.HEADER_REGEX = /^(#{2,})\s+(.+)$/;
MarkdownParser.TASK_REGEX = /^(\s*)-\s+\[([ ])\]\s+(.+)$/;
MarkdownParser.COMPLETED_TASK_REGEX = /^(\s*)-\s+\[(x|X)\]/;
MarkdownParser.CHECKMARK_REGEX = /✅\s+\d{4}-\d{2}-\d{2}/;
// src/csv-writer.ts
var CsvWriter = class {
constructor(delimiter = ",") {
this.delimiter = delimiter;
}
/**
* Generates CSV content from tasks with UTF-8 BOM encoding.
*/
generateCsv(tasks, compressLevels = false, includeHeader = true) {
if (tasks.length === 0) {
throw new Error("No tasks to export");
}
const maxLevelDepth = compressLevels ? this.determineMaxCompressedLevelDepth(tasks) : this.determineMaxLevelDepth(tasks);
let csv = "";
if (includeHeader) {
csv += `CustomerName`;
for (let i = 1; i <= maxLevelDepth; i++) {
csv += `${this.delimiter}Level${i}`;
}
csv += `${this.delimiter}Task
`;
}
for (const task of tasks) {
csv += this.escapeField(task.customerName);
if (compressLevels) {
const nonEmptyLevels = task.levels.filter((l) => l !== "");
for (const level of nonEmptyLevels) {
csv += this.delimiter;
csv += this.escapeField(level);
}
} else {
for (let i = 0; i < maxLevelDepth; i++) {
csv += this.delimiter;
if (i < task.levels.length) {
csv += this.escapeField(task.levels[i]);
}
}
}
csv += this.delimiter;
csv += this.escapeField(task.task);
csv += "\n";
}
return "\uFEFF" + csv;
}
/**
* Determines the maximum level depth across all tasks (includes empty slots).
*/
determineMaxLevelDepth(tasks) {
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.
*/
determineMaxCompressedLevelDepth(tasks) {
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 delimiter, newline, or quote
* - Doubles any quotes inside the field
*/
escapeField(field) {
if (!field) {
return "";
}
const needsQuotes = field.includes(this.delimiter) || field.includes('"') || field.includes("\n") || field.includes("\r");
if (needsQuotes) {
field = field.replace(/"/g, '""');
return `"${field}"`;
}
return field;
}
};
// src/exporter.ts
var TaskExporter = class {
constructor(app) {
this.app = app;
this.parser = new MarkdownParser();
this.csvWriter = new CsvWriter();
}
/**
* Extracts all outstanding tasks from the Customers directory.
*/
async extractTasks(customersFolder, verbose = false) {
const allTasks = [];
const normalizedPath = (0, import_obsidian2.normalizePath)(customersFolder);
const folder = this.app.vault.getAbstractFileByPath(normalizedPath);
if (!folder) {
console.error(`Customers directory not found: ${normalizedPath}`);
return allTasks;
}
const allFiles = this.app.vault.getMarkdownFiles();
const customerFiles = allFiles.filter(
(file) => file.path.startsWith(normalizedPath + "/")
);
for (const file of customerFiles) {
const { customerName, projectName, folderSegments } = this.extractCustomerAndProject(file, normalizedPath);
if (customerName.startsWith(".")) {
continue;
}
if (verbose) {
console.debug(`Processing: ${customerName} / ${projectName}`);
}
try {
const content = await this.app.vault.read(file);
const tasks = this.parser.parseFile(content, customerName, projectName, folderSegments);
if (tasks.length > 0) {
allTasks.push(...tasks);
if (verbose) {
console.debug(` 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, outputPath, compressLevels = false, includeHeader = true, delimiter = ",") {
const csvWriter = new CsvWriter(delimiter);
const csv = csvWriter.generateCsv(tasks, compressLevels, includeHeader);
const normalizedPath = (0, import_obsidian2.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.
*/
extractCustomerAndProject(file, customersFolder) {
let relativePath = file.path.substring(customersFolder.length + 1);
const parts = relativePath.split("/");
const customerName = parts[0];
const projectName = file.basename;
const folderSegments = parts.length > 2 ? parts.slice(1, parts.length - 1) : [];
return { customerName, projectName, folderSegments };
}
};
// src/file-watcher.ts
var FileWatcher = class {
constructor(callback, debounceDelay, customersFolder) {
this.debounceTimer = null;
this.isEnabled = false;
this.callback = callback;
this.debounceDelay = debounceDelay * 1e3;
this.customersFolder = customersFolder;
}
/**
* Updates the debounce delay.
*/
setDebounceDelay(seconds) {
this.debounceDelay = seconds * 1e3;
}
/**
* Updates the customers folder path.
*/
setCustomersFolder(folder) {
this.customersFolder = folder;
}
/**
* Triggers the debounced callback.
*/
trigger() {
if (!this.isEnabled) {
return;
}
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
}
this.debounceTimer = window.setTimeout(() => {
this.callback();
this.debounceTimer = null;
}, this.debounceDelay);
}
/**
* Checks if a file should trigger an export.
*/
shouldTrigger(file) {
if (!this.isEnabled || !file) {
return false;
}
if (file.extension !== "md") {
return false;
}
if (!file.path.startsWith(this.customersFolder + "/")) {
return false;
}
return true;
}
/**
* Enables the file watcher.
*/
enable() {
this.isEnabled = true;
}
/**
* Disables the file watcher and cancels any pending callbacks.
*/
disable() {
this.isEnabled = false;
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
}
/**
* Returns whether the watcher is enabled.
*/
isWatcherEnabled() {
return this.isEnabled;
}
};
// src/main.ts
var TaskExportPlugin = class extends import_obsidian3.Plugin {
constructor() {
super(...arguments);
this.lastExportTime = null;
this.exportInProgress = false;
}
async onload() {
await this.loadSettings();
this.exporter = new TaskExporter(this.app);
this.fileWatcher = new FileWatcher(
() => void this.autoExport(),
this.settings.debounceDelay,
this.settings.customersFolder
);
this.addRibbonIcon("file-text", "Export outstanding tasks", async () => {
await this.exportTasks();
});
this.addCommand({
id: "export-tasks",
name: "Export outstanding tasks",
callback: async () => {
await this.exportTasks();
}
});
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 import_obsidian3.Notice(`Auto-export ${status}`);
}
});
this.addCommand({
id: "open-settings",
name: "Open export settings",
callback: () => {
this.app.setting.open();
this.app.setting.openTabById(this.manifest.id);
}
});
this.addSettingTab(new TaskExportSettingTab(this.app, this));
this.statusBarItem = this.addStatusBarItem();
this.updateStatusBar();
this.registerFileEvents();
this.updateFileWatcher();
console.debug("Task Export Plugin loaded");
}
onunload() {
this.fileWatcher.disable();
console.debug("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.
*/
registerFileEvents() {
this.registerEvent(
this.app.vault.on("modify", (file) => {
if (file instanceof import_obsidian3.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).
*/
async autoExport() {
try {
await this.exportTasks(false);
} catch (error) {
console.error("Auto-export failed:", error);
}
}
/**
* Exports tasks to CSV.
*/
async exportTasks(showNotice = true) {
if (this.exportInProgress) {
if (showNotice) {
new import_obsidian3.Notice("Export already in progress...");
}
return;
}
this.exportInProgress = true;
try {
const tasks = await this.exporter.extractTasks(this.settings.customersFolder, false);
if (tasks.length === 0) {
if (showNotice && this.settings.showNotifications) {
new import_obsidian3.Notice("No outstanding tasks found");
}
return;
}
await this.exporter.exportToFile(
tasks,
this.settings.outputPath,
this.settings.compressLevels,
this.settings.includeHeader,
this.settings.delimiter
);
this.lastExportTime = new Date();
this.updateStatusBar();
if (showNotice && this.settings.showNotifications) {
new import_obsidian3.Notice(`Exported ${tasks.length} task(s) to ${this.settings.outputPath}`);
}
} catch (error) {
console.error("Export failed:", error);
if (showNotice && this.settings.showNotifications) {
new import_obsidian3.Notice(`Export failed: ${error.message}`);
}
} finally {
this.exportInProgress = false;
}
}
/**
* Updates the status bar with last export time.
*/
updateStatusBar() {
if (!this.lastExportTime) {
this.statusBarItem.setText("");
return;
}
const now = new Date();
const diff = now.getTime() - this.lastExportTime.getTime();
const minutes = Math.floor(diff / 6e4);
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(`\u2705 Last export: ${timeText}`);
window.setTimeout(() => this.updateStatusBar(), 6e4);
}
};

View file

@ -1,9 +1,9 @@
{ {
"id": "task-export-plugin", "id": "task-export-to-csv",
"name": "Task Export Tool", "name": "Task Export Tool",
"version": "1.0.0", "version": "1.0.5",
"minAppVersion": "1.0.0", "minAppVersion": "1.0.0",
"description": "Export outstanding tasks to CSV for time tracking integration", "description": "Export outstanding tasks to CSV for time tracking integration (e.g. ManicTime).",
"author": "Raoul Jacobs", "author": "Raoul Jacobs",
"authorUrl": "https://github.com/tailormade-eu", "authorUrl": "https://github.com/tailormade-eu",
"isDesktopOnly": false "isDesktopOnly": false

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "obsidian-task-export-plugin", "name": "obsidian-task-export-plugin",
"version": "1.0.0", "version": "1.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "obsidian-task-export-plugin", "name": "obsidian-task-export-plugin",
"version": "1.0.0", "version": "1.0.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "^16.11.6", "@types/node": "^16.11.6",

View file

@ -1,6 +1,6 @@
{ {
"name": "obsidian-task-export-plugin", "name": "obsidian-task-export-plugin",
"version": "1.0.0", "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

@ -5,6 +5,12 @@ import { TaskItem } from './types';
* Ported from C# CsvExporter.cs * Ported from C# CsvExporter.cs
*/ */
export class CsvWriter { export class CsvWriter {
private delimiter: string;
constructor(delimiter: ',' | ';' = ',') {
this.delimiter = delimiter;
}
/** /**
* Generates CSV content from tasks with UTF-8 BOM encoding. * Generates CSV content from tasks with UTF-8 BOM encoding.
*/ */
@ -22,39 +28,37 @@ 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,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 += `,Level${i}`; csv += `${this.delimiter}Level${i}`;
} }
csv += ',Task\n'; csv += `${this.delimiter}Task\n`;
} }
// 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 += ',';
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 !== '');
// Output non-empty levels (no padding needed - each row can have different column count) // Output non-empty levels (no padding needed - each row can have different column count)
for (const level of nonEmptyLevels) { for (const level of nonEmptyLevels) {
csv += ','; csv += this.delimiter;
csv += this.escapeField(level); csv += this.escapeField(level);
} }
} else { } else {
// Non-compressed mode: output all levels including empty slots to preserve hierarchy // Non-compressed mode: output all levels including empty slots to preserve hierarchy
for (let i = 0; i < maxLevelDepth; i++) { for (let i = 0; i < maxLevelDepth; i++) {
csv += ','; csv += this.delimiter;
if (i < task.levels.length) { if (i < task.levels.length) {
csv += this.escapeField(task.levels[i]); csv += this.escapeField(task.levels[i]);
} }
} }
} }
csv += this.delimiter;
csv += ',';
csv += this.escapeField(task.task); csv += this.escapeField(task.task);
csv += '\n'; csv += '\n';
} }
@ -96,7 +100,7 @@ export class CsvWriter {
/** /**
* Escapes a CSV field according to RFC 4180. * Escapes a CSV field according to RFC 4180.
* - Wraps in quotes if contains comma, newline, or quote * - Wraps in quotes if contains delimiter, newline, or quote
* - Doubles any quotes inside the field * - Doubles any quotes inside the field
*/ */
private escapeField(field: string): string { private escapeField(field: string): string {
@ -105,7 +109,7 @@ export class CsvWriter {
} }
// Check if escaping is needed // Check if escaping is needed
const needsQuotes = field.includes(',') || field.includes('"') || field.includes('\n') || field.includes('\r'); const needsQuotes = field.includes(this.delimiter) || field.includes('"') || field.includes('\n') || field.includes('\r');
if (needsQuotes) { if (needsQuotes) {
// Double any existing quotes // Double any existing quotes

View file

@ -15,7 +15,7 @@ export class TaskExporter {
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
this.parser = new MarkdownParser(); this.parser = new MarkdownParser();
this.csvWriter = new CsvWriter(); this.csvWriter = new CsvWriter(); // Will be recreated with delimiter in exportToFile
} }
/** /**
@ -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('.')) {
@ -52,18 +52,19 @@ export class TaskExporter {
} }
if (verbose) { if (verbose) {
console.log(`Processing: ${customerName} / ${projectName}`); console.debug(`Processing: ${customerName} / ${projectName}`);
} }
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);
if (verbose) { if (verbose) {
console.log(` Found ${tasks.length} task(s)`); console.debug(` Found ${tasks.length} task(s)`);
} }
} }
} catch (ex) { } catch (ex) {
@ -81,9 +82,12 @@ export class TaskExporter {
tasks: TaskItem[], tasks: TaskItem[],
outputPath: string, outputPath: string,
compressLevels: boolean = false, compressLevels: boolean = false,
includeHeader: boolean = true includeHeader: boolean = true,
delimiter: ',' | ';' = ','
): Promise<void> { ): Promise<void> {
const csv = this.csvWriter.generateCsv(tasks, compressLevels, includeHeader); // Create CSV writer with specified delimiter
const csvWriter = new CsvWriter(delimiter);
const csv = csvWriter.generateCsv(tasks, compressLevels, includeHeader);
const normalizedPath = normalizePath(outputPath); const normalizedPath = normalizePath(outputPath);
await this.app.vault.adapter.write(normalizedPath, csv); await this.app.vault.adapter.write(normalizedPath, csv);
} }
@ -93,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);
@ -105,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();
@ -81,13 +81,13 @@ export default class TaskExportPlugin extends Plugin {
// Update file watcher state // Update file watcher state
this.updateFileWatcher(); this.updateFileWatcher();
console.log('Task Export Plugin loaded'); console.debug('Task Export Plugin loaded');
} }
onunload() { onunload() {
// Disable file watcher // Disable file watcher
this.fileWatcher.disable(); this.fileWatcher.disable();
console.log('Task Export Plugin unloaded'); console.debug('Task Export Plugin unloaded');
} }
async loadSettings() { async loadSettings() {
@ -168,7 +168,8 @@ export default class TaskExportPlugin extends Plugin {
tasks, tasks,
this.settings.outputPath, this.settings.outputPath,
this.settings.compressLevels, this.settings.compressLevels,
this.settings.includeHeader this.settings.includeHeader,
this.settings.delimiter
); );
// Update last export time // Update last export time

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

@ -17,18 +17,16 @@ export class TaskExportSettingTab extends PluginSettingTab {
containerEl.empty(); containerEl.empty();
containerEl.createEl('h2', { text: 'Task Export Tool Settings' });
// 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(async (value) => { .onChange((value) => {
this.plugin.settings.outputPath = value || 'outstanding_tasks.csv'; this.plugin.settings.outputPath = value || 'tasks.csv';
await this.plugin.saveSettings(); void this.plugin.saveSettings();
})); }));
// Customers Folder // Customers Folder
@ -38,9 +36,9 @@ export class TaskExportSettingTab extends PluginSettingTab {
.addText(text => text .addText(text => text
.setPlaceholder('Customers') .setPlaceholder('Customers')
.setValue(this.plugin.settings.customersFolder) .setValue(this.plugin.settings.customersFolder)
.onChange(async (value) => { .onChange((value) => {
this.plugin.settings.customersFolder = value || 'Customers'; this.plugin.settings.customersFolder = value || 'Customers';
await this.plugin.saveSettings(); void this.plugin.saveSettings();
this.plugin.updateFileWatcher(); this.plugin.updateFileWatcher();
})); }));
@ -50,9 +48,9 @@ export class TaskExportSettingTab extends PluginSettingTab {
.setDesc('Automatically export tasks when files change') .setDesc('Automatically export tasks when files change')
.addToggle(toggle => toggle .addToggle(toggle => toggle
.setValue(this.plugin.settings.autoExport) .setValue(this.plugin.settings.autoExport)
.onChange(async (value) => { .onChange((value) => {
this.plugin.settings.autoExport = value; this.plugin.settings.autoExport = value;
await this.plugin.saveSettings(); void this.plugin.saveSettings();
this.plugin.updateFileWatcher(); this.plugin.updateFileWatcher();
})); }));
@ -62,9 +60,9 @@ export class TaskExportSettingTab extends PluginSettingTab {
.setDesc('Trigger export when files are saved') .setDesc('Trigger export when files are saved')
.addToggle(toggle => toggle .addToggle(toggle => toggle
.setValue(this.plugin.settings.exportOnSave) .setValue(this.plugin.settings.exportOnSave)
.onChange(async (value) => { .onChange((value) => {
this.plugin.settings.exportOnSave = value; this.plugin.settings.exportOnSave = value;
await this.plugin.saveSettings(); void this.plugin.saveSettings();
})); }));
// Export on Modify // Export on Modify
@ -73,9 +71,9 @@ export class TaskExportSettingTab extends PluginSettingTab {
.setDesc('Trigger export when files are modified (more frequent)') .setDesc('Trigger export when files are modified (more frequent)')
.addToggle(toggle => toggle .addToggle(toggle => toggle
.setValue(this.plugin.settings.exportOnModify) .setValue(this.plugin.settings.exportOnModify)
.onChange(async (value) => { .onChange((value) => {
this.plugin.settings.exportOnModify = value; this.plugin.settings.exportOnModify = value;
await this.plugin.saveSettings(); void this.plugin.saveSettings();
})); }));
// Show Notifications // Show Notifications
@ -84,9 +82,9 @@ export class TaskExportSettingTab extends PluginSettingTab {
.setDesc('Display notifications on export completion') .setDesc('Display notifications on export completion')
.addToggle(toggle => toggle .addToggle(toggle => toggle
.setValue(this.plugin.settings.showNotifications) .setValue(this.plugin.settings.showNotifications)
.onChange(async (value) => { .onChange((value) => {
this.plugin.settings.showNotifications = value; this.plugin.settings.showNotifications = value;
await this.plugin.saveSettings(); void this.plugin.saveSettings();
})); }));
// Compress Levels // Compress Levels
@ -95,9 +93,9 @@ export class TaskExportSettingTab extends PluginSettingTab {
.setDesc('Remove empty hierarchy columns from CSV output') .setDesc('Remove empty hierarchy columns from CSV output')
.addToggle(toggle => toggle .addToggle(toggle => toggle
.setValue(this.plugin.settings.compressLevels) .setValue(this.plugin.settings.compressLevels)
.onChange(async (value) => { .onChange((value) => {
this.plugin.settings.compressLevels = value; this.plugin.settings.compressLevels = value;
await this.plugin.saveSettings(); void this.plugin.saveSettings();
})); }));
// Include Header // Include Header
@ -106,9 +104,22 @@ export class TaskExportSettingTab extends PluginSettingTab {
.setDesc('Include CSV header row in output') .setDesc('Include CSV header row in output')
.addToggle(toggle => toggle .addToggle(toggle => toggle
.setValue(this.plugin.settings.includeHeader) .setValue(this.plugin.settings.includeHeader)
.onChange(async (value) => { .onChange((value) => {
this.plugin.settings.includeHeader = value; this.plugin.settings.includeHeader = value;
await this.plugin.saveSettings(); void this.plugin.saveSettings();
}));
// CSV Delimiter
new 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 as ',' | ';';
void this.plugin.saveSettings();
})); }));
// Debounce Delay // Debounce Delay
@ -119,9 +130,9 @@ export class TaskExportSettingTab extends PluginSettingTab {
.setLimits(1, 30, 1) .setLimits(1, 30, 1)
.setValue(this.plugin.settings.debounceDelay) .setValue(this.plugin.settings.debounceDelay)
.setDynamicTooltip() .setDynamicTooltip()
.onChange(async (value) => { .onChange((value) => {
this.plugin.settings.debounceDelay = value; this.plugin.settings.debounceDelay = value;
await this.plugin.saveSettings(); void this.plugin.saveSettings();
this.plugin.updateFileWatcher(); this.plugin.updateFileWatcher();
})); }));
} }

View file

@ -11,6 +11,7 @@ export interface TaskExportSettings {
compressLevels: boolean; // Remove empty hierarchy columns compressLevels: boolean; // Remove empty hierarchy columns
includeHeader: boolean; // Include CSV header row includeHeader: boolean; // Include CSV header row
debounceDelay: number; // Debounce delay in seconds (1-30) debounceDelay: number; // Debounce delay in seconds (1-30)
delimiter: ',' | ';'; // CSV delimiter: comma or semicolon
} }
/** /**
@ -25,7 +26,8 @@ export const DEFAULT_SETTINGS: TaskExportSettings = {
showNotifications: true, showNotifications: true,
compressLevels: false, compressLevels: false,
includeHeader: true, includeHeader: true,
debounceDelay: 3 debounceDelay: 3,
delimiter: ','
}; };
/** /**
@ -34,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,3 +1,7 @@
{ {
"1.0.0": "1.0.0" "1.0.0": "1.0.0",
"1.0.1": "1.0.0",
"1.0.2": "1.0.0",
"1.0.3": "1.0.0",
"1.0.4": "1.0.0"
} }