From b1ac40acf6f62d8b9e8e94af71ddaa6bc0384542 Mon Sep 17 00:00:00 2001 From: Raoul Jacobs Date: Sun, 16 Nov 2025 16:03:25 +0100 Subject: [PATCH] fix: Update .gitignore to retain main.js for plugin distribution feat: Add main.js file generated by ESBuild for task export functionality --- .gitignore | 4 +- main.js | 603 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 605 insertions(+), 2 deletions(-) create mode 100644 main.js diff --git a/.gitignore b/.gitignore index 89c86dc..e3cba16 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ # Node modules node_modules/ -# Build output -main.js +# Build output (keep main.js for plugin distribution) +# main.js should be committed for Obsidian plugin releases main.js.map *.js.map diff --git a/main.js b/main.js new file mode 100644 index 0000000..cfe478e --- /dev/null +++ b/main.js @@ -0,0 +1,603 @@ +/* +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("Task Export Tool Settings").setHeading(); + new import_obsidian.Setting(containerEl).setName("Output path").setDesc("CSV file location (relative to vault root)").addText((text) => text.setPlaceholder("outstanding_tasks.csv").setValue(this.plugin.settings.outputPath).onChange((value) => { + this.plugin.settings.outputPath = value || "outstanding_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("Choose delimiter for CSV output. Comma is standard, semicolon is common in Europe.").addDropdown((dropdown) => dropdown.addOption(",", "Comma (,)").addOption(";", "Semicolon (;)").setValue(this.plugin.settings.delimiter).onChange((value) => { + 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( + () => 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); + } +};