mirror of
https://github.com/tailormade-eu/obsidian-task-export-plugin.git
synced 2026-07-22 06:41:42 +00:00
602 lines
20 KiB
JavaScript
602 lines
20 KiB
JavaScript
/*
|
|
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) {
|
|
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 = [];
|
|
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${this.delimiter}ProjectName`;
|
|
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);
|
|
csv += this.delimiter;
|
|
csv += this.escapeField(task.projectName);
|
|
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 } = 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);
|
|
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;
|
|
return { customerName, projectName };
|
|
}
|
|
};
|
|
|
|
// 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);
|
|
}
|
|
};
|