mirror of
https://github.com/tailormade-eu/obsidian-task-export-plugin.git
synced 2026-07-22 16:30:23 +00:00
Compare commits
22 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcc0c3f4f0 | ||
|
|
6fdae87c5a | ||
|
|
5c98896917 | ||
|
|
01a5d99d0b | ||
|
|
ea5ea0f4a1 | ||
|
|
97db6c49ca | ||
|
|
3ae738b61a | ||
|
|
12f39a7bba | ||
|
|
b1ac40acf6 | ||
|
|
c1aa33783a | ||
|
|
cd731a4231 | ||
|
|
02a9a5e0c3 | ||
|
|
8eda84ca3a | ||
|
|
1ba60a8ccb | ||
|
|
a7cda7916d | ||
|
|
b5fe601584 | ||
|
|
4aec30b703 | ||
|
|
b135489121 | ||
|
|
3086921473 | ||
|
|
c7008f29e2 | ||
|
|
c686648e33 | ||
|
|
30b11493ad |
16 changed files with 1126 additions and 74 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
21
CHANGELOG.md
Normal 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.
|
||||||
2
LICENSE
2
LICENSE
|
|
@ -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
|
||||||
|
|
|
||||||
29
README.md
29
README.md
|
|
@ -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)
|
||||||
|
|
|
||||||
363
docs/DELIMITER-FEATURE-UPDATE.md
Normal file
363
docs/DELIMITER-FEATURE-UPDATE.md
Normal 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
607
main.js
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
src/main.ts
17
src/main.ts
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue